mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
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:
@@ -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();
|
||||
});
|
||||
@@ -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()";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user