@gavin-hu/my-pi-agent 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +153 -0
  3. package/extensions/bytefly/README.md +129 -122
  4. package/extensions/bytefly/doctor/format.ts +3 -22
  5. package/extensions/bytefly/doctor/register.ts +10 -8
  6. package/extensions/bytefly/footer/register.ts +17 -91
  7. package/extensions/bytefly/header/component.ts +8 -4
  8. package/extensions/bytefly/header/config.ts +11 -6
  9. package/extensions/bytefly/header/register.ts +7 -27
  10. package/extensions/bytefly/index.ts +15 -14
  11. package/extensions/bytefly/panel/commands.ts +147 -0
  12. package/extensions/bytefly/panel/format.ts +135 -0
  13. package/extensions/bytefly/panel/join.ts +103 -0
  14. package/extensions/bytefly/panel/tui.ts +416 -0
  15. package/extensions/bytefly/panel/types.ts +44 -0
  16. package/extensions/bytefly/picker/resources.ts +0 -11
  17. package/extensions/file-browse/README.md +20 -7
  18. package/extensions/file-browse/html.ts +29 -6
  19. package/extensions/file-browse/markdown.ts +288 -46
  20. package/extensions/plan/README.md +16 -12
  21. package/extensions/plan/plans.ts +16 -17
  22. package/extensions/plan/runtime.ts +1 -1
  23. package/extensions/plan/steps.ts +31 -19
  24. package/extensions/plan/tools.ts +62 -32
  25. package/extensions/plan/types.ts +6 -6
  26. package/lib/README.md +1 -1
  27. package/lib/requirements.ts +3 -3
  28. package/lib/tui.ts +7 -8
  29. package/lib/web/components.ts +11 -0
  30. package/package.json +2 -2
  31. package/extensions/bytefly/doctor/commands.ts +0 -68
  32. package/extensions/bytefly/doctor/tui.ts +0 -140
  33. package/extensions/bytefly/picker/commands.ts +0 -73
  34. package/extensions/bytefly/picker/tui.ts +0 -260
@@ -22,7 +22,7 @@ import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode }
22
22
  import type { GitStatus } from "./git.ts";
23
23
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
24
24
  import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
25
- import { renderMarkdown } from "./markdown.ts";
25
+ import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
26
26
  import { encodePath } from "./paths.ts";
27
27
 
28
28
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -78,6 +78,13 @@ ${WEB_COMPONENTS_CSS}
78
78
  .browse-broken{color:var(--muted);text-decoration:line-through}
79
79
  .browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
80
80
  .browse-when{display:none}
81
+ .browse-md-outline{max-width:80ch;margin:0 0 var(--space-3) var(--space-4);border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--fs-sm)}
82
+ .browse-md-outline>summary{padding:var(--space-2) var(--space-3);cursor:pointer;color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs)}
83
+ .browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
84
+ .browse-md-outline a{display:block;padding:2px 0}
85
+ .browse-md-outline a.lvl1{padding-left:var(--space-3)}
86
+ .browse-md-outline a.lvl2{padding-left:var(--space-5)}
87
+ .browse-md-outline a.lvl3{padding-left:var(--space-7)}
81
88
  body.palette-open{overflow:hidden}
82
89
  @media (max-width:520px){
83
90
  .bf-toolbar .bf-theme-toggle{margin-left:auto}
@@ -253,6 +260,20 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
253
260
  return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
254
261
  }
255
262
 
263
+ /** An "on this page" outline from the headings the Markdown renderer emitted. */
264
+ function renderOutline(headings: MarkdownHeading[]): string {
265
+ if (headings.length < 4) return "";
266
+ const base = Math.min(...headings.map((heading) => heading.level));
267
+ const items = headings
268
+ .map((heading) => {
269
+ const depth = Math.min(heading.level - base, 3);
270
+ const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
271
+ return `<li><a class="lvl${depth}" href="#${heading.id}">${escapeHtml(label)}</a></li>`;
272
+ })
273
+ .join("");
274
+ return `<details class="browse-md-outline"><summary>On this page</summary><ul>${items}</ul></details>`;
275
+ }
276
+
256
277
  /** The card-head controls: Markdown mode, line wrapping, and copy actions. */
