@uniweb/content-writer 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ import { docToPlainText } from '../src/plain-text.js'
2
+
3
+ describe('docToPlainText', () => {
4
+ test('returns empty string for null/undefined/empty', () => {
5
+ expect(docToPlainText(null)).toBe('')
6
+ expect(docToPlainText(undefined)).toBe('')
7
+ expect(docToPlainText({})).toBe('')
8
+ expect(docToPlainText({ type: 'doc' })).toBe('')
9
+ expect(docToPlainText({ type: 'doc', content: [] })).toBe('')
10
+ })
11
+
12
+ test('extracts heading text without # prefix', () => {
13
+ const doc = {
14
+ type: 'doc',
15
+ content: [
16
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Hello World' }] },
17
+ { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Subtitle' }] }
18
+ ]
19
+ }
20
+ expect(docToPlainText(doc)).toBe('Hello World\n\nSubtitle')
21
+ })
22
+
23
+ test('extracts paragraph text', () => {
24
+ const doc = {
25
+ type: 'doc',
26
+ content: [
27
+ { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph.' }] },
28
+ { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph.' }] }
29
+ ]
30
+ }
31
+ expect(docToPlainText(doc)).toBe('First paragraph.\n\nSecond paragraph.')
32
+ })
33
+
34
+ test('strips bold/italic/code marks', () => {
35
+ const doc = {
36
+ type: 'doc',
37
+ content: [{
38
+ type: 'paragraph',
39
+ content: [
40
+ { type: 'text', text: 'Some ' },
41
+ { type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
42
+ { type: 'text', text: ' and ' },
43
+ { type: 'text', text: 'italic', marks: [{ type: 'italic' }] },
44
+ { type: 'text', text: ' and ' },
45
+ { type: 'text', text: 'code', marks: [{ type: 'code' }] },
46
+ { type: 'text', text: ' text' }
47
+ ]
48
+ }]
49
+ }
50
+ expect(docToPlainText(doc)).toBe('Some bold and italic and code text')
51
+ })
52
+
53
+ test('strips link marks, keeping link text', () => {
54
+ const doc = {
55
+ type: 'doc',
56
+ content: [{
57
+ type: 'paragraph',
58
+ content: [
59
+ { type: 'text', text: 'Click ' },
60
+ { type: 'text', text: 'here', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] },
61
+ { type: 'text', text: ' for more.' }
62
+ ]
63
+ }]
64
+ }
65
+ expect(docToPlainText(doc)).toBe('Click here for more.')
66
+ })
67
+
68
+ test('extracts list item text without bullet/number prefixes', () => {
69
+ const doc = {
70
+ type: 'doc',
71
+ content: [{
72
+ type: 'bulletList',
73
+ content: [
74
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First item' }] }] },
75
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second item' }] }] }
76
+ ]
77
+ }]
78
+ }
79
+ expect(docToPlainText(doc)).toBe('First item\nSecond item')
80
+ })
81
+
82
+ test('extracts ordered list text without number prefixes', () => {
83
+ const doc = {
84
+ type: 'doc',
85
+ content: [{
86
+ type: 'orderedList',
87
+ attrs: { start: 1 },
88
+ content: [
89
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Step one' }] }] },
90
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Step two' }] }] }
91
+ ]
92
+ }]
93
+ }
94
+ expect(docToPlainText(doc)).toBe('Step one\nStep two')
95
+ })
96
+
97
+ test('extracts nested list text', () => {
98
+ const doc = {
99
+ type: 'doc',
100
+ content: [{
101
+ type: 'bulletList',
102
+ content: [{
103
+ type: 'listItem',
104
+ content: [
105
+ { type: 'paragraph', content: [{ type: 'text', text: 'Parent' }] },
106
+ {
107
+ type: 'bulletList',
108
+ content: [
109
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Child' }] }] }
110
+ ]
111
+ }
112
+ ]
113
+ }]
114
+ }]
115
+ }
116
+ expect(docToPlainText(doc)).toBe('Parent\nChild')
117
+ })
118
+
119
+ test('extracts code block content', () => {
120
+ const doc = {
121
+ type: 'doc',
122
+ content: [{
123
+ type: 'codeBlock',
124
+ attrs: { language: 'js' },
125
+ content: [{ type: 'text', text: 'const x = 1' }]
126
+ }]
127
+ }
128
+ expect(docToPlainText(doc)).toBe('const x = 1')
129
+ })
130
+
131
+ test('extracts blockquote text', () => {
132
+ const doc = {
133
+ type: 'doc',
134
+ content: [{
135
+ type: 'blockquote',
136
+ content: [
137
+ { type: 'paragraph', content: [{ type: 'text', text: 'Quoted text' }] }
138
+ ]
139
+ }]
140
+ }
141
+ expect(docToPlainText(doc)).toBe('Quoted text')
142
+ })
143
+
144
+ test('extracts table cell text', () => {
145
+ const doc = {
146
+ type: 'doc',
147
+ content: [{
148
+ type: 'table',
149
+ content: [
150
+ {
151
+ type: 'tableRow',
152
+ content: [
153
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Name' }] }] },
154
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Age' }] }] }
155
+ ]
156
+ },
157
+ {
158
+ type: 'tableRow',
159
+ content: [
160
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Alice' }] }] },
161
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '30' }] }] }
162
+ ]
163
+ }
164
+ ]
165
+ }]
166
+ }
167
+ expect(docToPlainText(doc)).toBe('Name Age\nAlice 30')
168
+ })
169
+
170
+ test('skips images, dividers, inset refs, and data blocks', () => {
171
+ const doc = {
172
+ type: 'doc',
173
+ content: [
174
+ { type: 'paragraph', content: [{ type: 'text', text: 'Before' }] },
175
+ { type: 'image', attrs: { src: 'photo.jpg' } },
176
+ { type: 'divider' },
177
+ { type: 'inset_ref', attrs: { component: 'Chart' } },
178
+ { type: 'dataBlock', attrs: { tag: 'items', data: [] } },
179
+ { type: 'paragraph', content: [{ type: 'text', text: 'After' }] }
180
+ ]
181
+ }
182
+ expect(docToPlainText(doc)).toBe('Before\n\nAfter')
183
+ })
184
+
185
+ test('skips inline images within paragraphs', () => {
186
+ const doc = {
187
+ type: 'doc',
188
+ content: [{
189
+ type: 'paragraph',
190
+ content: [
191
+ { type: 'text', text: 'Text with ' },
192
+ { type: 'image', attrs: { src: 'icon.svg', library: 'lu', name: 'star' } },
193
+ { type: 'text', text: ' icon' }
194
+ ]
195
+ }]
196
+ }
197
+ expect(docToPlainText(doc)).toBe('Text with icon')
198
+ })
199
+
200
+ test('handles mixed content document', () => {
201
+ const doc = {
202
+ type: 'doc',
203
+ content: [
204
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
205
+ { type: 'paragraph', content: [{ type: 'text', text: 'Introduction.' }] },
206
+ {
207
+ type: 'bulletList',
208
+ content: [
209
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Point A' }] }] },
210
+ { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Point B' }] }] }
211
+ ]
212
+ },
213
+ { type: 'paragraph', content: [{ type: 'text', text: 'Conclusion.' }] }
214
+ ]
215
+ }
216
+ expect(docToPlainText(doc)).toBe('Title\n\nIntroduction.\n\nPoint A\nPoint B\n\nConclusion.')
217
+ })
218
+ })
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Round-trip tests: markdown → content-reader → content-writer → content-reader
3
+ *
4
+ * Verifies that ProseMirror documents survive the round trip without
5
+ * information loss. Output markdown may differ in formatting (whitespace,
6
+ * attribute order) but must parse to the same ProseMirror structure.
7
+ */
8
+
9
+ import { markdownToProseMirror } from '@uniweb/content-reader'
10
+ import { proseMirrorToMarkdown } from '../src/index.js'
11
+
12
+ /**
13
+ * Test that markdown survives a round trip through reader → writer → reader.
14
+ * Compares ProseMirror documents, not markdown strings.
15
+ */
16
+ function testRoundTrip(markdown, description) {
17
+ test(description || markdown.trim().slice(0, 60), () => {
18
+ const parsed = markdownToProseMirror(markdown)
19
+ const serialized = proseMirrorToMarkdown(parsed)
20
+ const reparsed = markdownToProseMirror(serialized)
21
+ expect(reparsed).toEqual(parsed)
22
+ })
23
+ }
24
+
25
+ describe('Round-trip: Basic Formatting', () => {
26
+ testRoundTrip('Hello world', 'plain text')
27
+
28
+ testRoundTrip(
29
+ `text with 'single', "double", & specials`,
30
+ 'special characters'
31
+ )
32
+
33
+ testRoundTrip(
34
+ 'Some **bold** and *italic* text',
35
+ 'bold and italic'
36
+ )
37
+
38
+ testRoundTrip(
39
+ '***bold italic both***',
40
+ 'triple bold italic'
41
+ )
42
+
43
+ testRoundTrip(
44
+ '# Main Title\n\n## Subtitle',
45
+ 'headings'
46
+ )
47
+
48
+ testRoundTrip(
49
+ 'Text\n\n---\n\nMore text',
50
+ 'dividers'
51
+ )
52
+ })
53
+
54
+ describe('Round-trip: Links', () => {
55
+ testRoundTrip(
56
+ '[Link text](https://example.com "Title")',
57
+ 'link with title'
58
+ )
59
+
60
+ testRoundTrip(
61
+ '[Link](https://example.com)',
62
+ 'link without title'
63
+ )
64
+
65
+ testRoundTrip(
66
+ '[External Link](https://example.com){target=_blank rel="noopener noreferrer"}',
67
+ 'link with attributes'
68
+ )
69
+
70
+ testRoundTrip(
71
+ '[Download PDF](./document.pdf){download}',
72
+ 'download link (boolean)'
73
+ )
74
+
75
+ testRoundTrip(
76
+ '[Get Report](./data.pdf){download="annual-report.pdf"}',
77
+ 'download link with filename'
78
+ )
79
+ })
80
+
81
+ describe('Round-trip: Buttons', () => {
82
+ testRoundTrip(
83
+ '[Button Text](button:https://example.com)',
84
+ 'basic button'
85
+ )
86
+
87
+ // Note: .button class syntax round-trips to button: prefix (both are equivalent)
88
+ test('button with variant and size', () => {
89
+ // .button class → button: prefix is expected normalization
90
+ const markdown = '[Get Started](https://example.com){.button variant=secondary size=lg}'
91
+ const parsed = markdownToProseMirror(markdown)
92
+ const serialized = proseMirrorToMarkdown(parsed)
93
+ const reparsed = markdownToProseMirror(serialized)
94
+ expect(reparsed).toEqual(parsed)
95
+ })
96
+
97
+ test('button with icon', () => {
98
+ const markdown = '[Learn More](https://example.com){.button icon=arrow-right}'
99
+ const parsed = markdownToProseMirror(markdown)
100
+ const serialized = proseMirrorToMarkdown(parsed)
101
+ const reparsed = markdownToProseMirror(serialized)
102
+ expect(reparsed).toEqual(parsed)
103
+ })
104
+ })
105
+
106
+ describe('Round-trip: Images', () => {
107
+ testRoundTrip(
108
+ '![Alt Text](path/to/image.svg)',
109
+ 'basic image'
110
+ )
111
+
112
+ testRoundTrip(
113
+ '![Alt Text](https://test.com)',
114
+ 'image with URL'
115
+ )
116
+
117
+ testRoundTrip(
118
+ '![Hero Image](./hero.jpg){role=hero}',
119
+ 'image with role'
120
+ )
121
+
122
+ testRoundTrip(
123
+ '![Photo](./photo.jpg "A beautiful photo"){width=800 height=600 loading=lazy}',
124
+ 'image with caption and attributes'
125
+ )
126
+
127
+ testRoundTrip(
128
+ '![Logo](./logo.svg){.featured #main-logo}',
129
+ 'image with class and id'
130
+ )
131
+
132
+ testRoundTrip(
133
+ '![Gallery](./photo.jpg){.featured .rounded .shadow}',
134
+ 'image with multiple classes'
135
+ )
136
+
137
+ testRoundTrip(
138
+ '![Background](./bg.jpg){fit=cover position=center}',
139
+ 'image with fit and position'
140
+ )
141
+ })
142
+
143
+ describe('Round-trip: Video and PDF', () => {
144
+ testRoundTrip(
145
+ '![Intro Video](./intro.mp4){role=video poster=./poster.jpg autoplay muted loop}',
146
+ 'video with attributes'
147
+ )
148
+
149
+ testRoundTrip(
150
+ '![Demo Video](./demo.mp4){role=video controls muted}',
151
+ 'video with controls'
152
+ )
153
+
154
+ testRoundTrip(
155
+ '![User Guide](./guide.pdf){role=pdf preview=./guide-preview.jpg}',
156
+ 'PDF with preview'
157
+ )
158
+ })
159
+
160
+ describe('Round-trip: Icons', () => {
161
+ testRoundTrip(
162
+ '![](lu-home)',
163
+ 'icon dash format'
164
+ )
165
+
166
+ testRoundTrip(
167
+ '![](lu-home) [Sports](/sports)',
168
+ 'icon with adjacent link'
169
+ )
170
+
171
+ test('icon colon format normalizes to dash format', () => {
172
+ // lu:home → lu-home is expected normalization
173
+ const markdown = '![](lu:home)'
174
+ const parsed = markdownToProseMirror(markdown)
175
+ const serialized = proseMirrorToMarkdown(parsed)
176
+ expect(serialized).toBe('![](lu-home)')
177
+ const reparsed = markdownToProseMirror(serialized)
178
+ expect(reparsed).toEqual(parsed)
179
+ })
180
+ })
181
+
182
+ describe('Round-trip: Spans', () => {
183
+ testRoundTrip(
184
+ 'This is [highlighted text]{.highlight} in a sentence.',
185
+ 'span with class'
186
+ )
187
+
188
+ testRoundTrip(
189
+ '[important note]{.callout .bold}',
190
+ 'span with multiple classes'
191
+ )
192
+
193
+ testRoundTrip(
194
+ '[key term]{.highlight #glossary-term}',
195
+ 'span with id and class'
196
+ )
197
+
198
+ testRoundTrip(
199
+ '[tooltip text]{.info data-tooltip="More info"}',
200
+ 'span with custom attributes'
201
+ )
202
+
203
+ testRoundTrip(
204
+ '[first]{.highlight} normal [second]{.muted}',
205
+ 'multiple spans'
206
+ )
207
+
208
+ testRoundTrip(
209
+ '[Link](https://example.com) and [span]{.highlight}',
210
+ 'link and span in same paragraph'
211
+ )
212
+ })
213
+
214
+ describe('Round-trip: Component References', () => {
215
+ testRoundTrip(
216
+ '![](@Hero)',
217
+ 'bare component ref'
218
+ )
219
+
220
+ testRoundTrip(
221
+ '![Architecture diagram](@NetworkDiagram){variant=compact size=lg}',
222
+ 'component ref with alt and params'
223
+ )
224
+
225
+ testRoundTrip(
226
+ '![](@Widget)\n\n![](@Chart)',
227
+ 'multiple component refs'
228
+ )
229
+ })
230
+
231
+ describe('Round-trip: Code', () => {
232
+ testRoundTrip(
233
+ '```javascript\nconst x = 1;\nconsole.log(\'x:\', x);\n```',
234
+ 'fenced code block'
235
+ )
236
+
237
+ testRoundTrip(
238
+ '```\nline 1\n\nline 2\n```',
239
+ 'code block without language'
240
+ )
241
+
242
+ testRoundTrip(
243
+ 'Use the `console.log(\'test\')` function.',
244
+ 'inline code'
245
+ )
246
+
247
+ testRoundTrip(
248
+ '```json:nav-links\n[{"label": "Home"}]\n```',
249
+ 'tagged data block'
250
+ )
251
+ })
252
+
253
+ describe('Round-trip: Lists', () => {
254
+ testRoundTrip(
255
+ '- First item\n- Second item\n- Third item',
256
+ 'bullet list'
257
+ )
258
+
259
+ testRoundTrip(
260
+ '1. First item\n2. Second item\n3. Third item',
261
+ 'ordered list'
262
+ )
263
+
264
+ testRoundTrip(
265
+ '- First item\n - Nested item 1\n - Nested item 2\n- Second item\n 1. Nested ordered 1\n 2. Nested ordered 2',
266
+ 'nested lists'
267
+ )
268
+
269
+ testRoundTrip(
270
+ '- Item with **bold** text\n- Item with *italic* text\n- Item with [link](https://example.com)',
271
+ 'list items with formatted text'
272
+ )
273
+ })
274
+
275
+ describe('Round-trip: Tables', () => {
276
+ testRoundTrip(
277
+ '| Column 1 | Column 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |\n| Cell 3 | Cell 4 |',
278
+ 'basic table'
279
+ )
280
+
281
+ testRoundTrip(
282
+ '| Left | Center | Right |\n|:-----|:------:|------:|\n| 1 | 2 | 3 |',
283
+ 'table with alignments'
284
+ )
285
+
286
+ testRoundTrip(
287
+ '| Style | Example |\n|-------|--------|\n| Bold | **text** |\n| Link | [link](https://example.com) |',
288
+ 'table with formatted content'
289
+ )
290
+ })
291
+
292
+ describe('Round-trip: Mixed Content', () => {
293
+ testRoundTrip(
294
+ '# Title\n\nSome text with **bold** and *italic*.\n\n---\n\n![Image](./photo.jpg)\n\nMore text.',
295
+ 'mixed content document'
296
+ )
297
+
298
+ test('paragraph with extracted image', () => {
299
+ // "Text ![img](src) more" → reader extracts image to block level
300
+ // Writer must handle the extracted structure correctly
301
+ const markdown = 'Some text before\n\n![Widget](./widget.png)\n\nand after'
302
+ const parsed = markdownToProseMirror(markdown)
303
+ const serialized = proseMirrorToMarkdown(parsed)
304
+ const reparsed = markdownToProseMirror(serialized)
305
+ expect(reparsed).toEqual(parsed)
306
+ })
307
+ })