@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.
- package/README.md +10 -10
- package/README.zh-CN.md +153 -0
- package/extensions/bytefly/README.md +129 -122
- package/extensions/bytefly/doctor/format.ts +3 -22
- package/extensions/bytefly/doctor/register.ts +10 -8
- package/extensions/bytefly/footer/register.ts +17 -91
- package/extensions/bytefly/header/component.ts +8 -4
- package/extensions/bytefly/header/config.ts +11 -6
- package/extensions/bytefly/header/register.ts +7 -27
- package/extensions/bytefly/index.ts +15 -14
- package/extensions/bytefly/panel/commands.ts +147 -0
- package/extensions/bytefly/panel/format.ts +135 -0
- package/extensions/bytefly/panel/join.ts +103 -0
- package/extensions/bytefly/panel/tui.ts +416 -0
- package/extensions/bytefly/panel/types.ts +44 -0
- package/extensions/bytefly/picker/resources.ts +0 -11
- package/extensions/file-browse/README.md +20 -7
- package/extensions/file-browse/html.ts +29 -6
- package/extensions/file-browse/markdown.ts +288 -46
- package/extensions/plan/README.md +16 -12
- package/extensions/plan/plans.ts +16 -17
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/steps.ts +31 -19
- package/extensions/plan/tools.ts +62 -32
- package/extensions/plan/types.ts +6 -6
- package/lib/README.md +1 -1
- package/lib/requirements.ts +3 -3
- package/lib/tui.ts +7 -8
- package/lib/web/components.ts +11 -0
- package/package.json +2 -2
- package/extensions/bytefly/doctor/commands.ts +0 -68
- package/extensions/bytefly/doctor/tui.ts +0 -140
- package/extensions/bytefly/picker/commands.ts +0 -73
- 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 {
|
|
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
|
-
|
|
300
|
-
page.file.
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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[],
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
105
|
-
const [html, next] =
|
|
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,
|
|
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,
|
|
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(),
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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] ?? "",
|
|
389
|
+
tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", ctx)}</td>`);
|
|
264
390
|
}
|
|
265
391
|
rows.push(`<tr>${tds.join("")}</tr>`);
|
|
266
392
|
}
|
|
267
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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[],
|
|
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,
|
|
464
|
+
let body = renderBlocks(source, ctx);
|
|
338
465
|
if (tight) body = body.replace(/<\/?p>/g, "");
|
|
339
|
-
const mark = task ? `<
|
|
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
|
-
|
|
351
|
-
|
|
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)
|
|
489
|
+
if (depth === 0) return i;
|
|
364
490
|
}
|
|
365
491
|
}
|
|
366
|
-
|
|
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,
|
|
463
|
-
const label = renderInline(parsed.label,
|
|
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,
|
|
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: `*x*` 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,
|
|
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,
|
|
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,
|
|
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),
|
|
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),
|
|
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),
|
|
824
|
+
out += `<em>${renderInline(text.slice(i + 1, close), ctx)}</em>`;
|
|
583
825
|
i = close + 1;
|
|
584
826
|
continue;
|
|
585
827
|
}
|