SLOPSHOPPER

md-prompt

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…

newcommandprompt
★ 7v0.2.0MITupdated 2026-10-03nogu66/md-prompt/plugins/md-prompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md-prompt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /md-prompt ⎿ md-prompt: on — code, emphasis, links, headings, lists, tables and quotes are painted ⎿ md-prompt: /md-prompt on | off | toggle ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

md-prompt

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.

What the hooks do

The plugin is one hooks module, hooks/register.tsx:

HookWhat it does
prompt.editAfter 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.fillWhen the whole draft is replaced (a plugin or the engine filling the box), adds the same style runs to the new draft
session.startRegisters 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.

Settings it changes

  • 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.
  • Environment variables: none. On Claude Code 2.1.285 and 2.1.286 you turn on function hooks yourself with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; the plugin does not set it.
Source 11 files
hooks/register.tsx 83 lines
1// 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}
83
hooks/lib/mode.ts 65 lines
1// 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}
65
hooks/lib/mdprompt.ts 42 lines
1// 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}
42
hooks/lib/blocks.ts 691 lines
1// 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}
691
hooks/lib/palette.ts 62 lines
1// 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  /** `&amp;` `&#39;` */
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
62
hooks/lib/highlight.ts 517 lines
1// 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}
517
hooks/lib/inline.ts 685 lines
1// 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}
685
hooks/lib/highlight-config.ts 506 lines
1// 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}
506
hooks/lib/highlight-css.ts 287 lines
1// 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}
287
hooks/lib/highlight-markup.ts 340 lines
1// 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}
340
hooks/lib/highlight-util.ts 67 lines
1// 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