fix relative markdown refs, so images and and links work correctly

AuthorKonata <konata@posteo.jp>
Date
Commitaf2df22163c908d21d602e6c1d11636eb29bf75d
Parent05c76b4
9 files changed, 219 insertions(+), 24 deletions(-)
Msrc/routes/patches.tsx
@@ -99,6 +99,7 @@ export const patchRoutes = new Elysia()
9999 "patches.created_at",
100100 "patches.updated_at",
101101 "patches.edited_at",
102+ "patches.version",
102103 "users.username as author_username",
103104 "users.avatar_version as author_avatar_version",
104105 ])
Msrc/routes/repos.tsx
@@ -2,14 +2,15 @@ import { readFileSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { fileTypeFromBuffer } from "file-type";
5+import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
56 import {
67 ALLOWED_SIGNERS_PATH,
78 COMMITS_PER_PAGE,
89 REPOS_PER_PAGE,
910 VALID_REPO_NAME_RE,
1011 } from "../constants.ts";
11-import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
1212 import { db } from "../db/index.ts";
13+import { redirect } from "../lib/redirect.ts";
1314 import { requireAdmin, resolveSession } from "../middleware/session.ts";
1415 import { git, repoPath } from "../services/git.ts";
1516 import {
@@ -19,7 +20,6 @@ import {
1920 } from "../services/highlightWorker.ts";
2021 import { renderMarkdown } from "../services/markdown.ts";
2122 import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
22-import { redirect } from "../lib/redirect.ts";
2323 import { html } from "../views/render.tsx";
2424 import { CommitDetail } from "../views/repos/CommitDetail.tsx";
2525 import { CommitLog } from "../views/repos/CommitLog.tsx";
@@ -38,9 +38,18 @@ async function getRepo(name: string, isAdmin: boolean) {
3838 return repo;
3939 }
4040
41-async function mimeForContent(content: Buffer): Promise<string> {
41+async function mimeForContent(
42+ filename: string,
43+ content: Buffer,
44+): Promise<string> {
4245 const result = await fileTypeFromBuffer(content);
4346 if (result) return result.mime;
47+
48+ const typeFromName = Bun.file(filename).type;
49+ if (typeFromName !== "application/octet-stream") {
50+ return typeFromName;
51+ }
52+
4453 return hasBinaryContent(content.subarray(0, 8000))
4554 ? "application/octet-stream"
4655 : "text/plain; charset=utf-8";
@@ -90,8 +99,7 @@ export const repoRoutes = new Elysia()
9099 const user = await resolveSession(cookie.session.value);
91100 const search = query.q?.trim() || undefined;
92101 const page = Math.max(1, query.page ?? 1);
93- const sort =
94- cookie.repo_sort.value === "name" ? "name" : "created";
102+ const sort = cookie.repo_sort.value === "name" ? "name" : "created";
95103
96104 const isAdmin = user?.isAdmin ?? false;
97105
@@ -280,7 +288,11 @@ export const repoRoutes = new Elysia()
280288 const key = resolved
281289 ? `readme:${repo.name}:${resolved}:`
282290 : undefined;
283- readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key);
291+ readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key, {
292+ repo: repo.name,
293+ ref: repo.default_branch,
294+ dir: "",
295+ });
284296 }
285297 }
286298
@@ -361,6 +373,7 @@ export const repoRoutes = new Elysia()
361373 ? renderMarkdown(
362374 readmeBuf.toString("utf-8"),
363375 `readme:${repo.name}:${resolved}:`,
376+ { repo: repo.name, ref: params.ref, dir: "" },
364377 )
365378 : null;
366379 return html(
@@ -403,6 +416,7 @@ export const repoRoutes = new Elysia()
403416 ? renderMarkdown(
404417 readmeBuf.toString("utf-8"),
405418 `readme:${repo.name}:${resolved}:${subpath}`,
419+ { repo: repo.name, ref: params.ref, dir: subpath },
406420 )
407421 : null;
408422 return html(
@@ -460,7 +474,7 @@ export const repoRoutes = new Elysia()
460474 if (!content) return new Response("Not found", { status: 404 });
461475
462476 const filename = path.basename(filePath);
463- const contentType = await mimeForContent(content);
477+ const contentType = await mimeForContent(filename, content);
464478 const total = content.length;
465479
466480 const rangeHeader = request.headers.get("Range");
@@ -535,7 +549,8 @@ export const repoRoutes = new Elysia()
535549 if (!branches.includes(params.ref))
536550 return new Response("Not found", { status: 404 });
537551
538- const message = body.message?.trim() || `Edited ${path.basename(filePath)}`;
552+ const message =
553+ body.message?.trim() || `Edited ${path.basename(filePath)}`;
539554 const content = (body.content ?? "").replaceAll("\r\n", "\n");
540555
541556 const commit = await git.editFile(
Msrc/services/diffHighlight.ts
@@ -177,7 +177,7 @@ export async function parseDiff(
177177 return files;
178178 }
179179
180-function escapeHtml(s: string): string {
180+function _escapeHtml(s: string): string {
181181 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
182182 }
183183
@@ -206,7 +206,9 @@ async function highlightHunk(
206206 // single newline and silently drops the \r from every line except the last.
207207 const contents = hunk.lines.map((l) => l.content);
208208 const trailingCR = contents.map((c) => c.endsWith("\r"));
209- const stripped = contents.map((c, i) => (trailingCR[i] ? c.slice(0, -1) : c));
209+ const stripped = contents.map((c, i) =>
210+ trailingCR[i] ? c.slice(0, -1) : c,
211+ );
210212 const code = stripped.join("\n");
211213 try {
212214 const h = await getHighlighter();
@@ -227,7 +229,7 @@ async function highlightHunk(
227229 })
228230 .join("");
229231 return trailingCR[i]
230- ? html + '<span class="diff-ctrl">^M</span>'
232+ ? `${html}<span class="diff-ctrl">^M</span>`
231233 : html;
232234 });
233235 while (lines.length < hunk.lines.length) lines.push("");
Msrc/services/git.ts
@@ -507,9 +507,7 @@ export const git = {
507507 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
508508 ).trim();
509509 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
510- const tree = (
511- await $`git -C ${p} write-tree`.text()
512- ).trim();
510+ const tree = (await $`git -C ${p} write-tree`.text()).trim();
513511 const parent = (
514512 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
515513 ).trim();
Msrc/services/markdown.ts
@@ -6,12 +6,67 @@ marked.setOptions({ gfm: true });
66
77 const mdCache = new Map<string, string>();
88
9-export function renderMarkdown(md: string, cacheKey?: string): string {
9+export interface MarkdownContext {
10+ repo: string;
11+ ref: string;
12+ /** Directory of the markdown file relative to repo root, e.g. "" or "docs/subdir" */
13+ dir: string;
14+}
15+
16+/**
17+ * Resolves a markdown href to a repo-root-relative path for rewriting.
18+ * Returns null if the href should not be rewritten (protocol-absolute or anchor).
19+ *
20+ * - Protocol-absolute (http://, mailto:, data:, …): returns null
21+ * - Anchor (#section): returns null
22+ * - Root-relative (/subdir/img.png): strips leading slash → "subdir/img.png"
23+ * - Path-relative (./img.png, ../img.png, subdir/img.png): resolved against dir
24+ */
25+export function resolveMarkdownHref(dir: string, href: string): string | null {
26+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) return null; // protocol-absolute
27+ if (href.startsWith("#")) return null; // anchor
28+ if (href.startsWith("/")) return href.slice(1); // root-relative
29+ // Path-relative: resolve against current directory using URL API
30+ const base = new URL(`http://x/${dir ? `${dir}/` : ""}`);
31+ return new URL(href, base).pathname.slice(1); // strip leading /
32+}
33+
34+function makeContextualMarked(ctx: MarkdownContext): Marked {
35+ const m = new Marked({ gfm: true });
36+ m.use({
37+ renderer: {
38+ image({ href, title, text }) {
39+ const resolved = resolveMarkdownHref(ctx.dir, href);
40+ if (resolved !== null) {
41+ href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`;
42+ }
43+ return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ""}>`;
44+ },
45+ link({ href, title, tokens }) {
46+ const resolved = resolveMarkdownHref(ctx.dir, href);
47+ if (resolved !== null) {
48+ href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`;
49+ }
50+ const text = String(this.parser.parseInline(tokens));
51+ return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
52+ },
53+ },
54+ });
55+ return m;
56+}
57+
58+export function renderMarkdown(
59+ md: string,
60+ cacheKey?: string,
61+ ctx?: MarkdownContext,
62+): string {
1063 if (cacheKey) {
1164 const cached = mdCache.get(cacheKey);
1265 if (cached) return cached;
1366 }
14- const raw = marked(md) as string;
67+ const raw = ctx
68+ ? (makeContextualMarked(ctx).parse(md) as string)
69+ : (marked(md) as string);
1570 const result = DOMPurify.sanitize(raw, {
1671 ADD_TAGS: ["details", "summary"],
1772 ADD_ATTR: ["class"],
Msrc/views/patches/PatchDetail.tsx
@@ -451,8 +451,12 @@ export function PatchDetail({
451451 ) : (
452452 <div>
453453 <div class="commit-card">
454- <h2 class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}>
455- {patchMeta.subject ? escapeHtml(patchMeta.subject) : "No commit message"}
454+ <h2
455+ class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}
456+ >
457+ {patchMeta.subject
458+ ? escapeHtml(patchMeta.subject)
459+ : "No commit message"}
456460 </h2>
457461 {patchMeta.body && (
458462 <pre class="commit-card-body">
Msrc/views/repos/CommitDetail.tsx
@@ -49,7 +49,9 @@ export function CommitDetail({
4949
5050 {/* Commit metadata card */}
5151 <div class="commit-card">
52- <h2 class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}>
52+ <h2
53+ class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}
54+ >
5355 {meta.subject || "No commit message"}
5456 </h2>
5557 {meta.body && (
Msrc/views/repos/FileEdit.tsx
@@ -51,7 +51,9 @@ export function FileEdit({
5151 );
5252 })}
5353 </div>
54- <p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p>
54+ <p class="form-hint">
55+ WARNING: Line endings are normalized to LF (\n) on save.
56+ </p>
5557 {error && <p class="form-error">{error}</p>}
5658 <form
5759 method="POST"
@@ -74,17 +76,19 @@ export function FileEdit({
7476 class="file-edit-textarea"
7577 rows="30"
7678 spellcheck="false"
77- autocomplete="off"
7879 autocorrect="off"
7980 autocapitalize="off"
81+ {...{ autocomplete: "off" }}
8082 >
8183 {content}
8284 </textarea>
8385 </div>
8486 <div class="form-card">
85- <p class="form-hint" style="margin-bottom: var(--space-4);">
86- Committing directly to{" "}
87- <strong>{editRef}</strong>
87+ <p
88+ class="form-hint"
89+ style="margin-bottom: var(--space-4);"
90+ >
91+ Committing directly to <strong>{editRef}</strong>
8892 </p>
8993 <div class="form-group">
9094 <label for="message">Commit message</label>
Atests/markdown.test.ts
@@ -0,0 +1,114 @@
1+import { describe, expect, test } from "bun:test";
2+import { resolveMarkdownHref } from "../src/services/markdown.ts";
3+
4+describe("resolveMarkdownHref", () => {
5+ describe("protocol-absolute URLs (returns null)", () => {
6+ test("http", () => {
7+ expect(resolveMarkdownHref("", "http://example.com/img.png")).toBeNull();
8+ expect(resolveMarkdownHref("docs", "http://example.com/img.png")).toBeNull();
9+ });
10+ test("https", () => {
11+ expect(resolveMarkdownHref("", "https://example.com/img.png")).toBeNull();
12+ });
13+ test("ftp", () => {
14+ expect(resolveMarkdownHref("", "ftp://files.example.com/")).toBeNull();
15+ });
16+ test("mailto", () => {
17+ expect(resolveMarkdownHref("", "mailto:foo@bar.com")).toBeNull();
18+ });
19+ test("data", () => {
20+ expect(resolveMarkdownHref("", "data:image/png;base64,abc123")).toBeNull();
21+ });
22+ test("ssh", () => {
23+ expect(resolveMarkdownHref("", "ssh://git@example.com/repo.git")).toBeNull();
24+ });
25+ test("custom protocol", () => {
26+ expect(resolveMarkdownHref("", "myapp://open/something")).toBeNull();
27+ });
28+ });
29+
30+ describe("anchor links (returns null)", () => {
31+ test("simple anchor", () => {
32+ expect(resolveMarkdownHref("", "#section-heading")).toBeNull();
33+ });
34+ test("anchor with any dir", () => {
35+ expect(resolveMarkdownHref("docs/guide", "#toc")).toBeNull();
36+ });
37+ test("bare hash", () => {
38+ expect(resolveMarkdownHref("", "#")).toBeNull();
39+ });
40+ });
41+
42+ describe("root-relative URLs (strips leading /)", () => {
43+ test("single file at root", () => {
44+ expect(resolveMarkdownHref("", "/img.png")).toBe("img.png");
45+ });
46+ test("subdir file — dir is ignored", () => {
47+ expect(resolveMarkdownHref("docs", "/subdir/img.png")).toBe("subdir/img.png");
48+ });
49+ test("deep path — any dir is ignored", () => {
50+ expect(resolveMarkdownHref("x/y/z", "/a/b/c.png")).toBe("a/b/c.png");
51+ });
52+ test("root-relative with no filename (trailing slash)", () => {
53+ expect(resolveMarkdownHref("docs", "/assets/")).toBe("assets/");
54+ });
55+ });
56+
57+ describe("path-relative from root dir (dir = '')", () => {
58+ test("bare filename", () => {
59+ expect(resolveMarkdownHref("", "img.png")).toBe("img.png");
60+ });
61+ test("./filename", () => {
62+ expect(resolveMarkdownHref("", "./img.png")).toBe("img.png");
63+ });
64+ test("subdir/file", () => {
65+ expect(resolveMarkdownHref("", "subdir/img.png")).toBe("subdir/img.png");
66+ });
67+ test("./subdir/file", () => {
68+ expect(resolveMarkdownHref("", "./subdir/img.png")).toBe("subdir/img.png");
69+ });
70+ test("../ from root clamps to root", () => {
71+ // URL API resolves http://x/../img.png → http://x/img.png
72+ expect(resolveMarkdownHref("", "../img.png")).toBe("img.png");
73+ });
74+ });
75+
76+ describe("path-relative from one-level dir (dir = 'docs')", () => {
77+ test("bare filename", () => {
78+ expect(resolveMarkdownHref("docs", "img.png")).toBe("docs/img.png");
79+ });
80+ test("./filename", () => {
81+ expect(resolveMarkdownHref("docs", "./img.png")).toBe("docs/img.png");
82+ });
83+ test("../ traversal to root", () => {
84+ expect(resolveMarkdownHref("docs", "../img.png")).toBe("img.png");
85+ });
86+ test("subdir/file", () => {
87+ expect(resolveMarkdownHref("docs", "subdir/img.png")).toBe("docs/subdir/img.png");
88+ });
89+ test("./subdir/file", () => {
90+ expect(resolveMarkdownHref("docs", "./subdir/img.png")).toBe("docs/subdir/img.png");
91+ });
92+ });
93+
94+ describe("path-relative from nested dir (dir = 'docs/guide')", () => {
95+ test("bare filename", () => {
96+ expect(resolveMarkdownHref("docs/guide", "img.png")).toBe("docs/guide/img.png");
97+ });
98+ test("one level up", () => {
99+ expect(resolveMarkdownHref("docs/guide", "../img.png")).toBe("docs/img.png");
100+ });
101+ test("two levels up", () => {
102+ expect(resolveMarkdownHref("docs/guide", "../../img.png")).toBe("img.png");
103+ });
104+ test("sibling directory", () => {
105+ expect(resolveMarkdownHref("docs/guide", "../assets/img.png")).toBe("docs/assets/img.png");
106+ });
107+ test("./filename", () => {
108+ expect(resolveMarkdownHref("docs/guide", "./img.png")).toBe("docs/guide/img.png");
109+ });
110+ test("deeper subdir", () => {
111+ expect(resolveMarkdownHref("docs/guide", "sub/img.png")).toBe("docs/guide/sub/img.png");
112+ });
113+ });
114+});