Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side. /md opens it.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 807 lines1// md-preview: the Markdown files Claude edits, rendered like GitHub, in a pane.
2// A toast says when one changes; /md opens the pane on the latest one.
3// b shows the file before the last edit next to it now; /md compare <a> <b> shows
4// two files side by side; o opens the page in the browser.
5// GitHub renders it (gh api /markdown) when the file is in a GitHub repo and gh is
6// signed in; a small built-in renderer does otherwise. Headless Chrome draws the
7// page to PNG for the terminal; without Chrome or an image terminal, a text view.
8// A file Claude edits is drawn ahead, in the background, so /md opens on a page that
9// is already drawn; drawings are kept by what they show, so n/p back to a file is instant.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { MdFile, MdFrame, MdPair } from '../types'
14import { changedChunks, parse, toHtml, toLines, withMarks } from './md'
15import type { Line } from './md'
16import { fileUrl, page } from './page'
17import type { Side } from './page'
18
19const PANE = 'md-preview'
20// Chrome or a Chromium, first found; macOS first, then the usual Linux names.
21export const CHROMES = [
22 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
23 '/Applications/Chromium.app/Contents/MacOS/Chromium',
24 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
25 '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
26 '/usr/bin/google-chrome',
27 '/usr/bin/google-chrome-stable',
28 '/usr/bin/chromium',
29 '/usr/bin/chromium-browser',
30]
31const GH_MAX_BYTES = 390_000 // GitHub's API takes up to about 400 KB
32const BEFORE_MAX = 400_000 // longer earlier texts are not kept for b
33const BEFORE_FILES = 5 // only the latest files keep their earlier text
34export const MD_FILE = /\.(md|mdx|markdown)$/i
35const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit'])
36const CELL_PX = 9 // page pixels per terminal column
37const ROW_PX = 19 // page pixels per terminal row
38const PART_ROWS = 250 // an Image is at most 255 rows: taller pages come in parts
39const SIDE_BY_SIDE_COLUMNS = 120 // narrower panes stack a compare
40const MAX_FILES = 30
41const GH_TIMEOUT_MS = 10_000
42const CACHE_SIZE = 20 // drawings kept, the least recently shown dropped first
43const AHEAD_FILES = 5 // files waiting to be drawn ahead; older ones are dropped
44
45// Draws page.html into part-N.png, PART_ROWS rows each; prints "<rows> <parts>".
46// Headless Chrome writes its output but does not always exit: wait for the file, then
47// close that Chrome (its own throwaway profile, never the person's browser).
48// $1 chrome $2 page URL $3 out dir $4 profile $5 width (css px) $6 scale
49export const SCRIPT = `c="$1"; pg="$2"; out="$3"; prof="$4"; w="$5"; sc="$6"; pid=""
50mkdir -p "$out"; rm -f "$out"/part-*.png "$out"/dom.html
51stop() { sleep 0.2; pkill -P "$1" 2>/dev/null; kill "$1" 2>/dev/null; }
52trap '[ -n "$pid" ] && { pkill -P $pid 2>/dev/null; kill $pid 2>/dev/null; }' EXIT INT TERM
53"$c" --headless=new --disable-gpu --hide-scrollbars --no-first-run --no-default-browser-check --user-data-dir="$prof" --window-size="$w,800" --dump-dom "$pg" >"$out/dom.html" 2>/dev/null & pid=$!
54i=0; while ! grep -q '</html>' "$out/dom.html" 2>/dev/null && [ $i -lt 100 ]; do sleep 0.1; i=$((i+1)); done; stop $pid
55hgt=$(sed -n 's/.*data-height="\\([0-9]*\\)".*/\\1/p' "$out/dom.html" | head -1)
56[ -n "$hgt" ] || exit 2
57rows=$(( (hgt + ${ROW_PX - 1}) / ${ROW_PX} )); [ $rows -gt 1500 ] && rows=1500
58n=0; at=0
59while [ $at -lt $rows ]; do
60 r=$((rows - at)); [ $r -gt ${PART_ROWS} ] && r=${PART_ROWS}
61 f="$out/part-$n.png"
62 "$c" --headless=new --disable-gpu --hide-scrollbars --no-first-run --no-default-browser-check --user-data-dir="$prof" --force-device-scale-factor="$sc" --window-size="$w,$((r * ${ROW_PX}))" --screenshot="$f" "$pg#$((at * ${ROW_PX}))" >/dev/null 2>&1 & pid=$!
63 i=0; while [ ! -s "$f" ] && [ $i -lt 100 ]; do sleep 0.1; i=$((i+1)); done; stop $pid
64 [ -s "$f" ] || exit 3
65 n=$((n+1)); at=$((at + r))
66done
67echo "$rows $n"`
68
69// Held by the host, so a hot reload keeps them.
70const files = atom({ plugin: 'md-preview', key: 'files' } as const, [] as MdFile[])
71const shown = atom({ plugin: 'md-preview', key: 'shown' } as const, null as string | null)
72const pair = atom({ plugin: 'md-preview', key: 'pair' } as const, null as MdPair)
73const compare = atom({ plugin: 'md-preview', key: 'compare' } as const, false)
74const frame = atom({ plugin: 'md-preview', key: 'frame' } as const, null as MdFrame)
75const view = atom({ plugin: 'md-preview', key: 'view' } as const, 'page' as 'page' | 'text')
76const note = atom({ plugin: 'md-preview', key: 'note' } as const, '')
77
78// The module's own bookkeeping; a reload starts it over.
79const draw = { pending: false, dirty: false, n: 0, columns: 100, failed: '', drawing: '', opened: 0, force: false }
80// One drawing, in its own folder (frame-<slot>), kept until it is the least recently shown.
81type Drawn = { slot: number; columns: number; parts: { file: string; rows: number }[]; n: number; via: 'GitHub' | 'built-in' }
82const cache = new Map<string, Drawn>() // by what it shows (see cacheKey); oldest first
83const ahead = new Set<string>() // files to draw before /md asks for them; newest last
84const repos = new Map<string, string | null>() // each folder's GitHub repo, from its remotes
85const ghFailed = new Set<string>() // repos whose last GitHub render failed
86const turn = { isOn: false } // the main turn is under way: draw ahead once it ends
87const session = { cwd: '', home: '', command: 'md', canImage: false, chrome: '', noChrome: false, ghDownUntil: 0, opener: '' }
88const toasted = new Set<string>() // files toasted this turn
89const turnBefore = new Map<string, string | null>() // each file's text before this turn's first edit
90// Markdown files before the turn's first shell command, to catch writes that skip Write/Edit (`cat > README.md`).
91const shot = { taken: false, isOff: false, isFull: false, listed: new Set<string>(), texts: new Map<string, string>(), tooBig: new Set<string>() }
92const SNAPSHOT_FILES = 200
93const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
94let snapshotting: Promise<void> | null = null // shared, so parallel shell calls take one snapshot
95
96const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
97const folder = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'
98
99// What the pane shows: one file, a file before and after its last edit, or two files.
100type Part = { label?: string; path: string; isBefore?: boolean }
101type Plan = { key: string; title: string; parts: Part[] }
102type Loaded = { label?: string; path: string; text: string; marks: number[] }
103
104// owner/repo from `git remote -v`, the first GitHub remote.
105export function githubRepo(remotes: string): string | null {
106 const m = remotes.match(/(?:^|\s)(?:[\w.-]+@github\.com:|(?:https?|ssh|git):\/\/(?:[^@\s/]+@)?github\.com\/)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?(?:\s|$)/m)
107 return m ? `${m[1]}/${m[2]}` : null
108}
109
110// Terminals that draw pictures, and not through tmux (it passes none).
111async function imageTerminal($: EngineInterface) {
112 const low = (v: string | undefined) => (v ?? '').toLowerCase()
113 if (low(await $.env.get('TMUX').catch(() => undefined))) return false
114 const program = low(await $.env.get('TERM_PROGRAM').catch(() => undefined))
115 const term = low(await $.env.get('TERM').catch(() => undefined))
116 return /ghostty|iterm|wezterm|kitty/.test(program) || /kitty|ghostty|foot|wezterm/.test(term)
117}
118
119async function paneUp($: EngineInterface) {
120 return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
121}
122
123function absolute(arg: string) {
124 let p = arg.replace(/^["']|["']$/g, '')
125 if (p.startsWith('~/') && session.home) p = session.home + p.slice(1)
126 else if (!p.startsWith('/')) p = `${session.cwd.replace(/\/$/, '')}/${p}`
127 const parts: string[] = []
128 for (const s of p.split('/')) {
129 if (s === '..') parts.pop()
130 else if (s && s !== '.') parts.push(s)
131 }
132 return `/${parts.join('/')}`
133}
134
135export const register: Register = on => {
136 on('session.start', async ($, e, next) => {
137 const r = await next(e)
138 session.cwd = e.cwd
139 session.home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
140 session.opener = ''
141 session.canImage = await imageTerminal($)
142 // /md, or /mdview if a built-in ever takes /md.
143 for (const name of ['md', 'mdview']) {
144 const ok = await $.command
145 .register({ name, description: 'Preview the Markdown files Claude edited, rendered like GitHub', argumentHint: '[path | compare <a> <b> | open]' })
146 .then(() => true)
147 .catch(() => false)
148 if (ok) {
149 session.command = name
150 break
151 }
152 }
153 return r
154 })
155
156 on('turn.start', async (_$, e, next) => {
157 if (!e.agentId) {
158 // A subagent starting mid-turn must not drop the main turn's tracking.
159 toasted.clear()
160 turnBefore.clear()
161 resetSnapshot()
162 turn.isOn = true
163 }
164 return next(e)
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 const r = await next(e)
169 if (e.agentId) return r
170 if (shot.taken) await compareSnapshot($).catch(() => {}) // after next(e): the turn is already over
171 turn.isOn = false
172 await startAhead($).catch(() => {}) // the files the turn changed, each drawn once, as it left them
173 return r
174 })
175
176 // Reads what the main agent wrote; never blocks or changes the call.
177 on('tool.call', async ($, e, next) => {
178 // Shell commands can write Markdown without Write/Edit: snapshot before the turn's first one,
179 // compare at turn end. Turns without a shell command cost nothing.
180 if (SHELL_TOOLS.has(e.tool) && !shot.taken && !shot.isOff) {
181 snapshotting ??= takeSnapshot($).catch(() => {}).finally(() => (snapshotting = null))
182 await snapshotting
183 }
184 if (!EDIT_TOOLS.has(e.tool) || e.agentId) return next(e)
185 const path = (e as unknown as { file_path?: unknown }).file_path
186 if (typeof path !== 'string' || !MD_FILE.test(path)) return next(e)
187 const before = await $.fs.read(path).catch(() => null) // before the write lands
188 const r = await next(e)
189 if (r.deny !== undefined || r.isError === true) return r
190 await changed($, path, typeof before === 'string' ? before : null).catch(() => {}) // quick: a read and a toast
191 return r
192 })
193
194 on('command.run', { command: 'md' }, async ($, e) => command($, e.args))
195 on('command.run', { command: 'mdview' }, async ($, e) => command($, e.args))
196
197 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198 const { Box, Text, Button } = $.ui.resolve(e)
199 const list = await read($, files)
200 const path = await read($, shown)
201 const two = await read($, pair)
202 const isCompare = await read($, compare)
203 const v = await read($, view)
204 const msg = await read($, note)
205 const columns = Math.max(20, e.props.bodyColumns)
206 draw.columns = columns
207 const p = await plan($)
208 const i = list.findIndex(f => f.path === path)
209 const isPage = e.surface === 'terminal' && v === 'page' && session.canImage && !session.noChrome
210 const f = await read($, frame)
211 const file = list.find(x => x.path === path)
212 const where = two ? `${base(two.a)} | ${base(two.b)}` : path ? base(path) : ''
213 const info = two
214 ? ' side by side'
215 : ` ${i + 1}/${list.length}${isCompare ? (file?.before !== undefined ? ' · before | after' : ' · new file, no earlier version') : ''}${file?.marks.length ? ' · green bar: changed in the last edit' : ''}`
216 const head = (
217 <Box flexDirection="column">
218 <Box flexDirection="row" gap={1}>
219 <Button key="prev" label="Prev" hotkey="p" onPress={() => step($, -1)} />
220 <Button key="next" label="Next" hotkey="n" onPress={() => step($, 1)} />
221 <Button key="before" label={isCompare ? 'Single' : 'Before'} hotkey="b" onPress={() => toggleCompare($)} />
222 <Button key="again" label="Render" hotkey="r" onPress={() => again($)} />
223 <Button key="view" label={v === 'page' ? 'Text' : 'Page'} hotkey="t" onPress={() => toggleView($)} />
224 <Button key="open" label="Browser" hotkey="o" onPress={() => void openInBrowser($).then(t => $.ui.toast(`md-preview: ${t}`)).catch(() => {})} />
225 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
226 </Box>
227 {p && (
228 <Text wrap="truncate-end">
229 <Text bold>{where}</Text>
230 <Text dimColor>{`${info}${isPage && f?.key === p.key ? ` · ${f.via}` : ''}`}</Text>
231 </Text>
232 )}
233 </Box>
234 )
235 if (!p) {
236 return (
237 <Box flexDirection="column">
238 {head}
239 <Text dimColor>{`No Markdown edits yet. /${session.command} <path> opens a file.`}</Text>
240 </Box>
241 )
242 }
243
244 if (isPage) {
245 const key = `${p.key}@${columns}`
246 const isCurrent = f !== null && f.key === p.key && f.columns === columns
247 if (!isCurrent && draw.failed !== key) {
248 if (draw.pending) draw.dirty = true // draw again once this one is done
249 else void renderSoon($).catch(() => {})
250 }
251 // The last drawing of the same view stays up while a newer one draws, scaled to the width.
252 const same = f !== null && sameView(f.key, p.key)
253 if (f && same) {
254 const { Image } = $.ui.resolve(e)
255 return (
256 <Box flexDirection="column">
257 {head}
258 {f.parts.map((part, k) => (
259 <Image key={`part-${k}`} source={{ file: part.file, format: 'png', generation: f.n }} columns={columns} rows={Math.max(1, Math.min(255, Math.round((part.rows * columns) / f.columns)))} alt={k === 0 ? `${where}, rendered` : ' '} />
260 ))}
261 </Box>
262 )
263 }
264 }
265
266 // The text view: while the page draws, and where it cannot.
267 const sides = await load($, p)
268 const many = sides.length > 1
269 const lines: Line[] = []
270 for (const s of sides) {
271 if (many) lines.push({ prefix: '', segs: [{ text: `── ${s.label ?? base(s.path)} ──`, bold: true, color: 'yellow' }] }, { prefix: '', segs: [] })
272 lines.push(...toLines(parse(withMarks(s.text, s.marks)), columns - 2))
273 if (many) lines.push({ prefix: '', segs: [] })
274 }
275 const status = isPage ? msg || 'Rendering…' : msg
276 return (
277 <Box flexDirection="column">
278 {head}
279 {status && <Text dimColor>{status}</Text>}
280 {sides.length === 0 && <Text color="red">{`Cannot read ${where}`}</Text>}
281 {lines.slice(0, 3000).map((l, k) => (
282 <Text key={`l${k}`} wrap="wrap">
283 <Text color="green">{l.mark ? '▌' : ' '}</Text>
284 <Text dimColor>{l.prefix}</Text>
285 {l.segs.length === 0 ? <Text> </Text> : l.segs.map((s, j) => (
286 <Text key={`s${j}`} bold={s.bold} italic={s.italic} dimColor={s.dim} color={s.color} underline={s.underline} strikethrough={s.strike}>
287 {s.text.length > 4000 ? `${s.text.slice(0, 4000)}…` : s.text /* a Text child holds at most 10,000 characters */}
288 </Text>
289 ))}
290 </Text>
291 ))}
292 </Box>
293 )
294 })
295}
296
297// Two keys name the same view when only the versions differ.
298function sameView(a: string, b: string) {
299 return a.replace(/\u0001\d+/g, '') === b.replace(/\u0001\d+/g, '')
300}
301
302// What to show now, without reading any file.
303async function plan($: EngineInterface): Promise<Plan | null> {
304 return planFor(await read($, files), await read($, shown), await read($, pair), await read($, compare))
305}
306
307function planFor(list: MdFile[], path: string | null, two: MdPair, isCompare: boolean): Plan | null {
308 const at = (p: string) => list.find(f => f.path === p)?.at ?? 0
309 if (two) return { key: `pair:${two.a}\u0001${at(two.a)}|${two.b}\u0001${at(two.b)}`, title: `${base(two.a)} | ${base(two.b)}`, parts: [{ label: base(two.a), path: two.a }, { label: base(two.b), path: two.b }] }
310 if (!path) return null
311 const file = list.find(f => f.path === path)
312 if (isCompare && file?.before !== undefined) {
313 return { key: `cmp:${path}\u0001${at(path)}`, title: `${base(path)}: before | after`, parts: [{ label: 'Before', path, isBefore: true }, { label: 'After', path }] }
314 }
315 return { key: `one:${path}\u0001${at(path)}`, title: base(path), parts: [{ path }] }
316}
317
318// The texts a plan shows, with the blocks to mark green.
319async function load($: EngineInterface, p: Plan): Promise<Loaded[]> {
320 const list = await read($, files)
321 const out: Loaded[] = []
322 for (const part of p.parts) {
323 const file = list.find(f => f.path === part.path)
324 const text = part.isBefore ? file?.before : await $.fs.read(part.path).catch(() => null)
325 if (typeof text !== 'string') return []
326 out.push({ label: part.label, path: part.path, text, marks: part.isBefore ? [] : (file?.marks ?? []) })
327 }
328 // Two files: mark what the second has that the first does not.
329 if (p.key.startsWith('pair:') && out.length === 2) {
330 const [a, b] = out as [Loaded, Loaded]
331 b.marks = changedChunks(a.text, b.text)
332 a.marks = []
333 }
334 return out
335}
336
337// /md, /md <path>, /md compare <a> <b>, /md open.
338async function command($: EngineInterface, args: string) {
339 const words = args.trim().split(/\s+/).filter(Boolean)
340 if (words[0] === 'open') return { text: await openInBrowser($) }
341 if (words[0] === 'compare') {
342 // Quotes keep a path with spaces as one: compare "option a.md" "option b.md".
343 const named = [...args.trim().matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
344 if (named.length !== 3) return { text: `usage: /${session.command} compare <fileA> <fileB>` }
345 const [a, b] = [absolute(named[1] as string), absolute(named[2] as string)]
346 for (const f of [a, b]) {
347 if (!MD_FILE.test(f)) return { text: `${base(f)} is not a .md, .mdx or .markdown file.` }
348 if (!(await $.fs.exists(f).catch(() => false))) return { text: `no file at ${f}` }
349 }
350 await update($, pair, () => ({ a, b }))
351 await update($, files, list => list.map(f => (f.path === a || f.path === b ? { ...f, at: Date.now() } : f)))
352 await fromCache($).catch(() => false) // already drawn: the pane opens on the page
353 await $.ui.open({ id: PANE, title: 'Markdown', focus: true })
354 void renderSoon($).catch(() => {})
355 return { text: `${base(a)} | ${base(b)} side by side. o: open in the browser · q: close` }
356 }
357 let target: string | null = null
358 if (words.length) {
359 const abs = absolute(args.trim())
360 if (!MD_FILE.test(abs)) return { text: 'give a .md, .mdx or .markdown file.' }
361 if (!(await $.fs.exists(abs).catch(() => false))) return { text: `no file at ${abs}` }
362 await update($, files, list => (list.some(f => f.path === abs) ? list : [{ path: abs, at: Date.now(), marks: [] }, ...list].slice(0, MAX_FILES)))
363 target = abs
364 } else {
365 target = (await read($, files))[0]?.path ?? null
366 }
367 // Read again now: it may have changed outside Claude since it was last drawn.
368 if (target) await update($, files, list => list.map(f => (f.path === target ? { ...f, at: Date.now() } : f)))
369 if (!target) return { text: `no Markdown edits yet this session. /${session.command} <path> opens a file.` }
370 await update($, shown, () => target)
371 await update($, pair, () => null)
372 await fromCache($).catch(() => false) // drawn ahead: the pane opens on the page
373 await $.ui.open({ id: PANE, title: 'Markdown', focus: true })
374 void renderSoon($).catch(() => {})
375 return { text: `${base(target)}. n/p: next/previous · b: before | after · o: browser · r: render again · t: page/text · q: close` }
376}
377
378function resetSnapshot() {
379 shot.taken = false
380 shot.isOff = false
381 shot.isFull = false
382 shot.listed.clear()
383 shot.texts.clear()
384 shot.tooBig.clear()
385}
386
387// The repo's Markdown files (tracked and untracked, not ignored), or null when git can't list them.
388async function listMarkdown($: EngineInterface): Promise<string[] | null> {
389 if (!session.cwd) return null
390 const git = await $.process
391 .run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.md', '*.mdx', '*.markdown'], { cwd: session.cwd, timeoutMs: 5000 }) // a shell command waits on it
392 .catch(() => null)
393 if (!git || git.exitCode !== 0) return null
394 const root = session.cwd.replace(/\/$/, '')
395 return [...new Set(git.stdout.split('\0').filter(l => l !== '' && MD_FILE.test(l)))].map(l => `${root}/${l}`)
396}
397
398async function readAll($: EngineInterface, paths: string[]): Promise<(string | null)[]> {
399 const out: (string | null)[] = []
400 for (let i = 0; i < paths.length; i += 20) {
401 const batch = await Promise.all(paths.slice(i, i + 20).map(p => $.fs.read(p).then(t => (typeof t === 'string' ? t : null)).catch(() => null)))
402 out.push(...batch)
403 }
404 return out
405}
406
407async function takeSnapshot($: EngineInterface) {
408 const all = await listMarkdown($)
409 if (all === null) {
410 shot.isOff = true // not a git repo, or git failed or timed out: stay off for this turn
411 return
412 }
413 const paths = all.slice(0, SNAPSHOT_FILES)
414 shot.isFull = all.length > SNAPSHOT_FILES
415 const texts = await readAll($, paths)
416 paths.forEach((p, i) => {
417 shot.listed.add(p)
418 const text = texts[i]
419 if (typeof text === 'string' && text.length <= BEFORE_MAX) shot.texts.set(p, text)
420 else shot.tooBig.add(p)
421 })
422 shot.taken = true
423}
424
425async function compareSnapshot($: EngineInterface) {
426 const all = await listMarkdown($)
427 if (all === null) return
428 // A file is new only if the listing wasn't cut off; otherwise files that slid into the window would look new.
429 const paths = all.filter(p => !turnBefore.has(p) && !shot.tooBig.has(p) && (shot.listed.has(p) || !shot.isFull)).slice(0, SNAPSHOT_FILES)
430 const nows = await readAll($, paths)
431 for (let i = 0; i < paths.length; i++) {
432 const path = paths[i]
433 const now = nows[i]
434 if (now === null) continue
435 const was = shot.texts.get(path)
436 if (was === undefined) await changed($, path, null) // new file this turn
437 else if (was !== now) await changed($, path, was)
438 }
439}
440
441// A Markdown file changed: remember its text before and which blocks, redraw or say so.
442
443async function changed($: EngineInterface, path: string, before: string | null) {
444 const after = await $.fs.read(path).catch(() => null)
445 if (typeof after !== 'string') return
446 if (!turnBefore.has(path)) turnBefore.set(path, before)
447 const first = turnBefore.get(path) ?? null // several edits in a turn compare with the turn's start
448 const marks = changedChunks(first, after)
449 const entry: MdFile = first === null || first.length > BEFORE_MAX ? { path, at: Date.now(), marks } : { path, at: Date.now(), marks, before: first }
450 await update($, files, list =>
451 [entry, ...list.filter(f => f.path !== path)].slice(0, MAX_FILES).map((f, i) => {
452 if (i < BEFORE_FILES || f.before === undefined) return f
453 const { before: _drop, ...rest } = f
454 return rest
455 }),
456 )
457 const two = await read($, pair)
458 const isShown = ((await read($, shown)) === path || two?.a === path || two?.b === path) && (await paneUp($))
459 if (isShown) {
460 await renderSoon($)
461 return
462 }
463 await drawAhead($, path).catch(() => {}) // quick: it queues the drawing, never waits for it
464 if (toasted.has(path)) return
465 toasted.add(path)
466 $.ui.toast(`${base(path)} changed · /${session.command} to preview`)
467}
468
469async function step($: EngineInterface, by: number) {
470 const list = await read($, files)
471 if (list.length === 0) return
472 const now = await read($, shown)
473 const i = list.findIndex(f => f.path === now)
474 const next = list[(((i + by) % list.length) + list.length) % list.length]
475 if (next) await update($, shown, () => next.path)
476 await update($, pair, () => null)
477 await renderSoon($)
478}
479
480async function again($: EngineInterface) {
481 draw.failed = ''
482 session.noChrome = false
483 session.chrome = ''
484 session.ghDownUntil = 0
485 repos.clear()
486 ghFailed.clear()
487 draw.force = true
488 await renderSoon($) // the current picture stays up until the new one is ready
489}
490
491async function toggleView($: EngineInterface) {
492 await update($, view, v => (v === 'page' ? 'text' : 'page'))
493 await renderSoon($)
494}
495
496async function toggleCompare($: EngineInterface) {
497 await update($, pair, () => null)
498 await update($, compare, c => !c)
499 await renderSoon($)
500}
501
502// One drawing at a time; a change while drawing draws once more after.
503async function renderSoon($: EngineInterface) {
504 if (draw.pending) {
505 draw.dirty = true
506 return
507 }
508 if (!(await plan($)) || (await read($, view)) !== 'page' || !session.canImage || session.noChrome) return
509 if (!(await paneUp($))) return
510 if (draw.pending) {
511 draw.dirty = true // another call claimed the slot while this one checked
512 return
513 }
514 draw.pending = true
515 draw.dirty = false
516 // Drawn before (ahead, or earlier this session): shown now, no Chrome.
517 if (!draw.force && (await fromCache($).catch(() => false))) {
518 draw.pending = false
519 void afterDraw($).catch(() => {})
520 return
521 }
522 $.clock.after(300, () => void run($, true))
523}
524
525// Draws a file Claude changed, in the background once the turn ends, so /md opens on it drawn.
526// One file waits once: edited again before it is drawn, it is drawn once, as it is then.
527async function drawAhead($: EngineInterface, path: string) {
528 if (!session.canImage || session.noChrome || (await read($, view)) !== 'page') return
529 ahead.delete(path)
530 ahead.add(path)
531 for (const old of ahead) if (ahead.size > AHEAD_FILES) ahead.delete(old)
532 if (!turn.isOn) await startAhead($) // mid-turn, the file may change again: wait for the end
533}
534
535async function startAhead($: EngineInterface) {
536 if (ahead.size === 0 || draw.pending) return // the drawing under way picks it up when done
537 draw.pending = true
538 draw.dirty = false
539 $.clock.after(300, () => void run($, false)) // edits close together wait for each other
540}
541
542// The one drawing at a time, for the pane or ahead: two Chromes must never share the profile.
543// The pane comes first; then the newest file waiting to be drawn ahead.
544async function run($: EngineInterface, forPane: boolean) {
545 let isPane = forPane
546 try {
547 if (!isPane) isPane = await paneNeeds($)
548 if (isPane) await renderFile($)
549 else {
550 const path = turn.isOn ? undefined : [...ahead].pop() // a new turn began: wait for its end
551 if (path !== undefined) {
552 ahead.delete(path)
553 await renderAhead($, path)
554 }
555 }
556 } catch {
557 if (isPane) {
558 draw.failed = draw.drawing // no retry loop: r tries again
559 await update($, note, () => 'Could not draw the page. Showing text; r tries again.').catch(() => {})
560 }
561 } finally {
562 draw.pending = false
563 void afterDraw($).catch(() => {})
564 }
565}
566
567// After a drawing: the pane again if it went stale meanwhile, else the next file to draw ahead.
568async function afterDraw($: EngineInterface) {
569 if (draw.dirty) await drawAgainIfStale($)
570 if (draw.pending || ahead.size === 0 || turn.isOn) return
571 draw.pending = true
572 draw.dirty = false
573 void run($, false)
574}
575
576// Whether the open pane shows something other than what it should.
577async function paneNeeds($: EngineInterface) {
578 const p = await plan($)
579 if (!p || (await read($, view)) !== 'page' || !session.canImage || session.noChrome || !(await paneUp($))) return false
580 const f = await read($, frame)
581 const isStale = (f === null || f.key !== p.key || f.columns !== draw.columns) && draw.failed !== `${p.key}@${draw.columns}`
582 return draw.force || isStale
583}
584
585// After a drawing: once more only if what is on screen is no longer what to show.
586async function drawAgainIfStale($: EngineInterface) {
587 const p = await plan($)
588 const f = await read($, frame)
589 const isStale = p !== null && (f === null || f.key !== p.key || f.columns !== draw.columns) && draw.failed !== `${p.key}@${draw.columns}`
590 if (draw.force || isStale) await renderSoon($)
591}
592
593// GitHub's own renderer, through the person's gh; null when it cannot be used.
594async function github($: EngineInterface, md: string, dir: string): Promise<string | null> {
595 if ((await $.clock.now()) < session.ghDownUntil) return null
596 if (new TextEncoder().encode(md).length > GH_MAX_BYTES) return null // too large for the API: built-in, no penalty
597 const repo = await repoOf($, dir)
598 if (!repo) return null // not in a GitHub repo: the Markdown stays on this machine
599 const r = await $.process
600 .run(['gh', 'api', '-X', 'POST', '/markdown', '--input', '-'], { stdin: JSON.stringify({ text: md, mode: 'markdown', context: repo }), timeoutMs: GH_TIMEOUT_MS })
601 .catch(() => null)
602 if (!r || r.exitCode !== 0 || !r.stdout.trim()) {
603 session.ghDownUntil = (await $.clock.now()) + 60_000 // offline or signed out: built-in for a minute
604 ghFailed.add(repo)
605 return null
606 }
607 ghFailed.delete(repo)
608 return r.stdout
609}
610
611// A folder's GitHub repo, asked of git once (r asks again).
612async function repoOf($: EngineInterface, dir: string): Promise<string | null> {
613 if (repos.has(dir)) return repos.get(dir) ?? null
614 const remotes = await $.process.run(['git', '-C', dir, 'remote', '-v'], { timeoutMs: 5000 }).catch(() => null)
615 const repo = remotes && remotes.exitCode === 0 ? githubRepo(remotes.stdout) : null
616 if (remotes) repos.set(dir, repo) // a git that could not run is asked again next time
617 return repo
618}
619
620// The renderer html() would use now, without asking GitHub. A repo whose last GitHub
621// render failed counts as built-in until one works (or r), so kept drawings stay found.
622async function renderer($: EngineInterface, sides: Loaded[]): Promise<'GitHub' | 'built-in'> {
623 if ((await $.clock.now()) < session.ghDownUntil) return 'built-in'
624 for (const s of sides) {
625 if (new TextEncoder().encode(withMarks(s.text, s.marks)).length > GH_MAX_BYTES) return 'built-in'
626 const repo = await repoOf($, folder(s.path))
627 if (!repo || ghFailed.has(repo)) return 'built-in'
628 }
629 return 'GitHub'
630}
631
632// What a drawing shows: the files, their texts and marks, single or compare, the width, the renderer.
633async function cacheKey(p: Plan, sides: Loaded[], columns: number, via: string) {
634 const what = JSON.stringify([p.key.slice(0, p.key.indexOf(':')), p.title, sides.map(s => [s.label ?? '', s.path, s.text, s.marks]), columns, via])
635 const sum = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(what))
636 return Array.from(new Uint8Array(sum), b => b.toString(16).padStart(2, '0')).join('')
637}
638
639// A kept drawing, now the most recently shown.
640function cached(key: string) {
641 const d = cache.get(key)
642 if (d) {
643 cache.delete(key)
644 cache.set(key, d)
645 }
646 return d
647}
648
649// A folder for a new drawing: one no kept drawing uses and not the one on screen.
650function freeSlot(onScreen: string) {
651 const used = new Set([...cache.values()].map(d => d.slot))
652 let slot = 0
653 while (used.has(slot) || onScreen.includes(`/frame-${slot}/`)) slot++
654 return slot
655}
656
657function keep(key: string, d: Drawn, onScreen: string) {
658 cache.delete(key)
659 cache.set(key, d)
660 for (const [k, old] of cache) {
661 if (cache.size <= CACHE_SIZE) break
662 if (!onScreen.includes(`/frame-${old.slot}/`)) cache.delete(k) // its folder is reused later
663 }
664}
665
666// Draws a plan to PNG parts, or hands back the drawing kept for the same content.
667// null when Chrome could not draw it.
668async function drawPage($: EngineInterface, chrome: string, p: Plan, loaded: Loaded[], columns: number, force: boolean): Promise<Drawn | null> {
669 const want = await renderer($, loaded)
670 const hit = force ? undefined : cached(await cacheKey(p, loaded, columns, want))
671 if (hit) return hit
672 const { sides, via } = await html($, loaded)
673 const root = await tmpRoot($)
674 const f = await read($, frame)
675 const onScreen = f?.parts[0]?.file ?? ''
676 // Never the folder on screen: the script empties its folder first.
677 const slot = freeSlot(onScreen)
678 const pagePath = `${root}/page-${slot}.html`
679 const out = `${root}/frame-${slot}`
680 const width = columns * CELL_PX
681 await $.fs.write(pagePath, page(sides, { title: p.title, width, stacked: columns < SIDE_BY_SIDE_COLUMNS }))
682 const r = await $.process.run(['sh', '-c', SCRIPT, 'md-preview', chrome, fileUrl(pagePath), out, `${root}/chrome`, String(width), '2'], { timeoutMs: 120_000 })
683 const m = r.stdout.match(/(\d+) (\d+)/)
684 if (r.exitCode !== 0 || !m) return null
685 const rows = Number(m[1])
686 const parts = Array.from({ length: Number(m[2]) }, (_, k) => ({ file: `${out}/part-${k}.png`, rows: Math.min(PART_ROWS, rows - k * PART_ROWS) }))
687 draw.n = Math.max(draw.n, f?.n ?? -1) + 1 // a new picture in a folder: a new generation
688 const d: Drawn = { slot, columns, parts, n: draw.n, via }
689 keep(await cacheKey(p, loaded, columns, via), d, onScreen) // kept by the renderer it got
690 return d
691}
692
693// Shows the kept drawing of what the pane should show, when there is one; no Chrome.
694async function fromCache($: EngineInterface) {
695 if ((await read($, view)) !== 'page' || !session.canImage || session.noChrome) return false
696 const p = await plan($)
697 if (!p) return false
698 const columns = draw.columns
699 const loaded = await load($, p)
700 if (loaded.length === 0) return false
701 const d = cached(await cacheKey(p, loaded, columns, await renderer($, loaded)))
702 if (!d) return false
703 await update($, note, () => '')
704 await update($, frame, () => ({ key: p.key, columns, parts: d.parts, n: d.n, via: d.via }))
705 return true
706}
707
708// A file drawn ahead, as /md would show it, at the pane's last width; errors stay quiet.
709async function renderAhead($: EngineInterface, path: string) {
710 if ((await read($, view)) !== 'page' || !session.canImage || session.noChrome) return
711 const chrome = await findChrome($)
712 if (!chrome) return // the pane says so when it opens
713 const p = planFor(await read($, files), path, null, await read($, compare))
714 if (!p) return
715 const loaded = await load($, p)
716 if (loaded.length === 0) return
717 await drawPage($, chrome, p, loaded, draw.columns, false)
718}
719
720// Each side's HTML, from GitHub where it can.
721async function html($: EngineInterface, sides: Loaded[]): Promise<{ sides: Side[]; via: 'GitHub' | 'built-in' }> {
722 const mds = sides.map(s => withMarks(s.text, s.marks))
723 const fromGithub: string[] = []
724 for (const [k, md] of mds.entries()) {
725 const h1 = await github($, md, folder((sides[k] as Loaded).path))
726 if (h1 === null) break // one side cannot: all sides use the built-in renderer, so they match
727 fromGithub.push(h1)
728 }
729 const isGithub = fromGithub.length === sides.length
730 const out = sides.map((s, k) => ({ label: s.label, dir: folder(s.path), html: isGithub ? (fromGithub[k] as string) : toHtml(parse(mds[k] as string)) }))
731 return { sides: out, via: isGithub ? 'GitHub' : 'built-in' }
732}
733
734async function tmpRoot($: EngineInterface) {
735 const tmp = ((await $.env.get('TMPDIR').catch(() => undefined)) ?? '/tmp').replace(/\/$/, '')
736 return `${tmp}/md-preview`
737}
738
739async function renderFile($: EngineInterface) {
740 const p = await plan($)
741 if (!p) return
742 const columns = draw.columns
743 const key = `${p.key}@${columns}`
744 draw.drawing = key
745 const force = draw.force // r: draw it again, kept drawing or not
746 draw.force = false
747 const chrome = await findChrome($)
748 if (!chrome) {
749 session.noChrome = true
750 await update($, note, () => 'The rendered view needs Google Chrome (or Chromium). Showing text.')
751 return
752 }
753 const loaded = await load($, p)
754 if (loaded.length === 0) {
755 draw.failed = key
756 await update($, note, () => `Cannot read ${p.title}. r tries again.`)
757 return
758 }
759 const d = await drawPage($, chrome, p, loaded, columns, force)
760 if (!d) {
761 draw.failed = key
762 await update($, note, () => 'Chrome could not draw the page. Showing text; r tries again.')
763 return
764 }
765 await update($, note, () => '')
766 await update($, frame, () => ({ key: p.key, columns, parts: d.parts, n: d.n, via: d.via }))
767}
768
769async function findChrome($: EngineInterface) {
770 if (session.chrome) return session.chrome
771 for (const c of CHROMES) {
772 if (await $.fs.exists(c).catch(() => false)) {
773 session.chrome = c
774 return c
775 }
776 }
777 return ''
778}
779
780// The same page, full size in the person's own browser: self-contained HTML in a temp file.
781// Its text has no name in front: a command reply gets one from Claude Code, the Browser button's toast adds its own.
782async function openInBrowser($: EngineInterface): Promise<string> {
783 if (!(await read($, shown)) && !(await read($, pair))) {
784 const latest = (await read($, files))[0]?.path
785 if (latest) await update($, shown, () => latest)
786 }
787 const p = await plan($)
788 if (!p) return `nothing to open yet. /${session.command} <path> picks a file.`
789 const loaded = await load($, p)
790 if (loaded.length === 0) return `cannot read ${p.title}`
791 const { sides } = await html($, loaded)
792 const file = `${await tmpRoot($)}/open-${draw.opened++ % 5}.html` // five in turn, so they do not pile up
793 const isWritten = await $.fs.write(file, page(sides, { title: p.title, width: 0 })).then(() => true).catch(() => false)
794 if (!isWritten) return `could not write the page to ${file}`
795 const r = await $.process.run([await opener($), file], { timeoutMs: 10_000 }).catch(() => null)
796 return r && r.exitCode === 0 ? `opened ${p.title} in the browser` : `could not open the browser. The page is at ${file}`
797}
798
799// macOS's `open`, else `xdg-open`. Asked of uname, not of /usr/bin/open: elsewhere that path can be another program.
800async function opener($: EngineInterface) {
801 if (!session.opener) {
802 const uname = await $.process.run(['uname', '-s'], { timeoutMs: 3_000 }).catch(() => null)
803 session.opener = uname?.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open'
804 }
805 return session.opener
806}
807hooks/md.ts 534 lines1// A small Markdown reader: blocks, HTML for the page, styled lines for the text view,
2// and which top-level blocks the last edit changed.
3
4// A paragraph made of this word alone marks the block after it as changed.
5export const MARK = 'MDPREVIEWCHANGEDBLOCK'
6
7type Item = { task?: boolean; blocks: Block[] }
8export type Block =
9 | { t: 'h'; level: number; text: string }
10 | { t: 'p'; text: string }
11 | { t: 'code'; lang: string; text: string }
12 | { t: 'quote'; blocks: Block[] }
13 | { t: 'list'; ordered: boolean; start: number; items: Item[] }
14 | { t: 'table'; align: string[]; head: string[]; rows: string[][] }
15 | { t: 'hr' }
16 | { t: 'html'; text: string }
17 | { t: 'mark' }
18
19const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
20const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/ // the closing #s come off in headingText
21const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/
22const QUOTE = /^ {0,3}> ?/
23const ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])([ \t]+|$)(.*)$/
24const BLOCK_TAGS = 'address|article|aside|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|nav|ol|optgroup|option|p|picture|pre|script|section|source|style|summary|table|tbody|td|textarea|tfoot|th|thead|title|tr|ul|video|audio|img|a'
25const HTML_START = new RegExp(`^ {0,3}(?:<!--|<\\/?(?:${BLOCK_TAGS})(?:[\\s/>]|$))`, 'i')
26const DELIM = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/ // on the trimmed line: blanks at both ends made it quadratic
27
28const isBlank = (l: string) => l.trim() === ''
29const indentOf = (l: string) => (l.match(/^ */)?.[0].length ?? 0)
30
31function startsBlock(l: string) {
32 return FENCE.test(l) || HEADING.test(l) || HR.test(l) || QUOTE.test(l) || ITEM.test(l) || HTML_START.test(l)
33}
34
35function cells(row: string) {
36 let r = row.trim()
37 if (r.startsWith('|')) r = r.slice(1)
38 if (r.endsWith('|') && !r.endsWith('\\|')) r = r.slice(0, -1)
39 return r.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
40}
41
42// Reference definitions ([id]: url), used by [text][id] and [id][] links.
43let refs = new Map<string, string>()
44const DEF = /^ {0,3}\[([^\]]+)\]:[ \t]*<?([^\s>]+)>?(?:[ \t]+["'(].*["')])?[ \t]*$/
45
46export function parse(md: string): Block[] {
47 const lines = md
48 .replace(/\r\n?/g, '\n')
49 .split('\n')
50 .map(l => l.replace(/^\t+/, t => ' '.repeat(t.length)))
51 refs = new Map()
52 let fence = ''
53 const kept = lines.filter(l => {
54 const f = l.match(FENCE)
55 if (fence) {
56 if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(l)) fence = ''
57 return true
58 }
59 if (f) fence = f[1] as string
60 const d = !fence && l.match(DEF)
61 if (d) refs.set((d[1] as string).toLowerCase(), d[2] as string)
62 return !d
63 })
64 return blocks(kept)
65}
66
67function blocks(lines: string[]): Block[] {
68 const out: Block[] = []
69 let i = 0
70 while (i < lines.length) {
71 const l = lines[i] as string
72 if (isBlank(l)) {
73 i++
74 continue
75 }
76 if (indentOf(l) >= 4) {
77 const body: string[] = []
78 while (i < lines.length && (indentOf(lines[i] as string) >= 4 || isBlank(lines[i] as string))) {
79 body.push((lines[i] as string).slice(4))
80 i++
81 }
82 while (body.length && isBlank(body[body.length - 1] as string)) body.pop()
83 out.push({ t: 'code', lang: '', text: body.join('\n') })
84 continue
85 }
86 const fence = l.match(FENCE)
87 if (fence) {
88 const mark = fence[1] as string
89 const body: string[] = []
90 const pad = indentOf(l)
91 i++
92 while (i < lines.length && !new RegExp(`^ {0,3}${mark[0] === '`' ? '`' : '~'}{${mark.length},}\\s*$`).test(lines[i] as string)) {
93 body.push((lines[i] as string).replace(new RegExp(`^ {0,${pad}}`), ''))
94 i++
95 }
96 i++
97 out.push({ t: 'code', lang: fence[2] ?? '', text: body.join('\n') })
98 continue
99 }
100 const heading = l.match(HEADING)
101 if (heading) {
102 out.push({ t: 'h', level: (heading[1] as string).length, text: headingText(heading[2] ?? '') })
103 i++
104 continue
105 }
106 if (HR.test(l)) {
107 out.push({ t: 'hr' })
108 i++
109 continue
110 }
111 if (QUOTE.test(l)) {
112 const body: string[] = []
113 while (i < lines.length && !isBlank(lines[i] as string) && (QUOTE.test(lines[i] as string) || !startsBlock(lines[i] as string))) {
114 body.push((lines[i] as string).replace(QUOTE, ''))
115 i++
116 }
117 out.push({ t: 'quote', blocks: blocks(body) })
118 continue
119 }
120 const item = l.match(ITEM)
121 if (item) {
122 const r = list(lines, i)
123 out.push(r.block)
124 i = r.next
125 continue
126 }
127 if (l.includes('|') && i + 1 < lines.length && DELIM.test((lines[i + 1] as string).trim()) && (lines[i + 1] as string).includes('-') && cells(lines[i + 1] as string).length === cells(l).length) {
128 const head = cells(l)
129 const align = cells(lines[i + 1] as string).map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : c.startsWith(':') ? 'left' : ''))
130 const rows: string[][] = []
131 i += 2
132 while (i < lines.length && !isBlank(lines[i] as string) && (lines[i] as string).includes('|')) {
133 rows.push(cells(lines[i] as string))
134 i++
135 }
136 out.push({ t: 'table', align, head, rows })
137 continue
138 }
139 if (HTML_START.test(l)) {
140 const body: string[] = []
141 if (/^ {0,3}<!--/.test(l)) {
142 while (i < lines.length) {
143 body.push(lines[i] as string)
144 i++
145 if ((body[body.length - 1] as string).includes('-->')) break
146 }
147 out.push({ t: 'html', text: body.join('\n') })
148 continue
149 }
150 while (i < lines.length && !isBlank(lines[i] as string)) {
151 body.push(lines[i] as string)
152 i++
153 }
154 out.push({ t: 'html', text: body.join('\n') })
155 continue
156 }
157 // A paragraph, or a setext heading when a line of = or - closes it.
158 const body: string[] = [l]
159 i++
160 let level = 0
161 while (i < lines.length && !isBlank(lines[i] as string)) {
162 const n = lines[i] as string
163 if (/^ {0,3}=+\s*$/.test(n)) level = 1
164 else if (/^ {0,3}-+\s*$/.test(n)) level = 2
165 if (level) {
166 i++
167 break
168 }
169 if (startsBlock(n)) break
170 body.push(n)
171 i++
172 }
173 const text = body.map(b => b.replace(/^ {0,3}/, '')).join('\n')
174 if (level) out.push({ t: 'h', level, text: text.trim() })
175 else if (text.trim() === MARK) out.push({ t: 'mark' })
176 else out.push({ t: 'p', text })
177 }
178 return out
179}
180
181function list(lines: string[], from: number): { block: Block; next: number } {
182 const first = (lines[from] as string).match(ITEM) as RegExpMatchArray
183 const ordered = /\d/.test(first[2] as string)
184 const start = ordered ? parseInt(first[2] as string, 10) : 1
185 const base = (first[1] as string).length
186 const items: Item[] = []
187 let i = from
188 while (i < lines.length) {
189 const m = (lines[i] as string).match(ITEM)
190 if (!m || (m[1] as string).length > base + 1 || /\d/.test(m[2] as string) !== ordered) break
191 const content = (m[1] as string).length + (m[2] as string).length + Math.min(4, Math.max(1, (m[3] as string).length))
192 const body: string[] = [m[4] ?? '']
193 i++
194 while (i < lines.length) {
195 const n = lines[i] as string
196 if (isBlank(n)) {
197 // A blank line ends the item unless what follows is indented into it.
198 let j = i + 1
199 while (j < lines.length && isBlank(lines[j] as string)) j++
200 if (j < lines.length && indentOf(lines[j] as string) >= content) {
201 for (; i < j; i++) body.push('')
202 continue
203 }
204 break
205 }
206 if (indentOf(n) >= content) {
207 body.push(n.slice(content))
208 i++
209 continue
210 }
211 if (ITEM.test(n) || startsBlock(n)) break
212 body.push(n.trim()) // a lazy continuation of the paragraph
213 i++
214 }
215 let task: boolean | undefined
216 const t = (body[0] ?? '').match(/^\[([ xX])\][ \t]+/)
217 if (t) {
218 task = t[1] !== ' '
219 body[0] = (body[0] ?? '').slice(t[0].length)
220 }
221 items.push({ task, blocks: blocks(body) })
222 // A blank line between items keeps the list going.
223 let j = i
224 while (j < lines.length && isBlank(lines[j] as string)) j++
225 const nm = j < lines.length ? (lines[j] as string).match(ITEM) : null
226 if (j > i && nm && (nm[1] as string).length <= base + 1 && /\d/.test(nm[2] as string) === ordered) i = j
227 }
228 return { block: { t: 'list', ordered, start, items }, next: i }
229}
230
231// --- HTML ---
232
233export function esc(s: string) {
234 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
235}
236
237const slug = (s: string) =>
238 s
239 .toLowerCase()
240 .replace(/<[^>]+>/g, '')
241 .replace(/[^\p{L}\p{N}\s_-]/gu, '')
242 .trim()
243 .replace(/\s/g, '-')
244
245export function inline(text: string): string {
246 const keep: string[] = []
247 const hold = (html: string) => `\u0000${keep.push(html) - 1}\u0000`
248 let s = text
249 s = s.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (_m, _t, code: string) => hold(`<code>${esc(code.replace(/\n/g, ' ').trim())}</code>`))
250 s = s.replace(/\\([\\`*_{}[\]()#+\-.!|~<>])/g, (_m, c: string) => hold(esc(c)))
251 s = s.replace(/&(#\d+|#x[\da-f]+|[a-z][a-z\d]*);/gi, m => hold(m)) // an entity stays one
252 s = s.replace(/<(https?:\/\/[^\s>]+)>/g, (_m, url: string) => hold(`<a href="${esc(url)}">${esc(url)}</a>`))
253 s = s.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^<>]*)?\/?>|<!--[\s\S]*?-->/g, tag => hold(tag)) // inline HTML passes through
254 s = esc(s)
255 const URL_ = '((?:[^()\\s]|\\([^()\\s]*\\))*)' // one level of balanced parentheses
256 const TITLE = '(?:\\s+"([^&]*)")?'
257 const ref = (label: string, id: string) => refs.get((id || label).replace(/\u0000\d+\u0000/g, '').toLowerCase())
258 s = s.replace(new RegExp(`!\\[([^\\]]*)\\]\\(\\s*${URL_}${TITLE}\\s*\\)`, 'g'), (_m, alt: string, src: string, title?: string) => hold(`<img src="${safeUrl(src)}" alt="${alt}"${title ? ` title="${title}"` : ''}>`))
259 s = s.replace(new RegExp(`\\[((?:[^\\]\\\\]|\\\\.)*)\\]\\(\\s*${URL_}${TITLE}\\s*\\)`, 'g'), (_m, label: string, href: string, title?: string) => hold(`<a href="${safeUrl(href)}"${title ? ` title="${title}"` : ''}>${emphasis(label)}</a>`))
260 s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (m, alt: string, id: string) => {
261 const url = ref(alt, id)
262 return url ? hold(`<img src="${safeUrl(esc(url))}" alt="${alt}">`) : m
263 })
264 s = s.replace(/\[((?:[^\]\\]|\\.)*)\]\[([^\]]*)\]/g, (m, label: string, id: string) => {
265 const url = ref(label, id)
266 return url ? hold(`<a href="${safeUrl(esc(url))}">${emphasis(label)}</a>`) : m
267 })
268 s = s.replace(/(^|[\s(])(https?:\/\/[^\s<]*[^\s<.,;:!?)'"\u0000])/g, (_m, pre: string, url: string) => `${pre}${hold(`<a href="${url}">${url}</a>`)}`)
269 s = emphasis(s)
270 s = s.replace(/(?: {2,}|\\)\n/g, '<br>\n')
271 return s.replace(/\u0000(\d+)\u0000/g, (_m, n: string) => keep[Number(n)] ?? '').replace(/\u0000(\d+)\u0000/g, (_m, n: string) => keep[Number(n)] ?? '')
272}
273
274// Only web, mail, relative and in-page links; never javascript: and the like.
275function safeUrl(url: string) {
276 return /^\s*(?:javascript|vbscript|data|file):/i.test(url.replace(/&#?\w+;|[\u0000-\u001f]/g, '')) ? '#' : url
277}
278
279/** Drops what can run: script-like elements and on* handlers, javascript: URLs. */
280export function sanitize(html: string) {
281 return html
282 .replace(/<(base|meta|link)\b(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^'">])*(?:>|$)/gi, '') // void: only the tag goes, or the rest when it never closes
283 .replace(/<(script|style|iframe|object|embed|frame|frameset|applet|form)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, '')
284 .replace(/<\/?(script|style|iframe|object|embed|frame|frameset|applet|base|meta|link|form)\b[^>]*(?:>|$)/gi, '')
285 .replace(/<[a-z][^>]*>/gi, tag =>
286 tag
287 .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
288 .replace(/\b(href|src|action|formaction|srcset)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m, attr: string, v: string) => (safeUrl(v.replace(/^["']|["']$/g, '')) === '#' ? `${attr}="#"` : m)),
289 )
290}
291
292function emphasis(s: string) {
293 const strong = s.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>').replace(/\*(?=\S)([\s\S]*?\S)\*/g, '<em>$1</em>')
294 return underscores(strong).replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>')
295}
296
297const isSpaceOrTab = (c: string | undefined) => c === ' ' || c === '\t'
298
299// A heading's text without its closing #s and the blanks around them, in one pass.
300function headingText(t: string) {
301 let end = t.length
302 while (end > 0 && isSpaceOrTab(t[end - 1])) end--
303 let start = end
304 while (start > 0 && t[start - 1] === '#') start--
305 if (start < end && start > 0 && isSpaceOrTab(t[start - 1])) {
306 end = start
307 while (end > 0 && isSpaceOrTab(t[end - 1])) end--
308 }
309 return t.slice(0, end)
310}
311
312// _em_ in one pass. Same matches as /(^|[^\w])_(?=\S)([\s\S]*?\S)_(?!\w)/g, which rescanned
313// to the end of the text from every opener that had no closer.
314function underscores(s: string) {
315 const isWord = (c: string | undefined) => c !== undefined && /\w/.test(c)
316 const isSpace = (c: string | undefined) => c !== undefined && /\s/.test(c)
317 const closers: number[] = []
318 for (let q = 1; q < s.length; q++) if (s[q] === '_' && !isSpace(s[q - 1]) && !isWord(s[q + 1])) closers.push(q)
319 let out = ''
320 let from = 0
321 let c = 0
322 for (let i = 0; i < s.length; i++) {
323 if (s[i] !== '_' || s[i + 1] === undefined || isSpace(s[i + 1])) continue
324 if (i === 0 ? from !== 0 : i - 1 < from || isWord(s[i - 1])) continue
325 while (c < closers.length && (closers[c] as number) < i + 2) c++
326 const q = closers[c]
327 if (q === undefined) break // no closer left for this or any later opener
328 out += `${s.slice(from, i)}<em>${s.slice(i + 1, q)}</em>`
329 from = q + 1
330 i = q
331 }
332 return out + s.slice(from)
333}
334
335// The built-in renderer's HTML: the Markdown's own HTML passes through, made safe.
336export function toHtml(list: Block[]): string {
337 return sanitize(list.map(blockHtml).join('\n'))
338}
339
340function blockHtml(b: Block): string {
341 switch (b.t) {
342 case 'h': {
343 const body = inline(b.text)
344 return `<h${b.level} id="${slug(b.text)}">${body}</h${b.level}>`
345 }
346 case 'p':
347 return `<p>${inline(b.text)}</p>`
348 case 'code':
349 return `<pre><code${b.lang ? ` class="language-${esc(b.lang)}"` : ''}>${esc(b.text)}</code></pre>`
350 case 'quote':
351 return `<blockquote>\n${toHtml(b.blocks)}\n</blockquote>`
352 case 'list': {
353 const tag = b.ordered ? 'ol' : 'ul'
354 const hasTask = b.items.some(it => it.task !== undefined)
355 const items = b.items.map(it => {
356 // A one-paragraph item is drawn tight, as GitHub does.
357 const inner = it.blocks.length >= 1 && it.blocks[0]?.t === 'p' ? [inline(it.blocks[0].text), ...it.blocks.slice(1).map(blockHtml)].join('\n') : toHtml(it.blocks)
358 const box = it.task === undefined ? '' : `<input type="checkbox" class="task-list-item-checkbox" disabled${it.task ? ' checked' : ''}> `
359 return `<li${it.task !== undefined ? ' class="task-list-item"' : ''}>${box}${inner}</li>`
360 })
361 const start = b.ordered && b.start !== 1 ? ` start="${b.start}"` : ''
362 return `<${tag}${start}${hasTask ? ' class="contains-task-list"' : ''}>\n${items.join('\n')}\n</${tag}>`
363 }
364 case 'table': {
365 const al = (i: number) => (b.align[i] ? ` align="${b.align[i]}"` : '')
366 const head = b.head.map((c, i) => `<th${al(i)}>${inline(c)}</th>`).join('')
367 const rows = b.rows.map(r => `<tr>${b.head.map((_c, i) => `<td${al(i)}>${inline(r[i] ?? '')}</td>`).join('')}</tr>`).join('\n')
368 return `<table>\n<thead><tr>${head}</tr></thead>\n<tbody>\n${rows}\n</tbody>\n</table>`
369 }
370 case 'hr':
371 return '<hr>'
372 case 'html':
373 return b.text
374 case 'mark':
375 return '<div class="md-mark"></div>'
376 }
377}
378
379// --- What changed ---
380
381// Top-level chunks: runs of lines split by blank lines outside code fences.
382export function chunks(md: string): string[] {
383 const out: string[] = []
384 let cur: string[] = []
385 let fence = ''
386 for (const l of md.replace(/\r\n?/g, '\n').split('\n')) {
387 const f = l.match(FENCE)
388 if (fence) {
389 cur.push(l)
390 if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(l)) fence = ''
391 continue
392 }
393 if (f) fence = f[1] as string
394 if (isBlank(l) && !fence) {
395 if (cur.length) out.push(cur.join('\n'))
396 cur = []
397 continue
398 }
399 cur.push(l)
400 }
401 if (cur.length) out.push(cur.join('\n'))
402 return out
403}
404
405// The chunks of `after` that `before` did not have; none for a new file.
406export function changedChunks(before: string | null, after: string): number[] {
407 if (before === null || before.trim() === '') return []
408 const old = new Map<string, number>()
409 for (const c of chunks(before)) old.set(c.trim(), (old.get(c.trim()) ?? 0) + 1)
410 const out: number[] = []
411 chunks(after).forEach((c, i) => {
412 const k = c.trim()
413 const n = old.get(k) ?? 0
414 if (n > 0) old.set(k, n - 1)
415 else out.push(i)
416 })
417 return out
418}
419
420// The Markdown again, a MARK paragraph before each changed chunk that starts a block.
421export function withMarks(md: string, marks: readonly number[]): string {
422 if (marks.length === 0) return md
423 const list = chunks(md)
424 const at = new Set<number>()
425 let start = -1 // the first chunk of the list run this chunk belongs to
426 list.forEach((c, i) => {
427 const isListish = ITEM.test(c) || indentOf(c) > 0
428 start = isListish && i > 0 && start >= 0 && (ITEM.test(list[i - 1] as string) || indentOf(list[i - 1] as string) > 0) ? start : i
429 if (marks.includes(i)) at.add(isListish ? start : i)
430 })
431 return list.map((c, i) => (at.has(i) && indentOf(c) < 4 ? `${MARK}\n\n${c}` : c)).join('\n\n')
432}
433
434// --- Text view ---
435
436export type Seg = { text: string; bold?: boolean; italic?: boolean; dim?: boolean; color?: string; underline?: boolean; strike?: boolean }
437export type Line = { prefix: string; segs: Seg[]; mark?: boolean }
438
439const INLINE = /(`+)([\s\S]*?[^`])\1(?!`)|!\[([^\]]*)\]\([^)]*\)|\[((?:[^\]\\]|\\.)*)\]\(\s*([^)\s]*)[^)]*\)|<(https?:\/\/[^>\s]+)>|(\*\*|__)(?=\S)([\s\S]*?\S)\7|\*(?=\S)([^*]*?\S)\*|(?<![\w])_(?=\S)([^_]*?\S)_(?!\w)|~~([\s\S]*?)~~|<\/?[a-zA-Z][^<>]*>|<!--[\s\S]*?-->/g
440
441export function segments(text: string): Seg[] {
442 const out: Seg[] = []
443 const plain = (t: string) => t && out.push({ text: t.replace(/\\([\\`*_{}[\]()#+\-.!|~<>])/g, '$1').replace(/\s*\n\s*/g, ' ') })
444 let at = 0
445 for (const m of text.matchAll(INLINE)) {
446 plain(text.slice(at, m.index))
447 at = (m.index ?? 0) + m[0].length
448 if (m[2] !== undefined) out.push({ text: m[2].trim(), color: 'cyan' })
449 else if (m[3] !== undefined) out.push({ text: `[image: ${m[3] || 'untitled'}]`, dim: true })
450 else if (m[4] !== undefined) {
451 out.push({ text: m[4].replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, ''), color: 'blue', underline: true })
452 if (m[5] && !m[5].startsWith('#')) out.push({ text: ` (${m[5]})`, dim: true })
453 } else if (m[6] !== undefined) out.push({ text: m[6], color: 'blue', underline: true })
454 else if (m[8] !== undefined) out.push({ text: m[8], bold: true })
455 else if (m[9] !== undefined) out.push({ text: m[9], italic: true })
456 else if (m[10] !== undefined) out.push({ text: m[10], italic: true })
457 else if (m[11] !== undefined) out.push({ text: m[11], strike: true })
458 // an HTML tag or comment: dropped, its text stays
459 }
460 plain(text.slice(at))
461 return out
462}
463
464const plainText = (segs: Seg[]) => segs.map(s => s.text).join('')
465
466export function toLines(list: Block[], width = 80): Line[] {
467 const out: Line[] = []
468 let marked = false
469 const push = (ls: Line[]) => {
470 for (const l of ls) out.push(marked ? { ...l, mark: true } : l)
471 marked = false
472 }
473 for (const b of list) {
474 if (b.t === 'mark') {
475 marked = true
476 continue
477 }
478 if (out.length) out.push({ prefix: '', segs: [] })
479 push(blockLines(b, width))
480 }
481 return out
482}
483
484function blockLines(b: Block, width: number): Line[] {
485 switch (b.t) {
486 case 'h': {
487 const segs = segments(b.text).map(s => ({ ...s, bold: true, color: b.level <= 2 ? 'magenta' : 'cyan' }))
488 const ls: Line[] = [{ prefix: '', segs }]
489 if (b.level <= 2) ls.push({ prefix: '', segs: [{ text: '─'.repeat(Math.min(width, Math.max(8, plainText(segs).length))), dim: true }] })
490 return ls
491 }
492 case 'p':
493 return [{ prefix: '', segs: segments(b.text) }]
494 case 'code':
495 return b.text.split('\n').map(l => ({ prefix: ' │ ', segs: [{ text: l, dim: true }] }))
496 case 'quote':
497 return toLines(b.blocks, width - 2).map(l => ({ ...l, prefix: `▎ ${l.prefix}`, segs: l.segs.map(s => ({ ...s, dim: true })) }))
498 case 'list': {
499 const ls: Line[] = []
500 b.items.forEach((it, n) => {
501 const bullet = it.task === true ? '☑ ' : it.task === false ? '☐ ' : b.ordered ? `${b.start + n}. ` : '• '
502 const pad = ' '.repeat(bullet.length)
503 toLines(it.blocks, width - bullet.length)
504 .filter(l => l.segs.length > 0)
505 .forEach((l, k) => ls.push({ ...l, prefix: `${k === 0 ? bullet : pad}${l.prefix}` }))
506 })
507 return ls
508 }
509 case 'table': {
510 const rows = [b.head, ...b.rows].map(r => b.head.map((_c, i) => plainText(segments(r[i] ?? ''))))
511 const widths = b.head.map((_c, i) => Math.max(...rows.map(r => (r[i] ?? '').length)))
512 const fmt = (r: string[]) => r.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' │ ')
513 const ls: Line[] = [{ prefix: '', segs: [{ text: fmt(rows[0] ?? []), bold: true }] }]
514 ls.push({ prefix: '', segs: [{ text: widths.map(w => '─'.repeat(w)).join('─┼─'), dim: true }] })
515 for (const r of rows.slice(1)) ls.push({ prefix: '', segs: [{ text: fmt(r) }] })
516 return ls
517 }
518 case 'hr':
519 return [{ prefix: '', segs: [{ text: '─'.repeat(Math.min(width, 40)), dim: true }] }]
520 case 'html': {
521 const alts = [...b.text.matchAll(/<img[^>]*\balt="([^"]*)"/gi)].map(m => `[image: ${m[1] || 'untitled'}]`)
522 const text = b.text
523 .replace(/<!--[\s\S]*?-->/g, '')
524 .replace(/<[^>]+>/g, ' ')
525 .replace(/\s+/g, ' ')
526 .trim()
527 const all = [text, ...alts].filter(Boolean).join(' ')
528 return all ? [{ prefix: '', segs: [{ text: all, dim: true }] }] : []
529 }
530 case 'mark':
531 return []
532 }
533}
534hooks/page.ts 109 lines1// The page Chrome draws: the rendered Markdown in GitHub's dark look.
2import { esc, MARK } from './md'
3
4// GitHub's dark theme, trimmed to what a README uses.
5const CSS = `
6:root{color-scheme:dark}
7html,body{margin:0;background:#0d1117}
8body{color:#f0f6fc;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";word-wrap:break-word}
9.markdown-body{box-sizing:border-box;padding:20px 28px 32px}
10.markdown-body>*:first-child{margin-top:0!important}
11a{color:#4493f8;text-decoration:none}
12a:hover{text-decoration:underline}
13p,blockquote,ul,ol,dl,table,pre,details{margin-top:0;margin-bottom:16px}
14h1,h2,h3,h4,h5,h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}
15h1{font-size:2em;padding-bottom:.3em;border-bottom:1px solid #3d444db3}
16h2{font-size:1.5em;padding-bottom:.3em;border-bottom:1px solid #3d444db3}
17h3{font-size:1.25em}h4{font-size:1em}h5{font-size:.875em}h6{font-size:.85em;color:#9198a1}
18.markdown-heading{position:relative}
19.markdown-heading .anchor{display:none}
20strong{font-weight:600}
21hr{height:.25em;padding:0;margin:24px 0;background:#3d444d;border:0}
22blockquote{margin-left:0;margin-right:0;padding:0 1em;color:#9198a1;border-left:.25em solid #3d444d}
23blockquote>:last-child{margin-bottom:0}
24ul,ol{padding-left:2em}
25ul ul,ul ol,ol ol,ol ul{margin-top:0;margin-bottom:0}
26li+li{margin-top:.25em}
27li>p{margin-top:16px}
28.contains-task-list{list-style:none;padding-left:0}
29.contains-task-list .contains-task-list{padding-left:2em}
30.task-list-item-checkbox{margin:0 .2em .25em -1.4em;vertical-align:middle}
31.contains-task-list>.task-list-item{padding-left:1.6em}
32code,tt{padding:.2em .4em;margin:0;font-size:85%;white-space:break-spaces;background:#656c7633;border-radius:6px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
33pre{padding:16px;overflow:hidden;font-size:85%;line-height:1.45;color:#f0f6fc;background:#151b23;border-radius:6px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
34pre code{display:inline;padding:0;margin:0;font-size:100%;white-space:pre-wrap;background:transparent;border:0}
35.highlight pre{margin-bottom:0}
36.highlight{margin-bottom:16px}
37markdown-accessiblity-table{display:block}
38table{display:block;width:max-content;max-width:100%;overflow:hidden;border-spacing:0;border-collapse:collapse}
39table th{font-weight:600}
40table th,table td{padding:6px 13px;border:1px solid #3d444d}
41table tr{background:#0d1117;border-top:1px solid #3d444db3}
42table tr:nth-child(2n){background:#151b23}
43img{max-width:100%;box-sizing:content-box}
44kbd{display:inline-block;padding:3px 5px;font:11px ui-monospace,monospace;line-height:10px;color:#f0f6fc;vertical-align:middle;background:#151b23;border:solid 1px #3d444db3;border-bottom-color:#3d444db3;border-radius:6px;box-shadow:inset 0 -1px 0 #3d444db3}
45details summary{cursor:pointer}
46.label{padding:14px 28px 0;color:#9198a1;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase}
47.md-mark{display:none}
48.md-mark+*{position:relative}
49.md-mark+*::after{content:'';position:absolute;left:-18px;top:0;bottom:0;width:4px;border-radius:2px;background:#3fb950}
50.pl-k{color:#ff7b72}.pl-s,.pl-pds,.pl-s .pl-pse .pl-s1{color:#a5d6ff}.pl-c{color:#9198a1}.pl-c1,.pl-v{color:#79c0ff}.pl-en,.pl-e{color:#d2a8ff}.pl-smi,.pl-s1{color:#f0f6fc}.pl-ent{color:#7ee787}
51`
52
53// The MARK paragraph, as GitHub or the built-in renderer wrote it, becomes the marker.
54export function markers(html: string) {
55 return html.replace(new RegExp(`<p[^>]*>\\s*${MARK}\\s*</p>`, 'g'), '<div class="md-mark"></div>')
56}
57
58// A path as a file:// URL, each segment encoded (# and ? included).
59export function fileUrl(path: string) {
60 return `file://${path.split('/').map(encodeURIComponent).join('/')}`
61}
62
63// Relative links and images point at the Markdown file's folder, as file:// URLs.
64// Only attributes inside tags change: code that shows `href="x"` stays as written.
65export function absolute(html: string, dir: string) {
66 const root = fileUrl(dir.replace(/\/?$/, '/'))
67 const fix = (url: string) => {
68 if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(url) || url === '') return url
69 if (url.startsWith('/')) return `file://${url}`
70 return `${root}${url.replace(/^\.\//, '')}`
71 }
72 return html.replace(/<[a-z][^>]*>/gi, tag =>
73 tag
74 .replace(/(\s(?:src|href)\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi, (_m, pre: string, dq?: string, sq?: string) => `${pre}"${fix(dq ?? sq ?? '')}"`)
75 .replace(/(\ssrcset\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi, (_m, pre: string, dq?: string, sq?: string) =>
76 `${pre}"${(dq ?? sq ?? '').split(',').map(c => c.trim().replace(/^\S+/, u => fix(u))).join(', ')}"`),
77 )
78}
79
80export type Side = { label?: string; html: string; dir: string }
81
82/**
83 * A whole page: one rendered file, or several side by side (stacked when narrow),
84 * each under its label. `width` 0 is the browser's page: centered, any width.
85 * On load it writes its height into the body, for `--dump-dom` to read back; a
86 * `#<px>` in the URL shifts it up, so a tall page is drawn in parts.
87 */
88export function page(sides: readonly Side[], opts: { title: string; width: number; stacked?: boolean }) {
89 const many = sides.length > 1
90 const cols = sides
91 .map(s => `<section class="col">${s.label ? `<div class="label">${esc(s.label)}</div>` : ''}<article class="markdown-body">\n${absolute(markers(s.html), s.dir)}\n</article></section>`)
92 .join('\n')
93 const size = opts.width > 0 ? `body{width:${opts.width}px}` : `body{max-width:${many && !opts.stacked ? 2000 : 1012}px;margin:0 auto}`
94 const grid = many && !opts.stacked ? `.cols{display:grid;grid-template-columns:repeat(${sides.length},minmax(0,1fr))}.col+.col{border-left:1px solid #3d444d}` : '.col+.col{border-top:1px solid #3d444d}'
95 // Nothing in the Markdown may run: the page's own script alone, by its nonce.
96 const nonce = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('')
97 const csp = `default-src 'none'; img-src * data: file:; media-src * file:; style-src 'unsafe-inline'; font-src * data: file:; script-src 'nonce-${nonce}'`
98 return `<!doctype html>
99<html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>${esc(opts.title)}</title>
100<style>${CSS}${size}${grid}</style></head>
101<body><main class="page"><div class="cols">
102${cols}
103</div></main>
104<script nonce="${nonce}">if(location.hash.length>1){document.querySelector('.page').style.transform='translateY(-'+parseInt(location.hash.slice(1),10)+'px)'}
105addEventListener('load',function(){document.body.setAttribute('data-height',String(Math.ceil(document.querySelector('.page').getBoundingClientRect().height)))})</script>
106</body></html>
107`
108}
109types/index.d.ts 34 lines1// A Markdown file Claude changed this session (or one /md <path> opened).
2export type MdFile = {
3 path: string
4 at: number // when it last changed, ms
5 marks: number[] // the top-level blocks the last edit changed
6 before?: string // the text before the last edit; absent for a new file
7}
8
9// One drawing: PNG parts, each at most 250 rows tall, top to bottom.
10export type MdFrame = {
11 key: string // what was drawn: which files, which versions, single or compare
12 columns: number
13 parts: { file: string; rows: number }[]
14 n: number
15 via: 'GitHub' | 'built-in'
16} | null
17
18// Two files side by side, from /md compare <a> <b>.
19export type MdPair = { a: string; b: string } | null
20
21declare module 'claude-code' {
22 interface PluginState {
23 'md-preview': {
24 files: MdFile[]
25 shown: string | null
26 pair: MdPair
27 compare: boolean
28 frame: MdFrame
29 view: 'page' | 'text'
30 note: string
31 }
32 }
33}
34