@gavin-hu/my-pi-agent 0.14.0 → 0.15.0
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 +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
|
@@ -12,6 +12,8 @@ import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
|
12
12
|
import {
|
|
13
13
|
brandLockup,
|
|
14
14
|
docHead,
|
|
15
|
+
type KeyHint,
|
|
16
|
+
keysSheet,
|
|
15
17
|
SIDEBAR_COLLAPSE_MAX,
|
|
16
18
|
WEB_BASE_CSS,
|
|
17
19
|
WEB_COMPONENTS_CSS,
|
|
@@ -24,7 +26,7 @@ import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMod
|
|
|
24
26
|
import type { DiffFileView, GitStatus } from "./git.ts";
|
|
25
27
|
import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
|
|
26
28
|
import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
|
|
27
|
-
import {
|
|
29
|
+
import { renderMarkdown } from "./markdown.ts";
|
|
28
30
|
import { encodePath } from "./paths.ts";
|
|
29
31
|
|
|
30
32
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
@@ -101,6 +103,9 @@ ${WEB_COMPONENTS_CSS}
|
|
|
101
103
|
.browse-quick-open{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;margin-bottom:var(--space-2);padding:6px var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);color:var(--muted);font:var(--fs-sm) var(--font-ui);cursor:pointer}
|
|
102
104
|
.browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
|
|
103
105
|
.browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
|
|
106
|
+
.browse-side__filters{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)}
|
|
107
|
+
.browse-side__filters .bf-field{flex:1 1 auto;min-width:0}
|
|
108
|
+
.browse-side__filters .bf-btn{flex:0 0 auto;white-space:nowrap}
|
|
104
109
|
.browse-cat{width:16px;height:16px;flex:0 0 16px;color:var(--accent)}
|
|
105
110
|
.browse-cat.doc{color:var(--muted)}.browse-cat.img{color:var(--success)}.browse-cat.cfg{color:var(--warn)}.browse-cat.arc{color:var(--warn)}
|
|
106
111
|
.browse-heading{margin:0 0 var(--space-3);font-size:var(--fs-lg)}
|
|
@@ -111,13 +116,6 @@ ${WEB_COMPONENTS_CSS}
|
|
|
111
116
|
.browse-broken{color:var(--muted);text-decoration:line-through}
|
|
112
117
|
.browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
|
|
113
118
|
.browse-when{display:none}
|
|
114
|
-
.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)}
|
|
115
|
-
.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)}
|
|
116
|
-
.browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
|
|
117
|
-
.browse-md-outline a{display:block;padding:2px 0}
|
|
118
|
-
.browse-md-outline a.lvl1{padding-left:var(--space-3)}
|
|
119
|
-
.browse-md-outline a.lvl2{padding-left:var(--space-5)}
|
|
120
|
-
.browse-md-outline a.lvl3{padding-left:var(--space-7)}
|
|
121
119
|
body.palette-open{overflow:hidden}
|
|
122
120
|
.browse-diff{font-family:var(--font-mono);gap:5px}
|
|
123
121
|
.browse-diff:hover{color:var(--accent);text-decoration:none}
|
|
@@ -156,20 +154,27 @@ body.palette-open{overflow:hidden}
|
|
|
156
154
|
.browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
|
|
157
155
|
.browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
|
|
158
156
|
/* File and diff pages: a measured reading column inside the card. */
|
|
159
|
-
/*
|
|
160
|
-
|
|
157
|
+
/* The Markdown body fills the card, so the scroll box is the card's width: its
|
|
158
|
+
scrollbar sits at the card edge. The measure lives on the blocks themselves:
|
|
159
|
+
on .bf-md it would be a flex item with auto inline margins, which is
|
|
160
|
+
shrink-to-fit, so a short document collapsed to its widest line. */
|
|
161
161
|
.browse-measure{--browse-measure:1012px}
|
|
162
|
-
.browse-measure .bf-md{max-
|
|
163
|
-
.browse-measure .
|
|
162
|
+
.browse-measure .bf-md{--browse-gutter:max(0px,calc((100% - var(--browse-measure)) / 2));max-width:none;margin-inline:0;padding-inline:var(--space-5);padding-block:var(--space-4) var(--space-6)}
|
|
163
|
+
.browse-measure .bf-md>*{max-width:var(--browse-measure);margin-inline:auto}
|
|
164
|
+
/* Tables start at the prose column and take only the gutter to its right, so a
|
|
165
|
+
wide window widens them without a dead band on their left. Code stays inside
|
|
166
|
+
the measure with the rest of the prose. */
|
|
167
|
+
.browse-measure .bf-md>.md-table-frame{max-width:none;padding-left:var(--browse-gutter)}
|
|
168
|
+
/* A table wider than the pane scrolls, but an overlay scrollbar is invisible
|
|
169
|
+
until it moves; a gradient on the frame marks the columns still out of view.
|
|
170
|
+
It has to be the frame: the scroller clips its own content, so a pseudo-element
|
|
171
|
+
inside it would scroll away with the table. */
|
|
172
|
+
.browse-measure .bf-md>.md-table-frame.browse-table-more::after{content:"";position:absolute;top:0;right:0;bottom:0;width:40px;pointer-events:none;background:linear-gradient(to left,var(--surface) 12%,transparent)}
|
|
164
173
|
.bf-card__meta{font-variant-numeric:tabular-nums}
|
|
165
174
|
.browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
|
|
166
175
|
.browse-diff-summary .browse-diff-add{color:var(--success)}
|
|
167
176
|
.browse-diff-summary .browse-diff-del{color:var(--danger)}
|
|
168
177
|
.browse-hunks{color:var(--muted)}
|
|
169
|
-
.browse-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
|
|
170
|
-
.browse-keys dt{white-space:nowrap}
|
|
171
|
-
.browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
|
|
172
|
-
.browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
|
|
173
178
|
/* Wide screens: pin quick open and the filter, and scroll the tree on its own
|
|
174
179
|
box. The sidebar is a details element, whose content sits in an anonymous
|
|
175
180
|
wrapper with no definite height, so the panel fills the sidebar by absolute
|
|
@@ -187,12 +192,6 @@ body.palette-open{overflow:hidden}
|
|
|
187
192
|
.bf-toolbar .bf-theme-toggle{margin-left:auto}
|
|
188
193
|
}`;
|
|
189
194
|
|
|
190
|
-
/** One row of the keyboard sheet: the key or chord, and what it does. */
|
|
191
|
-
export interface KeyHint {
|
|
192
|
-
keys: string;
|
|
193
|
-
label: string;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
195
|
interface Layout {
|
|
197
196
|
title: string;
|
|
198
197
|
rootLabel: string;
|
|
@@ -210,22 +209,6 @@ interface Layout {
|
|
|
210
209
|
summary?: ChangeSummary;
|
|
211
210
|
}
|
|
212
211
|
|
|
213
|
-
/** The `?` keyboard sheet, hidden until the client opens it. */
|
|
214
|
-
function renderKeysSheet(keys: KeyHint[]): string {
|
|
215
|
-
const rows = keys
|
|
216
|
-
.map(
|
|
217
|
-
(key) =>
|
|
218
|
-
`<div><dt><kbd class="browse-kbd">${escapeHtml(key.keys)}</kbd></dt><dd>${escapeHtml(key.label)}</dd></div>`,
|
|
219
|
-
)
|
|
220
|
-
.join("");
|
|
221
|
-
return `<div class="bf-dialog" id="keys-dialog" hidden>
|
|
222
|
-
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
|
|
223
|
-
<div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
|
|
224
|
-
<dl class="browse-keys">${rows}</dl>
|
|
225
|
-
</div>
|
|
226
|
-
</div>`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
212
|
/** A full two-pane page. */
|
|
230
213
|
function renderLayout(layout: Layout): string {
|
|
231
214
|
return `<!doctype html>
|
|
@@ -237,7 +220,7 @@ ${docHead({ title: layout.title, css: STYLES })}
|
|
|
237
220
|
<body class="bf-app">
|
|
238
221
|
<a class="skip" href="#main">Skip to content</a>
|
|
239
222
|
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
|
240
|
-
<noscript><style>.bf-field{display:none}.bf-tree__tw--toggle{visibility:hidden}.bf-btn{display:none}.browse-quick-open{display:none}th .bf-sort{pointer-events:none;cursor:default}</style></noscript>
|
|
223
|
+
<noscript><style>.bf-field{display:none}.browse-side__filters{display:none}.bf-tree__tw--toggle{visibility:hidden}.bf-btn{display:none}.browse-quick-open{display:none}th .bf-sort{pointer-events:none;cursor:default}</style></noscript>
|
|
241
224
|
${SPRITE}
|
|
242
225
|
<header class="bf-app__header"><div class="bf-toolbar">
|
|
243
226
|
${brandLockup("browse")}
|
|
@@ -253,7 +236,10 @@ ${SPRITE}
|
|
|
253
236
|
<summary class="bf-panel__summary">Files</summary>
|
|
254
237
|
<aside class="bf-panel browse-side">
|
|
255
238
|
<button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
|
|
256
|
-
<
|
|
239
|
+
<div class="browse-side__filters">
|
|
240
|
+
<label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
|
|
241
|
+
<button type="button" class="bf-btn" id="fold-all" title="Fold or expand every folder">fold all</button>
|
|
242
|
+
</div>
|
|
257
243
|
<nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.summary)}</nav>
|
|
258
244
|
<p class="bf-note" id="tree-empty" hidden>No matches.</p>
|
|
259
245
|
</aside>
|
|
@@ -264,7 +250,7 @@ ${layout.main}
|
|
|
264
250
|
</main>
|
|
265
251
|
</div>
|
|
266
252
|
<footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
|
|
267
|
-
${layout.keys && layout.keys.length > 0 ?
|
|
253
|
+
${layout.keys && layout.keys.length > 0 ? keysSheet(layout.keys) : ""}
|
|
268
254
|
<script src="/app.js" defer></script>
|
|
269
255
|
</body>
|
|
270
256
|
</html>
|
|
@@ -380,6 +366,8 @@ export interface FilePage {
|
|
|
380
366
|
git?: GitStatus;
|
|
381
367
|
/** Show the Markdown source instead of the rendered HTML. */
|
|
382
368
|
markdownRaw?: boolean;
|
|
369
|
+
/** Re-emit sanitized raw HTML in rendered Markdown; `false` escapes it as text. */
|
|
370
|
+
markdownHtml?: boolean;
|
|
383
371
|
/** True when this file has uncommitted changes, for the changes action. */
|
|
384
372
|
changed?: boolean;
|
|
385
373
|
/** Changed-path index for the header chip and the tree markers. */
|
|
@@ -406,21 +394,12 @@ function lastSegment(path: string): string {
|
|
|
406
394
|
|
|
407
395
|
/** The shortcuts every page shares, plus whatever the page adds. */
|
|
408
396
|
function pageKeys(...extra: KeyHint[]): KeyHint[] {
|
|
409
|
-
return [
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const base = Math.min(...headings.map((heading) => heading.level));
|
|
416
|
-
const items = headings
|
|
417
|
-
.map((heading) => {
|
|
418
|
-
const depth = Math.min(heading.level - base, 3);
|
|
419
|
-
const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
|
|
420
|
-
return `<li><a class="lvl${depth}" href="#${heading.id}">${escapeHtml(label)}</a></li>`;
|
|
421
|
-
})
|
|
422
|
-
.join("");
|
|
423
|
-
return `<details class="browse-md-outline"><summary>On this page</summary><ul>${items}</ul></details>`;
|
|
397
|
+
return [
|
|
398
|
+
{ keys: "/ ⌘K", label: "Quick open a file" },
|
|
399
|
+
{ keys: "-", label: "Fold or expand every folder" },
|
|
400
|
+
...extra,
|
|
401
|
+
{ keys: "?", label: "This sheet" },
|
|
402
|
+
];
|
|
424
403
|
}
|
|
425
404
|
|
|
426
405
|
/** The card-head controls: Markdown mode, line wrapping, and copy actions. */
|
|
@@ -482,8 +461,10 @@ export function renderFilePage(page: FilePage): string {
|
|
|
482
461
|
let note = "";
|
|
483
462
|
if (page.file.kind === "text" || page.file.kind === "markdown") {
|
|
484
463
|
if (page.file.kind === "markdown" && !page.markdownRaw) {
|
|
485
|
-
const
|
|
486
|
-
|
|
464
|
+
const html = renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel), {
|
|
465
|
+
html: page.markdownHtml !== false,
|
|
466
|
+
});
|
|
467
|
+
content = `<article class="bf-md">${html}</article>`;
|
|
487
468
|
} else {
|
|
488
469
|
content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
|
|
489
470
|
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* that show up in repository READMEs — headings, paragraphs, fenced code,
|
|
6
6
|
* blockquotes, nested and task lists, tables, code spans, emphasis, inline and
|
|
7
7
|
* reference links, and images. Named and numeric character references are
|
|
8
|
-
* decoded as literal text.
|
|
9
|
-
*
|
|
8
|
+
* decoded as literal text. Raw HTML is re-emitted through the allowlist in
|
|
9
|
+
* `raw-html.ts`; `html: false` restores escape-only rendering, where the source
|
|
10
|
+
* is shown as text and never executed.
|
|
10
11
|
*
|
|
11
12
|
* Relative links become `/view/<path>` and relative images become `/raw/<path>`
|
|
12
13
|
* so they resolve from any page URL. The `/view` and `/raw` routes re-vet the
|
|
@@ -15,21 +16,30 @@
|
|
|
15
16
|
* and non-http(s) schemes such as `javascript:` are dropped.
|
|
16
17
|
*
|
|
17
18
|
* Headings get a stable anchor id and are demoted one level so the embedding
|
|
18
|
-
* page keeps the only `h1
|
|
19
|
-
* outline.
|
|
19
|
+
* page keeps the only `h1`.
|
|
20
20
|
*
|
|
21
21
|
* Kept free of the page renderer: it imports only `lib/escape.ts`,
|
|
22
|
-
* `lib/highlight.ts`, and `
|
|
23
|
-
* module cycle.
|
|
22
|
+
* `lib/highlight.ts`, `urls.ts`, and `raw-html.ts`, so `html.ts` can compose it
|
|
23
|
+
* without a module cycle. The block scanner, the inline scanner, and their
|
|
24
|
+
* per-document state stay in one module because they are a single pass; URL
|
|
25
|
+
* policy and raw HTML do not.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
|
-
import { posix } from "node:path";
|
|
27
28
|
import { escapeHtml } from "../../lib/escape.ts";
|
|
28
29
|
import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
closesSuppressed,
|
|
32
|
+
closeHtmlSink,
|
|
33
|
+
closeInlineTags,
|
|
34
|
+
createHtmlSink,
|
|
35
|
+
type HtmlBlockStart,
|
|
36
|
+
type HtmlSink,
|
|
37
|
+
htmlBlockStart,
|
|
38
|
+
sanitizeHtmlBlock,
|
|
39
|
+
sanitizeInlineTag,
|
|
40
|
+
} from "./raw-html.ts";
|
|
41
|
+
import { resolveImage, resolveLink } from "./urls.ts";
|
|
30
42
|
|
|
31
|
-
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
32
|
-
const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
|
|
33
43
|
const HTTP_SCHEME = /^https?:/i;
|
|
34
44
|
/** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
|
|
35
45
|
const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
|
|
@@ -55,41 +65,36 @@ interface LinkDefinition {
|
|
|
55
65
|
title?: string;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
/** A heading the renderer emitted, in document order. */
|
|
59
|
-
export interface MarkdownHeading {
|
|
60
|
-
/** The rendered tag level, already demoted and capped at `h6`. */
|
|
61
|
-
level: number;
|
|
62
|
-
/** The anchor id, unique within the document. */
|
|
63
|
-
id: string;
|
|
64
|
-
/** The heading's raw inline Markdown. */
|
|
65
|
-
text: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
68
|
/**
|
|
69
69
|
* Per-document render state: the resolution base, the reference definitions,
|
|
70
|
-
* and the
|
|
70
|
+
* and the raw-HTML balancing state.
|
|
71
71
|
*/
|
|
72
72
|
interface Context {
|
|
73
73
|
baseDir: string;
|
|
74
74
|
defs: Map<string, LinkDefinition>;
|
|
75
|
-
|
|
75
|
+
/** Emit sanitized raw HTML; `false` escapes it as text. */
|
|
76
|
+
html: boolean;
|
|
77
|
+
sink: HtmlSink;
|
|
76
78
|
slug(text: string): string;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*/
|
|
84
|
-
export function renderMarkdownDocument(source: string, baseDir = ""): { html: string; headings: MarkdownHeading[] } {
|
|
85
|
-
const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
|
|
86
|
-
const context = createContext(baseDir, defs);
|
|
87
|
-
return { html: renderBlocks(body, context), headings: context.headings };
|
|
81
|
+
/** Render options; every field has the behaviour the browse page uses. */
|
|
82
|
+
export interface MarkdownOptions {
|
|
83
|
+
/** Emit sanitized raw HTML (the default) or escape it (`false`). */
|
|
84
|
+
html?: boolean;
|
|
88
85
|
}
|
|
89
86
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Render a Markdown source string to a safe HTML fragment. Headings carry an
|
|
89
|
+
* anchor id and are demoted one level, so the embedding page keeps the only
|
|
90
|
+
* `h1`.
|
|
91
|
+
*/
|
|
92
|
+
export function renderMarkdown(source: string, baseDir = "", options: MarkdownOptions = {}): string {
|
|
93
|
+
const html = options.html ?? true;
|
|
94
|
+
const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/), html);
|
|
95
|
+
const context = createContext(baseDir, defs, html);
|
|
96
|
+
const rendered = renderBlocks(body, context);
|
|
97
|
+
return html ? rendered + closeHtmlSink(context.sink) : rendered;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
/** Turn heading text into a stable anchor id, dropping Markdown punctuation. */
|
|
@@ -105,12 +110,13 @@ function headingSlug(text: string): string {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
/** Build the per-document render state, suffixing repeated heading slugs. */
|
|
108
|
-
function createContext(baseDir: string, defs: Map<string, LinkDefinition
|
|
113
|
+
function createContext(baseDir: string, defs: Map<string, LinkDefinition>, html: boolean): Context {
|
|
109
114
|
const seen = new Map<string, number>();
|
|
110
115
|
return {
|
|
111
116
|
baseDir,
|
|
112
117
|
defs,
|
|
113
|
-
|
|
118
|
+
html,
|
|
119
|
+
sink: createHtmlSink(),
|
|
114
120
|
slug(text: string): string {
|
|
115
121
|
const base = headingSlug(text);
|
|
116
122
|
const count = seen.get(base) ?? 0;
|
|
@@ -132,10 +138,11 @@ function normalizeLabel(label: string): string {
|
|
|
132
138
|
* definition-shaped line inside code stays literal. Definitions are
|
|
133
139
|
* single-line; one inside a blockquote or list item is left in place as text.
|
|
134
140
|
*/
|
|
135
|
-
function collectDefinitions(lines: string[]): { defs: Map<string, LinkDefinition>; body: string[] } {
|
|
141
|
+
function collectDefinitions(lines: string[], html: boolean): { defs: Map<string, LinkDefinition>; body: string[] } {
|
|
136
142
|
const defs = new Map<string, LinkDefinition>();
|
|
137
143
|
const body: string[] = [];
|
|
138
144
|
let fence: Fence | undefined;
|
|
145
|
+
let inHtml: "block" | { suppressed: string } | undefined;
|
|
139
146
|
for (const line of lines) {
|
|
140
147
|
if (fence) {
|
|
141
148
|
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
|
@@ -143,12 +150,31 @@ function collectDefinitions(lines: string[]): { defs: Map<string, LinkDefinition
|
|
|
143
150
|
body.push(line);
|
|
144
151
|
continue;
|
|
145
152
|
}
|
|
153
|
+
if (inHtml) {
|
|
154
|
+
if (inHtml === "block") {
|
|
155
|
+
if (isBlank(line)) inHtml = undefined;
|
|
156
|
+
} else if (closesSuppressed(line, inHtml.suppressed)) {
|
|
157
|
+
inHtml = undefined;
|
|
158
|
+
}
|
|
159
|
+
body.push(line);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
146
162
|
const open = matchFence(line);
|
|
147
163
|
if (open) {
|
|
148
164
|
fence = open;
|
|
149
165
|
body.push(line);
|
|
150
166
|
continue;
|
|
151
167
|
}
|
|
168
|
+
const start = html ? htmlBlockStart(line) : undefined;
|
|
169
|
+
if (start) {
|
|
170
|
+
if (start.kind === "suppressed") {
|
|
171
|
+
inHtml = closesSuppressed(line, start.name) ? undefined : { suppressed: start.name };
|
|
172
|
+
} else {
|
|
173
|
+
inHtml = "block";
|
|
174
|
+
}
|
|
175
|
+
body.push(line);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
152
178
|
const match = DEFINITION.exec(line);
|
|
153
179
|
if (!match) {
|
|
154
180
|
body.push(line);
|
|
@@ -210,7 +236,6 @@ function renderBlocks(lines: string[], ctx: Context): string {
|
|
|
210
236
|
if (heading) {
|
|
211
237
|
const level = Math.min(heading.level + 1, 6);
|
|
212
238
|
const id = ctx.slug(heading.text);
|
|
213
|
-
ctx.headings.push({ level, id, text: heading.text });
|
|
214
239
|
out.push(`<h${level} id="${id}">${renderInline(heading.text, ctx)}</h${level}>`);
|
|
215
240
|
i++;
|
|
216
241
|
continue;
|
|
@@ -245,6 +270,14 @@ function renderBlocks(lines: string[], ctx: Context): string {
|
|
|
245
270
|
continue;
|
|
246
271
|
}
|
|
247
272
|
|
|
273
|
+
const htmlBlock = ctx.html ? htmlBlockStart(line) : undefined;
|
|
274
|
+
if (htmlBlock) {
|
|
275
|
+
const [html, next] = renderHtmlBlock(lines, i, htmlBlock, ctx);
|
|
276
|
+
if (html !== "") out.push(html);
|
|
277
|
+
i = next;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
248
281
|
const [html, next] = renderParagraph(lines, i, ctx);
|
|
249
282
|
out.push(html);
|
|
250
283
|
i = next;
|
|
@@ -253,7 +286,7 @@ function renderBlocks(lines: string[], ctx: Context): string {
|
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
/** True when `lines[i]` opens a block that may interrupt a paragraph. */
|
|
256
|
-
function isBlockStart(lines: string[], i: number): boolean {
|
|
289
|
+
function isBlockStart(lines: string[], i: number, html: boolean): boolean {
|
|
257
290
|
const line = lines[i];
|
|
258
291
|
return (
|
|
259
292
|
matchFence(line) !== undefined ||
|
|
@@ -261,6 +294,7 @@ function isBlockStart(lines: string[], i: number): boolean {
|
|
|
261
294
|
isThematicBreak(line) ||
|
|
262
295
|
isBlockquoteLine(line) ||
|
|
263
296
|
matchListItem(line) !== undefined ||
|
|
297
|
+
(html && htmlBlockStart(line) !== undefined) ||
|
|
264
298
|
(i + 1 < lines.length && isTableStart(line, lines[i + 1]))
|
|
265
299
|
);
|
|
266
300
|
}
|
|
@@ -270,7 +304,7 @@ function renderParagraph(lines: string[], start: number, ctx: Context): [string,
|
|
|
270
304
|
let i = start;
|
|
271
305
|
for (; i < lines.length; i++) {
|
|
272
306
|
if (isBlank(lines[i])) break;
|
|
273
|
-
if (i > start && isBlockStart(lines, i)) break;
|
|
307
|
+
if (i > start && isBlockStart(lines, i, ctx.html)) break;
|
|
274
308
|
collected.push(lines[i]);
|
|
275
309
|
}
|
|
276
310
|
const html = collected
|
|
@@ -285,6 +319,28 @@ function renderParagraph(lines: string[], start: number, ctx: Context): [string,
|
|
|
285
319
|
return [`<p>${html}</p>`, i];
|
|
286
320
|
}
|
|
287
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Render an HTML block. An allowed block runs to the next blank line, matching
|
|
324
|
+
* what a reader expects from GitHub: everything up to the blank line is markup,
|
|
325
|
+
* and Markdown resumes after it. A suppressed element (`<script>`, `<svg>`, …)
|
|
326
|
+
* swallows its body through the matching close tag, crossing blank lines, and
|
|
327
|
+
* emits nothing.
|
|
328
|
+
*/
|
|
329
|
+
function renderHtmlBlock(lines: string[], start: number, block: HtmlBlockStart, ctx: Context): [string, number] {
|
|
330
|
+
if (block.kind === "suppressed") {
|
|
331
|
+
let i = start;
|
|
332
|
+
while (i < lines.length && !closesSuppressed(lines[i], block.name)) i++;
|
|
333
|
+
return ["", Math.min(i + 1, lines.length)];
|
|
334
|
+
}
|
|
335
|
+
const collected: string[] = [];
|
|
336
|
+
let i = start;
|
|
337
|
+
for (; i < lines.length; i++) {
|
|
338
|
+
if (isBlank(lines[i])) break;
|
|
339
|
+
collected.push(lines[i]);
|
|
340
|
+
}
|
|
341
|
+
return [sanitizeHtmlBlock(collected.join("\n"), ctx.baseDir, ctx.sink), i];
|
|
342
|
+
}
|
|
343
|
+
|
|
288
344
|
interface Fence {
|
|
289
345
|
char: string;
|
|
290
346
|
length: number;
|
|
@@ -407,7 +463,10 @@ function renderTable(lines: string[], start: number, ctx: Context): [string, num
|
|
|
407
463
|
rows.push(`<tr>${tds.join("")}</tr>`);
|
|
408
464
|
}
|
|
409
465
|
const table = `<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
|
|
410
|
-
|
|
466
|
+
// The frame is the positioning context for the right-edge fade the client adds
|
|
467
|
+
// when the table scrolls; the scroller clips its own content, so the fade cannot
|
|
468
|
+
// live inside it.
|
|
469
|
+
return [`<div class="md-table-frame"><div class="md-table-wrap">${table}</div></div>`, i];
|
|
411
470
|
}
|
|
412
471
|
|
|
413
472
|
interface ListItem {
|
|
@@ -580,56 +639,6 @@ function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLi
|
|
|
580
639
|
return undefined;
|
|
581
640
|
}
|
|
582
641
|
|
|
583
|
-
interface ResolvedLink {
|
|
584
|
-
href: string;
|
|
585
|
-
external: boolean;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
interface ResolvedImage {
|
|
589
|
-
href: string;
|
|
590
|
-
image: boolean;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function resolveLocalPath(href: string, baseDir: string): { rel: string; suffix: string } | undefined {
|
|
594
|
-
const match = /^([^?#]*)([?#].*)?$/.exec(href);
|
|
595
|
-
if (!match) return undefined;
|
|
596
|
-
const rawPath = match[1];
|
|
597
|
-
const suffix = match[2] ?? "";
|
|
598
|
-
let path: string;
|
|
599
|
-
try {
|
|
600
|
-
path = decodeURIComponent(rawPath);
|
|
601
|
-
} catch {
|
|
602
|
-
path = rawPath;
|
|
603
|
-
}
|
|
604
|
-
let rel: string;
|
|
605
|
-
if (path.startsWith("/")) rel = posix.normalize(path).replace(/^\/+/, "");
|
|
606
|
-
else rel = posix.normalize(posix.join(baseDir, path));
|
|
607
|
-
if (rel === ".") rel = "";
|
|
608
|
-
if (rel === ".." || rel.startsWith("../") || rel.startsWith("/")) return undefined;
|
|
609
|
-
return { rel, suffix };
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function resolveLink(dest: string, baseDir: string): ResolvedLink | undefined {
|
|
613
|
-
const value = dest.trim();
|
|
614
|
-
if (value === "") return undefined;
|
|
615
|
-
if (value.startsWith("#")) return { href: value, external: false };
|
|
616
|
-
if (SAFE_LINK_SCHEME.test(value)) return { href: value, external: HTTP_SCHEME.test(value) };
|
|
617
|
-
if (HAS_SCHEME.test(value)) return undefined;
|
|
618
|
-
const local = resolveLocalPath(value, baseDir);
|
|
619
|
-
if (!local) return undefined;
|
|
620
|
-
return { href: `/view/${encodePath(local.rel)}${local.suffix}`, external: false };
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined {
|
|
624
|
-
const value = dest.trim();
|
|
625
|
-
if (value === "") return undefined;
|
|
626
|
-
if (HTTP_SCHEME.test(value)) return { href: value, image: false };
|
|
627
|
-
if (HAS_SCHEME.test(value)) return undefined;
|
|
628
|
-
const local = resolveLocalPath(value, baseDir);
|
|
629
|
-
if (!local) return undefined;
|
|
630
|
-
return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
|
|
631
|
-
}
|
|
632
|
-
|
|
633
642
|
function renderLink(parsed: ParsedLink, ctx: Context): string {
|
|
634
643
|
const label = renderInline(parsed.label, ctx);
|
|
635
644
|
const target = resolveLink(parsed.dest, ctx.baseDir);
|
|
@@ -757,6 +766,7 @@ function matchEntity(text: string, start: number): Entity | undefined {
|
|
|
757
766
|
function renderInline(text: string, ctx: Context): string {
|
|
758
767
|
let out = "";
|
|
759
768
|
let i = 0;
|
|
769
|
+
const tags: string[] = [];
|
|
760
770
|
while (i < text.length) {
|
|
761
771
|
const char = text[i];
|
|
762
772
|
|
|
@@ -814,6 +824,14 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
814
824
|
i += match[0].length;
|
|
815
825
|
continue;
|
|
816
826
|
}
|
|
827
|
+
if (ctx.html) {
|
|
828
|
+
const tag = sanitizeInlineTag(text, i, ctx.baseDir, tags);
|
|
829
|
+
if (tag) {
|
|
830
|
+
out += tag.html;
|
|
831
|
+
i = tag.end;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
817
835
|
}
|
|
818
836
|
|
|
819
837
|
// GFM extended autolink: a bare `https://` or `www.` URL in running text.
|
|
@@ -863,5 +881,5 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
863
881
|
out += escapeHtml(char);
|
|
864
882
|
i++;
|
|
865
883
|
}
|
|
866
|
-
return out;
|
|
884
|
+
return out + closeInlineTags(tags);
|
|
867
885
|
}
|