Show repository images in READMEs

Relative images in markdown and AsciiDoc files were broken, nothing
resolved them against the repository. They're read through a new
`repo_blob_bytes` command, which skips the text encoding and last
commit lookup of `repo_blob`. Images now also keep their alt text.

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Brandon Oxendine
2026-10-07 16:56:16 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 70046d0c74
commit ab8300e09a
13 changed files with 871 additions and 65 deletions
+12
View File
@@ -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<tauri::ipc::Response, Error> {
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>,
+1
View File
@@ -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,
+62
View File
@@ -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<Vec<u8>, 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<repo::RepoRefs, Error> {
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();
+17
View File
@@ -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<BlobBytesBody>,
) -> 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<DiffBody>,
+20 -3
View File
@@ -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) {
+26
View File
@@ -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 <br> on a single line break
breaks?: boolean;
toggleTaskItem?: (content: string) => Promise<void> | 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(
-27
View File
@@ -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<Response> {
let url: URL;
try {
+1
View File
@@ -31,6 +31,7 @@ dompurify.setConfig({
ALLOWED_URI_REGEXP: allowedUriSchemes,
ALLOWED_ATTR: [
"align",
"alt",
"checked",
"class",
"href",
+242
View File
@@ -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/<owner>/<repo>/blob/<ref>/<path> 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<string, string> = {
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<ArrayBuffer>;
function fetchRepoBytes(rid: string, sha: string, path: string) {
return invoke<ArrayBuffer>("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<string, Promise<RepoImage | undefined>>();
#urls = new Map<string, string>();
#used = new Set<string>();
#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<RepoImage | undefined> {
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();
}
+5 -1
View File
@@ -322,7 +322,11 @@
path={currentPath}
onNavigate={showPath} />
{:else}
<Markdown content={blob.content} />
<Markdown
rid={repo.rid}
path={currentPath}
sha={oid}
content={blob.content} />
{/if}
</div>
{:else if blob.content.trim() === ""}
+98 -2
View File
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#08f"/></svg>\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);
});
-32
View File
@@ -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();
});
});
+387
View File
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<ArrayBuffer>();
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<ArrayBuffer>();
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(
[
'<img src="../images/logo.svg">',
'<img src="https://github.com/a/b/blob/main/c.png">',
'<img src="https://example.com/d.png">',
'<img class="txt-emoji" src="twemoji/1f600.svg">',
].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('<img src="logo.png">');
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('<img src="50%.png"><img src="logo.png">');
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('<img src="logo.png">');
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<ArrayBuffer>();
const fetch = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValue(new Uint8Array([2]).buffer);
const images = new RepoImages(fetch);
const container = documentWith('<img src="logo.png">');
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('<img src="shot.png">');
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");
});
});