@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
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitized raw HTML for the rendered Markdown view.
|
|
3
|
+
*
|
|
4
|
+
* GitHub READMEs lean on a small HTML vocabulary — `<details>` to fold a long
|
|
5
|
+
* section, `<summary>`, `<img>` badges, `<kbd>`, a hand-written `<table>` — and
|
|
6
|
+
* an escape-only renderer shows all of it as source. This module re-emits an
|
|
7
|
+
* allowlist of elements instead, and it never copies source markup into the page:
|
|
8
|
+
* every tag is rebuilt from its parsed name plus an attribute allowlist, and
|
|
9
|
+
* every value is escaped again with `escapeHtml`.
|
|
10
|
+
*
|
|
11
|
+
* Three lists, deliberately explicit:
|
|
12
|
+
* - `BLOCK_ELEMENTS` and `PHRASING` are re-emitted with sanitized attributes.
|
|
13
|
+
* - `SUPPRESSED` elements are dropped with their content, so an inline SVG or a
|
|
14
|
+
* `<style>` block cannot dump its body into the document as text.
|
|
15
|
+
* - anything else is escaped and therefore visible: content is never lost
|
|
16
|
+
* silently, and a construct the renderer does not understand looks like text.
|
|
17
|
+
*
|
|
18
|
+
* `style` and `class` are never emitted. The page allows inline styles, so a
|
|
19
|
+
* `style` attribute could position over the chrome, and `class` could target the
|
|
20
|
+
* page's own `bf-*` selectors. Text nodes keep their `&`: an HTML block's
|
|
21
|
+
* references are decoded by the browser, so `©` in the source still renders
|
|
22
|
+
* as ©, while `<` and `>` are escaped and cannot become markup. Elements are
|
|
23
|
+
* balanced through a shared `HtmlSink` so an unclosed `<div>` cannot swallow the
|
|
24
|
+
* page footer; an entry opened in one block and closed in a later one is exactly
|
|
25
|
+
* how `<details>` wraps Markdown.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { escapeHtml } from "../../lib/escape.ts";
|
|
29
|
+
import { resolveImage, resolveLink } from "./urls.ts";
|
|
30
|
+
|
|
31
|
+
/** Elements re-emitted with sanitized attributes. */
|
|
32
|
+
const BLOCK_ELEMENTS = new Set([
|
|
33
|
+
"details",
|
|
34
|
+
"summary",
|
|
35
|
+
"div",
|
|
36
|
+
"p",
|
|
37
|
+
"h1",
|
|
38
|
+
"h2",
|
|
39
|
+
"h3",
|
|
40
|
+
"h4",
|
|
41
|
+
"h5",
|
|
42
|
+
"h6",
|
|
43
|
+
"ul",
|
|
44
|
+
"ol",
|
|
45
|
+
"li",
|
|
46
|
+
"dl",
|
|
47
|
+
"dt",
|
|
48
|
+
"dd",
|
|
49
|
+
"blockquote",
|
|
50
|
+
"pre",
|
|
51
|
+
"table",
|
|
52
|
+
"thead",
|
|
53
|
+
"tbody",
|
|
54
|
+
"tfoot",
|
|
55
|
+
"tr",
|
|
56
|
+
"th",
|
|
57
|
+
"td",
|
|
58
|
+
"caption",
|
|
59
|
+
"figure",
|
|
60
|
+
"figcaption",
|
|
61
|
+
"section",
|
|
62
|
+
"article",
|
|
63
|
+
"aside",
|
|
64
|
+
"header",
|
|
65
|
+
"footer",
|
|
66
|
+
"nav",
|
|
67
|
+
"hr",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Elements accepted inside a line of Markdown, never as a block start. */
|
|
71
|
+
const PHRASING = new Set([
|
|
72
|
+
"a",
|
|
73
|
+
"abbr",
|
|
74
|
+
"b",
|
|
75
|
+
"br",
|
|
76
|
+
"cite",
|
|
77
|
+
"code",
|
|
78
|
+
"del",
|
|
79
|
+
"em",
|
|
80
|
+
"i",
|
|
81
|
+
"img",
|
|
82
|
+
"ins",
|
|
83
|
+
"kbd",
|
|
84
|
+
"mark",
|
|
85
|
+
"q",
|
|
86
|
+
"s",
|
|
87
|
+
"samp",
|
|
88
|
+
"small",
|
|
89
|
+
"span",
|
|
90
|
+
"strong",
|
|
91
|
+
"sub",
|
|
92
|
+
"sup",
|
|
93
|
+
"time",
|
|
94
|
+
"u",
|
|
95
|
+
"var",
|
|
96
|
+
"wbr",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const ALLOWED = new Set([...BLOCK_ELEMENTS, ...PHRASING]);
|
|
100
|
+
|
|
101
|
+
/** Elements whose whole body is dropped: they are never meaningful as text. */
|
|
102
|
+
const SUPPRESSED = new Set([
|
|
103
|
+
"applet",
|
|
104
|
+
"audio",
|
|
105
|
+
"base",
|
|
106
|
+
"button",
|
|
107
|
+
"canvas",
|
|
108
|
+
"embed",
|
|
109
|
+
"form",
|
|
110
|
+
"frame",
|
|
111
|
+
"frameset",
|
|
112
|
+
"iframe",
|
|
113
|
+
"link",
|
|
114
|
+
"marquee",
|
|
115
|
+
"math",
|
|
116
|
+
"meta",
|
|
117
|
+
"noscript",
|
|
118
|
+
"object",
|
|
119
|
+
"param",
|
|
120
|
+
"picture",
|
|
121
|
+
"portal",
|
|
122
|
+
"script",
|
|
123
|
+
"select",
|
|
124
|
+
"source",
|
|
125
|
+
"style",
|
|
126
|
+
"svg",
|
|
127
|
+
"template",
|
|
128
|
+
"textarea",
|
|
129
|
+
"title",
|
|
130
|
+
"track",
|
|
131
|
+
"video",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const VOID = new Set(["area", "br", "col", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
135
|
+
|
|
136
|
+
const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*/;
|
|
137
|
+
const ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9_.:-]*/;
|
|
138
|
+
const BLOCK_OPEN = /^ {0,3}<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
|
|
139
|
+
const COMMENT_OPEN = /^ {0,3}<!--/;
|
|
140
|
+
const DECLARATION_OPEN = /^ {0,3}<[!?]/;
|
|
141
|
+
/** The five XML references, plus numeric ones, are the only refs decoded in a value. */
|
|
142
|
+
const ATTRIBUTE_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
143
|
+
const ATTRIBUTE_ENTITY = /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]+));/g;
|
|
144
|
+
|
|
145
|
+
/** Open elements, in document order, shared by every HTML block in a document. */
|
|
146
|
+
export interface HtmlSink {
|
|
147
|
+
stack: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Where a line starts an HTML block, and whether that block's body is suppressed. */
|
|
151
|
+
export type HtmlBlockStart = { kind: "html" } | { kind: "suppressed"; name: string };
|
|
152
|
+
|
|
153
|
+
interface RawAttribute {
|
|
154
|
+
name: string;
|
|
155
|
+
value: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface RawTag {
|
|
159
|
+
kind: "tag" | "comment" | "declaration";
|
|
160
|
+
name: string;
|
|
161
|
+
end: boolean;
|
|
162
|
+
selfClosing: boolean;
|
|
163
|
+
attrs: RawAttribute[];
|
|
164
|
+
/** Index just past the construct. */
|
|
165
|
+
next: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The per-document balancing state for HTML blocks. */
|
|
169
|
+
export function createHtmlSink(): HtmlSink {
|
|
170
|
+
return { stack: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Close whatever a document's HTML blocks left open, in reverse order. */
|
|
174
|
+
export function closeHtmlSink(sink: HtmlSink): string {
|
|
175
|
+
return closeTo(sink.stack, 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Close a local inline stack, in reverse order. */
|
|
179
|
+
export function closeInlineTags(stack: string[]): string {
|
|
180
|
+
return closeTo(stack, 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Where `line` opens an HTML block, if it does. */
|
|
184
|
+
export function htmlBlockStart(line: string): HtmlBlockStart | undefined {
|
|
185
|
+
if (COMMENT_OPEN.test(line) || DECLARATION_OPEN.test(line)) return { kind: "html" };
|
|
186
|
+
const match = BLOCK_OPEN.exec(line);
|
|
187
|
+
if (match === null) return undefined;
|
|
188
|
+
const name = match[2].toLowerCase();
|
|
189
|
+
if (SUPPRESSED.has(name)) return { kind: "suppressed", name };
|
|
190
|
+
if (BLOCK_ELEMENTS.has(name)) return { kind: "html" };
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when `line` closes the suppressed element `name`. */
|
|
195
|
+
export function closesSuppressed(line: string, name: string): boolean {
|
|
196
|
+
// `name` came from TAG_NAME, so it cannot carry regex metacharacters.
|
|
197
|
+
return new RegExp(`</${name}\\s*>`, "i").test(line);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Sanitize one HTML block, emitting text and allowlisted tags into `sink`. */
|
|
201
|
+
export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSink): string {
|
|
202
|
+
let out = "";
|
|
203
|
+
let i = 0;
|
|
204
|
+
while (i < source.length) {
|
|
205
|
+
const open = source.indexOf("<", i);
|
|
206
|
+
if (open === -1) {
|
|
207
|
+
out += escapeText(source.slice(i));
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
out += escapeText(source.slice(i, open));
|
|
211
|
+
const tag = parseRawTag(source, open);
|
|
212
|
+
if (tag === undefined) {
|
|
213
|
+
out += "<";
|
|
214
|
+
i = open + 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
i = tag.next;
|
|
218
|
+
if (tag.kind !== "tag") continue;
|
|
219
|
+
if (SUPPRESSED.has(tag.name)) {
|
|
220
|
+
i = skipSuppressed(source, i, tag);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!ALLOWED.has(tag.name)) {
|
|
224
|
+
out += escapeHtml(source.slice(open, tag.next));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (tag.end) {
|
|
228
|
+
out += closeTo(sink.stack, sink.stack.lastIndexOf(tag.name));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out += tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
|
|
232
|
+
if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) sink.stack.push(tag.name);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sanitize one tag inside a line of Markdown. Returns `undefined` when the tag is
|
|
239
|
+
* not HTML this renderer accepts, so the caller falls back to escaping the `<`.
|
|
240
|
+
* `stack` is local to a single inline run, so an unclosed phrasing tag is closed
|
|
241
|
+
* at the end of the line rather than leaking into the next block.
|
|
242
|
+
*/
|
|
243
|
+
export function sanitizeInlineTag(
|
|
244
|
+
source: string,
|
|
245
|
+
start: number,
|
|
246
|
+
baseDir: string,
|
|
247
|
+
stack: string[],
|
|
248
|
+
): { html: string; end: number } | undefined {
|
|
249
|
+
const tag = parseRawTag(source, start);
|
|
250
|
+
if (tag === undefined) return undefined;
|
|
251
|
+
if (tag.kind === "comment") return { html: "", end: tag.next };
|
|
252
|
+
if (tag.kind === "declaration") return undefined;
|
|
253
|
+
if (SUPPRESSED.has(tag.name)) return { html: "", end: skipSuppressed(source, tag.next, tag) };
|
|
254
|
+
if (!ALLOWED.has(tag.name) || !PHRASING.has(tag.name)) return undefined;
|
|
255
|
+
if (tag.end) return { html: closeTo(stack, stack.lastIndexOf(tag.name)), end: tag.next };
|
|
256
|
+
const html = tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
|
|
257
|
+
if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) stack.push(tag.name);
|
|
258
|
+
return { html, end: tag.next };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isSpace(char: string): boolean {
|
|
262
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Escape a text node, keeping `&` so the browser still decodes a source reference. */
|
|
266
|
+
function escapeText(value: string): string {
|
|
267
|
+
return value.replace(/[<>]/g, (char) => (char === "<" ? "<" : ">"));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Match `pattern` at `index`, returning the matched text or `undefined`. */
|
|
271
|
+
function matchAt(pattern: RegExp, source: string, index: number): string | undefined {
|
|
272
|
+
const match = pattern.exec(source.slice(index));
|
|
273
|
+
return match === null ? undefined : match[0];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Parse a tag, comment, or declaration starting at `start`. Anything outside the
|
|
278
|
+
* strict grammar returns `undefined`, which the caller turns back into text, so a
|
|
279
|
+
* malformed tag is never half-interpreted.
|
|
280
|
+
*/
|
|
281
|
+
function parseRawTag(source: string, start: number): RawTag | undefined {
|
|
282
|
+
if (source[start] !== "<") return undefined;
|
|
283
|
+
if (source.startsWith("<!--", start)) {
|
|
284
|
+
const close = source.indexOf("-->", start + 4);
|
|
285
|
+
return {
|
|
286
|
+
kind: "comment",
|
|
287
|
+
name: "",
|
|
288
|
+
end: false,
|
|
289
|
+
selfClosing: false,
|
|
290
|
+
attrs: [],
|
|
291
|
+
next: close === -1 ? source.length : close + 3,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (source[start + 1] === "!" || source[start + 1] === "?") {
|
|
295
|
+
const close = source.indexOf(">", start + 2);
|
|
296
|
+
return {
|
|
297
|
+
kind: "declaration",
|
|
298
|
+
name: "",
|
|
299
|
+
end: false,
|
|
300
|
+
selfClosing: false,
|
|
301
|
+
attrs: [],
|
|
302
|
+
next: close === -1 ? source.length : close + 1,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
let i = start + 1;
|
|
306
|
+
const end = source[i] === "/";
|
|
307
|
+
if (end) i++;
|
|
308
|
+
const name = matchAt(TAG_NAME, source, i);
|
|
309
|
+
if (name === undefined) return undefined;
|
|
310
|
+
i += name.length;
|
|
311
|
+
const attrs: RawAttribute[] = [];
|
|
312
|
+
let selfClosing = false;
|
|
313
|
+
for (;;) {
|
|
314
|
+
while (i < source.length && isSpace(source[i])) i++;
|
|
315
|
+
if (i >= source.length) return undefined;
|
|
316
|
+
if (source[i] === ">") {
|
|
317
|
+
i++;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (source[i] === "/" && source[i + 1] === ">") {
|
|
321
|
+
selfClosing = true;
|
|
322
|
+
i += 2;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
if (end) return undefined;
|
|
326
|
+
const attribute = matchAt(ATTR_NAME, source, i);
|
|
327
|
+
if (attribute === undefined) return undefined;
|
|
328
|
+
i += attribute.length;
|
|
329
|
+
while (i < source.length && isSpace(source[i])) i++;
|
|
330
|
+
let value = "";
|
|
331
|
+
if (source[i] === "=") {
|
|
332
|
+
i++;
|
|
333
|
+
while (i < source.length && isSpace(source[i])) i++;
|
|
334
|
+
const quote = source[i];
|
|
335
|
+
if (quote === '"' || quote === "'") {
|
|
336
|
+
const close = source.indexOf(quote, i + 1);
|
|
337
|
+
if (close === -1) return undefined;
|
|
338
|
+
value = source.slice(i + 1, close);
|
|
339
|
+
i = close + 1;
|
|
340
|
+
} else {
|
|
341
|
+
const from = i;
|
|
342
|
+
while (i < source.length && !isSpace(source[i]) && source[i] !== ">") i++;
|
|
343
|
+
value = source.slice(from, i);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
attrs.push({ name: attribute.toLowerCase(), value });
|
|
347
|
+
}
|
|
348
|
+
return { kind: "tag", name: name.toLowerCase(), end, selfClosing, attrs, next: i };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Decode the five XML references and numeric ones inside an attribute value. */
|
|
352
|
+
function decodeAttribute(value: string): string {
|
|
353
|
+
return value.replace(
|
|
354
|
+
ATTRIBUTE_ENTITY,
|
|
355
|
+
(match, decimal: string | undefined, hex: string | undefined, named: string | undefined) => {
|
|
356
|
+
if (decimal !== undefined) return codePoint(Number(decimal));
|
|
357
|
+
if (hex !== undefined) return codePoint(Number.parseInt(hex, 16));
|
|
358
|
+
if (named !== undefined && Object.hasOwn(ATTRIBUTE_ENTITIES, named)) return ATTRIBUTE_ENTITIES[named];
|
|
359
|
+
return match;
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A code point, or U+FFFD when the reference is a surrogate or out of range. */
|
|
365
|
+
function codePoint(value: number): string {
|
|
366
|
+
if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
|
|
367
|
+
return "\uFFFD";
|
|
368
|
+
return String.fromCodePoint(value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The first value for `name`, decoded, so a duplicate attribute cannot override it. */
|
|
372
|
+
function attribute(tag: RawTag, name: string): string | undefined {
|
|
373
|
+
const found = tag.attrs.find((candidate) => candidate.name === name);
|
|
374
|
+
return found === undefined ? undefined : decodeAttribute(found.value);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function numeric(value: string | undefined): string | undefined {
|
|
378
|
+
return value !== undefined && /^\d{1,5}$/.test(value) ? value : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Close `stack` down to `depth`, in reverse order; a negative depth drops a stray end tag. */
|
|
382
|
+
function closeTo(stack: string[], depth: number): string {
|
|
383
|
+
if (depth < 0) return "";
|
|
384
|
+
let out = "";
|
|
385
|
+
while (stack.length > depth) {
|
|
386
|
+
const open = stack.pop();
|
|
387
|
+
if (open !== undefined) out += `</${open}>`;
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Skip a suppressed element's body. The scan is deliberately simple: the first
|
|
394
|
+
* matching close tag wins, which is enough for `<script>`/`<style>`/`<svg>` bodies
|
|
395
|
+
* and, if the close is missing, drops the rest of the block rather than guessing.
|
|
396
|
+
*/
|
|
397
|
+
function skipSuppressed(source: string, from: number, tag: RawTag): number {
|
|
398
|
+
if (tag.selfClosing) return from;
|
|
399
|
+
const match = new RegExp(`</${tag.name}\\s*>`, "i").exec(source.slice(from));
|
|
400
|
+
if (match === null) return source.length;
|
|
401
|
+
return from + match.index + match[0].length;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Rebuild an allowlisted start tag from its parsed name and allowed attributes. */
|
|
405
|
+
function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
|
|
406
|
+
const out: string[] = [];
|
|
407
|
+
const id = attribute(tag, "id");
|
|
408
|
+
if (id !== undefined && id !== "") out.push(` id="${escapeHtml(id)}"`);
|
|
409
|
+
const title = attribute(tag, "title");
|
|
410
|
+
if (title !== undefined) out.push(` title="${escapeHtml(title)}"`);
|
|
411
|
+
switch (tag.name) {
|
|
412
|
+
case "a": {
|
|
413
|
+
const href = attribute(tag, "href");
|
|
414
|
+
const target = href === undefined ? undefined : resolveLink(href, baseDir);
|
|
415
|
+
if (target !== undefined) {
|
|
416
|
+
out.push(` href="${escapeHtml(target.href)}"`);
|
|
417
|
+
if (target.external) out.push(' target="_blank" rel="noopener noreferrer"');
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "details": {
|
|
422
|
+
if (tag.attrs.some((candidate) => candidate.name === "open")) out.push(" open");
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
case "ol": {
|
|
426
|
+
const start = numeric(attribute(tag, "start"));
|
|
427
|
+
if (start !== undefined) out.push(` start="${start}"`);
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case "li": {
|
|
431
|
+
const value = numeric(attribute(tag, "value"));
|
|
432
|
+
if (value !== undefined) out.push(` value="${value}"`);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "th":
|
|
436
|
+
case "td": {
|
|
437
|
+
const colspan = numeric(attribute(tag, "colspan"));
|
|
438
|
+
if (colspan !== undefined) out.push(` colspan="${colspan}"`);
|
|
439
|
+
const rowspan = numeric(attribute(tag, "rowspan"));
|
|
440
|
+
if (rowspan !== undefined) out.push(` rowspan="${rowspan}"`);
|
|
441
|
+
if (tag.name === "th") {
|
|
442
|
+
const scope = attribute(tag, "scope");
|
|
443
|
+
if (scope !== undefined && /^(?:row|col|rowgroup|colgroup)$/.test(scope)) out.push(` scope="${scope}"`);
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "time": {
|
|
448
|
+
const datetime = attribute(tag, "datetime");
|
|
449
|
+
if (datetime !== undefined) out.push(` datetime="${escapeHtml(datetime)}"`);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return `<${tag.name}${out.join("")}>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Emit an `<img>` from its allowed attributes. An external `src` becomes a link
|
|
458
|
+
* (the page CSP forbids remote images), and an unresolvable one leaves the alt
|
|
459
|
+
* text, so a broken badge is still readable.
|
|
460
|
+
*/
|
|
461
|
+
function sanitizeImage(tag: RawTag, baseDir: string): string {
|
|
462
|
+
const alt = attribute(tag, "alt") ?? "";
|
|
463
|
+
const src = attribute(tag, "src");
|
|
464
|
+
const target = src === undefined ? undefined : resolveImage(src, baseDir);
|
|
465
|
+
if (target === undefined) return alt === "" ? "" : escapeHtml(alt);
|
|
466
|
+
if (!target.image) {
|
|
467
|
+
const label = alt === "" ? target.href : alt;
|
|
468
|
+
return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`;
|
|
469
|
+
}
|
|
470
|
+
const out: string[] = [` class="md-img" src="${escapeHtml(target.href)}" alt="${escapeHtml(alt)}"`];
|
|
471
|
+
const width = numeric(attribute(tag, "width"));
|
|
472
|
+
if (width !== undefined) out.push(` width="${width}"`);
|
|
473
|
+
const height = numeric(attribute(tag, "height"));
|
|
474
|
+
if (height !== undefined) out.push(` height="${height}"`);
|
|
475
|
+
const title = attribute(tag, "title");
|
|
476
|
+
if (title !== undefined) out.push(` title="${escapeHtml(title)}"`);
|
|
477
|
+
return `<img${out.join("")} loading="lazy" decoding="async">`;
|
|
478
|
+
}
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Stats } from "node:fs";
|
|
11
11
|
import { stat } from "node:fs/promises";
|
|
12
|
+
import {
|
|
13
|
+
htmlResponse,
|
|
14
|
+
isLocalHostHeader,
|
|
15
|
+
jsonError,
|
|
16
|
+
jsonResponse,
|
|
17
|
+
pageHeaders,
|
|
18
|
+
parseRequestUrl,
|
|
19
|
+
scriptHeaders,
|
|
20
|
+
type ServeResponse,
|
|
21
|
+
} from "../../lib/http/index.ts";
|
|
12
22
|
import { THEME_JS } from "../../lib/web/index.ts";
|
|
13
23
|
import { CLIENT_JS } from "./client.ts";
|
|
14
24
|
import type { ServeConfig } from "./config.ts";
|
|
@@ -42,28 +52,14 @@ export interface ServeContext {
|
|
|
42
52
|
index?: FileIndexProvider;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
export interface ServeResponse {
|
|
46
|
-
status: number;
|
|
47
|
-
headers: Record<string, string>;
|
|
48
|
-
body?: string | Buffer;
|
|
49
|
-
/** When set, the server streams this file instead of `body`. */
|
|
50
|
-
filePath?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const HOST_PATTERN = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
54
|
-
|
|
55
55
|
/** Most palette rows the client will render for one query. */
|
|
56
56
|
const RESULT_LIMIT = 50;
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"Referrer-Policy": "no-referrer",
|
|
64
|
-
"Cache-Control": "no-store",
|
|
65
|
-
};
|
|
66
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A downloaded or inlined file: no script, no network, and sandboxed, so a served
|
|
60
|
+
* SVG or HTML file cannot execute. Only `/raw` uses it, because it is the only
|
|
61
|
+
* route whose body is someone else's file rather than a page this extension built.
|
|
62
|
+
*/
|
|
67
63
|
const RAW_SECURITY: Record<string, string> = {
|
|
68
64
|
"Content-Security-Policy": "default-src 'none'; script-src 'none'; sandbox",
|
|
69
65
|
"X-Content-Type-Options": "nosniff",
|
|
@@ -71,23 +67,6 @@ const RAW_SECURITY: Record<string, string> = {
|
|
|
71
67
|
"Cache-Control": "no-store",
|
|
72
68
|
};
|
|
73
69
|
|
|
74
|
-
function htmlResponse(status: number, body: string): ServeResponse {
|
|
75
|
-
return { status, headers: { ...HTML_SECURITY }, body };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function jsonResponse(status: number, value: unknown): ServeResponse {
|
|
79
|
-
return {
|
|
80
|
-
status,
|
|
81
|
-
headers: {
|
|
82
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
83
|
-
"X-Content-Type-Options": "nosniff",
|
|
84
|
-
"Referrer-Policy": "no-referrer",
|
|
85
|
-
"Cache-Control": "no-store",
|
|
86
|
-
},
|
|
87
|
-
body: JSON.stringify(value),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
70
|
async function treeFor(ctx: ServeContext, currentRel: string): Promise<TreeNode> {
|
|
92
71
|
return buildTree(ctx.root, currentRel, ctx.config.maxDirEntries);
|
|
93
72
|
}
|
|
@@ -129,7 +108,8 @@ async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
|
|
|
129
108
|
return list ? summaryOf(list) : undefined;
|
|
130
109
|
}
|
|
131
110
|
|
|
132
|
-
|
|
111
|
+
/** The error page: the browse shell with the status, the reason, and a usable tree. */
|
|
112
|
+
async function errorPage(
|
|
133
113
|
ctx: ServeContext,
|
|
134
114
|
status: number,
|
|
135
115
|
message: string,
|
|
@@ -156,7 +136,7 @@ async function errorResponse(
|
|
|
156
136
|
tree,
|
|
157
137
|
git: await gitFor(ctx),
|
|
158
138
|
});
|
|
159
|
-
return
|
|
139
|
+
return htmlResponse(status, body, extra);
|
|
160
140
|
}
|
|
161
141
|
|
|
162
142
|
/** Resolve a request path once and render the directory or file page it names. */
|
|
@@ -212,6 +192,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
|
|
|
212
192
|
maxFileBytes: ctx.config.maxFileBytes,
|
|
213
193
|
git,
|
|
214
194
|
markdownRaw: raw,
|
|
195
|
+
markdownHtml: ctx.config.markdownHtml,
|
|
215
196
|
changed: changes?.files.has(rel),
|
|
216
197
|
summary: changes,
|
|
217
198
|
}),
|
|
@@ -335,43 +316,24 @@ export async function handleRequest(
|
|
|
335
316
|
): Promise<ServeResponse> {
|
|
336
317
|
const upper = method.toUpperCase();
|
|
337
318
|
if (upper !== "GET" && upper !== "HEAD") {
|
|
338
|
-
return
|
|
319
|
+
return errorPage(ctx, 405, "Only GET and HEAD are served.", { Allow: "GET, HEAD" });
|
|
339
320
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return errorResponse(ctx, 403, "Unexpected Host header.");
|
|
321
|
+
if (!isLocalHostHeader(headers, { required: false })) {
|
|
322
|
+
return errorPage(ctx, 403, "Unexpected Host header.");
|
|
343
323
|
}
|
|
344
324
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const url = new URL(rawUrl, "http://127.0.0.1");
|
|
349
|
-
pathname = url.pathname;
|
|
350
|
-
search = url.searchParams;
|
|
351
|
-
} catch {
|
|
352
|
-
return errorResponse(ctx, 400, "Malformed request URL.");
|
|
353
|
-
}
|
|
325
|
+
const parsed = parseRequestUrl(rawUrl);
|
|
326
|
+
if (!parsed) return errorPage(ctx, 400, "Malformed request URL.");
|
|
327
|
+
const { pathname, search } = parsed;
|
|
354
328
|
|
|
355
329
|
try {
|
|
356
|
-
if (pathname === "/app.js") {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
headers: { ...HTML_SECURITY, "Content-Type": "text/javascript; charset=utf-8" },
|
|
360
|
-
body: CLIENT_JS,
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
if (pathname === "/theme.js") {
|
|
364
|
-
return {
|
|
365
|
-
status: 200,
|
|
366
|
-
headers: { ...HTML_SECURITY, "Content-Type": "text/javascript; charset=utf-8" },
|
|
367
|
-
body: THEME_JS,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: { ...HTML_SECURITY }, body: "" };
|
|
330
|
+
if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: CLIENT_JS };
|
|
331
|
+
if (pathname === "/theme.js") return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
332
|
+
if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
|
|
371
333
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
372
334
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|
|
373
335
|
const rawMode = search.get("mode");
|
|
374
|
-
if (rawMode !== null && !isDiffMode(rawMode)) return
|
|
336
|
+
if (rawMode !== null && !isDiffMode(rawMode)) return errorPage(ctx, 400, "Unknown diff mode.");
|
|
375
337
|
const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
|
|
376
338
|
if (pathname === "/diff" || pathname === "/diff/") return await diffOverview(ctx, mode);
|
|
377
339
|
if (pathname.startsWith("/diff/")) return await diffFile(ctx, pathname.slice("/diff/".length), mode);
|
|
@@ -380,11 +342,11 @@ export async function handleRequest(
|
|
|
380
342
|
if (pathname.startsWith("/view/"))
|
|
381
343
|
return await view(ctx, pathname.slice("/view/".length), search.get("raw") === "1");
|
|
382
344
|
if (pathname.startsWith("/raw/")) return await raw(ctx, pathname.slice("/raw/".length));
|
|
383
|
-
return
|
|
345
|
+
return errorPage(ctx, 404, "That page does not exist.");
|
|
384
346
|
} catch (error) {
|
|
385
347
|
const status = error instanceof HttpError ? error.status : 500;
|
|
386
348
|
const message = error instanceof HttpError ? error.message : "Something went wrong reading that path.";
|
|
387
|
-
if (pathname.startsWith("/api/")) return
|
|
388
|
-
return
|
|
349
|
+
if (pathname.startsWith("/api/")) return jsonError(status, message);
|
|
350
|
+
return errorPage(ctx, status, message);
|
|
389
351
|
}
|
|
390
352
|
}
|