SLOPSHOPPER

glamour-dark

Draws the model's text replies with charmbracelet/glamour's default dark style

newrows
v0.1.0MITupdated 2026-10-09binbandit/bin-stack/mods/glamour-dark
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glamour-dark
› 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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

bin-stack

A harness-agnostic agent skill stack.

Skills follow the Agent Skills format (SKILL.md + optional scripts/references/assets). One source of truth under skills/. Works with Cursor, Claude Code, Codex, and any other agent that discovers skills the standard way.

Inspired by stacks like pstack, but not locked to a single product.

Install

Via skills CLI

npx skills add binbandit/bin-stack

Pick specific skills:

npx skills add binbandit/bin-stack --skill file-pr --skill babysit-pr --skill arena --skill bro

Symlink a clone (one copy, every harness)

git clone https://github.com/binbandit/bin-stack.git ~/Developer/ai/bin-stack
cd ~/Developer/ai/bin-stack
./scripts/link-skills.sh

That links each skill directory into the harness roots you already have (~/.agents/skills, ~/.cursor/skills, ~/.claude/skills, ~/.codex/skills, …). Edit here; every harness sees the same files.

Skills

SkillWhen to use
file-prFile or update a concise pull request
babysit-prMonitor a PR through review bots and CI
arenaRun competing attempts, then graft the strongest ideas into one result
broRestate the last message in plain language
verifyProve a behavior change on the user surface with revision-bound evidence
reviewFilter adversarial findings by reachable failure and concrete evidence

Mods

Claude Code mods: plugins of function hooks that change how Claude Code looks and behaves. They are Claude Code only.

ModWhat it does
glamour-darkDraws replies as rich markdown in glamour's dark style, one sentence per line
marathon-footerRestyles the hints after the mode label under the prompt to match the SLOPOTHON banner: ⏵⏵ auto mode on · ⇧⇥ CYCLE // ← 1 AGENT
redact-secretsSwaps secrets for placeholders before Claude reads them or the session logs them; Write and Edit put the real values back
tool-linesDraws each tool call as one compact line and hides its result block
ascii-spinnerSwaps the working line (verb, timer, tokens, effort, tip) for a one-row WEAVEworm rethreading synthsilk, in Sekiguchi colors
slopothonPaints a SLOPOTHON lockup over the Claude logo in Marathon's design language: block wordmark, a dot-matrix card naming the model and branch, and a livery strip with a tagline and barcode per session. Fullscreen ("tui": "fullscreen") only; it starts each session with a /slopothon row and the logo shows for ~130ms first. Type /slopothon to redraw it inline

Install every mod at user scope:

./scripts/install-mods.sh

The script adds mods/ as the bin-stack marketplace and installs each mod from it. Claude Code reads the mods in place from this clone, so an edit here applies on the next session or /reload-plugins. Re-run it after adding a mod.

Layout

skills/
  file-pr/SKILL.md
  babysit-pr/SKILL.md
  arena/SKILL.md
  bro/SKILL.md
  verify/SKILL.md
  review/SKILL.md
mods/
  .claude-plugin/marketplace.json
  glamour-dark/
  marathon-footer/
  redact-secrets/
  tool-lines/
  ascii-spinner/
  slopothon/
scripts/
  link-skills.sh   # optional: symlink into local harness skill roots
  install-mods.sh  # install the Claude Code mods

Canonical skills live in skills/. Harness-specific paths are adapters (symlinks or CLI installs), not forks of the content.

Authoring

  1. Add skills/<name>/SKILL.md with required name and description frontmatter.
  2. Keep name matching the directory. Lowercase, hyphens only.
  3. Put long reference material in references/; keep SKILL.md short enough to load on activation.
  4. Prefer harness-neutral wording. Call out Cursor / Claude / Codex only when a step truly differs.

See agentskills.io/specification.

License

MIT

The verify and review skills adapt selected pstack guidance by Lauren Tan at revision 9f451cf875ad1239912762f67741e8e5ba6ac0f1. Each skill bundles its provenance and the original MIT notice in LICENSE-pstack so attribution travels with individual skill installs.

