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"><p>hi</p></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, "&").replace(/</g, "<").replace(/>/g, ">"); 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.