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
{
- 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 = "\n\n[](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("\n\n"));
+ 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();