Paints Markdown onto Claude Code's prompt box as you type: fenced code becomes a syntax-highlighted card (even before you close the fence), and inline code…

Markdown, painted onto Claude Code's prompt box as you type. Fenced code becomes a syntax-highlighted card before you even close the fence; inline code, bold, italic, strikethrough, lists, task lists, quotes, headings, tables and links are styled too.
Paint only. The plugin colours the characters of the draft but never changes them, so what you type, and what is sent to the model, is exactly what you typed.
Screenshots, the full list of what is painted, and troubleshooting: github.com/nogu66/md-prompt.
The plugin is one hooks module, hooks/register.tsx:
| Hook | What it does |
|---|---|
prompt.edit | After each edit or paste, adds style runs (decorations) over the draft in the prompt box. The text, the cursor and other plugins' decorations pass through unchanged |
prompt.fill | When the whole draft is replaced (a plugin or the engine filling the box), adds the same style runs to the new draft |
session.start | Registers the /md-prompt command |
command.run (/md-prompt) | Shows or switches painting on or off, and saves it as the plugin's enabled setting |
It reads no files, runs no processes, makes no network or model calls, and does not touch tool calls or what you submit.
md-prompt.enabled, the plugin's own Markdown painting setting (a toggle in /config): on paints, off leaves the prompt box as plain text. The default is on. /md-prompt on | off | toggle writes this one setting with $.config.set, as a change in /config would. It changes no other setting.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; the plugin does not set it.hooks/register.tsx 83 lines1// Hooks module. Wires the prompt box to the pure logic in ./lib — everything that decides *what*
2// to paint lives there (lib/mdprompt.ts, lib/highlight.ts) and everything that decides *whether*
3// (lib/mode.ts); this file only says *when*, and owns the two `$` uses: the `/md-prompt` command
4// and writing the mode setting.
5//
6// The prompt box cannot be redrawn by a hook, but the engine lets one paint style runs over the
7// draft (`decorations`, offsets into the text). Two events carry them:
8// prompt.edit — every edit or paste the person makes; we decorate the box the edit produced.
9// prompt.fill — a plugin (or the engine) writing the draft; only `replace` is the whole draft,
10// so only then are offsets into `e.text` offsets into the box.
11// The draft's characters, the cursor and other plugins' decorations pass through as they came.
12//
13// The mode is the plugin's `enabled` setting (userConfig), a toggle in /config. `/md-prompt <mode>`
14// writes that row, as a change in /config would; the engine then reloads this module with the
15// new value, which `register` reads from `options`. Where there is no /config row for plugin
16// fields the write is refused: the mode then holds for the rest of this activation only.
17
18import type { Register } from "claude-code"
19import {
20 describeMode,
21 formatStatus,
22 formatUsage,
23 paintFor,
24 parseModeCommand,
25 readMode,
26 type Mode,
27} from "./lib/mode"
28
29// A hook that throws is skipped with a notice on every keystroke; painting is decoration, so a
30// bug in it must cost the colours, never the notice.
31function paint(value: Mode, text: string) {
32 try {
33 return paintFor(value, text)
34 } catch {
35 return []
36 }
37}
38
39export const register: Register = (on, options) => {
40 let mode: Mode = readMode(options.enabled)
41
42 on("session.start", async ($, e, next) => {
43 const r = await next(e)
44 await $.command
45 .register({
46 name: "md-prompt",
47 description: "Turn Markdown painting in the prompt box on or off",
48 argumentHint: "[on | off | toggle]",
49 immediate: true,
50 })
51 .catch((err: unknown) => $.ui.log(`md-prompt: command.register failed: ${err}`))
52 return r
53 })
54
55 on("command.run", { command: "md-prompt" }, async ($, e) => {
56 const cmd = parseModeCommand(e.args, mode)
57 if (cmd.kind === "status") return { text: formatStatus(mode) }
58 if (cmd.kind === "usage") return { text: formatUsage(cmd.input) }
59 // Applies at once; a written setting reloads the module, which starts in the same mode.
60 mode = cmd.mode
61 // A refusal must never escape the hook: it only means the mode is not kept for next time
62 let failure: string | null
63 try {
64 const result = await $.config.set({ key: "md-prompt.enabled", value: mode === "on" })
65 failure = result.deny ?? null
66 } catch (err) {
67 failure = String(err)
68 }
69 return { text: failure ? `${describeMode(mode)} (not saved for next time: ${failure})` : describeMode(mode) }
70 })
71
72 on("prompt.edit", async ($, e, next) => {
73 const box = await next(e)
74 if (mode === "off") return box
75 return { ...box, decorations: [...(box.decorations ?? []), ...paint(mode, box.text)] }
76 })
77
78 on("prompt.fill", ($, e, next) => {
79 if (mode === "off" || e.mode !== "replace") return next(e)
80 return next({ ...e, decorations: [...(e.decorations ?? []), ...paint(mode, e.text)] })
81 })
82}
83hooks/lib/mode.ts 65 lines1// Pure logic for the on/off switch: what the modes are, how `/md-prompt <args>` is read, what the
2// command answers with, and which decorations a mode paints. No `$`, no state — the hooks module
3// owns the current mode and its persistence; everything decidable without them is here.
4
5import { decorateMarkdown, type Decoration } from "./mdprompt"
6
7/** `on` paints everything, `off` nothing. */
8export type Mode = "on" | "off"
9
10export const DEFAULT_MODE: Mode = "on"
11
12export type ModeCommand =
13 | { kind: "set"; mode: Mode }
14 | { kind: "status" }
15 | { kind: "usage"; input: string }
16
17/** Read the arguments of `/md-prompt`. Nothing (or `status`) asks for the state; `toggle` flips it. */
18export function parseModeCommand(args: string, current: Mode): ModeCommand {
19 const word = args.trim().toLowerCase()
20 switch (word) {
21 case "":
22 case "status":
23 return { kind: "status" }
24 case "on":
25 case "all":
26 return { kind: "set", mode: "on" }
27 case "off":
28 return { kind: "set", mode: "off" }
29 case "toggle":
30 return { kind: "set", mode: current === "off" ? "on" : "off" }
31 default:
32 return { kind: "usage", input: word }
33 }
34}
35
36/** The mode the `enabled` setting names; anything but a boolean (or absent) is the default. */
37export function readMode(value: unknown): Mode {
38 return value === true ? "on" : value === false ? "off" : DEFAULT_MODE
39}
40
41const USAGE = "/md-prompt on | off | toggle"
42
43export function describeMode(mode: Mode): string {
44 switch (mode) {
45 case "on":
46 return "on — code, emphasis, links, headings, lists, tables and quotes are painted"
47 case "off":
48 return "off — the prompt box is left as plain text"
49 }
50}
51
52export function formatStatus(mode: Mode): string {
53 return `${describeMode(mode)}\n${USAGE}`
54}
55
56export function formatUsage(input: string): string {
57 return `unknown option "${input}"\n${USAGE}`
58}
59
60/** The decorations `mode` paints over `text`; none when off. */
61export function paintFor(mode: Mode, text: string): Decoration[] {
62 if (mode === "off") return []
63 return decorateMarkdown(text)
64}
65hooks/lib/mdprompt.ts 42 lines1// Pure logic: the text of the prompt draft in, `PromptDecoration`-shaped runs out. No `$`, no UI.
2//
3// The prompt box can only be *painted* over (the engine never lets a hook change the drawn
4// characters), so every Markdown marker stays visible; the job here is to make the markup read
5// as formatted: fences and `**` dimmed, code blocks a coloured card, emphasis bold/italic, list
6// bullets and task boxes coloured, tables with faint pipes, quotes italic.
7//
8// Offsets are UTF-16 code units — exactly what JS string indices already are. Later entries win
9// per style key on the engine's side, so a run only names what it changes: a token colour sits
10// on top of the block background laid down first. No run is empty, leaves the text, or holds a
11// line break.
12//
13// The work is split by what it reads:
14// blocks.ts — line structure: quotes, lists, fences, headings, tables, rules, definitions
15// inline.ts — inside a paragraph: code spans, emphasis, links, images, HTML, entities, URLs
16// highlight.ts — the inside of a fenced code block, per language
17// palette.ts — the colours and the `Decoration` shape
18// Everything is linear in the length of the draft, and a draft over `MAX_CHARS` is left alone.
19//
20// Supported: fenced (``` and ~~~, closed or still being typed) and indented code, inline code,
21// bold, italic, bold+italic, strikethrough, links (inline, reference, autolink, bare URL),
22// images, footnotes, ATX and setext headings, quotes, bullet / ordered / task lists (nested),
23// GFM tables, thematic breaks, HTML tags and comments, entities, backslash escapes.
24
25import { decorateBlocks } from "./blocks"
26import { MAX_CHARS, type Decoration } from "./palette"
27
28export { MAX_CHARS, PALETTE } from "./palette"
29export type { Decoration } from "./palette"
30
31export type Options = {
32 /** Paint fenced / indented code blocks and inline code only: no emphasis, links, headings, lists, tables or quote marks. */
33 codeOnly?: boolean
34}
35
36export function decorateMarkdown(text: string, { codeOnly = false }: Options = {}): Decoration[] {
37 if (text === "" || text.length > MAX_CHARS) return []
38 const runs = decorateBlocks(text, codeOnly)
39 // a safety net, not a filter anyone should hit: the engine gets only ranges that fit the draft
40 return runs.filter((d) => d.start >= 0 && d.end <= text.length && d.end > d.start)
41}
42hooks/lib/blocks.ts 691 lines1// Block structure of the draft: which lines are a quote, a list item, a fence, a heading, a table,
2// and where the paragraphs are. Pure and linear: every line is looked at a bounded number of times,
3// and the container stack is capped, so no input makes this quadratic.
4//
5// It is CommonMark's block algorithm trimmed to what painting needs (no tree is built):
6// - containers first: each open `>` quote and list item must match the start of the line, or the
7// line is a lazy paragraph continuation, or the container closes;
8// - then leaf starts, in CommonMark's order: quote, ATX heading, fence, `<!--` comment, setext
9// underline, table delimiter row, thematic break, list item, link/footnote definition, text;
10// - a line indented 4+ columns (relative to its container) is indented code unless it continues
11// a paragraph, so a list item's wrapped lines never turn into code.
12// Deliberate deviations for prompts: only `<!--` starts an HTML block (an XML tag on its own line
13// is painted as a tag and its content is still Markdown), a table row without a `|` ends the table,
14// a `[ ]` / `[x]` opening a line of text is a task box even with no bullet before it (a checklist
15// typed as `[ ] todo`), and a setext heading is a single line, not ending like a sentence, over `===` / `---` of three or
16// more characters (so a `---` divider under a paragraph stays a divider, and typing `- item` under a
17// line never flashes it as a heading).
18//
19// Block chrome (`#`, `>`, bullets, rules, pipes) is painted while scanning; the text of every
20// paragraph / heading / table cell is queued as a "run" and painted last, when every reference
21// definition in the draft is known.
22
23import { highlightCode, languageOf, type TokenKind } from "./highlight"
24import { normalizeLabel, paintInline, type Emit } from "./inline"
25import { PALETTE, type Decoration, type Style } from "./palette"
26
27/** `[start, start + len)` of the draft: one line's worth of a run's text. */
28type Seg = { o: number; len: number }
29type Run = { segs: Seg[]; base?: Style }
30type Para = { segs: Seg[]; quote: boolean }
31type Fence = { char: number; len: number; language: string | null; segs: Seg[] }
32type Row = { cells: number[]; pipes: number[] }
33
34const MAX_DEPTH = 20
35const QUOTE = -1
36
37const DIM: Style = { dimColor: true }
38const QUOTE_MARK: Style = { color: PALETTE.fence }
39const QUOTE_TEXT: Style = { italic: true }
40const BULLET: Style = { color: PALETTE.bullet, bold: true }
41const TODO: Style = { color: PALETTE.taskTodo, bold: true }
42const DONE: Style = { color: PALETTE.taskDone, bold: true }
43const CARD: Style = { backgroundColor: PALETTE.codeBg, color: PALETTE.codeFg }
44const FENCE_LINE: Style = { backgroundColor: PALETTE.codeBg, color: PALETTE.fence }
45const LANG: Style = { color: PALETTE.lang, bold: true }
46const TABLE_HEAD: Style = { bold: true }
47const COMMENT: Style = { dimColor: true, italic: true }
48const DEF_LABEL: Style = { color: PALETTE.link }
49const DEF_TITLE: Style = { dimColor: true, italic: true }
50
51// h1 underlined and bold, h2 bold, h3 bold italic, h4 plain, h5 italic, h6 italic and faint
52const HEADINGS: readonly Style[] = [
53 { bold: true, underline: true, color: PALETTE.heading },
54 { bold: true, color: PALETTE.heading },
55 { bold: true, italic: true, color: PALETTE.heading },
56 { color: PALETTE.heading },
57 { italic: true, color: PALETTE.heading },
58 { italic: true, dimColor: true, color: PALETTE.heading },
59]
60
61const TOKEN_STYLE = Object.fromEntries(
62 Object.entries(PALETTE.token).map(([kind, color]) => [kind, { color }]),
63) as Record<TokenKind, Style>
64
65const isSp = (c: number) => c === 32 || c === 9
66/** A line ending in one of these reads as a sentence, not as a title. */
67const SENTENCE_END = new Set([".", "。", "!", "!", ":", ":", ";", ";", ",", "、"])
68
69/** An emitter for text that was cut out of several lines: virtual offsets in, one run per line out. */
70function makeMapper(segs: readonly Seg[], sink: Emit): Emit {
71 if (segs.length === 1) {
72 const o = segs[0]!.o
73 return (a, b, style) => sink(o + a, o + b, style)
74 }
75 const starts: number[] = []
76 let v = 0
77 for (const g of segs) {
78 starts.push(v)
79 v += g.len + 1 // the `\n` the pieces are joined with
80 }
81 return (a, b, style) => {
82 let lo = 0
83 let hi = segs.length - 1
84 while (lo < hi) {
85 const mid = (lo + hi + 1) >> 1
86 if (starts[mid]! <= a) lo = mid
87 else hi = mid - 1
88 }
89 for (let k = lo; k < segs.length && starts[k]! < b; k++) {
90 const g = segs[k]!
91 const from = Math.max(a, starts[k]!)
92 const to = Math.min(b, starts[k]! + g.len)
93 if (to > from) sink(g.o + (from - starts[k]!), g.o + (to - starts[k]!), style)
94 }
95 }
96}
97
98export function decorateBlocks(text: string, codeOnly: boolean): Decoration[] {
99 const n = text.length
100 const out: Decoration[] = []
101 const runs: Run[] = []
102 const links = new Set<string>()
103 const footnotes = new Set<string>()
104
105 const put: Emit = (start, end, style) => {
106 if (end > start) out.push({ start, end, ...style })
107 }
108 /** chrome that only the full mode paints; code is `put` in every mode */
109 const paint: Emit = codeOnly ? () => {} : put
110
111 // ---- the line being read -------------------------------------------------------------------
112 let p = 0 // next unread character
113 let col = 0 // its column (tabs stop at multiples of 4)
114 let le = 0 // end of the line (before its terminator)
115 let nwPos = 0 // first non-blank at or after p
116 let nwCol = 0
117
118 const peek = () => {
119 let q = p
120 let c = col
121 while (q < le) {
122 const ch = text.charCodeAt(q)
123 if (ch === 32) c++
124 else if (ch === 9) c += 4 - (c & 3)
125 else break
126 q++
127 }
128 nwPos = q
129 nwCol = c
130 }
131 /** Take up to `k` columns of blanks. A tab that is only partly taken stays at `p` with `col` inside it,
132 * so what is left of it still counts as blanks for whoever reads next (CommonMark's partial tabs). */
133 const eatCols = (k: number) => {
134 let need = k
135 while (need > 0 && p < le) {
136 const ch = text.charCodeAt(p)
137 if (ch === 32) {
138 p++
139 col++
140 need--
141 } else if (ch === 9) {
142 const w = 4 - (col & 3)
143 if (need >= w) {
144 p++
145 col += w
146 need -= w
147 } else {
148 col += need
149 need = 0
150 }
151 } else break
152 }
153 }
154
155 // ---- open blocks -----------------------------------------------------------------------------
156 const stack: number[] = [] // QUOTE, or a list item's content indent
157 /** per container: has a block started inside it yet? (an empty list item does not survive a blank line) */
158 const filled: boolean[] = []
159 let quotes = 0
160 let matched = 0
161 let all = true
162 let para: Para | null = null
163 let fence: Fence | null = null
164 let table = false
165 let icode = false
166 let comment = false
167 let boxed = false // a task box was painted on this line
168
169 const recount = () => {
170 quotes = 0
171 for (const c of stack) if (c === QUOTE) quotes++
172 }
173
174 const paintFence = (f: Fence) => {
175 const segs = f.segs
176 if (segs.length === 0) return
177 for (const g of segs) put(g.o, g.o + g.len, CARD)
178 const body = segs.map((g) => text.slice(g.o, g.o + g.len)).join("\n")
179 const map = makeMapper(segs, put)
180 for (const span of highlightCode(body, f.language)) map(span.start, span.end, TOKEN_STYLE[span.kind])
181 }
182 const flushPara = () => {
183 if (para) runs.push({ segs: para.segs, base: para.quote && !codeOnly ? QUOTE_TEXT : undefined })
184 para = null
185 }
186 const flushFence = () => {
187 if (fence) paintFence(fence)
188 fence = null
189 }
190 const flushLeaf = () => {
191 flushPara()
192 flushFence()
193 table = false
194 icode = false
195 comment = false
196 }
197 /** A new block begins here: whatever leaf is open ends, and containers this line did not match close. */
198 const startBlock = () => {
199 flushLeaf()
200 closeTo(matched)
201 if (stack.length > 0) filled[stack.length - 1] = true
202 all = true
203 }
204 const closeTo = (k: number) => {
205 if (k < stack.length) {
206 stack.length = k
207 filled.length = k
208 recount()
209 }
210 matched = stack.length
211 }
212
213 // ---- small scanners over the current line ----------------------------------------------------
214 const runOf = (pos: number, ch: number) => {
215 let q = pos
216 while (q < le && text.charCodeAt(q) === ch) q++
217 return q - pos
218 }
219 const trimEnd = (from: number) => {
220 let e = le
221 while (e > from && isSp(text.charCodeAt(e - 1))) e--
222 return e
223 }
224 /** `---`, `***`, `___` (3+, blanks between allowed); the end of the rule, or -1 */
225 const thematicEnd = (pos: number, ch: number) => {
226 let count = 0
227 for (let q = pos; q < le; q++) {
228 const c = text.charCodeAt(q)
229 if (c === ch) count++
230 else if (!isSp(c)) return -1
231 }
232 return count >= 3 ? trimEnd(pos) : -1
233 }
234 /**
235 * `===` / `---` under a paragraph: one run of the character, then blanks. CommonMark takes a single
236 * `-` too, but then typing `- item` under a line would flash that line as a heading, so 3+.
237 */
238 const underlineEnd = (pos: number, ch: number) => {
239 const k = runOf(pos, ch)
240 if (k < 3) return -1
241 for (let q = pos + k; q < le; q++) if (!isSp(text.charCodeAt(q))) return -1
242 return pos + k
243 }
244
245 /**
246 * Whether a paragraph may become a setext heading. CommonMark makes any paragraph a heading when a
247 * `---` follows it, but in a prompt `---` under a line is nearly always a divider, and painting a
248 * whole paragraph as a heading is the worst misfire there is. So only a single line that does not
249 * end like a sentence qualifies; anything else keeps its text and the `---` stays a rule.
250 */
251 const couldBeHeading = (candidate: Para) => {
252 if (candidate.segs.length !== 1) return false
253 const seg = candidate.segs[0]!
254 let e = seg.o + seg.len
255 while (e > seg.o && isSp(text.charCodeAt(e - 1))) e--
256 return e > seg.o && !SENTENCE_END.has(text[e - 1]!)
257 }
258
259 /** Cells of a table row as trimmed [start, end) pairs, and where the unescaped pipes are. */
260 const splitRow = (from: number, to: number): Row => {
261 let a = from
262 let b = to
263 while (a < b && isSp(text.charCodeAt(a))) a++
264 while (b > a && isSp(text.charCodeAt(b - 1))) b--
265 const cells: number[] = []
266 const pipes: number[] = []
267 const add = (x: number, y: number) => {
268 while (x < y && isSp(text.charCodeAt(x))) x++
269 while (y > x && isSp(text.charCodeAt(y - 1))) y--
270 cells.push(x, y)
271 }
272 let start = a
273 let endsWithPipe = false
274 for (let i = a; i < b; i++) {
275 const c = text.charCodeAt(i)
276 if (c === 92) {
277 i++
278 } else if (c === 124) {
279 pipes.push(i)
280 if (i !== a) add(start, i)
281 start = i + 1
282 endsWithPipe = i === b - 1
283 }
284 }
285 if (!endsWithPipe && a < b) add(start, b)
286 return { cells, pipes }
287 }
288 /** The column count of a `|---|:---:|` row, or 0 when the line is not one. */
289 const delimiterColumns = (from: number, to: number) => {
290 const row = splitRow(from, to)
291 if (row.pipes.length === 0 || row.cells.length === 0) return 0
292 for (let k = 0; k < row.cells.length; k += 2) {
293 let a = row.cells[k]!
294 let b = row.cells[k + 1]!
295 if (text.charCodeAt(a) === 58) a++
296 if (b > a && text.charCodeAt(b - 1) === 58) b--
297 if (b <= a) return 0
298 for (let i = a; i < b; i++) if (text.charCodeAt(i) !== 45) return 0
299 }
300 return row.cells.length / 2
301 }
302 const paintRow = (row: Row, base: Style | undefined) => {
303 for (const pipe of row.pipes) paint(pipe, pipe + 1, DIM)
304 for (let k = 0; k < row.cells.length; k += 2) {
305 const a = row.cells[k]!
306 const b = row.cells[k + 1]!
307 if (b > a) runs.push({ segs: [{ o: a, len: b - a }], base: codeOnly ? undefined : base })
308 }
309 }
310
311 // ---- one line ---------------------------------------------------------------------------------
312 const processLine = () => {
313 // 1. the open containers claim their part of the line
314 boxed = false
315 matched = 0
316 for (; matched < stack.length; matched++) {
317 peek()
318 const c = stack[matched]!
319 if (c === QUOTE) {
320 if (nwCol - col <= 3 && nwPos < le && text.charCodeAt(nwPos) === 62) {
321 paint(nwPos, nwPos + 1, QUOTE_MARK)
322 p = nwPos + 1
323 col = nwCol + 1
324 eatCols(1) // the one blank that may follow `>` (part of a tab, if that is what it is)
325 } else break
326 } else if (nwPos >= le) {
327 // a blank line keeps a list item open and takes nothing from it, unless the item is
328 // still empty: an item may begin with one blank line, not two
329 if (!filled[matched]) break
330 } else if (nwCol - col >= c) eatCols(c)
331 else break
332 }
333 all = matched === stack.length
334
335 // 2. a leaf that swallows lines whole
336 if (fence) {
337 if (all) {
338 peek()
339 const k = nwCol - col <= 3 ? runOf(nwPos, fence.char) : 0
340 if (k >= fence.len && trimEnd(nwPos + k) <= nwPos + k) {
341 flushFence()
342 put(p, le, FENCE_LINE)
343 return
344 }
345 fence.segs.push({ o: p, len: le - p })
346 return
347 }
348 flushFence()
349 } else if (comment) {
350 if (all) {
351 const close = text.slice(p, le).indexOf("-->")
352 paint(p, close === -1 ? le : p + close + 3, COMMENT)
353 if (close !== -1) comment = false
354 return
355 }
356 comment = false
357 } else if (icode) {
358 if (all) {
359 peek()
360 if (nwPos >= le) return
361 if (nwCol - col >= 4) {
362 put(p, le, CARD)
363 return
364 }
365 }
366 icode = false
367 }
368 if (table && !all) table = false
369
370 // 3. the rest of the line: blank, or a new block, or text
371 for (;;) {
372 peek()
373 if (nwPos >= le) {
374 if (all) {
375 flushPara()
376 table = false
377 } else {
378 flushLeaf()
379 closeTo(matched)
380 all = true
381 }
382 return
383 }
384 const ind = nwCol - col
385 const ch = text.charCodeAt(nwPos)
386
387 if (ind >= 4) {
388 if (table && tryTableRow()) return
389 if (para) {
390 para.segs.push({ o: nwPos, len: le - nwPos })
391 return
392 }
393 startBlock()
394 icode = true
395 put(p, le, CARD)
396 return
397 }
398
399 // block quote
400 if (ch === 62 && stack.length < MAX_DEPTH) {
401 startBlock()
402 paint(nwPos, nwPos + 1, QUOTE_MARK)
403 stack.push(QUOTE)
404 filled.push(false)
405 quotes++
406 matched = stack.length
407 p = nwPos + 1
408 col = nwCol + 1
409 eatCols(1)
410 continue
411 }
412
413 // ATX heading
414 if (ch === 35) {
415 const k = runOf(nwPos, 35)
416 if (k <= 6 && (nwPos + k >= le || isSp(text.charCodeAt(nwPos + k)))) {
417 startBlock()
418 paint(nwPos, nwPos + k, DIM)
419 let cs = nwPos + k
420 while (cs < le && isSp(text.charCodeAt(cs))) cs++
421 let ce = trimEnd(cs)
422 let q = ce
423 while (q > cs && text.charCodeAt(q - 1) === 35) q--
424 if (q < ce && (q === cs || isSp(text.charCodeAt(q - 1)))) {
425 paint(q, ce, DIM) // the closing `##`
426 ce = q
427 while (ce > cs && isSp(text.charCodeAt(ce - 1))) ce--
428 }
429 if (ce > cs) runs.push({ segs: [{ o: cs, len: ce - cs }], base: codeOnly ? undefined : HEADINGS[k - 1] })
430 return
431 }
432 }
433
434 // fenced code
435 if (ch === 96 || ch === 126) {
436 const k = runOf(nwPos, ch)
437 const info = k >= 3 ? text.slice(nwPos + k, le) : ""
438 // a backtick fence's info string cannot hold a backtick: ```code``` on one line is inline code
439 if (k >= 3 && !(ch === 96 && info.includes("`"))) {
440 startBlock()
441 put(p, le, FENCE_LINE)
442 const language = languageOf(info)
443 if (language) {
444 // the language word as typed (`TS`, `.py`): first non-space char, past an optional dot
445 const lead = info.search(/\S/)
446 const at = nwPos + k + lead + (info[lead] === "." ? 1 : 0)
447 put(at, at + language.length, LANG)
448 }
449 fence = { char: ch, len: k, language, segs: [] }
450 return
451 }
452 }
453
454 // HTML comment block
455 if (ch === 60 && text.startsWith("<!--", nwPos)) {
456 const rest = text.slice(nwPos, le)
457 const close = rest.indexOf("-->", 4)
458 if (close === -1 || rest.slice(close + 3).trim() === "") {
459 startBlock()
460 paint(nwPos, le, COMMENT)
461 comment = close === -1
462 return
463 }
464 }
465
466 // setext underline
467 if (para && all && (ch === 61 || ch === 45) && couldBeHeading(para)) {
468 const end = underlineEnd(nwPos, ch)
469 if (end !== -1) {
470 runs.push({ segs: para.segs, base: codeOnly ? undefined : HEADINGS[ch === 61 ? 0 : 1] })
471 para = null
472 paint(nwPos, end, DIM)
473 return
474 }
475 }
476
477 // table: this is the delimiter row under a header line
478 if (para && all && (ch === 124 || ch === 45 || ch === 58)) {
479 const cols = delimiterColumns(nwPos, le)
480 if (cols > 0) {
481 const last = para.segs[para.segs.length - 1]!
482 const header = splitRow(last.o, last.o + last.len)
483 if (header.pipes.length > 0 && header.cells.length / 2 === cols) {
484 para.segs.pop()
485 if (para.segs.length > 0) flushPara()
486 para = null
487 table = true
488 paintRow(header, TABLE_HEAD)
489 paint(nwPos, trimEnd(nwPos), DIM)
490 return
491 }
492 }
493 }
494
495 // thematic break
496 if (ch === 42 || ch === 45 || ch === 95) {
497 const end = thematicEnd(nwPos, ch)
498 if (end !== -1) {
499 startBlock()
500 paint(nwPos, end, DIM)
501 return
502 }
503 }
504
505 // list item
506 if (stack.length < MAX_DEPTH && listItem(ind, ch)) continue
507
508 // link reference / footnote definition (only where a paragraph could start)
509 if (ch === 91 && !para && definition()) return
510
511 // table row
512 if (table && tryTableRow()) return
513
514 // a task box with no bullet before it: the box is painted, what follows is read as usual
515 if (ch === 91 && !boxed) {
516 p = nwPos
517 col = nwCol
518 if (taskBox()) continue
519 }
520
521 // paragraph text
522 if (para) {
523 para.segs.push({ o: nwPos, len: le - nwPos })
524 return
525 }
526 startBlock()
527 para = { segs: [{ o: nwPos, len: le - nwPos }], quote: quotes > 0 }
528 return
529 }
530 }
531
532 const tryTableRow = (): boolean => {
533 const row = splitRow(nwPos, le)
534 if (row.pipes.length === 0) return false
535 paintRow(row, undefined)
536 return true
537 }
538
539 /** Open a list item if the line has a marker at `nwPos`; true when it did (the rest is still to read). */
540 const listItem = (ind: number, ch: number): boolean => {
541 let width = 0
542 let start = 1
543 if (ch === 42 || ch === 43 || ch === 45) {
544 if (nwPos + 1 === le || isSp(text.charCodeAt(nwPos + 1))) width = 1
545 } else if (ch >= 48 && ch <= 57) {
546 let q = nwPos
547 while (q < le && text.charCodeAt(q) >= 48 && text.charCodeAt(q) <= 57) q++
548 const digits = q - nwPos
549 const d = text.charCodeAt(q)
550 if (digits <= 9 && (d === 46 || d === 41) && (q + 1 === le || isSp(text.charCodeAt(q + 1)))) {
551 width = digits + 1
552 start = Number.parseInt(text.slice(nwPos, q), 10)
553 }
554 }
555 if (width === 0) return false
556
557 let q = nwPos + width
558 let c = nwCol + width
559 while (q < le && isSp(text.charCodeAt(q))) {
560 c += text.charCodeAt(q) === 9 ? 4 - (c & 3) : 1
561 q++
562 }
563 const empty = q >= le
564 // an item can interrupt a paragraph only when it has content, and an ordered one only from 1
565 if (para && all && (empty || (width > 1 && start !== 1))) return false
566
567 const spaces = c - (nwCol + width)
568 const takes = empty || spaces >= 5 ? 1 : spaces
569 startBlock()
570 paint(nwPos, nwPos + width, BULLET)
571 stack.push(ind + width + takes)
572 filled.push(false)
573 matched = stack.length
574 p = nwPos + width
575 col = nwCol + width
576 eatCols(takes)
577
578 // GFM task list: `[ ]` / `[x]` opening the item's text
579 if (!empty) taskBox()
580 return true
581 }
582
583 /** `[ ]` / `[x]` at `p`, then a blank or the end of the line: paint the box and step past it and its blanks. */
584 const taskBox = (): boolean => {
585 if (text.charCodeAt(p) !== 91 || text.charCodeAt(p + 2) !== 93 || (p + 3 !== le && !isSp(text.charCodeAt(p + 3)))) return false
586 const mark = text.charCodeAt(p + 1)
587 if (mark !== 32 && mark !== 120 && mark !== 88) return false
588 paint(p, p + 3, mark === 32 ? TODO : DONE)
589 p += 3
590 col += 3
591 peek()
592 p = nwPos
593 col = nwCol
594 boxed = true
595 return true
596 }
597
598 /** `[label]: url "title"` and `[^note]: text` at `nwPos`; true when the line was one. */
599 const definition = (): boolean => {
600 if (text.charCodeAt(nwPos + 1) === 94) {
601 let q = nwPos + 2
602 while (q < le) {
603 const c = text.charCodeAt(q)
604 if (c === 93 || c === 91 || c <= 32) break
605 q++
606 }
607 if (q === nwPos + 2 || text.charCodeAt(q) !== 93 || text.charCodeAt(q + 1) !== 58) return false
608 if (q + 2 < le && !isSp(text.charCodeAt(q + 2))) return false
609 startBlock()
610 footnotes.add(normalizeLabel(text.slice(nwPos + 2, q)))
611 paint(nwPos, nwPos + 2, DIM)
612 paint(nwPos + 2, q, DEF_LABEL)
613 paint(q, q + 2, DIM)
614 let cs = q + 2
615 while (cs < le && isSp(text.charCodeAt(cs))) cs++
616 if (cs < le) para = { segs: [{ o: cs, len: le - cs }], quote: quotes > 0 }
617 return true
618 }
619
620 let q = nwPos + 1
621 const limit = Math.min(le, nwPos + 1000)
622 while (q < limit && text.charCodeAt(q) !== 93) {
623 const c = text.charCodeAt(q)
624 if (c === 91) return false
625 q += c === 92 ? 2 : 1
626 }
627 if (text.charCodeAt(q) !== 93 || text.charCodeAt(q + 1) !== 58) return false
628 const label = text.slice(nwPos + 1, q)
629 if (label.trim() === "") return false
630 let r = q + 2
631 while (r < le && isSp(text.charCodeAt(r))) r++
632 const destStart = r
633 if (text.charCodeAt(r) === 60) {
634 while (r < le && text.charCodeAt(r) !== 62) r++
635 if (r >= le) return false
636 r++
637 } else {
638 while (r < le && !isSp(text.charCodeAt(r))) r++
639 }
640 if (r === destStart) return false
641 const destEnd = r
642 while (r < le && isSp(text.charCodeAt(r))) r++
643 let titleStart = -1
644 let titleEnd = -1
645 if (r < le) {
646 const open = text.charCodeAt(r)
647 const last = trimEnd(r) - 1
648 const closeCh = open === 40 ? 41 : open
649 if (r === destEnd || (open !== 34 && open !== 39 && open !== 40) || last <= r || text.charCodeAt(last) !== closeCh) return false
650 titleStart = r
651 titleEnd = last + 1
652 }
653 startBlock()
654 links.add(normalizeLabel(label))
655 paint(nwPos, nwPos + 1, DIM)
656 paint(nwPos + 1, q, DEF_LABEL)
657 paint(q, q + 2, DIM)
658 paint(destStart, destEnd, DIM)
659 if (titleStart !== -1) paint(titleStart, titleEnd, DEF_TITLE)
660 return true
661 }
662
663 // ---- read the draft line by line ---------------------------------------------------------------
664 for (let pos = 0; ; ) {
665 let e = pos
666 while (e < n) {
667 const c = text.charCodeAt(e)
668 if (c === 10 || c === 13) break
669 e++
670 }
671 p = pos
672 col = 0
673 le = e
674 processLine()
675 if (e >= n) break
676 pos = e + (text.charCodeAt(e) === 13 && text.charCodeAt(e + 1) === 10 ? 2 : 1)
677 }
678 flushLeaf()
679
680 // ---- the text of every paragraph, heading and cell, now that the definitions are known ---------
681 const refs = { links, footnotes }
682 for (const run of runs) {
683 const segs = run.segs
684 if (run.base && !codeOnly) for (const g of segs) put(g.o, g.o + g.len, run.base)
685 const s =
686 segs.length === 1 ? text.slice(segs[0]!.o, segs[0]!.o + segs[0]!.len) : segs.map((g) => text.slice(g.o, g.o + g.len)).join("\n")
687 paintInline(s, refs, codeOnly, makeMapper(segs, put))
688 }
689 return out
690}
691hooks/lib/palette.ts 62 lines1// Shared vocabulary of the painter: the `Decoration` shape the engine takes and the colours we use.
2// Pure data, no logic. `mdprompt.ts` re-exports everything here, so callers keep importing from there.
3
4import type { TokenKind } from "./highlight"
5
6export type Decoration = {
7 start: number
8 end: number
9 color?: string
10 backgroundColor?: string
11 dimColor?: boolean
12 bold?: boolean
13 italic?: boolean
14 underline?: boolean
15 strikethrough?: boolean
16}
17
18/** What a run says about its characters; `start`/`end` are added when it is emitted. */
19export type Style = Omit<Decoration, "start" | "end">
20
21// Explicit foreground + background on code so the block reads on a light or a dark terminal
22// alike, whatever the user's theme paints the rest of the prompt with.
23//
24// The colours that sit straight on the terminal background (no card behind them) are mid-tones,
25// luminance about 0.2: at least 3.3:1 on white and 4.5:1 on a dark grey (#1e1e1e) terminal.
26// tests/palette.test.ts holds that floor, so a new colour cannot make a light theme unreadable. Everything that is
27// only a marker (`#`, `|`, `**`, `---`) is `dimColor` instead, which follows the theme by itself.
28export const PALETTE = {
29 codeBg: "#1f2430",
30 codeFg: "#d4d4d4",
31 fence: "#808080",
32 lang: "#79c0ff",
33 inlineBg: "#2b2f3a",
34 inlineFg: "#e6c07b",
35 heading: "#3a86e0",
36 link: "#3a86e0",
37 /** list bullets and ordered-list numbers */
38 bullet: "#289488",
39 /** `[ ]`: still to do */
40 taskTodo: "#ac7d0a",
41 /** `[x]`: done */
42 taskDone: "#399657",
43 /** inline HTML / XML tags, `<thinking>` and friends */
44 tag: "#a06bd0",
45 /** `&` `'` */
46 entity: "#b87727",
47 token: {
48 keyword: "#c586c0",
49 string: "#ce9178",
50 comment: "#6a9955",
51 number: "#b5cea8",
52 type: "#4ec9b0",
53 literal: "#569cd6",
54 add: "#7ee787",
55 del: "#ff7b72",
56 meta: "#79c0ff",
57 } satisfies Record<TokenKind, string>,
58} as const
59
60/** A draft this long is left unpainted: the box stays responsive and nobody writes this in a prompt box. */
61export const MAX_CHARS = 60_000
62hooks/lib/highlight.ts 517 lines1// Pure syntax highlighter for the body of a fenced code block: source text in, token spans out.
2// No `$`, no UI — it only knows how to cut a string into comments / strings / numbers /
3// keywords. Deliberately shallow: one linear scan per block, a small table of languages, and a
4// safe fallback (strings + numbers only) for anything it does not know, so a wrong guess never
5// paints a whole paragraph as a comment.
6//
7// Most languages are a row of the `LANGS` table read by one generic scanner. The few whose shape
8// that cannot express have their own scanner in `highlight-markup.ts` (HTML/XML, Markdown),
9// `highlight-css.ts` and `highlight-config.ts` (TOML/INI, Dockerfile, Makefile); `SCANNERS`
10// maps names to them. All of them keep the same contract: spans come back ordered, non-empty,
11// non-overlapping and inside the text, in linear time, and nothing throws.
12
13import { highlightDockerfile, highlightMakefile, highlightToml } from "./highlight-config"
14import { highlightCss } from "./highlight-css"
15import { highlightMarkdown, highlightMarkup } from "./highlight-markup"
16import { quoteScanner, scanString, set } from "./highlight-util"
17
18export type TokenKind =
19 | "keyword"
20 | "string"
21 | "comment"
22 | "number"
23 | "type"
24 | "literal"
25 | "add"
26 | "del"
27 | "meta"
28
29/** `[start, end)` in UTF-16 code units of the text that was highlighted. */
30export type Span = { start: number; end: number; kind: TokenKind }
31
32type Lang = {
33 lineComments: readonly string[]
34 blockComment?: readonly [string, string]
35 /** quote characters that open a single-line string */
36 quotes: string
37 /** a backtick opens a string that may run over lines (JS/TS template literals) */
38 backtickStrings?: boolean
39 /** `"""` / `'''` open a string that may run over lines (Python) */
40 tripleQuotes?: boolean
41 keywords: ReadonlySet<string>
42 literals: ReadonlySet<string>
43 caseInsensitive?: boolean
44 /** Capitalised identifiers are drawn as types (classes, structs, enums) */
45 capitalizedTypes?: boolean
46 /** `#` only starts a comment at the start of a line or after whitespace (`$#`, `${#a}`) */
47 hashNeedsBoundary?: boolean
48 /** Lua: `--[==[ ... ]==]` comments and `[==[ ... ]==]` strings over lines */
49 longBrackets?: boolean
50}
51
52const JS: Lang = {
53 lineComments: ["//"],
54 blockComment: ["/*", "*/"],
55 quotes: `"'`,
56 backtickStrings: true,
57 keywords: set(
58 `const let var function return if else for while do switch case break continue new class extends
59 import export from default async await try catch finally throw typeof instanceof in of void delete
60 yield static get set interface type enum implements public private protected readonly as satisfies
61 abstract declare namespace module keyof`,
62 ),
63 literals: set("true false null undefined this super NaN Infinity"),
64 capitalizedTypes: true,
65}
66
67const PY: Lang = {
68 lineComments: ["#"],
69 quotes: `"'`,
70 tripleQuotes: true,
71 keywords: set(
72 `def class return if elif else for while in not and or is import from as with try except finally
73 raise pass break continue lambda yield global nonlocal assert del async await match case`,
74 ),
75 literals: set("True False None self cls"),
76 capitalizedTypes: true,
77}
78
79const SH: Lang = {
80 lineComments: ["#"],
81 quotes: `"'`,
82 hashNeedsBoundary: true,
83 keywords: set(
84 `if then else elif fi for while do done case esac in function return export local readonly unset
85 select until`,
86 ),
87 literals: set("true false"),
88}
89
90const JSON_LANG: Lang = {
91 lineComments: ["//"],
92 blockComment: ["/*", "*/"],
93 quotes: `"`,
94 keywords: set(""),
95 literals: set("true false null"),
96}
97
98const YAML: Lang = {
99 lineComments: ["#"],
100 quotes: `"'`,
101 hashNeedsBoundary: true,
102 keywords: set(""),
103 literals: set("true false null yes no on off"),
104}
105
106const GO: Lang = {
107 lineComments: ["//"],
108 blockComment: ["/*", "*/"],
109 quotes: `"`,
110 backtickStrings: true,
111 keywords: set(
112 `package import func return if else for range switch case default break continue go defer select
113 chan map struct interface type var const fallthrough goto`,
114 ),
115 literals: set("true false nil iota"),
116 capitalizedTypes: true,
117}
118
119// Rust's `'a` lifetimes collide with char quotes, so single quotes are left alone here.
120const RUST: Lang = {
121 lineComments: ["//"],
122 blockComment: ["/*", "*/"],
123 quotes: `"`,
124 keywords: set(
125 `fn let mut const static struct enum impl trait pub use mod crate match if else for while loop
126 return break continue as in where move async await dyn ref unsafe type`,
127 ),
128 literals: set("true false self Self None Some Ok Err"),
129 capitalizedTypes: true,
130}
131
132const SWIFT: Lang = {
133 lineComments: ["//"],
134 blockComment: ["/*", "*/"],
135 quotes: `"`,
136 keywords: set(
137 `func let var class struct enum protocol extension import return if else guard for while in
138 switch case default break continue init throws throw try catch defer as is async await actor
139 some any where static private public internal fileprivate open override final lazy weak inout
140 typealias`,
141 ),
142 literals: set("true false nil self super"),
143 capitalizedTypes: true,
144}
145
146const CLIKE: Lang = {
147 lineComments: ["//"],
148 blockComment: ["/*", "*/"],
149 quotes: `"'`,
150 keywords: set(
151 `if else for while do switch case break continue return class struct enum interface public
152 private protected static final void int long float double char bool boolean string new import
153 package using namespace const let var fun val typedef template typename virtual override
154 extends implements try catch finally throw throws abstract`,
155 ),
156 literals: set("true false null this nullptr NULL"),
157 capitalizedTypes: true,
158}
159
160const SQL: Lang = {
161 lineComments: ["--"],
162 blockComment: ["/*", "*/"],
163 quotes: `"'`,
164 caseInsensitive: true,
165 keywords: set(
166 `select from where insert into values update set delete create table alter drop join left right
167 inner outer on group by order having limit offset as and or not is in like distinct union all
168 primary key foreign references index unique default with case when then else end`,
169 ),
170 literals: set("null true false"),
171}
172
173const RUBY: Lang = {
174 lineComments: ["#"],
175 quotes: `"'`,
176 hashNeedsBoundary: true,
177 keywords: set(
178 `def end class module if elsif else unless while until do return yield begin rescue ensure
179 require include extend attr_accessor case when then for in`,
180 ),
181 literals: set("true false nil self"),
182 capitalizedTypes: true,
183}
184
185const LUA: Lang = {
186 lineComments: ["--"],
187 quotes: `"'`,
188 longBrackets: true,
189 keywords: set(
190 `and break do else elseif end for function goto if in local not or repeat return then until
191 while`,
192 ),
193 literals: set("nil true false"),
194}
195
196const HCL: Lang = {
197 lineComments: ["#", "//"],
198 blockComment: ["/*", "*/"],
199 quotes: `"`,
200 keywords: set(
201 `resource variable output module provider data locals terraform backend required_providers
202 dynamic for in if else`,
203 ),
204 literals: set("true false null"),
205}
206
207const POWERSHELL: Lang = {
208 lineComments: ["#"],
209 blockComment: ["<#", "#>"],
210 quotes: `"'`,
211 hashNeedsBoundary: true,
212 caseInsensitive: true,
213 keywords: set(
214 `function filter param begin process end if elseif else switch foreach for while do until break
215 continue return throw try catch finally trap exit in class enum using`,
216 ),
217 literals: set("$true $false $null"),
218}
219
220const GRAPHQL: Lang = {
221 lineComments: ["#"],
222 quotes: `"`,
223 tripleQuotes: true,
224 keywords: set(
225 `query mutation subscription fragment on type interface union enum input scalar schema extend
226 directive implements repeatable`,
227 ),
228 literals: set("true false null"),
229 capitalizedTypes: true,
230}
231
232const ELIXIR: Lang = {
233 lineComments: ["#"],
234 quotes: `"'`,
235 tripleQuotes: true,
236 hashNeedsBoundary: true,
237 keywords: set(
238 `def defp defmodule defmacro defmacrop defstruct defprotocol defimpl defguard do end fn if else
239 unless cond case when with for in and or not import alias use require quote unquote raise try
240 rescue catch after receive send`,
241 ),
242 literals: set("true false nil"),
243 capitalizedTypes: true,
244}
245
246const HASKELL: Lang = {
247 lineComments: ["--"],
248 blockComment: ["{-", "-}"],
249 quotes: `"`,
250 keywords: set(
251 `module import where let in if then else case of do data type newtype class instance deriving
252 infix infixl infixr forall qualified as hiding`,
253 ),
254 literals: set("True False Nothing"),
255 capitalizedTypes: true,
256}
257
258const ZIG: Lang = {
259 lineComments: ["//"],
260 quotes: `"'`,
261 keywords: set(
262 `const var fn pub return if else while for switch break continue defer errdefer try catch struct
263 enum union error test comptime inline extern export usingnamespace orelse and or unreachable
264 async await suspend resume packed volatile align anytype opaque noreturn threadlocal`,
265 ),
266 literals: set("true false null undefined"),
267 capitalizedTypes: true,
268}
269
270const R: Lang = {
271 lineComments: ["#"],
272 quotes: `"'`,
273 keywords: set("function if else for while repeat break next return in"),
274 literals: set("TRUE FALSE NULL NA NA_integer_ NA_real_ NA_character_ Inf NaN"),
275}
276
277const PERL: Lang = {
278 lineComments: ["#"],
279 quotes: `"'`,
280 hashNeedsBoundary: true,
281 keywords: set(
282 `my our local sub if elsif else unless while until for foreach do return last next redo use no
283 package require and or not eq ne lt gt le ge cmp print say die warn defined undef`,
284 ),
285 literals: set(""),
286}
287
288const NGINX: Lang = {
289 lineComments: ["#"],
290 quotes: `"'`,
291 hashNeedsBoundary: true,
292 keywords: set(
293 `server location upstream events stream map types include listen server_name root try_files
294 proxy_pass return rewrite set if error_page access_log error_log ssl_certificate
295 ssl_certificate_key fastcgi_pass add_header gzip worker_processes worker_connections user pid
296 default_type`,
297 ),
298 literals: set("on off"),
299}
300
301const PROTO: Lang = {
302 lineComments: ["//"],
303 blockComment: ["/*", "*/"],
304 quotes: `"'`,
305 keywords: set(
306 `syntax package import option message enum service rpc returns stream oneof map repeated
307 optional required reserved extensions extend group public weak to max string int32 int64
308 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 float double bool bytes`,
309 ),
310 literals: set("true false"),
311 capitalizedTypes: true,
312}
313
314// Anything unlabelled or unknown: quoted strings and numbers only.
315const PLAIN: Lang = {
316 lineComments: [],
317 quotes: `"`,
318 keywords: set(""),
319 literals: set(""),
320}
321
322const LANGS: Record<string, Lang> = {
323 js: JS, jsx: JS, javascript: JS, mjs: JS, cjs: JS, ts: JS, tsx: JS, typescript: JS, mts: JS, cts: JS,
324 py: PY, python: PY,
325 sh: SH, bash: SH, zsh: SH, shell: SH, console: SH,
326 json: JSON_LANG, jsonc: JSON_LANG, json5: JSON_LANG,
327 yaml: YAML, yml: YAML,
328 go: GO, golang: GO,
329 rust: RUST, rs: RUST,
330 swift: SWIFT,
331 java: CLIKE, kotlin: CLIKE, kt: CLIKE, kts: CLIKE, c: CLIKE, h: CLIKE, cpp: CLIKE, "c++": CLIKE,
332 cc: CLIKE, cxx: CLIKE, hpp: CLIKE, hh: CLIKE,
333 cs: CLIKE, csharp: CLIKE, php: CLIKE, dart: CLIKE, scala: CLIKE,
334 "objective-c": CLIKE, objc: CLIKE, m: CLIKE, groovy: CLIKE, gradle: CLIKE,
335 sql: SQL,
336 ruby: RUBY, rb: RUBY,
337 lua: LUA,
338 hcl: HCL, terraform: HCL, tf: HCL,
339 powershell: POWERSHELL, ps1: POWERSHELL, pwsh: POWERSHELL,
340 graphql: GRAPHQL, gql: GRAPHQL,
341 elixir: ELIXIR, ex: ELIXIR, exs: ELIXIR,
342 haskell: HASKELL, hs: HASKELL,
343 zig: ZIG,
344 r: R,
345 perl: PERL,
346 nginx: NGINX,
347 proto: PROTO, protobuf: PROTO,
348 // stated as plain on purpose: text stays text, and `csv` gets no comment or keyword rules
349 text: PLAIN, txt: PLAIN, plain: PLAIN, plaintext: PLAIN, csv: PLAIN,
350}
351
352// Languages with a scanner of their own (see the header comment).
353const SCANNERS: Record<string, (code: string) => Span[]> = {
354 html: highlightMarkup, htm: highlightMarkup, vue: highlightMarkup, svelte: highlightMarkup,
355 xml: highlightMarkup, svg: highlightMarkup, xhtml: highlightMarkup, plist: highlightMarkup,
356 xsd: highlightMarkup, xsl: highlightMarkup, xslt: highlightMarkup, rss: highlightMarkup,
357 atom: highlightMarkup, wsdl: highlightMarkup, xaml: highlightMarkup,
358 css: (code) => highlightCss(code, "css"),
359 scss: (code) => highlightCss(code, "scss"),
360 less: (code) => highlightCss(code, "less"),
361 markdown: highlightMarkdown, md: highlightMarkdown, mkd: highlightMarkdown,
362 dockerfile: highlightDockerfile, docker: highlightDockerfile, containerfile: highlightDockerfile,
363 makefile: highlightMakefile, make: highlightMakefile, mk: highlightMakefile,
364 toml: (code) => highlightToml(code, false),
365 ini: (code) => highlightToml(code, true), cfg: (code) => highlightToml(code, true),
366 properties: (code) => highlightToml(code, true), dotenv: (code) => highlightToml(code, true),
367}
368
369/** An own entry of a language table: `constructor` and `__proto__` are not languages. */
370function own<T>(table: Record<string, T>, name: string | null): T | undefined {
371 return name !== null && Object.prototype.hasOwnProperty.call(table, name) ? table[name] : undefined
372}
373
374/** The highlighter language an info string names (`ts`, `python title="x"`), or null. */
375export function languageOf(info: string): string | null {
376 const first = info.trim().split(/[\s{]/)[0]?.toLowerCase().replace(/^\./, "") ?? ""
377 return first === "" ? null : first
378}
379
380const isIdentStart = (c: string) => /[A-Za-z_$]/.test(c)
381const isIdentPart = (c: string) => /[A-Za-z0-9_$]/.test(c)
382const isDigit = (c: string) => c >= "0" && c <= "9"
383
384const NUMBER = /0[xX][0-9a-fA-F_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/y
385
386/** Cut `code` into highlighted spans; everything not returned is plain text. */
387export function highlightCode(code: string, language: string | null): Span[] {
388 if (language === "diff" || language === "patch") return highlightDiff(code)
389 const scanner = own(SCANNERS, language)
390 if (scanner) return scanner(code)
391 const lang = own(LANGS, language) ?? PLAIN
392 const spans: Span[] = []
393 const n = code.length
394 const quote = quoteScanner(code) // remembers a quote that never closed, so a line of them is scanned once
395 let i = 0
396
397 while (i < n) {
398 const c = code[i]!
399
400 // Lua long brackets: `--[==[ ... ]==]` is a comment, `[==[ ... ]==]` a string; unclosed ones run to the end
401 if (lang.longBrackets && (c === "[" || (c === "-" && code[i + 1] === "-"))) {
402 const isComment = c === "-"
403 const open = isComment ? i + 2 : i
404 const level = code[open] === "[" ? longBracketLevel(code, open) : -1
405 if (level !== -1) {
406 const closer = "]" + "=".repeat(level) + "]"
407 const close = code.indexOf(closer, open + level + 2)
408 const end = close === -1 ? n : close + closer.length
409 spans.push({ start: i, end, kind: isComment ? "comment" : "string" })
410 i = end
411 continue
412 }
413 }
414
415 // comments
416 if (lang.blockComment && code.startsWith(lang.blockComment[0], i)) {
417 const close = code.indexOf(lang.blockComment[1], i + lang.blockComment[0].length)
418 const end = close === -1 ? n : close + lang.blockComment[1].length
419 spans.push({ start: i, end, kind: "comment" })
420 i = end
421 continue
422 }
423 let lineComment = false
424 for (const m of lang.lineComments) {
425 if (code.startsWith(m, i) && (!lang.hashNeedsBoundary || m !== "#" || i === 0 || /\s/.test(code[i - 1]!))) {
426 lineComment = true
427 break
428 }
429 }
430 if (lineComment) {
431 const nl = code.indexOf("\n", i)
432 const end = nl === -1 ? n : nl
433 spans.push({ start: i, end, kind: "comment" })
434 i = end
435 continue
436 }
437
438 // strings
439 if (lang.tripleQuotes && (code.startsWith('"""', i) || code.startsWith("'''", i))) {
440 const q = code.slice(i, i + 3)
441 const close = code.indexOf(q, i + 3)
442 const end = close === -1 ? n : close + 3
443 spans.push({ start: i, end, kind: "string" })
444 i = end
445 continue
446 }
447 if (c === "`" && lang.backtickStrings) {
448 const end = scanString(code, i, "`", true)
449 spans.push({ start: i, end: end === -1 ? n : end, kind: "string" })
450 i = end === -1 ? n : end
451 continue
452 }
453 if (lang.quotes.includes(c)) {
454 const end = quote(i, c)
455 if (end !== -1) {
456 spans.push({ start: i, end, kind: "string" })
457 i = end
458 continue
459 }
460 i++ // an unmatched quote (an apostrophe) is just a character
461 continue
462 }
463
464 // numbers
465 if (isDigit(c) && (i === 0 || !isIdentPart(code[i - 1]!))) {
466 NUMBER.lastIndex = i
467 const m = NUMBER.exec(code)
468 if (m) {
469 spans.push({ start: i, end: i + m[0].length, kind: "number" })
470 i += m[0].length
471 continue
472 }
473 }
474
475 // words
476 if (isIdentStart(c)) {
477 let j = i + 1
478 while (j < n && isIdentPart(code[j]!)) j++
479 const word = code.slice(i, j)
480 const key = lang.caseInsensitive ? word.toLowerCase() : word
481 if (lang.keywords.has(key)) spans.push({ start: i, end: j, kind: "keyword" })
482 else if (lang.literals.has(key)) spans.push({ start: i, end: j, kind: "literal" })
483 else if (lang.capitalizedTypes && c >= "A" && c <= "Z" && word.length > 1 && /[a-z]/.test(word)) {
484 spans.push({ start: i, end: j, kind: "type" })
485 }
486 i = j
487 continue
488 }
489
490 i++
491 }
492 return spans
493}
494
495/** The number of `=` in a Lua long bracket opening at `open` (`[==[` is 2), or -1 when there is none. */
496function longBracketLevel(code: string, open: number): number {
497 let j = open + 1
498 while (code[j] === "=") j++
499 return code[j] === "[" ? j - open - 1 : -1
500}
501
502function highlightDiff(code: string): Span[] {
503 const spans: Span[] = []
504 let offset = 0
505 for (const line of code.split("\n")) {
506 const end = offset + line.length
507 if (end > offset) {
508 if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("diff ")) {
509 spans.push({ start: offset, end, kind: "meta" })
510 } else if (line.startsWith("+")) spans.push({ start: offset, end, kind: "add" })
511 else if (line.startsWith("-")) spans.push({ start: offset, end, kind: "del" })
512 }
513 offset = end + 1
514 }
515 return spans
516}
517hooks/lib/inline.ts 685 lines1// Inline Markdown: the text of one run (a paragraph, a heading, a table cell) in, style runs out.
2// Pure and linear. No `$`, no UI, no regular expression that backtracks over the whole run.
3//
4// It follows CommonMark's inline rules closely enough that the colours agree with what a renderer
5// would draw, with a few deliberate deviations for prompts (see the notes below):
6// - code spans, escapes, autolinks and inline HTML bind tighter than emphasis and links;
7// - emphasis is the delimiter-stack algorithm with the left/right-flanking rules, run once at
8// the end and once inside every link text (`openersBottom` keeps it linear);
9// - links are the bracket-stack algorithm: `[t](u "title")`, `[t][ref]`, `[t][]`, `[t]` (a
10// reference only counts when the draft defines it: `matrix[i][j]` stays plain), images, and
11// footnote references `[^1]`;
12// - GFM: `~~strike~~`, and bare `https://…` / `www.…` URLs.
13// Deviations: a CJK letter next to punctuation still opens/closes `*` emphasis (`日本語**「太字」**です`);
14// a `*` / `~~` between two ASCII word characters or right after `/` cannot open (`2*3*4`, `x**2`,
15// `src/*.ts`); Python's `__init__` / `__name__` are not bold; tag names may hold `_` and `:` (`<user_input>`); an unknown tag glued to a word
16// (`Array<string>`) is not a tag.
17//
18// Everything is reported through `emit(start, end, style)` in offsets of the text it was given;
19// a run may cover a `\n` there (a multi-line paragraph), the caller cuts it at the line breaks.
20// Every scanner that can walk far ahead draws on one budget, so no input turns this quadratic.
21
22import { PALETTE, type Style } from "./palette"
23
24export type Refs = { links: ReadonlySet<string>; footnotes: ReadonlySet<string> }
25export type Emit = (start: number, end: number, style: Style) => void
26
27/** The key a link label is looked up under: trimmed, whitespace collapsed, case-folded. */
28export function normalizeLabel(label: string): string {
29 return label.trim().replace(/\s+/g, " ").toLowerCase()
30}
31
32const DIM: Style = { dimColor: true }
33const LINK_TEXT: Style = { underline: true, color: PALETTE.link }
34const IMAGE_ALT: Style = { italic: true, color: PALETTE.link }
35const FOOTNOTE: Style = { color: PALETTE.link }
36const TAG: Style = { color: PALETTE.tag }
37const COMMENT: Style = { dimColor: true, italic: true }
38const ENTITY: Style = { color: PALETTE.entity }
39const STRONG: Style = { bold: true }
40const EM: Style = { italic: true }
41const STRIKE: Style = { strikethrough: true }
42const CODE_CHIP: Style = { backgroundColor: PALETTE.inlineBg, color: PALETTE.inlineFg }
43const CODE_TICKS: Style = { color: PALETTE.fence }
44
45const BACKSLASH = 92
46const BACKTICK = 96
47const STAR = 42
48const UNDERSCORE = 95
49const TILDE = 126
50const LBRACKET = 91
51const RBRACKET = 93
52const BANG = 33
53const LT = 60
54const GT = 62
55const AMP = 38
56const LPAREN = 40
57const RPAREN = 41
58const QUOTE = 34
59const APOS = 39
60const SEMI = 59
61
62// ---- character classes --------------------------------------------------------------------------
63
64function isWs(cp: number): boolean {
65 return (
66 cp === 32 || (cp >= 9 && cp <= 13) || cp === 0xa0 || cp === 0x1680 || (cp >= 0x2000 && cp <= 0x200a) ||
67 cp === 0x2028 || cp === 0x2029 || cp === 0x202f || cp === 0x205f || cp === 0x3000
68 )
69}
70
71const PUNCT_RE = /[\p{P}\p{S}]/u
72function isPunct(cp: number): boolean {
73 if (cp < 128) return (cp >= 33 && cp <= 47) || (cp >= 58 && cp <= 64) || (cp >= 91 && cp <= 96) || (cp >= 123 && cp <= 126)
74 return PUNCT_RE.test(String.fromCodePoint(cp))
75}
76
77/** Han, kana, hangul: scripts that do not put spaces between words. */
78function isCjk(cp: number): boolean {
79 return (
80 (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x3400 && cp <= 0x4dbf) || (cp >= 0x4e00 && cp <= 0x9fff) ||
81 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xac00 && cp <= 0xd7af) || (cp >= 0xff66 && cp <= 0xff9f) ||
82 (cp >= 0x20000 && cp <= 0x2fa1f)
83 )
84}
85
86const isAsciiPunct = (c: number) => (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126)
87const isAlpha = (c: number) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122)
88const isAlnum = (c: number) => isAlpha(c) || (c >= 48 && c <= 57)
89const isWord = (c: number) => isAlnum(c) || c === 95
90const isSpaceCode = (c: number) => c === 32 || c === 9 || c === 10
91const isTagNameChar = (c: number) => isAlnum(c) || c === 45 || c === 95 || c === 58
92const isAttrStart = (c: number) => isAlpha(c) || c === 95 || c === 58
93const isAttrChar = (c: number) => isAlnum(c) || c === 95 || c === 46 || c === 58 || c === 45
94
95// A tag name in this set is a tag wherever it stands (`line<br>two`); any other name only when it
96// is not glued to a word before it, so `Array<string>` and `f<T>(x)` stay text.
97const HTML_NAMES = new Set(
98 `a abbr article aside b bdi bdo blockquote br caption center cite code col colgroup dd del details dfn div dl dt em
99 figcaption figure font footer h1 h2 h3 h4 h5 h6 header hr i img ins kbd li main mark nav ol p pre q rp rt ruby s samp
100 section small span strike strong sub summary sup table tbody td tfoot th thead time tr tt u ul var wbr`
101 .split(/\s+/)
102 .filter(Boolean),
103)
104
105const SPECIAL = /[\\`*_~[<&]|https?:|www\./i
106const ENTITY_RE = /&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});/y
107const FOOTNOTE_RE = /\[\^([^\s[\]\\]{1,64})\]/y
108const URL_START_RE = /(?:https?:\/\/|www\.)[^\s<]/iy
109// `__init__.py` and `if __name__ == "__main__":` are Python, not bold; only these well-known names are exempt
110const DUNDER_RE = /[a-z]{1,20}(?=__(?!\w))/y
111const DUNDERS = new Set(
112 `init main name file doc dict class module all slots repr str len call enter exit getitem setitem delitem iter next new
113 del eq ne lt gt le ge hash bool add sub mul truediv floordiv mod pow contains getattr setattr version author package
114 path builtins annotations post_init pycache tests mocks snapshots fixtures`
115 .split(/\s+/)
116 .filter(Boolean),
117)
118
119// ---- delimiters and brackets --------------------------------------------------------------------
120
121type Delim = {
122 ch: number
123 /** first character of what is left of the run */
124 start: number
125 len: number
126 origLen: number
127 /** CommonMark's left-flanking verdict; it also feeds the rule of 3, so the exceptions below leave it alone */
128 canOpen: boolean
129 /** true when the run may not act as an opener after all (`2*3*4`, `src/*.ts`) */
130 noOpen: boolean
131 canClose: boolean
132 prev: Delim | null
133 next: Delim | null
134}
135
136type Bracket = {
137 /** the `[`, or the `!` of `![` */
138 start: number
139 /** the first character of the link text */
140 textStart: number
141 image: boolean
142 /** the last delimiter before this bracket: emphasis inside the link text only looks above it */
143 delimBefore: Delim | null
144}
145
146export function paintInline(s: string, refs: Refs, codeOnly: boolean, emit: Emit): void {
147 const n = s.length
148 if (n === 0 || !SPECIAL.test(s)) return
149
150 const out: Emit = codeOnly ? () => {} : emit
151 const dim = (a: number, b: number) => out(a, b, DIM)
152 const cpAt = (i: number) => s.codePointAt(i) ?? 32
153 const cpBefore = (i: number) => {
154 const c = s.charCodeAt(i - 1)
155 if (c >= 0xdc00 && c <= 0xdfff && i >= 2) {
156 const h = s.charCodeAt(i - 2)
157 if (h >= 0xd800 && h <= 0xdbff) return ((h - 0xd800) << 10) + (c - 0xdc00) + 0x10000
158 }
159 return c
160 }
161
162 // one budget for every scanner that can look far ahead (tags, destinations, labels, URLs)
163 let budget = 12 * n + 4096
164
165 const codes: number[] = [] // start, end, tick count — painted last so the chip is the final word
166
167 let head: Delim | null = null
168 let tail: Delim | null = null
169 const brackets: Bracket[] = []
170 /** brackets below this index are no longer allowed to become links (a link never holds a link) */
171 let deactivateBelow = 0
172 let lastBracket = -1
173
174 // ---- code spans: closer lookup is a per-length cursor over the backtick runs, so linear ----
175 let tickRuns: Map<number, number[]> | null = null
176 const tickCursor = new Map<number, number>()
177 const findTickCloser = (len: number, from: number): number => {
178 if (tickRuns === null) {
179 tickRuns = new Map()
180 for (let i = s.indexOf("`"); i !== -1 && i < n; ) {
181 let j = i
182 while (s.charCodeAt(j) === BACKTICK) j++
183 const list = tickRuns.get(j - i)
184 if (list) list.push(i)
185 else tickRuns.set(j - i, [i])
186 i = s.indexOf("`", j)
187 }
188 }
189 const list = tickRuns.get(len)
190 if (!list) return -1
191 let k = tickCursor.get(len) ?? 0
192 while (k < list.length && list[k]! < from) k++
193 tickCursor.set(len, k)
194 return k < list.length ? list[k]! : -1
195 }
196
197 // ---- "no closer after here" memory for `-->`, `?>`, `]]>`, `>` searches ----
198 const noCloser = new Map<string, number>()
199 const findAfter = (needle: string, from: number): number => {
200 const dead = noCloser.get(needle)
201 if (dead !== undefined && from >= dead) return -1
202 const k = s.indexOf(needle, from)
203 if (k === -1) noCloser.set(needle, dead === undefined ? from : Math.min(dead, from))
204 return k
205 }
206
207 // ---- emphasis: CommonMark's delimiter stack ----
208 const removeDelim = (d: Delim) => {
209 if (d.prev) d.prev.next = d.next
210 else head = d.next
211 if (d.next) d.next.prev = d.prev
212 else tail = d.prev
213 }
214
215 const processEmphasis = (bottom: Delim | null) => {
216 let closer: Delim | null = bottom ? bottom.next : head
217 if (!closer) return
218 const openersBottom: (Delim | null)[] = new Array(18).fill(bottom)
219 while (closer) {
220 if (!closer.canClose) {
221 closer = closer.next
222 continue
223 }
224 const key = (closer.ch === STAR ? 0 : closer.ch === UNDERSCORE ? 1 : 2) * 6 + (closer.canOpen ? 3 : 0) + (closer.origLen % 3)
225 const floor = openersBottom[key] ?? null
226 let opener: Delim | null = closer.prev
227 let found = false
228 while (opener && opener !== bottom && opener !== floor) {
229 // the rule of 3: `**foo*` cannot pair a 2-run with a 1-run that could also have closed/opened
230 const oddMatch =
231 (closer.canOpen || opener.canClose) && closer.origLen % 3 !== 0 && (opener.origLen + closer.origLen) % 3 === 0
232 if (opener.ch === closer.ch && opener.canOpen && !opener.noOpen && !oddMatch) {
233 found = true
234 break
235 }
236 opener = opener.prev
237 }
238 if (found && opener) {
239 const use = closer.len >= 2 && opener.len >= 2 ? 2 : 1
240 const openEnd = opener.start + opener.len
241 out(openEnd, closer.start, closer.ch === TILDE ? STRIKE : use === 2 ? STRONG : EM)
242 dim(openEnd - use, openEnd)
243 dim(closer.start, closer.start + use)
244 opener.len -= use
245 closer.len -= use
246 closer.start += use
247 for (let d = closer.prev; d && d !== opener; ) {
248 const before: Delim | null = d.prev
249 removeDelim(d)
250 d = before
251 }
252 if (opener.len === 0) removeDelim(opener)
253 if (closer.len === 0) {
254 const after: Delim | null = closer.next
255 removeDelim(closer)
256 closer = after
257 }
258 } else {
259 const after: Delim | null = closer.next
260 openersBottom[key] = closer.prev
261 if (!closer.canOpen || closer.noOpen) removeDelim(closer)
262 closer = after
263 }
264 }
265 while (tail && tail !== bottom) removeDelim(tail)
266 }
267
268 const pushDelim = (ch: number, i: number, j: number) => {
269 const len = j - i
270 const prev = i === 0 ? 32 : cpBefore(i)
271 const next = j >= n ? 32 : cpAt(j)
272 const prevWs = isWs(prev)
273 const nextWs = isWs(next)
274 const prevP = isPunct(prev)
275 const nextP = isPunct(next)
276 let left = !nextWs && (!nextP || prevWs || prevP)
277 let right = !prevWs && (!prevP || nextWs || nextP)
278 if (ch !== UNDERSCORE) {
279 // a CJK letter outside lets the punctuation inside count as a boundary: 日本語**「太字」**です
280 if (!left && !nextWs && isCjk(prev)) left = true
281 if (!right && !prevWs && isCjk(next)) right = true
282 }
283 const canOpen = ch === UNDERSCORE ? left && (!right || prevP) : left
284 // `__tests__/` and `__generated__/` are path segments: an `_` run right before a `/` does not close
285 const canClose = ch === UNDERSCORE ? right && (!left || nextP) && next !== 47 : right
286 // `2*3*4`, `x**2 + y**2` and `src/*.ts and lib/*.ts` are arithmetic and globs far more often than
287 // emphasis, so a `*` / `~~` run between two ASCII word characters, or any run right after a `/`, only closes
288 const noOpen = canOpen && ((ch !== UNDERSCORE && isAlnum(prev) && isAlnum(next)) || prev === 47)
289 if (!canClose && (!canOpen || noOpen)) return
290 const d: Delim = { ch, start: i, len, origLen: len, canOpen, noOpen, canClose, prev: tail, next: null }
291 if (tail) tail.next = d
292 else head = d
293 tail = d
294 }
295
296 // ---- links ----
297 /** `(dest "title")` starting at the `(` at `p`; the index just past `)`, or -1. */
298 const scanInlineLink = (p: number): number => {
299 if (budget <= 0) return -1
300 const from = p
301 let q = p + 1
302 const skipWs = () => {
303 while (q < n && isSpaceCode(s.charCodeAt(q))) q++
304 }
305 const result = (() => {
306 skipWs()
307 if (s.charCodeAt(q) === RPAREN) return q + 1
308 if (s.charCodeAt(q) === LT) {
309 q++
310 while (q < n) {
311 const c = s.charCodeAt(q)
312 if (c === 10 || c === LT) return -1
313 if (c === GT) break
314 q += c === BACKSLASH ? 2 : 1
315 }
316 if (s.charCodeAt(q) !== GT) return -1
317 q++
318 } else {
319 let depth = 0
320 const st = q
321 while (q < n) {
322 const c = s.charCodeAt(q)
323 if (c === BACKSLASH && q + 1 < n) {
324 q += 2
325 continue
326 }
327 if (c <= 32) break
328 if (c === LPAREN) {
329 if (++depth > 32) return -1
330 } else if (c === RPAREN) {
331 if (depth === 0) break
332 depth--
333 }
334 q++
335 }
336 if (q === st || depth !== 0) return -1
337 }
338 const afterDest = q
339 skipWs()
340 if (s.charCodeAt(q) === RPAREN) return q + 1
341 const open = s.charCodeAt(q)
342 if (q === afterDest || (open !== QUOTE && open !== APOS && open !== LPAREN)) return -1
343 const close = open === LPAREN ? RPAREN : open
344 q++
345 while (q < n) {
346 const c = s.charCodeAt(q)
347 if (c === BACKSLASH) {
348 q += 2
349 continue
350 }
351 if (c === close) break
352 if (c === LPAREN) return -1 // a (title) cannot hold a bare `(`
353 q++
354 }
355 if (s.charCodeAt(q) !== close) return -1
356 q++
357 skipWs()
358 return s.charCodeAt(q) === RPAREN ? q + 1 : -1
359 })()
360 budget -= Math.min(q, n) - from + 1
361 return result
362 }
363
364 /** `[label]` at `p`; [labelStart, labelEnd) of the label and the index past `]`, or null. */
365 const scanLabel = (p: number): [number, number, number] | null => {
366 let q = p + 1
367 const limit = Math.min(n, p + 1000)
368 while (q < limit) {
369 const c = s.charCodeAt(q)
370 if (c === BACKSLASH) {
371 q += 2
372 continue
373 }
374 if (c === RBRACKET) return [p + 1, q, q + 1]
375 if (c === LBRACKET) return null
376 q++
377 }
378 return null
379 }
380
381 const isDefined = (from: number, to: number): boolean =>
382 refs.links.size > 0 && to - from <= 999 && to > from && refs.links.has(normalizeLabel(s.slice(from, to)))
383
384 const popBracket = () => {
385 brackets.pop()
386 if (deactivateBelow > brackets.length) deactivateBelow = brackets.length
387 }
388
389 const closeBracket = (i: number): number => {
390 const previousBracket = lastBracket
391 lastBracket = i
392 const top = brackets[brackets.length - 1]
393 if (!top) return i + 1
394 if (!top.image && brackets.length - 1 < deactivateBelow) {
395 popBracket()
396 return i + 1
397 }
398 // a label taken from the link text itself (`[t][]`, `[t]`) may not hold brackets
399 const clean = previousBracket === top.textStart - 1
400 let end = -1
401 const next = s.charCodeAt(i + 1)
402 if (next === LPAREN) end = scanInlineLink(i + 1)
403 if (end === -1 && next === LBRACKET) {
404 const label = scanLabel(i + 1)
405 if (label) {
406 if (label[0] === label[1]) {
407 if (clean && isDefined(top.textStart, i)) end = label[2]
408 } else if (isDefined(label[0], label[1])) end = label[2]
409 }
410 }
411 if (end === -1 && clean && isDefined(top.textStart, i)) end = i + 1
412 if (end === -1) {
413 popBracket()
414 return i + 1
415 }
416 dim(top.start, top.textStart)
417 out(top.textStart, i, top.image ? IMAGE_ALT : LINK_TEXT)
418 dim(i, end)
419 processEmphasis(top.delimBefore)
420 popBracket()
421 if (!top.image) deactivateBelow = brackets.length
422 return end
423 }
424
425 // ---- `<`: autolinks and inline HTML ----
426 const scanAutolink = (i: number): number => {
427 const p = i + 1
428 if (isAlpha(s.charCodeAt(p))) {
429 let q = p + 1
430 while (q < n && q - p < 32 && (isAlnum(s.charCodeAt(q)) || s.charCodeAt(q) === 43 || s.charCodeAt(q) === 46 || s.charCodeAt(q) === 45)) q++
431 if (q - p >= 2 && s.charCodeAt(q) === 58) {
432 q++
433 while (q < n) {
434 const c = s.charCodeAt(q)
435 if (c <= 32 || c === LT || c === GT) break
436 q++
437 }
438 budget -= q - i
439 if (s.charCodeAt(q) === GT) return q + 1
440 }
441 }
442 // <name@example.com>
443 let q = p
444 while (q < n && (isAlnum(s.charCodeAt(q)) || ".!#$%&'*+/=?^_`{|}~-".includes(s[q]!))) q++
445 if (q > p && s.charCodeAt(q) === 64) {
446 const d0 = q + 1
447 let r = d0
448 while (r < n && (isAlnum(s.charCodeAt(r)) || s.charCodeAt(r) === 46 || s.charCodeAt(r) === 45)) r++
449 budget -= r - i
450 const domain = s.slice(d0, r)
451 if (r > d0 && s.charCodeAt(r) === GT && /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(domain) && !domain.includes("..")) return r + 1
452 }
453 return -1
454 }
455
456 /** end of the tag / comment / declaration at `i`, or -1; `comment` says which style it gets. */
457 const scanHtml = (i: number): { end: number; comment: boolean } | null => {
458 if (budget <= 0) return null
459 const c1 = s.charCodeAt(i + 1)
460 if (c1 === BANG) {
461 if (s.startsWith("!--", i + 1)) {
462 const k = findAfter("-->", i + 4)
463 return k === -1 ? null : { end: k + 3, comment: true }
464 }
465 if (s.startsWith("![CDATA[", i + 1)) {
466 const k = findAfter("]]>", i + 9)
467 return k === -1 ? null : { end: k + 3, comment: true }
468 }
469 if (isAlpha(s.charCodeAt(i + 2))) {
470 const k = findAfter(">", i + 2)
471 return k === -1 ? null : { end: k + 1, comment: false }
472 }
473 return null
474 }
475 if (c1 === 63) {
476 const k = findAfter("?>", i + 2)
477 return k === -1 ? null : { end: k + 2, comment: true }
478 }
479 const closing = c1 === 47
480 let p = i + (closing ? 2 : 1)
481 const nameStart = p
482 if (!isAlpha(s.charCodeAt(p))) return null
483 while (p < n && isTagNameChar(s.charCodeAt(p))) p++
484 if (!closing && i > 0 && isWord(s.charCodeAt(i - 1)) && !HTML_NAMES.has(s.slice(nameStart, p).toLowerCase())) return null
485 const result = (() => {
486 for (;;) {
487 const before = p
488 while (isSpaceCode(s.charCodeAt(p))) p++
489 const spaced = p > before
490 const c = s.charCodeAt(p)
491 if (c === GT) return p + 1
492 if (c === 47 && s.charCodeAt(p + 1) === GT && !closing) return p + 2
493 if (closing || !spaced || !isAttrStart(c)) return -1
494 p++
495 while (isAttrChar(s.charCodeAt(p))) p++
496 let q = p
497 while (isSpaceCode(s.charCodeAt(q))) q++
498 if (s.charCodeAt(q) !== 61) continue
499 q++
500 while (isSpaceCode(s.charCodeAt(q))) q++
501 const v = s.charCodeAt(q)
502 if (v === QUOTE || v === APOS) {
503 const limit = Math.min(n, q + 2000)
504 let r = q + 1
505 while (r < limit && s.charCodeAt(r) !== v) r++
506 if (s.charCodeAt(r) !== v) return -1
507 p = r + 1
508 } else {
509 const st = q
510 while (q < n) {
511 const c2 = s.charCodeAt(q)
512 if (isSpaceCode(c2) || c2 === QUOTE || c2 === APOS || c2 === 61 || c2 === LT || c2 === GT || c2 === BACKTICK) break
513 q++
514 }
515 if (q === st) return -1
516 p = q
517 }
518 }
519 })()
520 budget -= Math.min(p, n) - i + 1
521 return result === -1 ? null : { end: result, comment: false }
522 }
523
524 // ---- bare URLs (GFM extended autolink) ----
525 const scanBareUrl = (i: number): number => {
526 if (budget <= 0) return -1
527 URL_START_RE.lastIndex = i
528 const m = URL_START_RE.exec(s)
529 if (!m) return -1
530 const minEnd = i + m[0].length
531 let j = minEnd
532 const stopAtBracket = brackets.length > 0
533 let opens = 0
534 let closes = 0
535 while (j < n) {
536 const c = s.charCodeAt(j)
537 if (c <= 32 || isWs(c) || c === LT || (stopAtBracket && c === RBRACKET)) break
538 if (c === LPAREN) opens++
539 else if (c === RPAREN) closes++
540 j++
541 }
542 budget -= j - i
543 for (;;) {
544 const last = s.charCodeAt(j - 1)
545 if (j > minEnd && (last === 63 || last === 33 || last === 46 || last === 44 || last === 58 || last === STAR || last === UNDERSCORE || last === TILDE || last === APOS || last === QUOTE)) {
546 j--
547 } else if (j > minEnd && last === RPAREN && closes > opens) {
548 closes--
549 j--
550 } else if (j > minEnd && last === SEMI) {
551 let k = j - 2
552 while (k > i && isAlnum(s.charCodeAt(k))) k--
553 if (s.charCodeAt(k) === AMP && k < j - 2) j = k
554 else break
555 } else break
556 }
557 return j >= minEnd ? j : -1
558 }
559
560 // ---- the scan ----
561 let i = 0
562 while (i < n) {
563 const c = s.charCodeAt(i)
564 switch (c) {
565 case BACKSLASH:
566 i += i + 1 < n && isAsciiPunct(s.charCodeAt(i + 1)) ? 2 : 1
567 break
568
569 case BACKTICK: {
570 let j = i
571 while (s.charCodeAt(j) === BACKTICK) j++
572 const close = findTickCloser(j - i, j)
573 if (close === -1) {
574 i = j
575 } else {
576 codes.push(i, close + (j - i), j - i)
577 i = close + (j - i)
578 }
579 break
580 }
581
582 case STAR:
583 case UNDERSCORE:
584 case TILDE: {
585 let j = i
586 while (s.charCodeAt(j) === c) j++
587 if (c === UNDERSCORE && j - i === 2 && (i === 0 || !isWord(s.charCodeAt(i - 1)))) {
588 DUNDER_RE.lastIndex = j
589 const m = DUNDER_RE.exec(s)
590 if (m && DUNDERS.has(m[0])) {
591 i = j + m[0].length + 2
592 break
593 }
594 }
595 if (c !== TILDE || j - i === 2) pushDelim(c, i, j)
596 i = j
597 break
598 }
599
600 case BANG:
601 if (s.charCodeAt(i + 1) === LBRACKET) {
602 brackets.push({ start: i, textStart: i + 2, image: true, delimBefore: tail })
603 lastBracket = i + 1
604 i += 2
605 } else i++
606 break
607
608 case LBRACKET: {
609 if (s.charCodeAt(i + 1) === 94) {
610 FOOTNOTE_RE.lastIndex = i
611 const m = FOOTNOTE_RE.exec(s)
612 if (m && (refs.footnotes.has(normalizeLabel(m[1]!)) || /^\d{1,3}$/.test(m[1]!))) {
613 const end = i + m[0].length
614 dim(i, i + 2)
615 out(i + 2, end - 1, FOOTNOTE)
616 dim(end - 1, end)
617 i = end
618 break
619 }
620 }
621 brackets.push({ start: i, textStart: i + 1, image: false, delimBefore: tail })
622 lastBracket = i
623 i++
624 break
625 }
626
627 case RBRACKET:
628 i = closeBracket(i)
629 break
630
631 case LT: {
632 const auto = scanAutolink(i)
633 if (auto !== -1) {
634 dim(i, i + 1)
635 out(i + 1, auto - 1, LINK_TEXT)
636 dim(auto - 1, auto)
637 i = auto
638 break
639 }
640 const html = scanHtml(i)
641 if (html) {
642 out(i, html.end, html.comment ? COMMENT : TAG)
643 i = html.end
644 } else i++
645 break
646 }
647
648 case AMP: {
649 ENTITY_RE.lastIndex = i
650 const m = ENTITY_RE.exec(s)
651 if (m) {
652 out(i, i + m[0].length, ENTITY)
653 i += m[0].length
654 } else i++
655 break
656 }
657
658 default: {
659 const low = c | 32
660 if ((low === 104 || low === 119) && (i === 0 || isWs(s.charCodeAt(i - 1)) || s.charCodeAt(i - 1) === STAR || s.charCodeAt(i - 1) === UNDERSCORE || s.charCodeAt(i - 1) === TILDE || s.charCodeAt(i - 1) === LPAREN)) {
661 const end = scanBareUrl(i)
662 if (end !== -1) {
663 out(i, end, LINK_TEXT)
664 i = end
665 break
666 }
667 }
668 i++
669 }
670 }
671 }
672
673 processEmphasis(null)
674
675 // code spans last, so their background is the final word on those characters
676 for (let k = 0; k < codes.length; k += 3) {
677 const a = codes[k]!
678 const b = codes[k + 1]!
679 const t = codes[k + 2]!
680 emit(a, b, CODE_CHIP)
681 emit(a, a + t, CODE_TICKS)
682 emit(b - t, b, CODE_TICKS)
683 }
684}
685hooks/lib/highlight-config.ts 506 lines1// Line-oriented config formats whose meaning depends on where a word sits: TOML / INI,
2// Dockerfile and Makefile. Same contract as `highlight.ts`: text in, ordered non-overlapping
3// spans out, one linear pass, never throws.
4
5import type { Span } from "./highlight"
6import { isAlpha, isBlank, isBoundary, isDigit, isWord, lineEnd, push, quoteScanner, set, trimCr } from "./highlight-util"
7
8const TOML_NUMBER = /[+-]?(?:0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)/y
9const TOML_DATE =
10 /\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(?:\.\d+)?/y
11const PLAIN_NUMBER = /0[xX][0-9a-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?/y
12
13/**
14 * A number at `j`, or nothing when it is part of a longer word (`10px`, `1.2.3`, `abc1`): that
15 * whole run is skipped unpainted. Returns where to resume.
16 */
17function number(code: string, spans: Span[], j: number, to: number, re: RegExp): number {
18 re.lastIndex = j
19 const m = re.exec(code)
20 if (!m) return j + 1
21 let e = Math.min(j + m[0].length, to)
22 if (e < to && (isWord(code[e]) || (code[e] === "." && isWord(code[e + 1])))) {
23 while (e < to && (isWord(code[e]) || code[e] === ".")) e++
24 return e
25 }
26 push(spans, j, e, "number")
27 return e
28}
29
30/**
31 * TOML and INI. Section headers are meta, keys are types, plus strings (TOML's `"""` / `'''` run
32 * over lines), numbers and dates, `true` / `false`, and `#` comments (`;` too in INI, where a
33 * comment starts at the beginning of a line or after a blank).
34 */
35export function highlightToml(code: string, ini: boolean): Span[] {
36 const spans: Span[] = []
37 const n = code.length
38 const quote = quoteScanner(code)
39 const stack: string[] = [] // open `[` / `{` of a TOML value
40 let lineBegin = 0
41 let first = true // nothing seen yet on this line
42 let expectKey = true // a `key =` may start here
43
44 // `[section]` / `[[table]]`: the index past the header, or -1.
45 const header = (i: number): number => {
46 let j = i + 1
47 const double = !ini && code[j] === "["
48 if (double) j++
49 const nameStart = j
50 while (j < n && code[j] !== "]") {
51 const c = code[j]!
52 if (c === "\n" || (!ini && (c === "," || c === "[" || c === "="))) return -1
53 if (!ini && (c === '"' || c === "'")) {
54 const e = quote(j, c, c === '"')
55 if (e === -1) return -1
56 j = e
57 } else j++
58 }
59 if (j >= n || j === nameStart) return -1
60 j++
61 if (double) {
62 if (code[j] !== "]") return -1
63 j++
64 }
65 if (!ini) {
66 let k = j
67 while (k < n && (isBlank(code[k]) || code[k] === "\r")) k++
68 if (k < n && code[k] !== "\n" && code[k] !== "#") return -1 // `[1, 2]` is a value, not a header
69 }
70 return j
71 }
72
73 // `key =` (INI also `key:`): [end of the key text, index of the separator], or null.
74 const key = (i: number): [number, number] | null => {
75 let j = i
76 for (; j < n; j++) {
77 const c = code[j]!
78 if (c === "=") break
79 if (ini) {
80 if (c === ":") {
81 const d = code[j + 1]
82 if (d === undefined || d === "\n" || d === "\r" || isBlank(d)) break
83 return null // `http://...` is not a key
84 }
85 if (c === "\n" || c === "#" || c === ";") return null
86 } else if (c === '"' || c === "'") {
87 const e = quote(j, c, c === '"')
88 if (e === -1) return null
89 j = e - 1
90 } else if (!(isWord(c) || c === "-" || c === "." || isBlank(c))) return null
91 }
92 if (j >= n) return null
93 let e = j
94 while (e > i && isBlank(code[e - 1])) e--
95 return e > i ? [e, j] : null
96 }
97
98 const comment = (i: number): number => {
99 const e = trimCr(code, i, lineEnd(code, i))
100 push(spans, i, e, "comment")
101 return e
102 }
103
104 let i = 0
105 while (i < n) {
106 const c = code[i]!
107 if (c === "\n") {
108 i++
109 lineBegin = i
110 first = true
111 expectKey = stack.length === 0 || stack[stack.length - 1] === "{"
112 continue
113 }
114 if (c === " " || c === "\t" || c === "\r") {
115 i++
116 continue
117 }
118 const atFirst = first
119 first = false
120
121 if (ini ? (c === ";" || c === "#") && isBoundary(code[i - 1]) : c === "#") {
122 i = comment(i)
123 continue
124 }
125 if (c === "[" && atFirst && (expectKey || (!ini && i === lineBegin))) {
126 const e = header(i)
127 if (e !== -1) {
128 push(spans, i, e, "meta")
129 stack.length = 0 // a header at column 0 ends any array a truncated snippet left open
130 expectKey = false
131 i = e
132 continue
133 }
134 }
135 if (expectKey) {
136 expectKey = false
137 const k = key(i)
138 if (k) {
139 push(spans, i, k[0], "type")
140 i = k[1] + 1
141 continue
142 }
143 }
144
145 if (c === '"' || c === "'") {
146 const triple = c.repeat(3)
147 if (!ini && code.startsWith(triple, i)) {
148 const close = code.indexOf(triple, i + 3)
149 const e = close === -1 ? n : close + 3
150 push(spans, i, e, "string")
151 i = e
152 continue
153 }
154 const e = quote(i, c, c === '"')
155 if (e !== -1) {
156 push(spans, i, e, "string")
157 i = e
158 } else i++
159 continue
160 }
161 if (!ini) {
162 if (c === "[" || c === "{") {
163 stack.push(c)
164 if (c === "{") expectKey = true
165 i++
166 continue
167 }
168 if (c === "]" || c === "}") {
169 stack.pop()
170 i++
171 continue
172 }
173 if (c === ",") {
174 if (stack[stack.length - 1] === "{") expectKey = true
175 i++
176 continue
177 }
178 }
179 if (isDigit(c) || ((c === "+" || c === "-") && isDigit(code[i + 1]))) {
180 const prev = code[i - 1]
181 if (!isWord(prev) && prev !== ".") {
182 if (isDigit(c)) {
183 TOML_DATE.lastIndex = i
184 const d = TOML_DATE.exec(code)
185 if (d) {
186 push(spans, i, i + d[0].length, "number")
187 i += d[0].length
188 continue
189 }
190 }
191 i = number(code, spans, i, n, TOML_NUMBER)
192 continue
193 }
194 }
195 if (isAlpha(c) || c === "_") {
196 let e = i + 1
197 while (e < n && (isWord(code[e]) || code[e] === "-")) e++
198 const word = ini ? code.slice(i, e).toLowerCase() : code.slice(i, e)
199 if (word === "true" || word === "false" || (!ini && (word === "inf" || word === "nan"))) {
200 push(spans, i, e, "literal")
201 }
202 i = e
203 continue
204 }
205 i++
206 }
207 return spans
208}
209
210const DOCKER_INSTRUCTIONS = set(
211 `from run cmd label maintainer expose env add copy entrypoint volume user workdir arg onbuild
212 stopsignal healthcheck shell`,
213)
214
215/** Dockerfile: instructions (any case, only where an instruction can start), `AS`, `#` comments, strings, `$VAR`, numbers. */
216export function highlightDockerfile(code: string): Span[] {
217 const spans: Span[] = []
218 const n = code.length
219 const quote = quoteScanner(code)
220 let continued = false // the previous line ended in `\`
221 let instruction = ""
222
223 // `$NAME`, `${NAME}` at `j`: the index past it, or `j` when it is not one.
224 const variable = (j: number, end: number): number => {
225 const d = code[j + 1]
226 if (d === "{") {
227 let k = j + 2
228 while (k < end && code[k] !== "}" && (isWord(code[k]) || ":-+?=%#/.,@*!^~".includes(code[k]!))) k++
229 return k < end && k > j + 2 && code[k] === "}" ? k + 1 : j
230 }
231 if (isAlpha(d) || d === "_") {
232 let k = j + 2
233 while (k < end && isWord(code[k])) k++
234 return k
235 }
236 return j
237 }
238
239 const rest = (from: number, end: number): void => {
240 let j = from
241 while (j < end) {
242 const c = code[j]!
243 if (c === '"' || c === "'") {
244 const e = quote(j, c, c === '"')
245 if (e !== -1 && e <= end) {
246 push(spans, j, e, "string")
247 j = e
248 } else j++
249 } else if (c === "#" && isBoundary(code[j - 1])) {
250 push(spans, j, end, "comment")
251 return
252 } else if (c === "$") {
253 const e = variable(j, end)
254 if (e > j) push(spans, j, e, "literal")
255 j = Math.max(e, j + 1)
256 } else if (isDigit(c) && !isWord(code[j - 1])) {
257 j = number(code, spans, j, end, PLAIN_NUMBER)
258 } else if (isAlpha(c) || c === "_") {
259 let e = j + 1
260 while (e < end && (isWord(code[e]) || code[e] === "-")) e++
261 if (instruction === "from" && e - j === 2 && code.slice(j, e).toLowerCase() === "as") {
262 push(spans, j, e, "keyword")
263 }
264 j = e
265 } else j++
266 }
267 }
268
269 const wordEnd = (from: number, end: number): number => {
270 let e = from
271 while (e < end && isWord(code[e])) e++
272 return e
273 }
274
275 let i = 0
276 while (i < n) {
277 const nl = lineEnd(code, i)
278 const end = trimCr(code, i, nl)
279 let p = i
280 while (p < end && isBlank(code[p])) p++
281 if (p < end) {
282 if (code[p] === "#") push(spans, p, end, "comment") // a comment line does not end a continuation
283 else {
284 let q = p
285 if (!continued) {
286 instruction = ""
287 let e = wordEnd(p, end)
288 let name = code.slice(p, e).toLowerCase()
289 if (DOCKER_INSTRUCTIONS.has(name)) {
290 push(spans, p, e, "keyword")
291 instruction = name
292 q = e
293 if (name === "onbuild") {
294 let s = e
295 while (s < end && isBlank(code[s])) s++
296 e = wordEnd(s, end)
297 name = code.slice(s, e).toLowerCase()
298 if (DOCKER_INSTRUCTIONS.has(name)) {
299 push(spans, s, e, "keyword")
300 instruction = name
301 q = e
302 }
303 }
304 }
305 }
306 rest(q, end)
307 let t = end
308 while (t > p && isBlank(code[t - 1])) t--
309 continued = code[t - 1] === "\\"
310 }
311 }
312 i = nl + 1
313 }
314 return spans
315}
316
317const MAKE_DIRECTIVES = set(
318 `ifeq ifneq ifdef ifndef else endif include -include sinclude define endef export unexport
319 override vpath undefine private`,
320)
321// Directives followed by a variable name or an assignment: `export CC = gcc`, `define BODY`.
322const MAKE_NAMING = set("define export unexport override undefine private")
323const MAKE_FUNCTIONS = set(
324 `subst patsubst strip findstring filter filter-out sort word wordlist words firstword lastword
325 dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning info
326 shell origin flavor foreach call eval file value if or and let`,
327)
328const MAKE_SPECIAL = set(
329 `.PHONY .SUFFIXES .DEFAULT .PRECIOUS .INTERMEDIATE .NOTINTERMEDIATE .SECONDARY .SECONDEXPANSION
330 .DELETE_ON_ERROR .IGNORE .LOW_RESOLUTION_TIME .SILENT .EXPORT_ALL_VARIABLES .NOTPARALLEL
331 .ONESHELL .POSIX .DEFAULT_GOAL .RECIPEPREFIX .VARIABLES .FEATURES .INCLUDE_DIRS .SHELLFLAGS .WAIT`,
332)
333
334type MakeMode = "text" | "recipe" | "target"
335
336/**
337 * Makefile: `#` comments, directives (`ifeq`, `include`, ...), functions inside `$(...)`, variables
338 * (`$(CC)`, `${CC}`, `$@`, and the name in `CC := gcc`) as literals, special targets (`.PHONY`)
339 * as keywords, other target names as types, and strings and numbers.
340 */
341export function highlightMakefile(code: string): Span[] {
342 const spans: Span[] = []
343 const n = code.length
344 const quote = quoteScanner(code)
345
346 // A `$` reference at `j`: paints it and returns where to resume.
347 const ref = (j: number, to: number): number => {
348 const d = code[j + 1]
349 if (d === "$") return j + 2 // `$$` is a literal `$` (a shell variable in a recipe)
350 if (d === "(" || d === "{") {
351 let k = j + 2
352 while (k < to && (isWord(code[k]) || code[k] === "-" || code[k] === ".")) k++
353 if (k === j + 2) return j + 2
354 if (code[k] === (d === "(" ? ")" : "}")) {
355 push(spans, j, k + 1, "literal")
356 return k + 1
357 }
358 if (isBlank(code[k]) && MAKE_FUNCTIONS.has(code.slice(j + 2, k))) {
359 push(spans, j + 2, k, "keyword")
360 return k
361 }
362 push(spans, j, k, "literal") // `$(SRC:.c=.o)`
363 return k
364 }
365 if (d !== undefined && "@<^?*+|%".includes(d)) {
366 push(spans, j, j + 2, "literal")
367 return j + 2
368 }
369 if (isWord(d)) {
370 push(spans, j, j + 2, "literal") // `$X` is `$(X)`
371 return j + 2
372 }
373 return j + 1
374 }
375
376 const text = (from: number, to: number, mode: MakeMode): void => {
377 let j = from
378 while (j < to) {
379 const c = code[j]!
380 if (c === "#" && code[j - 1] !== "\\" && (mode !== "recipe" || isBoundary(code[j - 1]))) {
381 push(spans, j, to, "comment")
382 return
383 }
384 if (c === "$") {
385 j = ref(j, to)
386 } else if (mode === "target") {
387 if (isBlank(c)) j++
388 else {
389 let e = j
390 while (e < to && !isBlank(code[e]) && code[e] !== "$" && code[e] !== "#") e++
391 push(spans, j, e, MAKE_SPECIAL.has(code.slice(j, e)) ? "keyword" : "type")
392 j = Math.max(e, j + 1)
393 }
394 } else if (c === '"' || c === "'") {
395 const e = quote(j, c, c === '"')
396 if (e !== -1 && e <= to) {
397 push(spans, j, e, "string")
398 j = e
399 } else j++
400 } else if (isDigit(c) && !isWord(code[j - 1])) {
401 j = number(code, spans, j, to, PLAIN_NUMBER)
402 } else if (isAlpha(c) || c === "_") {
403 j++
404 while (j < to && isWord(code[j])) j++
405 } else j++
406 }
407 }
408
409 const assignment = (nameStart: number, opStart: number, opEnd: number, end: number): void => {
410 let e = opStart
411 while (e > nameStart && isBlank(code[e - 1])) e--
412 push(spans, nameStart, e, "literal")
413 text(opEnd, end, "text")
414 }
415
416 // A line that is not a directive or a recipe: `NAME = value`, `target: prerequisites`, or plain text.
417 const classify = (from: number, end: number, naming: boolean): void => {
418 let s = from
419 while (s < end && isBlank(code[s])) s++
420 let depth = 0
421 for (let j = s; j < end; j++) {
422 const c = code[j]!
423 if (c === "$" && (code[j + 1] === "(" || code[j + 1] === "{")) {
424 depth++
425 j++
426 } else if (depth > 0) {
427 if (c === "(" || c === "{") depth++
428 else if (c === ")" || c === "}") depth--
429 } else if (c === "#" && isBoundary(code[j - 1])) {
430 break
431 } else if (c === ":") {
432 let k = j + 1
433 while (code[k] === ":") k++
434 if (code[k] === "=") assignment(s, j, k + 1, end)
435 else {
436 text(s, j, "target")
437 text(k, end, "text")
438 }
439 return
440 } else if (c === "=") {
441 const op = j > s && "+?!".includes(code[j - 1]!) ? j - 1 : j
442 assignment(s, op, j + 1, end)
443 return
444 }
445 }
446 if (naming) {
447 let e = s
448 while (e < end && !isBlank(code[e]) && code[e] !== "#") e++
449 push(spans, s, e, "literal")
450 s = e
451 }
452 text(s, end, "text")
453 }
454
455 // A directive word starting at `p`: the index past it, or -1.
456 const directive = (p: number, end: number): number => {
457 let e = p
458 if (code[e] === "-") e++
459 while (e < end && isAlpha(code[e])) e++
460 if (!MAKE_DIRECTIVES.has(code.slice(p, e))) return -1
461 return e === end || isBlank(code[e]) || code[e] === "(" ? e : -1
462 }
463
464 const line = (p: number, end: number): void => {
465 let q = p
466 for (let e = directive(q, end); e !== -1; e = directive(q, end)) {
467 const word = code.slice(q, e)
468 push(spans, q, e, "keyword")
469 let s = e
470 while (s < end && isBlank(code[s])) s++
471 if (word === "else" && directive(s, end) !== -1) {
472 q = s // `else ifeq (...)`
473 continue
474 }
475 if (MAKE_NAMING.has(word)) classify(e, end, true)
476 else text(e, end, "text")
477 return
478 }
479 classify(q, end, false)
480 }
481
482 let continued = false
483 let recipe = false
484 let i = 0
485 while (i < n) {
486 const nl = lineEnd(code, i)
487 const end = trimCr(code, i, nl)
488 if (continued) text(i, end, recipe ? "recipe" : "text")
489 else if (code[i] === "\t") {
490 recipe = true
491 text(i + 1, end, "recipe")
492 } else {
493 recipe = false
494 let p = i
495 while (p < end && isBlank(code[p])) p++
496 if (p < end) {
497 if (code[p] === "#") push(spans, p, end, "comment")
498 else line(p, end)
499 }
500 }
501 continued = end > i && code[end - 1] === "\\"
502 i = nl + 1
503 }
504 return spans
505}
506hooks/lib/highlight-css.ts 287 lines1// CSS, SCSS and Less. The text is cut into statements (up to the next `;`, `{` or `}`, skipping
2// strings, comments and parentheses) and each statement is classified once: an at-rule, a
3// selector (it ends in `{`) or a declaration (`property: value`). Every character is looked at a
4// constant number of times, so any input scans in linear time.
5
6import type { Span, TokenKind } from "./highlight"
7import { isAlpha, isBlank, isDigit, isWord, lineEnd, push, quoteScanner, set, trimCr } from "./highlight-util"
8
9export type CssDialect = "css" | "scss" | "less"
10
11const NUM = /(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?(?:%|[A-Za-z]+)?/y
12const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/y
13const BANG = /![ \t]*(?:important|default|global|optional)(?![\w-])/iy
14// In Less an `@name` that is not one of these is a variable.
15const LESS_AT = set(
16 `import media charset font-face keyframes -webkit-keyframes -moz-keyframes supports namespace page
17 plugin viewport document counter-style font-feature-values layer container property`,
18)
19// Words that read as keywords inside an at-rule's prelude (`@media a and b`, `@each $i in $l`).
20const PRELUDE = set("and or not only in from through to if else")
21const LITERALS = set("true false null")
22
23const isIdent = (c: string): boolean => isWord(c) || c === "-" || c > "\x7f"
24const isIdentStart = (c: string | undefined): boolean =>
25 c !== undefined && (isAlpha(c) || c === "_" || c === "-" || c > "\x7f")
26
27export function highlightCss(code: string, dialect: CssDialect): Span[] {
28 const spans: Span[] = []
29 const n = code.length
30 const lineComments = dialect !== "css"
31 const less = dialect === "less"
32 const quote = quoteScanner(code)
33 let kind: TokenKind = "comment"
34
35 /** End of the comment or string starting at `i` (its kind lands in `kind`), or `i` when none does. */
36 const opaque = (i: number): number => {
37 const c = code[i]!
38 if (c === "/") {
39 const d = code[i + 1]
40 if (d === "*") {
41 kind = "comment"
42 const close = code.indexOf("*/", i + 2)
43 return close === -1 ? n : close + 2 // an unclosed comment runs to the end
44 }
45 if (d === "/" && lineComments && code[i - 1] !== ":") {
46 kind = "comment"
47 return trimCr(code, i, lineEnd(code, i))
48 }
49 } else if (c === '"' || c === "'") {
50 const end = quote(i, c)
51 if (end !== -1) {
52 kind = "string"
53 return end
54 }
55 }
56 return i
57 }
58
59 const ident = (i: number): number => {
60 let j = i
61 while (j < n && isIdent(code[j]!)) j++
62 return j
63 }
64
65 // Index of the `;`, `{` or `}` that ends the statement starting at `from`, or the text length.
66 const statementEnd = (from: number): number => {
67 let depth = 0
68 let i = from
69 while (i < n) {
70 const j = opaque(i)
71 if (j > i) {
72 i = j
73 continue
74 }
75 const c = code[i]!
76 if (c === "{" || c === "}") {
77 const before = code[i - 1]
78 if (c === "{" && i > from && (before === "#" || (less && before === "@"))) {
79 const close = code.indexOf("}", i + 1) // `#{$a}` / `@{a}` interpolation
80 if (close === -1) return n
81 i = close + 1
82 continue
83 }
84 return i
85 }
86 if (c === ";" && depth === 0) return i
87 if (c === "(") depth++
88 else if (c === ")" && depth > 0) depth--
89 i++
90 }
91 return n
92 }
93
94 // A value, or an at-rule's prelude: strings, numbers with units, colours, variables.
95 const value = (start: number, end: number, prelude = false): void => {
96 let i = start
97 while (i < end) {
98 const j = opaque(i)
99 if (j > i) {
100 push(spans, i, Math.min(j, end), kind)
101 i = j
102 continue
103 }
104 const c = code[i]!
105 if (c === "#") {
106 HEX.lastIndex = i
107 const m = HEX.exec(code)
108 if (m && i + m[0].length <= end) {
109 push(spans, i, i + m[0].length, "number")
110 i += m[0].length
111 } else i++
112 continue
113 }
114 if (c === "$" || (less && c === "@")) {
115 const e = ident(i + 1)
116 if (e > i + 1) push(spans, i, e, "literal")
117 i = Math.max(e, i + 1)
118 continue
119 }
120 if (c === "!") {
121 BANG.lastIndex = i
122 const m = BANG.exec(code)
123 if (m) {
124 push(spans, i, i + m[0].length, "keyword")
125 i += m[0].length
126 } else i++
127 continue
128 }
129 if (c === "-" && code[i + 1] === "-" && !isWord(code[i - 1])) {
130 const e = ident(i + 2)
131 if (e > i + 2) push(spans, i, e, "literal") // var(--name)
132 i = Math.max(e, i + 2)
133 continue
134 }
135 const next = code[i + 1]
136 const signed =
137 (c === "-" || c === "+") &&
138 (isDigit(next) || (next === "." && isDigit(code[i + 2]))) &&
139 !isWord(code[i - 1]) &&
140 code[i - 1] !== "."
141 if (isDigit(c) || (c === "." && isDigit(next)) || signed) {
142 const s = signed ? i + 1 : i
143 NUM.lastIndex = s
144 const m = NUM.exec(code)
145 if (m) {
146 const e = Math.min(s + m[0].length, end)
147 push(spans, i, e, "number")
148 i = e
149 } else i++
150 continue
151 }
152 if (isIdentStart(c)) {
153 const e = ident(i)
154 const word = code.slice(i, e)
155 if (e < end && code[e] === "(" && word.toLowerCase() === "url") {
156 // url(unquoted) is one string; a quoted url is left to the string rule
157 let k = e + 1
158 while (k < end && isBlank(code[k])) k++
159 if (code[k] !== '"' && code[k] !== "'") {
160 let m = k
161 while (m < end && code[m] !== ")" && code[m] !== "\n" && !isBlank(code[m])) m++
162 push(spans, k, m, "string")
163 i = m
164 continue
165 }
166 } else if (prelude && PRELUDE.has(word)) push(spans, i, e, "keyword")
167 else if (LITERALS.has(word)) push(spans, i, e, "literal")
168 i = Math.max(e, i + 1)
169 continue
170 }
171 i++
172 }
173 }
174
175 // `.class` / `#id` / `%placeholder` (type), `:pseudo` (literal), element names (keyword).
176 const selector = (start: number, end: number): void => {
177 let i = start
178 let paren = 0
179 let attr = false
180 while (i < end) {
181 const j = opaque(i)
182 if (j > i) {
183 push(spans, i, Math.min(j, end), kind)
184 i = j
185 continue
186 }
187 const c = code[i]!
188 if (attr) {
189 if (c === "]") attr = false
190 i++
191 } else if (c === "[") {
192 attr = true
193 i++
194 } else if (c === "(") {
195 paren++
196 i++
197 } else if (c === ")") {
198 if (paren > 0) paren--
199 i++
200 } else if ((c === "." || c === "#" || c === "%") && isIdentStart(code[i + 1])) {
201 const e = ident(i + 1)
202 push(spans, i, e, "type")
203 i = e
204 } else if (c === ":") {
205 const k = code[i + 1] === ":" ? i + 2 : i + 1
206 if (isIdentStart(code[k])) {
207 const e = ident(k)
208 push(spans, i, e, "literal")
209 i = e
210 } else i++
211 } else if (c === "&") {
212 i = ident(i + 1) // `&-suffix` is one piece of the selector
213 } else if (c === "$" || (less && c === "@")) {
214 const e = ident(i + 1)
215 if (e > i + 1) push(spans, i, e, "literal")
216 i = Math.max(e, i + 1)
217 } else if (isDigit(c)) {
218 NUM.lastIndex = i
219 const m = NUM.exec(code)
220 const e = m ? Math.min(i + m[0].length, end) : i + 1
221 push(spans, i, e, "number") // keyframe `50%`, `:nth-child(2n+1)`
222 i = e
223 } else if (isIdentStart(c)) {
224 const e = ident(i)
225 if (paren === 0) push(spans, i, e, "keyword")
226 i = e
227 } else i++
228 }
229 }
230
231 const statement = (start: number, end: number, block: boolean): void => {
232 if (code[start] === "@" && code[start + 1] !== "{") {
233 const e = ident(start + 1)
234 if (e > start + 1) {
235 let m = e
236 while (m < end && isBlank(code[m])) m++
237 const name = code.slice(start + 1, e).toLowerCase()
238 if (less && (code[m] === ":" || !LESS_AT.has(name))) {
239 push(spans, start, e, "literal") // `@var: 1px`, `@arguments`
240 value(code[m] === ":" ? m + 1 : e, end)
241 } else {
242 push(spans, start, e, "keyword")
243 value(e, end, true)
244 }
245 return
246 }
247 }
248 if (block) {
249 selector(start, end)
250 return
251 }
252 let j = start
253 while (j < end && (isWord(code[j]) || code[j] === "-" || code[j] === "$" || code[j] === "@" || code[j] === "*")) j++
254 if (j > start) {
255 let k = j
256 while (k < end && isBlank(code[k])) k++
257 if (k < end && code[k] === ":" && code[k + 1] !== ":") {
258 const first = code[start]
259 const variable = first === "$" || first === "@" || (first === "-" && code[start + 1] === "-")
260 push(spans, start, j, variable ? "literal" : "type")
261 value(k + 1, end)
262 return
263 }
264 }
265 value(start, end)
266 }
267
268 let i = 0
269 while (i < n) {
270 const c = code[i]!
271 if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === ";" || c === "{" || c === "}") {
272 i++
273 continue
274 }
275 const j = opaque(i)
276 if (j > i) {
277 push(spans, i, j, kind)
278 i = j
279 continue
280 }
281 const end = statementEnd(i)
282 statement(i, end, code[end] === "{")
283 i = end > i ? end : i + 1
284 }
285 return spans
286}
287hooks/lib/highlight-markup.ts 340 lines1// Scanners for the markup languages: HTML / XML and Markdown. Same contract as `highlight.ts`
2// (source text in, ordered non-overlapping spans out, one linear pass, never throws), but these
3// two are not "keywords, strings and numbers" languages, so they get their own small scanners.
4
5import type { Span } from "./highlight"
6import { isAlpha, isBlank, isDigit, isWord, lineEnd, push, trimCr } from "./highlight-util"
7
8const ENTITY = /&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});/y
9// Text inside <script> / <style> is not markup: skip to the closing tag so `a<b` is not a tag.
10const RAW_END: Record<string, RegExp> = { script: /<\/script/gi, style: /<\/style/gi }
11
12const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f"
13const isNameChar = (c: string): boolean => isWord(c) || c === "-" || c === ":" || c === "." || c > "\x7f"
14const isAttrEnd = (c: string): boolean =>
15 isSpace(c) || c === '"' || c === "'" || c === ">" || c === "/" || c === "=" || c === "<"
16
17/**
18 * HTML / XML: tag names (keyword), attribute names (type), attribute values (string), comments,
19 * `<!DOCTYPE>` / `<?xml?>` (meta), CDATA (string) and entities (literal). `<` opens a tag only
20 * when a letter, `/`, `!` or `?` follows and a `>` comes later; text between tags is left alone.
21 */
22export function highlightMarkup(code: string): Span[] {
23 const spans: Span[] = []
24 const n = code.length
25 const lastGt = code.lastIndexOf(">")
26 let raw = null as string | null // set by `tag` for an opening <script> / <style>
27 let i = 0
28
29 const quoted = (open: number): number => {
30 const close = code.indexOf(code[open]!, open + 1)
31 const end = close === -1 ? n : close + 1
32 push(spans, open, end, "string")
33 return end
34 }
35
36 // One tag from its `<`. Returns where to resume: past the `>`, or at a `<` that broke the tag.
37 const tag = (start: number): number => {
38 raw = null
39 let j = start + 1
40 const closing = code[j] === "/"
41 if (closing) j++
42 const nameStart = j
43 while (j < n && isNameChar(code[j]!)) j++
44 push(spans, nameStart, j, "keyword")
45 const name = code.slice(nameStart, j).toLowerCase()
46 let selfClosed = false
47 let ended = false
48 while (j < n) {
49 const c = code[j]!
50 if (c === ">") {
51 j++
52 ended = true
53 break
54 }
55 if (c === "<") break
56 if (c === "/") {
57 if (code[j + 1] === ">") {
58 selfClosed = true
59 ended = true
60 j += 2
61 break
62 }
63 j++
64 continue
65 }
66 if (isSpace(c) || c === "=") {
67 j++
68 continue
69 }
70 if (c === '"' || c === "'") {
71 j = quoted(j)
72 continue
73 }
74 const attr = j
75 while (j < n && !isAttrEnd(code[j]!)) j++
76 push(spans, attr, j, "type")
77 let k = j
78 while (k < n && isSpace(code[k]!)) k++
79 if (code[k] === "=") {
80 k++
81 while (k < n && isSpace(code[k]!)) k++
82 const q = code[k]
83 if (q === '"' || q === "'") j = quoted(k)
84 else {
85 const value = k
86 while (k < n && !isSpace(code[k]!) && code[k] !== ">") k++
87 push(spans, value, k, "string")
88 j = k
89 }
90 }
91 }
92 if (ended && !closing && !selfClosed && (name === "script" || name === "style")) raw = name
93 return j
94 }
95
96 while (i < n) {
97 const c = code[i]!
98 if (c === "<") {
99 const next = code[i + 1]
100 if (next === "!" && code.startsWith("<!--", i)) {
101 const close = code.indexOf("-->", i + 4)
102 const end = close === -1 ? n : close + 3 // an unclosed comment runs to the end
103 push(spans, i, end, "comment")
104 i = end
105 continue
106 }
107 if ((next === "!" || next === "?") && i < lastGt) {
108 if (code.startsWith("<![CDATA[", i)) {
109 const close = code.indexOf("]]>", i + 9)
110 const end = close === -1 ? n : close + 3
111 push(spans, i, end, "string")
112 i = end
113 continue
114 }
115 const close = code.indexOf(next === "?" ? "?>" : ">", i + 2)
116 const end = close === -1 ? n : close + (next === "?" ? 2 : 1)
117 push(spans, i, end, "meta")
118 i = end
119 continue
120 }
121 if ((next === "/" || isAlpha(next)) && i < lastGt) {
122 i = tag(i)
123 if (raw !== null) {
124 const re = RAW_END[raw]!
125 re.lastIndex = i
126 const m = re.exec(code)
127 i = m ? m.index : n
128 }
129 continue
130 }
131 i++
132 continue
133 }
134 if (c === "&") {
135 ENTITY.lastIndex = i
136 const m = ENTITY.exec(code)
137 if (m) {
138 push(spans, i, i + m[0].length, "literal")
139 i += m[0].length
140 continue
141 }
142 }
143 i++
144 }
145 return spans
146}
147
148/**
149 * Markdown, kept modest: headings (keyword); fence lines, list markers, `>` quotes, rules and
150 * the `](` of links' urls (string) as markers; inline code (string); `<!-- -->` comments, which
151 * may run over lines. The inside of a fenced block is left plain, so a `# comment` in it is not
152 * a heading. Emphasis, tables and raw HTML are not touched.
153 */
154export function highlightMarkdown(code: string): Span[] {
155 const spans: Span[] = []
156 const n = code.length
157 let fence = null as { ch: string; len: number } | null
158
159 const indent = (from: number, end: number): number => {
160 let p = from
161 while (p < end && p - from < 3 && code[p] === " ") p++
162 return p
163 }
164
165 // Backtick runs pair with the next run of exactly the same length: [start, end, start, end, ...].
166 const codeRanges = (from: number, end: number): number[] => {
167 const starts: number[] = []
168 const lens: number[] = []
169 for (let j = from; j < end; ) {
170 if (code[j] !== "`") {
171 j++
172 continue
173 }
174 let k = j + 1
175 while (k < end && code[k] === "`") k++
176 starts.push(j)
177 lens.push(k - j)
178 j = k
179 }
180 const nextSame: number[] = new Array<number>(starts.length).fill(-1)
181 const last = new Map<number, number>()
182 for (let r = starts.length - 1; r >= 0; r--) {
183 nextSame[r] = last.get(lens[r]!) ?? -1
184 last.set(lens[r]!, r)
185 }
186 const out: number[] = []
187 for (let r = 0; r < starts.length; ) {
188 const m = nextSame[r]!
189 if (m === -1) r++
190 else {
191 out.push(starts[r]!, starts[m]! + lens[m]!)
192 r = m + 1
193 }
194 }
195 return out
196 }
197
198 // Block-level markers of the line `[i, end)`. Returns where inline text starts, or -1 when the
199 // whole line is done (heading, rule, fence).
200 const block = (i: number, end: number): number => {
201 let p = indent(i, end)
202 const fc = code[p]
203 if (fc === "`" || fc === "~") {
204 let k = p
205 while (k < end && code[k] === fc) k++
206 let ticks = false
207 if (fc === "`") for (let m = k; m < end && !ticks; m++) ticks = code[m] === "`"
208 if (k - p >= 3 && !ticks) {
209 push(spans, p, end, "meta")
210 fence = { ch: fc, len: k - p }
211 return -1
212 }
213 }
214 let q = i
215 for (;;) {
216 const r = indent(q, end)
217 if (code[r] !== ">") break
218 push(spans, r, r + 1, "meta")
219 q = r + 1
220 if (code[q] === " ") q++
221 }
222 p = indent(q, end)
223 const c = code[p]
224 if (c === "#") {
225 let k = p
226 while (k < end && code[k] === "#") k++
227 if (k - p <= 6 && (k === end || isBlank(code[k]))) {
228 push(spans, p, end, "keyword")
229 return -1
230 }
231 }
232 if (c === "-" || c === "*" || c === "_") {
233 let count = 0
234 let k = p
235 while (k < end && (code[k] === c || isBlank(code[k]))) {
236 if (code[k] === c) count++
237 k++
238 }
239 if (k === end && count >= 3) {
240 push(spans, p, end, "meta")
241 return -1
242 }
243 }
244 let l = q
245 while (l < end && isBlank(code[l])) l++
246 const lc = code[l]
247 if (lc === "-" || lc === "*" || lc === "+") {
248 if (l + 1 === end || isBlank(code[l + 1])) {
249 push(spans, l, l + 1, "meta")
250 return l + 1
251 }
252 } else if (isDigit(lc)) {
253 let k = l
254 while (k < end && k - l < 9 && isDigit(code[k])) k++
255 if ((code[k] === "." || code[k] === ")") && (k + 1 === end || isBlank(code[k + 1]))) {
256 push(spans, l, k + 1, "meta")
257 return k + 1
258 }
259 }
260 return q
261 }
262
263 // Inline text of `[from, end)`. Returns `end`, or further when a comment ran over lines.
264 const inline = (from: number, end: number): number => {
265 const ranges = codeRanges(from, end)
266 let rp = 0
267 let bracket = false
268 let j = from
269 while (j < end) {
270 while (rp < ranges.length && ranges[rp]! < j) rp += 2
271 if (rp < ranges.length && ranges[rp] === j) {
272 push(spans, j, ranges[rp + 1]!, "string")
273 j = ranges[rp + 1]!
274 rp += 2
275 continue
276 }
277 const c = code[j]!
278 if (c === "\\") {
279 j += 2
280 continue
281 }
282 if (c === "<" && code.startsWith("<!--", j)) {
283 const close = code.indexOf("-->", j + 4)
284 const stop = close === -1 ? n : close + 3
285 push(spans, j, stop, "comment")
286 if (stop > end) return stop
287 j = stop
288 continue
289 }
290 if (c === "[") bracket = true
291 else if (c === "]" && bracket && code[j + 1] === "(") {
292 let k = j + 2
293 while (k < end && code[k] !== ")" && !isBlank(code[k])) k++
294 push(spans, j + 2, k, "string")
295 j = k
296 continue
297 }
298 j++
299 }
300 return end
301 }
302
303 let i = 0
304 let mid = false // `i` is inside a line: an HTML comment just ended there
305 while (i < n) {
306 const nl = lineEnd(code, i)
307 const end = trimCr(code, i, nl)
308 let from = i
309 if (!mid) {
310 if (fence !== null) {
311 const p = indent(i, end)
312 let k = p
313 while (k < end && code[k] === fence.ch) k++
314 let m = k
315 while (m < end && isBlank(code[m])) m++
316 if (k - p >= fence.len && m === end) {
317 push(spans, p, end, "meta")
318 fence = null
319 }
320 i = nl + 1
321 continue
322 }
323 from = block(i, end)
324 if (from === -1) {
325 i = nl + 1
326 continue
327 }
328 }
329 const stop = inline(from, end)
330 if (stop > nl) {
331 i = stop
332 mid = true
333 } else {
334 i = nl + 1
335 mid = false
336 }
337 }
338 return spans
339}
340hooks/lib/highlight-util.ts 67 lines1// Small shared helpers of the highlighters (`highlight.ts` and the `highlight-*.ts` scanners):
2// character classes, an order-keeping span push and a single-line string scanner that never
3// rescans a line it already failed on. Pure, no `$`, no UI, no I/O; nothing here throws.
4
5import type { Span, TokenKind } from "./highlight"
6
7export const set = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/).filter(Boolean))
8
9export const isDigit = (c: string | undefined): boolean => c !== undefined && c >= "0" && c <= "9"
10export const isAlpha = (c: string | undefined): boolean =>
11 c !== undefined && ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z"))
12export const isWord = (c: string | undefined): boolean => isAlpha(c) || isDigit(c) || c === "_"
13export const isBlank = (c: string | undefined): boolean => c === " " || c === "\t"
14/** A blank, a line break or the start of the text: what may sit before a `#` comment. */
15export const isBoundary = (c: string | undefined): boolean => c === undefined || c === "\n" || c === "\r" || isBlank(c)
16
17/** Append `[start, end)`; a span that would overlap the previous one is cut, an empty one dropped. */
18export function push(spans: Span[], start: number, end: number, kind: TokenKind): void {
19 const last = spans[spans.length - 1]
20 const from = last && last.end > start ? last.end : start
21 if (end > from) spans.push({ start: from, end, kind })
22}
23
24/** Index of the `\n` that ends the line holding `from`, or the text length. */
25export function lineEnd(code: string, from: number): number {
26 const nl = code.indexOf("\n", from)
27 return nl === -1 ? code.length : nl
28}
29
30/** End of a line's visible text: a trailing `\r` is not painted. */
31export function trimCr(code: string, from: number, end: number): number {
32 return end > from && code[end - 1] === "\r" ? end - 1 : end
33}
34
35/** Index just past the closing quote, or -1 when the string does not close (on the line). */
36export function scanString(code: string, open: number, quote: string, multiline: boolean, escapes = true): number {
37 for (let i = open + 1; i < code.length; i++) {
38 const c = code[i]!
39 if (escapes && c === "\\") {
40 i++
41 continue
42 }
43 if (c === quote) return i + 1
44 if (c === "\n" && !multiline) return -1
45 }
46 return -1
47}
48
49/**
50 * `scanString` for one text, with a memo. Once a quote fails to close, every later quote of the
51 * same kind up to the end of that line fails too (the escapes line up the same way), so the
52 * line is not scanned again: a long line of unmatched quotes stays linear.
53 */
54export type QuoteScanner = (open: number, quote: string, escapes?: boolean) => number
55
56export function quoteScanner(code: string): QuoteScanner {
57 const failed = new Map<string, [from: number, until: number]>()
58 return (open, quote, escapes = true) => {
59 const key = escapes ? quote : quote + "!"
60 const memo = failed.get(key)
61 if (memo && open >= memo[0] && open < memo[1]) return -1
62 const end = scanString(code, open, quote, false, escapes)
63 if (end === -1) failed.set(key, [open, lineEnd(code, open)])
64 return end
65 }
66}
67