SLOPSHOPPER

md-preview

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.

newpaneguardcommandtoastprocess
★ 167v0.1.3MITupdated 2026-10-05hamzafer/claude-code-mods/mods/md-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md-preview
│ ┃ md-preview ✕ › fix the failing auth test and add an audit log call │ ┃ [ Prev ] [ Next ] [ Before ] [ Render ] [ Te │ ┃ No Markdown edits yet. /md <path> opens a ⏺ Read(src/auth.ts) │ ┃ file. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /md │ ⎿ md-preview: no Markdown edits yet this session. /md <path> opens │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · md-preview
[ Prev ] [ Next ] [ Before ] [ Render ] [ Text ] [ Browser ] No Markdown edits yet. /md <path> opens a file.
README

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 4 files
hooks/register.tsx 791 lines
1// 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 }
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, 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.canImage = await imageTerminal($)
141    // /md, or /mdview if a built-in ever takes /md.
142    for (const name of ['md', 'mdview']) {
143      const ok = await $.command
144        .register({ name, description: 'Preview the Markdown files Claude edited, rendered like GitHub', argumentHint: '[path | compare <a> <b> | open]' })
145        .then(() => true)
146        .catch(() => false)
147      if (ok) {
148        session.command = name
149        break
150      }
151    }
152    return r
153  })
154
155  on('turn.start', async (_$, e, next) => {
156    if (!e.agentId) {
157      // A subagent starting mid-turn must not drop the main turn's tracking.
158      toasted.clear()
159      turnBefore.clear()
160      resetSnapshot()
161      turn.isOn = true
162    }
163    return next(e)
164  })
165
166  on('turn.complete', async ($, e, next) => {
167    const r = await next(e)
168    if (e.agentId) return r
169    if (shot.taken) await compareSnapshot($).catch(() => {}) // after next(e): the turn is already over
170    turn.isOn = false
171    await startAhead($).catch(() => {}) // the files the turn changed, each drawn once, as it left them
172    return r
173  })
174
175  // Reads what the main agent wrote; never blocks or changes the call.
176  on('tool.call', async ($, e, next) => {
177    // Shell commands can write Markdown without Write/Edit: snapshot before the turn's first one,
178    // compare at turn end. Turns without a shell command cost nothing.
179    if (SHELL_TOOLS.has(e.tool) && !shot.taken) {
180      snapshotting ??= takeSnapshot($).catch(() => {}).finally(() => (snapshotting = null))
181      await snapshotting
182    }
183    if (!EDIT_TOOLS.has(e.tool) || e.agentId) return next(e)
184    const path = (e as unknown as { file_path?: unknown }).file_path
185    if (typeof path !== 'string' || !MD_FILE.test(path)) return next(e)
186    const before = await $.fs.read(path).catch(() => null) // before the write lands
187    const r = await next(e)
188    if (r.deny !== undefined || r.isError === true) return r
189    await changed($, path, typeof before === 'string' ? before : null).catch(() => {}) // quick: a read and a toast
190    return r
191  })
192
193  on('command.run', { command: 'md' }, async ($, e) => command($, e.args))
194  on('command.run', { command: 'mdview' }, async ($, e) => command($, e.args))
195
196  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
197    const { Box, Text, Button } = $.ui.resolve(e)
198    const list = await read($, files)
199    const path = await read($, shown)
200    const two = await read($, pair)
201    const isCompare = await read($, compare)
202    const v = await read($, view)
203    const msg = await read($, note)
204    const columns = Math.max(20, e.props.bodyColumns)
205    draw.columns = columns
206    const p = await plan($)
207    const i = list.findIndex(f => f.path === path)
208    const isPage = e.surface === 'terminal' && v === 'page' && session.canImage && !session.noChrome
209    const f = await read($, frame)
210    const file = list.find(x => x.path === path)
211    const where = two ? `${base(two.a)} | ${base(two.b)}` : path ? base(path) : ''
212    const info = two
213      ? '  side by side'
214      : `  ${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' : ''}`
215    const head = (
216      <Box flexDirection="column">
217        <Box flexDirection="row" gap={1}>
218          <Button key="prev" label="Prev" hotkey="p" onPress={() => step($, -1)} />
219          <Button key="next" label="Next" hotkey="n" onPress={() => step($, 1)} />
220          <Button key="before" label={isCompare ? 'Single' : 'Before'} hotkey="b" onPress={() => toggleCompare($)} />
221          <Button key="again" label="Render" hotkey="r" onPress={() => again($)} />
222          <Button key="view" label={v === 'page' ? 'Text' : 'Page'} hotkey="t" onPress={() => toggleView($)} />
223          <Button key="open" label="Browser" hotkey="o" onPress={() => void openInBrowser($).then(t => $.ui.toast(`md-preview: ${t}`)).catch(() => {})} />
224          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
225        </Box>
226        {p && (
227          <Text wrap="truncate-end">
228            <Text bold>{where}</Text>
229            <Text dimColor>{`${info}${isPage && f?.key === p.key ? ` · ${f.via}` : ''}`}</Text>
230          </Text>
231        )}
232      </Box>
233    )
234    if (!p) {
235      return (
236        <Box flexDirection="column">
237          {head}
238          <Text dimColor>{`No Markdown edits yet. /${session.command} <path> opens a file.`}</Text>
239        </Box>
240      )
241    }
242
243    if (isPage) {
244      const key = `${p.key}@${columns}`
245      const isCurrent = f !== null && f.key === p.key && f.columns === columns
246      if (!isCurrent && draw.failed !== key) {
247        if (draw.pending) draw.dirty = true // draw again once this one is done
248        else void renderSoon($).catch(() => {})
249      }
250      // The last drawing of the same view stays up while a newer one draws, scaled to the width.
251      const same = f !== null && sameView(f.key, p.key)
252      if (f && same) {
253        const { Image } = $.ui.resolve(e)
254        return (
255          <Box flexDirection="column">
256            {head}
257            {f.parts.map((part, k) => (
258              <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` : ' '} />
259            ))}
260          </Box>
261        )
262      }
263    }
264
265    // The text view: while the page draws, and where it cannot.
266    const sides = await load($, p)
267    const many = sides.length > 1
268    const lines: Line[] = []
269    for (const s of sides) {
270      if (many) lines.push({ prefix: '', segs: [{ text: `── ${s.label ?? base(s.path)} ──`, bold: true, color: 'yellow' }] }, { prefix: '', segs: [] })
271      lines.push(...toLines(parse(withMarks(s.text, s.marks)), columns - 2))
272      if (many) lines.push({ prefix: '', segs: [] })
273    }
274    const status = isPage ? msg || 'Rendering…' : msg
275    return (
276      <Box flexDirection="column">
277        {head}
278        {status && <Text dimColor>{status}</Text>}
279        {sides.length === 0 && <Text color="red">{`Cannot read ${where}`}</Text>}
280        {lines.slice(0, 3000).map((l, k) => (
281          <Text key={`l${k}`} wrap="wrap">
282            <Text color="green">{l.mark ? '▌' : ' '}</Text>
283            <Text dimColor>{l.prefix}</Text>
284            {l.segs.length === 0 ? <Text> </Text> : l.segs.map((s, j) => (
285              <Text key={`s${j}`} bold={s.bold} italic={s.italic} dimColor={s.dim} color={s.color} underline={s.underline} strikethrough={s.strike}>
286                {s.text.length > 4000 ? `${s.text.slice(0, 4000)}…` : s.text /* a Text child holds at most 10,000 characters */}
287              </Text>
288            ))}
289          </Text>
290        ))}
291      </Box>
292    )
293  })
294}
295
296// Two keys name the same view when only the versions differ.
297function sameView(a: string, b: string) {
298  return a.replace(/\u0001\d+/g, '') === b.replace(/\u0001\d+/g, '')
299}
300
301// What to show now, without reading any file.
302async function plan($: EngineInterface): Promise<Plan | null> {
303  return planFor(await read($, files), await read($, shown), await read($, pair), await read($, compare))
304}
305
306function planFor(list: MdFile[], path: string | null, two: MdPair, isCompare: boolean): Plan | null {
307  const at = (p: string) => list.find(f => f.path === p)?.at ?? 0
308  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 }] }
309  if (!path) return null
310  const file = list.find(f => f.path === path)
311  if (isCompare && file?.before !== undefined) {
312    return { key: `cmp:${path}\u0001${at(path)}`, title: `${base(path)}: before | after`, parts: [{ label: 'Before', path, isBefore: true }, { label: 'After', path }] }
313  }
314  return { key: `one:${path}\u0001${at(path)}`, title: base(path), parts: [{ path }] }
315}
316
317// The texts a plan shows, with the blocks to mark green.
318async function load($: EngineInterface, p: Plan): Promise<Loaded[]> {
319  const list = await read($, files)
320  const out: Loaded[] = []
321  for (const part of p.parts) {
322    const file = list.find(f => f.path === part.path)
323    const text = part.isBefore ? file?.before : await $.fs.read(part.path).catch(() => null)
324    if (typeof text !== 'string') return []
325    out.push({ label: part.label, path: part.path, text, marks: part.isBefore ? [] : (file?.marks ?? []) })
326  }
327  // Two files: mark what the second has that the first does not.
328  if (p.key.startsWith('pair:') && out.length === 2) {
329    const [a, b] = out as [Loaded, Loaded]
330    b.marks = changedChunks(a.text, b.text)
331    a.marks = []
332  }
333  return out
334}
335
336// /md, /md <path>, /md compare <a> <b>, /md open.
337async function command($: EngineInterface, args: string) {
338  const words = args.trim().split(/\s+/).filter(Boolean)
339  if (words[0] === 'open') return { text: await openInBrowser($) }
340  if (words[0] === 'compare') {
341    if (words.length !== 3) return { text: `usage: /${session.command} compare <fileA> <fileB>` }
342    const [a, b] = [absolute(words[1] as string), absolute(words[2] as string)]
343    for (const f of [a, b]) {
344      if (!MD_FILE.test(f)) return { text: `${base(f)} is not a .md, .mdx or .markdown file.` }
345      if (!(await $.fs.exists(f).catch(() => false))) return { text: `no file at ${f}` }
346    }
347    await update($, pair, () => ({ a, b }))
348    await update($, files, list => list.map(f => (f.path === a || f.path === b ? { ...f, at: Date.now() } : f)))
349    await fromCache($).catch(() => false) // already drawn: the pane opens on the page
350    await $.ui.open({ id: PANE, title: 'Markdown', focus: true })
351    void renderSoon($).catch(() => {})
352    return { text: `${base(a)} | ${base(b)} side by side. o: open in the browser · q: close` }
353  }
354  let target: string | null = null
355  if (words.length) {
356    const abs = absolute(args.trim())
357    if (!MD_FILE.test(abs)) return { text: 'give a .md, .mdx or .markdown file.' }
358    if (!(await $.fs.exists(abs).catch(() => false))) return { text: `no file at ${abs}` }
359    await update($, files, list => (list.some(f => f.path === abs) ? list : [{ path: abs, at: Date.now(), marks: [] }, ...list].slice(0, MAX_FILES)))
360    target = abs
361  } else {
362    target = (await read($, files))[0]?.path ?? null
363  }
364  // Read again now: it may have changed outside Claude since it was last drawn.
365  if (target) await update($, files, list => list.map(f => (f.path === target ? { ...f, at: Date.now() } : f)))
366  if (!target) return { text: `no Markdown edits yet this session. /${session.command} <path> opens a file.` }
367  await update($, shown, () => target)
368  await update($, pair, () => null)
369  await fromCache($).catch(() => false) // drawn ahead: the pane opens on the page
370  await $.ui.open({ id: PANE, title: 'Markdown', focus: true })
371  void renderSoon($).catch(() => {})
372  return { text: `${base(target)}. n/p: next/previous · b: before | after · o: browser · r: render again · t: page/text · q: close` }
373}
374
375function resetSnapshot() {
376  shot.taken = false
377  shot.isFull = false
378  shot.listed.clear()
379  shot.texts.clear()
380  shot.tooBig.clear()
381}
382
383// The repo's Markdown files (tracked and untracked, not ignored), or null when git can't list them.
384async function listMarkdown($: EngineInterface): Promise<string[] | null> {
385  if (!session.cwd) return null
386  const git = await $.process
387    .run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.md', '*.mdx', '*.markdown'], { cwd: session.cwd })
388    .catch(() => null)
389  if (!git || git.exitCode !== 0) return null
390  const root = session.cwd.replace(/\/$/, '')
391  return [...new Set(git.stdout.split('\0').filter(l => l !== '' && MD_FILE.test(l)))].map(l => `${root}/${l}`)
392}
393
394async function readAll($: EngineInterface, paths: string[]): Promise<(string | null)[]> {
395  const out: (string | null)[] = []
396  for (let i = 0; i < paths.length; i += 20) {
397    const batch = await Promise.all(paths.slice(i, i + 20).map(p => $.fs.read(p).then(t => (typeof t === 'string' ? t : null)).catch(() => null)))
398    out.push(...batch)
399  }
400  return out
401}
402
403async function takeSnapshot($: EngineInterface) {
404  const all = await listMarkdown($)
405  if (all === null) return // not a git repo, or git failed: stay off for this turn
406  const paths = all.slice(0, SNAPSHOT_FILES)
407  shot.isFull = all.length > SNAPSHOT_FILES
408  const texts = await readAll($, paths)
409  paths.forEach((p, i) => {
410    shot.listed.add(p)
411    const text = texts[i]
412    if (typeof text === 'string' && text.length <= BEFORE_MAX) shot.texts.set(p, text)
413    else shot.tooBig.add(p)
414  })
415  shot.taken = true
416}
417
418async function compareSnapshot($: EngineInterface) {
419  const all = await listMarkdown($)
420  if (all === null) return
421  // A file is new only if the listing wasn't cut off; otherwise files that slid into the window would look new.
422  const paths = all.filter(p => !turnBefore.has(p) && !shot.tooBig.has(p) && (shot.listed.has(p) || !shot.isFull)).slice(0, SNAPSHOT_FILES)
423  const nows = await readAll($, paths)
424  for (let i = 0; i < paths.length; i++) {
425    const path = paths[i]
426    const now = nows[i]
427    if (now === null) continue
428    const was = shot.texts.get(path)
429    if (was === undefined) await changed($, path, null) // new file this turn
430    else if (was !== now) await changed($, path, was)
431  }
432}
433
434// A Markdown file changed: remember its text before and which blocks, redraw or say so.
435
436async function changed($: EngineInterface, path: string, before: string | null) {
437  const after = await $.fs.read(path).catch(() => null)
438  if (typeof after !== 'string') return
439  if (!turnBefore.has(path)) turnBefore.set(path, before)
440  const first = turnBefore.get(path) ?? null // several edits in a turn compare with the turn's start
441  const marks = changedChunks(first, after)
442  const entry: MdFile = first === null || first.length > BEFORE_MAX ? { path, at: Date.now(), marks } : { path, at: Date.now(), marks, before: first }
443  await update($, files, list =>
444    [entry, ...list.filter(f => f.path !== path)].slice(0, MAX_FILES).map((f, i) => {
445      if (i < BEFORE_FILES || f.before === undefined) return f
446      const { before: _drop, ...rest } = f
447      return rest
448    }),
449  )
450  const two = await read($, pair)
451  const isShown = ((await read($, shown)) === path || two?.a === path || two?.b === path) && (await paneUp($))
452  if (isShown) {
453    await renderSoon($)
454    return
455  }
456  await drawAhead($, path).catch(() => {}) // quick: it queues the drawing, never waits for it
457  if (toasted.has(path)) return
458  toasted.add(path)
459  $.ui.toast(`${base(path)} changed · /${session.command} to preview`)
460}
461
462async function step($: EngineInterface, by: number) {
463  const list = await read($, files)
464  if (list.length === 0) return
465  const now = await read($, shown)
466  const i = list.findIndex(f => f.path === now)
467  const next = list[(((i + by) % list.length) + list.length) % list.length]
468  if (next) await update($, shown, () => next.path)
469  await update($, pair, () => null)
470  await renderSoon($)
471}
472
473async function again($: EngineInterface) {
474  draw.failed = ''
475  session.noChrome = false
476  session.chrome = ''
477  session.ghDownUntil = 0
478  repos.clear()
479  ghFailed.clear()
480  draw.force = true
481  await renderSoon($) // the current picture stays up until the new one is ready
482}
483
484async function toggleView($: EngineInterface) {
485  await update($, view, v => (v === 'page' ? 'text' : 'page'))
486  await renderSoon($)
487}
488
489async function toggleCompare($: EngineInterface) {
490  await update($, pair, () => null)
491  await update($, compare, c => !c)
492  await renderSoon($)
493}
494
495// One drawing at a time; a change while drawing draws once more after.
496async function renderSoon($: EngineInterface) {
497  if (draw.pending) {
498    draw.dirty = true
499    return
500  }
501  if (!(await plan($)) || (await read($, view)) !== 'page' || !session.canImage || session.noChrome) return
502  if (!(await paneUp($))) return
503  if (draw.pending) {
504    draw.dirty = true // another call claimed the slot while this one checked
505    return
506  }
507  draw.pending = true
508  draw.dirty = false
509  // Drawn before (ahead, or earlier this session): shown now, no Chrome.
510  if (!draw.force && (await fromCache($).catch(() => false))) {
511    draw.pending = false
512    void afterDraw($).catch(() => {})
513    return
514  }
515  $.clock.after(300, () => void run($, true))
516}
517
518// Draws a file Claude changed, in the background once the turn ends, so /md opens on it drawn.
519// One file waits once: edited again before it is drawn, it is drawn once, as it is then.
520async function drawAhead($: EngineInterface, path: string) {
521  if (!session.canImage || session.noChrome || (await read($, view)) !== 'page') return
522  ahead.delete(path)
523  ahead.add(path)
524  for (const old of ahead) if (ahead.size > AHEAD_FILES) ahead.delete(old)
525  if (!turn.isOn) await startAhead($) // mid-turn, the file may change again: wait for the end
526}
527
528async function startAhead($: EngineInterface) {
529  if (ahead.size === 0 || draw.pending) return // the drawing under way picks it up when done
530  draw.pending = true
531  draw.dirty = false
532  $.clock.after(300, () => void run($, false)) // edits close together wait for each other
533}
534
535// The one drawing at a time, for the pane or ahead: two Chromes must never share the profile.
536// The pane comes first; then the newest file waiting to be drawn ahead.
537async function run($: EngineInterface, forPane: boolean) {
538  let isPane = forPane
539  try {
540    if (!isPane) isPane = await paneNeeds($)
541    if (isPane) await renderFile($)
542    else {
543      const path = turn.isOn ? undefined : [...ahead].pop() // a new turn began: wait for its end
544      if (path !== undefined) {
545        ahead.delete(path)
546        await renderAhead($, path)
547      }
548    }
549  } catch {
550    if (isPane) {
551      draw.failed = draw.drawing // no retry loop: r tries again
552      await update($, note, () => 'Could not draw the page. Showing text; r tries again.').catch(() => {})
553    }
554  } finally {
555    draw.pending = false
556    void afterDraw($).catch(() => {})
557  }
558}
559
560// After a drawing: the pane again if it went stale meanwhile, else the next file to draw ahead.
561async function afterDraw($: EngineInterface) {
562  if (draw.dirty) await drawAgainIfStale($)
563  if (draw.pending || ahead.size === 0 || turn.isOn) return
564  draw.pending = true
565  draw.dirty = false
566  void run($, false)
567}
568
569// Whether the open pane shows something other than what it should.
570async function paneNeeds($: EngineInterface) {
571  const p = await plan($)
572  if (!p || (await read($, view)) !== 'page' || !session.canImage || session.noChrome || !(await paneUp($))) return false
573  const f = await read($, frame)
574  const isStale = (f === null || f.key !== p.key || f.columns !== draw.columns) && draw.failed !== `${p.key}@${draw.columns}`
575  return draw.force || isStale
576}
577
578// After a drawing: once more only if what is on screen is no longer what to show.
579async function drawAgainIfStale($: EngineInterface) {
580  const p = await plan($)
581  const f = await read($, frame)
582  const isStale = p !== null && (f === null || f.key !== p.key || f.columns !== draw.columns) && draw.failed !== `${p.key}@${draw.columns}`
583  if (draw.force || isStale) await renderSoon($)
584}
585
586// GitHub's own renderer, through the person's gh; null when it cannot be used.
587async function github($: EngineInterface, md: string, dir: string): Promise<string | null> {
588  if ((await $.clock.now()) < session.ghDownUntil) return null
589  if (new TextEncoder().encode(md).length > GH_MAX_BYTES) return null // too large for the API: built-in, no penalty
590  const repo = await repoOf($, dir)
591  if (!repo) return null // not in a GitHub repo: the Markdown stays on this machine
592  const r = await $.process
593    .run(['gh', 'api', '-X', 'POST', '/markdown', '--input', '-'], { stdin: JSON.stringify({ text: md, mode: 'markdown', context: repo }), timeoutMs: GH_TIMEOUT_MS })
594    .catch(() => null)
595  if (!r || r.exitCode !== 0 || !r.stdout.trim()) {
596    session.ghDownUntil = (await $.clock.now()) + 60_000 // offline or signed out: built-in for a minute
597    ghFailed.add(repo)
598    return null
599  }
600  ghFailed.delete(repo)
601  return r.stdout
602}
603
604// A folder's GitHub repo, asked of git once (r asks again).
605async function repoOf($: EngineInterface, dir: string): Promise<string | null> {
606  if (repos.has(dir)) return repos.get(dir) ?? null
607  const remotes = await $.process.run(['git', '-C', dir, 'remote', '-v'], { timeoutMs: 5000 }).catch(() => null)
608  const repo = remotes && remotes.exitCode === 0 ? githubRepo(remotes.stdout) : null
609  if (remotes) repos.set(dir, repo) // a git that could not run is asked again next time
610  return repo
611}
612
613// The renderer html() would use now, without asking GitHub. A repo whose last GitHub
614// render failed counts as built-in until one works (or r), so kept drawings stay found.
615async function renderer($: EngineInterface, sides: Loaded[]): Promise<'GitHub' | 'built-in'> {
616  if ((await $.clock.now()) < session.ghDownUntil) return 'built-in'
617  for (const s of sides) {
618    if (new TextEncoder().encode(withMarks(s.text, s.marks)).length > GH_MAX_BYTES) return 'built-in'
619    const repo = await repoOf($, folder(s.path))
620    if (!repo || ghFailed.has(repo)) return 'built-in'
621  }
622  return 'GitHub'
623}
624
625// What a drawing shows: the files, their texts and marks, single or compare, the width, the renderer.
626async function cacheKey(p: Plan, sides: Loaded[], columns: number, via: string) {
627  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])
628  const sum = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(what))
629  return Array.from(new Uint8Array(sum), b => b.toString(16).padStart(2, '0')).join('')
630}
631
632// A kept drawing, now the most recently shown.
633function cached(key: string) {
634  const d = cache.get(key)
635  if (d) {
636    cache.delete(key)
637    cache.set(key, d)
638  }
639  return d
640}
641
642// A folder for a new drawing: one no kept drawing uses and not the one on screen.
643function freeSlot(onScreen: string) {
644  const used = new Set([...cache.values()].map(d => d.slot))
645  let slot = 0
646  while (used.has(slot) || onScreen.includes(`/frame-${slot}/`)) slot++
647  return slot
648}
649
650function keep(key: string, d: Drawn, onScreen: string) {
651  cache.delete(key)
652  cache.set(key, d)
653  for (const [k, old] of cache) {
654    if (cache.size <= CACHE_SIZE) break
655    if (!onScreen.includes(`/frame-${old.slot}/`)) cache.delete(k) // its folder is reused later
656  }
657}
658
659// Draws a plan to PNG parts, or hands back the drawing kept for the same content.
660// null when Chrome could not draw it.
661async function drawPage($: EngineInterface, chrome: string, p: Plan, loaded: Loaded[], columns: number, force: boolean): Promise<Drawn | null> {
662  const want = await renderer($, loaded)
663  const hit = force ? undefined : cached(await cacheKey(p, loaded, columns, want))
664  if (hit) return hit
665  const { sides, via } = await html($, loaded)
666  const root = await tmpRoot($)
667  const f = await read($, frame)
668  const onScreen = f?.parts[0]?.file ?? ''
669  // Never the folder on screen: the script empties its folder first.
670  const slot = freeSlot(onScreen)
671  const pagePath = `${root}/page-${slot}.html`
672  const out = `${root}/frame-${slot}`
673  const width = columns * CELL_PX
674  await $.fs.write(pagePath, page(sides, { title: p.title, width, stacked: columns < SIDE_BY_SIDE_COLUMNS }))
675  const r = await $.process.run(['sh', '-c', SCRIPT, 'md-preview', chrome, fileUrl(pagePath), out, `${root}/chrome`, String(width), '2'], { timeoutMs: 120_000 })
676  const m = r.stdout.match(/(\d+) (\d+)/)
677  if (r.exitCode !== 0 || !m) return null
678  const rows = Number(m[1])
679  const parts = Array.from({ length: Number(m[2]) }, (_, k) => ({ file: `${out}/part-${k}.png`, rows: Math.min(PART_ROWS, rows - k * PART_ROWS) }))
680  draw.n = Math.max(draw.n, f?.n ?? -1) + 1 // a new picture in a folder: a new generation
681  const d: Drawn = { slot, columns, parts, n: draw.n, via }
682  keep(await cacheKey(p, loaded, columns, via), d, onScreen) // kept by the renderer it got
683  return d
684}
685
686// Shows the kept drawing of what the pane should show, when there is one; no Chrome.
687async function fromCache($: EngineInterface) {
688  if ((await read($, view)) !== 'page' || !session.canImage || session.noChrome) return false
689  const p = await plan($)
690  if (!p) return false
691  const columns = draw.columns
692  const loaded = await load($, p)
693  if (loaded.length === 0) return false
694  const d = cached(await cacheKey(p, loaded, columns, await renderer($, loaded)))
695  if (!d) return false
696  await update($, note, () => '')
697  await update($, frame, () => ({ key: p.key, columns, parts: d.parts, n: d.n, via: d.via }))
698  return true
699}
700
701// A file drawn ahead, as /md would show it, at the pane's last width; errors stay quiet.
702async function renderAhead($: EngineInterface, path: string) {
703  if ((await read($, view)) !== 'page' || !session.canImage || session.noChrome) return
704  const chrome = await findChrome($)
705  if (!chrome) return // the pane says so when it opens
706  const p = planFor(await read($, files), path, null, await read($, compare))
707  if (!p) return
708  const loaded = await load($, p)
709  if (loaded.length === 0) return
710  await drawPage($, chrome, p, loaded, draw.columns, false)
711}
712
713// Each side's HTML, from GitHub where it can.
714async function html($: EngineInterface, sides: Loaded[]): Promise<{ sides: Side[]; via: 'GitHub' | 'built-in' }> {
715  const mds = sides.map(s => withMarks(s.text, s.marks))
716  const fromGithub: string[] = []
717  for (const [k, md] of mds.entries()) {
718    const h1 = await github($, md, folder((sides[k] as Loaded).path))
719    if (h1 === null) break // one side cannot: all sides use the built-in renderer, so they match
720    fromGithub.push(h1)
721  }
722  const isGithub = fromGithub.length === sides.length
723  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)) }))
724  return { sides: out, via: isGithub ? 'GitHub' : 'built-in' }
725}
726
727async function tmpRoot($: EngineInterface) {
728  const tmp = ((await $.env.get('TMPDIR').catch(() => undefined)) ?? '/tmp').replace(/\/$/, '')
729  return `${tmp}/md-preview`
730}
731
732async function renderFile($: EngineInterface) {
733  const p = await plan($)
734  if (!p) return
735  const columns = draw.columns
736  const key = `${p.key}@${columns}`
737  draw.drawing = key
738  const force = draw.force // r: draw it again, kept drawing or not
739  draw.force = false
740  const chrome = await findChrome($)
741  if (!chrome) {
742    session.noChrome = true
743    await update($, note, () => 'The rendered view needs Google Chrome (or Chromium). Showing text.')
744    return
745  }
746  const loaded = await load($, p)
747  if (loaded.length === 0) {
748    draw.failed = key
749    await update($, note, () => `Cannot read ${p.title}. r tries again.`)
750    return
751  }
752  const d = await drawPage($, chrome, p, loaded, columns, force)
753  if (!d) {
754    draw.failed = key
755    await update($, note, () => 'Chrome could not draw the page. Showing text; r tries again.')
756    return
757  }
758  await update($, note, () => '')
759  await update($, frame, () => ({ key: p.key, columns, parts: d.parts, n: d.n, via: d.via }))
760}
761
762async function findChrome($: EngineInterface) {
763  if (session.chrome) return session.chrome
764  for (const c of CHROMES) {
765    if (await $.fs.exists(c).catch(() => false)) {
766      session.chrome = c
767      return c
768    }
769  }
770  return ''
771}
772
773// The same page, full size in the person's own browser: self-contained HTML in a temp file.
774// Its text has no name in front: a command reply gets one from Claude Code, the Browser button's toast adds its own.
775async function openInBrowser($: EngineInterface): Promise<string> {
776  if (!(await read($, shown)) && !(await read($, pair))) {
777    const latest = (await read($, files))[0]?.path
778    if (latest) await update($, shown, () => latest)
779  }
780  const p = await plan($)
781  if (!p) return `nothing to open yet. /${session.command} <path> picks a file.`
782  const loaded = await load($, p)
783  if (loaded.length === 0) return `cannot read ${p.title}`
784  const { sides } = await html($, loaded)
785  const file = `${await tmpRoot($)}/open-${draw.opened++ % 5}.html` // five in turn, so they do not pile up
786  await $.fs.write(file, page(sides, { title: p.title, width: 0 }))
787  const opener = (await $.fs.exists('/usr/bin/open').catch(() => false)) ? 'open' : 'xdg-open'
788  const r = await $.process.run([opener, file], { timeoutMs: 10_000 }).catch(() => null)
789  return r && r.exitCode === 0 ? `opened ${p.title} in the browser` : `could not open the browser. The page is at ${file}`
790}
791
hooks/md.ts 498 lines
1// 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]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
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*:?-+:?\s*)*\|?\s*$/
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: 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) && (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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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+&quot;([^&]*)&quot;)?'
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(/<(script|style|iframe|object|embed|frame|frameset|applet|base|meta|link|form)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, '')
283    .replace(/<\/?(script|style|iframe|object|embed|frame|frameset|applet|base|meta|link|form)\b[^>]*>/gi, '')
284    .replace(/<[a-z][^>]*>/gi, tag =>
285      tag
286        .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
287        .replace(/\b(href|src|action|formaction|srcset)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m, attr: string, v: string) => (safeUrl(v.replace(/^["']|["']$/g, '')) === '#' ? `${attr}="#"` : m)),
288    )
289}
290
291function emphasis(s: string) {
292  return s
293    .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>')
294    .replace(/\*(?=\S)([\s\S]*?\S)\*/g, '<em>$1</em>')
295    .replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?!\w)/g, '$1<em>$2</em>')
296    .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>')
297}
298
299// The built-in renderer's HTML: the Markdown's own HTML passes through, made safe.
300export function toHtml(list: Block[]): string {
301  return sanitize(list.map(blockHtml).join('\n'))
302}
303
304function blockHtml(b: Block): string {
305  switch (b.t) {
306    case 'h': {
307      const body = inline(b.text)
308      return `<h${b.level} id="${slug(b.text)}">${body}</h${b.level}>`
309    }
310    case 'p':
311      return `<p>${inline(b.text)}</p>`
312    case 'code':
313      return `<pre><code${b.lang ? ` class="language-${esc(b.lang)}"` : ''}>${esc(b.text)}</code></pre>`
314    case 'quote':
315      return `<blockquote>\n${toHtml(b.blocks)}\n</blockquote>`
316    case 'list': {
317      const tag = b.ordered ? 'ol' : 'ul'
318      const hasTask = b.items.some(it => it.task !== undefined)
319      const items = b.items.map(it => {
320        // A one-paragraph item is drawn tight, as GitHub does.
321        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)
322        const box = it.task === undefined ? '' : `<input type="checkbox" class="task-list-item-checkbox" disabled${it.task ? ' checked' : ''}> `
323        return `<li${it.task !== undefined ? ' class="task-list-item"' : ''}>${box}${inner}</li>`
324      })
325      const start = b.ordered && b.start !== 1 ? ` start="${b.start}"` : ''
326      return `<${tag}${start}${hasTask ? ' class="contains-task-list"' : ''}>\n${items.join('\n')}\n</${tag}>`
327    }
328    case 'table': {
329      const al = (i: number) => (b.align[i] ? ` align="${b.align[i]}"` : '')
330      const head = b.head.map((c, i) => `<th${al(i)}>${inline(c)}</th>`).join('')
331      const rows = b.rows.map(r => `<tr>${b.head.map((_c, i) => `<td${al(i)}>${inline(r[i] ?? '')}</td>`).join('')}</tr>`).join('\n')
332      return `<table>\n<thead><tr>${head}</tr></thead>\n<tbody>\n${rows}\n</tbody>\n</table>`
333    }
334    case 'hr':
335      return '<hr>'
336    case 'html':
337      return b.text
338    case 'mark':
339      return '<div class="md-mark"></div>'
340  }
341}
342
343// --- What changed ---
344
345// Top-level chunks: runs of lines split by blank lines outside code fences.
346export function chunks(md: string): string[] {
347  const out: string[] = []
348  let cur: string[] = []
349  let fence = ''
350  for (const l of md.replace(/\r\n?/g, '\n').split('\n')) {
351    const f = l.match(FENCE)
352    if (fence) {
353      cur.push(l)
354      if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(l)) fence = ''
355      continue
356    }
357    if (f) fence = f[1] as string
358    if (isBlank(l) && !fence) {
359      if (cur.length) out.push(cur.join('\n'))
360      cur = []
361      continue
362    }
363    cur.push(l)
364  }
365  if (cur.length) out.push(cur.join('\n'))
366  return out
367}
368
369// The chunks of `after` that `before` did not have; none for a new file.
370export function changedChunks(before: string | null, after: string): number[] {
371  if (before === null || before.trim() === '') return []
372  const old = new Map<string, number>()
373  for (const c of chunks(before)) old.set(c.trim(), (old.get(c.trim()) ?? 0) + 1)
374  const out: number[] = []
375  chunks(after).forEach((c, i) => {
376    const k = c.trim()
377    const n = old.get(k) ?? 0
378    if (n > 0) old.set(k, n - 1)
379    else out.push(i)
380  })
381  return out
382}
383
384// The Markdown again, a MARK paragraph before each changed chunk that starts a block.
385export function withMarks(md: string, marks: readonly number[]): string {
386  if (marks.length === 0) return md
387  const list = chunks(md)
388  const at = new Set<number>()
389  let start = -1 // the first chunk of the list run this chunk belongs to
390  list.forEach((c, i) => {
391    const isListish = ITEM.test(c) || indentOf(c) > 0
392    start = isListish && i > 0 && start >= 0 && (ITEM.test(list[i - 1] as string) || indentOf(list[i - 1] as string) > 0) ? start : i
393    if (marks.includes(i)) at.add(isListish ? start : i)
394  })
395  return list.map((c, i) => (at.has(i) && indentOf(c) < 4 ? `${MARK}\n\n${c}` : c)).join('\n\n')
396}
397
398// --- Text view ---
399
400export type Seg = { text: string; bold?: boolean; italic?: boolean; dim?: boolean; color?: string; underline?: boolean; strike?: boolean }
401export type Line = { prefix: string; segs: Seg[]; mark?: boolean }
402
403const 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
404
405export function segments(text: string): Seg[] {
406  const out: Seg[] = []
407  const plain = (t: string) => t && out.push({ text: t.replace(/\\([\\`*_{}[\]()#+\-.!|~<>])/g, '$1').replace(/\s*\n\s*/g, ' ') })
408  let at = 0
409  for (const m of text.matchAll(INLINE)) {
410    plain(text.slice(at, m.index))
411    at = (m.index ?? 0) + m[0].length
412    if (m[2] !== undefined) out.push({ text: m[2].trim(), color: 'cyan' })
413    else if (m[3] !== undefined) out.push({ text: `[image: ${m[3] || 'untitled'}]`, dim: true })
414    else if (m[4] !== undefined) {
415      out.push({ text: m[4].replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, ''), color: 'blue', underline: true })
416      if (m[5] && !m[5].startsWith('#')) out.push({ text: ` (${m[5]})`, dim: true })
417    } else if (m[6] !== undefined) out.push({ text: m[6], color: 'blue', underline: true })
418    else if (m[8] !== undefined) out.push({ text: m[8], bold: true })
419    else if (m[9] !== undefined) out.push({ text: m[9], italic: true })
420    else if (m[10] !== undefined) out.push({ text: m[10], italic: true })
421    else if (m[11] !== undefined) out.push({ text: m[11], strike: true })
422    // an HTML tag or comment: dropped, its text stays
423  }
424  plain(text.slice(at))
425  return out
426}
427
428const plainText = (segs: Seg[]) => segs.map(s => s.text).join('')
429
430export function toLines(list: Block[], width = 80): Line[] {
431  const out: Line[] = []
432  let marked = false
433  const push = (ls: Line[]) => {
434    for (const l of ls) out.push(marked ? { ...l, mark: true } : l)
435    marked = false
436  }
437  for (const b of list) {
438    if (b.t === 'mark') {
439      marked = true
440      continue
441    }
442    if (out.length) out.push({ prefix: '', segs: [] })
443    push(blockLines(b, width))
444  }
445  return out
446}
447
448function blockLines(b: Block, width: number): Line[] {
449  switch (b.t) {
450    case 'h': {
451      const segs = segments(b.text).map(s => ({ ...s, bold: true, color: b.level <= 2 ? 'magenta' : 'cyan' }))
452      const ls: Line[] = [{ prefix: '', segs }]
453      if (b.level <= 2) ls.push({ prefix: '', segs: [{ text: '─'.repeat(Math.min(width, Math.max(8, plainText(segs).length))), dim: true }] })
454      return ls
455    }
456    case 'p':
457      return [{ prefix: '', segs: segments(b.text) }]
458    case 'code':
459      return b.text.split('\n').map(l => ({ prefix: '  │ ', segs: [{ text: l, dim: true }] }))
460    case 'quote':
461      return toLines(b.blocks, width - 2).map(l => ({ ...l, prefix: `▎ ${l.prefix}`, segs: l.segs.map(s => ({ ...s, dim: true })) }))
462    case 'list': {
463      const ls: Line[] = []
464      b.items.forEach((it, n) => {
465        const bullet = it.task === true ? '☑ ' : it.task === false ? '☐ ' : b.ordered ? `${b.start + n}. ` : '• '
466        const pad = ' '.repeat(bullet.length)
467        toLines(it.blocks, width - bullet.length)
468          .filter(l => l.segs.length > 0)
469          .forEach((l, k) => ls.push({ ...l, prefix: `${k === 0 ? bullet : pad}${l.prefix}` }))
470      })
471      return ls
472    }
473    case 'table': {
474      const rows = [b.head, ...b.rows].map(r => b.head.map((_c, i) => plainText(segments(r[i] ?? ''))))
475      const widths = b.head.map((_c, i) => Math.max(...rows.map(r => (r[i] ?? '').length)))
476      const fmt = (r: string[]) => r.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' │ ')
477      const ls: Line[] = [{ prefix: '', segs: [{ text: fmt(rows[0] ?? []), bold: true }] }]
478      ls.push({ prefix: '', segs: [{ text: widths.map(w => '─'.repeat(w)).join('─┼─'), dim: true }] })
479      for (const r of rows.slice(1)) ls.push({ prefix: '', segs: [{ text: fmt(r) }] })
480      return ls
481    }
482    case 'hr':
483      return [{ prefix: '', segs: [{ text: '─'.repeat(Math.min(width, 40)), dim: true }] }]
484    case 'html': {
485      const alts = [...b.text.matchAll(/<img[^>]*\balt="([^"]*)"/gi)].map(m => `[image: ${m[1] || 'untitled'}]`)
486      const text = b.text
487        .replace(/<!--[\s\S]*?-->/g, '')
488        .replace(/<[^>]+>/g, ' ')
489        .replace(/\s+/g, ' ')
490        .trim()
491      const all = [text, ...alts].filter(Boolean).join(' ')
492      return all ? [{ prefix: '', segs: [{ text: all, dim: true }] }] : []
493    }
494    case 'mark':
495      return []
496  }
497}
498
hooks/page.ts 109 lines
1// 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}
109
types/index.d.ts 34 lines
1// 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