@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -8,7 +8,15 @@
8
8
  * the page-only `browse-*` rules from `styles.ts`.
9
9
  */
10
10
 
11
- import { brandLockup, docHead, type KeyHint, keysSheet, tabTitle, themeToggle } from "../../../lib/web/index.ts";
11
+ import {
12
+ baseAttribute,
13
+ brandLockup,
14
+ docHead,
15
+ type KeyHint,
16
+ keysSheet,
17
+ tabTitle,
18
+ themeToggle,
19
+ } from "../../../lib/web/index.ts";
12
20
  import { escapeHtml } from "../../../lib/escape.ts";
13
21
  import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts";
14
22
  import { type DirEntry, type FileView, type Listing, type TreeNode } from "../files/files.ts";
@@ -58,11 +66,19 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
58
66
  : "";
59
67
  }
60
68
 
61
- function hrefFor(entry: { rel: string; isDir: boolean }): string {
62
- return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
69
+ /**
70
+ * The URL one tree or table entry links to, written under the page's `base`.
71
+ *
72
+ * Exported because the JSON `/api/tree` route answers with the same hrefs, so the rendered
73
+ * tree and the client's refresh cannot disagree about where an entry lives.
74
+ */
75
+ export function hrefFor(entry: { rel: string; isDir: boolean }, base: string): string {
76
+ return entry.isDir ? `${base}/browse/${encodePath(entry.rel)}` : `${base}/view/${encodePath(entry.rel)}`;
63
77
  }
64
78
 
65
79
  interface Layout {
80
+ /** The mount prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
81
+ base: string;
66
82
  title: string;
67
83
  rootLabel: string;
68
84
  tree: TreeNode;
@@ -84,9 +100,9 @@ interface Layout {
84
100
  /** A full two-pane page. */
85
101
  function renderLayout(layout: Layout): string {
86
102
  return `<!doctype html>
87
- <html lang="en">
103
+ <html lang="en"${baseAttribute(layout.base)}>
88
104
  <head>
89
- ${docHead({ title: layout.title, css: BROWSE_CSS })}
105
+ ${docHead({ title: layout.title, css: BROWSE_CSS, base: layout.base })}
90
106
  </head>
91
107
  <body class="bf-app">
92
108
  <a class="skip" href="#main">Skip to content</a>
@@ -97,7 +113,7 @@ ${SPRITE}
97
113
  ${brandLockup("browse")}
98
114
  <span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
99
115
  ${renderGit(layout.git)}
100
- ${renderChanges(layout.summary, layout.live)}
116
+ ${renderChanges(layout.summary, layout.live, layout.base)}
101
117
  <span class="bf-chip bf-chip--accent">read-only</span>
102
118
  ${renderLiveChrome(layout.live)}
103
119
  <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
@@ -112,7 +128,7 @@ ${SPRITE}
112
128
  <label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
113
129
  <button type="button" class="bf-btn" id="fold-all" title="Fold or expand every folder">fold all</button>
114
130
  </div>
115
- <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.summary)}</nav>
131
+ <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.base, layout.summary)}</nav>
116
132
  <p class="bf-note" id="tree-empty" hidden>No matches.</p>
117
133
  </aside>
118
134
  </details>
@@ -123,36 +139,36 @@ ${layout.main}
123
139
  </div>
124
140
  <footer class="bf-app__footer">${layout.live ? renderLiveStatus() : ""}served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
125
141
  ${layout.keys && layout.keys.length > 0 ? keysSheet(layout.keys) : ""}
126
- <script src="/app.js" defer></script>
142
+ <script src="${layout.base}/app.js" defer></script>
127
143
  </body>
128
144
  </html>
129
145
  `;
130
146
  }
131
147
 
132
148
  /** The breadcrumb trail for a root-relative path, up to (not including) the current page. */
133
- export function renderBreadcrumbs(rel: string): string {
149
+ export function renderBreadcrumbs(rel: string, base: string): string {
134
150
  const segments = rel.split("/").filter(Boolean);
135
151
  if (segments.length === 0) return `<span aria-current="page">home</span>`;
136
152
  // The last segment is the current page, which the heading or card title names;
137
153
  // the trail stops at its parent so the name is not shown twice.
138
- const parts = [`<a href="/browse/">home</a>`];
154
+ const parts = [`<a href="${base}/browse/">home</a>`];
139
155
  let accumulated = "";
140
156
  for (const segment of segments.slice(0, -1)) {
141
157
  accumulated = accumulated ? `${accumulated}/${segment}` : segment;
142
- parts.push(`<a href="/browse/${encodePath(accumulated)}">${escapeHtml(segment)}</a>`);
158
+ parts.push(`<a href="${base}/browse/${encodePath(accumulated)}">${escapeHtml(segment)}</a>`);
143
159
  }
144
160
  return parts.join(`<span class="bf-crumbs__sep">/</span>`);
145
161
  }
146
162
 
147
- function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
148
- return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
163
+ function renderTree(node: TreeNode, currentRel: string, base: string, summary?: ChangeSummary): string {
164
+ return `<ul>${renderTreeNode(node, currentRel, base, summary)}</ul>`;
149
165
  }
150
166
 
151
- function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
167
+ function renderTreeNode(node: TreeNode, currentRel: string, base: string, summary?: ChangeSummary): string {
152
168
  const expanded = node.children.length > 0;
153
169
  const isDir = node.isDir;
154
170
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
155
- const href = hrefFor(node);
171
+ const href = hrefFor(node, base);
156
172
  const current = node.rel === currentRel ? ` aria-current="page"` : "";
157
173
  const twisty = isDir
158
174
  ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" tabindex="-1" aria-hidden="true">${expanded ? "▾" : "▸"}</button>`
@@ -160,7 +176,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
160
176
  const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
161
177
  const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
162
178
  const children = expanded
163
- ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
179
+ ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, base, summary)).join("")}</ul>`
164
180
  : "";
165
181
  const label = node.broken
166
182
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
@@ -170,11 +186,17 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
170
186
  return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
171
187
  }
172
188
 
173
- function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
189
+ function renderRow(
190
+ entry: DirEntry,
191
+ maxThumbBytes: number,
192
+ thumbnails: boolean,
193
+ base: string,
194
+ summary?: ChangeSummary,
195
+ ): string {
174
196
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
175
197
  const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
176
198
  const visual = showThumb
177
- ? `<img class="bf-table__thumb" alt="" loading="lazy" decoding="async" src="/raw/${encodePath(entry.rel)}">${iconMarkup(spec, true)}`
199
+ ? `<img class="bf-table__thumb" alt="" loading="lazy" decoding="async" src="${base}/raw/${encodePath(entry.rel)}">${iconMarkup(spec, true)}`
178
200
  : iconMarkup(spec);
179
201
  const name = entry.isDir ? `${escapeHtml(entry.name)}/` : escapeHtml(entry.name);
180
202
  const classes = [entry.name.startsWith(".") ? "browse-dotfile" : "", entry.isSymlink ? "browse-symlink" : ""]
@@ -182,7 +204,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
182
204
  .join(" ");
183
205
  const link = entry.broken
184
206
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span>`
185
- : `<a href="${hrefFor(entry)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
207
+ : `<a href="${hrefFor(entry, base)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
186
208
  const mark = entry.isSymlink
187
209
  ? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
188
210
  : "";
@@ -191,6 +213,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
191
213
  }
