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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ DOUBLEWORD_SYSTEM_API_KEY=<api key for model data>

Without `DOUBLEWORD_SYSTEM_API_KEY`, model pages render empty.

Optional: `SANITY_API_HOST` points the app at a stand-in Sanity API (e.g. `http://localhost:3210` serving canned GROQ results) for dev/verification in environments that can't reach `*.sanity.io`. Setting it disables per-project hostname routing; leave it unset normally.

## Traps and gotchas

- **Deleting a Sanity page breaks inbound links.** Archive (move to `archive` category) unless you're certain nothing points to it.
Expand Down
176 changes: 176 additions & 0 deletions src/app/api/markdown/[product]/[...slug]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

vi.mock("@/sanity/lib/client", () => ({
sanityFetch: vi.fn(),
}));

vi.mock("@/lib/models", () => ({
fetchModelsServer: vi.fn(async () => ({
models: [],
fetchedAt: "2026-01-01T00:00:00.000Z",
})),
fetchModelsFromApiRoute: vi.fn(async () => ({
models: [],
fetchedAt: "2026-01-01T00:00:00.000Z",
})),
}));

vi.mock("@/lib/model-artifacts", () => ({
getModelArtifact: vi.fn(async () => null),
renderModelArtifactMarkdown: vi.fn(() => ""),
}));

import { GET } from "./route";
import { sanityFetch } from "@/sanity/lib/client";

const sanityFetchMock = vi.mocked(sanityFetch);

function getMarkdown(productSlug: string, slugSegments: string[]) {
const path = `/api/markdown/${productSlug}/${slugSegments.join("/")}`;
return GET(new NextRequest(`http://localhost:3000${path}`), {
params: Promise.resolve({ product: productSlug, slug: slugSegments }),
});
}

const EXTERNAL_URL =
"https://raw.githubusercontent.com/doublewordai/use-cases/refs/heads/main/async-agents/README.md";

const EXTERNAL_README = "# Async Agents\n\nBatch inference for agent trees.\n";

// The placeholder body Sanity holds for pages whose content lives in
// externalSource — a Portable Text block array, not a markdown string.
const PORTABLE_TEXT_BODY = [
{
_type: "block",
style: "normal",
markDefs: [],
children: [
{ _type: "span", text: "This page is sourced from GitHub.", marks: [] },
],
},
];

describe("GET /api/markdown/[product]/[...slug]", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it("serves the fetched markdown for a docPage backed by externalSource", async () => {
sanityFetchMock.mockResolvedValueOnce({
title: "Async Agents",
body: PORTABLE_TEXT_BODY,
externalSource: EXTERNAL_URL,
});
vi.mocked(fetch).mockResolvedValueOnce(
new Response(EXTERNAL_README, {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
})
);

const response = await getMarkdown("inference-api", ["async-agents.md"]);

expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/plain");
expect(await response.text()).toBe(EXTERNAL_README);
expect(fetch).toHaveBeenCalledWith(EXTERNAL_URL, expect.anything());
// The .md suffix from the rewrite is stripped before querying Sanity
expect(sanityFetchMock).toHaveBeenCalledWith(
expect.objectContaining({
params: { productSlug: "inference-api", docSlug: "async-agents" },
})
);
});

it("falls back to the coerced Portable Text body when the external fetch fails", async () => {
sanityFetchMock.mockResolvedValueOnce({
title: "Async Agents",
body: PORTABLE_TEXT_BODY,
externalSource: EXTERNAL_URL,
});
vi.mocked(fetch).mockResolvedValueOnce(new Response("", { status: 404 }));

const response = await getMarkdown("inference-api", ["async-agents.md"]);

expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("This page is sourced from GitHub.");
expect(text).not.toContain("[object Object]");
});

it("falls back to the body when externalSource serves HTML (repo-link pages)", async () => {
sanityFetchMock.mockResolvedValueOnce({
title: "Some Guide",
body: "Body authored in Sanity.",
externalSource: "https://github.com/doublewordai/use-cases",
});
vi.mocked(fetch).mockResolvedValueOnce(
new Response("<!doctype html><html></html>", {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
})
);

const response = await getMarkdown("inference-api", ["some-guide.md"]);

expect(await response.text()).toBe("Body authored in Sanity.");
});

it("serves a plain markdown string body and rewrites image filenames", async () => {
sanityFetchMock.mockResolvedValueOnce({
title: "Quickstart",
body: "## Quickstart\n\n![diagram](flow.png)\n",
images: [
{
filename: "flow.png",
asset: { _id: "image-1", url: "https://cdn.sanity.io/images/flow.png" },
},
],
});

const response = await getMarkdown("inference-api", ["quickstart.md"]);

expect(response.status).toBe(200);
expect(await response.text()).toBe(
"## Quickstart\n\n![diagram](https://cdn.sanity.io/images/flow.png)\n"
);
// No externalSource and no template placeholders: nothing fetched
expect(fetch).not.toHaveBeenCalled();
});

it("still serves content fetched from a linkedPost externalSource", async () => {
sanityFetchMock.mockResolvedValueOnce({
title: "Syndicated Post",
body: PORTABLE_TEXT_BODY,
linkedPost: {
body: "Linked post fallback.",
externalSource: "https://raw.githubusercontent.com/doublewordai/blog/main/post.md",
},
});
vi.mocked(fetch).mockResolvedValueOnce(
new Response("# Syndicated\n\nFrom the blog repo.\n", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
})
);

const response = await getMarkdown("inference-api", ["syndicated-post.md"]);

expect(await response.text()).toBe("# Syndicated\n\nFrom the blog repo.\n");
});

