@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
@@ -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 { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
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
- /* GitHub's markdown body width: one content column that headings, prose,
160
- tables, code, and images all live in, with wide tables scrolling inside it. */
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-width:var(--browse-measure);margin-inline:auto;padding-inline:0;padding-block:var(--space-4) var(--space-6)}
163
- .browse-measure .browse-md-outline{max-width:var(--browse-measure);margin:0 auto var(--space-3)}
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
- <label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
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 ? renderKeysSheet(layout.keys) : ""}
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 [{ keys: "/ ⌘K", label: "Quick open a file" }, ...extra, { keys: "?", label: "This sheet" }];
410
- }
411
-
412
- /** An "on this page" outline from the headings the Markdown renderer emitted. */
413
- function renderOutline(headings: MarkdownHeading[]): string {
414
- if (headings.length < 4) return "";
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 document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
486
- content = `${renderOutline(document.headings)}<article class="bf-md">${document.html}</article>`;
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. Everything is escaped and only a known set of tags
9
- * is emitted; raw HTML in the source is shown as text, never executed.
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`; `renderMarkdownDocument` returns them for an
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 `paths.ts`, so `html.ts` can compose it without a
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 { encodePath } from "./paths.ts";
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 headings emitted so far.
70
+ * and the raw-HTML balancing state.
71
71
  */
72
72
  interface Context {
73
73
  baseDir: string;
74
74
  defs: Map<string, LinkDefinition>;
75
- headings: MarkdownHeading[];
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
- * Render a Markdown source string to a safe HTML fragment, plus the headings it
81
- * emitted so a caller can build an outline. Headings carry an anchor id and are
82
- * demoted one level, so the embedding page keeps the only `h1`.
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
- /** Render a Markdown source string to a safe HTML fragment. */
91
- export function renderMarkdown(source: string, baseDir = ""): string {
92
- return renderMarkdownDocument(source, baseDir).html;
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>): Context {
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
- headings: [],
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
- return [`<div class="md-table-wrap">${table}</div>`, i];
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
  }