Skip to content

fix(api): return content images as markdown in API and MCP responses#317

Merged
mortondev merged 6 commits into
mainfrom
fix/api-markdown-images
Jun 29, 2026
Merged

fix(api): return content images as markdown in API and MCP responses#317
mortondev merged 6 commits into
mainfrom
fix/api-markdown-images

Conversation

@mortondev

Copy link
Copy Markdown
Member

What

Fixes #292. The API returned post/changelog/article content as markdown but dropped images, so a body that should read ![Alt](https://.../image.png) came back as text only.

Root cause

Rich content is stored in two columns: contentJson (the canonical TipTap tree, with rehosted absolute image URLs) and content (a markdown projection). The API and MCP returned the content column, 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. contentJson always keeps them.

Fix

Two layers, both built on one new helper, contentJsonToMarkdown(contentJson, fallback) in markdown-tiptap.ts. It renders markdown from contentJson only when an image node is present (restoring ![alt](src)), and otherwise returns the stored markdown verbatim, so image-free content is never reformatted. It falls back to the stored column when contentJson is absent (legacy rows / perf-trimmed list queries) or cannot be serialized.

  1. Read-time (c9fccbf2): API + MCP response formatters render content through the helper. This fixes existing rows immediately with no data migration. Covers REST changelog / posts / help-center (detail, list, create, update) + the apps post-create echo, and MCP get_details for post / changelog / article.

  2. Write-time (3b5fd23a): the three content services (post, changelog, help-center) now regenerate the stored content from the rehosted contentJson on 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 omits contentJson for performance).

Scope notes

  • Comments and chat/conversations are unchanged: their editor excludes images (server and client), so there is nothing to restore.
  • users/{id} engaged posts intentionally return a 200-char text preview, left as-is.
  • No backfill. Existing rows are already correct on REST + MCP detail via the read-time fallback; the webhook/list surfaces self-heal on the next save of each entity.
  • Image-free content is stored and returned byte-for-byte unchanged, so this is a no-op for the common case.

Testing

  • New unit tests for the helper (image restore, image-free verbatim, null/undefined/empty/malformed fallback).
  • New route test asserting the changelog GET endpoint returns ![alt](src) from contentJson and falls back for legacy rows.
  • New service test asserting write-time create stores the image markdown in the content column.
  • bun run typecheck, eslint, and the full posts / changelog / help-center / routes / mcp suites pass (511 tests).

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 ![alt](src)), 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/lib/server/markdown-tiptap.ts Outdated
…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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/lib/server/markdown-tiptap.ts Outdated
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 ![alt](src) (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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/lib/server/markdown-tiptap.ts Outdated
Comment thread apps/web/src/lib/server/domains/changelog/changelog.service.ts
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.
@mortondev mortondev merged commit ae6a24f into main Jun 29, 2026
7 checks passed
@mortondev mortondev deleted the fix/api-markdown-images branch June 29, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API output issue

1 participant