Render AsciiDoc readmes in repo source view

Uses @asciidoctor/core, lazily imported so the main bundle is
unchanged, with its own DOMPurify instance because the markdown
allowlist is pinned globally via setConfig.

Include directives resolve through a registered HttpCache that maps
Asciidoctor's URI reads onto repo_blob, so tag, line-range and
nesting handling stay upstream. A per-conversion token in base_dir
scopes each read to one repository and commit.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-06 16:28:10 +02:00
parent 2a0dd5a747
commit 651af122b2
11 changed files with 901 additions and 3 deletions
+114
View File
@@ -0,0 +1,114 @@
import { expect, test } from "@tests/support/fixtures.js";
test("render an AsciiDoc readme", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(
readme.getByRole("heading", { name: "AsciiDoc Fixture", level: 1 }),
).toBeVisible();
await expect(readme.locator("code.language-rust")).toBeVisible();
await expect(readme.locator("table.tableblock").first()).toBeVisible();
await expect(readme.getByText("An admonition.")).toBeVisible();
await expect(readme.getByText("A footnote.")).toBeVisible();
});
test("open a relative link in the source view", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(readme.getByRole("link", { name: "the notes" })).toBeVisible();
const url = page.url();
await readme.getByRole("link", { name: "the notes" }).click();
// The link opens the file instead of navigating the app away.
await expect(page.getByText("Notes from a sibling file.")).toBeVisible();
expect(page.url()).toBe(url);
});
test("keep wide AsciiDoc table cells inside the viewport", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(readme.locator("table.tableblock").nth(1)).toBeVisible();
const overflow = await readme.evaluate(
element => element.scrollWidth - element.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
test("resolve includes in an AsciiDoc readme", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(readme.getByText("Alpha section text.")).toBeVisible();
// Only the requested tag is included.
await expect(readme.getByText("Beta section text.")).toHaveCount(0);
});
test("refuse remote includes without a request", async ({ page }) => {
const remoteRequests: string[] = [];
page.on("request", request => {
if (new URL(request.url()).hostname === "example.invalid") {
remoteRequests.push(request.url());
}
});
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(
readme.getByText(/Unresolved directive.*example\.invalid/),
).toBeVisible();
expect(remoteRequests).toEqual([]);
});
test("bound the reads of recursive includes", async ({ page }) => {
let blobReads = 0;
page.on("request", request => {
if (new URL(request.url()).pathname === "/repo_blob") {
blobReads += 1;
}
});
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
await page.getByText("bomb.adoc", { exact: true }).click();
await expect(
page
.locator(".asciidoc")
.getByRole("heading", { name: "Include Bomb", level: 1 }),
).toBeVisible({ timeout: 30_000 });
// The include budget is 256 reads, plus the reads for the file itself and
// the readme's own include.
expect(blobReads).toBeLessThanOrEqual(260);
});
test("sanitize untrusted markup in an AsciiDoc readme", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(readme.getByText("Untrusted markup")).toBeVisible();
await expect(readme.locator("script")).toHaveCount(0);
await expect(readme.locator("iframe")).toHaveCount(0);
await expect(readme.locator("[onerror]")).toHaveCount(0);
await expect(readme.locator("[onclick]")).toHaveCount(0);
await expect(readme.locator('a[href^="javascript:"]')).toHaveCount(0);
const marker = await page.evaluate(
() => (window as unknown as { xssMarker?: string }).xssMarker,
);
expect(marker).toBeUndefined();
});
+98
View File
@@ -594,6 +594,104 @@ export async function createMarkdownFixture(peer: RadiclePeer) {
);
}
export async function createAsciidocFixture(peer: RadiclePeer) {
const { repoFolder } = await createRepo(peer, { name: "asciidoc" });
await Fs.writeFile(
Path.join(repoFolder, "README.adoc"),
[
"= AsciiDoc Fixture",
"",
"A paragraph with a `code span` and a https://radicle.xyz[link].",
"",
"== Rendering",
"",
"[source,rust]",
"----",
"fn main() {}",
"----",
"",
"|===",
"|Column A |Column B",
"",
"|1 |2",
"|===",
"",
// An AsciiDoc cell holding an unbreakable token: the column must stay
// inside the viewport instead of widening the page.
'[cols="2a,1",options="header"]',
"|===",
"|URI |Refers to",
"",
"|",
"....",
`rad:z${"L".repeat(120)}`,
"....",
"|A repository",
"|===",
"",
"NOTE: An admonition.",
"",
"A statement.footnote:[A footnote.]",
"",
"== Includes",
"",
"include::_include.adoc[tag=alpha]",
"",
// A remote include must be refused without any network request.
"include::https://example.invalid/remote.adoc[]",
"",
"A link to link:notes.txt[the notes] in the same repository.",
"",
"== Untrusted markup",
"",
// A passthrough block is the one place AsciiDoc emits author-supplied
// HTML verbatim, so it is what the sanitizer has to catch.
"++++",
'<script>window.xssMarker = "script";</script>',
'<img src="x" onerror="window.xssMarker = \'onerror\';">',
'<iframe src="https://example.invalid"></iframe>',
"<a href=\"javascript:window.xssMarker = 'href';\">link</a>",
"<div onclick=\"window.xssMarker = 'onclick';\">click</div>",
"++++",
"",
].join("\n"),
);
await Fs.writeFile(
Path.join(repoFolder, "notes.txt"),
"Notes from a sibling file.\n",
);
await Fs.writeFile(
Path.join(repoFolder, "_include.adoc"),
[
"// tag::alpha[]",
"Alpha section text.",
"// end::alpha[]",
"// tag::beta[]",
"Beta section text.",
"// end::beta[]",
"",
].join("\n"),
);
// Each level includes the next one twice, so without a read budget the
// reads grow exponentially with Asciidoctor's include depth.
await Fs.writeFile(
Path.join(repoFolder, "bomb.adoc"),
["= Include Bomb", "", "include::_bomb.adoc[]", ""].join("\n"),
);
await Fs.writeFile(
Path.join(repoFolder, "_bomb.adoc"),
["include::_bomb.adoc[]", "include::_bomb.adoc[]", ""].join("\n"),
);
await peer.git(["add", "."], { cwd: repoFolder });
await peer.git(["commit", "-m", "Add AsciiDoc readme"], { cwd: repoFolder });
await peer.git(["push", "rad"], { cwd: repoFolder });
}
export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24";
export const aliceMainCommitMessage =
"Verify that crate::DoubleColon::should_work()";
+3
View File
@@ -2,6 +2,7 @@ import * as Fs from "node:fs";
import * as Path from "node:path";
import {
createAsciidocFixture,
createCobsFixture,
createMarkdownFixture,
defaultConfig,
@@ -85,6 +86,8 @@ export default async function globalSetup(): Promise<() => void> {
try {
console.log("Creating markdown fixture");
await createMarkdownFixture(palm);
console.log("Creating asciidoc fixture");
await createAsciidocFixture(palm);
console.log("Creating cobs fixture");
await createCobsFixture(peerManager, palm);
console.log("All fixtures created");
+32
View File
@@ -0,0 +1,32 @@
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();
});
});