192
214
 
193
215
  export interface DirectoryPage {
216
+ base: string;
194
217
  rootLabel: string;
195
218
  rel: string;
196
219
  listing: Listing;
@@ -212,7 +235,7 @@ export function renderDirectoryPage(page: DirectoryPage): string {
212
235
  let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
213
236
  <thead><tr><th scope="col" aria-sort="ascending"><button type="button" class="bf-sort" data-sort="name" title="Sort by name">Name</button></th><th scope="col" class="bf-table__size"><button type="button" class="bf-sort" data-sort="size" title="Sort by size">Size</button></th><th scope="col" class="bf-table__when"><button type="button" class="bf-sort" data-sort="mtime" title="Sort by modified time">Modified</button></th></tr></thead>
214
237
  <tbody>
215
- ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.summary)).join("\n")}
238
+ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.base, page.summary)).join("\n")}
216
239
  </tbody>
217
240
  </table>`;
218
241
  if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
@@ -220,11 +243,12 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
220
243
  body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
221
244
 
222
245
  return renderLayout({
246
+ base: page.base,
223
247
  title: tabTitle("browse", page.rel || folderName),
224
248
  rootLabel: page.rootLabel,
225
249
  tree: page.tree,
226
250
  currentRel: page.rel,
227
- breadcrumbs: renderBreadcrumbs(page.rel),
251
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
228
252
  keys: pageKeys(),
229
253
  main: ` ${heading}\n ${body}`,
230
254
  footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
@@ -235,6 +259,7 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
235
259
  }
236
260
 
237
261
  export interface FilePage {
262
+ base: string;
238
263
  rootLabel: string;
239
264
  rel: string;
240
265
  file: FileView;
@@ -290,12 +315,12 @@ function numberedLines(page: FilePage): boolean {
290
315
  * plus the download the diff head keeps in the same group. */
291
316
  function renderFileActions(page: FilePage, download: string): string {
292
317
  const isMarkdown = page.file.kind === "markdown";
293
- const rawHref = `/raw/${encodePath(page.rel)}`;
294
- const viewHref = `/view/${encodePath(page.rel)}`;
318
+ const rawHref = `${page.base}/raw/${encodePath(page.rel)}`;
319
+ const viewHref = `${page.base}/view/${encodePath(page.rel)}`;
295
320
  const groups: string[] = [];
296
321
  if (page.changed) {
297
322
  groups.push(
298
- `<a class="bf-btn" href="/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
323
+ `<a class="bf-btn" href="${page.base}/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
299
324
  );
300
325
  }
301
326
  if (isMarkdown) {
@@ -336,7 +361,7 @@ export function renderFilePage(page: FilePage): string {
336
361
  // Text is always served as octet-stream, so the response type says nothing
337
362
  // about the file; the language label, size, and date are the useful metadata.
338
363
  const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)}`;
