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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10925-a-markdown-file-s-relative-images-and-li → main 3 commits fork 3 threads · 3 unresolved
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 | - `` draws `/<repo>/media/<ref>/<dir>/img/a.png`; `[m](more.md)` opens the file view, `[s](../src/)` the tree. `[](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.** `` 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"; | ||
| 18 | 18 | import { highlightToHtml } from "./highlight-html.ts"; |
| 19 | 19 | import { escapeHtml, Html, raw } from "./html.ts"; |
| 20 | 20 | |
| 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 { | |
| 22 | 80 | // Most lines of prose carry none of the marks; each rule only runs on a |
| 23 | 81 | // line that has the character it starts with, which changes nothing about |
| 24 | 82 | // what the rules produce. |
| @@ -31,28 +89,49 @@ function inline(escaped: string): string { | ||
| 31 | 89 | .replace(/(^|\W)\*([^*\s][^*]*)\*/g, "$1<em>$2</em>"); |
| 32 | 90 | } |
| 33 | 91 | if (!text.includes("](")) return text; |
| 92 | // Images first, so one inside a link's text (`[](x.md)`) is | |
| 93 | // already an <img> when the link is read. | |
| 34 | 94 | return ( |
| 35 | 95 | 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) | |
| 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 | } | |
| 40 | 111 | // Protocol-relative "//host" escapes to another origin — reject it, |
| 41 | 112 | // and reject anything carrying a scheme other than http(s), which is |
| 42 | 113 | // what keeps javascript:, data: and vbscript: out. What is left is a |
| 43 | 114 | // relative path (`docs/keeping.md`), a rooted one, or an anchor — |
| 44 | 115 | // all of which stay on this origin by construction. |
| 45 | 116 | 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 | } | |
| 46 | 126 | // Browsers strip C0 controls before reading a scheme, so |
| 47 | 127 | // "\u0001javascript:" is javascript: to them and no scheme to the |
| 48 | 128 | // test below — refuse any control character outright. |
| 49 | 129 | if (/[\u0000-\u001f\u007f-\u009f]/.test(url)) return match; |
| 50 | 130 | const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url); |
| 51 | 131 | 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 | } | |
| 56 | 135 | } |
| 57 | 136 | |
| 58 | 137 | /** |
| @@ -93,12 +172,12 @@ function splitRow(line: string): string[] { | ||
| 93 | 172 | } |
| 94 | 173 | |
| 95 | 174 | /** 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 { | |
| 97 | 176 | return splitRow(line) |
| 98 | 177 | .map((cell, i) => { |
| 99 | 178 | const a = align[i]; |
| 100 | 179 | const attr = a === "c" || a === "r" ? ` class="ta-${a}"` : ""; |
| 101 | return `<${tag}${attr}>${inline(cell)}</${tag}>`; | |
| 180 | return `<${tag}${attr}>${inline(cell, repo)}</${tag}>`; | |
| 102 | 181 | }) |
| 103 | 182 | .join(""); |
| 104 | 183 | } |
| @@ -123,27 +202,28 @@ export function markdownMemoStats(): { hits: number; misses: number } { | ||
| 123 | 202 | return { ...counts }; |
| 124 | 203 | } |
| 125 | 204 | |
| 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); | |
| 129 | 209 | if (hit !== undefined) { |
| 130 | 210 | // 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); | |
| 133 | 213 | counts.hits++; |
| 134 | 214 | return hit; |
| 135 | 215 | } |
| 136 | 216 | counts.misses++; |
| 137 | const made = renderUncached(source); | |
| 217 | const made = renderUncached(source, repo); | |
| 138 | 218 | if (memo.size >= MEMO_MAX) { |
| 139 | 219 | const oldest = memo.keys().next(); |
| 140 | 220 | if (!oldest.done) memo.delete(oldest.value); |
| 141 | 221 | } |
| 142 | memo.set(source, made); | |
| 222 | memo.set(key, made); | |
| 143 | 223 | return made; |
| 144 | 224 | } |
| 145 | 225 | |
| 146 | function renderUncached(source: string): Html { | |
| 226 | function renderUncached(source: string, repo?: MarkdownRepo): Html { | |
| 147 | 227 | worked++; |
| 148 | 228 | const text = source.replaceAll("\r\n", "\n"); |
| 149 | 229 | // Escaping does not move a newline, so the two arrays stay in step: `lines` |
| @@ -164,7 +244,7 @@ function renderUncached(source: string): Html { | ||
| 164 | 244 | |
| 165 | 245 | const closeParagraph = () => { |
| 166 | 246 | if (paragraph.length > 0) { |
| 167 | out.push(`<p>${inline(paragraph.join("\n"))}</p>`); | |
| 247 | out.push(`<p>${inline(paragraph.join("\n"), repo)}</p>`); | |
| 168 | 248 | paragraph = []; |
| 169 | 249 | } |
| 170 | 250 | }; |
| @@ -239,14 +319,14 @@ function renderUncached(source: string): Html { | ||
| 239 | 319 | // there — a lone line with a pipe in it is a sentence, not a table. |
| 240 | 320 | if (align) { |
| 241 | 321 | if (isTableRow(line)) { |
| 242 | out.push(`<tr>${cells(line, align, "td")}</tr>`); | |
| 322 | out.push(`<tr>${cells(line, align, "td", repo)}</tr>`); | |
| 243 | 323 | continue; |
| 244 | 324 | } |
| 245 | 325 | closeTable(); |
| 246 | 326 | } else if (isTableRow(line) && isDelimiterRow(lines[index + 1] ?? "")) { |
| 247 | 327 | closeBlocks(); |
| 248 | 328 | 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>`); | |
| 250 | 330 | out.push("<tbody>"); |
| 251 | 331 | skip = index + 1; |
| 252 | 332 | continue; |
| @@ -256,7 +336,7 @@ function renderUncached(source: string): Html { | ||
| 256 | 336 | if (heading) { |
| 257 | 337 | closeBlocks(); |
| 258 | 338 | 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}>`); | |
| 260 | 340 | continue; |
| 261 | 341 | } |
| 262 | 342 | |
| @@ -271,7 +351,7 @@ function renderUncached(source: string): Html { | ||
| 271 | 351 | out.push(`<${kind}>`); |
| 272 | 352 | list = kind; |
| 273 | 353 | } |
| 274 | out.push(`<li>${inline((bullet ?? numbered)![1]!)}</li>`); | |
| 354 | out.push(`<li>${inline((bullet ?? numbered)![1]!, repo)}</li>`); | |
| 275 | 355 | continue; |
| 276 | 356 | } |
| 277 | 357 | |
| @@ -283,7 +363,7 @@ function renderUncached(source: string): Html { | ||
| 283 | 363 | out.push("<blockquote>"); |
| 284 | 364 | quote = true; |
| 285 | 365 | } |
| 286 | out.push(`<p>${inline(quoted[1]!)}</p>`); | |
| 366 | out.push(`<p>${inline(quoted[1]!, repo)}</p>`); | |
| 287 | 367 | continue; |
| 288 | 368 | } |
| 289 | 369 |
src/web/pages/repo.ts +16 −3
| @@ -84,7 +84,7 @@ import { creditLine } from "../credit_line.ts"; | ||
| 84 | 84 | import { type CodeRows, rowsMarkup } from "../code_rows.ts"; |
| 85 | 85 | import { gcodeOffThread, rowsOffThread } from "../code_rows_pool.ts"; |
| 86 | 86 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 87 | import { renderMarkdown } from "../markdown.ts"; | |
| 87 | import { renderMarkdown, type MarkdownRepo } from "../markdown.ts"; | |
| 88 | 88 | import { overSize } from "../render/archive.ts"; |
| 89 | 89 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 90 | 90 | import { fontJs } from "../render/font.ts"; |
| @@ -628,6 +628,18 @@ export function clearReadmeCache(): void { | ||
| 628 | 628 | readmeCacheBytes = 0; |
| 629 | 629 | } |
| 630 | 630 | |
| 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 |
||
| 639 | if (at <= 0 || whole.startsWith("/origins/")) return undefined; | |
| 640 | return { base: whole.slice(0, at), ref: whole.slice(at + 6), dir }; | |
| 641 | } | |
| 642 | ||
| 631 | 643 | /** |
| 632 | 644 | * The README under a directory listing, read through whichever source is |
| 633 | 645 | * answering. It is found in the entries we already have rather than by asking |
| @@ -648,7 +660,7 @@ export async function readmeFor( | ||
| 648 | 660 | ); |
| 649 | 661 | if (!readme) return html``; |
| 650 | 662 | 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}`; | |
| 652 | 664 | const cached = key === null ? undefined : readmeCache.get(key); |
| 653 | 665 | if (key !== null && cached) { |
| 654 | 666 | readmeCache.delete(key); |
| @@ -663,7 +675,7 @@ export async function readmeFor( | ||
| 663 | 675 | const cut = whole === undefined ? null : cutAtBlock(full, README_HOME_BYTES); |
| 664 | 676 | const text = cut ?? full; |
| 665 | 677 | const rendered = /\.(md|markdown)$/i.test(readme.name) |
| 666 | ? renderMarkdown(text) | |
| 678 | ? renderMarkdown(text, readmeRepo(whole, prefix)) | |
| 667 | 679 | : html`<pre>${text}</pre>`; |
| 668 | 680 | const more = |
| 669 | 681 | cut === null |
| @@ -1173,6 +1185,7 @@ export function renderBlob( | ||
| 1173 | 1185 | media: mediaHref(base, o.ref, o.path), |
| 1174 | 1186 | download: downloadHref(base, o.ref, o.path), |
| 1175 | 1187 | pathHref: `${base}/blob/${encoded}/${o.path}`, |
| 1188 | repo: { base, ref: encoded }, | |
| 1176 | 1189 | all: o.all === true, |
| 1177 | 1190 | ...(o.table !== undefined ? { table: o.table } : {}), |
| 1178 | 1191 | ...(o.layer !== undefined ? { layer: o.layer } : {}), |
src/web/render/markdown.ts +6 −1
| @@ -5,9 +5,14 @@ import { renderMarkdown } from "../markdown.ts"; | ||
| 5 | 5 | import type { Rendered, RenderInput } from "./renderers.ts"; |
| 6 | 6 | |
| 7 | 7 | export function markdown(input: RenderInput): Rendered { |
| 8 | const slash = input.path.lastIndexOf("/"); | |
| 9 | const repo = | |
The |
||
| 10 | input.repo === undefined | |
| 11 | ? undefined | |
| 12 | : { ...input.repo, dir: slash < 0 ? "" : input.path.slice(0, slash) }; | |
| 8 | 13 | const text = new TextDecoder().decode(input.body); |
| 9 | 14 | return { |
| 10 | 15 | 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>`, | |
| 12 | 17 | }; |
| 13 | 18 | } |
src/web/render/renderers.ts +2
| @@ -45,6 +45,8 @@ export interface RenderInput { | ||
| 45 | 45 | readonly download: string; |
| 46 | 46 | /** The file page's own address, for a link back to it (`?all=1`). */ |
| 47 | 47 | 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 }; | |
| 48 | 50 | /** `?all=1`: draw past the default window, up to the renderer's cap. */ |
| 49 | 51 | readonly all?: boolean; |
| 50 | 52 | /** `?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; } | ||
| 342 | 342 | .bar > .menu { flex: 0 1 auto; min-width: 0; max-width: 100%; } |
| 343 | 343 | .bar > .menu > summary { max-width: 100%; min-width: 0; } |
| 344 | 344 | .bar > .menu > summary .truncate { min-width: 0; } |
| 345 | .prose img { max-width: 100%; height: auto; } | |
| 345 | 346 | .prose pre, .prose table { max-width: 100%; overflow-x: auto; } |
| 346 | 347 | .prose table { display: block; } |
| 347 | 348 |
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("")).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("")).toContain('src="/ann/app/media/main/pics/b.svg"'); | |
| 16 | expect(md("", { ...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(``); | |
| 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(``)).not.toContain("<img"); | |
| 31 | } | |
| 32 | }); | |
| 33 | test("an image that is not an image file is not drawn as one", () => { | |
| 34 | expect(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("")).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(' [<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) ")); | |
| 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("[](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("[](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 = " [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(" [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 | }); |
A
!altbadge matches here as a link whose text is!alt, leavingas literal text. Resolve images first, or allow an image inside the link text, and test it.