From c9fccbf2dfa29c719cd3e7621cb7bece675813be Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 22:42:06 +0100 Subject: [PATCH 1/6] fix(api): return content images as markdown in API and MCP responses 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 --- .../server/__tests__/markdown-tiptap.test.ts | 55 ++++++++++++ apps/web/src/lib/server/markdown-tiptap.ts | 34 ++++++++ apps/web/src/lib/server/mcp/tools.ts | 9 +- apps/web/src/routes/api/v1/apps/posts.ts | 3 +- .../src/routes/api/v1/changelog/$entryId.ts | 5 +- .../v1/changelog/__tests__/$entryId.test.ts | 87 +++++++++++++++++++ apps/web/src/routes/api/v1/changelog/index.ts | 5 +- .../api/v1/help-center/articles/$articleId.ts | 33 +------ .../api/v1/help-center/articles/-serialize.ts | 41 +++++++++ .../api/v1/help-center/articles/index.ts | 39 ++------- apps/web/src/routes/api/v1/posts/$postId.ts | 5 +- apps/web/src/routes/api/v1/posts/index.ts | 5 +- 12 files changed, 245 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts create mode 100644 apps/web/src/routes/api/v1/help-center/articles/-serialize.ts diff --git a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts index 09c7c04af..5cf4266af 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest' import { markdownToTiptapJson, tiptapJsonToMarkdown, + contentJsonToMarkdown, commentMarkdownToTiptapJson, } from '../markdown-tiptap' @@ -177,6 +178,60 @@ describe('tiptapJsonToMarkdown', () => { }) }) +describe('contentJsonToMarkdown', () => { + const imageDoc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Shipped a thing.' }] }, + { + type: 'image', + attrs: { src: 'https://cdn.example.com/shot.png', alt: 'Screenshot', title: null }, + }, + ], + } + + test('serializes image nodes the stored markdown dropped', () => { + // The reported bug: the API returned text-only markdown because the stored + // `content` column lost images. Deriving from contentJson restores them. + const result = contentJsonToMarkdown(imageDoc, 'Shipped a thing.') + expect(result).toContain('Shipped a thing.') + expect(result).toContain('![Screenshot](https://cdn.example.com/shot.png)') + }) + + test('returns the stored markdown verbatim for image-free content', () => { + // No images means the stored column is already faithful; don't re-serialize + // (and risk reformatting) what was correct. + const noImageDoc = { + type: 'doc' as const, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Just text' }] }], + } + expect(contentJsonToMarkdown(noImageDoc, '_Just_ text')).toBe('_Just_ text') + }) + + test.each([null, undefined])( + 'falls back to stored markdown when contentJson is %s (legacy rows)', + (value) => { + expect(contentJsonToMarkdown(value, '# Legacy\n\nPlain markdown')).toBe( + '# Legacy\n\nPlain markdown' + ) + } + ) + + test('falls back when contentJson has no real content', () => { + expect(contentJsonToMarkdown({ type: 'doc', content: [] }, 'fallback text')).toBe( + 'fallback text' + ) + }) + + test('falls back instead of throwing on malformed contentJson', () => { + // A corrupt/unexpected shape must never 500 a read endpoint. + const malformed = { not: 'a real doc' } as unknown as Parameters< + typeof contentJsonToMarkdown + >[0] + expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback') + }) +}) + describe('commentMarkdownToTiptapJson', () => { test('plain text becomes a paragraph', () => { const result = commentMarkdownToTiptapJson('Hello world') diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index 9a7ff62a6..874c62162 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -72,6 +72,40 @@ export function tiptapJsonToMarkdown(json: TiptapContent | JSONContent): string return manager.serialize(json as JSONContent) } +/** + * Render an entity's markdown for output (API / MCP responses), preferring the + * stored `content` column but restoring images from the canonical `contentJson`. + * + * The stored markdown is faithful for everything except images: the editor's + * resizable-image node has no markdown serializer, so client-computed markdown + * silently dropped them. `contentJson` keeps the images (with rehosted src), so + * only when it carries an image do we re-serialize it to put them back as + * `![alt](src)`. Image-free content returns the stored markdown verbatim — no + * reason to pay for, or risk reformatting from, a re-serialize. + * + * Falls back to the stored markdown when `contentJson` is absent (legacy rows, + * or list queries that omit it for performance) or can't be serialized — a read + * path must never fail over content shape. + */ +export function contentJsonToMarkdown( + contentJson: TiptapContent | JSONContent | null | undefined, + fallback: string +): string { + if (!contentJson || !hasImageNode(contentJson)) return fallback + try { + const markdown = tiptapJsonToMarkdown(contentJson) + return markdown.trim() ? markdown : fallback + } catch { + return fallback + } +} + +/** Depth-first scan for an image node anywhere in a TipTap tree. */ +function hasImageNode(node: JSONContent): boolean { + if (node.type === 'image') return true + return node.content?.some(hasImageNode) ?? false +} + /** * Slim extension set for comments — no images, no tables, no YouTube. * Comments are short, dense, and inline; we want the safe subset only. diff --git a/apps/web/src/lib/server/mcp/tools.ts b/apps/web/src/lib/server/mcp/tools.ts index 62cb7bf3e..10201807b 100644 --- a/apps/web/src/lib/server/mcp/tools.ts +++ b/apps/web/src/lib/server/mcp/tools.ts @@ -94,6 +94,8 @@ import { import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { DomainException } from '@/lib/shared/errors' import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import { realEmail } from '@/lib/shared/anonymous-email' import type { McpAuthContext, McpScope } from './types' import type { @@ -216,6 +218,7 @@ function articleResult(article: { slug: string title: string content: string + contentJson: TiptapContent | null description: string | null position: number | null category: { id: string; slug: string; name: string } @@ -231,7 +234,7 @@ function articleResult(article: { id: article.id, slug: article.slug, title: article.title, - content: article.content, + content: contentJsonToMarkdown(article.contentJson, article.content), description: article.description, position: article.position, category: article.category, @@ -2481,7 +2484,7 @@ async function getPostDetails(postId: PostId): Promise { return jsonResult({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), voteCount: post.voteCount, commentCount: post.commentCount, boardId: post.boardId, @@ -2525,7 +2528,7 @@ async function getChangelogDetails(changelogId: ChangelogId): Promise ({ diff --git a/apps/web/src/routes/api/v1/apps/posts.ts b/apps/web/src/routes/api/v1/apps/posts.ts index 9c2fbc2c2..39f7e4be2 100644 --- a/apps/web/src/routes/api/v1/apps/posts.ts +++ b/apps/web/src/routes/api/v1/apps/posts.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { BoardId, PostId } from '@quackback/ids' import { appJsonResponse, preflightResponse } from '@/lib/server/integrations/apps/cors' import { segmentIdsForPrincipal } from '@/lib/server/domains/segments/segment-membership.service' @@ -117,7 +118,7 @@ export const Route = createFileRoute('/api/v1/apps/posts')({ { id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), voteCount: result.voteCount, boardId: result.boardId, statusId: result.statusId, diff --git a/apps/web/src/routes/api/v1/changelog/$entryId.ts b/apps/web/src/routes/api/v1/changelog/$entryId.ts index 3b8240afb..98d256ac5 100644 --- a/apps/web/src/routes/api/v1/changelog/$entryId.ts +++ b/apps/web/src/routes/api/v1/changelog/$entryId.ts @@ -13,6 +13,8 @@ import { updateChangelog, deleteChangelog, } from '@/lib/server/domains/changelog/changelog.service' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { PublishState } from '@/lib/shared/schemas/changelog' import type { ChangelogId } from '@quackback/ids' @@ -28,6 +30,7 @@ function formatChangelogResponse(entry: { id: string title: string content: string + contentJson: TiptapContent | null publishedAt: Date | null displayDate: Date | null createdAt: Date @@ -36,7 +39,7 @@ function formatChangelogResponse(entry: { return { id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), diff --git a/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts b/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts new file mode 100644 index 000000000..a8a6ef90c --- /dev/null +++ b/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChangelogId } from '@quackback/ids' + +const mockWithApiKeyAuth = vi.fn() +const mockGetChangelogById = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/api/auth', () => ({ + withApiKeyAuth: (...args: unknown[]) => mockWithApiKeyAuth(...args), +})) +vi.mock('@/lib/server/domains/changelog/changelog.service', () => ({ + getChangelogById: (...args: unknown[]) => mockGetChangelogById(...args), + updateChangelog: vi.fn(), + deleteChangelog: vi.fn(), +})) + +// markdown-tiptap is intentionally NOT mocked — the point of these tests is the +// real contentJson -> markdown serialization, including image nodes. + +import { Route } from '../$entryId' + +type RouteOpts = { + server: { handlers: { GET: (...args: unknown[]) => Promise } } +} +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +const ENTRY_ID = 'changelog_01h455vb4pex5vsknk084sn02q' as unknown as ChangelogId + +function baseEntry() { + return { + id: ENTRY_ID, + title: 'Dark mode', + content: 'We shipped dark mode.', + contentJson: null as unknown, + publishedAt: new Date('2026-01-01T00:00:00.000Z'), + displayDate: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } +} + +describe('GET /api/v1/changelog/:entryId — markdown image output', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWithApiKeyAuth.mockResolvedValue({ principalId: 'principal_x', role: 'team' }) + }) + + it('renders images from contentJson as markdown that the stored content dropped', async () => { + mockGetChangelogById.mockResolvedValue({ + ...baseEntry(), + // Stored markdown column lost the image (client serializer has no spec + // for the image node); contentJson is the source of truth. + content: 'We shipped dark mode.', + contentJson: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'We shipped dark mode.' }] }, + { + type: 'image', + attrs: { src: 'https://cdn.example.com/dark.png', alt: 'Dark mode', title: null }, + }, + ], + }, + }) + + const res = await GET({ request: new Request('http://t/'), params: { entryId: ENTRY_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.content).toContain('We shipped dark mode.') + expect(json.data.content).toContain('![Dark mode](https://cdn.example.com/dark.png)') + }) + + it('falls back to the stored content for legacy rows without contentJson', async () => { + mockGetChangelogById.mockResolvedValue({ + ...baseEntry(), + content: '# Legacy entry\n\nPlain text.', + contentJson: null, + }) + + const res = await GET({ request: new Request('http://t/'), params: { entryId: ENTRY_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.content).toBe('# Legacy entry\n\nPlain text.') + }) +}) diff --git a/apps/web/src/routes/api/v1/changelog/index.ts b/apps/web/src/routes/api/v1/changelog/index.ts index 5ce5a31fc..0e3d6812c 100644 --- a/apps/web/src/routes/api/v1/changelog/index.ts +++ b/apps/web/src/routes/api/v1/changelog/index.ts @@ -10,6 +10,7 @@ import { import { createChangelog } from '@/lib/server/domains/changelog/changelog.service' import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query' import { publishedAtToPublishState } from '@/lib/shared/schemas/changelog' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { db, principal, eq } from '@/lib/server/db' import type { PostId } from '@quackback/ids' @@ -52,7 +53,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ result.items.map((entry) => ({ id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), @@ -113,7 +114,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ return createdResponse({ id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), diff --git a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts index fe3feebe5..a07d36431 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts @@ -17,6 +17,7 @@ import { unpublishArticle, deleteArticle, } from '@/lib/server/domains/help-center/help-center.service' +import { formatArticle } from './-serialize' import type { HelpCenterArticleId, PrincipalId } from '@quackback/ids' const updateArticleBody = z.object({ @@ -29,38 +30,6 @@ const updateArticleBody = z.object({ authorId: z.string().optional(), }) -function formatArticle(article: { - id: string - slug: string - title: string - description: string | null - content: string - publishedAt: Date | null - viewCount: number - helpfulCount: number - notHelpfulCount: number - createdAt: Date - updatedAt: Date - category: { id: string; slug: string; name: string } - author: { id: string; name: string; avatarUrl: string | null } | null -}) { - return { - id: article.id, - slug: article.slug, - title: article.title, - description: article.description, - content: article.content, - publishedAt: article.publishedAt?.toISOString() || null, - viewCount: article.viewCount, - helpfulCount: article.helpfulCount, - notHelpfulCount: article.notHelpfulCount, - createdAt: article.createdAt.toISOString(), - updatedAt: article.updatedAt.toISOString(), - category: article.category, - author: article.author, - } -} - export const Route = createFileRoute('/api/v1/help-center/articles/$articleId')({ server: { handlers: { diff --git a/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts b/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts new file mode 100644 index 000000000..b046641a3 --- /dev/null +++ b/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts @@ -0,0 +1,41 @@ +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' + +/** + * Public, stable help-center article shape for the read API. `content` is + * rendered from the canonical `contentJson` so images survive as markdown + * (see {@link contentJsonToMarkdown}); list queries pass `contentJson: null` + * and fall back to the stored column. + */ +export function formatArticle(article: { + id: string + slug: string + title: string + description: string | null + content: string + contentJson: TiptapContent | null + publishedAt: Date | null + viewCount: number + helpfulCount: number + notHelpfulCount: number + createdAt: Date + updatedAt: Date + category: { id: string; slug: string; name: string } + author: { id: string; name: string; avatarUrl: string | null } | null +}) { + return { + id: article.id, + slug: article.slug, + title: article.title, + description: article.description, + content: contentJsonToMarkdown(article.contentJson, article.content), + publishedAt: article.publishedAt?.toISOString() || null, + viewCount: article.viewCount, + helpfulCount: article.helpfulCount, + notHelpfulCount: article.notHelpfulCount, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + category: article.category, + author: article.author, + } +} diff --git a/apps/web/src/routes/api/v1/help-center/articles/index.ts b/apps/web/src/routes/api/v1/help-center/articles/index.ts index 89854c04f..dbb19c785 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/index.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/index.ts @@ -12,6 +12,7 @@ import { import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { listArticles, createArticle } from '@/lib/server/domains/help-center/help-center.service' +import { formatArticle } from './-serialize' import type { PrincipalId } from '@quackback/ids' const createArticleBody = z.object({ @@ -23,38 +24,6 @@ const createArticleBody = z.object({ authorId: z.string().optional(), }) -function formatArticle(article: { - id: string - slug: string - title: string - description: string | null - content: string - publishedAt: Date | null - viewCount: number - helpfulCount: number - notHelpfulCount: number - createdAt: Date - updatedAt: Date - category: { id: string; slug: string; name: string } - author: { id: string; name: string; avatarUrl: string | null } | null -}) { - return { - id: article.id, - slug: article.slug, - title: article.title, - description: article.description, - content: article.content, - publishedAt: article.publishedAt?.toISOString() || null, - viewCount: article.viewCount, - helpfulCount: article.helpfulCount, - notHelpfulCount: article.notHelpfulCount, - createdAt: article.createdAt.toISOString(), - updatedAt: article.updatedAt.toISOString(), - category: article.category, - author: article.author, - } -} - export const Route = createFileRoute('/api/v1/help-center/articles/')({ server: { handlers: { @@ -100,7 +69,11 @@ export const Route = createFileRoute('/api/v1/help-center/articles/')({ const { authorId, ...articleData } = parsed.data - const authorPrincipalId = parseOptionalTypeId(authorId, 'principal', 'author ID') + const authorPrincipalId = parseOptionalTypeId( + authorId, + 'principal', + 'author ID' + ) const article = await createArticle( articleData, diff --git a/apps/web/src/routes/api/v1/posts/$postId.ts b/apps/web/src/routes/api/v1/posts/$postId.ts index d5e6a3cd5..fb5b69a6c 100644 --- a/apps/web/src/routes/api/v1/posts/$postId.ts +++ b/apps/web/src/routes/api/v1/posts/$postId.ts @@ -13,6 +13,7 @@ import { parseOptionalTypeId, parseTypeIdArray, } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { PostId, StatusId, TagId, PrincipalId } from '@quackback/ids' import type { MergedPostSummary } from '@/lib/server/domains/posts/post.types' @@ -49,7 +50,7 @@ export const Route = createFileRoute('/api/v1/posts/$postId')({ return successResponse({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), contentJson: post.contentJson, voteCount: post.voteCount, commentCount: post.commentCount, @@ -141,7 +142,7 @@ export const Route = createFileRoute('/api/v1/posts/$postId')({ return successResponse({ id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), contentJson: result.contentJson, voteCount: result.voteCount, boardId: result.boardId, diff --git a/apps/web/src/routes/api/v1/posts/index.ts b/apps/web/src/routes/api/v1/posts/index.ts index c96e714a5..9e8bf5603 100644 --- a/apps/web/src/routes/api/v1/posts/index.ts +++ b/apps/web/src/routes/api/v1/posts/index.ts @@ -13,6 +13,7 @@ import { parseOptionalTypeId, parseTypeIdArray, } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { BoardId, PrincipalId, StatusId, TagId } from '@quackback/ids' import { segmentIdsForPrincipal } from '@/lib/server/domains/segments/segment-membership.service' @@ -91,7 +92,7 @@ export const Route = createFileRoute('/api/v1/posts/')({ result.items.map((post) => ({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), voteCount: post.voteCount, commentCount: post.commentCount, boardId: post.boardId, @@ -239,7 +240,7 @@ export const Route = createFileRoute('/api/v1/posts/')({ return createdResponse({ id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), voteCount: result.voteCount, boardId: result.boardId, statusId: result.statusId, From 3b5fd23aa68397bc03dabf5ceff6c26871326e41 Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 22:58:23 +0100 Subject: [PATCH 2/6] fix(content): store the markdown projection of contentJson at write time 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. --- .../__tests__/changelog-notified-at.test.ts | 22 +++++++++++++++++++ .../domains/changelog/changelog.service.ts | 14 ++++++++---- .../help-center-article.service.test.ts | 1 + .../help-center.article.service.ts | 20 ++++++++++++----- .../__tests__/post-create-service.test.ts | 1 + .../posts/__tests__/post-tier-limits.test.ts | 1 + .../__tests__/post.service-mentions.test.ts | 1 + .../lib/server/domains/posts/post.service.ts | 14 ++++++++---- 8 files changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts b/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts index 260cf85a7..9a1d82afd 100644 --- a/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts +++ b/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts @@ -232,6 +232,28 @@ describe('createChangelog wiring', () => { expect(dispatchChangelogPublished).not.toHaveBeenCalled() }) + + it('stores the markdown projection of contentJson so images reach the content column', async () => { + // Write-time regen: the stored `content` column must carry the image even + // when the caller-supplied markdown would have, so downstream consumers + // that read the column directly (webhooks, notifications) get it too. + const { createChangelog } = await import('../changelog.service') + + await createChangelog( + { + title: 'X', + content: '![Shot](https://cdn.example.com/shot.png)', + publishState: { type: 'draft' }, + }, + AUTHOR + ) + + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('![Shot](https://cdn.example.com/shot.png)'), + }) + ) + }) }) describe('updateChangelog wiring', () => { diff --git a/apps/web/src/lib/server/domains/changelog/changelog.service.ts b/apps/web/src/lib/server/domains/changelog/changelog.service.ts index e26384560..495354299 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.service.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.service.ts @@ -25,7 +25,7 @@ import { } from '@/lib/server/db' import type { ChangelogId, PrincipalId, PostId } from '@quackback/ids' import { NotFoundError, ValidationError } from '@/lib/shared/errors' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { buildEventActor, @@ -105,7 +105,9 @@ export async function createChangelog( .insert(changelogEntries) .values({ title, - content, + // Store the markdown projection of the canonical contentJson so every + // consumer of the `content` column (webhooks, notifications) sees images. + content: contentJsonToMarkdown(contentJson, content), contentJson, principalId: author.principalId, publishedAt, @@ -180,13 +182,17 @@ export async function updateChangelog( } if (input.title !== undefined) updateData.title = input.title.trim() - if (input.content !== undefined) updateData.content = input.content.trim() if (input.contentJson !== undefined || input.content !== undefined) { const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'changelog', principalId: existing.principalId ?? undefined, }) + updateData.contentJson = contentJson + updateData.content = contentJsonToMarkdown( + contentJson, + (input.content ?? existing.content).trim() + ) } if (input.displayDate !== undefined) { diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts index 72ba867b9..d7707143e 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts @@ -122,6 +122,7 @@ vi.mock('@/lib/server/db', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) let getArticleById: typeof import('../help-center.article.service').getArticleById diff --git a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts index 2131d3b64..eea670df3 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts @@ -14,7 +14,7 @@ import { import type { HelpCenterArticleId, HelpCenterCategoryId, PrincipalId } from '@quackback/ids' import { NotFoundError, ValidationError } from '@/lib/shared/errors' import { isTeamMember } from '@/lib/shared/roles' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { slugify } from '@/lib/shared/utils' import { uniqueHelpCenterSlug } from './help-center.slug' @@ -182,7 +182,9 @@ export async function createArticle( .values({ categoryId: input.categoryId as HelpCenterCategoryId, title, - content, + // Store the markdown projection of the canonical contentJson so the + // article list endpoint (which omits contentJson) still serves images. + content: contentJsonToMarkdown(contentJson, content), contentJson, slug, principalId: effectivePrincipalId, @@ -209,13 +211,19 @@ export async function updateArticle( const updateData: Partial = { updatedAt: new Date() } if (input.title !== undefined) updateData.title = input.title.trim() if (input.content !== undefined || input.contentJson !== undefined) { - if (input.content !== undefined) { - updateData.content = input.content.trim() - } const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'help-center', }) + updateData.contentJson = contentJson + if (input.content !== undefined) { + updateData.content = contentJsonToMarkdown(contentJson, input.content.trim()) + } else { + // contentJson-only edit (no markdown source): refresh the stored column + // only when the tree carries images, else leave it as-is. + const regenerated = contentJsonToMarkdown(contentJson, '') + if (regenerated) updateData.content = regenerated + } } if (input.categoryId !== undefined) updateData.categoryId = input.categoryId as HelpCenterCategoryId diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts index a24607e61..54f528326 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts @@ -143,6 +143,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({})), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts index 49a0b8301..6b4548138 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts @@ -61,6 +61,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({})), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts index 4ed730e01..e3c24c963 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts @@ -195,6 +195,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/post.service.ts b/apps/web/src/lib/server/domains/posts/post.service.ts index b4cf47588..4aaa9248e 100644 --- a/apps/web/src/lib/server/domains/posts/post.service.ts +++ b/apps/web/src/lib/server/domains/posts/post.service.ts @@ -43,7 +43,7 @@ import { import { announcePublishedPost } from './post.announce' import { NotFoundError, ValidationError } from '@/lib/shared/errors' import { recordAuditEvent } from '@/lib/server/audit/log' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { subscribeToPost } from '@/lib/server/domains/subscriptions/subscription.service' import type { CreatePostInput, UpdatePostInput, CreatePostResult } from './post.types' @@ -216,7 +216,9 @@ export async function createPost( .values({ boardId: input.boardId, title, - content, + // Store the markdown projection of the canonical contentJson so every + // consumer of the `content` column (webhooks, notifications) sees images. + content: contentJsonToMarkdown(contentJson, content), contentJson, statusId, principalId: author.principalId, @@ -375,13 +377,17 @@ export async function updatePost( // Build update data const updateData: Partial = {} if (input.title !== undefined) updateData.title = input.title.trim() - if (input.content !== undefined) updateData.content = input.content.trim() if (input.contentJson !== undefined || input.content !== undefined) { const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'post', principalId: existingPost.principalId, }) + updateData.contentJson = contentJson + updateData.content = contentJsonToMarkdown( + contentJson, + (input.content ?? existingPost.content).trim() + ) } if (input.statusId !== undefined) updateData.statusId = input.statusId if (input.ownerPrincipalId !== undefined) updateData.ownerPrincipalId = input.ownerPrincipalId From d5b84bea1766a0ec48ed0f5a1b2087dd20de77c5 Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 23:08:58 +0100 Subject: [PATCH 3/6] refactor(help-center): inline the article serializer to match route convention 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. --- .../api/v1/help-center/articles/$articleId.ts | 38 ++++++++++++++++- .../api/v1/help-center/articles/-serialize.ts | 41 ------------------- .../api/v1/help-center/articles/index.ts | 39 +++++++++++++++++- 3 files changed, 75 insertions(+), 43 deletions(-) delete mode 100644 apps/web/src/routes/api/v1/help-center/articles/-serialize.ts diff --git a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts index a07d36431..88b862ab5 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts @@ -17,7 +17,8 @@ import { unpublishArticle, deleteArticle, } from '@/lib/server/domains/help-center/help-center.service' -import { formatArticle } from './-serialize' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { HelpCenterArticleId, PrincipalId } from '@quackback/ids' const updateArticleBody = z.object({ @@ -30,6 +31,41 @@ const updateArticleBody = z.object({ authorId: z.string().optional(), }) +function formatArticle(article: { + id: string + slug: string + title: string + description: string | null + content: string + contentJson: TiptapContent | null + publishedAt: Date | null + viewCount: number + helpfulCount: number + notHelpfulCount: number + createdAt: Date + updatedAt: Date + category: { id: string; slug: string; name: string } + author: { id: string; name: string; avatarUrl: string | null } | null +}) { + return { + id: article.id, + slug: article.slug, + title: article.title, + description: article.description, + // Render markdown from the canonical contentJson so images survive; falls + // back to the stored column for legacy rows. + content: contentJsonToMarkdown(article.contentJson, article.content), + publishedAt: article.publishedAt?.toISOString() || null, + viewCount: article.viewCount, + helpfulCount: article.helpfulCount, + notHelpfulCount: article.notHelpfulCount, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + category: article.category, + author: article.author, + } +} + export const Route = createFileRoute('/api/v1/help-center/articles/$articleId')({ server: { handlers: { diff --git a/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts b/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts deleted file mode 100644 index b046641a3..000000000 --- a/apps/web/src/routes/api/v1/help-center/articles/-serialize.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' -import type { TiptapContent } from '@/lib/server/db' - -/** - * Public, stable help-center article shape for the read API. `content` is - * rendered from the canonical `contentJson` so images survive as markdown - * (see {@link contentJsonToMarkdown}); list queries pass `contentJson: null` - * and fall back to the stored column. - */ -export function formatArticle(article: { - id: string - slug: string - title: string - description: string | null - content: string - contentJson: TiptapContent | null - publishedAt: Date | null - viewCount: number - helpfulCount: number - notHelpfulCount: number - createdAt: Date - updatedAt: Date - category: { id: string; slug: string; name: string } - author: { id: string; name: string; avatarUrl: string | null } | null -}) { - return { - id: article.id, - slug: article.slug, - title: article.title, - description: article.description, - content: contentJsonToMarkdown(article.contentJson, article.content), - publishedAt: article.publishedAt?.toISOString() || null, - viewCount: article.viewCount, - helpfulCount: article.helpfulCount, - notHelpfulCount: article.notHelpfulCount, - createdAt: article.createdAt.toISOString(), - updatedAt: article.updatedAt.toISOString(), - category: article.category, - author: article.author, - } -} diff --git a/apps/web/src/routes/api/v1/help-center/articles/index.ts b/apps/web/src/routes/api/v1/help-center/articles/index.ts index dbb19c785..fae05fa06 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/index.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/index.ts @@ -12,7 +12,8 @@ import { import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { listArticles, createArticle } from '@/lib/server/domains/help-center/help-center.service' -import { formatArticle } from './-serialize' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { PrincipalId } from '@quackback/ids' const createArticleBody = z.object({ @@ -24,6 +25,42 @@ const createArticleBody = z.object({ authorId: z.string().optional(), }) +function formatArticle(article: { + id: string + slug: string + title: string + description: string | null + content: string + contentJson: TiptapContent | null + publishedAt: Date | null + viewCount: number + helpfulCount: number + notHelpfulCount: number + createdAt: Date + updatedAt: Date + category: { id: string; slug: string; name: string } + author: { id: string; name: string; avatarUrl: string | null } | null +}) { + return { + id: article.id, + slug: article.slug, + title: article.title, + description: article.description, + // Render markdown from the canonical contentJson so images survive; falls + // back to the stored column for legacy rows (and the list query, which + // omits contentJson for performance). + content: contentJsonToMarkdown(article.contentJson, article.content), + publishedAt: article.publishedAt?.toISOString() || null, + viewCount: article.viewCount, + helpfulCount: article.helpfulCount, + notHelpfulCount: article.notHelpfulCount, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + category: article.category, + author: article.author, + } +} + export const Route = createFileRoute('/api/v1/help-center/articles/')({ server: { handlers: { From ff5e9211d7149a3d2f1dd9c36ee14e159669ae02 Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 23:14:15 +0100 Subject: [PATCH 4/6] fix(content): serialize resizableImage nodes, not just image 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). --- .../server/__tests__/markdown-tiptap.test.ts | 17 +++++++++++++ apps/web/src/lib/server/markdown-tiptap.ts | 24 ++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts index 5cf4266af..d509d3a28 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -198,6 +198,23 @@ describe('contentJsonToMarkdown', () => { expect(result).toContain('![Screenshot](https://cdn.example.com/shot.png)') }) + test('serializes resizableImage nodes (the type the editor actually stores)', () => { + // UI uploads are stored as `resizableImage`, which @tiptap/markdown's Image + // extension does not know — they must be normalized to `image` first. + const resizableDoc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Look:' }] }, + { + type: 'resizableImage', + attrs: { src: 'https://cdn.example.com/r.png', alt: 'Resized', title: null, width: 400 }, + }, + ], + } + const result = contentJsonToMarkdown(resizableDoc, 'Look:') + expect(result).toContain('![Resized](https://cdn.example.com/r.png)') + }) + test('returns the stored markdown verbatim for image-free content', () => { // No images means the stored column is already faithful; don't re-serialize // (and risk reformatting) what was correct. diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index 874c62162..f1ff62817 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -72,6 +72,13 @@ export function tiptapJsonToMarkdown(json: TiptapContent | JSONContent): string return manager.serialize(json as JSONContent) } +/** + * Image node types found in stored `contentJson`. The editor stores uploads as + * `resizableImage`; markdown parsed via {@link markdownToTiptapJson} yields the + * plain `image`. Mirrors `IMAGE_NODE_TYPES` in content/rehost-images.ts. + */ +const IMAGE_NODE_TYPES = new Set(['image', 'resizableImage']) + /** * Render an entity's markdown for output (API / MCP responses), preferring the * stored `content` column but restoring images from the canonical `contentJson`. @@ -93,19 +100,30 @@ export function contentJsonToMarkdown( ): string { if (!contentJson || !hasImageNode(contentJson)) return fallback try { - const markdown = tiptapJsonToMarkdown(contentJson) + const markdown = tiptapJsonToMarkdown(normalizeImageNodes(contentJson)) return markdown.trim() ? markdown : fallback } catch { return fallback } } -/** Depth-first scan for an image node anywhere in a TipTap tree. */ +/** Depth-first scan for an image node (`image` or `resizableImage`) anywhere in a tree. */ function hasImageNode(node: JSONContent): boolean { - if (node.type === 'image') return true + if (typeof node.type === 'string' && IMAGE_NODE_TYPES.has(node.type)) return true return node.content?.some(hasImageNode) ?? false } +/** + * Rewrite `resizableImage` nodes to plain `image` so @tiptap/markdown's Image + * extension serializes them — the editor's resizable node shares the `src`/`alt` + * attrs but has no markdown spec, so it would otherwise serialize to nothing. + */ +function normalizeImageNodes(node: JSONContent): JSONContent { + const next = node.type === 'resizableImage' ? { ...node, type: 'image' } : node + if (!next.content) return next + return { ...next, content: next.content.map(normalizeImageNodes) } +} + /** * Slim extension set for comments — no images, no tables, no YouTube. * Comments are short, dense, and inline; we want the safe subset only. From b725d06d3ff68262138f8b398ee1ccda9b0cc11d Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 23:15:38 +0100 Subject: [PATCH 5/6] fix(content): keep the image scan fail-soft on malformed contentJson 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. --- .../src/lib/server/__tests__/markdown-tiptap.test.ts | 10 ++++++++++ apps/web/src/lib/server/markdown-tiptap.ts | 10 +++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts index d509d3a28..6914bb0e1 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -247,6 +247,16 @@ describe('contentJsonToMarkdown', () => { >[0] expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback') }) + + test.each([ + ['content is a string', { type: 'doc', content: 'oops' }], + ['content is an object', { type: 'doc', content: { bad: 1 } }], + ])('falls back when %s (image scan must not throw)', (_label, doc) => { + // The image scan runs before the serialize try/catch, so a row whose + // `content` is present but not an array must not throw a read into a 500. + const malformed = doc as unknown as Parameters[0] + expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback') + }) }) describe('commentMarkdownToTiptapJson', () => { diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index f1ff62817..dc1809a2b 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -107,10 +107,14 @@ export function contentJsonToMarkdown( } } -/** Depth-first scan for an image node (`image` or `resizableImage`) anywhere in a tree. */ +/** + * Depth-first scan for an image node (`image` or `resizableImage`) anywhere in a + * tree. Runs before the serialize try/catch, so it must stay total: a malformed + * row whose `content` is present but not an array must not throw. + */ function hasImageNode(node: JSONContent): boolean { if (typeof node.type === 'string' && IMAGE_NODE_TYPES.has(node.type)) return true - return node.content?.some(hasImageNode) ?? false + return Array.isArray(node.content) ? node.content.some(hasImageNode) : false } /** @@ -120,7 +124,7 @@ function hasImageNode(node: JSONContent): boolean { */ function normalizeImageNodes(node: JSONContent): JSONContent { const next = node.type === 'resizableImage' ? { ...node, type: 'image' } : node - if (!next.content) return next + if (!Array.isArray(next.content)) return next return { ...next, content: next.content.map(normalizeImageNodes) } } From 76e484141e8267d725081534e4b741671ccda53c Mon Sep 17 00:00:00 2001 From: James Morton Date: Mon, 29 Jun 2026 23:37:57 +0100 Subject: [PATCH 6/6] fix(content): don't drop non-image nodes when restoring images 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. --- .../server/__tests__/markdown-tiptap.test.ts | 36 ++++++++++ .../domains/changelog/changelog.service.ts | 4 ++ .../lib/server/domains/posts/post.service.ts | 4 ++ apps/web/src/lib/server/markdown-tiptap.ts | 70 ++++++++++++++++--- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts index 6914bb0e1..d54487a84 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -215,6 +215,42 @@ describe('contentJsonToMarkdown', () => { expect(result).toContain('![Resized](https://cdn.example.com/r.png)') }) + test('keeps mentions (as @label) when restoring an image', () => { + // The server manager has no mention extension, so re-serializing must not + // drop it; normalize it to the @label text instead. + const doc = { + type: 'doc' as const, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'cc ' }, + { type: 'mention', attrs: { id: 'p1', label: 'Alice' } }, + ], + }, + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + ], + } + const result = contentJsonToMarkdown(doc, 'cc @Alice') + expect(result).toContain('@Alice') + expect(result).toContain('![S](https://cdn.example.com/s.png)') + }) + + test('keeps stored markdown when an image coexists with an unsupported node', () => { + // A youtube embed has no server renderer; re-serializing would drop it, so + // the whole document keeps its stored markdown (image not re-derived) rather + // than losing the embed. + const doc = { + type: 'doc' as const, + content: [ + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + { type: 'youtube', attrs: { src: 'https://youtu.be/abc' } }, + ], + } + const stored = 'stored markdown with :::youtube::: and no image' + expect(contentJsonToMarkdown(doc, stored)).toBe(stored) + }) + test('returns the stored markdown verbatim for image-free content', () => { // No images means the stored column is already faithful; don't re-serialize // (and risk reformatting) what was correct. diff --git a/apps/web/src/lib/server/domains/changelog/changelog.service.ts b/apps/web/src/lib/server/domains/changelog/changelog.service.ts index 495354299..9115f1cfb 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.service.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.service.ts @@ -189,6 +189,10 @@ export async function updateChangelog( principalId: existing.principalId ?? undefined, }) updateData.contentJson = contentJson + // Every content edit carries `input.content` (the API accepts only markdown; + // the editor emits markdown alongside contentJson), so the fallback reflects + // the new doc. `existing.content` is only a defensive default for a + // contentJson-only edit, which no caller makes. updateData.content = contentJsonToMarkdown( contentJson, (input.content ?? existing.content).trim() diff --git a/apps/web/src/lib/server/domains/posts/post.service.ts b/apps/web/src/lib/server/domains/posts/post.service.ts index 4aaa9248e..f668108cb 100644 --- a/apps/web/src/lib/server/domains/posts/post.service.ts +++ b/apps/web/src/lib/server/domains/posts/post.service.ts @@ -384,6 +384,10 @@ export async function updatePost( principalId: existingPost.principalId, }) updateData.contentJson = contentJson + // Every content edit carries `input.content` (the API accepts only markdown; + // the editor emits markdown alongside contentJson), so the fallback reflects + // the new doc. `existingPost.content` is only a defensive default for a + // contentJson-only edit, which no caller makes. updateData.content = contentJsonToMarkdown( contentJson, (input.content ?? existingPost.content).trim() diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index dc1809a2b..fdf42c4df 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -79,6 +79,37 @@ export function tiptapJsonToMarkdown(json: TiptapContent | JSONContent): string */ const IMAGE_NODE_TYPES = new Set(['image', 'resizableImage']) +/** + * Node types this module can faithfully turn into markdown: the server + * manager's own nodes (see SERVER_EXTENSIONS) plus the two we normalize below + * (`resizableImage` -> `image`, `mention` -> text). Anything else — `youtube`, + * `quackbackEmbed`, `emoji`, future custom nodes — would be silently dropped by + * the narrower server manager, so a document containing one keeps its stored + * markdown (which the client serialized with full coverage) instead. + */ +const RESERIALIZABLE_NODE_TYPES = new Set([ + 'doc', + 'paragraph', + 'text', + 'heading', + 'blockquote', + 'bulletList', + 'orderedList', + 'listItem', + 'codeBlock', + 'horizontalRule', + 'hardBreak', + 'taskList', + 'taskItem', + 'table', + 'tableRow', + 'tableCell', + 'tableHeader', + 'image', + 'resizableImage', + 'mention', +]) + /** * Render an entity's markdown for output (API / MCP responses), preferring the * stored `content` column but restoring images from the canonical `contentJson`. @@ -90,17 +121,22 @@ const IMAGE_NODE_TYPES = new Set(['image', 'resizableImage']) * `![alt](src)`. Image-free content returns the stored markdown verbatim — no * reason to pay for, or risk reformatting from, a re-serialize. * - * Falls back to the stored markdown when `contentJson` is absent (legacy rows, - * or list queries that omit it for performance) or can't be serialized — a read + * Re-serialization runs through the narrower server manager, so we only do it + * when every node is representable (see {@link RESERIALIZABLE_NODE_TYPES}); a + * document mixing an image with, say, a YouTube embed keeps its stored markdown + * rather than dropping the embed. Also falls back when `contentJson` is absent + * (legacy rows / list queries that omit it) or can't be serialized — a read * path must never fail over content shape. */ export function contentJsonToMarkdown( contentJson: TiptapContent | JSONContent | null | undefined, fallback: string ): string { - if (!contentJson || !hasImageNode(contentJson)) return fallback + if (!contentJson || !hasImageNode(contentJson) || !isReserializable(contentJson)) { + return fallback + } try { - const markdown = tiptapJsonToMarkdown(normalizeImageNodes(contentJson)) + const markdown = tiptapJsonToMarkdown(normalizeForMarkdown(contentJson)) return markdown.trim() ? markdown : fallback } catch { return fallback @@ -118,14 +154,30 @@ function hasImageNode(node: JSONContent): boolean { } /** - * Rewrite `resizableImage` nodes to plain `image` so @tiptap/markdown's Image - * extension serializes them — the editor's resizable node shares the `src`/`alt` - * attrs but has no markdown spec, so it would otherwise serialize to nothing. + * True only when every node in the tree can be re-serialized without loss. A + * single unknown node type makes this false so the caller keeps stored markdown. + * Total by the same contract as {@link hasImageNode}. */ -function normalizeImageNodes(node: JSONContent): JSONContent { +function isReserializable(node: JSONContent): boolean { + if (typeof node.type === 'string' && !RESERIALIZABLE_NODE_TYPES.has(node.type)) return false + return Array.isArray(node.content) ? node.content.every(isReserializable) : true +} + +/** + * Rewrite the editor's custom nodes into ones @tiptap/markdown can serialize: + * `resizableImage` -> `image` (shares src/alt but has no markdown spec) and + * `mention` -> the `@label` text the directive would otherwise hide. Only + * called once {@link isReserializable} has cleared the tree. + */ +function normalizeForMarkdown(node: JSONContent): JSONContent { + if (node.type === 'mention') { + const attrs = node.attrs ?? {} + const label = (attrs.label as string) || (attrs.id as string) || 'mention' + return { type: 'text', text: `@${label}` } + } const next = node.type === 'resizableImage' ? { ...node, type: 'image' } : node if (!Array.isArray(next.content)) return next - return { ...next, content: next.content.map(normalizeImageNodes) } + return { ...next, content: next.content.map(normalizeForMarkdown) } } /**