339
- const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
364
+ const download = `<a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a>`;
340
365
  const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(meta)}</span><span class="bf-spacer"></span>${renderFileActions(page, download)}</div>`;
341
366
 
342
367
  const isMarkdown = page.file.kind === "markdown";
@@ -354,6 +379,7 @@ export function renderFilePage(page: FilePage): string {
354
379
  if (page.file.kind === "markdown" && !page.markdownRaw) {
355
380
  const html = renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel), {
356
381
  html: page.markdownHtml !== false,
382
+ base: page.base,
357
383
  });
358
384
  content = `<article class="bf-md">${html}</article>`;
359
385
  } else {
@@ -362,7 +388,7 @@ export function renderFilePage(page: FilePage): string {
362
388
  if (page.file.truncated)
363
389
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
364
390
  } else if (page.file.kind === "image") {
365
- content = `<div class="bf-media"><img src="/raw/${encodePath(page.rel)}" alt="${escapeHtml(name)}"></div>`;
391
+ content = `<div class="bf-media"><img src="${page.base}/raw/${encodePath(page.rel)}" alt="${escapeHtml(name)}"></div>`;
366
392
  } else if (page.file.kind === "tooLarge") {
367
393
  content = `<div class="bf-empty"><p class="bf-empty__title">File is ${escapeHtml(formatSize(page.file.size))}.</p><p class="bf-empty__sub">Larger than the ${escapeHtml(formatSize(page.maxFileBytes))} inline limit.</p>${download}</div>`;
368
394
  } else {
@@ -370,11 +396,12 @@ export function renderFilePage(page: FilePage): string {
370
396
  }
371
397
 
372
398
  return renderLayout({
399
+ base: page.base,
373
400
  title: tabTitle("browse", page.rel),
374
401
  rootLabel: page.rootLabel,
375
402
  tree: page.tree,
376
403
  currentRel: page.rel,
377
- breadcrumbs: renderBreadcrumbs(page.rel),
404
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
378
405
  mainClass: rendered ? "browse-page browse-measure" : "browse-page",
379
406
  keys,
380
407
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
@@ -418,6 +445,7 @@ function renderModeControl(mode: DiffMode, build: (mode: DiffMode) => string): s
418
445
  }
419
446
 
420
447
  export interface DiffPage {
448
+ base: string;
421
449
  rootLabel: string;
422
450
  mode: DiffMode;
423
451
  files: ChangedFile[];
@@ -431,23 +459,24 @@ export interface DiffPage {
431
459
 
432
460
  /** The change overview: `git diff --stat` plus every changed path. */
433
461
  export function renderDiffPage(page: DiffPage): string {
434
- const modeHref = (mode: DiffMode) => (mode === "head" ? "/diff" : `/diff?mode=${mode}`);
462
+ const modeHref = (mode: DiffMode) => (mode === "head" ? `${page.base}/diff` : `${page.base}/diff?mode=${mode}`);
435
463
  const body =
436
464
  page.files.length === 0
437
465
  ? `<div class="bf-empty"><p class="bf-empty__title">No changes ${escapeHtml(modeScope(page.mode))}.</p><p class="bf-empty__sub">The working tree matches this scope.</p></div>`
438
466
  : `<table class="bf-table browse-diff" aria-labelledby="page-title">
