Public: anyone can read this; the crew can change it.

A markdown file's relative images and links resolve inside the same repository at the same ref #10943

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10925-a-markdown-file-s-relative-images-and-li → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/10925-a-markdown-file-s-relative-images-and-li was merged into main as 1b7df36.

Review

Sign in to review this change.
7 files changed +251 −29
docs/features/markdown-relative.md added +7
@@ -0,0 +1,7 @@
1 # Markdown relative images and links
2
3 In a repository's Markdown file or README card, a relative image or link resolves inside the same repository at the same ref. Code: `resolveRelative` in `src/web/markdown.ts`; the context comes from `readmeRepo` (`src/web/pages/repo.ts`) and `src/web/render/markdown.ts`.
4
5 - `![x](img/a.png)` draws `/<repo>/media/<ref>/<dir>/img/a.png`; `[m](more.md)` opens the file view, `[s](../src/)` the tree. `[![badge](b.svg)](x.md)` is an image inside a link.
6 - A `../` that leaves the repository, encoded dots or slashes, backslashes and control characters stay as plain text.
7 - **Absolute images are not drawn.** `![x](https://…)` shows only its alt text: the page's Content-Security-Policy allows no foreign images, so drawing one would only break. The origins browser has no media route and rewrites nothing.
src/web/markdown.ts +105 −25
@@ -18,7 +18,65 @@ import { languageFromName, type Language } from "./highlight.ts";
1818 import { highlightToHtml } from "./highlight-html.ts";
1919 import { escapeHtml, Html, raw } from "./html.ts";
2020
21 function inline(escaped: string): string {
21 /**
22 * Where a file lives, so its relative addresses can mean something: the
23 * repository's base path (`/owner/name`), the ref as written in a URL, and the
24 * directory the file is in (`""` for the root).
25 */
26 export interface MarkdownRepo {
27 readonly base: string;
28 readonly ref: string;
29 readonly dir: string;
30 }
31
32 const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/i;
33
34 /**
35 * A relative address resolved against the file's directory, inside the
36 * repository only. `undefined` when it leaves the root, is not a plain path,
37 * or is not relative at all (anchors, rooted paths stay as they were).
38 */
39 function resolveRelative(
40 escapedUrl: string,
41 repo: MarkdownRepo,
42 ): { path: string; tail: string; dir: boolean } | "inert" | undefined {
43 if (escapedUrl === "" || escapedUrl.startsWith("#") || escapedUrl.startsWith("/")) return undefined;
44 if (/^[a-z][a-z0-9+.-]*:/i.test(escapedUrl)) return undefined;
45 if (escapedUrl.includes("&") || escapedUrl.includes("\\")) return "inert";
46 const cut = escapedUrl.search(/[?#]/);
47 const pathPart = cut < 0 ? escapedUrl : escapedUrl.slice(0, cut);
48 const tail = cut < 0 ? "" : escapedUrl.slice(cut);
49 if (pathPart === "") return undefined;
50 const parts: string[] = repo.dir === "" ? [] : repo.dir.split("/");
51 const given = pathPart.split("/");
52 for (const [i, raw] of given.entries()) {
53 let seg: string;
54 try {
55 seg = decodeURIComponent(raw);
56 } catch {
57 return "inert";
58 }
59 if (/[\x00-\x1f\x7f\\/]/.test(seg)) return "inert";
60 if (seg === "" || seg === ".") {
61 if (seg === "" && i !== given.length - 1) return "inert";
62 continue;
63 }
64 if (seg === "..") {
65 if (parts.length === 0) return "inert";
66 parts.pop();
67 continue;
68 }
69 parts.push(seg);
70 }
71 const path = parts.join("/");
72 if (path.length > 4096) return "inert";
73 const lastSlash = pathPart.endsWith("/") || pathPart.endsWith("/.") || pathPart.endsWith("/..");
74 return { path, tail, dir: lastSlash || path === "" };
75 }
76
77 const encodePath = (path: string): string => path.split("/").map(encodeURIComponent).join("/");
78
79 function inline(escaped: string, repo?: MarkdownRepo): string {
2280 // Most lines of prose carry none of the marks; each rule only runs on a
2381 // line that has the character it starts with, which changes nothing about
2482 // what the rules produce.
@@ -31,28 +89,49 @@ function inline(escaped: string): string {
3189 .replace(/(^|\W)\*([^*\s][^*]*)\*/g, "$1<em>$2</em>");
3290 }
3391 if (!text.includes("](")) return text;
92 // Images first, so one inside a link's text (`[![badge](b.svg)](x.md)`) is
93 // already an <img> when the link is read.
3494 return (
3595 text
36 // [text](url) — safe schemes only; quotes are already escaped away
37 .replace(
38 /\[([^\]]+)\]\(([^)\s]+)\)/g,
39 (match, text: string, url: string) => {
96 .replace(/(!)\[([^\]]+)\]\(([^)\s]+)\)/g, link)
outdated

A !alt badge matches here as a link whose text is !alt, leaving as literal text. Resolve images first, or allow an image inside the link text, and test it.

97 .replace(/()\[([^\]]+)\]\(([^)\s]+)\)/g, link)
98 );
99 // [text](url) — safe schemes only; quotes are already escaped away
100 function link(match: string, bang: string, text: string, url: string): string {
101 {
102 if (bang === "!" && repo !== undefined) {
103 // An image is only ever one of this repository's own files.
104 const at = resolveRelative(url, repo);
105 if (at !== undefined) {
106 const alt = text.replace(/<\/?(?:code|strong|em)>/g, "");
107 if (at === "inert" || at.dir || !IMAGE.test(at.path)) return alt;
108 return `<img src="${repo.base}/media/${repo.ref}/${encodePath(at.path)}" alt="${alt}" loading="lazy">`;
109 }
110 }
40111 // Protocol-relative "//host" escapes to another origin — reject it,
41112 // and reject anything carrying a scheme other than http(s), which is
42113 // what keeps javascript:, data: and vbscript: out. What is left is a
43114 // relative path (`docs/keeping.md`), a rooted one, or an anchor —
44115 // all of which stay on this origin by construction.
45116 if (url.startsWith("//")) return match;
117 if (repo !== undefined) {
118 const at = resolveRelative(url, repo);
119 if (at === "inert") return `${bang}${text}`;
120 if (at !== undefined) {
121 const route = at.dir ? "tree" : "blob";
122 const target = at.path === "" ? `${repo.base}/tree/${repo.ref}` : `${repo.base}/${route}/${repo.ref}/${encodePath(at.path)}`;
123 return `${bang}<a href="${target}${at.tail}" rel="nofollow noopener">${text}</a>`;
124 }
125 }
46126 // Browsers strip C0 controls before reading a scheme, so
47127 // "\u0001javascript:" is javascript: to them and no scheme to the
48128 // test below — refuse any control character outright.
49129 if (/[\u0000-\u001f\u007f-\u009f]/.test(url)) return match;
50130 const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url);
51131 if (scheme && !/^https?$/i.test(scheme[1]!)) return match;
52 return `<a href="${url}" rel="nofollow noopener">${text}</a>`;
53 },
54 )
55 );
132 return `${bang}<a href="${url}" rel="nofollow noopener">${text}</a>`;
133 }
134 }
56135 }
57136
58137 /**
@@ -93,12 +172,12 @@ function splitRow(line: string): string[] {
93172 }
94173
95174 /** One row's cells, inline-rendered, aligned as the delimiter row asked. */
96 function cells(line: string, align: string[], tag: "th" | "td"): string {
175 function cells(line: string, align: string[], tag: "th" | "td", repo?: MarkdownRepo): string {
97176 return splitRow(line)
98177 .map((cell, i) => {
99178 const a = align[i];
100179 const attr = a === "c" || a === "r" ? ` class="ta-${a}"` : "";
101 return `<${tag}${attr}>${inline(cell)}</${tag}>`;
180 return `<${tag}${attr}>${inline(cell, repo)}</${tag}>`;
102181 })
103182 .join("");
104183 }
@@ -123,27 +202,28 @@ export function markdownMemoStats(): { hits: number; misses: number } {
123202 return { ...counts };
124203 }
125204
126 export function renderMarkdown(source: string): Html {
127 if (source.length > MEMO_MAX_SOURCE) return renderUncached(source);
128 const hit = memo.get(source);
205 export function renderMarkdown(source: string, repo?: MarkdownRepo): Html {
206 if (source.length > MEMO_MAX_SOURCE) return renderUncached(source, repo);
207 const key = repo === undefined ? source : `${repo.base}\0${repo.ref}\0${repo.dir}\0\0${source}`;
208 const hit = memo.get(key);
129209 if (hit !== undefined) {
130210 // Re-insert so the Map's order is recency: the oldest key is the first.
131 memo.delete(source);
132 memo.set(source, hit);
211 memo.delete(key);
212 memo.set(key, hit);
133213 counts.hits++;
134214 return hit;
135215 }
136216 counts.misses++;
137 const made = renderUncached(source);
217 const made = renderUncached(source, repo);
138218 if (memo.size >= MEMO_MAX) {
139219 const oldest = memo.keys().next();
140220 if (!oldest.done) memo.delete(oldest.value);
141221 }
142 memo.set(source, made);
222 memo.set(key, made);
143223 return made;
144224 }
145225
146 function renderUncached(source: string): Html {
226 function renderUncached(source: string, repo?: MarkdownRepo): Html {
147227 worked++;
148228 const text = source.replaceAll("\r\n", "\n");
149229 // Escaping does not move a newline, so the two arrays stay in step: `lines`
@@ -164,7 +244,7 @@ function renderUncached(source: string): Html {
164244
165245 const closeParagraph = () => {
166246 if (paragraph.length > 0) {
167 out.push(`<p>${inline(paragraph.join("\n"))}</p>`);
247 out.push(`<p>${inline(paragraph.join("\n"), repo)}</p>`);
168248 paragraph = [];
169249 }
170250 };
@@ -239,14 +319,14 @@ function renderUncached(source: string): Html {
239319 // there — a lone line with a pipe in it is a sentence, not a table.
240320 if (align) {
241321 if (isTableRow(line)) {
242 out.push(`<tr>${cells(line, align, "td")}</tr>`);
322 out.push(`<tr>${cells(line, align, "td", repo)}</tr>`);
243323 continue;
244324 }
245325 closeTable();
246326 } else if (isTableRow(line) && isDelimiterRow(lines[index + 1] ?? "")) {
247327 closeBlocks();
248328 align = alignments(lines[index + 1]!);
249 out.push(`<table><thead><tr>${cells(line, align, "th")}</tr></thead>`);
329 out.push(`<table><thead><tr>${cells(line, align, "th", repo)}</tr></thead>`);
250330 out.push("<tbody>");
251331 skip = index + 1;
252332 continue;
@@ -256,7 +336,7 @@ function renderUncached(source: string): Html {
256336 if (heading) {
257337 closeBlocks();
258338 const level = heading[1]!.length;
259 out.push(`<h${level}>${inline(heading[2]!)}</h${level}>`);
339 out.push(`<h${level}>${inline(heading[2]!, repo)}</h${level}>`);
260340 continue;
261341 }
262342
@@ -271,7 +351,7 @@ function renderUncached(source: string): Html {
271351 out.push(`<${kind}>`);
272352 list = kind;
273353 }
274 out.push(`<li>${inline((bullet ?? numbered)![1]!)}</li>`);
354 out.push(`<li>${inline((bullet ?? numbered)![1]!, repo)}</li>`);
275355 continue;
276356 }
277357
@@ -283,7 +363,7 @@ function renderUncached(source: string): Html {
283363 out.push("<blockquote>");
284364 quote = true;
285365 }
286 out.push(`<p>${inline(quoted[1]!)}</p>`);
366 out.push(`<p>${inline(quoted[1]!, repo)}</p>`);
287367 continue;
288368 }
289369
src/web/pages/repo.ts +16 −3
@@ -84,7 +84,7 @@ import { creditLine } from "../credit_line.ts";
8484 import { type CodeRows, rowsMarkup } from "../code_rows.ts";
8585 import { gcodeOffThread, rowsOffThread } from "../code_rows_pool.ts";
8686 import { flashTo, fragment, page, redirect } from "../layout.ts";
87 import { renderMarkdown } from "../markdown.ts";
87 import { renderMarkdown, type MarkdownRepo } from "../markdown.ts";
8888 import { overSize } from "../render/archive.ts";
8989 import { csvJs, isCsvPath } from "../render/csv.ts";
9090 import { fontJs } from "../render/font.ts";
@@ -628,6 +628,18 @@ export function clearReadmeCache(): void {
628628 readmeCacheBytes = 0;
629629 }
630630
631 /**
632 * Where a README's relative addresses point: its own repository at its own
633 * ref, read off the blob base the caller already passes. Nothing for the
634 * origins browser, which has no media route.
635 */
636 function readmeRepo(whole: string | undefined, dir: string): MarkdownRepo | undefined {
637 if (whole === undefined) return undefined;
638 const at = whole.lastIndexOf("/blob/");

The /blob/ split and the /origins/ exclusion have no test; a regression here would not fail any test. Cover it through readmeFor.

639 if (at <= 0 || whole.startsWith("/origins/")) return undefined;
640 return { base: whole.slice(0, at), ref: whole.slice(at + 6), dir };
641 }
642
631643 /**
632644 * The README under a directory listing, read through whichever source is
633645 * answering. It is found in the entries we already have rather than by asking
@@ -648,7 +660,7 @@ export async function readmeFor(
648660 );
649661 if (!readme) return html``;
650662 const baseKey = readmeKey(source, readme);
651 const key = baseKey === null || whole === undefined ? baseKey : `${baseKey}:${whole}`;
663 const key = baseKey === null || whole === undefined ? baseKey : `${baseKey}:${whole}:${prefix}`;
652664 const cached = key === null ? undefined : readmeCache.get(key);
653665 if (key !== null && cached) {
654666 readmeCache.delete(key);
@@ -663,7 +675,7 @@ export async function readmeFor(
663675 const cut = whole === undefined ? null : cutAtBlock(full, README_HOME_BYTES);
664676 const text = cut ?? full;
665677 const rendered = /\.(md|markdown)$/i.test(readme.name)
666 ? renderMarkdown(text)
678 ? renderMarkdown(text, readmeRepo(whole, prefix))
667679 : html`<pre>${text}</pre>`;
668680 const more =
669681 cut === null
@@ -1173,6 +1185,7 @@ export function renderBlob(
11731185 media: mediaHref(base, o.ref, o.path),
11741186 download: downloadHref(base, o.ref, o.path),
11751187 pathHref: `${base}/blob/${encoded}/${o.path}`,
1188 repo: { base, ref: encoded },
11761189 all: o.all === true,
11771190 ...(o.table !== undefined ? { table: o.table } : {}),
11781191 ...(o.layer !== undefined ? { layer: o.layer } : {}),
src/web/render/markdown.ts +6 −1
@@ -5,9 +5,14 @@ import { renderMarkdown } from "../markdown.ts";
55 import type { Rendered, RenderInput } from "./renderers.ts";
66
77 export function markdown(input: RenderInput): Rendered {
8 const slash = input.path.lastIndexOf("/");
9 const repo =

The dir derivation from the file path is untested through the file view. Add a renderBlob or renderers test for a nested docs/x.md.

10 input.repo === undefined
11 ? undefined
12 : { ...input.repo, dir: slash < 0 ? "" : input.path.slice(0, slash) };
813 const text = new TextDecoder().decode(input.body);
914 return {
1015 lines: text === "" ? 0 : text.replace(/\n$/, "").split("\n").length,
11 content: html`<div class="card prose">${renderMarkdown(text)}</div>`,
16 content: html`<div class="card prose">${renderMarkdown(text, repo)}</div>`,
1217 };
1318 }
src/web/render/renderers.ts +2
@@ -45,6 +45,8 @@ export interface RenderInput {
4545 readonly download: string;
4646 /** The file page's own address, for a link back to it (`?all=1`). */
4747 readonly pathHref?: string;
48 /** The repository base and ref (as written in a URL), so a relative address resolves. */
49 readonly repo?: { readonly base: string; readonly ref: string };
4850 /** `?all=1`: draw past the default window, up to the renderer's cap. */
4951 readonly all?: boolean;
5052 /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */
src/web/style/content.ts +1
@@ -342,6 +342,7 @@ form.inline { display: inline; margin-left: auto; }
342342 .bar > .menu { flex: 0 1 auto; min-width: 0; max-width: 100%; }
343343 .bar > .menu > summary { max-width: 100%; min-width: 0; }
344344 .bar > .menu > summary .truncate { min-width: 0; }
345 .prose img { max-width: 100%; height: auto; }
345346 .prose pre, .prose table { max-width: 100%; overflow-x: auto; }
346347 .prose table { display: block; }
347348
test/markdown-relative.test.ts added +114
@@ -0,0 +1,114 @@
1 import { describe, expect, test } from "bun:test";
2 import type { RefSet } from "../src/web/pages/refs.ts";
3 import { renderMarkdown } from "../src/web/markdown.ts";
4 import { clearReadmeCache, readmeFor, renderBlob } from "../src/web/pages/repo.ts";
5 import type { RepoScope } from "../src/web/scope.ts";
6
7 const repo = { base: "/ann/app", ref: "main", dir: "docs" };
8 const md = (s: string, r = repo): string => String(renderMarkdown(s, r));
9
10 describe("relative images and links resolve inside the repository", () => {
11 test("an image renders the file at the same ref", () => {
12 expect(md("![logo](img/a.png)")).toContain('<img src="/ann/app/media/main/docs/img/a.png" alt="logo" loading="lazy">');
13 });
14 test("an image from the root directory and a ../ that stays inside", () => {
15 expect(md("![x](../pics/b.svg)")).toContain('src="/ann/app/media/main/pics/b.svg"');
16 expect(md("![x](a.png)", { ...repo, dir: "" })).toContain('src="/ann/app/media/main/a.png"');
17 });
18 test("a ../ out of the repository stays inert", () => {
19 for (const url of ["../../x.png", "../../../etc/passwd", "a/../../../x.png"]) {
20 const out = md(`![alt](${url})`);
21 expect(out).not.toContain("<img");
22 expect(out).not.toContain("href");
23 }
24 const link = md("[go](../../x.md)");
25 expect(link).not.toContain("<a");
26 expect(link).toContain("go");
27 });
28 test("encoded traversal and backslashes are inert", () => {
29 for (const url of ["%2e%2e/%2e%2e/x.png", "..%2f..%2fx.png", "a%5c..%5cx.png", "a%00.png", "%zz.png"]) {
30 expect(md(`![a](${url})`)).not.toContain("<img");
31 }
32 });
33 test("an image that is not an image file is not drawn as one", () => {
34 expect(md("![a](notes.md)")).not.toContain("<img");
35 });
36 test("a link to another .md opens it in the file view", () => {
37 expect(md("[more](keeping.md)")).toContain('href="/ann/app/blob/main/docs/keeping.md"');
38 expect(md("[more](../README.md#top)")).toContain('href="/ann/app/blob/main/README.md#top"');
39 });
40 test("a directory link opens the tree", () => {
41 expect(md("[src](../src/)")).toContain('href="/ann/app/tree/main/src"');
42 });
43 test("the ref is carried as given, already encoded", () => {
44 expect(md("[a](b.md)", { ...repo, ref: "feat%2Fx" })).toContain("/blob/feat%2Fx/docs/b.md");
45 });
46 test("unsafe schemes stay stripped, anchors and absolute links stay", () => {
47 expect(md("[x](javascript:alert(1))")).not.toContain("<a");
48 expect(md("[x](//evil.test/a)")).not.toContain("<a");
49 expect(md("![x](https://evil.test/a.png)")).not.toContain("<img");
50 expect(md("[x](#top)")).toContain('href="#top"');
51 expect(md("[x](https://ok.test/)")).toContain('href="https://ok.test/"');
52 });
53 test("escaping is unchanged", () => {
54 const out = md('![<script>](a.png) [<b>](x.md) "q"');
55 expect(out).not.toContain("<script");
56 expect(out).not.toContain("<b>");
57 });
58 test("without a repository, nothing is rewritten", () => {
59 const out = String(renderMarkdown("[a](b.md) ![i](c.png)"));
60 expect(out).toContain('href="b.md"');
61 expect(out).not.toContain("<img");
62 });
63 test("the memo does not mix two repositories", () => {
64 const a = md("[a](b.md)");
65 const b = md("[a](b.md)", { base: "/bob/app", ref: "main", dir: "" });
66 expect(a).toContain("/ann/app/blob/main/docs/b.md");
67 expect(b).toContain("/bob/app/blob/main/b.md");
68 });
69
70 test("an image inside a link is an image inside a link", () => {
71 const out = md("[![badge](b.svg)](x.md)");
72 expect(out).toContain('<a href="/ann/app/blob/main/docs/x.md" rel="nofollow noopener"><img src="/ann/app/media/main/docs/b.svg" alt="badge" loading="lazy"></a>');
73 expect(out).not.toContain("](");
74 expect(md("[![b](https://ok.test/b.svg)](https://ok.test/)")).not.toContain("](");
75 });
76 });
77
78 describe("the wiring: README card and file view carry the repository", () => {
79 const src = (text: string) =>
80 ({
81 where: { kind: "local", repo: { id: "r1" } },
82 blob: async () => ({ ok: true, value: { kind: "text", body: new TextEncoder().encode(text), size: text.length } }),
83 }) as never;
84 const entry = (oid: string) => ({ mode: "100644", type: "blob", oid, size: 1, name: "README.md" }) as never;
85 const text = "![l](img/a.png) [m](more.md)";
86
87 test("the README card resolves against its blob base, in a subdirectory too", async () => {
88 clearReadmeCache();
89 const root = String(await readmeFor(src(text), "main", [entry("1".repeat(40))], "", "/ann/app/blob/main"));
90 expect(root).toContain('src="/ann/app/media/main/img/a.png"');
91 expect(root).toContain('href="/ann/app/blob/main/more.md"');
92 const sub = String(await readmeFor(src(text), "main", [entry("1".repeat(40))], "docs", "/ann/app/blob/main"));
93 expect(sub).toContain('src="/ann/app/media/main/docs/img/a.png"');
94 });
95 test("the origins browser and a README with no blob base rewrite nothing", async () => {
96 clearReadmeCache();
97 const origins = String(await readmeFor(src(text), "main", [entry("2".repeat(40))], "", "/origins/x/blob/main"));
98 expect(origins).not.toContain("/media/");
99 expect(origins).not.toContain("/blob/main/more.md");
100 clearReadmeCache();
101 const bare = String(await readmeFor(src(text), "main", [entry("3".repeat(40))], ""));
102 expect(bare).not.toContain("/media/");
103 });
104 test("the file view resolves a nested docs/x.md against docs", () => {
105 const out = renderBlob({ base: "/ann/app", kind: "local", caps: { write: false }, facts: { name: "app" } } as unknown as RepoScope, {
106 refs: { branches: ["main"], tags: [] } as RefSet,
107 ref: "main",
108 path: "docs/x.md",
109 blob: { kind: "text" as const, body: new TextEncoder().encode("![l](img/a.png) [m](../README.md)"), size: 30, oid: "0".repeat(40) } as never,
110 }).text;
111 expect(out).toContain('src="/ann/app/media/main/docs/img/a.png"');
112 expect(out).toContain('href="/ann/app/blob/main/README.md"');
113 });
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs