Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/lib/omm-docs/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
<img
src={token.href}
src={url.href}
alt={token.text}
title={token.title ?? undefined}
loading="lazy"
Expand All @@ -240,13 +248,13 @@ function renderImage(token: Tokens.Image): ReactNode {

function isBadgeParagraph(paragraph: Tokens.Paragraph): boolean {
return paragraph.tokens.every((token) => {
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() === ""),
);
}
Expand Down
66 changes: 17 additions & 49 deletions src/lib/omm-docs/section.ts
Original file line number Diff line number Diff line change
@@ -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();
}
35 changes: 26 additions & 9 deletions src/lib/omm-docs/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response | undefined>;
Expand All @@ -39,24 +40,36 @@ export function readmeCacheKey(): Request {
}

async function runAfterResponse(promise: Promise<unknown>): Promise<void> {
// 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<string> {
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();
}

/**
Expand All @@ -69,8 +82,12 @@ export const fetchReadme = cache(async (): Promise<string> => {
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, {
Expand Down
102 changes: 102 additions & 0 deletions tests/omm-docs-source.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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);
});
39 changes: 39 additions & 0 deletions tests/omm-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<h2"));
Expand Down Expand Up @@ -114,6 +137,22 @@ test("renderMarkdown drops shields.io badge paragraphs", () => {
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("<img"));
});

test("every /docs route directory has a page and a dictionary entry", async () => {
const entries = await readdir(DOCS_ROUTES, { withFileTypes: true });
const slugs = new Set<string>();
Expand Down
Loading