439
467
  <thead><tr><th scope="col" class="browse-diff-status">Status</th><th scope="col">Path</th><th scope="col" class="browse-diff-word">Changes</th></tr></thead>
440
468
  <tbody>
441
- ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
469
+ ${page.files.map((file) => renderChangedRow(file, page.mode, page.base)).join("\n")}
442
470
  </tbody>
443
471
  </table>`;
444
472
  const stat = page.stat ? `<pre class="browse-diff-stat">${escapeHtml(page.stat)}</pre>` : "";
445
473
  return renderLayout({
474
+ base: page.base,
446
475
  title: tabTitle("browse", "changes"),
447
476
  rootLabel: page.rootLabel,
448
477
  tree: page.tree,
449
478
  currentRel: "",
450
- breadcrumbs: renderBreadcrumbs(""),
479
+ breadcrumbs: renderBreadcrumbs("", page.base),
451
480
  mainClass: "browse-page",
452
481
  keys: pageKeys(),
453
482
  main: ` <h1 class="bf-title browse-heading" id="page-title">Changes <span class="browse-diff-scope">${escapeHtml(modeScope(page.mode))}</span></h1>\n <div class="browse-diff-toolbar">${renderModeControl(page.mode, modeHref)}</div>\n ${stat}\n ${body}`,
@@ -458,16 +487,17 @@ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
458
487
  });
459
488
  }
460
489
 
461
- function renderChangedRow(file: ChangedFile, mode: DiffMode): string {
490
+ function renderChangedRow(file: ChangedFile, mode: DiffMode, base: string): string {
462
491
  const letter = statusLetter(file);
463
492
  const label = statusWord(letter);
464
493
  const badge = `browse-diff-badge browse-diff-badge--${letter === "?" ? "Q" : letter}`;
465
494
  const orig = file.origRel ? ` <span class="browse-diff-orig">from ${escapeHtml(file.origRel)}</span>` : "";
466
- const href = `/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
495
+ const href = `${base}/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
467
496
  return `<tr data-path="${escapeHtml(file.rel)}"><td><span class="${badge}" aria-label="${escapeHtml(label)}">${escapeHtml(letter)}</span></td><td><span class="bf-table__name">${iconMarkup(languageOf(file.rel))}<a href="${href}">${escapeHtml(file.rel)}</a>${orig}</span></td><td class="browse-diff-word">${escapeHtml(label)}</td></tr>`;
468
497
  }
469
498
 
470
499
  export interface FileDiffPage {
500
+ base: string;
471
501
  rootLabel: string;
472
502
  rel: string;
473
503
  mode: DiffMode;
@@ -483,12 +513,12 @@ export interface FileDiffPage {
483
513
  export function renderFileDiffPage(page: FileDiffPage): string {
484
514
  const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
485
515
  const spec = languageOf(name);
486
- const base = `/diff/${encodePath(page.rel)}`;
516
+ const diffBase = `${page.base}/diff/${encodePath(page.rel)}`;
487
517
  const actions = [
488
- renderModeControl(page.mode, (mode) => (mode === "head" ? base : `${base}?mode=${mode}`)),
489
- `<a class="bf-btn" href="/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
518
+ renderModeControl(page.mode, (mode) => (mode === "head" ? diffBase : `${diffBase}?mode=${mode}`)),
519
+ `<a class="bf-btn" href="${page.base}/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
490
520
  `<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
