SLOPSHOPPER

glamour-dark

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

newrows
★ 73v0.1.0no licenseupdated 2026-10-08Tickloop/claude-mods/glamour-dark
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glamour-dark
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

claude-mods

Make your claude code look and feel better. Easier to read so you miss fewer things.

ModWhat it does
glamour-darkDraws the model's text replies with glamour's default dark style
tool-linesDraws each tool call as one compact tool_call: Tool(input) - meta line and hides its result block

Installation

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.

Development

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'
Source 6 files
hooks/register.ts 60 lines
1import type { Register } from 'claude-code'
2import { cascade, layout, type Line, type Paint, type Span } from './layout'
3import { parse, type Block } from './markdown'
4import { splitSentences } from './sentences'
5import { DARK, type StyleConfig } from './style'
6
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}
60
hooks/layout.ts 399 lines
1import { 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}
399
hooks/markdown.ts 474 lines
1// 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}
474
hooks/sentences.ts 78 lines
1import { 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}
78
hooks/style.ts 259 lines
1// 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}
259
hooks/highlight.ts 373 lines
1import 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