@gavin-hu/my-pi-agent 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* the page-only `browse-*` rules from `styles.ts`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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 =
|
|
294
|
-
const viewHref =
|
|
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" ?
|
|
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 =
|
|
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
|
|
516
|
+
const diffBase = `${page.base}/diff/${encodePath(page.rel)}`;
|
|
487
517
|
const actions = [
|
|
488
|
-
renderModeControl(page.mode, (mode) => (mode === "head" ?
|
|
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
|
-
|
|
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(
|
|
112
|
+
function createContext(link: LinkBase, defs: Map<string, LinkDefinition>, html: boolean): Context {
|
|
114
113
|
const seen = new Map<string, number>();
|
|
115
114
|
return {
|
|
116
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
76
|
+
const local = resolveLocalPath(value, linkBase.dir);
|
|
63
77
|
if (!local) return undefined;
|
|
64
|
-
return { href:
|
|
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,
|
|
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,
|
|
91
|
+
const local = resolveLocalPath(value, linkBase.dir);
|
|
78
92
|
if (!local) return undefined;
|
|
79
|
-
return { href:
|
|
93
|
+
return { href: `${linkBase.base}/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
|
|
80
94
|
}
|