491
- `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`,
521
+ `<a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a>`,
492
522
  `<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
493
523
  ];
494
524
  // A per-file change summary and hunk count, so the scope is visible before scrolling.
@@ -507,7 +537,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
507
537
  let content: string;
508
538
  let note = "";
509
539
  if (page.diff.binary) {
510
- content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a></div>`;
540
+ content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a></div>`;
511
541
  } else if (page.diff.lines.length === 0) {
512
542
  content = `<div class="bf-empty"><p class="bf-empty__title">No changes for this file.</p><p class="bf-empty__sub">It is unchanged in the selected scope.</p></div>`;
513
543
  } else {
@@ -515,11 +545,12 @@ export function renderFileDiffPage(page: FileDiffPage): string {
515
545
  if (page.diff.truncated) note = `<p class="bf-note">Showing the first ${page.diff.lines.length} diff lines.</p>`;
516
546
  }
517
547
  return renderLayout({
548
+ base: page.base,
518
549
  title: tabTitle("browse", page.rel),
519
550
  rootLabel: page.rootLabel,
520
551
  tree: page.tree,
521
552
  currentRel: page.rel,
522
- breadcrumbs: renderBreadcrumbs(page.rel),
553
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
523
554
  mainClass: "browse-page",
524
555
  keys,
525
556
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
@@ -531,6 +562,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
531
562
  }
532
563
 
533
564
  export interface ErrorPage {
565
+ base: string;
534
566
  rootLabel: string;
535
567
  status: number;
536
568
  message: string;
@@ -543,12 +575,13 @@ export interface ErrorPage {
543
575
  /** Render a status/error page with the same chrome. */
544
576
  export function renderErrorPage(page: ErrorPage): string {
545
577
  return renderLayout({
578
+ base: page.base,
546
579
  title: tabTitle("browse", String(page.status)),
547
580
  rootLabel: page.rootLabel,
548
581
  tree: page.tree,
549
582
  currentRel: "",
550
- breadcrumbs: renderBreadcrumbs(""),
551
- main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="/browse/">back to the served folder</a></p></div>`,
583
+ breadcrumbs: renderBreadcrumbs("", page.base),
584
+ main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="${page.base}/browse/">back to the served folder</a></p></div>`,
552
585
  footerNote: "error",
553
586
  git: page.git,
554
587
  live: page.live,
@@ -166,7 +166,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
166
166
 
167
167
  function checkLiveDir() {
168
168
  if (document.hidden || liveTone !== "success") return;
169
- fetch("/api/dir-rev?path=" + encodeURIComponent(liveDir))
169
+ fetch(pageBase() + "/api/dir-rev?path=" + encodeURIComponent(liveDir))
170
170
  .then(function (response) {
171
171
  if (!response.ok) throw new Error("check failed");
172
172
  return response.json();
@@ -179,7 +179,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
179
179
  }
180
180
 
181
181
  if (window.EventSource) {
182
- var liveStream = new EventSource("/events");
182
+ var liveStream = new EventSource(pageBase() + "/events");
183
183
  liveStream.addEventListener("open", function () {
184
184
  if (liveTone !== "success") setLiveTone("accent", "connecting");
185
185
  });
@@ -39,7 +39,7 @@ import {
39
39
  sanitizeInlineTag,
40
40
  } from "./raw-html.ts";
41
41
  import { emitAnchor, emitImage, emitImageLink } from "./emit.ts";
42
- import { resolveImage, resolveLink } from "./urls.ts";
42
+ import { type LinkBase, resolveImage, resolveLink } from "./urls.ts";
43
43
 
44
44
  /** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
45
45
  const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
@@ -65,12 +65,9 @@ interface LinkDefinition {
65
65
  title?: string;
66
66
  }
67
67
 
68
- /**
69
- * Per-document render state: the resolution base, the reference definitions,
70
- * and the raw-HTML balancing state.
71
- */
68
+ /** Per-document render state: where links resolve, the definitions, and the raw-HTML sink. */
72
69
  interface Context {
73
- baseDir: string;
70
+ link: LinkBase;
74
71
  defs: Map<string, LinkDefinition>;
75
72
  /** Emit sanitized raw HTML; `false` escapes it as text. */
76
73
  html: boolean;
@@ -82,6 +79,8 @@ interface Context {
82
79
  export interface MarkdownOptions {
83
80
  /** Emit sanitized raw HTML (the default) or escape it (`false`). */
84
81
  html?: boolean;
82
+ /** The prefix local links are written under, e.g. `/files`; `""` for the origin's root. */
83
+ base?: string;
85
84
  }
86
85
 
87
86
  /**
@@ -92,7 +91,7 @@ export interface MarkdownOptions {
92
91
  export function renderMarkdown(source: string, baseDir = "", options: MarkdownOptions = {}): string {
93
92
  const html = options.html ?? true;
94
93
  const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/), html);
95
- const context = createContext(baseDir, defs, html);
94
+ const context = createContext({ dir: baseDir, base: options.base ?? "" }, defs, html);
96
95
  const rendered = renderBlocks(body, context);
97
96
  return html ? rendered + closeHtmlSink(context.sink) : rendered;
98
97
  }
@@ -110,10 +109,10 @@ function headingSlug(text: string): string {
110
109
  }
111
110
 
112
111
  /** Build the per-document render state, suffixing repeated heading slugs. */
113
- function createContext(baseDir: string, defs: Map<string, LinkDefinition>, html: boolean): Context {
112
+ function createContext(link: LinkBase, defs: Map<string, LinkDefinition>, html: boolean): Context {
114
113
  const seen = new Map<string, number>();
115
114
  return {
116
- baseDir,
115
+ link,
117
116
  defs,
118
117
  html,
119
118
  sink: createHtmlSink(),
@@ -338,7 +337,7 @@ function renderHtmlBlock(lines: string[], start: number, block: HtmlBlockStart,
338
337
  if (isBlank(lines[i])) break;
339
338
  collected.push(lines[i]);
340
339
  }
341
- return [sanitizeHtmlBlock(collected.join("\n"), ctx.baseDir, ctx.sink), i];
340
+ return [sanitizeHtmlBlock(collected.join("\n"), ctx.link, ctx.sink), i];
342
341
  }
343
342
 
344
343
  interface Fence {
@@ -641,7 +640,7 @@ function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLi
641
640
 
642
641
  function renderLink(parsed: ParsedLink, ctx: Context): string {
643
642
  const label = renderInline(parsed.label, ctx);
644
- const target = resolveLink(parsed.dest, ctx.baseDir);
643
+ const target = resolveLink(parsed.dest, ctx.link);
645
644
  if (!target) return label;
646
645
  // The anchor's shape is `emit.ts`'s, the same one the raw-HTML sanitizer emits: a link
647
646
  // written in Markdown and the same link written as HTML are the same link.
@@ -650,7 +649,7 @@ function renderLink(parsed: ParsedLink, ctx: Context): string {
650
649
 
651
650
  function renderImage(parsed: ParsedLink, ctx: Context): string {
652
651
  const alt = escapeHtml(parsed.label);
653
- const target = resolveImage(parsed.dest, ctx.baseDir);
652
+ const target = resolveImage(parsed.dest, ctx.link);
654
653
  if (!target) return alt;
655
654
  if (!target.image) return emitImageLink(target.href, parsed.label || parsed.dest);
656
655
  return emitImage({ href: target.href, alt, title: parsed.title });
@@ -811,13 +810,13 @@ function renderInline(text: string, ctx: Context): string {
811
810
  const href = match[1];
812
811
  // Resolved rather than pattern-matched: `urls.ts` owns which schemes open in a
813
812
  // new tab, and a `<mailto:>` link is external but does not.
814
- const external = resolveLink(href, ctx.baseDir)?.external ?? false;
813
+ const external = resolveLink(href, ctx.link)?.external ?? false;
815
814
  out += emitAnchor({ href, content: escapeHtml(href), external });
816
815
  i += match[0].length;
817
816
  continue;
818
817
  }
819
818
  if (ctx.html) {
820
- const tag = sanitizeInlineTag(text, i, ctx.baseDir, tags);
819
+ const tag = sanitizeInlineTag(text, i, ctx.link, tags);
821
820
  if (tag) {
822
821
  out += tag.html;
823
822
  i = tag.end;
@@ -122,7 +122,7 @@ export const PALETTE_CLIENT_JS = ` // Quick open: a fuzzy path palette backed by
122
122
 
