Skip to content

Commit 00939d3

Browse files
committed
refactor: implement Markdown ↔ PortableText conversion and add tests for email system
1 parent d3be36f commit 00939d3

7 files changed

Lines changed: 790 additions & 2 deletions

File tree

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
portableTextBodyToMarkdown,
4+
markdownToPortableTextBody,
5+
} from '@/lib/email/markdown'
6+
import type { PortableTextBlock } from '@portabletext/types'
7+
8+
// Helper to strip generated _key fields for structural comparison.
9+
// Also normalizes mark references: replaces markDef keys in both
10+
// markDefs and children.marks with sequential placeholders.
11+
function stripKeys(blocks: PortableTextBlock[]): unknown[] {
12+
return blocks.map((block) => {
13+
const { _key: _bk, ...rest } = block as unknown as Record<string, unknown>
14+
15+
// Build a mapping from original markDef keys to sequential placeholders
16+
const rawMarkDefs = (rest.markDefs as Array<Record<string, unknown>>) || []
17+
const keyMap = new Map<string, string>()
18+
rawMarkDefs.forEach((md, i) => {
19+
if (typeof md._key === 'string') {
20+
keyMap.set(md._key, `ref-${i}`)
21+
}
22+
})
23+
24+
const markDefs = rawMarkDefs.map(({ _key: _mk, ...m }) => m)
25+
26+
const children = (rest.children as Array<Record<string, unknown>>)?.map(
27+
({ _key: _ck, ...c }) => ({
28+
...c,
29+
marks: ((c.marks as string[]) || []).map(
30+
(mark) => keyMap.get(mark) ?? mark,
31+
),
32+
}),
33+
)
34+
35+
return { ...rest, children, markDefs }
36+
})
37+
}
38+
39+
// Helper to assert round-trip: markdown → PT → markdown produces same text.
40+
// Normalizes italic syntax and trailing whitespace since the library may
41+
// use _ instead of * and may add trailing spaces for hard breaks.
42+
function assertRoundTrip(markdown: string) {
43+
const pt = markdownToPortableTextBody(markdown)
44+
const backToMd = portableTextBodyToMarkdown(pt)
45+
const normalize = (s: string) =>
46+
s
47+
.trim()
48+
.split('\n')
49+
.map((line) => line.trimEnd())
50+
.join('\n')
51+
// Normalize *italic* → _italic_ for comparison
52+
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '_$1_')
53+
expect(normalize(backToMd)).toBe(normalize(markdown))
54+
}
55+
56+
describe('portableTextBodyToMarkdown', () => {
57+
it('returns empty string for empty array', () => {
58+
expect(portableTextBodyToMarkdown([])).toBe('')
59+
})
60+
61+
it('returns empty string for null-ish input', () => {
62+
expect(
63+
portableTextBodyToMarkdown(null as unknown as PortableTextBlock[]),
64+
).toBe('')
65+
expect(
66+
portableTextBodyToMarkdown(undefined as unknown as PortableTextBlock[]),
67+
).toBe('')
68+
})
69+
70+
it('converts a simple paragraph', () => {
71+
const blocks: PortableTextBlock[] = [
72+
{
73+
_type: 'block',
74+
_key: 'a1',
75+
style: 'normal',
76+
markDefs: [],
77+
children: [
78+
{ _type: 'span', _key: 's1', text: 'Hello world', marks: [] },
79+
],
80+
},
81+
]
82+
expect(portableTextBodyToMarkdown(blocks)).toBe('Hello world')
83+
})
84+
85+
it('converts bold and italic marks', () => {
86+
const blocks: PortableTextBlock[] = [
87+
{
88+
_type: 'block',
89+
_key: 'a1',
90+
style: 'normal',
91+
markDefs: [],
92+
children: [
93+
{ _type: 'span', _key: 's1', text: 'Hello ', marks: [] },
94+
{ _type: 'span', _key: 's2', text: 'bold', marks: ['strong'] },
95+
{ _type: 'span', _key: 's3', text: ' and ', marks: [] },
96+
{ _type: 'span', _key: 's4', text: 'italic', marks: ['em'] },
97+
],
98+
},
99+
]
100+
// Library uses underscore style for italic
101+
expect(portableTextBodyToMarkdown(blocks)).toBe(
102+
'Hello **bold** and _italic_',
103+
)
104+
})
105+
106+
it('converts links with markDefs', () => {
107+
const blocks: PortableTextBlock[] = [
108+
{
109+
_type: 'block',
110+
_key: 'a1',
111+
style: 'normal',
112+
markDefs: [
113+
{ _key: 'lnk1', _type: 'link', href: 'https://example.com' },
114+
],
115+
children: [
116+
{ _type: 'span', _key: 's1', text: 'Visit ', marks: [] },
117+
{ _type: 'span', _key: 's2', text: 'our site', marks: ['lnk1'] },
118+
],
119+
},
120+
]
121+
expect(portableTextBodyToMarkdown(blocks)).toBe(
122+
'Visit [our site](https://example.com)',
123+
)
124+
})
125+
126+
it('converts headings', () => {
127+
const blocks: PortableTextBlock[] = [
128+
{
129+
_type: 'block',
130+
_key: 'a1',
131+
style: 'h1',
132+
markDefs: [],
133+
children: [{ _type: 'span', _key: 's1', text: 'Title', marks: [] }],
134+
},
135+
{
136+
_type: 'block',
137+
_key: 'a2',
138+
style: 'h2',
139+
markDefs: [],
140+
children: [{ _type: 'span', _key: 's2', text: 'Subtitle', marks: [] }],
141+
},
142+
]
143+
expect(portableTextBodyToMarkdown(blocks)).toBe('# Title\n\n## Subtitle')
144+
})
145+
146+
it('converts multiple paragraphs', () => {
147+
const blocks: PortableTextBlock[] = [
148+
{
149+
_type: 'block',
150+
_key: 'a1',
151+
style: 'normal',
152+
markDefs: [],
153+
children: [
154+
{ _type: 'span', _key: 's1', text: 'Paragraph one.', marks: [] },
155+
],
156+
},
157+
{
158+
_type: 'block',
159+
_key: 'a2',
160+
style: 'normal',
161+
markDefs: [],
162+
children: [
163+
{ _type: 'span', _key: 's2', text: 'Paragraph two.', marks: [] },
164+
],
165+
},
166+
]
167+
expect(portableTextBodyToMarkdown(blocks)).toBe(
168+
'Paragraph one.\n\nParagraph two.',
169+
)
170+
})
171+
})
172+
173+
describe('markdownToPortableTextBody', () => {
174+
it('returns empty array for empty string', () => {
175+
expect(markdownToPortableTextBody('')).toEqual([])
176+
})
177+
178+
it('returns empty array for whitespace-only', () => {
179+
expect(markdownToPortableTextBody(' \n ')).toEqual([])
180+
})
181+
182+
it('converts a simple paragraph', () => {
183+
const result = markdownToPortableTextBody('Hello world')
184+
expect(result).toHaveLength(1)
185+
expect(result[0]._type).toBe('block')
186+
expect(result[0].style).toBe('normal')
187+
188+
const children = result[0].children as Array<{ text: string }>
189+
expect(children).toHaveLength(1)
190+
expect(children[0].text).toBe('Hello world')
191+
})
192+
193+
it('converts bold and italic', () => {
194+
const result = markdownToPortableTextBody('Hello **bold** and *italic*')
195+
const children = result[0].children as Array<{
196+
text: string
197+
marks: string[]
198+
}>
199+
expect(children.length).toBeGreaterThanOrEqual(3)
200+
201+
const boldSpan = children.find((c) => c.text === 'bold')
202+
expect(boldSpan?.marks).toContain('strong')
203+
204+
const italicSpan = children.find((c) => c.text === 'italic')
205+
expect(italicSpan?.marks).toContain('em')
206+
})
207+
208+
it('converts links', () => {
209+
const result = markdownToPortableTextBody(
210+
'Visit [our site](https://example.com)',
211+
)
212+
const markDefs = result[0].markDefs as Array<{
213+
_type: string
214+
href: string
215+
_key: string
216+
}>
217+
expect(markDefs).toHaveLength(1)
218+
expect(markDefs[0]._type).toBe('link')
219+
expect(markDefs[0].href).toBe('https://example.com')
220+
221+
const children = result[0].children as Array<{
222+
text: string
223+
marks: string[]
224+
}>
225+
const linkSpan = children.find((c) => c.text === 'our site')
226+
expect(linkSpan?.marks).toContain(markDefs[0]._key)
227+
})
228+
229+
it('converts headings', () => {
230+
const result = markdownToPortableTextBody('# Title\n\n## Subtitle')
231+
expect(result).toHaveLength(2)
232+
expect(result[0].style).toBe('h1')
233+
expect(result[1].style).toBe('h2')
234+
})
235+
236+
it('converts unordered lists', () => {
237+
const result = markdownToPortableTextBody('- Item one\n- Item two')
238+
expect(result).toHaveLength(2)
239+
expect(result[0].listItem).toBe('bullet')
240+
expect(result[1].listItem).toBe('bullet')
241+
})
242+
243+
it('converts ordered lists', () => {
244+
const result = markdownToPortableTextBody('1. First\n2. Second')
245+
expect(result).toHaveLength(2)
246+
expect(result[0].listItem).toBe('number')
247+
expect(result[1].listItem).toBe('number')
248+
})
249+
})
250+
251+
describe('round-trip fidelity', () => {
252+
it('round-trips a plain paragraph', () => {
253+
assertRoundTrip('Hello world')
254+
})
255+
256+
it('round-trips bold and italic', () => {
257+
assertRoundTrip('Hello **bold** and *italic* text')
258+
})
259+
260+
it('round-trips links', () => {
261+
assertRoundTrip('Visit [our site](https://example.com) for more')
262+
})
263+
264+
it('round-trips headings', () => {
265+
assertRoundTrip('# Title\n\n## Subtitle')
266+
})
267+
268+
it('round-trips multiple paragraphs', () => {
269+
assertRoundTrip('First paragraph.\n\nSecond paragraph.')
270+
})
271+
272+
it('round-trips unordered lists', () => {
273+
assertRoundTrip('- Item one\n- Item two\n- Item three')
274+
})
275+
276+
it('round-trips ordered lists', () => {
277+
assertRoundTrip('1. First\n2. Second\n3. Third')
278+
})
279+
280+
it('round-trips mixed content resembling a real email template', () => {
281+
// In production, template variables are resolved *before* markdown
282+
// conversion, so we test with realistic resolved values.
283+
const md = [
284+
'# Sponsor Invitation',
285+
'',
286+
'Dear **Yves and Petter**,',
287+
'',
288+
'We would like to invite Acme Corp to sponsor Cloud Native Days Norway 2026.',
289+
'',
290+
'Benefits:',
291+
'',
292+
'- Brand visibility',
293+
'- Booth space',
294+
'- Speaking slot',
295+
'',
296+
'Learn more at [our website](https://cloudnativedays.no).',
297+
'',
298+
'Best regards,',
299+
'Hans',
300+
].join('\n')
301+
assertRoundTrip(md)
302+
})
303+
})
304+
305+
describe('edge cases', () => {
306+
it('handles template variables in text without mangling', () => {
307+
const blocks = markdownToPortableTextBody(
308+
'Hello {{{CONTACT_NAMES}}}, welcome to {{{CONFERENCE_TITLE}}}!',
309+
)
310+
const md = portableTextBodyToMarkdown(blocks)
311+
expect(md).toContain('{{{CONTACT_NAMES}}}')
312+
expect(md).toContain('{{{CONFERENCE_TITLE}}}')
313+
})
314+
315+
it('handles template variables inside link hrefs (URL-encoded by library)', () => {
316+
const blocks = markdownToPortableTextBody(
317+
'Visit [the website]({{{CONFERENCE_URL}}})',
318+
)
319+
const markDefs = blocks[0].markDefs as unknown as Array<{ href: string }>
320+
// The library URL-encodes braces in hrefs — this is expected.
321+
// The server-side variable substitution runs *before* markdown conversion,
322+
// so actual URLs will be valid when the email is sent.
323+
expect(markDefs[0].href).toContain('CONFERENCE_URL')
324+
})
325+
326+
it('preserves structural equality through PT→MD→PT (ignoring keys)', () => {
327+
const original: PortableTextBlock[] = [
328+
{
329+
_type: 'block',
330+
_key: 'orig1',
331+
style: 'normal',
332+
markDefs: [
333+
{ _key: 'lnk1', _type: 'link', href: 'https://example.com' },
334+
],
335+
children: [
336+
{ _type: 'span', _key: 's1', text: 'Hello ', marks: [] },
337+
{ _type: 'span', _key: 's2', text: 'bold', marks: ['strong'] },
338+
{ _type: 'span', _key: 's3', text: ' and ', marks: [] },
339+
{ _type: 'span', _key: 's4', text: 'link', marks: ['lnk1'] },
340+
],
341+
},
342+
]
343+
344+
const md = portableTextBodyToMarkdown(original)
345+
const reconstructed = markdownToPortableTextBody(md)
346+
347+
expect(stripKeys(reconstructed)).toEqual(stripKeys(original))
348+
})
349+
})

0 commit comments

Comments
 (0)