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

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.
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
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.
| Skill | When to use |
|---|---|
file-pr | File or update a concise pull request |
babysit-pr | Monitor a PR through review bots and CI |
arena | Run competing attempts, then graft the strongest ideas into one result |
bro | Restate the last message in plain language |
verify | Prove a behavior change on the user surface with revision-bound evidence |
review | Filter adversarial findings by reachable failure and concrete evidence |
Claude Code mods: plugins of function hooks that change how Claude Code looks and behaves. They are Claude Code only.
| Mod | What it does |
|---|---|
glamour-dark | Draws replies as rich markdown in glamour's dark style, one sentence per line |
marathon-footer | Restyles the hints after the mode label under the prompt to match the SLOPOTHON banner: ⏵⏵ auto mode on · ⇧⇥ CYCLE // ← 1 AGENT |
redact-secrets | Swaps secrets for placeholders before Claude reads them or the session logs them; Write and Edit put the real values back |
tool-lines | Draws each tool call as one compact line and hides its result block |
ascii-spinner | Swaps the working line (verb, timer, tokens, effort, tip) for a one-row WEAVEworm rethreading synthsilk, in Sekiguchi colors |
slopothon | Paints 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.
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.
skills/<name>/SKILL.md with required name and description frontmatter.name matching the directory. Lowercase, hyphens only.references/; keep SKILL.md short enough to load on activation.See agentskills.io/specification.
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.
hooks/register.ts 46 lines1import 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}
46hooks/layout.ts 371 lines1import { 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}
371hooks/markdown.ts 468 lines1export 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}
468hooks/sentences.ts 83 lines1import { 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}
83hooks/style.ts 251 lines1type 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}
251hooks/highlight.ts 353 lines1import 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