123
123
  function runSearch() {
124
124
  var query = paletteInput.value.trim();
125
- fetch("/api/files?q=" + encodeURIComponent(query))
125
+ fetch(pageBase() + "/api/files?q=" + encodeURIComponent(query))
126
126
  .then(function (response) {
127
127
  if (!response.ok) throw new Error("search failed");
128
128
  return response.json();
@@ -212,7 +212,7 @@ export const PALETTE_CLIENT_JS = ` // Quick open: a fuzzy path palette backed by
212
212
 
213
213
  function openActive() {
214
214
  var result = paletteResults[paletteActive];
215
- if (result) window.location.assign("/view/" + encodeSegments(result.path));
215
+ if (result) window.location.assign(pageBase() + "/view/" + encodeSegments(result.path));
216
216
  }
217
217
  var quickOpenButton = document.getElementById("quick-open");
218
218
  if (quickOpenButton) quickOpenButton.addEventListener("click", openPalette);
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { decodeCodePoint, escapeHtml } from "../../../lib/escape.ts";
29
29
  import { emitImage, emitImageLink, EXTERNAL_ATTRS } from "./emit.ts";
30
- import { resolveImage, resolveLink } from "./urls.ts";
30
+ import { type LinkBase, resolveImage, resolveLink } from "./urls.ts";
31
31
 
32
32
  /** Elements re-emitted with sanitized attributes. */
33
33
  const BLOCK_ELEMENTS = new Set([
@@ -205,7 +205,7 @@ export function closesSuppressed(line: string, name: string): boolean {
205
205
  }
206
206
 
207
207
  /** Sanitize one HTML block, emitting text and allowlisted tags into `sink`. */
208
- export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSink): string {
208
+ export function sanitizeHtmlBlock(source: string, linkBase: LinkBase, sink: HtmlSink): string {
209
209
  let out = "";
210
210
  let i = 0;
211
211
  while (i < source.length) {
@@ -235,7 +235,7 @@ export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSin
235
235
  out += closeTo(sink.stack, sink.stack.lastIndexOf(tag.name));
236
236
  continue;
237
237
  }
238
- out += tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
238
+ out += tag.name === "img" ? sanitizeImage(tag, linkBase) : sanitizeOpenTag(tag, linkBase);
239
239
  if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) sink.stack.push(tag.name);
240
240
  }
241
241
  return out;
@@ -250,7 +250,7 @@ export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSin
250
250
  export function sanitizeInlineTag(
251
251
  source: string,
252
252
  start: number,
253
- baseDir: string,
253
+ linkBase: LinkBase,
254
254
  stack: string[],
255
255
  ): { html: string; end: number } | undefined {
256
256
  const tag = parseRawTag(source, start);
@@ -260,7 +260,7 @@ export function sanitizeInlineTag(
260
260
  if (SUPPRESSED.has(tag.name)) return { html: "", end: skipSuppressed(source, tag.next, tag) };
261
261
  if (!ALLOWED.has(tag.name) || !PHRASING.has(tag.name)) return undefined;
262
262
  if (tag.end) return { html: closeTo(stack, stack.lastIndexOf(tag.name)), end: tag.next };
263
- const html = tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
263
+ const html = tag.name === "img" ? sanitizeImage(tag, linkBase) : sanitizeOpenTag(tag, linkBase);
264
264
  if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) stack.push(tag.name);
265
265
  return { html, end: tag.next };
266
266
  }
@@ -402,7 +402,7 @@ function skipSuppressed(source: string, from: number, tag: RawTag): number {
402
402
  }
403
403
 
404
404
  /** Rebuild an allowlisted start tag from its parsed name and allowed attributes. */
405
- function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
405
+ function sanitizeOpenTag(tag: RawTag, linkBase: LinkBase): string {
406
406
  const out: string[] = [];
407
407
  const id = attribute(tag, "id");
408
408
  if (id !== undefined && id !== "") out.push(` id="${escapeHtml(id)}"`);
@@ -411,7 +411,7 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
411
411
  switch (tag.name) {
412
412
  case "a": {
413
413
  const href = attribute(tag, "href");
414
- const target = href === undefined ? undefined : resolveLink(href, baseDir);
414
+ const target = href === undefined ? undefined : resolveLink(href, linkBase);
415
415
  if (target !== undefined) {
416
416
  out.push(` href="${escapeHtml(target.href)}"`);
417
417
  if (target.external) out.push(EXTERNAL_ATTRS);
@@ -458,10 +458,10 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
458
458
  * (the page CSP forbids remote images), and an unresolvable one leaves the alt
459
459
  * text, so a broken badge is still readable.
460
460
  */
461
- function sanitizeImage(tag: RawTag, baseDir: string): string {
461
+ function sanitizeImage(tag: RawTag, linkBase: LinkBase): string {
462
462
  const alt = attribute(tag, "alt") ?? "";
463
463
  const src = attribute(tag, "src");
464
- const target = src === undefined ? undefined : resolveImage(src, baseDir);
464
+ const target = src === undefined ? undefined : resolveImage(src, linkBase);
465
465
  if (target === undefined) return alt === "" ? "" : escapeHtml(alt);
466
466
  if (!target.image) return emitImageLink(target.href, alt === "" ? target.href : alt);
467
467
  // The element is `emit.ts`'s, so an `<img>` written as HTML and the same one written as
@@ -189,7 +189,7 @@ export const TREE_CLIENT_JS = ` var tree = document.querySelector("nav.bf-tree")
189
189
  li.dataset.loading = "true";
190
190
  twisty.textContent = "\\u2026";
191
191
  if (link) link.setAttribute("aria-busy", "true");
192
- fetch("/api/tree?path=" + encodeURIComponent(path))
192
+ fetch(pageBase() + "/api/tree?path=" + encodeURIComponent(path))
193
193
  .then(function (response) {
194
194
  if (!response.ok) throw new Error("load failed");
195
195
  return response.json();
@@ -26,6 +26,20 @@ export interface ResolvedLink {
26
26
  external: boolean;
27
27
  }
28
28
 
29
+ /**
30
+ * Where a destination resolves from, and what it is written under.
31
+ *
32
+ * `dir` is the root-relative directory the destination is relative to; `base` is the
33
+ * prefix the page answers under, so a page served at `/files` writes
34
+ * `/files/view/<path>` and one holding the origin's root writes `/view/<path>`. The two
35
+ * are one value because a caller that knows one almost always knows the other, and a
36
+ * second parameter is a second thing to thread through ten functions.
37
+ */
38
+ export interface LinkBase {
39
+ dir: string;
40
+ base: string;
41
+ }
42
+
29
43
  /** An image destination the page may emit; `image: false` means link to it instead. */
30
44
  export interface ResolvedImage {
31
45
  href: string;
@@ -53,15 +67,15 @@ function resolveLocalPath(href: string, baseDir: string): { rel: string; suffix:
53
67
  }
54
68
 
55
69
  /** Resolve a link destination, or `undefined` when the scheme or path is not allowed. */
56
- export function resolveLink(dest: string, baseDir: string): ResolvedLink | undefined {
70
+ export function resolveLink(dest: string, linkBase: LinkBase): ResolvedLink | undefined {
57
71
  const value = dest.trim();
58
72
  if (value === "") return undefined;
59
73
  if (value.startsWith("#")) return { href: value, external: false };
60
74
  if (SAFE_LINK_SCHEME.test(value)) return { href: value, external: HTTP_SCHEME.test(value) };
61
75
  if (HAS_SCHEME.test(value)) return undefined;
62
- const local = resolveLocalPath(value, baseDir);
76
+ const local = resolveLocalPath(value, linkBase.dir);
63
77
  if (!local) return undefined;
64
- return { href: `/view/${encodePath(local.rel)}${local.suffix}`, external: false };
78
+ return { href: `${linkBase.base}/view/${encodePath(local.rel)}${local.suffix}`, external: false };
65
79
  }
66
80
 
67
81
  /**
@@ -69,12 +83,12 @@ export function resolveLink(dest: string, baseDir: string): ResolvedLink | undef
69
83
  * URL resolves with `image: false` because the page CSP forbids remote images, so
70
84
  * the caller renders a link instead of an `<img>`.
71
85
  */
72
- export function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined {
86
+ export function resolveImage(dest: string, linkBase: LinkBase): ResolvedImage | undefined {
73
87
  const value = dest.trim();
74
88
  if (value === "") return undefined;
75
89
  if (HTTP_SCHEME.test(value)) return { href: value, image: false };
76
90
  if (HAS_SCHEME.test(value)) return undefined;
77
- const local = resolveLocalPath(value, baseDir);
91
+ const local = resolveLocalPath(value, linkBase.dir);
78
92
  if (!local) return undefined;
79
- return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
93
+ return { href: `${linkBase.base}/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
80
94
  }