Source 6 files
hooks/register.ts 46 lines
1import type { Register } from 'claude-code'
2import { cascade, layout, type Line, type Paint, type Span } from './layout'
3import { parse, type Block } from './markdown'
4import { splitSentences } from './sentences'
5import { DARK, type StyleConfig } from './style'
6
7const REPLY_STYLE: StyleConfig = DARK
8const REWRITES_BEFORE_LAYOUT: ((blocks: Block[]) => Block[])[] = [splitSentences]
9const ENGINE_EDGE_COLUMNS = 2
10const UNMEASURED_TERMINAL_COLUMNS = 120
11const BULLET = '●'
12const BLANK_ROW_TEXT = ' '
13
14function withBulletInMargin(lines: Line[], paint: Paint): Line[] {
15  const [first, ...rest] = lines
16  const leftMargin = first?.[0]
17  if (first === undefined || leftMargin === undefined || !leftMargin.text.startsWith('  ')) return lines
18  return [[{ text: BULLET, paint }, { ...leftMargin, text: leftMargin.text.slice(1) }, ...first.slice(1)], ...rest]
19}
20
21const newlinesIn = (text = '') => text.split('\n').length - 1
22
23export const register: Register = on => {
24  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
25    if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
26    const blocks = REWRITES_BEFORE_LAYOUT.reduce((tree, rewrite) => rewrite(tree), parse(e.props.text))
27    if (blocks.length === 0) return next(e)
28
29    const { Box, Text, Link } = $.ui.resolve(e)
30    const columns = (e.viewport?.columns ?? UNMEASURED_TERMINAL_COLUMNS) - ENGINE_EDGE_COLUMNS
31    const laidOut = layout(blocks, REPLY_STYLE, columns)
32    const lines = e.props.isFirstOfReply ? withBulletInMargin(laidOut, cascade({}, REPLY_STYLE.document)) : laidOut
33
34    const drawSpan = (span: Span) => {
35      const text = Text({ ...span.paint, children: [span.text] })
36      return span.href === undefined ? text : Link({ href: span.href, children: [text] })
37    }
38    return Box({
39      flexDirection: 'column',
40      marginTop: newlinesIn(REPLY_STYLE.document.block_prefix),
41      marginBottom: newlinesIn(REPLY_STYLE.document.block_suffix),
42      children: lines.map(line => Text({ children: line.length === 0 ? [BLANK_ROW_TEXT] : line.map(drawSpan) })),
43    })
44  })
45}
46
hooks/layout.ts 371 lines
1import { highlight } from './highlight'
2import type { Align, Block, Inline, ListItem } from './markdown'
3import type { ChromaToken, StyleBlock, StyleColor, StyleConfig, StylePrimitive } from './style'
4
5export type Paint = {
6  color?: string
7  backgroundColor?: string
8  bold?: boolean
9  italic?: boolean
10  underline?: boolean
11  strikethrough?: boolean
12  inverse?: boolean
13  dimColor?: boolean
14}
15
16export type Span = { text: string; paint: Paint; href?: string }
17
18export type Line = Span[]
19
20const XTERM_256_INDEX = /^\d{1,3}$/
21const TEMPLATE_TEXT_FIELD = /\{\{\s*\.text\s*\}\}/g
22const NO_BREAK_SPACE = ' '
23const MIN_COLUMN_WIDTH = 3
24
25function colorOf(color: StyleColor): string {
26  return XTERM_256_INDEX.test(color) ? `ansi256(${color})` : color
27}
28
29export function cascade(parent: Paint, style: StylePrimitive): Paint {
30  const paint = { ...parent }
31  if (style.color !== undefined) paint.color = colorOf(style.color)
32  if (style.background_color !== undefined) paint.backgroundColor = colorOf(style.background_color)
33  if (style.bold !== undefined) paint.bold = style.bold
34  if (style.italic !== undefined) paint.italic = style.italic
35  if (style.underline !== undefined) paint.underline = style.underline
36  if (style.crossed_out !== undefined) paint.strikethrough = style.crossed_out
37  if (style.inverse !== undefined) paint.inverse = style.inverse
38  if (style.faint !== undefined) paint.dimColor = style.faint
39  return paint
40}
41
42function decorate(style: StylePrimitive, token: string): string {
43  const formatted = style.format?.replace(TEMPLATE_TEXT_FIELD, token) ?? token
44  return [style.block_prefix, style.prefix, formatted, style.suffix, style.block_suffix].join('')
45}
46
47type CodePointRange = [low: number, high: number]
48
49const ZERO_WIDTH: CodePointRange[] = [[0x300, 0x36f], [0x200b, 0x200f], [0xfe00, 0xfe0f]]
50
51const DOUBLE_WIDTH: CodePointRange[] = [
52  [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
53  [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
54  [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
55]
56
57const isWithin = (code: number, ranges: CodePointRange[]) => ranges.some(([low, high]) => code >= low && code <= high)
58
59function charCells(ch: string): number {
60  const code = ch.codePointAt(0) ?? 0
61  if (isWithin(code, ZERO_WIDTH)) return 0
62  return isWithin(code, DOUBLE_WIDTH) ? 2 : 1
63}
64
65function cellsOf(text: string): number {
66  let cells = 0
67  for (const ch of text) cells += charCells(ch)
68  return cells
69}
70
71function spansCells(spans: Span[]): number {
72  return spans.reduce((cells, span) => cells + cellsOf(span.text), 0)
73}
74
75type Word = { parts: Span[]; cells: number }
76type Piece = { kind: 'word'; word: Word } | { kind: 'space'; span: Span } | { kind: 'newline' }
77
78function piecesOf(spans: Span[]): Piece[] {
79  const pieces: Piece[] = []
80  for (const span of spans) {
81    for (const text of span.text.split(/(\n| +)/)) {
82      if (text === '') continue
83      const part = { ...span, text }
84      const previous = pieces[pieces.length - 1]
85      if (text === '\n') pieces.push({ kind: 'newline' })
86      else if (text.startsWith(' ')) pieces.push({ kind: 'space', span: part })
87      else if (previous?.kind === 'word') {
88        previous.word.parts.push(part)
89        previous.word.cells += cellsOf(text)
90      } else pieces.push({ kind: 'word', word: { parts: [part], cells: cellsOf(text) } })
91    }
92  }
93  return pieces
94}
95
96function samePaint(a: Paint, b: Paint): boolean {
97  const keys = new Set([...Object.keys(a), ...Object.keys(b)] as (keyof Paint)[])
98  return [...keys].every(key => a[key] === b[key])
99}
100
101const looksTheSame = (a: Span, b: Span) => a.href === b.href && samePaint(a.paint, b.paint)
102
103function append(line: Line, from: Span, text: string): void {
104  const last = line[line.length - 1]
105  if (last !== undefined && looksTheSame(last, from)) last.text += text
106  else line.push({ ...from, text })
107}
108
109function wrap(spans: Span[], width: number): Line[] {
110  const lines: Line[] = [[]]
111  let used = 0
112  let pendingGap: Span | undefined
113  let keepsLeadingSpaces = true
114  const startLine = ({ keepLeadingSpaces }: { keepLeadingSpaces: boolean }) => {
115    lines.push([])
116    used = 0
117    pendingGap = undefined
118    keepsLeadingSpaces = keepLeadingSpaces
119  }
120  const put = (part: Span, text = part.text) => {
121    append(lines[lines.length - 1] as Line, part, text)
122    used += cellsOf(text)
123  }
124  for (const piece of piecesOf(spans)) {
125    if (piece.kind === 'newline') {
126      startLine({ keepLeadingSpaces: true })
127      continue
128    }
129    if (piece.kind === 'space') {
130      if (used > 0) pendingGap = pendingGap === undefined ? piece.span : { ...pendingGap, text: pendingGap.text + piece.span.text }
131      else if (keepsLeadingSpaces) put(piece.span)
132      continue
133    }
134    const { word } = piece
135    const gapCells = pendingGap === undefined ? 0 : cellsOf(pendingGap.text)
136    if (used > 0 && used + gapCells + word.cells > width) startLine({ keepLeadingSpaces: false })
137    else if (pendingGap !== undefined) put(pendingGap)
138    pendingGap = undefined
139    if (word.cells <= width - used) {
140      for (const part of word.parts) put(part)
141      continue
142    }
143    for (const part of word.parts) {
144      for (const ch of part.text) {
145        if (used > 0 && used + charCells(ch) > width) startLine({ keepLeadingSpaces: false })
146        put(part, ch)
147      }
148    }
149  }
150  return lines
151}
152
153function cutAtEdge(line: Line, width: number): Line[] {
154  const lines: Line[] = [[]]
155  let used = 0
156  for (const span of line) {
157    for (const ch of span.text) {
158      const cells = charCells(ch)
159      if (used > 0 && used + cells > width) {
160        lines.push([])
161        used = 0
162      }
163      append(lines[lines.length - 1] as Line, span, ch)
164      used += cells
165    }
166  }
167  return lines
168}
169
170type InlineDrawer<K extends Inline['kind']> = (node: Extract<Inline, { kind: K }>, paint: Paint, style: StyleConfig) => Span[]
171
172function inlineSpans(nodes: Inline[], paint: Paint, style: StyleConfig): Span[] {
173  return nodes.flatMap(node => (INLINE_DRAWERS[node.kind] as InlineDrawer<typeof node.kind>)(node as never, paint, style))
174}
175
176const withNoBreakSpaces = (text = '') => text.replaceAll(' ', NO_BREAK_SPACE)
177
178const INLINE_DRAWERS: { [K in Inline['kind']]: InlineDrawer<K> } = {
179  text: (node, paint, style) => [{ text: node.text, paint: cascade(paint, style.text) }],
180  emph: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.emph), style),
181  strong: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.strong), style),
182  strike: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.strikethrough), style),
183  code: (node, paint, style) => [
184    {
185      text: withNoBreakSpaces(style.code.prefix) + node.text + withNoBreakSpaces(style.code.suffix),
186      paint: cascade(paint, style.code),
187    },
188  ],
189  link: (node, paint, style) => [
190    ...inlineSpans(node.children, cascade(paint, style.link_text), style).map(span => ({ ...span, href: node.href })),
191    { text: ' ', paint },
192    { text: node.href, paint: cascade(paint, style.link), href: node.href },
193  ],
194  autolink: (node, paint, style) => [{ text: node.href, paint: cascade(paint, style.link), href: node.href }],
195  image: (node, paint, style) => [
196    ...(node.alt === '' ? [] : [{ text: decorate(style.image_text, node.alt), paint: cascade(paint, style.image_text) }, { text: ' ', paint }]),
197    { text: node.href, paint: cascade(paint, style.image), href: node.href },
198  ],
199  break: (_, paint) => [{ text: '\n', paint }],
200}
201
202type Context = { style: StyleConfig; width: number; paint: Paint; depth: number }
203
204type BlockDrawer<K extends Block['kind']> = (block: Extract<Block, { kind: K }>, context: Context) => Line[]
205
206function drawBlock(block: Block, context: Context): Line[] {
207  return (BLOCK_DRAWERS[block.kind] as BlockDrawer<typeof block.kind>)(block as never, context)
208}
209
210function drawBlocks(blocks: Block[], context: Context, gap: number): Line[] {
211  return blocks.flatMap((block, i) => [
212    ...Array.from({ length: i > 0 ? gap : 0 }, (): Line => []),
213    ...drawBlock(block, context),
214  ])
215}
216
217function framed(rules: StyleBlock, context: Context, draw: (inner: Context) => Line[]): Line[] {
218  const margin = rules.margin ?? 0
219  const indent = (rules.indent_token ?? ' ').repeat(rules.indent ?? 0)
220  const lead = ' '.repeat(margin) + indent
221  const width = Math.max(context.width - margin * 2 - cellsOf(indent), 1)
222  const lines = draw({ ...context, width, paint: cascade(context.paint, rules) })
223  return lead === '' ? lines : lines.map(line => [{ text: lead, paint: context.paint }, ...line])
224}
225
226const CHROMA_PARENT: Partial<Record<ChromaToken, ChromaToken>> = {
227  keyword_reserved: 'keyword',
228  keyword_namespace: 'keyword',
229  keyword_type: 'keyword',
230  comment_preproc: 'comment',
231  name_builtin: 'name',
232  name_tag: 'name',
233  name_attribute: 'name',
234  name_class: 'name',
235  name_constant: 'name',
236  name_decorator: 'name',
237  name_exception: 'name',
238  name_function: 'name',
239  name_other: 'name',
240  literal_number: 'literal',
241  literal_date: 'literal',
242  literal_string: 'literal',
243  literal_string_escape: 'literal_string',
244}
245
246function lineageOf(kind: ChromaToken): ChromaToken[] {
247  const parent = CHROMA_PARENT[kind]
248  return parent === undefined ? [kind] : [...lineageOf(parent), kind]
249}
250
251function markerOf(list: Extract<Block, { kind: 'list' }>, item: ListItem, index: number, context: Context): Span {
252  const { style, paint } = context
253  if (item.checked !== undefined) {
254    return { text: decorate(style.task, (item.checked ? style.task.ticked : style.task.unticked) ?? ''), paint: cascade(paint, style.task) }
255  }
256  if (list.ordered) return { text: `${list.start + index}` + decorate(style.enumeration, ''), paint: cascade(paint, style.enumeration) }
257  return { text: decorate(style.item, ''), paint: cascade(paint, style.item) }
258}
259
260function itemLines(item: ListItem, marker: Span, context: Context): Line[] {
261  const markerCells = cellsOf(marker.text)
262  const hangingIndent = { text: ' '.repeat(markerCells), paint: context.paint }
263  const textContext = { ...context, width: Math.max(context.width - markerCells, 1) }
264  const lines: Line[] = []
265  for (const block of item.blocks) {
266    const isNestedList = block.kind === 'list' && lines.length > 0
267    if (isNestedList) {
268      lines.push(...drawBlock(block, context))
269      continue
270    }
271    for (const line of drawBlock(block, textContext)) lines.push([lines.length === 0 ? marker : hangingIndent, ...line])
272  }
273  return lines.length === 0 ? [[marker]] : lines
274}
275
276function aligned(line: Line, width: number, align: Align, paint: Paint): Line {
277  const room = Math.max(width - spansCells(line), 0)
278  const before = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
279  const pad = (cells: number): Line => (cells > 0 ? [{ text: ' '.repeat(cells), paint }] : [])
280  return [...pad(before), ...line, ...pad(room - before)]
281}
282
283function shrinkWidestColumnsToFit(natural: number[], room: number): number[] {
284  const widths = [...natural]
285  while (widths.reduce((sum, w) => sum + w, 0) > room) {
286    const widest = widths.indexOf(Math.max(...widths))
287    if ((widths[widest] ?? 0) <= MIN_COLUMN_WIDTH) break
288    widths[widest] = (widths[widest] ?? 0) - 1
289  }
290  return widths
291}
292
293const BLOCK_DRAWERS: { [K in Block['kind']]: BlockDrawer<K> } = {
294  paragraph: (block, context) => wrap(inlineSpans(block.content, cascade(context.paint, context.style.paragraph), context.style), context.width),
295
296  heading: (block, context) => {
297    const { style } = context
298    const level = style[`h${block.level}` as 'h1'] ?? {}
299    const rules = { ...style.heading, ...level }
300    const paint = cascade(cascade(context.paint, style.heading), level)
301    const lines = wrap([{ text: rules.prefix ?? '', paint }, ...inlineSpans(block.content, paint, style)], context.width)
302    const lastLine = lines[lines.length - 1]
303    if (rules.suffix && lastLine !== undefined) append(lastLine, { text: rules.suffix, paint }, rules.suffix)
304    return lines
305  },
306
307  code: (block, context) =>
308    framed(context.style.code_block, context, inner => {
309      const { chroma } = context.style.code_block
310      const base = chroma === undefined ? inner.paint : [chroma.background, chroma.text].reduce<Paint>((paint, rules) => cascade(paint, rules ?? {}), {})
311      const tokens = chroma === undefined ? [{ text: block.text, kind: 'text' as const }] : highlight(block.text, block.language)
312      const lines: Line[] = [[]]
313      for (const token of tokens) {
314        const paint = chroma === undefined ? base : lineageOf(token.kind).reduce((p, kind) => cascade(p, chroma[kind] ?? {}), base)
315        token.text.split('\n').forEach((text, i) => {
316          if (i > 0) lines.push([])
317          if (text !== '') append(lines[lines.length - 1] as Line, { text, paint }, text)
318        })
319      }
320      return lines.flatMap(line => cutAtEdge(line, inner.width))
321    }),
322
323  quote: (block, context) => framed(context.style.block_quote, context, inner => drawBlocks(block.blocks, inner, 1)),
324
325  list: (block, context) => {
326    const { list } = context.style
327    const isNested = context.depth > 0
328    const rules = { ...list, indent: isNested ? list.level_indent ?? 0 : list.indent ?? 0 }
329    return framed(rules, { ...context, depth: context.depth + 1 }, inner =>
330      block.items.flatMap((item, i) => itemLines(item, markerOf(block, item, i, inner), inner)),
331    )
332  },
333
334  hr: (_, context) => {
335    const rule = decorate(context.style.hr, '').replace(/^\n+|\n+$/g, '')
336    const paint = cascade(context.paint, context.style.hr)
337    return rule.split('\n').map(text => [{ text, paint }])
338  },
339
340  table: (block, context) => {
341    const { style } = context
342    const paint = cascade(context.paint, style.table)
343    const head = block.head.map(cell => inlineSpans(cell, paint, style))
344    const rows = block.rows.map(row => row.map(cell => inlineSpans(cell, paint, style)))
345    const natural = block.align.map((_, c) => Math.max(1, ...[head, ...rows].map(row => spansCells(row[c] ?? []))))
346    const columnCount = block.align.length
347    const cellPaddingCells = columnCount * 2
348    const separatorCells = columnCount - 1
349    const widths = shrinkWidestColumnsToFit(natural, context.width - cellPaddingCells - separatorCells)
350    const separator = style.table.column_separator ?? '│'
351    const rowLines = (cells: Span[][]): Line[] => {
352      const wrapped = widths.map((width, c) => wrap(cells[c] ?? [], width))
353      const height = Math.max(...wrapped.map(lines => lines.length))
354      return Array.from({ length: height }, (_, r) =>
355        widths.flatMap((width, c): Line => [
356          ...(c > 0 ? [{ text: separator, paint }] : []),
357          { text: ' ', paint },
358          ...aligned(wrapped[c]?.[r] ?? [], width, block.align[c] ?? 'none', paint),
359          { text: ' ', paint },
360        ]),
361      )
362    }
363    const rule = widths.map(width => (style.table.row_separator ?? '─').repeat(width + 2)).join(style.table.center_separator ?? '┼')
364    return [...rowLines(head), [{ text: rule, paint }], ...rows.flatMap(rowLines)]
365  },
366}
367
368export function layout(blocks: Block[], style: StyleConfig, columns: number): Line[] {
369  return framed(style.document, { style, width: columns, paint: {}, depth: 0 }, inner => drawBlocks(blocks, inner, 1))
370}
371
hooks/markdown.ts 468 lines
1export type Inline =
2  | { kind: 'text'; text: string }
3  | { kind: 'emph'; children: Inline[] }
4  | { kind: 'strong'; children: Inline[] }
5  | { kind: 'strike'; children: Inline[] }
6  | { kind: 'code'; text: string }
7  | { kind: 'link'; href: string; children: Inline[] }
8  | { kind: 'autolink'; href: string }
9  | { kind: 'image'; href: string; alt: string }
10  | { kind: 'break' }
11
12export type Align = 'left' | 'center' | 'right' | 'none'
13
14export type ListItem = { checked?: boolean; blocks: Block[] }
15
16export type Block =
17  | { kind: 'heading'; level: number; content: Inline[] }
18  | { kind: 'paragraph'; content: Inline[] }
19  | { kind: 'code'; language: string; text: string }
20  | { kind: 'quote'; blocks: Block[] }
21  | { kind: 'list'; ordered: boolean; start: number; items: ListItem[] }
22  | { kind: 'hr' }
23  | { kind: 'table'; align: Align[]; head: Inline[][]; rows: Inline[][][] }
24
25const BLANK = /^\s*$/
26const FENCE = /^( {0,3})(`{3,}|~{3,})[ \t]*([^\s`]*)/
27const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
28const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/
29const QUOTE = /^ {0,3}> ?(.*)$/
30const ITEM = /^( {0,3})([-+*]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/
31const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/
32const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/
33const TASK_BOX = /^\[([ xX])\](?:[ \t]+|$)/
34
35type Hit = { block: Block; next: number }
36
37type BlockRule = (lines: string[], at: number) => Hit | undefined
38
39const fence: BlockRule = (lines, at) => {
40  const open = FENCE.exec(lines[at] ?? '')
41  if (open === null) return undefined
42  const [, indent = '', marker = '', language = ''] = open
43  const closingFence = new RegExp(`^ {0,3}${marker[0] === '`' ? '`' : '~'}{${marker.length},}[ \\t]*$`)
44  const body: string[] = []
45  let i = at + 1
46  for (; i < lines.length; i++) {
47    const line = lines[i] ?? ''
48    if (closingFence.test(line)) {
49      i++
50      break
51    }
52    body.push(dropColumns(line, Math.min(indent.length, leadingColumns(line))))
53  }
54  return { block: { kind: 'code', language, text: body.join('\n') }, next: i }
55}
56
57const heading: BlockRule = (lines, at) => {
58  const match = ATX.exec(lines[at] ?? '')
59  if (match === null) return undefined
60  const [, hashes = '', text = ''] = match
61  return { block: { kind: 'heading', level: hashes.length, content: parseInline(text.trim()) }, next: at + 1 }
62}
63
64const rule: BlockRule = (lines, at) => (HR.test(lines[at] ?? '') ? { block: { kind: 'hr' }, next: at + 1 } : undefined)
65
66const quote: BlockRule = (lines, at) => {
67  if (!QUOTE.test(lines[at] ?? '')) return undefined
68  const body: string[] = []
69  let i = at
70  for (; i < lines.length; i++) {
71    const line = lines[i] ?? ''
72    const match = QUOTE.exec(line)
73    if (match !== null) body.push(match[1] ?? '')
74    else if (isLazyContinuation(lines, i, body)) body.push(line)
75    else break
76  }
77  return { block: { kind: 'quote', blocks: parseBlocks(body) }, next: i }
78}
79
80const list: BlockRule = (lines, at) => {
81  const first = ITEM.exec(lines[at] ?? '')
82  if (first === null) return undefined
83  const marker = first[2] ?? ''
84  const ordered = /\d/.test(marker)
85  const items: ListItem[] = []
86  let i = at
87  while (i < lines.length) {
88    const match = ITEM.exec(lines[i] ?? '')
89    if (match === null || !sameList(marker, match[2] ?? '')) break
90    const [, indent = '', bullet = '', gap = ' ', rest = ''] = match
91    const contentGap = gap.length > 4 || rest === '' ? 1 : gap.length
92    const contentColumn = indent.length + bullet.length + contentGap
93    const body = [rest]
94    for (i++; i < lines.length; i++) {
95      const line = lines[i] ?? ''
96      if (BLANK.test(line)) body.push('')
97      else if (leadingColumns(line) >= contentColumn) body.push(dropColumns(line, contentColumn))
98      else if (isLazyContinuation(lines, i, body)) body.push(line.trim())
99      else break
100    }
101    const task = TASK_BOX.exec(body[0] ?? '')
102    if (task !== null) body[0] = (body[0] ?? '').slice(task[0].length)
103    items.push({ ...(task !== null && { checked: task[1] !== ' ' }), blocks: parseBlocks(body) })
104  }
105  const start = ordered ? Number.parseInt(marker, 10) : 1
106  return { block: { kind: 'list', ordered, start, items }, next: i }
107}
108
109const table: BlockRule = (lines, at) => {
110  const head = lines[at] ?? ''
111  const delimiters = lines[at + 1] ?? ''
112  if (!head.includes('|') || !DELIMITER_ROW.test(delimiters)) return undefined
113  const align = cellsOf(delimiters).map(alignOf)
114  const headCells = cellsOf(head)
115  if (headCells.length !== align.length) return undefined
116  const rows: Inline[][][] = []
117  let i = at + 2
118  for (; i < lines.length; i++) {
119    const line = lines[i] ?? ''
120    if (BLANK.test(line) || !line.includes('|') || startsBlock(lines, i)) break
121    const cells = cellsOf(line)
122    rows.push(align.map((_, column) => parseInline(cells[column] ?? '')))
123  }
124  return { block: { kind: 'table', align, head: headCells.map(parseInline), rows }, next: i }
125}
126
127const BLOCK_RULES: BlockRule[] = [fence, heading, rule, quote, list, table]
128
129const PARAGRAPH_INTERRUPTERS: BlockRule[] = [fence, heading, rule, quote, nonEmptyItem]
130
131function nonEmptyItem(lines: string[], at: number): Hit | undefined {
132  return ITEM.exec(lines[at] ?? '')?.[4]?.trim() ? list(lines, at) : undefined
133}
134
135function startsBlock(lines: string[], at: number): boolean {
136  return PARAGRAPH_INTERRUPTERS.some(read => read([lines[at] ?? ''], 0) !== undefined)
137}
138
139function isLazyContinuation(lines: string[], at: number, body: string[]): boolean {
140  return !BLANK.test(lines[at] ?? '') && !BLANK.test(body[body.length - 1] ?? '') && !startsBlock(lines, at)
141}
142
143function paragraph(lines: string[], at: number): Hit {
144  const body = [lines[at] ?? '']
145  let i = at + 1
146  for (; i < lines.length; i++) {
147    const line = lines[i] ?? ''
148    if (BLANK.test(line)) break
149    const underline = SETEXT.exec(line)
150    if (underline !== null) {
151      const level = underline[1]?.startsWith('=') ? 1 : 2
152      return { block: { kind: 'heading', level, content: parseInline(body.join('\n').trim()) }, next: i + 1 }
153    }
154    if (startsBlock(lines, i) || table(lines, i) !== undefined) break
155    body.push(line)
156  }
157  const textKeepingHardBreaks = body.map(line => line.trimStart()).join('\n').trimEnd()
158  return { block: { kind: 'paragraph', content: parseInline(textKeepingHardBreaks) }, next: i }
159}
160
161function parseBlocks(lines: string[]): Block[] {
162  const blocks: Block[] = []
163  let i = 0
164  while (i < lines.length) {
165    if (BLANK.test(lines[i] ?? '')) {
166      i++
167      continue
168    }
169    const hit = BLOCK_RULES.reduce<Hit | undefined>((found, read) => found ?? read(lines, i), undefined) ?? paragraph(lines, i)
170    blocks.push(hit.block)
171    i = hit.next
172  }
173  return blocks
174}
175
176export function parse(markdown: string): Block[] {
177  return parseBlocks(markdown.replace(/\r\n?/g, '\n').split('\n').map(expandLeadingTabs))
178}
179
180function expandLeadingTabs(line: string): string {
181  const lead = /^[ \t]*/.exec(line)?.[0] ?? ''
182  let columns = 0
183  for (const ch of lead) columns = ch === '\t' ? columns + 4 - (columns % 4) : columns + 1
184  return ' '.repeat(columns) + line.slice(lead.length)
185}
186
187function leadingColumns(line: string): number {
188  return line.length - line.trimStart().length
189}
190
191function dropColumns(line: string, columns: number): string {
192  return line.slice(Math.min(columns, leadingColumns(line)))
193}
194
195function sameList(first: string, next: string): boolean {
196  const ordered = /\d/.test(first)
197  return ordered ? /\d/.test(next) && first.slice(-1) === next.slice(-1) : first === next
198}
199
200function cellsOf(row: string): string[] {
201  const trimmed = row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
202  return trimmed.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'))
203}
204
205function alignOf(cell: string): Align {
206  const left = cell.startsWith(':')
207  const right = cell.endsWith(':')
208  return left && right ? 'center' : right ? 'right' : left ? 'left' : 'none'
209}
210
211type Delimiter = { kind: 'delimiter'; char: string; count: number; canOpen: boolean; canClose: boolean }
212type Piece = Inline | Delimiter
213
214const ESCAPABLE = /[!-/:-@[-`{-~]/
215const PUNCTUATION = /[\p{P}\p{S}]/u
216const AUTOLINK = /^<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)>/
217const BARE_URL = /^(?:https?:\/\/|www\.)[^\s<]*/
218const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }
219const TRAILING_PUNCTUATION = /[?!.,:*_~'"]+$/
220
221function parseInline(source: string): Inline[] {
222  const pieces: Piece[] = []
223  let text = ''
224  const flush = () => {
225    if (text !== '') pieces.push({ kind: 'text', text })
226    text = ''
227  }
228  let i = 0
229  while (i < source.length) {
230    const ch = source[i] ?? ''
231    const rest = source.slice(i)
232
233    if (ch === '\\' && source[i + 1] === '\n') {
234      flush()
235      pieces.push({ kind: 'break' })
236      i += 2
237      continue
238    }
239    if (ch === '\\' && ESCAPABLE.test(source[i + 1] ?? '')) {
240      text += source[i + 1]
241      i += 2
242      continue
243    }
244    if (ch === '`') {
245      const run = /^`+/.exec(rest)?.[0] ?? '`'
246      const end = closingRun(source, i + run.length, run)
247      if (end === -1) {
248        text += run
249        i += run.length
250        continue
251      }
252      flush()
253      pieces.push({ kind: 'code', text: codeSpanText(source.slice(i + run.length, end)) })
254      i = end + run.length
255      continue
256    }
257    if (ch === '!' && source[i + 1] === '[') {
258      const link = linkAt(source, i + 1)
259      if (link !== undefined) {
260        flush()
261        pieces.push({ kind: 'image', href: link.href, alt: plainText(parseInline(link.label)) })
262        i = link.end
263        continue
264      }
265    }
266    if (ch === '[') {
267      const link = linkAt(source, i)
268      if (link !== undefined) {
269        flush()
270        pieces.push({ kind: 'link', href: link.href, children: parseInline(link.label) })
271        i = link.end
272        continue
273      }
274    }
275    if (ch === '<') {
276      const auto = AUTOLINK.exec(rest)
277      if (auto !== null) {
278        flush()
279        pieces.push({ kind: 'autolink', href: auto[1] ?? '' })
280        i += auto[0].length
281        continue
282      }
283    }
284    if ((ch === 'h' || ch === 'w') && /^$|[\s*_~(]$/.test(source[i - 1] ?? '')) {
285      const url = BARE_URL.exec(rest)?.[0]
286      if (url !== undefined) {
287        const href = trimUrl(url)
288        flush()
289        pieces.push({ kind: 'autolink', href })
290        i += href.length
291        continue
292      }
293    }
294    if (ch === '*' || ch === '_' || ch === '~') {
295      const run = new RegExp(`^\\${ch}+`).exec(rest)?.[0] ?? ch
296      flush()
297      pieces.push(delimiterOf(ch, run.length, source[i - 1] ?? ' ', source[i + run.length] ?? ' '))
298      i += run.length
299      continue
300    }
301    if (ch === '\n') {
302      const isHardBreak = text.endsWith('  ')
303      text = text.trimEnd()
304      if (isHardBreak) {
305        flush()
306        pieces.push({ kind: 'break' })
307      } else text += ' '
308      i++
309      while (source[i] === ' ') i++
310      continue
311    }
312    if (ch === '&') {
313      const entity = /^&(#\d{1,7}|#[xX][\da-fA-F]{1,6}|[a-zA-Z]+);/.exec(rest)
314      const decoded = entity === null ? undefined : decodeEntity(entity[1] ?? '')
315      if (entity !== null && decoded !== undefined) {
316        text += decoded
317        i += entity[0].length
318        continue
319      }
320    }
321    text += ch
322    i++
323  }
324  flush()
325  return resolveEmphasis(pieces)
326}
327
328function closingRun(source: string, from: number, run: string): number {
329  for (let at = source.indexOf(run, from); at !== -1; at = source.indexOf(run, at + 1)) {
330    const longer = source[at + run.length] === '`' || source[at - 1] === '`'
331    if (!longer) return at
332  }
333  return -1
334}
335
336function codeSpanText(raw: string): string {
337  const flat = raw.replace(/\n/g, ' ')
338  return /^ .*[^ ].* $/.test(flat) ? flat.slice(1, -1) : flat
339}
340
341type LinkMatch = { label: string; href: string; end: number }
342
343function linkAt(source: string, open: number): LinkMatch | undefined {
344  let depth = 0
345  let close = -1
346  for (let i = open; i < source.length; i++) {
347    const ch = source[i]
348    if (ch === '\\') i++
349    else if (ch === '`') i = Math.max(i, closingRun(source, i + 1, '`'))
350    else if (ch === '[') depth++
351    else if (ch === ']' && --depth === 0) {
352      close = i
353      break
354    }
355  }
356  if (close === -1 || source[close + 1] !== '(') return undefined
357  const target = /^\(\s*(?:<([^<>\n]*)>|((?:[^\s()\\]|\\.|\((?:[^\s()\\]|\\.)*\))*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/.exec(
358    source.slice(close + 1),
359  )
360  if (target === null) return undefined
361  return { label: source.slice(open + 1, close), href: target[1] ?? target[2] ?? '', end: close + 1 + target[0].length }
362}
363
364const occurrences = (text: string, char: string) => text.split(char).length - 1
365
366const endsWithUnopenedParen = (href: string) => href.endsWith(')') && occurrences(href, '(') < occurrences(href, ')')
367
368function trimUrl(url: string): string {
369  let href = url.replace(TRAILING_PUNCTUATION, '')
370  while (endsWithUnopenedParen(href)) href = href.slice(0, -1).replace(TRAILING_PUNCTUATION, '')
371  return href
372}
373
374function decodeEntity(name: string): string | undefined {
375  if (name.startsWith('#')) {
376    const code = name[1] === 'x' || name[1] === 'X' ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10)
377    return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : undefined
378  }
379  return ENTITIES[name]
380}
381
382function delimiterOf(char: string, count: number, before: string, after: string): Delimiter {
383  const spaceBefore = /\s/.test(before)
384  const spaceAfter = /\s/.test(after)
385  const punctBefore = PUNCTUATION.test(before)
386  const punctAfter = PUNCTUATION.test(after)
387  const leftFlanking = !spaceAfter && (!punctAfter || spaceBefore || punctBefore)
388  const rightFlanking = !spaceBefore && (!punctBefore || spaceAfter || punctAfter)
389  const isUnderscoreInsideWord = char === '_' && leftFlanking && rightFlanking
390  return {
391    kind: 'delimiter',
392    char,
393    count,
394    canOpen: leftFlanking && (!isUnderscoreInsideWord || punctBefore),
395    canClose: rightFlanking && (!isUnderscoreInsideWord || punctAfter),
396  }
397}
398
399function isDelimiter(piece: Piece | undefined): piece is Delimiter {
400  return piece?.kind === 'delimiter'
401}
402
403function breaksRuleOfThree(opener: Delimiter, closer: Delimiter): boolean {
404  const eitherCouldBeBoth = opener.canClose || closer.canOpen
405  const sumIsMultipleOfThree = (opener.count + closer.count) % 3 === 0
406  const bothAreMultiplesOfThree = opener.count % 3 === 0 && closer.count % 3 === 0
407  return eitherCouldBeBoth && sumIsMultipleOfThree && !bothAreMultiplesOfThree
408}
409
410function canPair(opener: Delimiter, closer: Delimiter): boolean {
411  if (opener.char !== closer.char || !opener.canOpen) return false
412  if (closer.char === '~') return opener.count === closer.count && opener.count <= 2
413  return !breaksRuleOfThree(opener, closer)
414}
415
416function resolveEmphasis(input: Piece[]): Inline[] {
417  const pieces = [...input]
418  for (let closerAt = 0; closerAt < pieces.length; closerAt++) {
419    const closer = pieces[closerAt]
420    if (!isDelimiter(closer) || !closer.canClose) continue
421    const openerAt = pieces.slice(0, closerAt).findLastIndex(piece => isDelimiter(piece) && canPair(piece, closer))
422    const opener = pieces[openerAt]
423    if (!isDelimiter(opener)) continue
424    const used = closer.char === '~' ? closer.count : opener.count >= 2 && closer.count >= 2 ? 2 : 1
425    const kind = closer.char === '~' ? 'strike' : used === 2 ? 'strong' : 'emph'
426    const node: Inline = { kind, children: toInlines(pieces.slice(openerAt + 1, closerAt)) }
427    const openerLeftover = opener.count > used ? [{ ...opener, count: opener.count - used }] : []
428    const closerLeftover = closer.count > used ? [{ ...closer, count: closer.count - used }] : []
429    pieces.splice(openerAt, closerAt - openerAt + 1, ...openerLeftover, node, ...closerLeftover)
430    const nodeAt = openerAt + openerLeftover.length
431    closerAt = nodeAt
432  }
433  return toInlines(pieces)
434}
435
436const unmatchedAsText = (delimiter: Delimiter): Inline => ({ kind: 'text', text: delimiter.char.repeat(delimiter.count) })
437
438function toInlines(pieces: Piece[]): Inline[] {
439  const inlines: Inline[] = []
440  for (const piece of pieces) {
441    const node = isDelimiter(piece) ? unmatchedAsText(piece) : piece
442    const previous = inlines[inlines.length - 1]
443    if (node.kind === 'text' && previous?.kind === 'text') inlines[inlines.length - 1] = { kind: 'text', text: previous.text + node.text }
444    else inlines.push(node)
445  }
446  return inlines
447}
448
449export function plainText(inlines: Inline[]): string {
450  return inlines
451    .map(node => {
452      switch (node.kind) {
453        case 'text':
454        case 'code':
455          return node.text
456        case 'autolink':
457          return node.href
458        case 'image':
459          return node.alt
460        case 'break':
461          return '\n'
462        default:
463          return plainText(node.children)
464      }
465    })
466    .join('')
467}
468
hooks/sentences.ts 83 lines
1import { plainText, type Block, type Inline } from './markdown'
2
3const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|cf|approx|al|Mr|Mrs|Ms|Dr|St|No|Fig)\.$/i
4const INITIAL = /(?:^|\s)\p{Lu}\.$/u
5const SENTENCE_END = /[.!?]["'”’)\]]*$/
6const LOWERCASE = /\p{Ll}/u
7
8function endsSentence(textSoFar: string): boolean {
9  return SENTENCE_END.test(textSoFar) && !ABBREVIATION.test(textSoFar) && !INITIAL.test(textSoFar)
10}
11
12function startsSentence(ch: string | undefined): boolean {
13  return ch !== undefined && !LOWERCASE.test(ch)
14}
15
16function opensSentence(node: Inline | undefined): boolean {
17  switch (node?.kind) {
18    case undefined:
19    case 'break':
20      return false
21    case 'text':
22    case 'emph':
23    case 'strong':
24    case 'strike':
25      return startsSentence(plainText([node])[0])
26    case 'code':
27    case 'link':
28    case 'autolink':
29    case 'image':
30      return true
31  }
32}
33
34function breakAfterSentences(text: string, precedingText: string, nextNode: Inline | undefined): Inline[] {
35  const out: Inline[] = []
36  let start = 0
37  for (const gap of text.matchAll(/\s+/g)) {
38    const gapStart = gap.index ?? 0
39    const gapEnd = gapStart + gap[0].length
40    const nextOpens = gapEnd < text.length ? startsSentence(text[gapEnd]) : opensSentence(nextNode)
41    if (!nextOpens || !endsSentence(precedingText + text.slice(0, gapStart))) continue
42    if (gapStart > start) out.push({ kind: 'text', text: text.slice(start, gapStart) })
43    out.push({ kind: 'break' })
44    start = gapEnd
45  }
46  if (start < text.length) out.push({ kind: 'text', text: text.slice(start) })
47  return out
48}
49
50function splitInlines(nodes: Inline[]): Inline[] {
51  const out: Inline[] = []
52  nodes.forEach((node, i) => {
53    switch (node.kind) {
54      case 'emph':
55      case 'strong':
56      case 'strike':
57        out.push({ ...node, children: splitInlines(node.children) })
58        break
59      case 'text':
60        out.push(...breakAfterSentences(node.text, plainText(out.slice(-1)), nodes[i + 1]))
61        break
62      default:
63        out.push(node)
64    }
65  })
66  return out
67}
68
69export function splitSentences(blocks: Block[]): Block[] {
70  return blocks.map((block): Block => {
71    switch (block.kind) {
72      case 'paragraph':
73        return { ...block, content: splitInlines(block.content) }
74      case 'quote':
75        return { ...block, blocks: splitSentences(block.blocks) }
76      case 'list':
77        return { ...block, items: block.items.map(item => ({ ...item, blocks: splitSentences(item.blocks) })) }
78      default:
79        return block
80    }
81  })
82}
83
hooks/style.ts 251 lines
1type XtermIndex = `${number}`
2type HexColor = `#${string}`
3
4export type StyleColor = XtermIndex | HexColor
5
6export type StylePrimitive = {
7  block_prefix?: string
8  block_suffix?: string
9  prefix?: string
10  suffix?: string
11  color?: StyleColor
12  background_color?: StyleColor
13  underline?: boolean
14  bold?: boolean
15  italic?: boolean
16  crossed_out?: boolean
17  faint?: boolean
18  inverse?: boolean
19  format?: string
20}
21
22export type StyleBlock = StylePrimitive & {
23  indent?: number
24  indent_token?: string
25  margin?: number
26}
27
28export type StyleList = StyleBlock & {
29  level_indent?: number
30}
31
32export type StyleTask = StylePrimitive & {
33  ticked?: string
34  unticked?: string
35}
36
37export type ChromaToken =
38  | 'text'
39  | 'error'
40  | 'comment'
41  | 'comment_preproc'
42  | 'keyword'
43  | 'keyword_reserved'
44  | 'keyword_namespace'
45  | 'keyword_type'
46  | 'operator'
47  | 'punctuation'
48  | 'name'
49  | 'name_builtin'
50  | 'name_tag'
51  | 'name_attribute'
52  | 'name_class'
53  | 'name_constant'
54  | 'name_decorator'
55  | 'name_exception'
56  | 'name_function'
57  | 'name_other'
58  | 'literal'
59  | 'literal_number'
60  | 'literal_date'
61  | 'literal_string'
62  | 'literal_string_escape'
63  | 'generic_deleted'
64  | 'generic_emph'
65  | 'generic_inserted'
66  | 'generic_strong'
67  | 'generic_subheading'
68  | 'background'
69
70export type StyleCodeBlock = StyleBlock & {
71  theme?: string
72  chroma?: Partial<Record<ChromaToken, StylePrimitive>>
73}
74
75export type StyleTable = StyleBlock & {
76  center_separator?: string
77  column_separator?: string
78  row_separator?: string
79}
80
81export type StyleConfig = {
82  document: StyleBlock
83  block_quote: StyleBlock
84  paragraph: StyleBlock
85  list: StyleList
86  heading: StyleBlock
87  h1: StyleBlock
88  h2: StyleBlock
89  h3: StyleBlock
90  h4: StyleBlock
91  h5: StyleBlock
92  h6: StyleBlock
93  text: StylePrimitive
94  strikethrough: StylePrimitive
95  emph: StylePrimitive
96  strong: StylePrimitive
97  hr: StylePrimitive
98  item: StylePrimitive
99  enumeration: StylePrimitive
100  task: StyleTask
101  link: StylePrimitive
102  link_text: StylePrimitive
103  image: StylePrimitive
104  image_text: StylePrimitive
105  code: StyleBlock
106  code_block: StyleCodeBlock
107  table: StyleTable
108  definition_list: StyleBlock
109  definition_term: StylePrimitive
110  definition_description: StylePrimitive
111  html_block: StyleBlock
112  html_span: StyleBlock
113}
114
115export const DARK: StyleConfig = {
116  document: {
117    block_prefix: '\n',
118    block_suffix: '\n',
119    color: '252',
120    margin: 2,
121  },
122  block_quote: {
123    indent: 1,
124    indent_token: '│ ',
125  },
126  paragraph: {},
127  list: {
128    level_indent: 2,
129  },
130  heading: {
131    block_suffix: '\n',
132    color: '39',
133    bold: true,
134  },
135  h1: {
136    prefix: ' ',
137    suffix: ' ',
138    color: '228',
139    background_color: '63',
140    bold: true,
141  },
142  h2: {
143    prefix: '## ',
144  },
145  h3: {
146    prefix: '### ',
147  },
148  h4: {
149    prefix: '#### ',
150  },
151  h5: {
152    prefix: '##### ',
153  },
154  h6: {
155    prefix: '###### ',
156    color: '35',
157    bold: false,
158  },
159  text: {},
160  strikethrough: {
161    crossed_out: true,
162  },
163  emph: {
164    italic: true,
165  },
166  strong: {
167    bold: true,
168  },
169  hr: {
170    color: '240',
171    format: '\n--------\n',
172  },
173  item: {
174    block_prefix: '• ',
175  },
176  enumeration: {
177    block_prefix: '. ',
178  },
179  task: {
180    ticked: '[✓] ',
181    unticked: '[ ] ',
182  },
183  link: {
184    color: '30',
185    underline: true,
186  },
187  link_text: {
188    color: '35',
189    bold: true,
190  },
191  image: {
192    color: '212',
193    underline: true,
194  },
195  image_text: {
196    color: '243',
197    format: 'Image: {{.text}} →',
198  },
199  code: {
200    prefix: ' ',
201    suffix: ' ',
202    color: '203',
203    background_color: '236',
204  },
205  code_block: {
206    color: '244',
207    margin: 2,
208    chroma: {
209      text: { color: '#C4C4C4' },
210      error: { color: '#F1F1F1', background_color: '#F05B5B' },
211      comment: { color: '#676767' },
212      comment_preproc: { color: '#FF875F' },
213      keyword: { color: '#00AAFF' },
214      keyword_reserved: { color: '#FF5FD2' },
215      keyword_namespace: { color: '#FF5F87' },
216      keyword_type: { color: '#6E6ED8' },
217      operator: { color: '#EF8080' },
218      punctuation: { color: '#E8E8A8' },
219      name: { color: '#C4C4C4' },
220      name_builtin: { color: '#FF8EC7' },
221      name_tag: { color: '#B083EA' },
222      name_attribute: { color: '#7A7AE6' },
223      name_class: { color: '#F1F1F1', underline: true, bold: true },
224      name_constant: {},
225      name_decorator: { color: '#FFFF87' },
226      name_exception: {},
227      name_function: { color: '#00D787' },
228      name_other: {},
229      literal: {},
230      literal_number: { color: '#6EEFC0' },
231      literal_date: {},
232      literal_string: { color: '#C69669' },
233      literal_string_escape: { color: '#AFFFD7' },
234      generic_deleted: { color: '#FD5B5B' },
235      generic_emph: { italic: true },
236      generic_inserted: { color: '#00D787' },
237      generic_strong: { bold: true },
238      generic_subheading: { color: '#777777' },
239      background: { background_color: '#373737' },
240    },
241  },
242  table: {},
243  definition_list: {},
244  definition_term: {},
245  definition_description: {
246    block_prefix: '\n🠶 ',
247  },
248  html_block: {},
249  html_span: {},
250}
251
hooks/highlight.ts 353 lines
1import type { ChromaToken } from './style'
2
3export type Token = { text: string; kind: ChromaToken }
4
5type Lexer = (source: string) => Token[]
6
7type LanguageSpec = {
8  keywords?: string[]
9  reserved?: string[]
10  namespaces?: string[]
11  types?: string[]
12  builtins?: string[]
13  functionDeclarers?: string[]
14  classDeclarers?: string[]
15  lineComments?: string[]
16  blockComment?: [string, string]
17  quotes?: string[]
18  decorators?: boolean
19  preprocessorLine?: RegExp
20  lineStartKey?: RegExp
21  quotedKeys?: boolean
22  ignoreCase?: boolean
23}
24
25const NUMBER = /^(?:0[xX][\da-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[a-zA-Z]*/
26const NAME = /^[A-Za-z_$][\w$]*/
27const OPERATOR = /^(?:=>|->|::|[-+*/%=<>!&|^~?:]+)/
28const PUNCTUATION = /^[()[\]{};,.@#]/
29const SPACE = /^\s+/
30const DECORATOR = /^@[A-Za-z_][\w.]*/
31
32function codeLexer(spec: LanguageSpec): Lexer {
33  const fold = (word: string) => (spec.ignoreCase ? word.toLowerCase() : word)
34  const setOf = (words: string[] = []) => new Set(words.map(fold))
35  const classes: [Set<string>, ChromaToken][] = [
36    [setOf(spec.namespaces), 'keyword_namespace'],
37    [setOf(spec.reserved), 'keyword_reserved'],
38    [setOf(spec.types), 'keyword_type'],
39    [setOf(spec.keywords), 'keyword'],
40    [setOf(spec.builtins), 'name_builtin'],
41  ]
42  const functionDeclarers = setOf(spec.functionDeclarers)
43  const classDeclarers = setOf(spec.classDeclarers)
44  const quotesLongestFirst = [...(spec.quotes ?? [])].sort((a, b) => b.length - a.length)
45
46  return source => {
47    const tokens: Token[] = []
48    const push = (text: string, kind: ChromaToken) => tokens.push({ text, kind })
49    let pendingDeclarationKind: ChromaToken | undefined
50    let atLineStart = true
51    let i = 0
52    while (i < source.length) {
53      const rest = source.slice(i)
54      const lineEnd = rest.indexOf('\n') === -1 ? rest.length : rest.indexOf('\n')
55      const take = (text: string, kind: ChromaToken) => {
56        push(text, kind)
57        i += text.length
58      }
59
60      const space = SPACE.exec(rest)?.[0]
61      if (space !== undefined) {
62        take(space, 'text')
63        if (space.includes('\n')) atLineStart = true
64        continue
65      }
66      const wasLineStart = atLineStart
67      atLineStart = false
68      const declarationKind = pendingDeclarationKind
69      pendingDeclarationKind = undefined
70
71      if (wasLineStart && spec.preprocessorLine?.test(rest.slice(0, lineEnd))) {
72        take(rest.slice(0, lineEnd), 'comment_preproc')
73        continue
74      }
75      const key = wasLineStart ? spec.lineStartKey?.exec(rest)?.[0] : undefined
76      if (key !== undefined) {
77        take(key, 'name_tag')
78        continue
79      }
80      const lineComment = spec.lineComments?.find(open => rest.startsWith(open))
81      if (lineComment !== undefined) {
82        take(rest.slice(0, lineEnd), 'comment')
83        continue
84      }
85      if (spec.blockComment !== undefined && rest.startsWith(spec.blockComment[0])) {
86        const close = rest.indexOf(spec.blockComment[1], spec.blockComment[0].length)
87        take(close === -1 ? rest : rest.slice(0, close + spec.blockComment[1].length), 'comment')
88        continue
89      }
90      const quote = quotesLongestFirst.find(open => rest.startsWith(open))
91      if (quote !== undefined) {
92        const literal = stringLiteralAt(rest, quote)
93        const isKey = spec.quotedKeys === true && /^\s*:/.test(rest.slice(literal.length))
94        if (isKey) take(literal, 'name_tag')
95        else {
96          tokens.push(...stringTokens(literal))
97          i += literal.length
98        }
99        continue
100      }
101      const decorator = spec.decorators ? DECORATOR.exec(rest)?.[0] : undefined
102      if (decorator !== undefined) {
103        take(decorator, 'name_decorator')
104        continue
105      }
106      const number = NUMBER.exec(rest)?.[0]
107      if (number !== undefined) {
108        take(number, 'literal_number')
109        continue
110      }
111      const name = NAME.exec(rest)?.[0]
112      if (name !== undefined) {
113        const folded = fold(name)
114        const kind = declarationKind ?? classes.find(([words]) => words.has(folded))?.[1] ?? 'name'
115        pendingDeclarationKind = functionDeclarers.has(folded) ? 'name_function' : classDeclarers.has(folded) ? 'name_class' : undefined
116        take(name, kind)
117        continue
118      }
119      const operator = OPERATOR.exec(rest)?.[0]
120      if (operator !== undefined) {
121        take(operator, 'operator')
122        continue
123      }
124      take(PUNCTUATION.exec(rest)?.[0] ?? rest[0] ?? '', PUNCTUATION.test(rest) ? 'punctuation' : 'text')
125    }
126    return tokens
127  }
128}
129
130function stringLiteralAt(rest: string, quote: string): string {
131  const canSpanLines = quote.length === 3 || quote === '`'
132  for (let i = quote.length; i < rest.length; i++) {
133    if (rest[i] === '\\') i++
134    else if (rest.startsWith(quote, i)) return rest.slice(0, i + quote.length)
135    else if (rest[i] === '\n' && !canSpanLines) return rest.slice(0, i)
136  }
137  return rest
138}
139
140function stringTokens(literal: string): Token[] {
141  return literal
142    .split(/(\\(?:x[\da-fA-F]{2}|u\{?[\da-fA-F]{1,6}\}?|[^\n]))/)
143    .filter(part => part !== '')
144    .map(part => ({ text: part, kind: part.startsWith('\\') ? 'literal_string_escape' : 'literal_string' }))
145}
146
147function lineLexer(rules: [RegExp, ChromaToken][]): Lexer {
148  return source =>
149    source.split(/(?<=\n)/).map(line => ({ text: line, kind: rules.find(([test]) => test.test(line))?.[1] ?? 'text' }))
150}
151
152const diff = lineLexer([
153  [/^\+/, 'generic_inserted'],
154  [/^-/, 'generic_deleted'],
155  [/^@/, 'generic_subheading'],
156  [/^(?:diff|index) /, 'generic_strong'],
157])
158
159const markup: Lexer = source => {
160  const tokens: Token[] = []
161  const pattern =
162    /(<!--[\s\S]*?(?:-->|$))|(<\/?)([\w:-]+)|([\w:-]+)(?==)|("[^"]*"?|'[^']*'?)|(\/?>)|([^<>"'=\s]+|\s+|[<>=])/g
163  for (const match of source.matchAll(pattern)) {
164    const [, comment, open, tag, attribute, string, close] = match
165    if (comment !== undefined) tokens.push({ text: comment, kind: 'comment' })
166    else if (open !== undefined && tag !== undefined) tokens.push({ text: open, kind: 'punctuation' }, { text: tag, kind: 'name_tag' })
167    else if (attribute !== undefined) tokens.push({ text: attribute, kind: 'name_attribute' })
168    else if (string !== undefined) tokens.push({ text: string, kind: 'literal_string' })
169    else if (close !== undefined) tokens.push({ text: close, kind: 'punctuation' })
170    else tokens.push({ text: match[0], kind: 'text' })
171  }
172  return tokens
173}
174
175const C_FAMILY: LanguageSpec = {
176  keywords: [
177    'break', 'case', 'catch', 'continue', 'default', 'do', 'else', 'finally', 'for', 'goto', 'if', 'new', 'return',
178    'sizeof', 'switch', 'this', 'throw', 'try', 'while', 'true', 'false', 'null', 'nullptr', 'NULL', 'class',
179    'struct', 'enum', 'union', 'typedef', 'const', 'static', 'extern', 'public', 'private', 'protected', 'virtual',
180    'override', 'final', 'abstract', 'extends', 'implements', 'interface', 'template', 'typename', 'auto', 'inline',
181    'volatile', 'throws', 'instanceof', 'super', 'var', 'val', 'fun', 'func', 'let', 'guard', 'when', 'object',
182  ],
183  namespaces: ['import', 'package', 'namespace', 'using'],
184  types: [
185    'void', 'int', 'char', 'short', 'long', 'float', 'double', 'bool', 'boolean', 'byte', 'unsigned', 'signed',
186    'size_t', 'string', 'String', 'Int', 'Double', 'Float', 'Bool', 'Boolean', 'Long', 'Unit', 'Any',
187  ],
188  classDeclarers: ['class', 'struct', 'enum', 'interface', 'union', 'object'],
189  functionDeclarers: ['fun', 'func'],
190  lineComments: ['//'],
191  blockComment: ['/*', '*/'],
192  quotes: ['"""', '"', "'"],
193  decorators: true,
194  preprocessorLine: /^#\s*\w+/,
195}
196
197const SCRIPT: LanguageSpec = {
198  keywords: [
199    'break', 'case', 'catch', 'continue', 'default', 'delete', 'do', 'else', 'finally', 'for', 'if',
200    'in', 'instanceof', 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'void', 'while', 'with',
201    'yield', 'await', 'async', 'of', 'as', 'satisfies', 'keyof', 'infer', 'is', 'var', 'let',
202    'function', 'type', 'declare', 'namespace', 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
203  ],
204  reserved: [
205    'abstract', 'class', 'const', 'debugger', 'enum', 'export', 'extends', 'implements', 'import', 'interface',
206    'package', 'private', 'protected', 'public', 'readonly', 'static', 'super',
207  ],
208  types: ['string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'symbol', 'bigint'],
209  builtins: [
210    'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Map', 'Math', 'Number', 'Object', 'Promise', 'Proxy',
211    'Reflect', 'RegExp', 'Set', 'String', 'Symbol', 'WeakMap', 'WeakSet', 'console', 'document', 'globalThis',
212    'process', 'window', 'parseInt', 'parseFloat', 'setTimeout', 'clearTimeout', 'fetch',
213  ],
214  functionDeclarers: ['function'],
215  classDeclarers: ['class', 'interface', 'enum'],
216  lineComments: ['//'],
217  blockComment: ['/*', '*/'],
218  quotes: ['"', "'", '`'],
219  decorators: true,
220}
221
222const PYTHON: LanguageSpec = {
223  keywords: [
224    'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
225    'finally', 'for', 'global', 'if', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return',
226    'try', 'while', 'with', 'yield', 'match', 'case', 'True', 'False', 'None',
227  ],
228  namespaces: ['import', 'from'],
229  builtins: [
230    'abs', 'all', 'any', 'bool', 'bytes', 'dict', 'enumerate', 'filter', 'float', 'getattr', 'hasattr', 'int',
231    'isinstance', 'len', 'list', 'map', 'max', 'min', 'open', 'print', 'range', 'repr', 'reversed', 'set',
232    'setattr', 'sorted', 'str', 'sum', 'super', 'tuple', 'type', 'zip', 'self', 'cls',
233  ],
234  functionDeclarers: ['def'],
235  classDeclarers: ['class'],
236  lineComments: ['#'],
237  quotes: ['"""', "'''", '"', "'"],
238  decorators: true,
239}
240
241const GO: LanguageSpec = {
242  keywords: [
243    'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for', 'func', 'go',
244    'goto', 'if', 'interface', 'map', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var', 'true',
245    'false', 'nil', 'iota',
246  ],
247  namespaces: ['package', 'import'],
248  types: [
249    'any', 'bool', 'byte', 'complex64', 'complex128', 'error', 'float32', 'float64', 'int', 'int8', 'int16', 'int32',
250    'int64', 'rune', 'string', 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
251  ],
252  builtins: ['append', 'cap', 'clear', 'close', 'copy', 'delete', 'len', 'make', 'max', 'min', 'new', 'panic', 'print', 'println', 'recover'],
253  functionDeclarers: ['func'],
254  lineComments: ['//'],
255  blockComment: ['/*', '*/'],
256  quotes: ['"', "'", '`'],
257}
258
259const RUST: LanguageSpec = {
260  keywords: [
261    'as', 'async', 'await', 'break', 'const', 'continue', 'dyn', 'else', 'enum', 'false', 'fn', 'for', 'if', 'impl',
262    'in', 'let', 'loop', 'match', 'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct', 'super',
263    'trait', 'true', 'type', 'unsafe', 'where', 'while',
264  ],
265  namespaces: ['use', 'mod', 'crate', 'extern'],
266  types: [
267    'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', 'f32', 'f64', 'bool',
268    'char', 'str', 'String', 'Vec', 'Option', 'Result', 'Box',
269  ],
270  builtins: ['Some', 'None', 'Ok', 'Err'],
271  functionDeclarers: ['fn'],
272  classDeclarers: ['struct', 'enum', 'trait'],
273  lineComments: ['//'],
274  blockComment: ['/*', '*/'],
275  quotes: ['"'],
276}
277
278const SHELL: LanguageSpec = {
279  keywords: [
280    'if', 'then', 'else', 'elif', 'fi', 'case', 'esac', 'for', 'while', 'until', 'do', 'done', 'in', 'function',
281    'select', 'return', 'exit', 'export', 'local', 'readonly', 'declare', 'unset',
282  ],
283  builtins: [
284    'alias', 'awk', 'cat', 'cd', 'cp', 'curl', 'echo', 'eval', 'exec', 'find', 'git', 'grep', 'ls', 'mkdir', 'mv',
285    'printf', 'pwd', 'read', 'rm', 'sed', 'set', 'shift', 'source', 'test', 'trap', 'wait',
286  ],
287  lineComments: ['#'],
288  quotes: ['"', "'"],
289}
290
291const SQL: LanguageSpec = {
292  keywords: [
293    'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', 'create', 'table', 'drop',
294    'alter', 'add', 'index', 'join', 'left', 'right', 'inner', 'outer', 'on', 'group', 'by', 'order', 'having',
295    'limit', 'offset', 'as', 'and', 'or', 'not', 'null', 'is', 'in', 'like', 'distinct', 'union', 'all', 'case',
296    'when', 'then', 'else', 'end', 'primary', 'key', 'foreign', 'references', 'default', 'with', 'returning',
297  ],
298  types: ['int', 'integer', 'bigint', 'text', 'varchar', 'char', 'boolean', 'date', 'timestamp', 'numeric', 'real', 'serial', 'uuid', 'jsonb'],
299  lineComments: ['--'],
300  blockComment: ['/*', '*/'],
301  quotes: ["'", '"'],
302  ignoreCase: true,
303}
304
305const JSON_SPEC: LanguageSpec = { keywords: ['true', 'false', 'null'], quotes: ['"'], quotedKeys: true }
306
307const YAML: LanguageSpec = {
308  keywords: ['true', 'false', 'null', 'yes', 'no', 'on', 'off'],
309  lineComments: ['#'],
310  quotes: ['"', "'"],
311  lineStartKey: /^(?:- +)?[\w.-]+(?=\s*:(?:\s|$))/,
312}
313
314const TOML: LanguageSpec = {
315  keywords: ['true', 'false'],
316  lineComments: ['#'],
317  quotes: ['"""', "'''", '"', "'"],
318  lineStartKey: /^(?:\[\[?[^\]\n]*\]\]?|[\w.-]+(?=\s*=))/,
319}
320
321const CSS: LanguageSpec = {
322  keywords: ['important', 'inherit', 'initial', 'unset', 'none', 'auto'],
323  lineComments: [],
324  blockComment: ['/*', '*/'],
325  quotes: ['"', "'"],
326  lineStartKey: /^[\w-]+(?=\s*:)/,
327}
328
329const LEXERS_BY_LANGUAGE: Record<string, Lexer> = {}
330
331function language(lexer: Lexer, ...names: string[]): void {
332  for (const name of names) LEXERS_BY_LANGUAGE[name] = lexer
333}
334
335language(codeLexer(SCRIPT), 'js', 'javascript', 'jsx', 'mjs', 'cjs', 'ts', 'typescript', 'tsx', 'mts', 'cts')
336language(codeLexer(PYTHON), 'py', 'python', 'python3')
337language(codeLexer(GO), 'go', 'golang')
338language(codeLexer(RUST), 'rs', 'rust')
339language(codeLexer(SHELL), 'sh', 'bash', 'zsh', 'shell', 'console', 'fish')
340language(codeLexer(C_FAMILY), 'c', 'h', 'cpp', 'c++', 'cc', 'hpp', 'java', 'kotlin', 'kt', 'swift', 'cs', 'csharp', 'scala', 'dart')
341language(codeLexer(SQL), 'sql', 'postgres', 'postgresql', 'mysql', 'sqlite')
342language(codeLexer(JSON_SPEC), 'json', 'jsonc', 'json5')
343language(codeLexer(YAML), 'yaml', 'yml')
344language(codeLexer(TOML), 'toml', 'ini')
345language(codeLexer(CSS), 'css', 'scss', 'less')
346language(markup, 'html', 'xml', 'svg', 'vue', 'svelte')
347language(diff, 'diff', 'patch')
348
349export function highlight(source: string, languageName: string): Token[] {
350  const lexer = LEXERS_BY_LANGUAGE[languageName.toLowerCase()]
351  return lexer === undefined ? [{ text: source, kind: 'text' }] : lexer(source)
352}
353