From c3c67d7d50ce36bdbb70f330388fc898a90bc9a0 Mon Sep 17 00:00:00 2001 From: Kai Ytterberg Date: Fri, 24 Jul 2026 02:22:32 +0200 Subject: [PATCH] fix(api): keep images when a post also contains emoji nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contentJsonToMarkdown` only re-serializes `contentJson` when every node is in `RESERIALIZABLE_NODE_TYPES`. `emoji` was not, so any post written with the `:` emoji picker fell back to the stored markdown column β€” which is exactly the column that loses images (#317). The result: a changelog entry with both a screenshot and an emoji came back from `GET /api/v1/changelog` (and MCP) with no images and with raw `:tada:` shortcodes. Teach `normalizeForMarkdown` about the node instead: emit the emoji's Unicode character (from `attrs.emoji` when the editor stored it, else the shortcode table), falling back to `:name:` for image-backed custom emoji that have no character. Images then survive alongside emoji, and the emoji read correctly in markdown consumers rather than as shortcodes. --- .../server/__tests__/markdown-tiptap.test.ts | 47 +++++++++++++++++++ apps/web/src/lib/server/markdown-tiptap.ts | 37 +++++++++++---- 2 files changed, 76 insertions(+), 8 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 d54487a84..a1ae0cd4c 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -236,6 +236,53 @@ describe('contentJsonToMarkdown', () => { expect(result).toContain('![S](https://cdn.example.com/s.png)') }) + test('keeps emoji (as the Unicode character) when restoring an image', () => { + // The reported bug: a post written with the `:` emoji picker made the whole + // document non-reserializable, so the API fell back to stored markdown β€” + // which has the shortcodes but not the images. Both must survive. + const doc = { + type: 'doc' as const, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'We shipped it ' }, + { type: 'emoji', attrs: { name: 'tada' } }, + ], + }, + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + ], + } + const result = contentJsonToMarkdown(doc, 'We shipped it :tada:') + expect(result).toContain('We shipped it πŸŽ‰') + expect(result).not.toContain(':tada:') + expect(result).toContain('![S](https://cdn.example.com/s.png)') + }) + + test('prefers an emoji node’s stored character over the shortcode table', () => { + const doc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'emoji', attrs: { name: 'tada', emoji: '🎊' } }] }, + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + ], + } + expect(contentJsonToMarkdown(doc, 'stored')).toContain('🎊') + }) + + test('keeps a custom emoji as its shortcode when it has no Unicode character', () => { + // Workspace-uploaded emoji are image-backed and have no character to emit; + // `:name:` is what the editor round-trips them as. + const doc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'emoji', attrs: { name: 'quackback_logo' } }] }, + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + ], + } + expect(contentJsonToMarkdown(doc, 'stored')).toContain(':quackback_logo:') + }) + 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 diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index fdf42c4df..a502a8bbe 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -12,6 +12,7 @@ import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Underline from '@tiptap/extension-underline' import Image from '@tiptap/extension-image' +import { emojis as defaultEmojis } from '@tiptap/extension-emoji' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' @@ -81,11 +82,11 @@ 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. + * manager's own nodes (see SERVER_EXTENSIONS) plus the three we normalize below + * (`resizableImage` -> `image`, `mention` -> text, `emoji` -> text). Anything + * else β€” `youtube`, `quackbackEmbed`, 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', @@ -108,6 +109,7 @@ const RESERIALIZABLE_NODE_TYPES = new Set([ 'image', 'resizableImage', 'mention', + 'emoji', ]) /** @@ -165,9 +167,10 @@ function isReserializable(node: JSONContent): boolean { /** * 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. + * `resizableImage` -> `image` (shares src/alt but has no markdown spec), + * `mention` -> the `@label` text the directive would otherwise hide, and + * `emoji` -> its Unicode character. Only called once {@link isReserializable} + * has cleared the tree. */ function normalizeForMarkdown(node: JSONContent): JSONContent { if (node.type === 'mention') { @@ -175,11 +178,29 @@ function normalizeForMarkdown(node: JSONContent): JSONContent { const label = (attrs.label as string) || (attrs.id as string) || 'mention' return { type: 'text', text: `@${label}` } } + if (node.type === 'emoji') { + return { type: 'text', text: emojiNodeToText(node) } + } const next = node.type === 'resizableImage' ? { ...node, type: 'image' } : node if (!Array.isArray(next.content)) return next return { ...next, content: next.content.map(normalizeForMarkdown) } } +/** + * The text an `emoji` node serializes to. `@tiptap/extension-emoji` persists + * `{ name }` and only sometimes the character itself, so fall back to the + * shortcode table, and to `:name:` for a custom emoji that has no Unicode + * equivalent β€” the same text the editor round-trips it as. + */ +function emojiNodeToText(node: JSONContent): string { + const attrs = node.attrs ?? {} + const stored = attrs.emoji + if (typeof stored === 'string' && stored) return stored + const name = typeof attrs.name === 'string' ? attrs.name : '' + if (!name) return '' + return defaultEmojis.find((e) => e.emoji && e.shortcodes.includes(name))?.emoji ?? `:${name}:` +} + /** * Slim extension set for comments β€” no images, no tables, no YouTube. * Comments are short, dense, and inline; we want the safe subset only.