diff --git a/src/lib/omm-docs/markdown.tsx b/src/lib/omm-docs/markdown.tsx index 0c9efbf..b82b507 100644 --- a/src/lib/omm-docs/markdown.tsx +++ b/src/lib/omm-docs/markdown.tsx @@ -12,6 +12,7 @@ import { marked, type Token, type Tokens } from "marked"; const REPO_ROOT = "https://github.com/omm-hippo/omm"; const REPO_BLOB = `${REPO_ROOT}/blob/main/`; +const REPO_RAW = "https://raw.githubusercontent.com/omm-hippo/omm/main/"; const SITE_HOSTS = new Set(["omm.run", "www.omm.run"]); const LINK_CLASS = @@ -226,10 +227,17 @@ function MarkedLink({ token }: { token: Tokens.Link }) { function renderImage(token: Tokens.Image): ReactNode { if (BADGE_HREF.test(token.href)) return null; + let url: URL; + try { + url = new URL(token.href, REPO_RAW); + } catch { + return token.text; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return token.text; return ( // eslint-disable-next-line @next/next/no-img-element -- upstream README image, host + dimensions unknown {token.text} { - if (token.type === "image") return true; + if (token.type === "image") return BADGE_HREF.test(token.href); if (token.type === "space" || token.type === "br") return true; if (token.type === "text") return token.raw.trim() === ""; if (token.type === "link") { return (token as Tokens.Link).tokens.every( (child) => - child.type === "image" || + (child.type === "image" && BADGE_HREF.test(child.href)) || (child.type === "text" && child.raw.trim() === ""), ); } diff --git a/src/lib/omm-docs/section.ts b/src/lib/omm-docs/section.ts index b42a1bf..6eca4da 100644 --- a/src/lib/omm-docs/section.ts +++ b/src/lib/omm-docs/section.ts @@ -1,56 +1,24 @@ -import { OmmDocsSectionMissing } from "./errors"; +import { marked, type Tokens } from "marked"; -const HEADING = /^(#{1,6})\s+(.+?)\s*$/; -const FENCE = /^\s*(```|~~~)/; +import { OmmDocsSectionMissing } from "./errors"; -/** - * Slice one section out of a markdown document: from the first heading whose - * text matches `heading` (trimmed, case-insensitive) down to the next heading - * of the same or a higher level, or the end of the document. - * - * Fenced code blocks are skipped so a `# comment` line inside a shell snippet - * is never mistaken for a heading. - */ +/** Slice a heading and its descendants using the same parser as the renderer. + * This keeps fenced/indented code and alternate heading syntax consistent. */ export function extractSection(markdown: string, heading: string): string { const target = heading.trim().toLowerCase(); - const lines = markdown.split("\n"); - - let start = -1; - let level = 0; - let inFence = false; - - for (let i = 0; i < lines.length; i += 1) { - if (FENCE.test(lines[i])) { - inFence = !inFence; - continue; - } - if (inFence) continue; - - const match = HEADING.exec(lines[i]); - if (match && match[2].trim().toLowerCase() === target) { - start = i; - level = match[1].length; - break; - } - } - + const tokens = marked.lexer(markdown); + const start = tokens.findIndex( + (token) => token.type === "heading" && token.text.trim().toLowerCase() === target, + ); if (start === -1) throw new OmmDocsSectionMissing(heading); - let end = lines.length; - inFence = false; - for (let i = start + 1; i < lines.length; i += 1) { - if (FENCE.test(lines[i])) { - inFence = !inFence; - continue; - } - if (inFence) continue; - - const match = HEADING.exec(lines[i]); - if (match && match[1].length <= level) { - end = i; - break; - } - } - - return lines.slice(start, end).join("\n").trim(); + const level = (tokens[start] as Tokens.Heading).depth; + const next = tokens.findIndex( + (token, index) => index > start && token.type === "heading" && token.depth <= level, + ); + return tokens + .slice(start, next === -1 ? undefined : next) + .map((token) => token.raw) + .join("") + .trim(); } diff --git a/src/lib/omm-docs/source.ts b/src/lib/omm-docs/source.ts index ee7e813..5798da2 100644 --- a/src/lib/omm-docs/source.ts +++ b/src/lib/omm-docs/source.ts @@ -17,6 +17,7 @@ export const README_URL = "https://raw.githubusercontent.com/omm-hippo/omm/main/README.md"; const EDGE_CACHE_TTL_SECONDS = 3600; +const FETCH_TIMEOUT_MS = 5_000; type EdgeCache = { match(request: Request): Promise; @@ -39,24 +40,36 @@ export function readmeCacheKey(): Request { } async function runAfterResponse(promise: Promise): Promise { + // Handle rejection before importing the runtime: cache writes are best effort. + const settled = promise.catch(() => {}); try { const { getCloudflareContext } = await import("@opennextjs/cloudflare"); - getCloudflareContext().ctx.waitUntil(promise); + getCloudflareContext().ctx.waitUntil(settled); } catch { // No Cloudflare context (dev, tests): just await it inline. - await promise; + await settled; } } async function fetchFromGitHub(): Promise { - let response: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { - response = await fetch(README_URL, { cache: "no-store" }); - } catch { + const response = await fetch(README_URL, { + cache: "no-store", + signal: controller.signal, + redirect: "manual", + }); + if (!response.ok) throw new OmmDocsUnavailable(response.status); + // Await the body here: a connection can fail after successful headers, + // and the same deadline must cover the entire download. + return await response.text(); + } catch (error) { + if (error instanceof OmmDocsUnavailable) throw error; throw new OmmDocsUnavailable(); + } finally { + clearTimeout(timeout); } - if (!response.ok) throw new OmmDocsUnavailable(response.status); - return response.text(); } /** @@ -69,8 +82,12 @@ export const fetchReadme = cache(async (): Promise => { if (!store) return fetchFromGitHub(); const key = readmeCacheKey(); - const hit = await store.match(key); - if (hit) return hit.text(); + try { + const hit = await store.match(key); + if (hit) return await hit.text(); + } catch { + // A failed cache read is a miss, not a document outage. + } const markdown = await fetchFromGitHub(); const entry = new Response(markdown, { diff --git a/tests/omm-docs-source.test.ts b/tests/omm-docs-source.test.ts new file mode 100644 index 0000000..3a3db77 --- /dev/null +++ b/tests/omm-docs-source.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; + +import { OmmDocsUnavailable } from "../src/lib/omm-docs/errors"; +import { fetchReadme } from "../src/lib/omm-docs/source"; + +const originalCaches = Object.getOwnPropertyDescriptor(globalThis, "caches"); + +afterEach(() => { + mock.restoreAll(); + mock.timers.reset(); + if (originalCaches) Object.defineProperty(globalThis, "caches", originalCaches); + else Reflect.deleteProperty(globalThis, "caches"); +}); + +function cacheWith(overrides: Record = {}) { + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: { + default: { + async match() { return undefined; }, + async put() {}, + ...overrides, + }, + }, + }); +} + +test("a cache read failure still serves the upstream README", async () => { + cacheWith({ async match() { throw new Error("cache unavailable"); } }); + const upstream = mock.method(globalThis, "fetch", async () => new Response("# README")); + assert.equal(await fetchReadme(), "# README"); + assert.equal(upstream.mock.callCount(), 1); +}); + +test("a cache write failure does not discard a successful upstream response", async () => { + cacheWith({ async put() { throw new Error("cache full"); } }); + mock.method(globalThis, "fetch", async () => new Response("# README")); + assert.equal(await fetchReadme(), "# README"); +}); + +test("a valid cache hit makes no upstream request", async () => { + cacheWith({ async match() { return new Response("# Cached README"); } }); + const upstream = mock.method(globalThis, "fetch", async () => { + throw new Error("unexpected network request"); + }); + assert.equal(await fetchReadme(), "# Cached README"); + assert.equal(upstream.mock.callCount(), 0); +}); + +test("an interrupted upstream body uses the document fallback error", async () => { + Reflect.deleteProperty(globalThis, "caches"); + mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({ + start(controller) { controller.error(new Error("connection lost during body")); }, + }))); + await assert.rejects(fetchReadme(), OmmDocsUnavailable); +}); + +test("the README request has a deadline that also cancels an incomplete body", async () => { + Reflect.deleteProperty(globalThis, "caches"); + mock.timers.enable({ apis: ["setTimeout"] }); + let requestSignal: AbortSignal | undefined; + mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + assert.ok(requestSignal, "the upstream request must be cancellable"); + return new Response(new ReadableStream({ + start(controller) { + requestSignal!.addEventListener("abort", () => { + controller.error(requestSignal!.reason); + }, { once: true }); + }, + })); + }); + const pending = assert.rejects(fetchReadme(), OmmDocsUnavailable); + // Let the response headers arrive before the download deadline passes. + await Promise.resolve(); + mock.timers.tick(5_000); + await pending; + assert.equal(requestSignal?.aborted, true); +}); + +test("an upstream HTTP error retains its status and is not cached", async () => { + let writes = 0; + cacheWith({ async put() { writes += 1; } }); + mock.method(globalThis, "fetch", async () => new Response("unavailable", { status: 503 })); + await assert.rejects(fetchReadme(), (error: unknown) => + error instanceof OmmDocsUnavailable && error.status === 503, + ); + assert.equal(writes, 0); +}); + +test("an upstream redirect is rejected instead of followed", async () => { + Reflect.deleteProperty(globalThis, "caches"); + const upstream = mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => { + assert.equal(init?.redirect, "manual"); + return new Response(null, { status: 302, headers: { location: "https://example.com/README.md" } }); + }); + await assert.rejects(fetchReadme(), (error: unknown) => + error instanceof OmmDocsUnavailable && error.status === 302, + ); + assert.equal(upstream.mock.callCount(), 1); +}); diff --git a/tests/omm-docs.test.ts b/tests/omm-docs.test.ts index 9a3099c..c9f20d6 100644 --- a/tests/omm-docs.test.ts +++ b/tests/omm-docs.test.ts @@ -86,6 +86,29 @@ test("extractSection throws OmmDocsSectionMissing when absent", () => { ); }); +test("extractSection uses markdown fence rules instead of toggling on any fence", () => { + for (const [open, inner, close] of [["````md", "```", "````"], ["```md", "~~~", "```"]]) { + const md = `## First\n\n${open}\n${inner}\n## In code\n${close}\n\nKept.\n\n## Next\nOther.`; + const section = extractSection(md, "First"); + assert.ok(section.includes("Kept.")); + assert.ok(!section.includes("Other.")); + assert.throws(() => extractSection(md, "In code"), OmmDocsSectionMissing); + } +}); + +test("extractSection recognizes setext and closing-hash headings", () => { + assert.equal(extractSection("Intro\n\nFirst\n-----\n\nText.\n\n## Next", "First"), "First\n-----\n\nText."); + assert.equal(extractSection("## First ##\n\nText.\n\n## Next", "First"), "## First ##\n\nText."); +}); + +test("extractSection preserves reference link definitions and their rendered target", () => { + const md = "## First\n\n[Read the guide][guide]\n\n[guide]: docs/guide.md\n\n## Next"; + const section = extractSection(md, "First"); + assert.ok(section.includes("[guide]: docs/guide.md")); + const html = renderToStaticMarkup(renderMarkdown(section)); + assert.ok(html.includes('href="https://github.com/omm-hippo/omm/blob/main/docs/guide.md"')); +}); + test("renderMarkdown renders every handled token without throwing", () => { const html = renderToStaticMarkup(renderMarkdown(SAMPLE)); assert.ok(html.includes(" { assert.ok(html.includes("Real text.")); }); +test("renderMarkdown preserves standalone and linked content images", () => { + const md = "![Diagram](docs/diagram.png)\n\n[![Screenshot](https://example.com/screen.png)](docs/guide.md)"; + const html = renderToStaticMarkup(renderMarkdown(md)); + assert.ok(html.includes('alt="Diagram"')); + assert.ok(html.includes('src="https://raw.githubusercontent.com/omm-hippo/omm/main/docs/diagram.png"')); + assert.ok(html.includes('alt="Screenshot"')); + assert.ok(html.includes('href="https://github.com/omm-hippo/omm/blob/main/docs/guide.md"')); +}); + +test("malformed or non-web image URLs degrade to their alternative text", () => { + const html = renderToStaticMarkup(renderMarkdown("![Broken](http://[)\n\n![Local](file:///tmp/image.png)")); + assert.ok(html.includes("Broken")); + assert.ok(html.includes("Local")); + assert.ok(!html.includes(" { const entries = await readdir(DOCS_ROUTES, { withFileTypes: true }); const slugs = new Set();