@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.
Files changed (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. 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 `&copy;` 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 += "&lt;";
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 === "<" ? "&lt;" : "&gt;"));
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
- const HTML_SECURITY: Record<string, string> = {
59
- "Content-Type": "text/html; charset=utf-8",
60
- "Content-Security-Policy":
61
- "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; connect-src 'self'",
62
- "X-Content-Type-Options": "nosniff",
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
- async function errorResponse(
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 { status, headers: { ...HTML_SECURITY, ...extra }, body };
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 errorResponse(ctx, 405, "Only GET and HEAD are served.", { Allow: "GET, HEAD" });
319
+ return errorPage(ctx, 405, "Only GET and HEAD are served.", { Allow: "GET, HEAD" });
339
320
  }
340
- const host = headers.host;
341
- if (typeof host === "string" && host && !HOST_PATTERN.test(host)) {
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
- let pathname: string;
346
- let search: URLSearchParams;
347
- try {
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
- return {
358
- status: 200,
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 errorResponse(ctx, 400, "Unknown diff mode.");
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 errorResponse(ctx, 404, "That page does not exist.");
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 jsonResponse(status, { error: message });
388
- return errorResponse(ctx, status, message);
349
+ if (pathname.startsWith("/api/")) return jsonError(status, message);
350
+ return errorPage(ctx, status, message);
389
351
  }
390
352
  }