Fix .md endpoint serving "[object Object]" for externalSource-backed pages#29
Merged
Conversation
The raw-markdown endpoint returned a literal "[object Object]" (HTTP 200) for docPages whose content comes from the top-level externalSource field — e.g. /inference-api/async-agents.md. The route's GROQ query never selected externalSource, so the handler fell through to the page's placeholder Portable Text body, and NextResponse stringified the block array. - select externalSource and resolve content in the same order as the HTML page route: externalSource > linkedPost > body - ignore HTML responses from externalSource (repo-link pages), falling back to the Sanity body, matching the page route - coerce Portable Text bodies to markdown via coerceMarkdownContent, extracted from MarkdownRenderer into src/lib/portable-text.ts, so the endpoint can never emit a stringified object - add tests for the route handler and the coercion helper - support a SANITY_API_HOST override in the Sanity client so a local mock can back offline dev/verification (documented in AGENTS.md) https://claude.ai/code/session_017nS4EWobkNNbu1jCGZ92LL
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Fixes the .md markdown API route so Sanity docPages backed by externalSource return actual markdown (instead of stringifying Portable Text to "[object Object]"), and keeps markdown/body resolution consistent with the HTML doc page route.
Changes:
- Update the markdown route GROQ query + resolver order to support
docPage.externalSource, with an HTML-response guard for external fetches. - Extract Portable Text → markdown coercion into a shared helper and use it in both the renderer and the markdown API route.
- Add targeted Vitest coverage for the markdown API route and the Portable Text coercion contract; document
SANITY_API_HOSTand add an optional client override for offline/local mocking.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/sanity/lib/client.ts | Adds optional SANITY_API_HOST override for pointing the Sanity client at a stand-in API host. |
| src/lib/portable-text.ts | New shared coerceMarkdownContent helper to safely convert Portable Text blocks to markdown strings. |
| src/lib/portable-text.test.ts | Unit tests for coercion behavior (strings passthrough, blocks rendered, no "[object Object]"). |
| src/components/MarkdownRenderer.tsx | Switches renderer to use shared coercion helper (removes duplicated implementation). |
| src/app/api/markdown/[product]/[...slug]/route.ts | Fixes .md endpoint content resolution to handle externalSource and Portable Text bodies. |
| src/app/api/markdown/[product]/[...slug]/route.test.ts | Adds route-level tests for externalSource fetch, fallbacks, HTML guard, image rewriting, 404, and .md stripping. |
| AGENTS.md | Documents SANITY_API_HOST for offline dev/verification workflows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+106
to
+121
| // 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)); | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
curl https://docs.doubleword.ai/inference-api/async-agents.md(alsopr-review-bot.md,cli-examples.md) returns HTTP 200 with the literal body[object Object]. These are Sanity docPages whose real content lives in the top-levelexternalSourcefield (raw GitHub README URLs). Per AGENTS.md, AI agents rely on the.mdendpoints, so this is user-facing breakage.Root cause
Two bugs in
src/app/api/markdown/[product]/[...slug]/route.ts:linkedPost->{body, externalSource}— never the docPage's ownexternalSource— so the handler never fetched the external README and fell through todoc.body.doc.bodyfor these pages is a placeholder Portable Text block array, not a markdown string. Passing the array tonew NextResponse(...)stringifies it (the fetch spec's ToString conversion), producing[object Object].The HTML page route (
src/app/[product]/[...slug]/page.tsx) already resolves content asexternalSource > linkedPost > bodyand coerces Portable Text — the markdown route had drifted from it.Fix
externalSourcein the markdown route's query and resolve content in the same order as the page route:externalSource→linkedPost.externalSource→linkedPost.body→body.externalSourcereturnstext/html(pages that use it only to power the "View source" link), fall back to the Sanity body instead of serving HTML.coerceMarkdownContent(Portable Text → markdown) fromMarkdownRenderer.tsxinto sharedsrc/lib/portable-text.tsand use it in the route, so the endpoint can never emit a stringified object regardless of body shape.SANITY_API_HOSTenv override on the Sanity client (no-op unless set, documented in AGENTS.md) so a local mock Sanity can back offline dev/verification in sandboxed environments that can't reach*.sanity.io.Tests
route.test.ts: externalSource page serves fetched markdown; failed external fetch falls back to coerced Portable Text (asserts no[object Object]); HTML externalSource falls back to body; plain string body + image rewriting unchanged;linkedPost.externalSourcepath unchanged; 404 for unknown docs;.mdsuffix stripping.portable-text.test.ts: coercion contract (strings pass through, blocks render, non-strings never stringify to[object Object]).Verification
Ran
npm run devagainst a local mock Sanity API (viaSANITY_API_HOST) returning the async-agents doc (Portable Text placeholder body + realexternalSourceURL) and a normal string-body page:curl localhost:3000/inference-api/async-agents.md→ HTTP 200, body[object Object]— reproduces the production symptom exactly.raw.githubusercontent.com/doublewordai/use-cases/.../async-agents/README.md("# Async Agents: Deep Research in a Day for $0.34 …").quickstart.md) serves its markdown string unchanged before and after.vitest run(51 tests, only pre-existingsearch.test.ts > stripMarkdownfailure, which also fails on a clean checkout ofmain),tsc --noEmitclean, eslint introduces no new findings. The Vercel preview build for this PR passed (deployment Ready), which coversnpm run build(not runnable in this sandbox — no Sanity network access).Preview spot-check (sandbox can't reach
*.vercel.app, so please click — each should be markdown, not[object Object]):https://claude.ai/code/session_017nS4EWobkNNbu1jCGZ92LL