fix(api): return content images as markdown in API and MCP responses#317
Conversation
The REST API and MCP returned the stored `content` column, which silently drops images: the editor's resizable-image node has no markdown serializer, so the client-computed markdown never included them. contentJson is the source of truth and keeps image nodes with their rehosted URLs. Add a contentJsonToMarkdown() helper that renders markdown from contentJson (restoring images as ), but only when an image node is present, so image-free content keeps its stored markdown verbatim. Apply it at the changelog, posts, help-center, and apps post-create responses plus the MCP get_details formatters. Legacy rows without contentJson fall back to the stored column, so existing entries are fixed with no backfill. Fixes #292
Webhook and notification payloads, and the help-center list endpoint, read the stored `content` column directly rather than through the API formatters, so they kept emitting image-stripped markdown that the read-time fix could not reach. Regenerate `content` from the rehosted contentJson on create and update via the same image-gated helper, so the column is faithful for every consumer. Image-free content is stored verbatim (the helper returns the input unchanged), so only image-bearing rows change. Existing rows need no backfill: the REST and MCP read paths already restore images via the read-time fallback, and the stored column self-heals on the next save of each entity.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b5fd23aa6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…onvention The article formatter was briefly extracted to a -serialize.ts module, but the dominant v1 route convention (changelog, posts, boards, ...) keeps the response formatter inline per route file. Inline it in both article routes to match, keeping the contentJson markdown rendering.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d5b84bea17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The editor stores uploaded images as `resizableImage` nodes (per sanitize-tiptap's allowlist and rehost-images' IMAGE_NODE_TYPES), while only markdown parsed via markdownToTiptapJson yields the plain `image` node. The helper detected and serialized only `image`, so posts/changelogs/articles created through the UI still lost their images on both the read and write paths. Detect both node types, and normalize `resizableImage` to `image` before serializing so @tiptap/markdown's Image extension emits  (the resizable node shares the src/alt attrs but has no markdown spec of its own).
hasImageNode runs before the serialize try/catch and walked `node.content` with `.some` unconditionally, so a malformed or legacy row whose `content` is present but not an array threw a read into a 500 instead of falling back to the stored markdown. Guard both tree-walks (hasImageNode, normalizeImageNodes) with Array.isArray so they stay total, matching the documented fail-soft contract.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b725d06d3f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Re-serializing runs through the narrower server markdown manager, which has no extension for the editor's custom nodes (youtube, quackbackEmbed, emoji, ...). A document that mixed an image with one of those would keep the image but drop the other node from API/MCP output and the regenerated content column. Gate re-serialization on a node allowlist: only re-derive markdown when every node is representable, normalizing `resizableImage` to `image` and `mention` to its `@label` text along the way. A document containing any other custom node keeps its stored markdown instead, so nothing is silently lost. Also document why the update path's `existing.content` fallback is safe: every content edit carries `input.content`, so a contentJson-only edit (the only path that could leave a stale image) never happens.
What
Fixes #292. The API returned post/changelog/article content as markdown but dropped images, so a body that should read
came back as text only.Root cause
Rich content is stored in two columns:
contentJson(the canonical TipTap tree, with rehosted absolute image URLs) andcontent(a markdown projection). The API and MCP returned thecontentcolumn, but that column is lossy for images: the editor's resizable-image node has no markdown serializer, so the client-computed markdown never included images in the first place.contentJsonalways keeps them.Fix
Two layers, both built on one new helper,
contentJsonToMarkdown(contentJson, fallback)inmarkdown-tiptap.ts. It renders markdown fromcontentJsononly when an image node is present (restoring), and otherwise returns the stored markdown verbatim, so image-free content is never reformatted. It falls back to the stored column whencontentJsonis absent (legacy rows / perf-trimmed list queries) or cannot be serialized.Read-time (
c9fccbf2): API + MCP response formatters rendercontentthrough the helper. This fixes existing rows immediately with no data migration. Covers REST changelog / posts / help-center (detail, list, create, update) + theappspost-create echo, and MCPget_detailsfor post / changelog / article.Write-time (
3b5fd23a): the three content services (post,changelog,help-center) now regenerate the storedcontentfrom the rehostedcontentJsonon create and update, using the same helper. This makes the column itself faithful, so the surfaces that read it directly rather than through a formatter are correct too: webhook + notification payloads, and the help-center list endpoint (which omitscontentJsonfor performance).Scope notes
users/{id}engaged posts intentionally return a 200-char text preview, left as-is.Testing
fromcontentJsonand falls back for legacy rows.contentcolumn.bun run typecheck,eslint, and the full posts / changelog / help-center / routes / mcp suites pass (511 tests).