diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index 7f74b9eb9..57a4441fb 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -81,6 +81,18 @@ pub async fn repo_blob( blocking(ctx, move |ctx| ctx.repo_blob(rid, path, sha)).await } +#[tauri::command] +pub async fn repo_blob_bytes( + ctx: tauri::State<'_, AppState>, + rid: RepoId, + path: std::path::PathBuf, + sha: git::Oid, +) -> Result { + let bytes = blocking(ctx, move |ctx| ctx.repo_blob_bytes(rid, path, sha)).await?; + + Ok(tauri::ipc::Response::new(bytes)) +} + #[tauri::command] pub async fn diff_stats( ctx: tauri::State<'_, AppState>, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 9fcaf7e9f..f6040aeab 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -117,6 +117,7 @@ pub fn run() { repo::repo_readme, repo::repo_tree, repo::repo_blob, + repo::repo_blob_bytes, repo::seed, repo::seeded_not_replicated, repo::unseed, diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 3ed0f6115..11cfdc894 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -603,6 +603,31 @@ pub trait Repo: Profile { )) } + /// The bytes of the file at `path`, for files the webview renders itself, + /// like the images in a README. Unlike `repo_blob` it skips the last + /// commit lookup and the text encoding. + fn repo_blob_bytes( + &self, + rid: identity::RepoId, + path: std::path::PathBuf, + sha: git::Oid, + ) -> Result, Error> { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let commit = repo.backend.find_commit(sha.into())?; + let entry = commit.tree()?.get_path(&path)?; + let blob = entry + .to_object(&repo.backend)? + .into_blob() + .map_err(|_| git2::Error::from_str("path does not point to a blob"))?; + + if blob.size() > MAX_BLOB_SIZE { + return Err(Error::FileTooLarge(blob.size())); + } + + Ok(blob.content().to_vec()) + } + fn list_repo_refs(&self, rid: identity::RepoId) -> Result { let profile = self.profile(); let repo = profile.storage.repository(rid)?; @@ -1318,6 +1343,43 @@ mod test { assert_eq!(split("master"), ("master".into(), "".into())); } + #[test] + fn repo_blob_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap(); + let backend = &profile.storage.repository(rid).unwrap().backend; + + let large = backend.blob(&vec![0; super::MAX_BLOB_SIZE + 1]).unwrap(); + let mut tree = backend.treebuilder(None).unwrap(); + tree.insert("large.png", large, 0o100644).unwrap(); + let tree = backend.find_tree(tree.write().unwrap()).unwrap(); + let author = git2::Signature::now("alice", "alice@example.com").unwrap(); + let large_commit = backend + .commit(None, &author, &author, "Add a large file", &tree, &[]) + .unwrap(); + + let state = AppState { profile }; + + assert_eq!( + state + .repo_blob_bytes(rid, "README".into(), head.into()) + .unwrap(), + b"Hello World!\n" + ); + assert!( + state + .repo_blob_bytes(rid, "missing.png".into(), head.into()) + .is_err() + ); + assert!(matches!( + state.repo_blob_bytes(rid, "large.png".into(), large_commit.into()), + Err(crate::error::Error::FileTooLarge(size)) if size == super::MAX_BLOB_SIZE + 1 + )); + } + #[test] fn repo_commit_accepts_an_oid_revision() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index d7469e508..05ed2ab90 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -175,6 +175,7 @@ pub fn router(shared: Shared) -> Router { .route("/repo_readme", post(readme_handler)) .route("/repo_tree", post(tree_handler)) .route("/repo_blob", post(blob_handler)) + .route("/repo_blob_bytes", post(blob_bytes_handler)) .route("/get_diff", post(diff_handler)) .route("/get_diff_text", post(diff_text_handler)) .route("/save_diff_to_disk", post(save_diff_handler)) @@ -529,6 +530,22 @@ async fn blob_handler( Ok::<_, Error>(Json(info)) } +#[derive(Deserialize)] +struct BlobBytesBody { + pub rid: identity::RepoId, + pub path: PathBuf, + pub sha: git::Oid, +} + +async fn blob_bytes_handler( + Ctx(ctx): Ctx, + Json(BlobBytesBody { rid, path, sha }): Json, +) -> impl IntoResponse { + let bytes = ctx.repo_blob_bytes(rid, path, sha)?; + + Ok::<_, Error>(([(CONTENT_TYPE, "application/octet-stream")], bytes)) +} + async fn diff_handler( Ctx(ctx): Ctx, Json(DiffBody { rid, options }): Json, diff --git a/src/components/Asciidoc.svelte b/src/components/Asciidoc.svelte index 065aedb39..dda68987f 100644 --- a/src/components/Asciidoc.svelte +++ b/src/components/Asciidoc.svelte @@ -2,8 +2,13 @@ import { tick } from "svelte"; import type { AsciidocSource } from "@app/lib/asciidoc"; - import { renderAsciidoc, resolveRepoPath } from "@app/lib/asciidoc"; + import { renderAsciidoc } from "@app/lib/asciidoc"; import { enhanceCodeBlocks } from "@app/lib/codeBlocks"; + import { + enhanceRepoImages, + RepoImages, + resolveRepoPath, + } from "@app/lib/repoImages"; import { scrollIntoView, twemoji } from "@app/lib/utils"; interface Props extends AsciidocSource { @@ -18,13 +23,24 @@ let error: string | undefined = $state(); let scrolledToHash = false; + const images = new RepoImages(); + + $effect(() => () => images.dispose()); + $effect(() => { let stale = false; renderAsciidoc(content, { rid, sha, path }) .then(async result => { - // Unchanged markup isn't re-rendered, so its DOM is already enhanced. - if (stale || result === html) return; + if (stale) return; + // Unchanged markup isn't re-rendered, so its DOM is already enhanced, + // but its images may come from another revision. + if (result === html) { + if (container) { + enhanceRepoImages(container, { rid, sha, path }, images); + } + return; + } html = result; error = undefined; await tick(); @@ -66,6 +82,7 @@ } } + enhanceRepoImages(container, { rid, sha, path }, images); enhanceCodeBlocks(container); if (!scrolledToHash && window.location.hash) { diff --git a/src/components/Markdown.svelte b/src/components/Markdown.svelte index 687719237..b3aadc3da 100644 --- a/src/components/Markdown.svelte +++ b/src/components/Markdown.svelte @@ -24,6 +24,7 @@ } from "@app/lib/markdown"; import { parseEntityHref } from "@app/lib/mentions"; import { isOid } from "@app/lib/radUri"; + import { enhanceRepoImages, RepoImages } from "@app/lib/repoImages"; import { scrollIntoView, twemoji } from "@app/lib/utils"; import Icon from "@app/components/Icon.svelte"; @@ -33,6 +34,12 @@ interface Props { rid?: string; content: string; + // Path of the document being rendered, used to resolve relative links to + // other files in the repository. + path?: string; + // Commit the document is being read at, without which repository-relative + // images cannot be resolved. + sha?: string; // If true, add
on a single line break breaks?: boolean; toggleTaskItem?: (content: string) => Promise | void; @@ -41,6 +48,8 @@ const { rid = "", content, + path = "", + sha = undefined, breaks = false, toggleTaskItem = undefined, }: Props = $props(); @@ -68,6 +77,8 @@ { element: HTMLElement; url: string } >(); + const images = new RepoImages(); + let destroyed = false; $effect(() => () => { @@ -76,6 +87,21 @@ URL.revokeObjectURL(url); } embedPreviews.clear(); + images.dispose(); + }); + + // The revision and path of a document can change while its text stays the + // same, so images are resolved apart from the rest of the markup. + $effect(() => { + const source = { rid, sha, path }; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + content; + + void tick().then(() => { + if (container) { + enhanceRepoImages(container, source, images); + } + }); }); function createEmbedPreview( diff --git a/src/lib/asciidoc.ts b/src/lib/asciidoc.ts index 3abd0f4e8..fd31c5246 100644 --- a/src/lib/asciidoc.ts +++ b/src/lib/asciidoc.ts @@ -103,33 +103,6 @@ const includeScopes = new Map< // depth. Every read past this budget is refused. export const maxIncludeReads = 256; -/** - * Resolve a link written relative to the document at `from` into a repository - * path. Returns undefined when the link points to another origin or to the - * repository root. Targets that climb above the root stop at it. - */ -export function resolveRepoPath( - href: string, - from: string, -): string | undefined { - const dir = from.split("/").slice(0, -1).join("/"); - - let url: URL; - try { - // The trailing slash makes the base a directory, so `a.adoc` resolves as - // a sibling of `from` rather than replacing its last segment. - url = new URL(href, `${includeOrigin}/${dir ? `${dir}/` : ""}`); - } catch { - return undefined; - } - - if (url.origin !== includeOrigin) { - return undefined; - } - - return decodeURIComponent(url.pathname).replace(/^\//, "") || undefined; -} - async function readInclude(uri: string): Promise { let url: URL; try { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 89f3e281c..8f9238efd 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -31,6 +31,7 @@ dompurify.setConfig({ ALLOWED_URI_REGEXP: allowedUriSchemes, ALLOWED_ATTR: [ "align", + "alt", "checked", "class", "href", diff --git a/src/lib/repoImages.ts b/src/lib/repoImages.ts new file mode 100644 index 000000000..9cc8507c2 --- /dev/null +++ b/src/lib/repoImages.ts @@ -0,0 +1,242 @@ +import { retinaWidth } from "@app/lib/embeds"; +import { invoke } from "@app/lib/invoke"; + +// Links in a document are resolved against this origin, so that anything +// resolving to another origin is known to point outside the repository. It +// is never requested. +const repoOrigin = "https://repo.radicle.invalid"; + +/** + * Resolve a link written relative to the document at `from` into a repository + * path. Returns undefined when the link points to another origin or to the + * repository root. Targets that climb above the root stop at it. + */ +export function resolveRepoPath( + href: string, + from: string, +): string | undefined { + const dir = from.split("/").slice(0, -1).join("/"); + + let url: URL; + try { + // The trailing slash makes the base a directory, so `a.adoc` resolves as + // a sibling of `from` rather than replacing its last segment. + url = new URL(href, `${repoOrigin}/${dir ? `${dir}/` : ""}`); + } catch { + return undefined; + } + + if (url.origin !== repoOrigin) { + return undefined; + } + + let path = url.pathname; + try { + path = decodeURIComponent(path); + } catch { + // A `%` that doesn't start an escape is part of the file name. + } + + return path.replace(/^\//, "") || undefined; +} + +// Rewrite GitHub "blob" image URLs so they resolve to the raw image content. A +// URL like https://github.com///blob// serves an HTML +// page rather than the image bytes, so appending `?raw=true` makes GitHub +// redirect to the raw content. URLs already pointing at the raw content are +// returned unchanged. +export function canonicalizeGithubImageUrl(input: string): string { + let url; + try { + url = new URL(input); + } catch { + return input; + } + + if ( + (url.hostname === "github.com" || url.hostname === "www.github.com") && + /^\/[^/]+\/[^/]+\/blob\//.test(url.pathname) && + url.searchParams.get("raw") !== "true" + ) { + url.searchParams.set("raw", "true"); + return url.toString(); + } + + return input; +} + +const imageTypes: Record = { + avif: "image/avif", + bmp: "image/bmp", + gif: "image/gif", + ico: "image/x-icon", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + webp: "image/webp", +}; + +// The webview sniffs raster formats, but only shows an SVG when the blob it +// comes from says it is one, so the type comes from the file name. +export function imageMimeType(path: string): string | undefined { + const extension = /\.([^./]+)$/.exec(path)?.[1].toLowerCase(); + return extension ? imageTypes[extension] : undefined; +} + +export interface RepoImage { + url: string; + // The width a Retina screenshot is meant to be shown at. + width: number | undefined; +} + +export type FetchRepoBytes = ( + rid: string, + sha: string, + path: string, +) => Promise; + +function fetchRepoBytes(rid: string, sha: string, path: string) { + return invoke("repo_blob_bytes", { rid, sha, path }); +} + +/** + * Loads files out of a repository as images, once per file, and owns the + * object URLs they are shown through. + */ +export class RepoImages { + #fetch: FetchRepoBytes; + #images = new Map>(); + #urls = new Map(); + #used = new Set(); + #disposed = false; + + constructor(fetch: FetchRepoBytes = fetchRepoBytes) { + this.#fetch = fetch; + } + + /** + * Resolves to undefined when the image was released before it loaded. + */ + load(rid: string, sha: string, path: string): Promise { + const key = JSON.stringify([rid, sha, path]); + this.#used.add(key); + + const cached = this.#images.get(key); + if (cached) { + return cached; + } + + const image = this.#fetch(rid, sha, path).then(buffer => { + const bytes = new Uint8Array(buffer); + const url = URL.createObjectURL( + new Blob([bytes], { type: imageMimeType(path) ?? "" }), + ); + if (this.#images.get(key) !== image) { + URL.revokeObjectURL(url); + return undefined; + } + this.#urls.set(key, url); + return { url, width: retinaWidth(bytes) }; + }); + image.catch(() => { + // A failed load is retried on the next pass rather than cached. + if (this.#images.get(key) === image) { + this.#images.delete(key); + } + }); + + if (!this.#disposed) { + this.#images.set(key, image); + } + return image; + } + + /** + * Releases the images not loaded since the previous sweep. + */ + sweep() { + for (const key of this.#images.keys()) { + if (!this.#used.has(key)) { + this.#release(key); + } + } + this.#used.clear(); + } + + dispose() { + this.#disposed = true; + for (const key of this.#images.keys()) { + this.#release(key); + } + this.#used.clear(); + } + + #release(key: string) { + this.#images.delete(key); + const url = this.#urls.get(key); + if (url) { + URL.revokeObjectURL(url); + this.#urls.delete(key); + } + } +} + +// A pass replaces the source of a repository image with an object URL, so the +// source the document was written with is kept here for the next pass. +const sourceAttribute = "data-repo-src"; + +/** + * Points the images in `container` at their content: images in the repository + * are read out of storage, GitHub page URLs are rewritten to the raw file. + * Repository images need the commit the document is read at, and resolve + * against the document's `path`. + */ +export function enhanceRepoImages( + container: HTMLElement, + source: { rid: string; sha: string | undefined; path: string }, + images: RepoImages, +) { + for (const image of container.querySelectorAll("img")) { + if (image.classList.contains("txt-emoji")) { + continue; + } + + const src = + image.getAttribute(sourceAttribute) ?? image.getAttribute("src"); + if (!src) { + continue; + } + + const repoPath = resolveRepoPath(src, source.path); + if (!repoPath) { + const canonical = canonicalizeGithubImageUrl(src); + if (canonical !== src) { + image.setAttribute("src", canonical); + } + continue; + } + + if (!source.sha) { + continue; + } + + image.setAttribute(sourceAttribute, src); + images + .load(source.rid, source.sha, repoPath) + .then(loaded => { + if (!loaded || image.getAttribute(sourceAttribute) !== src) { + return; + } + image.setAttribute("src", loaded.url); + if (loaded.width && !image.hasAttribute("width")) { + image.style.width = `${loaded.width}px`; + } else { + image.style.removeProperty("width"); + } + }) + .catch(error => console.warn("Not able to load image", src, error)); + } + + images.sweep(); +} diff --git a/src/views/repo/RepoHome.svelte b/src/views/repo/RepoHome.svelte index d5f1dde20..a8b8f90a0 100644 --- a/src/views/repo/RepoHome.svelte +++ b/src/views/repo/RepoHome.svelte @@ -322,7 +322,11 @@ path={currentPath} onNavigate={showPath} /> {:else} - + {/if} {:else if blob.content.trim() === ""} diff --git a/tests/e2e/repo/markdownRendering.spec.ts b/tests/e2e/repo/markdownRendering.spec.ts index ed2290924..b11a0a6ca 100644 --- a/tests/e2e/repo/markdownRendering.spec.ts +++ b/tests/e2e/repo/markdownRendering.spec.ts @@ -2,15 +2,74 @@ import type { RadiclePeer } from "@tests/support/peerManager.js"; import * as Fs from "node:fs/promises"; import * as Path from "node:path"; +import * as Zlib from "node:zlib"; import { expect, test } from "@tests/support/fixtures.js"; import { createRepo } from "@tests/support/repo.js"; +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "latin1"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(Zlib.crc32(body)); + return Buffer.concat([length, body, crc]); +} + +// A blank PNG saved at 144 DPI, the way macOS saves Retina screenshots. +function retinaPng(width: number, height: number): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 2, 0, 0, 0], 8); + const density = Buffer.alloc(9); + density.writeUInt32BE(5669, 0); + density.writeUInt32BE(5669, 4); + density[8] = 1; + const rows = Buffer.alloc((width * 3 + 1) * height); + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("pHYs", density), + chunk("IDAT", Zlib.deflateSync(rows)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +const logo = + '\n'; + async function createRichRepo(peer: RadiclePeer) { const { rid, repoFolder } = await createRepo(peer, { name: "rich" }); + await Fs.mkdir(Path.join(repoFolder, "images")); + await Fs.mkdir(Path.join(repoFolder, "docs")); + await Fs.writeFile(Path.join(repoFolder, "images", "logo.svg"), logo); + await Fs.writeFile( + Path.join(repoFolder, "images", "shot.png"), + retinaPng(64, 8), + ); await Fs.writeFile( Path.join(repoFolder, "README.md"), - ["# Rich", "", "> [!NOTE]", "> Read the guide first.", ""].join("\n"), + [ + "# Rich", + "", + "> [!NOTE]", + "> Read the guide first.", + "", + // A `%` that doesn't start an escape must not stop the rest of the + // document from being enhanced. + "![Half](50%.png)", + "", + "![Logo](images/logo.svg)", + "", + "![Screenshot](./images/shot.png)", + "", + ].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "docs", "guide.adoc"), + ["= Guide", "", "image::../images/logo.svg[Guide logo]", ""].join("\n"), ); await peer.git(["add", "."], { cwd: repoFolder }); await peer.git(["commit", "-m", "Write a rich readme"], { cwd: repoFolder }); @@ -18,7 +77,10 @@ async function createRichRepo(peer: RadiclePeer) { return { rid }; } -test("render alerts in a readme", async ({ page, peer }) => { +test("render alerts and repository images in a readme", async ({ + page, + peer, +}) => { const { rid } = await createRichRepo(peer); await page.goto(`/repos/${rid}/home`); @@ -26,4 +88,38 @@ test("render alerts in a readme", async ({ page, peer }) => { await expect( readme.locator(".alert-note").getByText("Read the guide first."), ).toBeVisible(); + + const logoImage = readme.getByRole("img", { name: "Logo" }); + await expect(logoImage).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + logoImage.evaluate(image => (image as HTMLImageElement).naturalWidth), + ) + .toBe(40); + + const screenshot = readme.getByRole("img", { name: "Screenshot" }); + await expect(screenshot).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + screenshot.evaluate(image => image.getBoundingClientRect().width), + ) + .toBe(32); +}); + +test("show repository images in an AsciiDoc file", async ({ page, peer }) => { + const { rid } = await createRichRepo(peer); + await page.goto(`/repos/${rid}/home`); + + await page.getByText("docs", { exact: true }).click(); + await page.getByText("guide.adoc", { exact: true }).click(); + + const image = page.locator(".asciidoc").getByRole("img", { + name: "Guide logo", + }); + await expect(image).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + image.evaluate(element => (element as HTMLImageElement).naturalWidth), + ) + .toBe(40); }); diff --git a/tests/unit/asciidoc.test.ts b/tests/unit/asciidoc.test.ts deleted file mode 100644 index 5bb174507..000000000 --- a/tests/unit/asciidoc.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { resolveRepoPath } from "@app/lib/asciidoc"; - -describe("resolveRepoPath", () => { - test.each([ - ["notes.txt", "README.adoc", "notes.txt"], - ["./notes.txt", "README.adoc", "notes.txt"], - ["guide.adoc", "docs/index.adoc", "docs/guide.adoc"], - ["../README.adoc", "docs/index.adoc", "README.adoc"], - ["../../README.adoc", "docs/index.adoc", "README.adoc"], - ["../notes.txt", "README.adoc", "notes.txt"], - ["/src/main.rs", "docs/index.adoc", "src/main.rs"], - ["my%20notes.txt", "README.adoc", "my notes.txt"], - ["notes.txt?plain=1", "README.adoc", "notes.txt"], - ["notes.txt#usage", "README.adoc", "notes.txt"], - ["docs/", "README.adoc", "docs/"], - ])("resolves %j from %j to %j", (href, from, expected) => { - expect(resolveRepoPath(href, from)).toBe(expected); - }); - - test.each([ - ["https://radicle.xyz", "README.adoc"], - ["//radicle.xyz/notes.txt", "README.adoc"], - ["mailto:alice@example.com", "README.adoc"], - ["javascript:alert(1)", "README.adoc"], - ["/", "README.adoc"], - ["..", "README.adoc"], - ])("leaves %j from %j outside the repository", (href, from) => { - expect(resolveRepoPath(href, from)).toBeUndefined(); - }); -}); diff --git a/tests/unit/repoImages.test.ts b/tests/unit/repoImages.test.ts new file mode 100644 index 000000000..1f9125fe9 --- /dev/null +++ b/tests/unit/repoImages.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + canonicalizeGithubImageUrl, + enhanceRepoImages, + imageMimeType, + RepoImages, + resolveRepoPath, +} from "@app/lib/repoImages"; + +describe("resolveRepoPath", () => { + test.each([ + ["notes.txt", "README.adoc", "notes.txt"], + ["./notes.txt", "README.adoc", "notes.txt"], + ["guide.adoc", "docs/index.adoc", "docs/guide.adoc"], + ["../README.adoc", "docs/index.adoc", "README.adoc"], + ["../../README.adoc", "docs/index.adoc", "README.adoc"], + ["../notes.txt", "README.adoc", "notes.txt"], + ["/src/main.rs", "docs/index.adoc", "src/main.rs"], + ["my%20notes.txt", "README.adoc", "my notes.txt"], + ["notes.txt?plain=1", "README.adoc", "notes.txt"], + ["notes.txt#usage", "README.adoc", "notes.txt"], + ["docs/", "README.adoc", "docs/"], + ["50%.png", "README.md", "50%.png"], + ["docs/100%25.png", "README.md", "docs/100%.png"], + ])("resolves %j from %j to %j", (href, from, expected) => { + expect(resolveRepoPath(href, from)).toBe(expected); + }); + + test.each([ + ["https://radicle.xyz", "README.adoc"], + ["//radicle.xyz/notes.txt", "README.adoc"], + ["mailto:alice@example.com", "README.adoc"], + ["javascript:alert(1)", "README.adoc"], + ["data:image/png;base64,AAAA", "README.md"], + ["blob:tauri://localhost/1234", "README.md"], + ["/", "README.adoc"], + ["..", "README.adoc"], + ])("leaves %j from %j outside the repository", (href, from) => { + expect(resolveRepoPath(href, from)).toBeUndefined(); + }); +}); + +describe("canonicalizeGithubImageUrl", () => { + test.each([ + [ + "https://github.com/radicle/app/blob/main/shot.png", + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + ], + [ + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + ], + [ + "https://raw.githubusercontent.com/radicle/app/main/shot.png", + "https://raw.githubusercontent.com/radicle/app/main/shot.png", + ], + [ + "https://example.com/radicle/app/blob/main/shot.png", + "https://example.com/radicle/app/blob/main/shot.png", + ], + ["shot.png", "shot.png"], + ])("rewrites %j to %j", (input, expected) => { + expect(canonicalizeGithubImageUrl(input)).toBe(expected); + }); +}); + +describe("imageMimeType", () => { + test.each([ + ["logo.svg", "image/svg+xml"], + ["docs/LOGO.SVG", "image/svg+xml"], + ["shot.png", "image/png"], + ])("of %j is %j", (path, expected) => { + expect(imageMimeType(path)).toBe(expected); + }); + + test.each(["Makefile", "docs.v2/logo", "notes.txt", ""])( + "of %j is unknown", + path => { + expect(imageMimeType(path)).toBeUndefined(); + }, + ); +}); + +function u32(n: number): number[] { + return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +} + +function chunk(type: string, data: number[]): number[] { + const name = Array.from(type, c => c.charCodeAt(0)); + return [...u32(data.length), ...name, ...data, 0, 0, 0, 0]; +} + +// A PNG `width` pixels wide, saved at 144 DPI like a Retina screenshot. +function retinaPng(width: number): ArrayBuffer { + return new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ...chunk("IHDR", [...u32(width), ...u32(100), 8, 6, 0, 0, 0]), + ...chunk("pHYs", [...u32(5669), ...u32(5669), 1]), + ...chunk("IEND", []), + ]).buffer; +} + +let created: Blob[]; +let revoked: string[]; + +beforeEach(() => { + created = []; + revoked = []; + URL.createObjectURL = vi.fn((blob: Blob) => { + created.push(blob); + return `blob:test/${created.length}`; + }); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); +}); + +function fakeFetch() { + return vi.fn(async (_rid: string, _sha: string, _path: string) => { + return new Uint8Array([1, 2, 3]).buffer; + }); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("RepoImages", () => { + test("reads each file once", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + + const [first, second] = await Promise.all([ + images.load("rad:a", "sha1", "logo.png"), + images.load("rad:a", "sha1", "logo.png"), + ]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(created).toHaveLength(1); + }); + + test("reads the same path again at another revision", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + + await images.load("rad:a", "sha1", "logo.png"); + await images.load("rad:a", "sha2", "logo.png"); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test("types an SVG so the webview shows it", async () => { + const images = new RepoImages(fakeFetch()); + + await images.load("rad:a", "sha1", "docs/logo.svg"); + + expect(created[0].type).toBe("image/svg+xml"); + }); + + test("sizes a Retina screenshot to its intended width", async () => { + const images = new RepoImages(async () => retinaPng(3680)); + + const image = await images.load("rad:a", "sha1", "shot.png"); + + expect(image?.width).toBe(1840); + }); + + test("sweeps the images not loaded since the last sweep", async () => { + const images = new RepoImages(fakeFetch()); + + const old = await images.load("rad:a", "sha1", "old.png"); + const kept = await images.load("rad:a", "sha1", "kept.png"); + images.sweep(); + await images.load("rad:a", "sha1", "kept.png"); + images.sweep(); + + expect(revoked).toEqual([old?.url]); + expect(revoked).not.toContain(kept?.url); + }); + + test("frees an image released before it loaded", async () => { + const pending = deferred(); + const images = new RepoImages(() => pending.promise); + + const image = images.load("rad:a", "sha1", "logo.png"); + images.sweep(); + images.sweep(); + pending.resolve(new Uint8Array([1]).buffer); + + expect(await image).toBeUndefined(); + expect(revoked).toEqual(["blob:test/1"]); + }); + + test("tries a failed file again", async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValue(new Uint8Array([1]).buffer); + const images = new RepoImages(fetch); + + await expect(images.load("rad:a", "sha1", "logo.png")).rejects.toThrow(); + await expect( + images.load("rad:a", "sha1", "logo.png"), + ).resolves.toBeDefined(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test("frees every image when disposed, and those still loading", async () => { + const pending = deferred(); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Uint8Array([1]).buffer) + .mockReturnValueOnce(pending.promise); + const images = new RepoImages(fetch); + + const loaded = await images.load("rad:a", "sha1", "a.png"); + const loading = images.load("rad:a", "sha1", "b.png"); + images.dispose(); + pending.resolve(new Uint8Array([1]).buffer); + + expect(await loading).toBeUndefined(); + expect(revoked).toEqual([loaded?.url, "blob:test/2"]); + }); +}); + +function documentWith(html: string): HTMLElement { + const container = document.createElement("div"); + container.innerHTML = html; + return container; +} + +async function settled() { + await new Promise(resolve => setTimeout(resolve)); +} + +describe("enhanceRepoImages", () => { + test("shows repository images and rewrites GitHub page URLs", async () => { + const fetch = fakeFetch(); + const container = documentWith( + [ + '', + '', + '', + '', + ].join(""), + ); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "docs/README.md" }, + new RepoImages(fetch), + ); + await settled(); + + const [logo, github, external, emoji] = container.querySelectorAll("img"); + expect(fetch).toHaveBeenCalledExactlyOnceWith( + "rad:a", + "sha1", + "images/logo.svg", + ); + expect(logo.getAttribute("src")).toBe("blob:test/1"); + expect(github.getAttribute("src")).toBe( + "https://github.com/a/b/blob/main/c.png?raw=true", + ); + expect(external.getAttribute("src")).toBe("https://example.com/d.png"); + expect(emoji.getAttribute("src")).toBe("twemoji/1f600.svg"); + }); + + test("leaves repository images alone without a revision", async () => { + const fetch = fakeFetch(); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "", sha: undefined, path: "" }, + new RepoImages(fetch), + ); + await settled(); + + expect(fetch).not.toHaveBeenCalled(); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "logo.png", + ); + }); + + test("a stray percent sign doesn't stop the other images", async () => { + const fetch = fakeFetch(); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + new RepoImages(fetch), + ); + await settled(); + + expect(fetch.mock.calls.map(([, , path]) => path)).toEqual([ + "50%.png", + "logo.png", + ]); + }); + + test("reads the images again when the revision changes", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + images, + ); + await settled(); + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha2", path: "README.md" }, + images, + ); + await settled(); + + expect(fetch.mock.calls).toEqual([ + ["rad:a", "sha1", "logo.png"], + ["rad:a", "sha2", "logo.png"], + ]); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:test/2", + ); + expect(revoked).toEqual(["blob:test/1"]); + }); + + test("an older revision loading late doesn't replace the newer one", async () => { + const first = deferred(); + const fetch = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValue(new Uint8Array([2]).buffer); + const images = new RepoImages(fetch); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + images, + ); + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha2", path: "README.md" }, + images, + ); + await settled(); + first.resolve(new Uint8Array([1]).buffer); + await settled(); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:test/1", + ); + expect(revoked).toEqual(["blob:test/2"]); + }); + + test("shows a Retina screenshot at its intended width", async () => { + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + new RepoImages(async () => retinaPng(3680)), + ); + await settled(); + + expect(container.querySelector("img")?.style.width).toBe("1840px"); + }); +});