Glow-style rendering of replies, with centered LaTeX math and Mermaid/PlantUML diagrams drawn as images

NixOS + Home Manager configuration for victus, structured as a dendritic flake-parts tree: every .nix file under modules/ is a self-contained, self-registering module, auto-discovered by import-tree - there's no manual imports = [ ... ] list anywhere in modules/. A feature that spans both layers (e.g. Hyprland: compositor enable at the NixOS level, config + theme registration at the Home Manager level) declares both in one file instead of being split across a system/user directory boundary.
Hyprland under UWSM, SDDM wearing the same face as the lock screen, and a Quickshell shell that owns the desktop's notifications, lock screen, polkit prompts, credential prompts, screen-share picker and tray. It is deliberately plain: one status line along the bottom, a themed wmenu for every choice, and flat cards only where something needs an answer. The Omen key switches the whole desktop between a flat look (square, translucent, blurred, still layers) and a soft one (rounded, opaque, animated layers). Colours are runtime, not build-time: theme-set <name> repaints almost everything without a rebuild.
flake.nix # entry point: inputs + flake-parts + import-tree ./modules
hosts/victus/ # host identity + hardware - not a pooled aspect
default.nix # hostName, networking, users, GPU workarounds
hardware.nix # nixos-generate-config output
modules/ # one directory per aspect: <name>/<name>.nix
# plus whatever files it reads or symlinks
flake/ # the flake's own assembly, not aspects
system.nix # nixosConfigurations.victus assembly
checks.nix # theme-render check (see below)
formatter.nix # treefmt (nixfmt, deadnix, stylua, shfmt, keep-sorted)
apps.nix, lib.nix # nix run .#<app> standalone previews
boot/, locales/, nix/, coredump/, secrets/ # nixos-only aspects
desktop/ # portals, power, bluetooth
networking/ # NetworkManager, BBR, wireshark
virtualisation/ # libvirt + virt-manager (the Proxmox lab VM)
sddm/ # SDDM (Xorg-hosted greeter) wearing a theme
# built from the lock screen's own QML
auth/ # bare-askpass: ssh, git and sudo prompts
# routed into the shell (nixos + homeManager)
screencast/ # bare-share-picker: the screen-share
# picker xdg-desktop-portal-hyprland raises
hyprland/ # compositor: nixos enable + homeManager
# config (Lua, live-edited via symlink)
theme-engine/ # runtime theming system - theme-set CLI,
# theme_engine.py, themes/*.toml
quickshell/ # the shell (QML) - see below, plus its
# lock screen's PAM service and hypridle
bare/ # the menus: bare-menu (themed wmenu,
# patched for image previews), bare-pick
# (the menus), bare-look (flat / soft)
gaming/ # RTX 3050 Ti under PRIME offload, Steam
# and gamescope, the gamerun wrapper
calendar/ # Google Calendar sync (gcal CLI + timer)
# feeding the agenda, and course deadlines
# pushed from Markdown files on save
audio/, files/, localsend/ # remaining service aspects
cli/, packages/, appearance/, cursor/,
icons/, scripts/ # shell/fonts/cursor/icons/ad-hoc packages,
# writeShellApplication wrappers
neovim/ # nixvim, and the $EDITOR/$VISUAL contract
music/ # mpd + rmpc, local files, MPRIS into the shell
kitty/, browser/, gtk/, qt/, starship/,
vesktop/ # remaining homeManager-only aspects
utils/ # exposes `utils` module arg
# (dotfiles path + symlink helper)
modules/quickshell/ is a Quickshell/QML shell with one status line along the bottom: workspaces, the focused app (or qutebrowser's and yazi's own status line while they have focus), and on the right whatever is going on - a recording, a transfer, a download, the next event, the timer, muted, do not disturb, night light, the mic in use - then weather, battery, network and the clock. Every segment is clickable. It owns the desktop's notification server, lock screen, polkit agent, screen-share picker and system tray, and every part of it is backed by a real service - PipeWire, MPRIS, UPower, NetworkManager, bluez, cliphist, StatusNotifier, open-meteo, PAM, polkit, xdg-desktop-portal, Hyprland's IPC - with nothing mocked.
bare-pick <source> opens a themed wmenu for each of them: apps (or a web search), windows, clipboard, wallpaper, theme, media controls, power and profiles, Wi-Fi, Bluetooth, audio, displays, calendar, course deadlines, notes, the timer, the files shelf, LocalSend, the tray (an app, then its own right-click menu), and a calculator REPL. SUPER+B lists them all.SUPER+? opens a cheatsheet card.shell.qml # ShellRoot: the bar per screen, the OSD and cards, the session lock, every IPC target
singletons/ # state, theme tokens, and the service wrappers
bare/ # the surfaces: bar, OSD, popup, prompt cards, cheatsheet
lock/ # the session lock surface and its face (also the SDDM greeter's)
components/ # the icon glyph the Moodle app shares
Driven by keybinds or over IPC:
| Keys | Action |
|---|---|
SUPER+B | every menu |
SUPER+space | launcher, or ?query to search |
Calculator | calculator |
SUPER+V / SUPER+SHIFT+V | clipboard / files shelf |
SUPER+A | wallpaper |
SUPER+SHIFT+T | timer |
SUPER+M / SUPER+O | media controls / windows |
SUPER+Escape | power and profiles |
SUPER+T | tray |
SUPER+? | keybinding cheatsheet |
SUPER+CTRL+L | lock |
Omen | the flat look and the soft one |
SUPER+D | Discord and music pad |
bare-pick # list every menu
bare-pick wifi # open one from a terminal
quickshell ipc call bare osd vol
quickshell ipc call look toggle
quickshell ipc call timer start 300
QML is symlinked from the repo, so edits apply on save with no rebuild.
The theme engine (theme-set <name>) renders each app's config from a template and reloads it in place. Hyprland, kitty, Neovim, vesktop, rmpc, GTK, Qt, SDDM and the shell all follow it without a rebuild.
A theme is a palette and a font, nothing else - no wallpaper, no radius, no light/dark flag. The wallpaper is your own pick (theme-set wallpaper <path>, or the wallpaper menu) and persists across theme switches, re-tinted to each theme's accent unless you pass --no-recolor. The wallpaper menu also lists Gustave Doré's engravings, which are always dithered to two tones of the current theme.
Adding a theme means dropping a .toml into modules/theme-engine/themes/; just check renders every theme against every template and fails on a missing key or a leftover {{token}}.
The flake describes one machine (victus) and one user (neru). To install it somewhere else, rename those and delete the parts that only make sense here.
/mnt.~/flake, because the shell, Hyprland and the other live-edited configs are symlinks into that checkout: ``sh git clone https://github.com/isneru/flake /mnt/home/<user>/flake cd /mnt/home/<user>/flake ``sh git rm -r modules/{calendar,isep-site,phone,moodle,java,mic,wireguard} modules/packages/personal.nix modules/packages/*-monitor.sh ` and, unless you set them up, modules/secureboot (needs sbctl create-keys and the keys enrolled in firmware) and modules/gaming (needs NVIDIA hybrid graphics). Without calendar and isep-site nothing reads secrets/, so no sops key is needed. modules/whatsapp (a WhatsApp client you link to your own account) and modules/claude (Claude Code skills) are optional - delete them if you do not use them. modules/packages/optional.nix is tools nothing else in the flake calls - keep, trim or empty it to taste; core.nix` beside it is what the shell and scripts need.sh git mv hosts/victus hosts/<host> sed -i 's/\bneru\b/<user>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/sddm/sddm.nix modules/virtualisation/virtualisation.nix sed -i 's/\bvictus\b/<host>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/neovim/neovim.nix ` If you kept calendar, isep-site or phone, rename in modules/calendar/calendar.nix, modules/isep-site/isep-site.nix and modules/phone/phone.nix too. Other matches for neru (lua/neru/ in Neovim, --neru-surface` in the Vesktop theme) are internal names - leave them.hosts/<host>/default.nix: timezone, keyboard layout, and drop what belongs to this laptop (the hardware.nvidia.prime bus IDs, i915 and its enable_fbc=0, the nouveau blacklist): ``sh nixos-generate-config --root /mnt --show-hardware-config > hosts/<host>/hardware.nix ``git add -A (the flake cannot see untracked files), then install and give the user a password (SDDM logs in automatically, but the lock screen and sudo ask for it): ``sh nixos-install --flake .#<host> nixos-enter --root /mnt -c 'passwd <user>' nixos-enter --root /mnt -c 'chown -R <user>:users /home/<user>/flake' ``just switch # nixos-rebuild switch --flake . (needs a password - run it yourself)
just build # nixos-rebuild build (no activation) - safe to verify a change
just check # nix flake check
just fmt # format all files (treefmt)
just update # update flake inputs, commit the lockfile
just clean # gc generations older than 7 days, optimise the store
just repair # verify and repair the Nix store (alias: just fix)
just deploy <host> # switch a remote host over SSH
just secrets # decrypt/edit secrets/secrets.yaml with sops
just rotate # rotate SOPS secrets (secrets/*.yaml)
Run just with no arguments to list all recipes.
mkOutOfStoreSymlink (utils.create_symlink), so structural edits take effect immediately; Nix-generated and templated files still need a rebuild or theme-set reapply.nix run github:isneru/flake#kitty (also #quickshell, #btop, #shell) starts either app with this config and a theme of your choice, with no checkout and nothing written to your $HOME.gamerun (or gamerun %command% in Steam). Nothing infers "this is a game", and the launcher deliberately wraps nothing.xdg-desktop-portal-hyprland is pointed at a picker that draws in the shell. It also absorbs the burst - a Chromium app opens two or three portal sessions for one share, so picking a source used to mean answering the picker two or three times over.vesktop-plugins flake input (isneru/vesktop-custom-plugins).modules/hyprland/frame-plugin/ with its licence and built against the system's own Hyprland, so it never needs rebuilding by hand; the plates (public domain, sources in plates.toml) are in modules/theme-engine/plates/.secrets/. Rotate with just rotate.hooks/register.tsx 458 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Renders } from '../types'
5import { latexMath, unicodeMath } from './unicode-math'
6
7const renders = atom({ plugin: 'readable', key: 'renders' } as const, 0)
8
9const C = {
10 h1Fg: '#ffff87',
11 h1Bg: '#5f5fff',
12 heading: '#00afff',
13 code: '#ff5f5f',
14 codeBg: '#303030',
15 link: '#00af87',
16 url: '#585858',
17 math: '#d7afff',
18 rule: '#585858',
19 quote: '#808080',
20 bullet: '#00afff',
21}
22
23type Kind = 'math' | 'plantuml' | 'mermaid'
24
25type Block =
26 | { type: 'heading'; level: number; text: string }
27 | { type: 'para'; text: string }
28 | { type: 'code'; lang: string; src: string }
29 | { type: 'visual'; kind: Kind; src: string }
30 | { type: 'quote'; blocks: Block[] }
31 | { type: 'list'; ordered: boolean; start: number; items: Block[][] }
32 | { type: 'table'; raw: string; align: string[]; header: string[]; rows: string[][] }
33 | { type: 'hr' }
34
35type Span = {
36 text: string
37 bold?: boolean
38 italic?: boolean
39 strike?: boolean
40 code?: boolean
41 math?: boolean
42 link?: boolean
43 url?: boolean
44}
45
46const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/
47const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
48const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
49const QUOTE = /^\s{0,3}>\s?/
50const LIST = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
51const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
52
53const indentOf = (line: string) => line.length - line.trimStart().length
54
55const visualKind = (lang: string): Kind | undefined =>
56 lang === 'math' ? 'math'
57 : lang === 'mermaid' ? 'mermaid'
58 : ['plantuml', 'puml', 'uml'].includes(lang) ? 'plantuml'
59 : undefined
60
61const startsBlock = (line: string) => {
62 const t = line.trim()
63 return FENCE.test(line) || HEADING.test(line) || HR.test(line) || QUOTE.test(line)
64 || LIST.test(line) || t.startsWith('$$') || t.startsWith('\\[') || t.startsWith('@startuml')
65}
66
67const splitRow = (line: string) =>
68 line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim())
69
70function untilClose(lines: string[], i: number, open: string, close: string) {
71 const first = lines[i].trim().slice(open.length)
72 if (first.trimEnd().endsWith(close) && first.trim().length >= close.length) {
73 return { src: first.trimEnd().slice(0, -close.length), next: i + 1 }
74 }
75 const body = [first]
76 for (let j = i + 1; j < lines.length; j++) {
77 const t = lines[j].trimEnd()
78 if (t.endsWith(close)) {
79 body.push(t.slice(0, -close.length))
80 return { src: body.join('\n'), next: j + 1 }
81 }
82 body.push(lines[j])
83 }
84 return undefined
85}
86
87function parseBlocks(lines: string[]): Block[] {
88 const out: Block[] = []
89 let i = 0
90 while (i < lines.length) {
91 const line = lines[i]
92 const t = line.trim()
93 if (!t) { i++; continue }
94
95 const fence = FENCE.exec(line)
96 if (fence) {
97 const body: string[] = []
98 let j = i + 1
99 while (j < lines.length && !lines[j].trim().startsWith(fence[1])) body.push(lines[j++])
100 const isClosed = j < lines.length
101 const lang = fence[2].toLowerCase()
102 const kind = visualKind(lang)
103 const src = body.join('\n')
104 out.push(kind && isClosed ? { type: 'visual', kind, src } : { type: 'code', lang, src })
105 i = j + 1
106 continue
107 }
108
109 if (t.startsWith('@startuml')) {
110 const j = lines.findIndex((l, k) => k > i && l.trim().startsWith('@enduml'))
111 if (j !== -1) {
112 out.push({ type: 'visual', kind: 'plantuml', src: lines.slice(i, j + 1).join('\n') })
113 i = j + 1
114 continue
115 }
116 }
117
118 const delims = t.startsWith('$$') ? ['$$', '$$'] : t.startsWith('\\[') ? ['\\[', '\\]'] : undefined
119 const math = delims && untilClose(lines, i, delims[0], delims[1])
120 if (math && math.src.trim()) {
121 out.push({ type: 'visual', kind: 'math', src: math.src.trim() })
122 i = math.next
123 continue
124 }
125
126 const heading = HEADING.exec(line)
127 if (heading) {
128 out.push({ type: 'heading', level: heading[1].length, text: heading[2] })
129 i++
130 continue
131 }
132
133 if (HR.test(line)) { out.push({ type: 'hr' }); i++; continue }
134
135 if (QUOTE.test(line)) {
136 const body: string[] = []
137 while (i < lines.length && QUOTE.test(lines[i])) body.push(lines[i++].replace(QUOTE, ''))
138 out.push({ type: 'quote', blocks: parseBlocks(body) })
139 continue
140 }
141
142 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-')) {
143 const start = i
144 const header = splitRow(line)
145 const align = splitRow(lines[i + 1]).map(c =>
146 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left')
147 const rows: string[][] = []
148 i += 2
149 while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(splitRow(lines[i++]))
150 out.push({ type: 'table', raw: lines.slice(start, i).join('\n'), align, header, rows })
151 continue
152 }
153
154 const item = LIST.exec(line)
155 if (item) {
156 const base = indentOf(line)
157 const ordered = /\d/.test(item[2])
158 const items: string[][] = []
159 let cur: string[] = []
160 let contentIndent = 0
161 while (i < lines.length) {
162 const l = lines[i]
163 const m = LIST.exec(l)
164 if (m && indentOf(l) === base) {
165 cur = [m[3]]
166 items.push(cur)
167 contentIndent = m[1].length + m[2].length + 1
168 } else if (!l.trim()) {
169 const nextLine = lines[i + 1]
170 if (nextLine === undefined || !nextLine.trim() || indentOf(nextLine) < base
171 || (indentOf(nextLine) === base && !LIST.test(nextLine))) break
172 cur.push('')
173 } else if (indentOf(l) > base) {
174 cur.push(l.slice(Math.min(contentIndent, indentOf(l))))
175 } else if (lines[i - 1]?.trim() && !startsBlock(l)) {
176 cur.push(l.trim())
177 } else {
178 break
179 }
180 i++
181 }
182 out.push({ type: 'list', ordered, start: ordered ? parseInt(item[2], 10) : 1, items: items.map(parseBlocks) })
183 continue
184 }
185
186 const body = [t]
187 i++
188 while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) body.push(lines[i++].trim())
189 out.push({ type: 'para', text: body.join(' ') })
190 }
191 return out
192}
193
194const INLINE = new RegExp([
195 '(`+)([\\s\\S]*?[^`])\\1(?!`)',
196 '\\$(?![\\s$])([^$\\n]*?[^\\s\\\\])\\$(?!\\d)',
197 '\\\\\\(([\\s\\S]+?)\\\\\\)',
198 '\\*\\*([\\s\\S]+?)\\*\\*|__([\\s\\S]+?)__',
199 '~~([\\s\\S]+?)~~',
200 '\\*(?!\\s)([\\s\\S]+?)\\*|\\b_(?!\\s)([\\s\\S]+?)_\\b',
201 '\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)',
202 '<?(https?://[^\\s>]+[^\\s>.,;:!?)])>?',
203].join('|'), 'g')
204
205function parseInline(text: string, style: Omit<Span, 'text'> = {}): Span[] {
206 const out: Span[] = []
207 let last = 0
208 for (const m of text.matchAll(INLINE)) {
209 const at = m.index ?? 0
210 if (at > last) out.push({ ...style, text: text.slice(last, at) })
211 last = at + m[0].length
212 if (m[2] !== undefined) out.push({ ...style, code: true, text: m[2].trim() })
213 else if (m[3] !== undefined || m[4] !== undefined) out.push({ ...style, math: true, text: unicodeMath((m[3] ?? m[4]).trim()) })
214 else if (m[5] !== undefined || m[6] !== undefined) out.push(...parseInline(m[5] ?? m[6], { ...style, bold: true }))
215 else if (m[7] !== undefined) out.push(...parseInline(m[7], { ...style, strike: true }))
216 else if (m[8] !== undefined || m[9] !== undefined) out.push(...parseInline(m[8] ?? m[9], { ...style, italic: true }))
217 else if (m[10] !== undefined) {
218 out.push(...parseInline(m[10], { ...style, link: true }))
219 if (m[11] !== m[10]) out.push({ text: ` ${m[11]}`, url: true })
220 } else out.push({ ...style, link: true, text: m[12] })
221 }
222 if (last < text.length) out.push({ ...style, text: text.slice(last) })
223 return out.map(s => ({ ...s, text: s.code || s.math ? s.text : s.text.replace(/\\([\\`*_{}[\]()#+\-.!|$~])/g, '$1') }))
224}
225
226const plain = (text: string) => parseInline(text).map(s => s.text).join('')
227
228const pending = new Set<string>()
229const failed = new Map<string, string>()
230let cache: { dir: string; version: string } | undefined
231
232function hashKey(s: string) {
233 let a = 0x811c9dc5
234 let b = 0x01000193
235 for (let i = 0; i < s.length; i++) {
236 const c = s.charCodeAt(i)
237 a = Math.imul(a ^ c, 0x01000193)
238 b = Math.imul(b ^ c, 0x5bd1e995)
239 }
240 return (a >>> 0).toString(16).padStart(8, '0') + (b >>> 0).toString(16).padStart(8, '0')
241}
242
243async function renderCache($: EngineInterface) {
244 if (!cache) {
245 const runtime = await $.env.get('XDG_RUNTIME_DIR')
246 const renderer = await $.process
247 .run(['sh', '-c', 'readlink -f "$(command -v readable-render)"'], { timeoutMs: 5_000 })
248 .catch(() => undefined)
249 cache = { dir: `${runtime || '/tmp'}/claude-readable`, version: renderer?.stdout?.trim() ?? '' }
250 }
251 return cache
252}
253
254function schedule($: EngineInterface, key: string, kind: Kind, src: string, color: string) {
255 if (pending.has(key)) return
256 pending.add(key)
257 $.clock.after(0, async () => {
258 try {
259 const { dir } = await renderCache($)
260 await $.fs.write(`${dir}/${key}.src`, src)
261 const run = await $.process.run(
262 ['readable-render', kind, `${dir}/${key}.src`, `${dir}/${key}.png`, color],
263 { timeoutMs: 60_000 },
264 )
265 if (run.exitCode === 0) {
266 const [w, h] = run.stdout.trim().split(/\s+/).map(Number)
267 await $.fs.write(`${dir}/${key}.json`, JSON.stringify({ w, h }))
268 } else {
269 failed.set(key, run.stderr.trim() || `render failed (${run.exitCode})`)
270 }
271 } catch (error) {
272 failed.set(key, String(error))
273 } finally {
274 pending.delete(key)
275 await update($, renders, (n: Renders) => n + 1)
276 }
277 })
278}
279
280export const register: Register = (on, options) => {
281 const cellW = Number(options.cellWidthPx) || 10
282 const cellH = Number(options.cellHeightPx) || 21
283 const mathColor = String(options.mathColor || '#e4e4e4')
284
285 on('prompt.compose', async ($, e, next) => {
286 const composed = await next(e)
287 if (!e.surfaces.includes('terminal')) return composed
288 const text = [
289 '# Readable output',
290 'Your replies are rendered as styled markdown in a kitty terminal. Make them easy to scan:',
291 '- Lead with the answer. Keep paragraphs short. Use headings only for multi-part answers, bullet lists for parallel items and tables for comparisons.',
292 '- Display math goes on its own lines between $$ and $$ (LaTeX with amsmath; use aligned for multi-line). It is drawn as a centered image. Keep inline $...$ math short and simple.',
293 '- When a flow, sequence, state machine, architecture or relationship is clearer as a picture, draw it as a ```mermaid or ```plantuml fenced block; it is drawn as an image.',
294 '- A simple chart of discrete data is a ```mermaid block too: xychart-beta for bars and lines, pie for shares. An xychart has no legend, so name each series in the title or the text.',
295 ].join('\n')
296 return { sections: [...composed.sections, { id: 'readable:format', text, scope: 'session' as const }] }
297 })
298
299 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
300 if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
301 const { Box, Text, Code, Markdown, Image } = $.ui.resolve(e)
302 await read($, renders)
303 const width = Math.max(20, (e.viewport?.columns ?? 80) - 4)
304
305 const spans = (list: Span[], base: { bold?: boolean; color?: string } = {}) =>
306 list.map(s => (
307 <Text
308 color={s.code ? C.code : s.math ? C.math : s.link ? C.link : s.url ? C.url : base.color}
309 backgroundColor={s.code ? C.codeBg : undefined}
310 bold={s.bold || base.bold}
311 italic={s.italic || s.math}
312 strikethrough={s.strike}
313 underline={s.link}
314 >
315 {s.text}
316 </Text>
317 ))
318
319 const codeBlock = (src: string, lang: string, room: number) => {
320 const lines = (src || ' ').replace(/\t/g, ' ').split('\n').flatMap(line => {
321 const chunks: string[] = []
322 for (let k = 0; k < line.length; k += room) chunks.push(line.slice(k, k + room))
323 return chunks.length ? chunks : ['']
324 })
325 const label = lang && lang.length + 6 <= room ? lang : ''
326 return (
327 <Box flexDirection="column" width={room}>
328 <Text color={C.rule}>
329 {label ? '─ ' : ''}
330 {label ? <Text color={C.heading}>{label}</Text> : ''}
331 {(label ? ' ' : '') + '─'.repeat(room - (label ? label.length + 3 : 0))}
332 </Text>
333 <Code source={lines.join('\n')} language={lang || undefined} wrap="truncate-end" />
334 <Text color={C.rule}>{'─'.repeat(room)}</Text>
335 </Box>
336 )
337 }
338
339 const visual = async (b: Extract<Block, { type: 'visual' }>, room: number) => {
340 const color = b.kind === 'math' ? mathColor : ''
341 const src = b.kind === 'math' ? latexMath(b.src) : b.src
342 const { dir, version } = await renderCache($)
343 const key = hashKey(`${version}\0${b.kind}\0${color}\0${src}`)
344 if (await $.fs.exists(`${dir}/${key}.json`)) {
345 const { w, h: height } = JSON.parse(await $.fs.read(`${dir}/${key}.json`))
346 const scale = b.kind === 'plantuml' ? 1 : 2
347 let columns = (w / scale) / cellW
348 let rows = (height / scale) / cellH
349 const fit = Math.min(1, room / columns, 255 / columns, 255 / rows)
350 columns = Math.max(1, Math.round(columns * fit))
351 rows = Math.max(1, Math.round(rows * fit))
352 return (
353 <Box width={room} justifyContent="center">
354 <Image source={{ file: `${dir}/${key}.png`, format: 'png' }} columns={columns} rows={rows} alt={b.src} />
355 </Box>
356 )
357 }
358 const error = failed.get(key)?.split('\n')[0]
359 const note = error ? `⚠ ${error}` : 'rendering…'
360 if (!error) schedule($, key, b.kind, src, color)
361 return (
362 <Box flexDirection="column" width={room}>
363 {codeBlock(b.src, b.kind === 'math' ? 'latex' : b.kind, room)}
364 <Text dimColor>{note}</Text>
365 </Box>
366 )
367 }
368
369 const table = (b: Extract<Block, { type: 'table' }>, room: number) => {
370 const cells = [b.header, ...b.rows].map(r => b.header.map((_, k) => parseInline(r[k] ?? '')))
371 const widths = b.header.map((_, k) => Math.max(3, ...cells.map(r => r[k].map(s => s.text).join('').length)))
372 if (widths.reduce((sum, w) => sum + w + 3, 1) > room) return <Markdown text={b.raw} />
373 const line = (l: string, m: string, r: string) => (
374 <Text color={C.rule}>{l + widths.map(w => '─'.repeat(w + 2)).join(m) + r}</Text>
375 )
376 const row = (r: Span[][], isHeader: boolean) => (
377 <Text wrap="truncate">
378 {r.flatMap((c, k) => {
379 const gap = widths[k] - c.map(s => s.text).join('').length
380 const left = b.align[k] === 'right' ? gap : b.align[k] === 'center' ? Math.floor(gap / 2) : 0
381 return [
382 <Text color={C.rule}>{k === 0 ? '│ ' : ' │ '}</Text>,
383 ' '.repeat(left),
384 ...spans(c, { bold: isHeader }),
385 ' '.repeat(gap - left),
386 ]
387 })}
388 <Text color={C.rule}> │</Text>
389 </Text>
390 )
391 return (
392 <Box flexDirection="column">
393 {line('┌', '┬', '┐')}
394 {row(cells[0], true)}
395 {line('├', '┼', '┤')}
396 {cells.slice(1).map(r => row(r, false))}
397 {line('└', '┴', '┘')}
398 </Box>
399 )
400 }
401
402 const blocks = async (list: Block[], room: number, gap = 1): Promise<any> => (
403 <Box flexDirection="column" gap={gap} width={room}>
404 {await Promise.all(list.map(b => block(b, room)))}
405 </Box>
406 )
407
408 const block = async (b: Block, room: number): Promise<any> => {
409 switch (b.type) {
410 case 'heading':
411 return b.level === 1
412 ? <Text bold color={C.h1Fg} backgroundColor={C.h1Bg}> {plain(b.text)} </Text>
413 : <Text bold color={C.heading}>{'#'.repeat(b.level)} {spans(parseInline(b.text), { bold: true, color: C.heading })}</Text>
414 case 'para':
415 return <Text>{spans(parseInline(b.text))}</Text>
416 case 'code':
417 return codeBlock(b.src, b.lang, room)
418 case 'visual':
419 return visual(b, room)
420 case 'quote':
421 return (
422 <Box flexDirection="row" width={room}>
423 <Box width={2} flexShrink={0}><Text color={C.quote}>│</Text></Box>
424 <Box flexDirection="column">{await blocks(b.blocks, room - 2)}</Box>
425 </Box>
426 )
427 case 'list': {
428 const markerWidth = b.ordered ? String(b.start + b.items.length - 1).length + 2 : 2
429 return (
430 <Box flexDirection="column" width={room}>
431 {await Promise.all(b.items.map(async (item, k) => (
432 <Box flexDirection="row">
433 <Box width={markerWidth} flexShrink={0}>
434 <Text color={C.bullet}>{b.ordered ? `${b.start + k}.` : '•'}</Text>
435 </Box>
436 {await blocks(item, room - markerWidth, 0)}
437 </Box>
438 )))}
439 </Box>
440 )
441 }
442 case 'table':
443 return table(b, room)
444 case 'hr':
445 return <Text color={C.rule}>{'─'.repeat(Math.min(room, 40))}</Text>
446 }
447 }
448
449 const body = await blocks(parseBlocks(e.props.text.split('\n')), width)
450 return (
451 <Box flexDirection="row" marginTop={1}>
452 <Box width={2} flexShrink={0}><Text>{e.props.isFirstOfReply ? '●' : ' '}</Text></Box>
453 {body}
454 </Box>
455 )
456 })
457}
458hooks/unicode-math.ts 421 lines1
2const SYMBOLS: Record<string, string> = {
3 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
4 theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
5 varpi: 'ϖ', rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ',
6 varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω', Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ',
7 Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
8 varkappa: 'ϰ', digamma: 'ϝ', varGamma: 'Γ', varDelta: 'Δ', varTheta: 'Θ', varLambda: 'Λ',
9 varXi: 'Ξ', varPi: 'Π', varSigma: 'Σ', varUpsilon: 'Υ', varPhi: 'Φ', varPsi: 'Ψ', varOmega: 'Ω',
10 nabla: '∇', partial: '∂', infty: '∞', sum: '∑', prod: '∏', int: '∫', oint: '∮', sqrt: '√',
11 coprod: '∐', iint: '∬', iiint: '∭', iiiint: '⨌', bigcup: '⋃', bigcap: '⋂', bigsqcup: '⨆',
12 bigvee: '⋁', bigwedge: '⋀', bigoplus: '⨁', bigotimes: '⨂', bigodot: '⨀', biguplus: '⨄',
13 cdot: '·', cdots: '⋯', ldots: '…', dots: '…', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗',
14 dotsc: '…', dotso: '…', dotsb: '⋯', dotsm: '⋯', dotsi: '⋯', hdots: '…', vdots: '⋮', ddots: '⋱',
15 cdotp: '·', ldotp: '.', colon: ':', centerdot: '·',
16 circ: '∘', bullet: '•', star: '⋆', prime: '′', le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠',
17 neq: '≠', approx: '≈', sim: '∼', simeq: '≃', cong: '≅', equiv: '≡', propto: '∝', ll: '≪', gg: '≫',
18 leqq: '≦', geqq: '≧', leqslant: '⩽', geqslant: '⩾', lll: '⋘', ggg: '⋙', lesssim: '≲', gtrsim: '≳',
19 lessapprox: '⪅', gtrapprox: '⪆', lessgtr: '≶', gtrless: '≷', lesseqgtr: '⋚', gtreqless: '⋛',
20 prec: '≺', succ: '≻', preceq: '⪯', succeq: '⪰', preccurlyeq: '≼', succcurlyeq: '≽', precsim: '≾',
21 succsim: '≿', nless: '≮', ngtr: '≯', nleq: '≰', ngeq: '≱', lneq: '⪇', gneq: '⪈', lneqq: '≨',
22 gneqq: '≩', nprec: '⊀', nsucc: '⊁', approxeq: '≊', nsim: '≁', ncong: '≇', doteq: '≐', doteqdot: '≑',
23 Doteq: '≑', triangleq: '≜', coloneqq: '≔', coloneq: '≔', eqqcolon: '≕', eqcirc: '≖', circeq: '≗',
24 bumpeq: '≏', Bumpeq: '≎', asymp: '≍', varpropto: '∝', backsim: '∽', backsimeq: '⋍', thicksim: '∼',
25 thickapprox: '≈', eqsim: '≂',
26 in: '∈', notin: '∉', ni: '∋', subset: '⊂', subseteq: '⊆', supset: '⊃', supseteq: '⊇', cup: '∪',
27 owns: '∋', nsubseteq: '⊈', nsupseteq: '⊉', subsetneq: '⊊', supsetneq: '⊋', subseteqq: '⫅',
28 supseteqq: '⫆', subsetneqq: '⫋', supsetneqq: '⫌', Subset: '⋐', Supset: '⋑', sqsubset: '⊏',
29 sqsupset: '⊐', sqsubseteq: '⊑', sqsupseteq: '⊒', setminus: '∖', smallsetminus: '∖', complement: '∁',
30 cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', neg: '¬', lnot: '¬', land: '∧',
31 wedge: '∧', lor: '∨', vee: '∨', oplus: '⊕', otimes: '⊗', to: '→', rightarrow: '→', leftarrow: '←',
32 nexists: '∄', uplus: '⊎', sqcap: '⊓', sqcup: '⊔', wr: '≀', diamond: '⋄', bigtriangleup: '△',
33 bigtriangledown: '▽', triangleleft: '◁', triangleright: '▷', lhd: '⊲', rhd: '⊳', unlhd: '⊴',
34 unrhd: '⊵', ominus: '⊖', oslash: '⊘', odot: '⊙', bigcirc: '◯', amalg: '⨿', dotplus: '∔',
35 ltimes: '⋉', rtimes: '⋊', leftthreetimes: '⋋', rightthreetimes: '⋌', Cap: '⋒', Cup: '⋓',
36 doublecap: '⋒', doublecup: '⋓', barwedge: '⊼', veebar: '⊻', doublebarwedge: '⩞', boxplus: '⊞',
37 boxminus: '⊟', boxtimes: '⊠', boxdot: '⊡', circledast: '⊛', circledcirc: '⊚', circleddash: '⊝',
38 intercal: '⊺', divideontimes: '⋇', curlyvee: '⋎', curlywedge: '⋏', dag: '†', dagger: '†',
39 ddag: '‡', ddagger: '‡',
40 gets: '←', Rightarrow: '⇒', Leftarrow: '⇐', implies: '⟹', iff: '⟺', leftrightarrow: '↔',
41 Leftrightarrow: '⇔', mapsto: '↦', uparrow: '↑', downarrow: '↓', langle: '⟨', rangle: '⟩',
42 impliedby: '⟸', longmapsto: '⟼', longrightarrow: '⟶', longleftarrow: '⟵', longleftrightarrow: '⟷',
43 Longrightarrow: '⟹', Longleftarrow: '⟸', Longleftrightarrow: '⟺', updownarrow: '↕', Uparrow: '⇑',
44 Downarrow: '⇓', Updownarrow: '⇕', nearrow: '↗', searrow: '↘', swarrow: '↙', nwarrow: '↖',
45 hookrightarrow: '↪', hookleftarrow: '↩', rightharpoonup: '⇀', rightharpoondown: '⇁',
46 leftharpoonup: '↼', leftharpoondown: '↽', rightleftharpoons: '⇌', leftrightharpoons: '⇋',
47 rightleftarrows: '⇄', leftrightarrows: '⇆', rightrightarrows: '⇉', leftleftarrows: '⇇',
48 twoheadrightarrow: '↠', twoheadleftarrow: '↞', rightarrowtail: '↣', leftarrowtail: '↢',
49 rightsquigarrow: '⇝', leadsto: '⇝', leftrightsquigarrow: '↭', nrightarrow: '↛', nleftarrow: '↚',
50 nRightarrow: '⇏', nLeftarrow: '⇍', nleftrightarrow: '↮', nLeftrightarrow: '⇎',
51 curvearrowright: '↷', curvearrowleft: '↶', circlearrowright: '↻', circlearrowleft: '↺',
52 upharpoonright: '↾', upharpoonleft: '↿', downharpoonright: '⇂', downharpoonleft: '⇃',
53 restriction: '↾', Lleftarrow: '⇚', Rrightarrow: '⇛', dashrightarrow: '⇢', dashleftarrow: '⇠',
54 looparrowright: '↬', looparrowleft: '↫', Lsh: '↰', Rsh: '↱', upuparrows: '⇈', downdownarrows: '⇊',
55 lfloor: '⌊', rfloor: '⌋', lceil: '⌈', rceil: '⌉', hbar: 'ℏ', ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ',
56 lbrace: '{', rbrace: '}', lbrack: '[', rbrack: ']', backslash: '\\', ulcorner: '⌜', urcorner: '⌝',
57 llcorner: '⌞', lrcorner: '⌟', lgroup: '⟮', rgroup: '⟯', llbracket: '⟦', rrbracket: '⟧',
58 hslash: 'ℏ', wp: '℘', imath: 'ı', jmath: 'ȷ', beth: 'ℶ', gimel: 'ℷ', daleth: 'ℸ', eth: 'ð',
59 Bbbk: '𝕜', mho: '℧', Finv: 'Ⅎ', Game: '⅁', AA: 'Å',
60 angle: '∠', perp: '⊥', parallel: '∥', mid: '∣', top: '⊤', bot: '⊥', vdash: '⊢', models: '⊨',
61 measuredangle: '∡', sphericalangle: '∢', nmid: '∤', nparallel: '∦', shortmid: '∣',
62 shortparallel: '∥', dashv: '⊣', vDash: '⊨', Vdash: '⊩', Vvdash: '⊪', nvdash: '⊬', nvDash: '⊭',
63 nVdash: '⊮', nVDash: '⊯', bowtie: '⋈', Join: '⋈', smile: '⌣', frown: '⌢', between: '≬',
64 pitchfork: '⋔', vartriangle: '△', vartriangleleft: '⊲', vartriangleright: '⊳',
65 trianglelefteq: '⊴', trianglerighteq: '⊵', ntriangleleft: '⋪', ntriangleright: '⋫', multimap: '⊸',
66 triangle: '△', triangledown: '▽', blacktriangle: '▲', blacktriangledown: '▼', square: '□',
67 Box: '□', blacksquare: '■', lozenge: '◊', blacklozenge: '⧫', Diamond: '◇', diamondsuit: '♢',
68 heartsuit: '♡', clubsuit: '♣', spadesuit: '♠', flat: '♭', natural: '♮', sharp: '♯', surd: '√',
69 backprime: '‵', S: '§', P: '¶', copyright: '©', pounds: '£', yen: '¥', checkmark: '✓',
70 maltese: '✠', circledS: 'Ⓢ', circledR: '®', bigstar: '★', therefore: '∴',
71 because: '∵', diagup: '╱', diagdown: '╲',
72 quad: ' ', qquad: ' ', lvert: '|', rvert: '|', vert: '|', Vert: '‖', lVert: '‖', rVert: '‖',
73 enspace: ' ', enskip: ' ', thinspace: ' ', medspace: ' ', thickspace: ' ', negthinspace: '',
74 negmedspace: '', negthickspace: '', mod: 'mod', bmod: 'mod', hfill: ' ', hfil: ' ', space: ' ',
75 nobreakspace: ' ', over: '/', atop: '', choose: 'choose', relbar: '─', Relbar: '═', joinrel: '',
76 liminf: 'lim inf', limsup: 'lim sup', injlim: 'inj lim', projlim: 'proj lim', varliminf: 'lim inf',
77 varlimsup: 'lim sup', varinjlim: 'inj lim', varprojlim: 'proj lim', backepsilon: '϶',
78 blacktriangleleft: '◀', blacktriangleright: '▶', curlyeqprec: '⋞', curlyeqsucc: '⋟',
79 eqslantgtr: '⪖', eqslantless: '⪕', fallingdotseq: '≒', risingdotseq: '≓', gnapprox: '⪊',
80 lnapprox: '⪉', gnsim: '⋧', lnsim: '⋦', gtrdot: '⋗', lessdot: '⋖', gtreqqless: '⪌',
81 lesseqqgtr: '⪋', gvertneqq: '≩', lvertneqq: '≨', ngeqq: '≱', ngeqslant: '≱', nleqq: '≰',
82 nleqslant: '≰', npreceq: '⋠', nsucceq: '⋡', nshortmid: '∤', nshortparallel: '∦',
83 nsubseteqq: '⊈', nsupseteqq: '⊉', ntrianglelefteq: '⋬', ntrianglerighteq: '⋭', precapprox: '⪷',
84 succapprox: '⪸', precnapprox: '⪹', succnapprox: '⪺', precneqq: '⪵', succneqq: '⪶', precnsim: '⋨',
85 succnsim: '⋩', smallfrown: '⌢', smallsmile: '⌣', smallint: '∫', intop: '∫', ointop: '∮',
86 idotsint: '∫⋯∫', varsubsetneq: '⊊', varsubsetneqq: '⫋', varsupsetneq: '⊋', varsupsetneqq: '⫌',
87 varbigtriangledown: '▽', varbigtriangleup: '△', lmoustache: '⎰', rmoustache: '⎱', arrowvert: '|',
88 Arrowvert: '‖', bracevert: '⎪', mathdollar: '$', mathellipsis: '…', mathparagraph: '¶',
89 mathsection: '§', mathsterling: '£', mathunderscore: '_', And: '&', vcentcolon: ':',
90}
91
92const FUNCTIONS = new Set([
93 'arccos', 'arcsin', 'arctan', 'arg', 'cos', 'cosh', 'cot', 'coth', 'csc', 'deg', 'det', 'dim', 'exp',
94 'gcd', 'hom', 'inf', 'ker', 'lg', 'lim', 'ln', 'log', 'max', 'min', 'Pr', 'sec', 'sin', 'sinh',
95 'sup', 'tan', 'tanh', 'argmin', 'argmax',
96])
97
98const ACCENTS: Record<string, string> = {
99 hat: '\u0302', widehat: '\u0302', check: '\u030c', widecheck: '\u030c', tilde: '\u0303',
100 widetilde: '\u0303', acute: '\u0301', grave: '\u0300', breve: '\u0306', bar: '\u0304',
101 mathring: '\u030a', dot: '\u0307', ddot: '\u0308', dddot: '\u20db', ddddot: '\u20dc', vec: '\u20d7',
102 overrightarrow: '\u20d7', overleftarrow: '\u20d6', overleftrightarrow: '\u20e1', overline: '\u0305',
103 underline: '\u0332', underbar: '\u0332', cancel: '\u0338', bcancel: '\u0338', xcancel: '\u0338',
104 sout: '\u0336', underrightarrow: '\u20ef', underleftarrow: '\u20ee', underleftrightarrow: '\u034d',
105 Hat: '\u0302', Check: '\u030c', Tilde: '\u0303', Acute: '\u0301', Grave: '\u0300', Breve: '\u0306',
106 Bar: '\u0304', Dot: '\u0307', Ddot: '\u0308', Vec: '\u20d7',
107}
108
109const SPREAD = new Set(['overline', 'underline', 'underbar', 'cancel', 'bcancel', 'xcancel', 'sout'])
110
111type Alphabet = {
112 upper?: number
113 lower?: number
114 digit?: number
115 Greek?: number
116 greek?: number
117 holes?: Record<string, string>
118}
119
120const ALPHABETS: Record<string, Alphabet> = {
121 bb: {
122 upper: 0x1d538, lower: 0x1d552, digit: 0x1d7d8,
123 holes: { C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ' },
124 },
125 cal: {
126 upper: 0x1d49c, lower: 0x1d4b6,
127 holes: { B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ', e: 'ℯ', g: 'ℊ', o: 'ℴ' },
128 },
129 frak: { upper: 0x1d504, lower: 0x1d51e, holes: { C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ' } },
130 bf: { upper: 0x1d400, lower: 0x1d41a, digit: 0x1d7ce, Greek: 0x1d6a8, greek: 0x1d6c2 },
131 bm: { upper: 0x1d468, lower: 0x1d482, digit: 0x1d7ce, Greek: 0x1d71c, greek: 0x1d736 },
132 sf: { upper: 0x1d5a0, lower: 0x1d5ba, digit: 0x1d7e2 },
133 tt: { upper: 0x1d670, lower: 0x1d68a, digit: 0x1d7f6 },
134}
135
136const FONTS: Record<string, string> = {
137 mathbb: 'bb', mathds: 'bb', mathbbm: 'bb', Bbb: 'bb', mathcal: 'cal', mathscr: 'cal', mathfrak: 'frak',
138 frak: 'frak', mathbf: 'bf', textbf: 'bf', bold: 'bf', boldsymbol: 'bm', bm: 'bm', pmb: 'bm', mathbfit: 'bm', mathsf: 'sf',
139 textsf: 'sf', mathtt: 'tt', texttt: 'tt',
140}
141
142const RAW = new Set([
143 'text', 'textrm', 'textit', 'textnormal', 'textup', 'textbf', 'textsf', 'texttt', 'mbox', 'hbox',
144 'textsc', 'emph', 'intertext', 'shortintertext',
145])
146
147const PASS = new Set([
148 'mathrm', 'mathit', 'mathnormal', 'operatorname', 'mathop', 'mathrel', 'mathbin', 'mathord',
149 'mathopen', 'mathclose', 'mathpunct', 'mathinner', 'ensuremath', 'smash', 'mathclap', 'mathllap', 'mathrlap', 'clap', 'llap', 'rlap',
150 'substack', 'sideset', 'operatornamewithlimits', 'shoveleft', 'shoveright', 'ref',
151])
152
153const NOOP = new Set([
154 'left', 'right', 'middle', 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'bigr', 'bigm', 'Bigl', 'Bigr',
155 'Bigm', 'biggl', 'biggr', 'biggm', 'Biggl', 'Biggr', 'Biggm', 'displaystyle', 'textstyle',
156 'scriptstyle', 'scriptscriptstyle', 'limits', 'nolimits', 'nonumber', 'notag', 'mathstrut', 'strut',
157 'relax', 'protect', 'allowbreak', 'nobreak', 'rm', 'it', 'bf', 'sf', 'tt', 'cal', 'normalsize',
158 'tiny', 'scriptsize', 'footnotesize', 'small', 'large', 'Large', 'null', 'displaybreak',
159 'allowdisplaybreaks', 'nobreakdash',
160])
161
162const BRACES = new Set(['underbrace', 'overbrace', 'underbracket', 'overbracket'])
163
164const DROP = new Set(['label', 'vphantom', 'color', 'textcolor', 'colorbox', 'vspace', 'leftroot', 'uproot', 'skew', 'raisetag'])
165
166const FILLERS: Record<string, string> = { hspace: ' ', mspace: ' ', hdotsfor: '…' }
167
168const STARRED = new Set(['operatorname', 'hspace', 'vspace', 'tag'])
169
170const XARROWS: Record<string, [string, string]> = {
171 xrightarrow: ['─', '→'], xleftarrow: ['←', '─'], xRightarrow: ['═', '⇒'], xLeftarrow: ['⇐', '═'],
172 xleftrightarrow: ['←', '→'], xLeftrightarrow: ['⇐', '⇒'], xmapsto: ['├', '→'],
173 xhookrightarrow: ['↪', '→'], xhookleftarrow: ['←', '↩'],
174}
175
176const ENVS: Record<string, [string, string]> = {
177 pmatrix: ['(', ')'], bmatrix: ['[', ']'], Bmatrix: ['{', '}'], vmatrix: ['|', '|'],
178 Vmatrix: ['‖', '‖'], cases: ['{ ', ''], dcases: ['{ ', ''], rcases: ['', ' }'],
179}
180
181const SUP: Record<string, string> = {
182 0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹', '+': '⁺', '-': '⁻',
183 '=': '⁼', '(': '⁽', ')': '⁾', a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ',
184 j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ',
185 w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', T: 'ᵀ', '∗': '*', '*': '*', '′': '′', 'θ': 'ᶿ', 'β': 'ᵝ', 'γ': 'ᵞ', 'δ': 'ᵟ',
186 'φ': 'ᵠ', 'χ': 'ᵡ', ' ': ' ',
187 A: 'ᴬ', B: 'ᴮ', D: 'ᴰ', E: 'ᴱ', G: 'ᴳ', H: 'ᴴ', I: 'ᴵ', J: 'ᴶ', K: 'ᴷ', L: 'ᴸ', M: 'ᴹ', N: 'ᴺ',
188 O: 'ᴼ', P: 'ᴾ', R: 'ᴿ', U: 'ᵁ', V: 'ⱽ', W: 'ᵂ', 'α': 'ᵅ', 'ε': 'ᵋ', 'ι': 'ᶥ', 'ϕ': 'ᶲ', '∘': '°',
189 '⊤': 'ᵀ', '⊥': '⊥', '†': '†', '‡': '‡', '⋆': '⋆',
190}
191
192const SUB: Record<string, string> = {
193 0: '₀', 1: '₁', 2: '₂', 3: '₃', 4: '₄', 5: '₅', 6: '₆', 7: '₇', 8: '₈', 9: '₉', '+': '₊', '-': '₋',
194 '=': '₌', '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ',
195 o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ', 'β': 'ᵦ', 'γ': 'ᵧ', 'ρ': 'ᵨ',
196 'φ': 'ᵩ', 'χ': 'ᵪ', ' ': ' ',
197}
198
199function arg(s: string, i: number): [string, number] {
200 while (s[i] === ' ') i++
201 if (s[i] === '{') {
202 let depth = 1
203 let j = i + 1
204 while (j < s.length && depth > 0) {
205 if (s[j] === '{') depth++
206 else if (s[j] === '}') depth--
207 j++
208 }
209 return [s.slice(i + 1, depth > 0 ? j : j - 1), j]
210 }
211 const command = /^\\[a-zA-Z]+/.exec(s.slice(i))
212 return command ? [command[0], i + command[0].length] : [s[i] ?? '', i + 1]
213}
214
215function opt(s: string, i: number): [string | undefined, number] {
216 const j = s[i] === '[' ? s.indexOf(']', i) : -1
217 return j < 0 ? [undefined, i] : [s.slice(i + 1, j), j + 1]
218}
219
220const script = (text: string, table: Record<string, string>, mark: string) => {
221 const chars = [...text]
222 return chars.every(c => table[c]) ? chars.map(c => table[c]).join('')
223 : `${mark}${chars.length > 1 ? `(${text})` : text}`
224}
225
226const GREEK_VARIANTS = '∂ϵϑϰϕϱϖ'
227
228function restyle(text: string, a: Alphabet): string {
229 const at = (ch: string, base: number | undefined, offset: number) =>
230 base === undefined ? ch : String.fromCodePoint(base + offset)
231 return [...text].map(ch => {
232 const code = ch.codePointAt(0) ?? 0
233 if (a.holes?.[ch]) return a.holes[ch]
234 if (ch >= 'A' && ch <= 'Z') return at(ch, a.upper, code - 0x41)
235 if (ch >= 'a' && ch <= 'z') return at(ch, a.lower, code - 0x61)
236 if (ch >= '0' && ch <= '9') return at(ch, a.digit, code - 0x30)
237 if (ch >= 'Α' && ch <= 'Ω') return at(ch, a.Greek, code - 0x391)
238 if (ch >= 'α' && ch <= 'ω') return at(ch, a.greek, code - 0x3b1)
239 if (ch === '∇') return at(ch, a.Greek, 25)
240 const variant = GREEK_VARIANTS.indexOf(ch)
241 return variant < 0 ? ch : at(ch, a.greek, 25 + variant)
242 }).join('')
243}
244
245function environment(name: string, body: string): string {
246 const env = name.replace('*', '')
247 const [open, close] = ENVS[env] ?? ['', '']
248 const rows = (/^(array|alignedat|subarray)$/.test(env) ? body.slice(arg(body, 0)[1]) : body)
249 .split('\\\\')
250 .map(row => row.split('&').map(cell => unicodeMath(cell.trim())).filter(Boolean).join(' '))
251 .filter(Boolean)
252 return `${open}${rows.join('; ')}${close}`
253}
254
255const LATEX: Record<string, string> = {
256 '−': '-', '′': "'", '″': "''", '°': '^{\\circ}', 'ε': '\\varepsilon', 'ϵ': '\\epsilon', '√': '\\surd',
257 'ο': 'o', '∎': '\\blacksquare', '≢': '\\not\\equiv', '≉': '\\not\\approx', '✗': '\\text{\\ding{55}}',
258 '✘': '\\text{\\ding{56}}', '✔': '\\text{\\ding{52}}', '⋅': '\\cdot', '∙': '\\bullet', '◦': '\\circ',
259 '∶': ':', '∕': '/', '⁄': '/', '〈': '\\langle', '〉': '\\rangle', '⌀': '\\varnothing', '∆': '\\Delta',
260 'µ': '\\mu', 'Ω': '\\Omega', '➝': '\\to', '➔': '\\to', '☆': '\\star', '‐': '-', '‑': '-', '–': '-',
261 '½': '\\tfrac{1}{2}', '⅓': '\\tfrac{1}{3}', '⅔': '\\tfrac{2}{3}', '¼': '\\tfrac{1}{4}',
262 '¾': '\\tfrac{3}{4}', '∛': '\\sqrt[3]{\\,}', '∜': '\\sqrt[4]{\\,}', '\u2009': '\\,', '\u200a': '\\,',
263 '\u202f': '\\,', '\u2005': '\\,', '\u2003': '\\quad', '\u200b': '',
264}
265for (const pair of 'ΑA ΒB ΕE ΖZ ΗH ΙI ΚK ΜM ΝN ΟO ΡP ΤT ΧX'.split(' ')) LATEX[pair[0]] = pair[1]
266for (const [name, ch] of Object.entries(SYMBOLS)) {
267 if (ch > '\u007f' && [...ch].length === 1) LATEX[ch] ??= `\\${name}`
268}
269for (const [font, key, ranges] of [
270 ['mathbb', 'bb', 'A'], ['mathcal', 'cal', 'A'], ['mathfrak', 'frak', 'Aa'], ['mathbf', 'bf', 'Aa0'],
271 ['boldsymbol', 'bm', 'Aa'], ['mathsf', 'sf', 'Aa0'], ['mathtt', 'tt', 'Aa0'],
272]) {
273 for (const first of ranges) {
274 for (let n = 0; n < (first === '0' ? 10 : 26); n++) {
275 const ch = String.fromCharCode(first.charCodeAt(0) + n)
276 LATEX[restyle(ch, ALPHABETS[key])] ??= `\\${font}{${ch}}`
277 }
278 }
279}
280
281const invert = (table: Record<string, string>) =>
282 Object.fromEntries(Object.entries(table).filter(([from, to]) => to !== from && to !== '*' && to !== '°').reverse().map(([from, to]) => [to, from]))
283const RAISED = invert(SUP)
284const LOWERED = invert(SUB)
285
286export function latexMath(src: string): string {
287 const scripts = (text: string, table: Record<string, string>, mark: string) =>
288 text.replace(new RegExp(`[${Object.keys(table).join('')}]+`, 'gu'), run => `${mark}{${[...run].map(ch => table[ch]).join('')}}`)
289 return [...scripts(scripts(src, RAISED, '^'), LOWERED, '_')]
290 .map(ch => (LATEX[ch] === undefined ? ch : LATEX[ch] && `\\ensuremath{${LATEX[ch]}}`))
291 .join('')
292}
293
294export function unicodeMath(src: string): string {
295 let out = ''
296 let i = 0
297 while (i < src.length) {
298 const c = src[i]
299 if (c === '^' || c === '_') {
300 const [a, next] = arg(src, i + 1)
301 out += script(unicodeMath(a), c === '^' ? SUP : SUB, c)
302 i = next
303 } else if (c === '{' || c === '}') {
304 i++
305 } else if (c === '\\') {
306 const name = /^\\([a-zA-Z]+|.)/.exec(src.slice(i))?.[1] ?? ''
307 i += name.length + 1
308 if (src[i] === '*' && STARRED.has(name)) i++
309 if (['frac', 'dfrac', 'tfrac', 'cfrac', 'genfrac'].includes(name)) {
310 if (name === 'genfrac') for (let skipped = 0; skipped < 4; skipped++) i = arg(src, i)[1]
311 const [num, a] = arg(src, i)
312 const [den, b] = arg(src, a)
313 const wrap = (t: string) => (t.length > 1 ? `(${t})` : t)
314 out += `${wrap(unicodeMath(num))}/${wrap(unicodeMath(den))}`
315 i = b
316 } else if (['binom', 'dbinom', 'tbinom'].includes(name)) {
317 const [n, a] = arg(src, i)
318 const [k, b] = arg(src, a)
319 out += `C(${unicodeMath(n)}, ${unicodeMath(k)})`
320 i = b
321 } else if (name === 'sqrt') {
322 const [n, start] = opt(src, i)
323 const [a, next] = arg(src, start)
324 const inner = unicodeMath(a)
325 const root = n === undefined || n === '2' ? '√' : n === '3' ? '∛' : n === '4' ? '∜'
326 : `${script(unicodeMath(n), SUP, '^')}√`
327 out += inner.length > 1 ? `${root}(${inner})` : `${root}${inner}`
328 i = next
329 } else if (['overset', 'underset', 'stackrel'].includes(name)) {
330 const [mark, a] = arg(src, i)
331 const [base, b] = arg(src, a)
332 out += unicodeMath(base) + (name === 'underset' ? script(unicodeMath(mark), SUB, '_') : script(unicodeMath(mark), SUP, '^'))
333 i = b
334 } else if (name === 'overunderset') {
335 const [over, a] = arg(src, i)
336 const [under, b] = arg(src, a)
337 const [base, next] = arg(src, b)
338 out += unicodeMath(base) + script(unicodeMath(over), SUP, '^') + script(unicodeMath(under), SUB, '_')
339 i = next
340 } else if (XARROWS[name]) {
341 const [a, next] = arg(src, opt(src, i)[1])
342 out += `${XARROWS[name][0]}${unicodeMath(a)}${XARROWS[name][1]}`
343 i = next
344 } else if (name === 'not') {
345 const [a, next] = arg(src, i)
346 const [first = '', ...rest] = [...unicodeMath(a)]
347 out += `${first}\u0338`.normalize('NFC') + rest.join('')
348 i = next
349 } else if (['pmod', 'pod', 'tag', 'eqref'].includes(name)) {
350 const [a, next] = arg(src, i)
351 out += name === 'pmod' ? `(mod ${unicodeMath(a)})` : `(${name === 'pod' ? unicodeMath(a) : a})`
352 i = next
353 } else if (name === 'textsuperscript' || name === 'textsubscript') {
354 const [a, next] = arg(src, i)
355 out += name === 'textsuperscript' ? script(a, SUP, '^') : script(a, SUB, '_')
356 i = next
357 } else if (name === 'boxed' || name === 'fbox') {
358 const [a, next] = arg(src, i)
359 out += `[${unicodeMath(a)}]`
360 i = next
361 } else if (BRACES.has(name)) {
362 const [a, next] = arg(src, i)
363 const inner = unicodeMath(a)
364 out += [...inner].length > 1 ? `(${inner})` : inner
365 i = next
366 } else if (name === 'phantom' || name === 'hphantom') {
367 const [a, next] = arg(src, i)
368 out += ' '.repeat([...unicodeMath(a)].length)
369 i = next
370 } else if (FILLERS[name]) {
371 out += FILLERS[name]
372 i = arg(src, i)[1]
373 } else if (name === 'begin') {
374 const [env, start] = arg(src, i)
375 const close = `\\end{${env}}`
376 const end = src.indexOf(close, start)
377 out += environment(env, src.slice(start, end < 0 ? src.length : end))
378 i = end < 0 ? src.length : end + close.length
379 } else if (FONTS[name] || RAW.has(name) || PASS.has(name)) {
380 const [a, next] = arg(src, i)
381 const inner = RAW.has(name) ? a : unicodeMath(a)
382 out += FONTS[name] ? restyle(inner, ALPHABETS[FONTS[name]]) : inner
383 i = next
384 } else if (ACCENTS[name]) {
385 const [a, next] = arg(src, i)
386 const inner = unicodeMath(a)
387 out += SPREAD.has(name) ? [...inner].map(ch => ch + ACCENTS[name]).join('') : inner + ACCENTS[name]
388 i = next
389 } else if (DROP.has(name)) {
390 i = arg(src, i)[1]
391 } else if (NOOP.has(name)) {
392 if (['left', 'right', 'middle'].includes(name) && src[i] === '.') i++
393 } else if ([',', ';', ':', ' ', '>', '\\'].includes(name)) {
394 out += ' '
395 } else if (name === '!' || name === '/' || name === '-') {
396 } else if (name === '|') {
397 out += '‖'
398 } else if (name === '{' || name === '}' || name === '$' || name === '%' || name === '#' || name === '&' || name === '_') {
399 out += name
400 } else {
401 out += SYMBOLS[name] ?? (FUNCTIONS.has(name) ? name : `\\${name}`)
402 while (SYMBOLS[name] === undefined && !FUNCTIONS.has(name) && src[i] === '{') {
403 const [a, next] = arg(src, i)
404 out += `{${a}}`
405 i = next
406 }
407 }
408 } else if (c === "'") {
409 out += '′'
410 i++
411 } else if (c === '~') {
412 out += ' '
413 i++
414 } else {
415 out += c
416 i++
417 }
418 }
419 return out
420}
421types/index.d.ts 8 lines1export type Renders = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 readable: { renders: Renders }
6 }
7}
8