it("returns 404 for unknown documents", async () => {
sanityFetchMock.mockResolvedValueOnce(null);

const response = await getMarkdown("inference-api", ["does-not-exist.md"]);

expect(response.status).toBe(404);
});
});
36 changes: 24 additions & 12 deletions src/app/api/markdown/[product]/[...slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { fetchModelsServer } from "@/lib/models";
import { templateMarkdown, buildTemplateContext } from "@/lib/handlebars";
import { getModelArtifact, renderModelArtifactMarkdown } from "@/lib/model-artifacts";
import { coerceMarkdownContent } from "@/lib/portable-text";

const MARKDOWN_BY_SLUG_QUERY = defineQuery(`*[
_type == "docPage" &&
Expand All @@ -16,6 +17,7 @@ const MARKDOWN_BY_SLUG_QUERY = defineQuery(`*[
][0]{
title,
body,
externalSource,
linkedPost->{body, externalSource},
images[]{
filename,
Expand All @@ -24,12 +26,16 @@ const MARKDOWN_BY_SLUG_QUERY = defineQuery(`*[
}`);

/**
* Fetch markdown content from an external URL
* Fetch markdown content from an external URL. HTML responses (e.g. an
* `externalSource` pointing at a GitHub repo page just to power the
* "View source" link) return null so the caller falls back to the Sanity body.
*/
async function fetchExternalContent(url: string): Promise<string | null> {
try {
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) return null;
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("text/html")) return null;
return await response.text();
} catch {
return null;
Expand Down Expand Up @@ -67,8 +73,9 @@ export async function GET(
tags: ["docPage"],
})) as {
title: string;
body: string;
linkedPost?: { body: string; externalSource?: string };
body: unknown;
externalSource?: string;
linkedPost?: { body: unknown; externalSource?: string };
images?: { filename: string; asset: { _id: string; url: string } }[];
} | null;

Expand Down Expand Up @@ -96,17 +103,22 @@ export async function GET(
});
}

// Get the raw markdown content
let content: string;
if (doc.linkedPost?.externalSource) {
const externalContent = await fetchExternalContent(
doc.linkedPost.externalSource
);
content = externalContent || doc.linkedPost.body || doc.body;
} else {
content = doc.linkedPost?.body || doc.body;
// Get the raw markdown content, resolved in the same order as the HTML
// page route: externalSource > linkedPost > body
let externalContent: string | null = null;
if (doc.externalSource) {
externalContent = await fetchExternalContent(doc.externalSource);
} else if (doc.linkedPost?.externalSource) {
externalContent = await fetchExternalContent(doc.linkedPost.externalSource);
}

// Sanity bodies may be Portable Text blocks rather than markdown strings;
// coerce so the response is always markdown, never a stringified object
let content =
externalContent ??
(coerceMarkdownContent(doc.linkedPost?.body) ||
coerceMarkdownContent(doc.body));

Comment on lines +106 to +121
// Apply Handlebars templating (same as MarkdownRenderer)
const modelsResponse = await fetchModelsServer();
const templateContext = buildTemplateContext(modelsResponse);
Expand Down
83 changes: 1 addition & 82 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fetchModelsServer } from "@/lib/models";
import { templateMarkdown, buildTemplateContext } from "@/lib/handlebars";
import { StatusWidget } from './StatusWidget';
import { rewriteExternalMarkdownLinks } from "@/lib/external-docs";
import { coerceMarkdownContent } from "@/lib/portable-text";


/**
Expand Down Expand Up @@ -46,88 +47,6 @@ type ImageData = {
caption?: string;
};

type PortableTextSpan = {
_type?: string;
text?: string;
marks?: string[];
};

type PortableTextMarkDef = {
_key?: string;
_type?: string;
href?: string;
};

type PortableTextBlock = {
_type?: string;
style?: string;
children?: PortableTextSpan[];
markDefs?: PortableTextMarkDef[];
listItem?: string;
level?: number;
};

function renderPortableTextSpan(
span: PortableTextSpan,
markDefs: PortableTextMarkDef[] = [],
): string {
const text = span?.text || "";
const marks = span?.marks || [];

return marks.reduce((result, mark) => {
if (mark === "strong") return `**${result}**`;
if (mark === "code") return `\`${result}\``;

const markDef = markDefs.find((def) => def._key === mark);
if (markDef?._type === "link" && markDef.href) {
return `[${result}](${markDef.href})`;
}

return result;
}, text);
}

function coerceMarkdownContent(content: unknown): string {
if (typeof content === "string") {
return content;
}

if (Array.isArray(content)) {
return content
.map((block) => {
if (!block || typeof block !== "object") return "";

const portableBlock = block as PortableTextBlock;
const text = portableBlock.children
?.map((child) => renderPortableTextSpan(child, portableBlock.markDefs))
.join("")
.trim();

if (!text) return "";

const listPrefix =
portableBlock.listItem === "bullet"
? `${" ".repeat(Math.max((portableBlock.level || 1) - 1, 0))}- `
: "";

switch (portableBlock.style) {
case "h1":
return `# ${text}`;
case "h2":
return `## ${text}`;
case "h3":
return `### ${text}`;
default:
return `${listPrefix}${text}`;
}
})
.filter(Boolean)
.join("\n\n");
}

return "";
}

/**
* Convert a raw GitHub URL to a base URL for resolving relative links
* e.g., https://raw.githubusercontent.com/doublewordai/use-cases/refs/heads/main/README.md
Expand Down
Loading