mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
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:
committed by
Rūdolfs Ošiņš
co-authored by
Rūdolfs Ošiņš
parent
70046d0c74
commit
ab8300e09a
@@ -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.
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user