Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 29 additions & 8 deletions apps/web/src/lib/server/markdown-tiptap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -108,6 +109,7 @@ const RESERIALIZABLE_NODE_TYPES = new Set([
'image',
'resizableImage',
'mention',
'emoji',
])

/**
Expand Down Expand Up @@ -165,21 +167,40 @@ 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') {
const attrs = node.attrs ?? {}
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.
Expand Down