PageSourceSearch

https://www.qntns.com/assets/js-716.f3ca76c/qntns/code.js

js qntns.com collected 2026-10-04 08:08:34 UTC 15,191 bytes, 349 lines download raw bytes

1/*
2 * THE CODE BLOCK — line numbers, copy button, syntax colour.
3 * ===========================================================================
4 * Ghost's code card emits exactly this, and nothing else:
5 *
6 *     <pre><code class="language-html">&lt;p&gt;hi&lt;/p&gt;</code></pre>
7 *
8 * The class is present only when the writer filled in the editor's language
9 * field, or typed ```html in markdown. A block with no language is the normal
10 * case for a URL or a shell line, and it gets the gutter and the button but no
11 * colour rather than a guess.
12 *
13 * WHY THE HIGHLIGHTER IS 60 LINES AND NOT A LIBRARY. CLAUDE.md forbids
14 * dependencies and forbids the page depending on a network call, which rules
15 * out both a CDN highlighter and a vendored one — Prism's core plus five
16 * grammars is larger than every other script this theme ships put together,
17 * for a component that appears on a handful of pages. What is here handles
18 * comments, strings, numbers, keywords and names, which is the whole of what
19 * the mockup asks for. It is a colouriser, not a parser: it will mis-colour
20 * pathological input (a keyword inside an unterminated string) and that is an
21 * accepted cost, not a bug to fix by growing it into a parser.
22 *
23 * THE ORDER OF THE PATTERNS IS THE PRECEDENCE, and it is the whole design.
24 * Comments and strings come first in every grammar so that a `#` inside a
25 * string is a character and a keyword inside a comment is a comment. One
26 * alternation is built per language and run once over the source, so a match
27 * consumes its own text and later patterns never see inside it.
28 *
29 * ESCAPING. The source is read with textContent — Ghost has already decoded
30 * its own entities by then — and every span of it goes back through escape()
31 * before it re-enters the DOM as HTML. Nothing reaches innerHTML unescaped,
32 * including the untokenised gaps between matches.
33 */
34
35import { icon } from "../icon-set.js";
36
37// Matches the 1500ms in content.js's share button, and for the same reason:
38// long enough to read a tick, short enough that a second copy does not feel
39// blocked. The two are independent constants on purpose — a change to the
40// share button's timing is not a change to this one.
41const COPIED_FEEDBACK = 1500;
42
43const escape = (text) =>
44    text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
46/*
47 * THE GRAMMARS.
48 * Each is an ordered list of [tokenType, pattern]. Every pattern is written to
49 * match from the current position outward and to be safe under a global scan —
50 * no lookbehind (Safari shipped it late and this theme supports older phones),
51 * and no unbounded backtracking pair like (a+)+.
52 *
53 * ALIASES BELOW map what a writer actually types — ```js, ```sh, ```yml — onto
54 * these five. An unknown language falls through to no highlighting at all,
55 * which is the same result as no language, and is correct: a wrong grammar
56 * colours the wrong words, which is worse than plain text.
57 */
58const GRAMMARS = {
59    markup: [
60        ["comment", /<!--[\s\S]*?-->/],
61        ["string", /"[^"]*"|'[^']*'/],
62        // The tag name, captured with its bracket so </div> and <div both land
63        // as one token rather than a punct followed by a name.
64        ["name", /<\/?[a-zA-Z][\w:-]*/],
65        ["punct", /\/?>/],
66        ["keyword", /\s[a-zA-Z-][\w:-]*(?==)/]
67    ],
68    css: [
69        ["comment", /\/\*[\s\S]*?\*\//],
70        ["string", /"[^"]*"|'[^']*'/],
71        ["keyword", /@[a-zA-Z-]+/],
72        // Hex colours before the selector pattern, or #fff is read as an id
73        // selector and coloured as a name. Ordering is the precedence here as
74        // everywhere else in this table.
75        ["number", /#[0-9a-fA-F]{3,8}\b/],
76        ["name", /[.#][a-zA-Z_-][\w-]*|--[\w-]+/],
77        ["number", /-?\d*\.?\d+(?:px|rem|em|%|vh|vw|s|ms|deg)?\b/],
78        ["punct", /[{}:;,()]/]
79    ],
80    javascript: [
81        ["comment", /\/\/[^\n]*|\/\*[\s\S]*?\*\//],
82        ["string", /"[^"\n]*"|'[^'\n]*'|`[^`]*`/],
83        ["keyword",
84            /\b(?:const|let|var|function|return|if|else|for|while|of|in|new|class|extends|import|export|from|default|async|await|try|catch|finally|throw|typeof|instanceof|null|undefined|true|false|this)\b/],
85        ["number", /\b-?\d*\.?\d+\b/],
86        ["punct", /[{}()[\];,.]/]
87    ],
88    json: [
89        ["string", /"[^"\\]*(?:\\.[^"\\]*)*"/],
90        ["keyword", /\b(?:true|false|null)\b/],
91        ["number", /-?\d*\.?\d+(?:[eE][+-]?\d+)?/],
92        ["punct", /[{}[\]:,]/]
93    ],
94    bash: [
95        ["comment", /#[^\n]*/],
96        ["string", /"[^"\n]*"|'[^'\n]*'/],
97        ["keyword",
98            /\b(?:cd|ls|cat|echo|export|curl|npm|npx|node|git|sudo|mkdir|rm|cp|mv|grep|sed|awk|ffmpeg|if|then|fi|for|do|done|while)\b/],
99        ["number", /\b-?\d+\b/],
100        ["punct", /[|&;<>()$]/]
101    ]
102};
103
104const ALIASES = {
105    html: "markup", xml: "markup", svg: "markup", markup: "markup",
106    css: "css",
107    js: "javascript", javascript: "javascript", mjs: "javascript",
108    json: "json",
109    bash: "bash", sh: "bash", shell: "bash", zsh: "bash"
110};
111
112/*
113 * ONE ALTERNATION PER GRAMMAR, BUILT ONCE AND CACHED.
114 * Each pattern becomes one capturing group, so the index of the group that
115 * matched is the index of the token type. Building it per block would rebuild
116 * the same object for every code card on the page; building it per page load
117 * is once.
118 */
119const compiled = new Map();
120
121const compile = (name) => {
122    if (compiled.has(name)) return compiled.get(name);
123    const rules = GRAMMARS[name];
124    const pattern = new RegExp(rules.map(([, re]) => `(${re.source})`).join("|"), "g");
125    const entry = { pattern, types: rules.map(([type]) => type) };
126    compiled.set(name, entry);
127    return entry;
128};
129
130/*
131 * COLOUR ONE BLOCK'S SOURCE.
132 * Walks the alternation and emits escaped plain text for everything between
133 * matches. Returns HTML. A language with no grammar returns the escaped source
134 * unchanged, which is what makes "unknown language" and "no language" the same
135 * safe path.
136 */
137const highlight = (source, language) => {
138    const grammar = ALIASES[language];
139    if (!grammar) return escape(source);
140
141    const { pattern, types } = compile(grammar);
142    pattern.lastIndex = 0;
143
144    let out = "";
145    let last = 0;
146    let match;
147
148    while ((match = pattern.exec(source)) !== null) {
149        // A pattern that can match the empty string would spin here forever.
150        // None of the grammars above can, but the guard costs one comparison
151        // and removes the entire class of failure from a future edit.
152        if (match[0] === "") {
153            pattern.lastIndex += 1;
154            continue;
155        }
156        out += escape(source.slice(last, match.index));
157        // Group 0 is the whole match, so the type index is one less than the
158        // group number that is defined.
159        const which = match.findIndex((value, index) => index > 0 && value !== undefined);
160        out += `<span class="qntns-code__token--${types[which - 1]}">${escape(match[0])}</span>`;
161        last = match.index + match[0].length;
162    }
163
164    return out + escape(source.slice(last));
165};
166
167/*
168 * CUT HIGHLIGHTED HTML INTO ONE STRING PER SOURCE LINE.
169 *
170 * WHY NOT JUST HIGHLIGHT EACH LINE SEPARATELY, which would be four lines
171 * shorter: three of the five grammars have tokens that legally span a newline —
172 * `/* *\/` in CSS and JavaScript, `<!-- -->` in markup, and a backtick template
173 * literal. Highlighting line by line would end those tokens at the first
174 * newline and colour the rest of the comment as code. The whole block is
175 * highlighted once, as it always was, and only the RESULT is cut up.
176 *
177 * The cut has to reopen the span it is standing inside, which is only tractable
178 * because highlight() emits FLAT spans — one alternation, no nesting, ever. So
179 * there is at most one span open at any newline and remembering its class is
180 * the entire state this needs.
181 */
182const LINE_SPLIT = /(<span class="[^"]*">)|(<\/span>)|\n/g;
183
184const splitLines = (html) => {
185    const lines = [];
186    let current = "";
187    let open = "";
188    let last = 0;
189    let match;
190
191    LINE_SPLIT.lastIndex = 0;
192    while ((match = LINE_SPLIT.exec(html)) !== null) {
193        current += html.slice(last, match.index);
194        last = match.index + match[0].length;
195
196        if (match[1]) {
197            open = match[1];
198            current += match[1];
199        } else if (match[2]) {
200            open = "";
201            current += match[2];
202        } else {
203            // A newline. Close whatever is open, bank the line, and reopen the
204            // same span at the start of the next one.
205            lines.push(open ? `${current}</span>` : current);
206            current = open;
207        }
208    }
209
210    lines.push(current + html.slice(last));
211    return lines;
212};
213
214/*
215 * THE COPY BUTTON.
216 * navigator.clipboard needs a secure context and a user gesture; a click on
217 * this button is both. Where it is missing or refused, NOTHING HAPPENS AND NO
218 * ERROR IS SHOWN — same rule as the share button in qntns/content.js. A button
219 * that reported a clipboard failure would be telling the reader about a
220 * browser setting they did not choose and cannot fix from here.
221 *
222 * The text copied is the ORIGINAL source, captured before highlighting, so a
223 * copy never picks up a span boundary or a line number.
224 */
225const attachCopy = (button, source) => {
226    const slot = button.querySelector(".qntns-code__copy-icon-slot");
227
228    button.addEventListener("click", async () => {
229        try {
230            await navigator.clipboard.writeText(source);
231        } catch {
232            return;
233        }
234
235        /* THE WORD AND THE TICK, NOT THE TICK ALONE. A check mark on its own
236           is read as "done" only by somebody who was already watching the
237           button; the word says which thing is done. The button widens to hold
238           it, and because it is floated the first line or two of code reflow
239           around the wider box and back again — that movement is the feedback
240           as much as the label is.
241
242           ONLY THE ICON IS SWAPPED. The label is in the markup from the start
243           and stays there, clipped to nothing by a 0fr grid track; the class
244           below is what opens that track. Replacing the button's whole
245           innerHTML — which is what this used to do — inserted the label a
246           frame before it was measured, and a transition cannot run from a
247           width an element never had. That is why the button snapped.
248
249           aria-label still carries the same two states, and it overrides the
250           button's contents, so the permanent label span cannot make a screen
251           reader announce "Copied Copied" or read "Copied" on a resting
252           button. */
253        slot.innerHTML = icon("check", { size: 20, className: "qntns-code__copy-icon" });
254        button.classList.add("is-copied");
255        button.setAttribute("aria-label", "Copied");
256        setTimeout(() => {
257            slot.innerHTML = icon("copy", { size: 20, className: "qntns-code__copy-icon" });
258            button.classList.remove("is-copied");
259            button.setAttribute("aria-label", "Copy code");
260        }, COPIED_FEEDBACK);
261    });
262};
263
264/*
265 * ENHANCE ONE <pre><code>.
266 * The <pre> is replaced by a wrapper holding a gutter, a scroller and a button.
267 * The original <code> is reused rather than rebuilt so that anything Ghost put
268 * on it — its language class, any id — survives.
269 */
270const enhance = (pre) => {
271    const code = pre.querySelector("code");
272    if (!code) return;
273
274    const source = code.textContent.replace(/\n$/, "");
275    const languageClass = [...code.classList].find((name) => name.startsWith("language-"));
276    const language = languageClass ? languageClass.slice("language-".length).toLowerCase() : "";
277
278    const wrapper = document.createElement("div");
279    wrapper.className = "qntns-code";
280
281    /*
282     * THE LINE NUMBER LIVES INSIDE ITS LINE NOW, AS `data-line`, AND CSS DRAWS
283     * IT WITH ::before.
284     *
285     * It used to be a separate grid column of fixed 24px rows, aligned to the
286     * code only because the code could not wrap. The copy button now floats and
287     * the code wraps around it, so a source line can be two or three visual rows
288     * tall and a parallel column of fixed rows would drift out of step with it
289     * from the first wrapped line onward.
290     *
291     * A NUMBER IN AN ATTRIBUTE IS STILL NOT SELECTABLE AND STILL NOT SPOKEN.
292     * That was the gutter's other job and this keeps both halves of it for free:
293     * generated content is not part of the selection and is not copied, and it
294     * is not in the accessibility tree as text either. So a drag-select still
295     * picks up the code alone, and the <code> element still holds the source as
296     * one continuous run.
297     *
298     * Three digits because that is the mockup's gutter width; padStart leaves a
299     * longer number alone rather than truncating, so line 1000 renders as 1000
300     * and eats into the slack.
301     *
302     * NO SEPARATOR BETWEEN THE SPANS. They are block boxes inside a pre-wrap
303     * container, so a newline between two of them would be preserved as an
304     * empty line box and every line would be double-spaced.
305     */
306    const newPre = document.createElement("pre");
307    newPre.className = "qntns-code__pre";
308    code.innerHTML = splitLines(highlight(source, language))
309        .map((line, index) =>
310            `<span class="qntns-code__line" data-line="${String(index + 1).padStart(3, "0")}">${line}</span>`)
311        .join("");
312    newPre.append(code);
313
314    /*
315     * THE BUTTON IS FIRST IN THE FLOW AND FLOATED, WHICH IS THE WHOLE POINT.
316     * A float only shortens the line boxes that come AFTER it in source order,
317     * so it has to precede the <pre>. It also only reaches into that <pre> if
318     * nothing between them establishes a new block formatting context — which
319     * is why the old .qntns-code__scroll wrapper is gone rather than merely
320     * unused: `overflow-x: auto` would have made one and the code would have
321     * run under the button exactly as it did before.
322     */
323    const button = document.createElement("button");
324    button.type = "button";
325    button.className = "qntns-code__copy";
326    button.setAttribute("aria-label", "Copy code");
327
328    /* TWO SLOTS, BOTH PERMANENT: the icon, which is swapped in place, and the
329       label, which is only ever revealed or clipped. See THE CONFIRMED SIZE in
330       code.css — the button is a two-column grid and these are its columns. */
331    button.innerHTML =
332        `<span class="qntns-code__copy-icon-slot">${icon("copy", { size: 20, className: "qntns-code__copy-icon" })}</span>` +
333        '<span class="qntns-code__copy-label"><span>Copied</span></span>';
334    attachCopy(button, source);
335
336    wrapper.append(button, newPre);
337
338    pre.replaceWith(wrapper);
339};
340
341/*
342 * THE ENTRY POINT.
343 * Idempotent by construction: enhance() removes the <pre> it was given, so a
344 * second call finds nothing left to match. Safe to call from a page that also
345 * rewrites its body.
346 */
347export const attachCode = (root = document) => {
348    root.querySelectorAll("pre:not(.qntns-code__pre)").forEach(enhance);
349};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.