257
278
  function renderFileActions(page: FilePage): string {
258
279
  const isMarkdown = page.file.kind === "markdown";
@@ -275,7 +296,7 @@ function renderFileActions(page: FilePage): string {
275
296
  }
276
297
  if (page.file.kind === "text" || isMarkdown) {
277
298
  groups.push(
278
- `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy</button>`,
299
+ `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy contents</button>`,
279
300
  );
280
301
  }
281
302
  groups.push(
@@ -296,10 +317,12 @@ export function renderFilePage(page: FilePage): string {
296
317
  let content: string;
297
318
  let note = "";
298
319
  if (page.file.kind === "text" || page.file.kind === "markdown") {
299
- content =
300
- page.file.kind === "markdown" && !page.markdownRaw
301
- ? `<div class="bf-md">${renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel))}</div>`
302
- : `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
320
+ if (page.file.kind === "markdown" && !page.markdownRaw) {
321
+ const rendered = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
322
+ content = `${renderOutline(rendered.headings)}<article class="bf-md">${rendered.html}</article>`;
323
+ } else {
324
+ content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
325
+ }
303
326
  if (page.file.truncated)
304
327
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
305
328
  } else if (page.file.kind === "image") {
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * It is deliberately not CommonMark: it covers the block and inline constructs
5
5
  * that show up in repository READMEs — headings, paragraphs, fenced code,
6
- * blockquotes, nested and task lists, tables, code spans, emphasis, links, and
7
- * images. Everything is escaped and only a known set of tags is emitted; raw
8
- * HTML in the source is shown as text, never executed.
6
+ * blockquotes, nested and task lists, tables, code spans, emphasis, inline and
7
+ * reference links, and images. Named and numeric character references are
8
+ * decoded as literal text. Everything is escaped and only a known set of tags
9
+ * is emitted; raw HTML in the source is shown as text, never executed.
9
10
  *
10
11
  * Relative links become `/view/<path>` and relative images become `/raw/<path>`
11
12
  * so they resolve from any page URL. The `/view` and `/raw` routes re-vet the
@@ -13,6 +14,10 @@
13
14
  * External images are rendered as links (the page CSP is `img-src 'self' data:`)
14
15
  * and non-http(s) schemes such as `javascript:` are dropped.
15
16
  *
17
+ * Headings get a stable anchor id and are demoted one level so the embedding
18
+ * page keeps the only `h1`; `renderMarkdownDocument` returns them for an
19
+ * outline.
20
+ *
16
21
  * Kept free of the page renderer: it imports only `lib/escape.ts`,
17
22
  * `lib/highlight.ts`, and `paths.ts`, so `html.ts` can compose it without a
18
23
  * module cycle.
@@ -26,10 +31,121 @@ import { encodePath } from "./paths.ts";
26
31
  const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
27
32
  const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
28
33
  const HTTP_SCHEME = /^https?:/i;
34
+ /** A link reference definition: `[id]: dest "title"`. */
35
+ const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
36
+
37
+ interface LinkDefinition {
38
+ dest: string;
39
+ title?: string;
40
+ }
41
+
42
+ /** A heading the renderer emitted, in document order. */
43
+ export interface MarkdownHeading {
44
+ /** The rendered tag level, already demoted and capped at `h6`. */
45
+ level: number;
46
+ /** The anchor id, unique within the document. */
47
+ id: string;
48
+ /** The heading's raw inline Markdown. */
49
+ text: string;
50
+ }
51
+
52
+ /**
53
+ * Per-document render state: the resolution base, the reference definitions,
54
+ * and the headings emitted so far.
55
+ */
56
+ interface Context {
57
+ baseDir: string;
58
+ defs: Map<string, LinkDefinition>;
59
+ headings: MarkdownHeading[];
60
+ slug(text: string): string;
61
+ }
62
+
63
+ /**
64
+ * Render a Markdown source string to a safe HTML fragment, plus the headings it
65
+ * emitted so a caller can build an outline. Headings carry an anchor id and are
66
+ * demoted one level, so the embedding page keeps the only `h1`.
67
+ */
68
+ export function renderMarkdownDocument(source: string, baseDir = ""): { html: string; headings: MarkdownHeading[] } {
69
+ const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
70
+ const context = createContext(baseDir, defs);
71
+ return { html: renderBlocks(body, context), headings: context.headings };
72
+ }
29
73
 
30
74
  /** Render a Markdown source string to a safe HTML fragment. */
31
75
  export function renderMarkdown(source: string, baseDir = ""): string {
32
- return renderBlocks(source.split(/\r\n|\r|\n/), baseDir);
76
+ return renderMarkdownDocument(source, baseDir).html;
77
+ }
78
+
79
+ /** Turn heading text into a stable anchor id, dropping Markdown punctuation. */
80
+ function headingSlug(text: string): string {
81
+ const plain = text
82
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
83
+ .replace(/[`*_~]/g, "")
84
+ .toLowerCase()
85
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
86
+ .replace(/[\s-]+/g, "-")
87
+ .replace(/^-+|-+$/g, "");
88
+ return plain || "section";
89
+ }
90
+
91
+ /** Build the per-document render state, suffixing repeated heading slugs. */
92
+ function createContext(baseDir: string, defs: Map<string, LinkDefinition>): Context {
93
+ const seen = new Map<string, number>();
94
+ return {
95
+ baseDir,
96
+ defs,
97
+ headings: [],
98
+ slug(text: string): string {
99
+ const base = headingSlug(text);
100
+ const count = seen.get(base) ?? 0;
101
+ seen.set(base, count + 1);
102
+ return count === 0 ? base : `${base}-${count}`;
103
+ },
104
+ };
105
+ }
106
+
107
+ /** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
108
+ function normalizeLabel(label: string): string {
109
+ return label.trim().replace(/\s+/g, " ").toLowerCase();
110
+ }
111
+
112
+ /**
113
+ * Lift link reference definitions out of the source before block rendering, so
114
+ * a `[id]: dest` line becomes a definition rather than a paragraph and can be
115
+ * referenced from anywhere in the document. Fences are skipped so a
116
+ * definition-shaped line inside code stays literal. Definitions are
117
+ * single-line; one inside a blockquote or list item is left in place as text.
118
+ */
119
+ function collectDefinitions(lines: string[]): { defs: Map<string, LinkDefinition>; body: string[] } {
120
+ const defs = new Map<string, LinkDefinition>();
121
+ const body: string[] = [];
122
+ let fence: Fence | undefined;
123
+ for (const line of lines) {
124
+ if (fence) {
125
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
126
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = undefined;
127
+ body.push(line);
128
+ continue;
129
+ }
130
+ const open = matchFence(line);
131
+ if (open) {
132
+ fence = open;
133
+ body.push(line);
134
+ continue;
135
+ }
136
+ const match = DEFINITION.exec(line);
137
+ if (!match) {
138
+ body.push(line);
139
+ continue;
140
+ }
141
+ const key = normalizeLabel(match[1]);
142
+ if (!defs.has(key)) {
143
+ const dest = match[2].startsWith("<") && match[2].endsWith(">") ? match[2].slice(1, -1) : match[2];
144
+ defs.set(key, { dest, title: match[3] });
145
+ }
146
+ body.push("");
147
+ }
148
+ return { defs, body };
33
149
  }
34
150
 
35
151
  function isBlank(line: string): boolean {
@@ -56,7 +172,7 @@ function stripIndent(line: string, columns: number): string {
56
172
  return line.slice(index);
57
173
  }
58
174
 
59
- function renderBlocks(lines: string[], baseDir: string): string {
175
+ function renderBlocks(lines: string[], ctx: Context): string {
60
176
  const out: string[] = [];
61
177
  let i = 0;
62
178
  while (i < lines.length) {
@@ -76,7 +192,10 @@ function renderBlocks(lines: string[], baseDir: string): string {
76
192
 
77
193
  const heading = matchHeading(line);
78
194
  if (heading) {
79
- out.push(`<h${heading.level}>${renderInline(heading.text, baseDir)}</h${heading.level}>`);
195
+ const level = Math.min(heading.level + 1, 6);
196
+ const id = ctx.slug(heading.text);
197
+ ctx.headings.push({ level, id, text: heading.text });
198
+ out.push(`<h${level} id="${id}">${renderInline(heading.text, ctx)}</h${level}>`);
80
199
  i++;
81
200
  continue;
82
201
  }
@@ -88,27 +207,29 @@ function renderBlocks(lines: string[], baseDir: string): string {
88
207
  }
89
208
 
90
209
  if (isBlockquoteLine(line)) {
91
- const [html, next] = renderBlockquote(lines, i, baseDir);
210
+ const [html, next] = renderBlockquote(lines, i, ctx);
92
211
  out.push(html);
93
212
  i = next;
94
213
  continue;
95
214
  }
96
215
 
97
- if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) {
98
- const [html, next] = renderTable(lines, i, baseDir);
216
+ // A list marker wins over a pipe, so `- | a | b |` stays a list item
217
+ // instead of becoming a table whose first cell is `-`.
218
+ if (matchListItem(line)) {
219
+ const [html, next] = renderList(lines, i, ctx);
99
220
  out.push(html);
100
221
  i = next;
101
222
  continue;
102
223
  }
103
224
 
104
- if (matchListItem(line)) {
105
- const [html, next] = renderList(lines, i, baseDir);
225
+ if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) {
226
+ const [html, next] = renderTable(lines, i, ctx);
106
227
  out.push(html);
107
228
  i = next;
108
229
  continue;
109
230
  }
110
231
 
111
- const [html, next] = renderParagraph(lines, i, baseDir);
232
+ const [html, next] = renderParagraph(lines, i, ctx);
112
233
  out.push(html);
113
234
  i = next;
114
235
  }
@@ -128,7 +249,7 @@ function isBlockStart(lines: string[], i: number): boolean {
128
249
  );
129
250
  }
130
251
 
131
- function renderParagraph(lines: string[], start: number, baseDir: string): [string, number] {
252
+ function renderParagraph(lines: string[], start: number, ctx: Context): [string, number] {
132
253
  const collected: string[] = [];
133
254
  let i = start;
134
255
  for (; i < lines.length; i++) {
@@ -141,7 +262,7 @@ function renderParagraph(lines: string[], start: number, baseDir: string): [stri
141
262
  let text = line.trimEnd();
142
263
  const hard = / {2}$/.test(line) || /\\$/.test(text);
143
264
  if (text.endsWith("\\")) text = text.slice(0, -1);
144
- const rendered = renderInline(text.trim(), baseDir);
265
+ const rendered = renderInline(text.trim(), ctx);
145
266
  return hard ? `${rendered}<br>` : rendered;
146
267
  })
147
268
  .join("\n");
@@ -192,7 +313,7 @@ function isBlockquoteLine(line: string): boolean {
192
313
  return /^ {0,3}>/.test(line);
193
314
  }
194
315
 
195
- function renderBlockquote(lines: string[], start: number, baseDir: string): [string, number] {
316
+ function renderBlockquote(lines: string[], start: number, ctx: Context): [string, number] {
196
317
  const inner: string[] = [];
197
318
  let i = start;
198
319
  for (; i < lines.length; i++) {
@@ -207,7 +328,7 @@ function renderBlockquote(lines: string[], start: number, baseDir: string): [str
207
328
  }
208
329
  break;
209
330
  }
210
- return [`<blockquote>${renderBlocks(inner, baseDir)}</blockquote>`, i];
331
+ return [`<blockquote>${renderBlocks(inner, ctx)}</blockquote>`, i];
211
332
  }
212
333
 
213
334
  function splitTableRow(line: string): string[] {
@@ -237,7 +358,10 @@ function splitTableRow(line: string): string[] {
237
358
  function isTableStart(header: string, delimiter: string): boolean {
238
359
  if (!header.includes("|")) return false;
239
360
  const cells = splitTableRow(delimiter);
240
- return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
361
+ // GFM requires the delimiter row to match the header row's cell count;
362
+ // otherwise the lines are a paragraph, not a table.
363
+ if (cells.length === 0 || cells.length !== splitTableRow(header).length) return false;
364
+ return cells.every((cell) => /^:?-+:?$/.test(cell));
241
365
  }
242
366
 
243
367
  function alignmentClass(cell: string): string {
@@ -249,10 +373,12 @@ function alignmentClass(cell: string): string {
249
373
  return "";
250
374
  }
251
375
 
252
- function renderTable(lines: string[], start: number, baseDir: string): [string, number] {
376
+ function renderTable(lines: string[], start: number, ctx: Context): [string, number] {
253
377
  const header = splitTableRow(lines[start]);
254
378
  const align = splitTableRow(lines[start + 1]).map(alignmentClass);
255
- const head = header.map((cell, index) => `<th${align[index] ?? ""}>${renderInline(cell, baseDir)}</th>`).join("");
379
+ const head = header
380
+ .map((cell, index) => `<th scope="col"${align[index] ?? ""}>${renderInline(cell, ctx)}</th>`)
381
+ .join("");
256
382
  const rows: string[] = [];
257
383
  let i = start + 2;
258
384
  for (; i < lines.length; i++) {
@@ -260,11 +386,12 @@ function renderTable(lines: string[], start: number, baseDir: string): [string,
260
386
  const cells = splitTableRow(lines[i]);
261
387
  const tds: string[] = [];
262
388
  for (let column = 0; column < header.length; column++) {
263
- tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", baseDir)}</td>`);
389
+ tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", ctx)}</td>`);
264
390
  }
265
391
  rows.push(`<tr>${tds.join("")}</tr>`);
266
392
  }
267
- return [`<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`, i];
393
+ const table = `<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
394
+ return [`<div class="md-table-wrap">${table}</div>`, i];
268
395
  }
269
396
 
270
397
  interface ListItem {
@@ -285,9 +412,9 @@ function matchListItem(line: string): ListItem | undefined {
285
412
  };
286
413
  }
287
414
 
288
- function renderList(lines: string[], start: number, baseDir: string): [string, number] {
415
+ function renderList(lines: string[], start: number, ctx: Context): [string, number] {
289
416
  const first = matchListItem(lines[start]);
290
- if (!first) return renderParagraph(lines, start, baseDir);
417
+ if (!first) return renderParagraph(lines, start, ctx);
291
418
  const baseIndent = first.indent;
292
419
  const ordered = first.ordered;
293
420
  const items: string[] = [];
@@ -323,20 +450,20 @@ function renderList(lines: string[], start: number, baseDir: string): [string, n
323
450
  }
324
451
  break;
325
452
  }
326
- items.push(renderListItem(contentLines, baseDir));
453
+ items.push(renderListItem(contentLines, ctx));
327
454
  }
328
455
  const tag = ordered ? "ol" : "ul";
329
456
  return [`<${tag}>${items.join("")}</${tag}>`, i];
330
457
  }
331
458
 
332
- function renderListItem(contentLines: string[], baseDir: string): string {
459
+ function renderListItem(contentLines: string[], ctx: Context): string {
333
460
  const task = /^\[([ xX])\]\s*(.*)$/.exec(contentLines[0] ?? "");
334
461
  const done = task ? task[1].toLowerCase() === "x" : false;
335
462
  const source = task ? [task[2], ...contentLines.slice(1)] : contentLines;
336
463
  const tight = !source.some(isBlank);
337
- let body = renderBlocks(source, baseDir);
464
+ let body = renderBlocks(source, ctx);
338
465
  if (tight) body = body.replace(/<\/?p>/g, "");
339
- const mark = task ? `<span class="md-check" aria-hidden="true">${done ? "☑" : "☐"}</span> ` : "";
466
+ const mark = task ? `<input class="md-check" type="checkbox" disabled${done ? " checked" : ""}> ` : "";
340
467
  return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
341
468
  }
342
469
 
@@ -347,11 +474,10 @@ interface ParsedLink {
347
474
  end: number;
348
475
  }
349
476
 
350
- function parseInlineLink(text: string, start: number): ParsedLink | undefined {
351
- if (text[start] !== "[") return undefined;
477
+ /** The index of the `]` closing the bracket run opened at `start`, or -1. */
478
+ function matchBracket(text: string, start: number): number {
352
479
  let depth = 0;
353
- let i = start;
354
- for (; i < text.length; i++) {
480
+ for (let i = start; i < text.length; i++) {
355
481
  const char = text[i];
356
482
  if (char === "\\") {
357
483
  i++;
@@ -360,10 +486,16 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
360
486
  if (char === "[") depth++;
361
487
  else if (char === "]") {
362
488
  depth--;
363
- if (depth === 0) break;
489
+ if (depth === 0) return i;
364
490
  }
365
491
  }
366
- if (i >= text.length || text[i] !== "]" || text[i + 1] !== "(") return undefined;
492
+ return -1;
493
+ }
494
+
495
+ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
496
+ if (text[start] !== "[") return undefined;
497
+ const i = matchBracket(text, start);
498
+ if (i === -1 || text[i + 1] !== "(") return undefined;
367
499
  const label = text.slice(start + 1, i);
368
500
  let j = i + 2;
369
501
  while (j < text.length && /\s/.test(text[j])) j++;
@@ -409,6 +541,29 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
409
541
  return { label, dest, title, end: j + 1 };
410
542
  }
411
543
 
544
+ /**
545
+ * Resolve `[label][id]`, `[label][]`, or `[label]` against the document's
546
+ * reference definitions, preferring the full form and falling back to the
547
+ * shortcut so `[foo][bar]` still resolves `[foo]` when only that is defined.
548
+ */
549
+ function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLink | undefined {
550
+ if (text[start] !== "[") return undefined;
551
+ const close = matchBracket(text, start);
552
+ if (close === -1 || text[close + 1] === "(") return undefined;
553
+ const label = text.slice(start + 1, close);
554
+ if (text[close + 1] === "[") {
555
+ const end = matchBracket(text, close + 1);
556
+ if (end !== -1) {
557
+ const raw = text.slice(close + 2, end);
558
+ const def = ctx.defs.get(normalizeLabel(raw === "" ? label : raw));
559
+ if (def) return { label, dest: def.dest, title: def.title, end: end + 1 };
560
+ }
561
+ }
562
+ const def = ctx.defs.get(normalizeLabel(label));
563
+ if (def) return { label, dest: def.dest, title: def.title, end: close + 1 };
564
+ return undefined;
565
+ }
566
+
412
567
  interface ResolvedLink {
413
568
  href: string;
414
569
  external: boolean;
@@ -459,18 +614,18 @@ function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined
459
614
  return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
460
615
  }
461
616
 
462
- function renderLink(parsed: ParsedLink, baseDir: string): string {
463
- const label = renderInline(parsed.label, baseDir);
464
- const target = resolveLink(parsed.dest, baseDir);
617
+ function renderLink(parsed: ParsedLink, ctx: Context): string {
618
+ const label = renderInline(parsed.label, ctx);
619
+ const target = resolveLink(parsed.dest, ctx.baseDir);
465
620
  if (!target) return label;
466
621
  const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
467
622
  const external = target.external ? ` target="_blank" rel="noopener noreferrer"` : "";
468
623
  return `<a href="${escapeHtml(target.href)}"${title}${external}>${label}</a>`;
469
624
  }
470
625
 
471
- function renderImage(parsed: ParsedLink, baseDir: string): string {
626
+ function renderImage(parsed: ParsedLink, ctx: Context): string {
472
627
  const alt = escapeHtml(parsed.label);
473
- const target = resolveImage(parsed.dest, baseDir);
628
+ const target = resolveImage(parsed.dest, ctx.baseDir);
474
629
  if (!target) return alt;
475
630
  if (!target.image) {
476
631
  return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${alt || escapeHtml(parsed.dest)}</a>`;
@@ -504,8 +659,86 @@ function findEmphasisClose(text: string, start: number, char: string): number {
504
659
  return -1;
505
660
  }
506
661
 
662
+ /**
663
+ * The named character references common enough to matter in a README. Anything
664
+ * outside this set stays literal; numeric references are always decoded.
665
+ */
666
+ const NAMED_ENTITIES: Record<string, string> = {
667
+ amp: "&",
668
+ lt: "<",
669
+ gt: ">",
670
+ quot: '"',
671
+ apos: "'",
672
+ nbsp: "\u00a0",
673
+ copy: "©",
674
+ reg: "®",
675
+ trade: "™",
676
+ hellip: "…",
677
+ mdash: "—",
678
+ ndash: "–",
679
+ lsquo: "‘",
680
+ rsquo: "’",
681
+ ldquo: "“",
682
+ rdquo: "”",
683
+ laquo: "«",
684
+ raquo: "»",
685
+ times: "×",
686
+ divide: "÷",
687
+ middot: "·",
688
+ bull: "•",
689
+ deg: "°",
690
+ plusmn: "±",
691
+ dagger: "†",
692
+ sect: "§",
693
+ para: "¶",
694
+ euro: "€",
695
+ pound: "£",
696
+ yen: "¥",
697
+ cent: "¢",
698
+ larr: "←",
699
+ rarr: "→",
700
+ harr: "↔",
701
+ le: "≤",
702
+ ge: "≥",
703
+ ne: "≠",
704
+ asymp: "≈",
705
+ sup2: "²",
706
+ sup3: "³",
707
+ frac12: "½",
708
+ frac14: "¼",
709
+ frac34: "¾",
710
+ };
711
+
712
+ interface Entity {
713
+ value: string;
714
+ length: number;
715
+ }
716
+
717
+ /** A code point, or U+FFFD when the reference is a surrogate or out of range. */
718
+ function codePoint(value: number): string {
719
+ if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
720
+ return "\uFFFD";
721
+ return String.fromCodePoint(value);
722
+ }
723
+
724
+ /**
725
+ * Match a numeric or named character reference at `start`. Decoding happens in
726
+ * the inline scanner rather than a pre-pass so an entity cannot become Markdown
727
+ * syntax: `&#42;x&#42;` stays the literal text `*x*`.
728
+ */
729
+ function matchEntity(text: string, start: number): Entity | undefined {
730
+ const match = /^&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/.exec(text.slice(start));
731
+ if (!match) return undefined;
732
+ let value: string | undefined;
733
+ if (match[1] !== undefined) value = codePoint(Number(match[1]));
734
+ else if (match[2] !== undefined) value = codePoint(Number.parseInt(match[2], 16));
735
+ else if (match[3] !== undefined && Object.hasOwn(NAMED_ENTITIES, match[3])) value = NAMED_ENTITIES[match[3]];
736
+ if (value === undefined) return undefined;
737
+ return { value, length: match[0].length };
738
+ }
739
+
507
740
  /** Render inline Markdown within a block, escaping every literal character. */
508
- function renderInline(text: string, baseDir: string): string {
741
+ function renderInline(text: string, ctx: Context): string {
509
742
  let out = "";
510
743
  let i = 0;
511
744
  while (i < text.length) {
@@ -517,6 +750,15 @@ function renderInline(text: string, baseDir: string): string {
517
750
  continue;
518
751
  }
519
752
 
753
+ if (char === "&") {
754
+ const entity = matchEntity(text, i);
755
+ if (entity) {
756
+ out += escapeHtml(entity.value);
757
+ i += entity.length;
758
+ continue;
759
+ }
760
+ }
761
+
520
762
  if (char === "`") {
521
763
  const run = matchRun(text, i, "`");
522
764
  const close = text.indexOf("`".repeat(run), i + run);
@@ -530,18 +772,18 @@ function renderInline(text: string, baseDir: string): string {
530
772
  }
531
773
 
532
774
  if (char === "!" && text[i + 1] === "[") {
533
- const parsed = parseInlineLink(text, i + 1);
775
+ const parsed = parseInlineLink(text, i + 1) ?? parseReferenceLink(text, i + 1, ctx);
534
776
  if (parsed) {
535
- out += renderImage(parsed, baseDir);
777
+ out += renderImage(parsed, ctx);
536
778
  i = parsed.end;
537
779
  continue;
538
780
  }
539
781
  }
540
782
 
541
783
  if (char === "[") {
542
- const parsed = parseInlineLink(text, i);
784
+ const parsed = parseInlineLink(text, i) ?? parseReferenceLink(text, i, ctx);
543
785
  if (parsed) {
544
- out += renderLink(parsed, baseDir);
786
+ out += renderLink(parsed, ctx);
545
787
  i = parsed.end;
546
788
  continue;
547
789
  }
@@ -561,7 +803,7 @@ function renderInline(text: string, baseDir: string): string {
561
803
  if ((char === "*" || char === "_") && text[i + 1] === char) {
562
804
  const close = text.indexOf(char + char, i + 2);
563
805
  if (close > i + 2) {
564
- out += `<strong>${renderInline(text.slice(i + 2, close), baseDir)}</strong>`;
806
+ out += `<strong>${renderInline(text.slice(i + 2, close), ctx)}</strong>`;
565
807
  i = close + 2;
566
808
  continue;
567
809
  }
@@ -570,7 +812,7 @@ function renderInline(text: string, baseDir: string): string {
570
812
  if (char === "~" && text[i + 1] === "~") {
571
813
  const close = text.indexOf("~~", i + 2);
572
814
  if (close > i + 2) {
573
- out += `<del>${renderInline(text.slice(i + 2, close), baseDir)}</del>`;
815
+ out += `<del>${renderInline(text.slice(i + 2, close), ctx)}</del>`;
574
816
  i = close + 2;
575
817
  continue;
576
818
  }
@@ -579,7 +821,7 @@ function renderInline(text: string, baseDir: string): string {
579
821
  if (char === "*" || char === "_") {
580
822
  const close = findEmphasisClose(text, i, char);
581
823
  if (close > i + 1) {
582
- out += `<em>${renderInline(text.slice(i + 1, close), baseDir)}</em>`;
824
+ out += `<em>${renderInline(text.slice(i + 1, close), ctx)}</em>`;
583
825
  i = close + 1;
584
826
  continue;
585
827
  }