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

Make your claude code look and feel better. Easier to read so you miss fewer things.
| Mod | What it does |
|---|---|
glamour-dark | Draws the model's text replies with glamour's default dark style |
tool-lines | Draws each tool call as one compact tool_call: Tool(input) - meta line and hides its result block |
Add this repo as a plugin marketplace, then install the mods you want:
claude plugin marketplace add Tickloop/claude-mods
claude plugin install glamour-dark@claude-mods
claude plugin install tool-lines@claude-mods
Or do the same from inside a session with /plugin marketplace add Tickloop/claude-mods, then pick the mods from /plugin. Run /reload-plugins to apply them without restarting.
To pick up a new release (a mod's version in its plugin.json changed):
claude plugin marketplace update claude-mods
claude plugin update glamour-dark@claude-mods
Then run /reload-plugins in any open session to switch to the new version.
Clone the repo, then start Claude Code from its root with both mods loaded:
claude --plugin-dir ./glamour-dark --plugin-dir ./tool-lines
--plugin-dir loads a mod for that session only. To load them every time, add an alias to your shell config (~/.zshrc or ~/.bashrc), pointing at where you cloned the repo:
alias claude='command claude --plugin-dir ~/path/to/claude-mods/glamour-dark --plugin-dir ~/path/to/claude-mods/tool-lines'hooks/register.ts 60 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
7// MARK: Layout
8
9/** The style replies are drawn in; any glamour theme converted to a StyleConfig drops in here. */
10const STYLE: StyleConfig = DARK
11
12/** Reshapes the parsed reply before it is laid out, in order; add one here to change how replies read. */
13const REWRITES: ((blocks: Block[]) => Block[])[] = [splitSentences]
14
15// The engine keeps a column clear at each edge of the transcript, so a line sized to the full width would spill.
16const GUTTER = 2
17// The viewport is absent until the terminal has been measured.
18const FALLBACK_COLUMNS = 120
19const BULLET = '●'
20
21// The bullet opening a reply sits in the document's left margin, where the engine draws its own.
22function withBullet(lines: Line[], paint: Paint): Line[] {
23 const [first, ...rest] = lines
24 const lead = first?.[0]
25 if (first === undefined || lead === undefined || !lead.text.startsWith(' ')) return lines
26 return [[{ text: BULLET, paint }, { ...lead, text: lead.text.slice(1) }, ...first.slice(1)], ...rest]
27}
28
29// glamour opens and closes a document with a blank line, drawn as the row's margins. The closing one lives here,
30// not in a wrapper of another plugin's, so it holds whichever plugin's hook runs first.
31const blankLines = (text = '') => text.split('\n').length - 1
32
33// MARK: Hooks
34
35export const register: Register = on => {
36 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
37 // A summary keeps the engine's faint "· summary" mark, which a tree of the mod's own would drop.
38 if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
39 const blocks = REWRITES.reduce((tree, rewrite) => rewrite(tree), parse(e.props.text))
40 if (blocks.length === 0) return next(e)
41
42 const { Box, Text, Link } = $.ui.resolve(e)
43 const columns = (e.viewport?.columns ?? FALLBACK_COLUMNS) - GUTTER
44 const laidOut = layout(blocks, STYLE, columns)
45 const lines = e.props.isFirstOfReply ? withBullet(laidOut, cascade({}, STYLE.document)) : laidOut
46
47 const drawSpan = (span: Span) => {
48 const text = Text({ ...span.paint, children: [span.text] })
49 return span.href === undefined ? text : Link({ href: span.href, children: [text] })
50 }
51 return Box({
52 flexDirection: 'column',
53 marginTop: blankLines(STYLE.document.block_prefix),
54 marginBottom: blankLines(STYLE.document.block_suffix),
55 // An empty Text takes no height, so a blank line between blocks is drawn as one space.
56 children: lines.map(line => Text({ children: line.length === 0 ? [' '] : line.map(drawSpan) })),
57 })
58 })
59}
60hooks/layout.ts 399 lines1import { highlight } from './highlight'
2import type { Align, Block, Inline, ListItem } from './markdown'
3import type { ChromaToken, StyleBlock, StyleColor, StyleConfig, StylePrimitive } from './style'
4
5// MARK: Painted lines
6
7/** The Text props a span is drawn with. */
8export type Paint = {
9 color?: string
10 backgroundColor?: string
11 bold?: boolean
12 italic?: boolean
13 underline?: boolean
14 strikethrough?: boolean
15 inverse?: boolean
16 dimColor?: boolean
17}
18
19export type Span = { text: string; paint: Paint; href?: string }
20
21/** One terminal row, already wrapped to the width it was laid out for. */
22export type Line = Span[]
23
24// MARK: Color
25
26// glamour sends an xterm-256 index as that palette entry, so the terminal paints it in its own palette as glamour would;
27// a hex value stays hex.
28export function colorOf(color: StyleColor): string {
29 return /^\d{1,3}$/.test(color) ? `ansi256(${color})` : color
30}
31
32/** The style's own fields win over the parent's, as glamour cascades a style down the tree. */
33export function cascade(parent: Paint, style: StylePrimitive): Paint {
34 const paint = { ...parent }
35 if (style.color !== undefined) paint.color = colorOf(style.color)
36 if (style.background_color !== undefined) paint.backgroundColor = colorOf(style.background_color)
37 if (style.bold !== undefined) paint.bold = style.bold
38 if (style.italic !== undefined) paint.italic = style.italic
39 if (style.underline !== undefined) paint.underline = style.underline
40 if (style.crossed_out !== undefined) paint.strikethrough = style.crossed_out
41 if (style.inverse !== undefined) paint.inverse = style.inverse
42 if (style.faint !== undefined) paint.dimColor = style.faint
43 return paint
44}
45
46/** What glamour's BaseElement writes around a token: block prefix, prefix, the formatted token, suffix, block suffix. */
47function decorate(style: StylePrimitive, token: string): string {
48 const text = style.format === undefined ? token : style.format.replace(/\{\{\s*\.text\s*\}\}/g, token)
49 return (style.block_prefix ?? '') + (style.prefix ?? '') + text + (style.suffix ?? '') + (style.block_suffix ?? '')
50}
51
52// MARK: Cells
53
54// Ranges the terminal draws two cells wide: CJK, Hangul, fullwidth forms and the emoji blocks.
55const WIDE: [number, number][] = [
56 [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
57 [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
58 [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
59]
60
61function charCells(ch: string): number {
62 const code = ch.codePointAt(0) ?? 0
63 const joins = (code >= 0x300 && code <= 0x36f) || (code >= 0x200b && code <= 0x200f) || (code >= 0xfe00 && code <= 0xfe0f)
64 if (joins) return 0
65 return WIDE.some(([low, high]) => code >= low && code <= high) ? 2 : 1
66}
67
68export function cellsOf(text: string): number {
69 let cells = 0
70 for (const ch of text) cells += charCells(ch)
71 return cells
72}
73
74function spansCells(spans: Span[]): number {
75 return spans.reduce((cells, span) => cells + cellsOf(span.text), 0)
76}
77
78// MARK: Wrapping
79
80type Word = { parts: Span[]; cells: number }
81type Piece = { kind: 'word'; word: Word } | { kind: 'space'; span: Span } | { kind: 'newline' }
82
83// Text that runs across spans with no space between (`foo**bar**`) is one word, so it never breaks there.
84function piecesOf(spans: Span[]): Piece[] {
85 const pieces: Piece[] = []
86 let continues = false
87 for (const span of spans) {
88 for (const text of span.text.split(/(\n| +)/)) {
89 if (text === '') continue
90 if (text === '\n') pieces.push({ kind: 'newline' })
91 else if (text.startsWith(' ')) pieces.push({ kind: 'space', span: { ...span, text } })
92 else {
93 const last = pieces[pieces.length - 1]
94 if (continues && last?.kind === 'word') {
95 last.word.parts.push({ ...span, text })
96 last.word.cells += cellsOf(text)
97 } else pieces.push({ kind: 'word', word: { parts: [{ ...span, text }], cells: cellsOf(text) } })
98 }
99 continues = text !== '\n' && !text.startsWith(' ')
100 }
101 }
102 return pieces
103}
104
105function samePaint(a: Paint, b: Paint): boolean {
106 const keys = new Set([...Object.keys(a), ...Object.keys(b)] as (keyof Paint)[])
107 return [...keys].every(key => a[key] === b[key])
108}
109
110// Text that looks the same joins the span before it, so a row is drawn with as few elements as it needs.
111function append(line: Line, from: Span, text: string): void {
112 const last = line[line.length - 1]
113 if (last !== undefined && last.href === from.href && samePaint(last.paint, from.paint)) last.text += text
114 else line.push({ ...from, text })
115}
116
117/** Greedy word wrap, as lipgloss.Wrap does: a space where a line breaks goes, a word wider than a line is cut. */
118export function wrap(spans: Span[], width: number): Line[] {
119 const lines: Line[] = [[]]
120 let used = 0
121 let gap: Span | undefined
122 // A line glamour starts itself keeps its leading spaces (a heading's padding); one the wrap starts drops them.
123 let hardStart = true
124 const line = () => lines[lines.length - 1] as Line
125 const newLine = (hard: boolean) => {
126 lines.push([])
127 used = 0
128 gap = undefined
129 hardStart = hard
130 }
131 for (const piece of piecesOf(spans)) {
132 if (piece.kind === 'newline') {
133 newLine(true)
134 continue
135 }
136 if (piece.kind === 'space') {
137 if (used > 0) gap = gap === undefined ? piece.span : { ...gap, text: gap.text + piece.span.text }
138 else if (hardStart) {
139 append(line(), piece.span, piece.span.text)
140 used += cellsOf(piece.span.text)
141 }
142 continue
143 }
144 const { word } = piece
145 const gapCells = gap === undefined ? 0 : cellsOf(gap.text)
146 if (used > 0 && used + gapCells + word.cells > width) newLine(false)
147 else if (gap !== undefined) {
148 append(line(), gap, gap.text)
149 used += gapCells
150 }
151 gap = undefined
152 if (word.cells <= width - used) {
153 for (const part of word.parts) append(line(), part, part.text)
154 used += word.cells
155 continue
156 }
157 for (const part of word.parts) {
158 for (const ch of part.text) {
159 const cells = charCells(ch)
160 if (used > 0 && used + cells > width) newLine(false)
161 append(line(), part, ch)
162 used += cells
163 }
164 }
165 }
166 return lines
167}
168
169// Code keeps its spacing, so a long line is cut at the edge rather than at a space.
170function cut(line: Line, width: number): Line[] {
171 const lines: Line[] = [[]]
172 let used = 0
173 for (const span of line) {
174 for (const ch of span.text) {
175 const cells = charCells(ch)
176 if (used > 0 && used + cells > width) {
177 lines.push([])
178 used = 0
179 }
180 append(lines[lines.length - 1] as Line, span, ch)
181 used += cells
182 }
183 }
184 return lines
185}
186
187// MARK: Inlines
188
189type InlineDrawer<K extends Inline['kind']> = (node: Extract<Inline, { kind: K }>, paint: Paint, style: StyleConfig) => Span[]
190
191function inlineSpans(nodes: Inline[], paint: Paint, style: StyleConfig): Span[] {
192 return nodes.flatMap(node => (INLINE_DRAWERS[node.kind] as InlineDrawer<typeof node.kind>)(node as never, paint, style))
193}
194
195// glamour's Go config pads code with no-break spaces so the padding never wraps away from the code.
196const glued = (text = '') => text.replace(/ /g, ' ')
197
198/** How each inline kind is drawn; add an entry here to draw a new kind. */
199const INLINE_DRAWERS: { [K in Inline['kind']]: InlineDrawer<K> } = {
200 text: (node, paint, style) => [{ text: node.text, paint: cascade(paint, style.text) }],
201 emph: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.emph), style),
202 strong: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.strong), style),
203 strike: (node, paint, style) => inlineSpans(node.children, cascade(paint, style.strikethrough), style),
204 code: (node, paint, style) => [
205 { text: glued(style.code.prefix) + node.text + glued(style.code.suffix), paint: cascade(paint, style.code) },
206 ],
207 link: (node, paint, style) => [
208 ...inlineSpans(node.children, cascade(paint, style.link_text), style).map(span => ({ ...span, href: node.href })),
209 { text: ' ', paint },
210 { text: node.href, paint: cascade(paint, style.link), href: node.href },
211 ],
212 autolink: (node, paint, style) => [{ text: node.href, paint: cascade(paint, style.link), href: node.href }],
213 image: (node, paint, style) => [
214 ...(node.alt === '' ? [] : [{ text: decorate(style.image_text, node.alt), paint: cascade(paint, style.image_text) }, { text: ' ', paint }]),
215 { text: node.href, paint: cascade(paint, style.image), href: node.href },
216 ],
217 break: (_, paint) => [{ text: '\n', paint }],
218}
219
220// MARK: Blocks
221
222type Context = { style: StyleConfig; width: number; paint: Paint; depth: number }
223
224type BlockDrawer<K extends Block['kind']> = (block: Extract<Block, { kind: K }>, context: Context) => Line[]
225
226function drawBlock(block: Block, context: Context): Line[] {
227 return (BLOCK_DRAWERS[block.kind] as BlockDrawer<typeof block.kind>)(block as never, context)
228}
229
230function drawBlocks(blocks: Block[], context: Context, gap: number): Line[] {
231 return blocks.flatMap((block, i) => [
232 ...Array.from({ length: i > 0 ? gap : 0 }, (): Line => []),
233 ...drawBlock(block, context),
234 ])
235}
236
237/** A block's margin and indent tokens, as glamour's margin writer puts them around every line. */
238function framed(rules: StyleBlock, context: Context, draw: (inner: Context) => Line[]): Line[] {
239 const margin = rules.margin ?? 0
240 const indent = (rules.indent_token ?? ' ').repeat(rules.indent ?? 0)
241 const lead = ' '.repeat(margin) + indent
242 const width = Math.max(context.width - margin * 2 - cellsOf(indent), 1)
243 const lines = draw({ ...context, width, paint: cascade(context.paint, rules) })
244 return lead === '' ? lines : lines.map(line => [{ text: lead, paint: context.paint }, ...line])
245}
246
247// The chroma classes a token falls back through when the theme leaves it unstyled.
248const CHROMA_PARENT: Partial<Record<ChromaToken, ChromaToken>> = {
249 keyword_reserved: 'keyword',
250 keyword_namespace: 'keyword',
251 keyword_type: 'keyword',
252 comment_preproc: 'comment',
253 name_builtin: 'name',
254 name_tag: 'name',
255 name_attribute: 'name',
256 name_class: 'name',
257 name_constant: 'name',
258 name_decorator: 'name',
259 name_exception: 'name',
260 name_function: 'name',
261 name_other: 'name',
262 literal_number: 'literal',
263 literal_date: 'literal',
264 literal_string: 'literal',
265 literal_string_escape: 'literal_string',
266}
267
268function lineageOf(kind: ChromaToken): ChromaToken[] {
269 const parent = CHROMA_PARENT[kind]
270 return parent === undefined ? [kind] : [...lineageOf(parent), kind]
271}
272
273function markerOf(list: Extract<Block, { kind: 'list' }>, item: ListItem, index: number, context: Context): Span {
274 const { style, paint } = context
275 if (item.checked !== undefined) {
276 return { text: decorate(style.task, (item.checked ? style.task.ticked : style.task.unticked) ?? ''), paint: cascade(paint, style.task) }
277 }
278 if (list.ordered) return { text: `${list.start + index}` + decorate(style.enumeration, ''), paint: cascade(paint, style.enumeration) }
279 return { text: decorate(style.item, ''), paint: cascade(paint, style.item) }
280}
281
282// The item's text hangs under its first line; a nested list keeps glamour's level indent from the list's edge.
283function itemLines(item: ListItem, marker: Span, context: Context): Line[] {
284 const hang = cellsOf(marker.text)
285 const lines: Line[] = []
286 for (const block of item.blocks) {
287 if (block.kind === 'list' && lines.length > 0) {
288 lines.push(...drawBlock(block, context))
289 continue
290 }
291 for (const line of drawBlock(block, { ...context, width: Math.max(context.width - hang, 1) })) {
292 lines.push([lines.length === 0 ? marker : { text: ' '.repeat(hang), paint: context.paint }, ...line])
293 }
294 }
295 return lines.length === 0 ? [[marker]] : lines
296}
297
298function aligned(line: Line, width: number, align: Align, paint: Paint): Line {
299 const room = Math.max(width - spansCells(line), 0)
300 const before = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
301 const pad = (cells: number): Line => (cells > 0 ? [{ text: ' '.repeat(cells), paint }] : [])
302 return [...pad(before), ...line, ...pad(room - before)]
303}
304
305// The widest column gives way first until the table fits, the way lipgloss shrinks a table to its width.
306function fitColumns(natural: number[], room: number): number[] {
307 const widths = [...natural]
308 const MIN = 3
309 while (widths.reduce((sum, w) => sum + w, 0) > room) {
310 const widest = widths.indexOf(Math.max(...widths))
311 if ((widths[widest] ?? 0) <= MIN) break
312 widths[widest] = (widths[widest] ?? 0) - 1
313 }
314 return widths
315}
316
317/** How each block kind is drawn; add an entry here to draw a new kind. */
318const BLOCK_DRAWERS: { [K in Block['kind']]: BlockDrawer<K> } = {
319 paragraph: (block, context) => wrap(inlineSpans(block.content, cascade(context.paint, context.style.paragraph), context.style), context.width),
320
321 heading: (block, context) => {
322 const { style } = context
323 const level = style[`h${block.level}` as 'h1'] ?? {}
324 const rules = { ...style.heading, ...level }
325 const paint = cascade(cascade(context.paint, style.heading), level)
326 const lines = wrap([{ text: rules.prefix ?? '', paint }, ...inlineSpans(block.content, paint, style)], context.width)
327 // glamour writes the suffix after the wrapped text, so it never moves to a line of its own.
328 const last = lines[lines.length - 1]
329 if (rules.suffix && last !== undefined) append(last, { text: rules.suffix, paint }, rules.suffix)
330 return lines
331 },
332
333 code: (block, context) =>
334 framed(context.style.code_block, context, inner => {
335 const { chroma } = context.style.code_block
336 const base = chroma === undefined ? inner.paint : [chroma.background, chroma.text].reduce<Paint>((paint, rules) => cascade(paint, rules ?? {}), {})
337 const tokens = chroma === undefined ? [{ text: block.text, kind: 'text' as const }] : highlight(block.text, block.language)
338 const lines: Line[] = [[]]
339 for (const token of tokens) {
340 const paint = chroma === undefined ? base : lineageOf(token.kind).reduce((p, kind) => cascade(p, chroma[kind] ?? {}), base)
341 token.text.split('\n').forEach((text, i) => {
342 if (i > 0) lines.push([])
343 if (text !== '') append(lines[lines.length - 1] as Line, { text, paint }, text)
344 })
345 }
346 return lines.flatMap(line => cut(line, inner.width))
347 }),
348
349 quote: (block, context) => framed(context.style.block_quote, context, inner => drawBlocks(block.blocks, inner, 1)),
350
351 list: (block, context) => {
352 const { list } = context.style
353 // glamour indents a list by level_indent only when it sits inside another list.
354 const rules = { ...list, indent: context.depth > 0 ? list.level_indent ?? 0 : list.indent ?? 0 }
355 return framed(rules, { ...context, depth: context.depth + 1 }, inner =>
356 block.items.flatMap((item, i) => itemLines(item, markerOf(block, item, i, inner), inner)),
357 )
358 },
359
360 hr: (_, context) => {
361 const rule = decorate(context.style.hr, '').replace(/^\n+|\n+$/g, '')
362 const paint = cascade(context.paint, context.style.hr)
363 return rule.split('\n').map(text => [{ text, paint }])
364 },
365
366 table: (block, context) => {
367 const { style } = context
368 const paint = cascade(context.paint, style.table)
369 const head = block.head.map(cell => inlineSpans(cell, paint, style))
370 const rows = block.rows.map(row => row.map(cell => inlineSpans(cell, paint, style)))
371 const natural = block.align.map((_, c) => Math.max(1, ...[head, ...rows].map(row => spansCells(row[c] ?? []))))
372 // Each cell has a space on either side, and a separator stands between columns.
373 const frame = block.align.length * 3 - 1
374 const widths = fitColumns(natural, context.width - frame)
375 const separator = style.table.column_separator ?? '│'
376 const rowLines = (cells: Span[][]): Line[] => {
377 const wrapped = widths.map((width, c) => wrap(cells[c] ?? [], width))
378 const height = Math.max(...wrapped.map(lines => lines.length))
379 return Array.from({ length: height }, (_, r) =>
380 widths.flatMap((width, c): Line => [
381 ...(c > 0 ? [{ text: separator, paint }] : []),
382 { text: ' ', paint },
383 ...aligned(wrapped[c]?.[r] ?? [], width, block.align[c] ?? 'none', paint),
384 { text: ' ', paint },
385 ]),
386 )
387 }
388 const rule = widths.map(width => (style.table.row_separator ?? '─').repeat(width + 2)).join(style.table.center_separator ?? '┼')
389 return [...rowLines(head), [{ text: rule, paint }], ...rows.flatMap(rowLines)]
390 },
391}
392
393// MARK: Document
394
395/** Lays a parsed reply out as glamour would print it at `columns` cells wide. */
396export function layout(blocks: Block[], style: StyleConfig, columns: number): Line[] {
397 return framed(style.document, { style, width: columns, paint: {}, depth: 0 }, inner => drawBlocks(blocks, inner, 1))
398}
399hooks/markdown.ts 474 lines1// MARK: Tree
2
3export type Inline =
4 | { kind: 'text'; text: string }
5 | { kind: 'emph'; children: Inline[] }
6 | { kind: 'strong'; children: Inline[] }
7 | { kind: 'strike'; children: Inline[] }
8 | { kind: 'code'; text: string }
9 | { kind: 'link'; href: string; children: Inline[] }
10 | { kind: 'autolink'; href: string }
11 | { kind: 'image'; href: string; alt: string }
12 | { kind: 'break' }
13
14export type Align = 'left' | 'center' | 'right' | 'none'
15
16/** `checked` is present only on a task item (`- [x] done`). */
17export type ListItem = { checked?: boolean; blocks: Block[] }
18
19export type Block =
20 | { kind: 'heading'; level: number; content: Inline[] }
21 | { kind: 'paragraph'; content: Inline[] }
22 | { kind: 'code'; language: string; text: string }
23 | { kind: 'quote'; blocks: Block[] }
24 | { kind: 'list'; ordered: boolean; start: number; items: ListItem[] }
25 | { kind: 'hr' }
26 | { kind: 'table'; align: Align[]; head: Inline[][]; rows: Inline[][][] }
27
28// MARK: Blocks
29
30const BLANK = /^\s*$/
31const FENCE = /^( {0,3})(`{3,}|~{3,})[ \t]*([^\s`]*)/
32const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
33const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/
34const QUOTE = /^ {0,3}> ?(.*)$/
35const ITEM = /^( {0,3})([-+*]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/
36const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/
37const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/
38
39type Hit = { block: Block; next: number }
40
41/** Each rule claims the block starting at `at`, or passes; add one here to read a new block kind. */
42type BlockRule = (lines: string[], at: number) => Hit | undefined
43
44const fence: BlockRule = (lines, at) => {
45 const open = FENCE.exec(lines[at] ?? '')
46 if (open === null) return undefined
47 const [, indent = '', marker = '', language = ''] = open
48 const close = new RegExp(`^ {0,3}${marker[0] === '`' ? '`' : '~'}{${marker.length},}[ \\t]*$`)
49 const body: string[] = []
50 let i = at + 1
51 // A fence still streaming in has no closer yet, so the rest of the reply is its body.
52 for (; i < lines.length; i++) {
53 const line = lines[i] ?? ''
54 if (close.test(line)) {
55 i++
56 break
57 }
58 body.push(dropColumns(line, Math.min(indent.length, leadingColumns(line))))
59 }
60 return { block: { kind: 'code', language, text: body.join('\n') }, next: i }
61}
62
63const heading: BlockRule = (lines, at) => {
64 const match = ATX.exec(lines[at] ?? '')
65 if (match === null) return undefined
66 const [, hashes = '', text = ''] = match
67 return { block: { kind: 'heading', level: hashes.length, content: parseInline(text.trim()) }, next: at + 1 }
68}
69
70const rule: BlockRule = (lines, at) => (HR.test(lines[at] ?? '') ? { block: { kind: 'hr' }, next: at + 1 } : undefined)
71
72const quote: BlockRule = (lines, at) => {
73 if (!QUOTE.test(lines[at] ?? '')) return undefined
74 const body: string[] = []
75 let i = at
76 for (; i < lines.length; i++) {
77 const line = lines[i] ?? ''
78 const match = QUOTE.exec(line)
79 if (match !== null) body.push(match[1] ?? '')
80 // A lazy line carries on the quoted paragraph without its own `>`.
81 else if (!BLANK.test(line) && !BLANK.test(body[body.length - 1] ?? '') && !startsBlock(lines, i)) body.push(line)
82 else break
83 }
84 return { block: { kind: 'quote', blocks: parseBlocks(body) }, next: i }
85}
86
87const list: BlockRule = (lines, at) => {
88 const first = ITEM.exec(lines[at] ?? '')
89 if (first === null) return undefined
90 const marker = first[2] ?? ''
91 const ordered = /\d/.test(marker)
92 const items: ListItem[] = []
93 let i = at
94 while (i < lines.length) {
95 const match = ITEM.exec(lines[i] ?? '')
96 if (match === null || !sameList(marker, match[2] ?? '')) break
97 const [, indent = '', bullet = '', gap = ' ', rest = ''] = match
98 // Content sits after the marker and its gap; a gap of five or more starts the content after one space.
99 const column = indent.length + bullet.length + (gap.length > 4 || rest === '' ? 1 : gap.length)
100 const body = [rest]
101 for (i++; i < lines.length; i++) {
102 const line = lines[i] ?? ''
103 if (BLANK.test(line)) body.push('')
104 else if (leadingColumns(line) >= column) body.push(dropColumns(line, column))
105 else if (!BLANK.test(body[body.length - 1] ?? '') && !startsBlock(lines, i)) body.push(line.trim())
106 else break
107 }
108 const task = /^\[([ xX])\](?:[ \t]+|$)/.exec(body[0] ?? '')
109 if (task !== null) body[0] = (body[0] ?? '').slice(task[0].length)
110 items.push({ ...(task !== null && { checked: task[1] !== ' ' }), blocks: parseBlocks(body) })
111 }
112 const start = ordered ? Number.parseInt(marker, 10) : 1
113 return { block: { kind: 'list', ordered, start, items }, next: i }
114}
115
116const table: BlockRule = (lines, at) => {
117 const head = lines[at] ?? ''
118 const delimiters = lines[at + 1] ?? ''
119 if (!head.includes('|') || !DELIMITER_ROW.test(delimiters)) return undefined
120 const align = cellsOf(delimiters).map(alignOf)
121 const headCells = cellsOf(head)
122 if (headCells.length !== align.length) return undefined
123 const rows: Inline[][][] = []
124 let i = at + 2
125 for (; i < lines.length; i++) {
126 const line = lines[i] ?? ''
127 if (BLANK.test(line) || !line.includes('|') || startsBlock(lines, i)) break
128 const cells = cellsOf(line)
129 rows.push(align.map((_, column) => parseInline(cells[column] ?? '')))
130 }
131 return { block: { kind: 'table', align, head: headCells.map(parseInline), rows }, next: i }
132}
133
134const BLOCK_RULES: BlockRule[] = [fence, heading, rule, quote, list, table]
135
136// The rules that may cut a paragraph short; a table needs a paragraph line as its header, so it is checked apart.
137const INTERRUPTS: BlockRule[] = [fence, heading, rule, quote, nonEmptyItem]
138
139function nonEmptyItem(lines: string[], at: number): Hit | undefined {
140 return ITEM.exec(lines[at] ?? '')?.[4]?.trim() ? list(lines, at) : undefined
141}
142
143function startsBlock(lines: string[], at: number): boolean {
144 return INTERRUPTS.some(read => read([lines[at] ?? ''], 0) !== undefined)
145}
146
147function paragraph(lines: string[], at: number): Hit {
148 const body = [lines[at] ?? '']
149 let i = at + 1
150 for (; i < lines.length; i++) {
151 const line = lines[i] ?? ''
152 if (BLANK.test(line)) break
153 const underline = SETEXT.exec(line)
154 if (underline !== null) {
155 const level = underline[1]?.startsWith('=') ? 1 : 2
156 return { block: { kind: 'heading', level, content: parseInline(body.join('\n').trim()) }, next: i + 1 }
157 }
158 if (startsBlock(lines, i) || table(lines, i) !== undefined) break
159 body.push(line)
160 }
161 // Only leading space goes: two trailing spaces mark a hard break.
162 const text = body.map(line => line.trimStart()).join('\n').trimEnd()
163 return { block: { kind: 'paragraph', content: parseInline(text) }, next: i }
164}
165
166function parseBlocks(lines: string[]): Block[] {
167 const blocks: Block[] = []
168 let i = 0
169 while (i < lines.length) {
170 if (BLANK.test(lines[i] ?? '')) {
171 i++
172 continue
173 }
174 const hit = BLOCK_RULES.reduce<Hit | undefined>((found, read) => found ?? read(lines, i), undefined) ?? paragraph(lines, i)
175 blocks.push(hit.block)
176 i = hit.next
177 }
178 return blocks
179}
180
181export function parse(markdown: string): Block[] {
182 return parseBlocks(markdown.replace(/\r\n?/g, '\n').split('\n').map(expandLeadingTabs))
183}
184
185// MARK: Block helpers
186
187function expandLeadingTabs(line: string): string {
188 const lead = /^[ \t]*/.exec(line)?.[0] ?? ''
189 let columns = 0
190 for (const ch of lead) columns = ch === '\t' ? columns + 4 - (columns % 4) : columns + 1
191 return ' '.repeat(columns) + line.slice(lead.length)
192}
193
194function leadingColumns(line: string): number {
195 return line.length - line.trimStart().length
196}
197
198function dropColumns(line: string, columns: number): string {
199 return line.slice(Math.min(columns, leadingColumns(line)))
200}
201
202function sameList(first: string, next: string): boolean {
203 const ordered = /\d/.test(first)
204 return ordered ? /\d/.test(next) && first.slice(-1) === next.slice(-1) : first === next
205}
206
207function cellsOf(row: string): string[] {
208 const trimmed = row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
209 return trimmed.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'))
210}
211
212function alignOf(cell: string): Align {
213 const left = cell.startsWith(':')
214 const right = cell.endsWith(':')
215 return left && right ? 'center' : right ? 'right' : left ? 'left' : 'none'
216}
217
218// MARK: Inlines
219
220type Delimiter = { kind: 'delimiter'; char: string; count: number; canOpen: boolean; canClose: boolean }
221type Piece = Inline | Delimiter
222
223const ESCAPABLE = /[!-/:-@[-`{-~]/
224const PUNCTUATION = /[\p{P}\p{S}]/u
225const AUTOLINK = /^<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)>/
226const BARE_URL = /^(?:https?:\/\/|www\.)[^\s<]*/
227const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
228
229export function parseInline(source: string): Inline[] {
230 const pieces: Piece[] = []
231 let text = ''
232 const flush = () => {
233 if (text !== '') pieces.push({ kind: 'text', text })
234 text = ''
235 }
236 let i = 0
237 while (i < source.length) {
238 const ch = source[i] ?? ''
239 const rest = source.slice(i)
240
241 if (ch === '\\' && source[i + 1] === '\n') {
242 flush()
243 pieces.push({ kind: 'break' })
244 i += 2
245 continue
246 }
247 if (ch === '\\' && ESCAPABLE.test(source[i + 1] ?? '')) {
248 text += source[i + 1]
249 i += 2
250 continue
251 }
252 if (ch === '`') {
253 const run = /^`+/.exec(rest)?.[0] ?? '`'
254 const end = closingRun(source, i + run.length, run)
255 if (end === -1) {
256 text += run
257 i += run.length
258 continue
259 }
260 flush()
261 pieces.push({ kind: 'code', text: codeSpanText(source.slice(i + run.length, end)) })
262 i = end + run.length
263 continue
264 }
265 if (ch === '!' && source[i + 1] === '[') {
266 const link = linkAt(source, i + 1)
267 if (link !== undefined) {
268 flush()
269 pieces.push({ kind: 'image', href: link.href, alt: plainText(parseInline(link.label)) })
270 i = link.end
271 continue
272 }
273 }
274 if (ch === '[') {
275 const link = linkAt(source, i)
276 if (link !== undefined) {
277 flush()
278 pieces.push({ kind: 'link', href: link.href, children: parseInline(link.label) })
279 i = link.end
280 continue
281 }
282 }
283 if (ch === '<') {
284 const auto = AUTOLINK.exec(rest)
285 if (auto !== null) {
286 flush()
287 pieces.push({ kind: 'autolink', href: auto[1] ?? '' })
288 i += auto[0].length
289 continue
290 }
291 }
292 if ((ch === 'h' || ch === 'w') && /^$|[\s*_~(]$/.test(source[i - 1] ?? '')) {
293 const url = BARE_URL.exec(rest)?.[0]
294 if (url !== undefined) {
295 const href = trimUrl(url)
296 flush()
297 pieces.push({ kind: 'autolink', href })
298 i += href.length
299 continue
300 }
301 }
302 if (ch === '*' || ch === '_' || ch === '~') {
303 const run = new RegExp(`^\\${ch}+`).exec(rest)?.[0] ?? ch
304 flush()
305 pieces.push(delimiterOf(ch, run.length, source[i - 1] ?? ' ', source[i + run.length] ?? ' '))
306 i += run.length
307 continue
308 }
309 if (ch === '\n') {
310 // A soft break flows into the paragraph as a space, as glamour joins lines unless told to keep them.
311 const hard = text.endsWith(' ')
312 text = text.trimEnd()
313 if (hard) {
314 flush()
315 pieces.push({ kind: 'break' })
316 } else text += ' '
317 i++
318 while (source[i] === ' ') i++
319 continue
320 }
321 if (ch === '&') {
322 const entity = /^&(#\d{1,7}|#[xX][\da-fA-F]{1,6}|[a-zA-Z]+);/.exec(rest)
323 const decoded = entity === null ? undefined : decodeEntity(entity[1] ?? '')
324 if (entity !== null && decoded !== undefined) {
325 text += decoded
326 i += entity[0].length
327 continue
328 }
329 }
330 text += ch
331 i++
332 }
333 flush()
334 return resolveEmphasis(pieces)
335}
336
337// MARK: Inline helpers
338
339function closingRun(source: string, from: number, run: string): number {
340 for (let at = source.indexOf(run, from); at !== -1; at = source.indexOf(run, at + 1)) {
341 const longer = source[at + run.length] === '`' || source[at - 1] === '`'
342 if (!longer) return at
343 }
344 return -1
345}
346
347function codeSpanText(raw: string): string {
348 const flat = raw.replace(/\n/g, ' ')
349 return /^ .*[^ ].* $/.test(flat) ? flat.slice(1, -1) : flat
350}
351
352type LinkMatch = { label: string; href: string; end: number }
353
354function linkAt(source: string, open: number): LinkMatch | undefined {
355 let depth = 0
356 let close = -1
357 for (let i = open; i < source.length; i++) {
358 const ch = source[i]
359 if (ch === '\\') i++
360 else if (ch === '`') i = Math.max(i, closingRun(source, i + 1, '`'))
361 else if (ch === '[') depth++
362 else if (ch === ']' && --depth === 0) {
363 close = i
364 break
365 }
366 }
367 if (close === -1 || source[close + 1] !== '(') return undefined
368 const target = /^\(\s*(?:<([^<>\n]*)>|((?:[^\s()\\]|\\.|\((?:[^\s()\\]|\\.)*\))*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/.exec(
369 source.slice(close + 1),
370 )
371 if (target === null) return undefined
372 return { label: source.slice(open + 1, close), href: target[1] ?? target[2] ?? '', end: close + 1 + target[0].length }
373}
374
375// Trailing punctuation reads as the sentence's, and a closing paren only belongs to the URL when it opened one.
376function trimUrl(url: string): string {
377 let href = url.replace(/[?!.,:*_~'"]+$/, '')
378 while (href.endsWith(')') && (href.match(/\(/g)?.length ?? 0) < (href.match(/\)/g)?.length ?? 0)) {
379 href = href.slice(0, -1).replace(/[?!.,:*_~'"]+$/, '')
380 }
381 return href
382}
383
384function decodeEntity(name: string): string | undefined {
385 if (name.startsWith('#')) {
386 const code = name[1] === 'x' || name[1] === 'X' ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10)
387 return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : undefined
388 }
389 return ENTITIES[name]
390}
391
392// Flanking follows CommonMark: `_` also refuses to open or close inside a word, so snake_case stays plain.
393function delimiterOf(char: string, count: number, before: string, after: string): Delimiter {
394 const spaceBefore = /\s/.test(before)
395 const spaceAfter = /\s/.test(after)
396 const punctBefore = PUNCTUATION.test(before)
397 const punctAfter = PUNCTUATION.test(after)
398 const leftFlanking = !spaceAfter && (!punctAfter || spaceBefore || punctBefore)
399 const rightFlanking = !spaceBefore && (!punctBefore || spaceAfter || punctAfter)
400 if (char !== '_') return { kind: 'delimiter', char, count, canOpen: leftFlanking, canClose: rightFlanking }
401 return {
402 kind: 'delimiter',
403 char,
404 count,
405 canOpen: leftFlanking && (!rightFlanking || punctBefore),
406 canClose: rightFlanking && (!leftFlanking || punctAfter),
407 }
408}
409
410function isDelimiter(piece: Piece | undefined): piece is Delimiter {
411 return piece?.kind === 'delimiter'
412}
413
414function opens(opener: Delimiter, closer: Delimiter): boolean {
415 if (opener.char !== closer.char || !opener.canOpen) return false
416 if (closer.char === '~') return opener.count === closer.count && opener.count <= 2
417 // CommonMark's rule of three keeps `*a**b*` from pairing the wrong runs.
418 const both = opener.canClose || closer.canOpen
419 return !(both && (opener.count + closer.count) % 3 === 0 && !(opener.count % 3 === 0 && closer.count % 3 === 0))
420}
421
422function resolveEmphasis(input: Piece[]): Inline[] {
423 const pieces = [...input]
424 for (let c = 0; c < pieces.length; c++) {
425 const closer = pieces[c]
426 if (!isDelimiter(closer) || !closer.canClose) continue
427 let o = c - 1
428 while (o >= 0 && !(isDelimiter(pieces[o]) && opens(pieces[o] as Delimiter, closer))) o--
429 if (o < 0) continue
430 const opener = pieces[o] as Delimiter
431 const used = closer.char === '~' ? closer.count : opener.count >= 2 && closer.count >= 2 ? 2 : 1
432 const kind = closer.char === '~' ? 'strike' : used === 2 ? 'strong' : 'emph'
433 const node: Inline = { kind, children: toInlines(pieces.slice(o + 1, c)) }
434 const left = opener.count > used ? [{ ...opener, count: opener.count - used }] : []
435 const right = closer.count > used ? [{ ...closer, count: closer.count - used }] : []
436 pieces.splice(o, c - o + 1, ...left, node, ...right)
437 // The closer's leftover may close an earlier opener, so the scan resumes right after the new node.
438 c = o + left.length
439 }
440 return toInlines(pieces)
441}
442
443// Unmatched delimiters fall back to text and join their neighbours, so `snake_case` stays one text node.
444function toInlines(pieces: Piece[]): Inline[] {
445 const inlines: Inline[] = []
446 for (const piece of pieces) {
447 const node: Inline = isDelimiter(piece) ? { kind: 'text', text: piece.char.repeat(piece.count) } : piece
448 const last = inlines[inlines.length - 1]
449 if (node.kind === 'text' && last?.kind === 'text') inlines[inlines.length - 1] = { kind: 'text', text: last.text + node.text }
450 else inlines.push(node)
451 }
452 return inlines
453}
454
455export function plainText(inlines: Inline[]): string {
456 return inlines
457 .map(node => {
458 switch (node.kind) {
459 case 'text':
460 case 'code':
461 return node.text
462 case 'autolink':
463 return node.href
464 case 'image':
465 return node.alt
466 case 'break':
467 return '\n'
468 default:
469 return plainText(node.children)
470 }
471 })
472 .join('')
473}
474hooks/sentences.ts 78 lines1import { plainText, type Block, type Inline } from './markdown'
2
3// MARK: Sentence ends
4
5// Words whose period ends the word, not the sentence, even before a capital ("e.g. Python").
6const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|cf|approx|al|Mr|Mrs|Ms|Dr|St|No|Fig)\.$/i
7// A lone capital before a period is an initial ("J. Smith").
8const INITIAL = /(?:^|\s)\p{Lu}\.$/u
9const SENTENCE_END = /[.!?]["'”’)\]]*$/
10
11function endsSentence(before: string): boolean {
12 return SENTENCE_END.test(before) && !ABBREVIATION.test(before) && !INITIAL.test(before)
13}
14
15// A lowercase word after the period reads as the same sentence going on ("approx. ten", "v2. then").
16function startsSentence(ch: string | undefined): boolean {
17 return ch !== undefined && !/\p{Ll}/u.test(ch)
18}
19
20function opensSentence(node: Inline | undefined): boolean {
21 if (node === undefined || node.kind === 'break') return false
22 if (node.kind === 'text' || node.kind === 'emph' || node.kind === 'strong' || node.kind === 'strike') {
23 return startsSentence(plainText([node])[0])
24 }
25 // Code, a link or an image after a full stop starts the next sentence.
26 return true
27}
28
29// MARK: Inlines
30
31// The space after a sentence becomes a line break; `before` is the text the node follows, for a period it ended with.
32function splitText(text: string, before: string, next: Inline | undefined): Inline[] {
33 const out: Inline[] = []
34 let start = 0
35 for (const gap of text.matchAll(/\s+/g)) {
36 const at = gap.index ?? 0
37 const end = at + gap[0].length
38 const opens = end < text.length ? startsSentence(text[end]) : opensSentence(next)
39 if (!opens || !endsSentence(before + text.slice(0, at))) continue
40 if (at > start) out.push({ kind: 'text', text: text.slice(start, at) })
41 out.push({ kind: 'break' })
42 start = end
43 }
44 if (start < text.length) out.push({ kind: 'text', text: text.slice(start) })
45 return out
46}
47
48// A link's label is left whole: a break inside it would split one link over two lines.
49function splitInlines(nodes: Inline[]): Inline[] {
50 const out: Inline[] = []
51 nodes.forEach((node, i) => {
52 if (node.kind === 'emph' || node.kind === 'strong' || node.kind === 'strike') {
53 out.push({ ...node, children: splitInlines(node.children) })
54 } else if (node.kind === 'text') {
55 out.push(...splitText(node.text, plainText(out.slice(-1)), nodes[i + 1]))
56 } else out.push(node)
57 })
58 return out
59}
60
61// MARK: Blocks
62
63/** Starts each sentence of the reply's prose on a line of its own; headings, tables and code keep their lines. */
64export function splitSentences(blocks: Block[]): Block[] {
65 return blocks.map((block): Block => {
66 switch (block.kind) {
67 case 'paragraph':
68 return { ...block, content: splitInlines(block.content) }
69 case 'quote':
70 return { ...block, blocks: splitSentences(block.blocks) }
71 case 'list':
72 return { ...block, items: block.items.map(item => ({ ...item, blocks: splitSentences(item.blocks) })) }
73 default:
74 return block
75 }
76 })
77}
78hooks/style.ts 259 lines1// MARK: Shape
2
3// Keys and values follow glamour's style JSON (ansi.StyleConfig), so any glamour theme file
4// (dracula.json, tokyo-night.json, ...) can be pasted in as another StyleConfig unchanged.
5
6/** A color as glamour writes it: an xterm-256 index ("252") or a hex value ("#C4C4C4"). */
7export type StyleColor = string
8
9export type StylePrimitive = {
10 block_prefix?: string
11 block_suffix?: string
12 prefix?: string
13 suffix?: string
14 color?: StyleColor
15 background_color?: StyleColor
16 underline?: boolean
17 bold?: boolean
18 italic?: boolean
19 crossed_out?: boolean
20 faint?: boolean
21 inverse?: boolean
22 /** A Go template whose `{{.text}}` is replaced by the element's text. */
23 format?: string
24}
25
26export type StyleBlock = StylePrimitive & {
27 indent?: number
28 indent_token?: string
29 margin?: number
30}
31
32export type StyleList = StyleBlock & {
33 level_indent?: number
34}
35
36export type StyleTask = StylePrimitive & {
37 ticked?: string
38 unticked?: string
39}
40
41/** The chroma token classes glamour themes a code block by. */
42export type ChromaToken =
43 | 'text'
44 | 'error'
45 | 'comment'
46 | 'comment_preproc'
47 | 'keyword'
48 | 'keyword_reserved'
49 | 'keyword_namespace'
50 | 'keyword_type'
51 | 'operator'
52 | 'punctuation'
53 | 'name'
54 | 'name_builtin'
55 | 'name_tag'
56 | 'name_attribute'
57 | 'name_class'
58 | 'name_constant'
59 | 'name_decorator'
60 | 'name_exception'
61 | 'name_function'
62 | 'name_other'
63 | 'literal'
64 | 'literal_number'
65 | 'literal_date'
66 | 'literal_string'
67 | 'literal_string_escape'
68 | 'generic_deleted'
69 | 'generic_emph'
70 | 'generic_inserted'
71 | 'generic_strong'
72 | 'generic_subheading'
73 | 'background'
74
75export type StyleCodeBlock = StyleBlock & {
76 theme?: string
77 chroma?: Partial<Record<ChromaToken, StylePrimitive>>
78}
79
80export type StyleTable = StyleBlock & {
81 center_separator?: string
82 column_separator?: string
83 row_separator?: string
84}
85
86export type StyleConfig = {
87 document: StyleBlock
88 block_quote: StyleBlock
89 paragraph: StyleBlock
90 list: StyleList
91 heading: StyleBlock
92 h1: StyleBlock
93 h2: StyleBlock
94 h3: StyleBlock
95 h4: StyleBlock
96 h5: StyleBlock
97 h6: StyleBlock
98 text: StylePrimitive
99 strikethrough: StylePrimitive
100 emph: StylePrimitive
101 strong: StylePrimitive
102 hr: StylePrimitive
103 item: StylePrimitive
104 enumeration: StylePrimitive
105 task: StyleTask
106 link: StylePrimitive
107 link_text: StylePrimitive
108 image: StylePrimitive
109 image_text: StylePrimitive
110 code: StyleBlock
111 code_block: StyleCodeBlock
112 table: StyleTable
113 definition_list: StyleBlock
114 definition_term: StylePrimitive
115 definition_description: StylePrimitive
116 html_block: StyleBlock
117 html_span: StyleBlock
118}
119
120// MARK: Dark
121
122// glamour's default dark style: styles/dark.json on charmbracelet/glamour master.
123export const DARK: StyleConfig = {
124 document: {
125 block_prefix: '\n',
126 block_suffix: '\n',
127 color: '252',
128 margin: 2,
129 },
130 block_quote: {
131 indent: 1,
132 indent_token: '│ ',
133 },
134 paragraph: {},
135 list: {
136 level_indent: 2,
137 },
138 heading: {
139 block_suffix: '\n',
140 color: '39',
141 bold: true,
142 },
143 h1: {
144 prefix: ' ',
145 suffix: ' ',
146 color: '228',
147 background_color: '63',
148 bold: true,
149 },
150 h2: {
151 prefix: '## ',
152 },
153 h3: {
154 prefix: '### ',
155 },
156 h4: {
157 prefix: '#### ',
158 },
159 h5: {
160 prefix: '##### ',
161 },
162 h6: {
163 prefix: '###### ',
164 color: '35',
165 bold: false,
166 },
167 text: {},
168 strikethrough: {
169 crossed_out: true,
170 },
171 emph: {
172 italic: true,
173 },
174 strong: {
175 bold: true,
176 },
177 hr: {
178 color: '240',
179 format: '\n--------\n',
180 },
181 item: {
182 block_prefix: '• ',
183 },
184 enumeration: {
185 block_prefix: '. ',
186 },
187 task: {
188 ticked: '[✓] ',
189 unticked: '[ ] ',
190 },
191 link: {
192 color: '30',
193 underline: true,
194 },
195 link_text: {
196 color: '35',
197 bold: true,
198 },
199 image: {
200 color: '212',
201 underline: true,
202 },
203 image_text: {
204 color: '243',
205 format: 'Image: {{.text}} →',
206 },
207 code: {
208 prefix: ' ',
209 suffix: ' ',
210 color: '203',
211 background_color: '236',
212 },
213 code_block: {
214 color: '244',
215 margin: 2,
216 chroma: {
217 text: { color: '#C4C4C4' },
218 error: { color: '#F1F1F1', background_color: '#F05B5B' },
219 comment: { color: '#676767' },
220 comment_preproc: { color: '#FF875F' },
221 keyword: { color: '#00AAFF' },
222 keyword_reserved: { color: '#FF5FD2' },
223 keyword_namespace: { color: '#FF5F87' },
224 keyword_type: { color: '#6E6ED8' },
225 operator: { color: '#EF8080' },
226 punctuation: { color: '#E8E8A8' },
227 name: { color: '#C4C4C4' },
228 name_builtin: { color: '#FF8EC7' },
229 name_tag: { color: '#B083EA' },
230 name_attribute: { color: '#7A7AE6' },
231 name_class: { color: '#F1F1F1', underline: true, bold: true },
232 name_constant: {},
233 name_decorator: { color: '#FFFF87' },
234 name_exception: {},
235 name_function: { color: '#00D787' },
236 name_other: {},
237 literal: {},
238 literal_number: { color: '#6EEFC0' },
239 literal_date: {},
240 literal_string: { color: '#C69669' },
241 literal_string_escape: { color: '#AFFFD7' },
242 generic_deleted: { color: '#FD5B5B' },
243 generic_emph: { italic: true },
244 generic_inserted: { color: '#00D787' },
245 generic_strong: { bold: true },
246 generic_subheading: { color: '#777777' },
247 background: { background_color: '#373737' },
248 },
249 },
250 table: {},
251 definition_list: {},
252 definition_term: {},
253 definition_description: {
254 block_prefix: '\n🠶 ',
255 },
256 html_block: {},
257 html_span: {},
258}
259hooks/highlight.ts 373 lines1import type { ChromaToken } from './style'
2
3// MARK: Contract
4
5export type Token = { text: string; kind: ChromaToken }
6
7/** Splits source into tokens tagged with the chroma class glamour's theme colors them by. */
8export type Lexer = (source: string) => Token[]
9
10// MARK: Code lexer
11
12/** What tells one C-like or script language from another; a new language is one more spec. */
13export type LanguageSpec = {
14 keywords?: string[]
15 reserved?: string[]
16 namespaces?: string[]
17 types?: string[]
18 builtins?: string[]
19 /** Keywords whose next name is a function being declared. */
20 functionAfter?: string[]
21 /** Keywords whose next name is a class, struct or trait being declared. */
22 classAfter?: string[]
23 lineComments?: string[]
24 blockComment?: [string, string]
25 /** Longest first, so `"""` wins over `"`. */
26 quotes?: string[]
27 decorators?: boolean
28 /** A whole line read as a preprocessor directive (`#include`). */
29 preprocessor?: RegExp
30 /** A key at the start of a line (`name:` in YAML), drawn as a tag. */
31 lineKey?: RegExp
32 /** A string followed by `:` is a key (JSON). */
33 stringKeys?: boolean
34 ignoreCase?: boolean
35}
36
37const NUMBER = /^(?:0[xX][\da-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[a-zA-Z]*/
38const NAME = /^[A-Za-z_$][\w$]*/
39const OPERATOR = /^(?:=>|->|::|[-+*/%=<>!&|^~?:]+)/
40const PUNCTUATION = /^[()[\]{};,.@#]/
41const SPACE = /^\s+/
42
43export function codeLexer(spec: LanguageSpec): Lexer {
44 const fold = (word: string) => (spec.ignoreCase ? word.toLowerCase() : word)
45 const setOf = (words: string[] = []) => new Set(words.map(fold))
46 const classes: [Set<string>, ChromaToken][] = [
47 [setOf(spec.namespaces), 'keyword_namespace'],
48 [setOf(spec.reserved), 'keyword_reserved'],
49 [setOf(spec.types), 'keyword_type'],
50 [setOf(spec.keywords), 'keyword'],
51 [setOf(spec.builtins), 'name_builtin'],
52 ]
53 const functionAfter = setOf(spec.functionAfter)
54 const classAfter = setOf(spec.classAfter)
55 const quotes = [...(spec.quotes ?? [])].sort((a, b) => b.length - a.length)
56
57 return source => {
58 const tokens: Token[] = []
59 const push = (text: string, kind: ChromaToken) => tokens.push({ text, kind })
60 let declaring: ChromaToken | undefined
61 let atLineStart = true
62 let i = 0
63 while (i < source.length) {
64 const rest = source.slice(i)
65 const lineEnd = rest.indexOf('\n') === -1 ? rest.length : rest.indexOf('\n')
66 const take = (text: string, kind: ChromaToken) => {
67 push(text, kind)
68 i += text.length
69 }
70
71 const space = SPACE.exec(rest)?.[0]
72 if (space !== undefined) {
73 take(space, 'text')
74 if (space.includes('\n')) atLineStart = true
75 continue
76 }
77 const wasLineStart = atLineStart
78 atLineStart = false
79 // A declaring keyword names only the token right after it, so `function (` leaves the next name alone.
80 const declared = declaring
81 declaring = undefined
82
83 if (wasLineStart && spec.preprocessor?.test(rest.slice(0, lineEnd))) {
84 take(rest.slice(0, lineEnd), 'comment_preproc')
85 continue
86 }
87 const key = wasLineStart ? spec.lineKey?.exec(rest)?.[0] : undefined
88 if (key !== undefined) {
89 take(key, 'name_tag')
90 continue
91 }
92 const lineComment = spec.lineComments?.find(open => rest.startsWith(open))
93 if (lineComment !== undefined) {
94 take(rest.slice(0, lineEnd), 'comment')
95 continue
96 }
97 if (spec.blockComment !== undefined && rest.startsWith(spec.blockComment[0])) {
98 const close = rest.indexOf(spec.blockComment[1], spec.blockComment[0].length)
99 take(close === -1 ? rest : rest.slice(0, close + spec.blockComment[1].length), 'comment')
100 continue
101 }
102 const quote = quotes.find(open => rest.startsWith(open))
103 if (quote !== undefined) {
104 const literal = stringAt(rest, quote)
105 const isKey = spec.stringKeys === true && /^\s*:/.test(rest.slice(literal.length))
106 if (isKey) take(literal, 'name_tag')
107 else {
108 tokens.push(...stringTokens(literal))
109 i += literal.length
110 }
111 continue
112 }
113 if (spec.decorators && /^@[A-Za-z_][\w.]*/.test(rest)) {
114 take(/^@[A-Za-z_][\w.]*/.exec(rest)?.[0] ?? '@', 'name_decorator')
115 continue
116 }
117 const number = NUMBER.exec(rest)?.[0]
118 if (number !== undefined) {
119 take(number, 'literal_number')
120 continue
121 }
122 const name = NAME.exec(rest)?.[0]
123 if (name !== undefined) {
124 const folded = fold(name)
125 const kind = declared ?? classes.find(([words]) => words.has(folded))?.[1] ?? 'name'
126 declaring = functionAfter.has(folded) ? 'name_function' : classAfter.has(folded) ? 'name_class' : undefined
127 take(name, kind)
128 continue
129 }
130 const operator = OPERATOR.exec(rest)?.[0]
131 if (operator !== undefined) {
132 take(operator, 'operator')
133 continue
134 }
135 take(PUNCTUATION.exec(rest)?.[0] ?? rest[0] ?? '', PUNCTUATION.test(rest) ? 'punctuation' : 'text')
136 }
137 return tokens
138 }
139}
140
141// A string runs to its closing quote, or to the line's end when it never closes (a reply still streaming).
142function stringAt(rest: string, quote: string): string {
143 const multiline = quote.length === 3 || quote === '`'
144 for (let i = quote.length; i < rest.length; i++) {
145 if (rest[i] === '\\') i++
146 else if (rest.startsWith(quote, i)) return rest.slice(0, i + quote.length)
147 else if (rest[i] === '\n' && !multiline) return rest.slice(0, i)
148 }
149 return rest
150}
151
152function stringTokens(literal: string): Token[] {
153 return literal
154 .split(/(\\(?:x[\da-fA-F]{2}|u\{?[\da-fA-F]{1,6}\}?|[^\n]))/)
155 .filter(part => part !== '')
156 .map(part => ({ text: part, kind: part.startsWith('\\') ? 'literal_string_escape' : 'literal_string' }))
157}
158
159// MARK: Line lexers
160
161/** A lexer that colors whole lines by their opening characters, first rule that matches wins. */
162export function lineLexer(rules: [RegExp, ChromaToken][]): Lexer {
163 return source =>
164 source.split(/(?<=\n)/).map(line => ({ text: line, kind: rules.find(([test]) => test.test(line))?.[1] ?? 'text' }))
165}
166
167const diff = lineLexer([
168 [/^\+/, 'generic_inserted'],
169 [/^-/, 'generic_deleted'],
170 [/^@/, 'generic_subheading'],
171 [/^(?:diff|index) /, 'generic_strong'],
172])
173
174// MARK: Markup lexer
175
176const markup: Lexer = source => {
177 const tokens: Token[] = []
178 const pattern =
179 /(<!--[\s\S]*?(?:-->|$))|(<\/?)([\w:-]+)|([\w:-]+)(?==)|("[^"]*"?|'[^']*'?)|(\/?>)|([^<>"'=\s]+|\s+|[<>=])/g
180 for (const match of source.matchAll(pattern)) {
181 const [, comment, open, tag, attribute, string, close] = match
182 if (comment !== undefined) tokens.push({ text: comment, kind: 'comment' })
183 else if (open !== undefined && tag !== undefined) tokens.push({ text: open, kind: 'punctuation' }, { text: tag, kind: 'name_tag' })
184 else if (attribute !== undefined) tokens.push({ text: attribute, kind: 'name_attribute' })
185 else if (string !== undefined) tokens.push({ text: string, kind: 'literal_string' })
186 else if (close !== undefined) tokens.push({ text: close, kind: 'punctuation' })
187 else tokens.push({ text: match[0], kind: 'text' })
188 }
189 return tokens
190}
191
192// MARK: Languages
193
194const C_FAMILY: LanguageSpec = {
195 keywords: [
196 'break', 'case', 'catch', 'continue', 'default', 'do', 'else', 'finally', 'for', 'goto', 'if', 'new', 'return',
197 'sizeof', 'switch', 'this', 'throw', 'try', 'while', 'true', 'false', 'null', 'nullptr', 'NULL', 'class',
198 'struct', 'enum', 'union', 'typedef', 'const', 'static', 'extern', 'public', 'private', 'protected', 'virtual',
199 'override', 'final', 'abstract', 'extends', 'implements', 'interface', 'template', 'typename', 'auto', 'inline',
200 'volatile', 'throws', 'instanceof', 'super', 'var', 'val', 'fun', 'func', 'let', 'guard', 'when', 'object',
201 ],
202 namespaces: ['import', 'package', 'namespace', 'using'],
203 types: [
204 'void', 'int', 'char', 'short', 'long', 'float', 'double', 'bool', 'boolean', 'byte', 'unsigned', 'signed',
205 'size_t', 'string', 'String', 'Int', 'Double', 'Float', 'Bool', 'Boolean', 'Long', 'Unit', 'Any',
206 ],
207 classAfter: ['class', 'struct', 'enum', 'interface', 'union', 'object'],
208 functionAfter: ['fun', 'func'],
209 lineComments: ['//'],
210 blockComment: ['/*', '*/'],
211 quotes: ['"""', '"', "'"],
212 decorators: true,
213 preprocessor: /^#\s*\w+/,
214}
215
216const SCRIPT: LanguageSpec = {
217 keywords: [
218 'break', 'case', 'catch', 'continue', 'default', 'delete', 'do', 'else', 'finally', 'for', 'if',
219 'in', 'instanceof', 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'void', 'while', 'with',
220 'yield', 'await', 'async', 'of', 'as', 'satisfies', 'keyof', 'infer', 'is', 'var', 'let',
221 'function', 'type', 'declare', 'namespace', 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
222 ],
223 reserved: [
224 'abstract', 'class', 'const', 'debugger', 'enum', 'export', 'extends', 'implements', 'import', 'interface',
225 'package', 'private', 'protected', 'public', 'readonly', 'static', 'super',
226 ],
227 types: ['string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'symbol', 'bigint'],
228 builtins: [
229 'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Map', 'Math', 'Number', 'Object', 'Promise', 'Proxy',
230 'Reflect', 'RegExp', 'Set', 'String', 'Symbol', 'WeakMap', 'WeakSet', 'console', 'document', 'globalThis',
231 'process', 'window', 'parseInt', 'parseFloat', 'setTimeout', 'clearTimeout', 'fetch',
232 ],
233 functionAfter: ['function'],
234 classAfter: ['class', 'interface', 'enum'],
235 lineComments: ['//'],
236 blockComment: ['/*', '*/'],
237 quotes: ['"', "'", '`'],
238 decorators: true,
239}
240
241const PYTHON: LanguageSpec = {
242 keywords: [
243 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
244 'finally', 'for', 'global', 'if', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return',
245 'try', 'while', 'with', 'yield', 'match', 'case', 'True', 'False', 'None',
246 ],
247 namespaces: ['import', 'from'],
248 builtins: [
249 'abs', 'all', 'any', 'bool', 'bytes', 'dict', 'enumerate', 'filter', 'float', 'getattr', 'hasattr', 'int',
250 'isinstance', 'len', 'list', 'map', 'max', 'min', 'open', 'print', 'range', 'repr', 'reversed', 'set',
251 'setattr', 'sorted', 'str', 'sum', 'super', 'tuple', 'type', 'zip', 'self', 'cls',
252 ],
253 functionAfter: ['def'],
254 classAfter: ['class'],
255 lineComments: ['#'],
256 quotes: ['"""', "'''", '"', "'"],
257 decorators: true,
258}
259
260const GO: LanguageSpec = {
261 keywords: [
262 'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for', 'func', 'go',
263 'goto', 'if', 'interface', 'map', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var', 'true',
264 'false', 'nil', 'iota',
265 ],
266 namespaces: ['package', 'import'],
267 types: [
268 'any', 'bool', 'byte', 'complex64', 'complex128', 'error', 'float32', 'float64', 'int', 'int8', 'int16', 'int32',
269 'int64', 'rune', 'string', 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
270 ],
271 builtins: ['append', 'cap', 'clear', 'close', 'copy', 'delete', 'len', 'make', 'max', 'min', 'new', 'panic', 'print', 'println', 'recover'],
272 functionAfter: ['func'],
273 lineComments: ['//'],
274 blockComment: ['/*', '*/'],
275 quotes: ['"', "'", '`'],
276}
277
278const RUST: LanguageSpec = {
279 keywords: [
280 'as', 'async', 'await', 'break', 'const', 'continue', 'dyn', 'else', 'enum', 'false', 'fn', 'for', 'if', 'impl',
281 'in', 'let', 'loop', 'match', 'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct', 'super',
282 'trait', 'true', 'type', 'unsafe', 'where', 'while',
283 ],
284 namespaces: ['use', 'mod', 'crate', 'extern'],
285 types: [
286 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', 'f32', 'f64', 'bool',
287 'char', 'str', 'String', 'Vec', 'Option', 'Result', 'Box',
288 ],
289 builtins: ['Some', 'None', 'Ok', 'Err'],
290 functionAfter: ['fn'],
291 classAfter: ['struct', 'enum', 'trait'],
292 lineComments: ['//'],
293 blockComment: ['/*', '*/'],
294 quotes: ['"'],
295}
296
297const SHELL: LanguageSpec = {
298 keywords: [
299 'if', 'then', 'else', 'elif', 'fi', 'case', 'esac', 'for', 'while', 'until', 'do', 'done', 'in', 'function',
300 'select', 'return', 'exit', 'export', 'local', 'readonly', 'declare', 'unset',
301 ],
302 builtins: [
303 'alias', 'awk', 'cat', 'cd', 'cp', 'curl', 'echo', 'eval', 'exec', 'find', 'git', 'grep', 'ls', 'mkdir', 'mv',
304 'printf', 'pwd', 'read', 'rm', 'sed', 'set', 'shift', 'source', 'test', 'trap', 'wait',
305 ],
306 lineComments: ['#'],
307 quotes: ['"', "'"],
308}
309
310const SQL: LanguageSpec = {
311 keywords: [
312 'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', 'create', 'table', 'drop',
313 'alter', 'add', 'index', 'join', 'left', 'right', 'inner', 'outer', 'on', 'group', 'by', 'order', 'having',
314 'limit', 'offset', 'as', 'and', 'or', 'not', 'null', 'is', 'in', 'like', 'distinct', 'union', 'all', 'case',
315 'when', 'then', 'else', 'end', 'primary', 'key', 'foreign', 'references', 'default', 'with', 'returning',
316 ],
317 types: ['int', 'integer', 'bigint', 'text', 'varchar', 'char', 'boolean', 'date', 'timestamp', 'numeric', 'real', 'serial', 'uuid', 'jsonb'],
318 lineComments: ['--'],
319 blockComment: ['/*', '*/'],
320 quotes: ["'", '"'],
321 ignoreCase: true,
322}
323
324const JSON_SPEC: LanguageSpec = { keywords: ['true', 'false', 'null'], quotes: ['"'], stringKeys: true }
325
326const YAML: LanguageSpec = {
327 keywords: ['true', 'false', 'null', 'yes', 'no', 'on', 'off'],
328 lineComments: ['#'],
329 quotes: ['"', "'"],
330 lineKey: /^(?:- +)?[\w.-]+(?=\s*:(?:\s|$))/,
331}
332
333const TOML: LanguageSpec = {
334 keywords: ['true', 'false'],
335 lineComments: ['#'],
336 quotes: ['"""', "'''", '"', "'"],
337 lineKey: /^(?:\[\[?[^\]\n]*\]\]?|[\w.-]+(?=\s*=))/,
338}
339
340const CSS: LanguageSpec = {
341 keywords: ['important', 'inherit', 'initial', 'unset', 'none', 'auto'],
342 lineComments: [],
343 blockComment: ['/*', '*/'],
344 quotes: ['"', "'"],
345 lineKey: /^[\w-]+(?=\s*:)/,
346}
347
348/** Each fence language and its aliases; unknown ones draw as plain text, as chroma's fallback lexer does. */
349export const LEXERS: Record<string, Lexer> = {}
350
351function language(lexer: Lexer, ...names: string[]): void {
352 for (const name of names) LEXERS[name] = lexer
353}
354
355language(codeLexer(SCRIPT), 'js', 'javascript', 'jsx', 'mjs', 'cjs', 'ts', 'typescript', 'tsx', 'mts', 'cts')
356language(codeLexer(PYTHON), 'py', 'python', 'python3')
357language(codeLexer(GO), 'go', 'golang')
358language(codeLexer(RUST), 'rs', 'rust')
359language(codeLexer(SHELL), 'sh', 'bash', 'zsh', 'shell', 'console', 'fish')
360language(codeLexer(C_FAMILY), 'c', 'h', 'cpp', 'c++', 'cc', 'hpp', 'java', 'kotlin', 'kt', 'swift', 'cs', 'csharp', 'scala', 'dart')
361language(codeLexer(SQL), 'sql', 'postgres', 'postgresql', 'mysql', 'sqlite')
362language(codeLexer(JSON_SPEC), 'json', 'jsonc', 'json5')
363language(codeLexer(YAML), 'yaml', 'yml')
364language(codeLexer(TOML), 'toml', 'ini')
365language(codeLexer(CSS), 'css', 'scss', 'less')
366language(markup, 'html', 'xml', 'svg', 'vue', 'svelte')
367language(diff, 'diff', 'patch')
368
369export function highlight(source: string, languageName: string): Token[] {
370 const lexer = LEXERS[languageName.toLowerCase()]
371 return lexer === undefined ? [{ text: source, kind: 'text' }] : lexer(source)
372}
373