@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,123 @@
1
+ /**
2
+ * @fileoverview Convert ProseMirror documents to plain text
3
+ *
4
+ * Strips all markdown formatting, returning just the text content
5
+ * with newlines for structure. Useful for word counting, search
6
+ * indexing, and generating summaries.
7
+ */
8
+
9
+ /**
10
+ * Extract plain text from a ProseMirror document.
11
+ *
12
+ * @param {Object} doc - ProseMirror document ({ type: "doc", content: [...] })
13
+ * @returns {string} Plain text with newlines between blocks
14
+ */
15
+ export function docToPlainText(doc) {
16
+ if (!doc?.content) return ''
17
+
18
+ return doc.content
19
+ .map(node => nodeToPlainText(node))
20
+ .filter(s => s !== '')
21
+ .join('\n\n')
22
+ }
23
+
24
+ /**
25
+ * Extract plain text from a single ProseMirror node.
26
+ * @param {Object} node
27
+ * @returns {string}
28
+ */
29
+ function nodeToPlainText(node) {
30
+ switch (node.type) {
31
+ case 'heading':
32
+ case 'paragraph':
33
+ return inlineToPlainText(node.content)
34
+
35
+ case 'codeBlock':
36
+ return node.content?.[0]?.text || ''
37
+
38
+ case 'blockquote':
39
+ if (!node.content) return ''
40
+ return node.content
41
+ .map(child => nodeToPlainText(child))
42
+ .filter(Boolean)
43
+ .join('\n\n')
44
+
45
+ case 'bulletList':
46
+ case 'orderedList':
47
+ return listToPlainText(node)
48
+
49
+ case 'table':
50
+ return tableToPlainText(node)
51
+
52
+ case 'dataBlock':
53
+ case 'divider':
54
+ case 'image':
55
+ case 'inset_ref':
56
+ return ''
57
+
58
+ default:
59
+ return ''
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Extract plain text from inline content (array of text/image nodes).
65
+ * @param {Array} content
66
+ * @returns {string}
67
+ */
68
+ function inlineToPlainText(content) {
69
+ if (!content) return ''
70
+ return content
71
+ .filter(node => node.type === 'text')
72
+ .map(node => node.text || '')
73
+ .join('')
74
+ }
75
+
76
+ /**
77
+ * Extract plain text from a list node.
78
+ * @param {Object} node - bulletList or orderedList
79
+ * @returns {string}
80
+ */
81
+ function listToPlainText(node) {
82
+ if (!node.content) return ''
83
+ return node.content
84
+ .map(item => listItemToPlainText(item))
85
+ .filter(Boolean)
86
+ .join('\n')
87
+ }
88
+
89
+ /**
90
+ * Extract plain text from a list item.
91
+ * @param {Object} node - listItem node
92
+ * @returns {string}
93
+ */
94
+ function listItemToPlainText(node) {
95
+ if (!node.content) return ''
96
+ return node.content
97
+ .map(child => {
98
+ if (child.type === 'paragraph') return inlineToPlainText(child.content)
99
+ if (child.type === 'bulletList' || child.type === 'orderedList') return listToPlainText(child)
100
+ return ''
101
+ })
102
+ .filter(Boolean)
103
+ .join('\n')
104
+ }
105
+
106
+ /**
107
+ * Extract plain text from a table node.
108
+ * @param {Object} node - table node
109
+ * @returns {string}
110
+ */
111
+ function tableToPlainText(node) {
112
+ if (!node.content) return ''
113
+ return node.content
114
+ .map(row => {
115
+ if (!row.content) return ''
116
+ return row.content
117
+ .map(cell => inlineToPlainText(cell.content?.[0]?.type === 'paragraph' ? cell.content[0].content : null))
118
+ .filter(Boolean)
119
+ .join(' ')
120
+ })
121
+ .filter(Boolean)
122
+ .join('\n')
123
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @fileoverview Core ProseMirror document serializer
3
+ *
4
+ * Iterates doc.content and dispatches to node serializers.
5
+ */
6
+
7
+ import {
8
+ serializeHeading,
9
+ serializeParagraph,
10
+ serializeImage,
11
+ serializeInsetRef,
12
+ serializeCodeBlock,
13
+ serializeDataBlock,
14
+ serializeBlockquote,
15
+ serializeBulletList,
16
+ serializeOrderedList,
17
+ serializeDivider,
18
+ serializeTable,
19
+ setSerializer,
20
+ } from './nodes.js'
21
+
22
+ const NODE_SERIALIZERS = {
23
+ heading: serializeHeading,
24
+ paragraph: serializeParagraph,
25
+ image: serializeImage,
26
+ inset_ref: serializeInsetRef,
27
+ codeBlock: serializeCodeBlock,
28
+ dataBlock: serializeDataBlock,
29
+ blockquote: serializeBlockquote,
30
+ bulletList: serializeBulletList,
31
+ orderedList: serializeOrderedList,
32
+ divider: serializeDivider,
33
+ table: serializeTable,
34
+ }
35
+
36
+ /**
37
+ * Serialize a single ProseMirror node to markdown.
38
+ * @param {Object} node - ProseMirror node
39
+ * @returns {string|null} Markdown string or null
40
+ */
41
+ export function serializeNode(node) {
42
+ const serializer = NODE_SERIALIZERS[node.type]
43
+ if (!serializer) return null
44
+ return serializer(node)
45
+ }
46
+
47
+ // Wire up the circular reference for blockquotes
48
+ setSerializer(serializeNode)
49
+
50
+ /**
51
+ * Serialize a ProseMirror document to markdown.
52
+ * @param {Object} doc - ProseMirror document ({ type: "doc", content: [...] })
53
+ * @returns {string} Markdown string
54
+ */
55
+ export function serializeDoc(doc) {
56
+ if (!doc?.content) return ''
57
+
58
+ return doc.content
59
+ .map(node => serializeNode(node))
60
+ .filter(s => s !== null && s !== undefined)
61
+ .join('\n\n')
62
+ }
@@ -0,0 +1,103 @@
1
+ import { serializeAttributes } from '../src/attributes.js'
2
+
3
+ describe('Attribute Serialization', () => {
4
+ test('returns empty string for null/undefined/empty', () => {
5
+ expect(serializeAttributes(null)).toBe('')
6
+ expect(serializeAttributes(undefined)).toBe('')
7
+ expect(serializeAttributes({})).toBe('')
8
+ })
9
+
10
+ test('skips null and undefined values', () => {
11
+ expect(serializeAttributes({ role: null, src: undefined })).toBe('')
12
+ })
13
+
14
+ test('serializes key=value pairs', () => {
15
+ expect(serializeAttributes({ role: 'hero' })).toBe('{role=hero}')
16
+ })
17
+
18
+ test('serializes multiple key=value pairs', () => {
19
+ expect(serializeAttributes({ width: 800, height: 600 })).toBe('{width=800 height=600}')
20
+ })
21
+
22
+ test('quotes values with spaces', () => {
23
+ expect(serializeAttributes({ rel: 'noopener noreferrer' })).toBe('{rel="noopener noreferrer"}')
24
+ })
25
+
26
+ test('serializes boolean attributes', () => {
27
+ expect(serializeAttributes({ autoplay: true, muted: true })).toBe('{autoplay muted}')
28
+ })
29
+
30
+ test('serializes class as dot notation', () => {
31
+ expect(serializeAttributes({ class: 'featured' })).toBe('{.featured}')
32
+ })
33
+
34
+ test('serializes multiple classes', () => {
35
+ expect(serializeAttributes({ class: 'featured rounded shadow' })).toBe('{.featured .rounded .shadow}')
36
+ })
37
+
38
+ test('serializes id as hash notation', () => {
39
+ expect(serializeAttributes({ id: 'main-logo' })).toBe('{#main-logo}')
40
+ })
41
+
42
+ test('serializes mixed attributes', () => {
43
+ const result = serializeAttributes({
44
+ class: 'featured',
45
+ id: 'main-logo',
46
+ width: 800,
47
+ loading: 'lazy',
48
+ })
49
+ expect(result).toBe('{.featured #main-logo width=800 loading=lazy}')
50
+ })
51
+
52
+ test('skips specified keys', () => {
53
+ const result = serializeAttributes(
54
+ { src: './image.jpg', alt: 'Photo', role: 'hero' },
55
+ ['src', 'alt']
56
+ )
57
+ expect(result).toBe('{role=hero}')
58
+ })
59
+
60
+ test('skips all keys if all are in skipKeys', () => {
61
+ const result = serializeAttributes(
62
+ { src: './image.jpg', alt: 'Photo' },
63
+ ['src', 'alt']
64
+ )
65
+ expect(result).toBe('')
66
+ })
67
+
68
+ test('serializes video attributes', () => {
69
+ const result = serializeAttributes({
70
+ role: 'video',
71
+ poster: './poster.jpg',
72
+ autoplay: true,
73
+ muted: true,
74
+ loop: true,
75
+ })
76
+ expect(result).toBe('{role=video poster=./poster.jpg autoplay muted loop}')
77
+ })
78
+
79
+ test('serializes boolean with other attributes', () => {
80
+ const result = serializeAttributes({
81
+ role: 'video',
82
+ controls: true,
83
+ muted: true,
84
+ })
85
+ expect(result).toBe('{role=video controls muted}')
86
+ })
87
+
88
+ test('serializes download with string value', () => {
89
+ expect(serializeAttributes({ download: 'annual-report.pdf' })).toBe('{download=annual-report.pdf}')
90
+ })
91
+
92
+ test('serializes download as boolean', () => {
93
+ expect(serializeAttributes({ download: true })).toBe('{download}')
94
+ })
95
+
96
+ test('serializes data attributes', () => {
97
+ const result = serializeAttributes({
98
+ class: 'info',
99
+ 'data-tooltip': 'More info',
100
+ })
101
+ expect(result).toBe('{.info data-tooltip="More info"}')
102
+ })
103
+ })
@@ -0,0 +1,69 @@
1
+ import { serializeFrontmatter } from '../src/frontmatter.js'
2
+
3
+ describe('Frontmatter Serialization', () => {
4
+ test('returns empty string for null/undefined/empty', () => {
5
+ expect(serializeFrontmatter(null)).toBe('')
6
+ expect(serializeFrontmatter(undefined)).toBe('')
7
+ expect(serializeFrontmatter({})).toBe('')
8
+ })
9
+
10
+ test('serializes simple params', () => {
11
+ const result = serializeFrontmatter({ type: 'Hero' })
12
+ expect(result).toBe('---\ntype: Hero\n---')
13
+ })
14
+
15
+ test('serializes multiple params', () => {
16
+ const result = serializeFrontmatter({
17
+ type: 'Hero',
18
+ alignment: 'center',
19
+ })
20
+ expect(result).toBe('---\ntype: Hero\nalignment: center\n---')
21
+ })
22
+
23
+ test('skips null and undefined values', () => {
24
+ const result = serializeFrontmatter({
25
+ type: 'Hero',
26
+ background: null,
27
+ variant: undefined,
28
+ alignment: 'center',
29
+ })
30
+ expect(result).toBe('---\ntype: Hero\nalignment: center\n---')
31
+ })
32
+
33
+ test('returns empty string when all values are null', () => {
34
+ expect(serializeFrontmatter({ a: null, b: undefined })).toBe('')
35
+ })
36
+
37
+ test('serializes nested objects', () => {
38
+ const result = serializeFrontmatter({
39
+ type: 'Section',
40
+ params: { width: 1200, layout: 'grid' },
41
+ })
42
+ expect(result).toContain('type: Section')
43
+ expect(result).toContain('params:')
44
+ expect(result).toContain('width: 1200')
45
+ expect(result).toContain('layout: grid')
46
+ expect(result).toMatch(/^---\n/)
47
+ expect(result).toMatch(/\n---$/)
48
+ })
49
+
50
+ test('serializes boolean values', () => {
51
+ const result = serializeFrontmatter({ hidden: true })
52
+ expect(result).toBe('---\nhidden: true\n---')
53
+ })
54
+
55
+ test('serializes numeric values', () => {
56
+ const result = serializeFrontmatter({ order: 3 })
57
+ expect(result).toBe('---\norder: 3\n---')
58
+ })
59
+
60
+ test('serializes arrays', () => {
61
+ const result = serializeFrontmatter({
62
+ type: 'Page',
63
+ tags: ['featured', 'news'],
64
+ })
65
+ expect(result).toContain('tags:')
66
+ expect(result).toContain('- featured')
67
+ expect(result).toContain('- news')
68
+ })
69
+ })
@@ -0,0 +1,315 @@
1
+ import { serializeInlineContent } from '../src/marks.js'
2
+
3
+ describe('Inline Content Serialization', () => {
4
+ test('serializes plain text', () => {
5
+ const content = [{ type: 'text', text: 'Hello world' }]
6
+ expect(serializeInlineContent(content)).toBe('Hello world')
7
+ })
8
+
9
+ test('serializes bold text', () => {
10
+ const content = [
11
+ { type: 'text', text: 'Some ' },
12
+ { type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
13
+ { type: 'text', text: ' text' },
14
+ ]
15
+ expect(serializeInlineContent(content)).toBe('Some **bold** text')
16
+ })
17
+
18
+ test('serializes italic text', () => {
19
+ const content = [
20
+ { type: 'text', text: 'Some ' },
21
+ { type: 'text', text: 'italic', marks: [{ type: 'italic' }] },
22
+ { type: 'text', text: ' text' },
23
+ ]
24
+ expect(serializeInlineContent(content)).toBe('Some *italic* text')
25
+ })
26
+
27
+ test('serializes bold italic text', () => {
28
+ const content = [
29
+ {
30
+ type: 'text',
31
+ text: 'bold italic both',
32
+ marks: [{ type: 'bold' }, { type: 'italic' }],
33
+ },
34
+ ]
35
+ expect(serializeInlineContent(content)).toBe('***bold italic both***')
36
+ })
37
+
38
+ test('serializes nested formatting (bold with italic inside)', () => {
39
+ const content = [
40
+ { type: 'text', text: 'bold ', marks: [{ type: 'bold' }] },
41
+ {
42
+ type: 'text',
43
+ text: 'then italic',
44
+ marks: [{ type: 'italic' }, { type: 'bold' }],
45
+ },
46
+ ]
47
+ expect(serializeInlineContent(content)).toBe('**bold *****then italic***')
48
+ })
49
+
50
+ test('serializes inline code', () => {
51
+ const content = [
52
+ { type: 'text', text: 'Use the ' },
53
+ { type: 'text', text: "console.log('test')", marks: [{ type: 'code' }] },
54
+ { type: 'text', text: ' function.' },
55
+ ]
56
+ expect(serializeInlineContent(content)).toBe("Use the `console.log('test')` function.")
57
+ })
58
+
59
+ test('serializes simple link', () => {
60
+ const content = [
61
+ {
62
+ type: 'text',
63
+ text: 'Link text',
64
+ marks: [
65
+ { type: 'link', attrs: { href: 'https://example.com', title: 'Title' } },
66
+ ],
67
+ },
68
+ ]
69
+ expect(serializeInlineContent(content)).toBe('[Link text](https://example.com "Title")')
70
+ })
71
+
72
+ test('serializes link without title', () => {
73
+ const content = [
74
+ {
75
+ type: 'text',
76
+ text: 'Link',
77
+ marks: [
78
+ { type: 'link', attrs: { href: 'https://example.com', title: null } },
79
+ ],
80
+ },
81
+ ]
82
+ expect(serializeInlineContent(content)).toBe('[Link](https://example.com)')
83
+ })
84
+
85
+ test('serializes link with attributes', () => {
86
+ const content = [
87
+ {
88
+ type: 'text',
89
+ text: 'External Link',
90
+ marks: [
91
+ {
92
+ type: 'link',
93
+ attrs: {
94
+ href: 'https://example.com',
95
+ title: null,
96
+ target: '_blank',
97
+ rel: 'noopener noreferrer',
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ ]
103
+ expect(serializeInlineContent(content)).toBe(
104
+ '[External Link](https://example.com){target=_blank rel="noopener noreferrer"}'
105
+ )
106
+ })
107
+
108
+ test('serializes download link (boolean)', () => {
109
+ const content = [
110
+ {
111
+ type: 'text',
112
+ text: 'Download PDF',
113
+ marks: [
114
+ {
115
+ type: 'link',
116
+ attrs: { href: './document.pdf', title: null, download: true },
117
+ },
118
+ ],
119
+ },
120
+ ]
121
+ expect(serializeInlineContent(content)).toBe('[Download PDF](./document.pdf){download}')
122
+ })
123
+
124
+ test('serializes download link with filename', () => {
125
+ const content = [
126
+ {
127
+ type: 'text',
128
+ text: 'Get Report',
129
+ marks: [
130
+ {
131
+ type: 'link',
132
+ attrs: { href: './data.pdf', title: null, download: 'annual-report.pdf' },
133
+ },
134
+ ],
135
+ },
136
+ ]
137
+ expect(serializeInlineContent(content)).toBe(
138
+ '[Get Report](./data.pdf){download=annual-report.pdf}'
139
+ )
140
+ })
141
+
142
+ test('serializes button link', () => {
143
+ const content = [
144
+ {
145
+ type: 'text',
146
+ text: 'Button Text',
147
+ marks: [
148
+ {
149
+ type: 'button',
150
+ attrs: { href: 'https://example.com', title: null, variant: 'primary' },
151
+ },
152
+ ],
153
+ },
154
+ ]
155
+ expect(serializeInlineContent(content)).toBe('[Button Text](button:https://example.com)')
156
+ })
157
+
158
+ test('serializes button with non-default variant', () => {
159
+ const content = [
160
+ {
161
+ type: 'text',
162
+ text: 'Get Started',
163
+ marks: [
164
+ {
165
+ type: 'button',
166
+ attrs: { href: 'https://example.com', title: null, variant: 'secondary', size: 'lg' },
167
+ },
168
+ ],
169
+ },
170
+ ]
171
+ expect(serializeInlineContent(content)).toBe(
172
+ '[Get Started](button:https://example.com){variant=secondary size=lg}'
173
+ )
174
+ })
175
+
176
+ test('serializes button with icon', () => {
177
+ const content = [
178
+ {
179
+ type: 'text',
180
+ text: 'Learn More',
181
+ marks: [
182
+ {
183
+ type: 'button',
184
+ attrs: { href: 'https://example.com', title: null, variant: 'primary', icon: 'arrow-right' },
185
+ },
186
+ ],
187
+ },
188
+ ]
189
+ expect(serializeInlineContent(content)).toBe(
190
+ '[Learn More](button:https://example.com){icon=arrow-right}'
191
+ )
192
+ })
193
+
194
+ test('serializes span with class', () => {
195
+ const content = [
196
+ { type: 'text', text: 'This is ' },
197
+ {
198
+ type: 'text',
199
+ text: 'highlighted text',
200
+ marks: [{ type: 'span', attrs: { class: 'highlight' } }],
201
+ },
202
+ { type: 'text', text: ' in a sentence.' },
203
+ ]
204
+ expect(serializeInlineContent(content)).toBe(
205
+ 'This is [highlighted text]{.highlight} in a sentence.'
206
+ )
207
+ })
208
+
209
+ test('serializes span with multiple classes', () => {
210
+ const content = [
211
+ {
212
+ type: 'text',
213
+ text: 'important note',
214
+ marks: [{ type: 'span', attrs: { class: 'callout bold' } }],
215
+ },
216
+ ]
217
+ expect(serializeInlineContent(content)).toBe('[important note]{.callout .bold}')
218
+ })
219
+
220
+ test('serializes span with id and class', () => {
221
+ const content = [
222
+ {
223
+ type: 'text',
224
+ text: 'key term',
225
+ marks: [{ type: 'span', attrs: { class: 'highlight', id: 'glossary-term' } }],
226
+ },
227
+ ]
228
+ expect(serializeInlineContent(content)).toBe('[key term]{.highlight #glossary-term}')
229
+ })
230
+
231
+ test('serializes span with custom attributes', () => {
232
+ const content = [
233
+ {
234
+ type: 'text',
235
+ text: 'tooltip text',
236
+ marks: [{ type: 'span', attrs: { class: 'info', 'data-tooltip': 'More info' } }],
237
+ },
238
+ ]
239
+ expect(serializeInlineContent(content)).toBe('[tooltip text]{.info data-tooltip="More info"}')
240
+ })
241
+
242
+ test('serializes inline icon image', () => {
243
+ const content = [
244
+ {
245
+ type: 'image',
246
+ attrs: {
247
+ src: null,
248
+ caption: null,
249
+ alt: null,
250
+ role: 'icon',
251
+ library: 'lu',
252
+ name: 'home',
253
+ },
254
+ },
255
+ { type: 'text', text: ' ' },
256
+ {
257
+ type: 'text',
258
+ text: 'Sports',
259
+ marks: [
260
+ { type: 'link', attrs: { href: '/sports', title: null } },
261
+ ],
262
+ },
263
+ ]
264
+ expect(serializeInlineContent(content)).toBe('![](lu-home) [Sports](/sports)')
265
+ })
266
+
267
+ test('serializes multiple spans in same paragraph', () => {
268
+ const content = [
269
+ {
270
+ type: 'text',
271
+ text: 'first',
272
+ marks: [{ type: 'span', attrs: { class: 'highlight' } }],
273
+ },
274
+ { type: 'text', text: ' normal ' },
275
+ {
276
+ type: 'text',
277
+ text: 'second',
278
+ marks: [{ type: 'span', attrs: { class: 'muted' } }],
279
+ },
280
+ ]
281
+ expect(serializeInlineContent(content)).toBe('[first]{.highlight} normal [second]{.muted}')
282
+ })
283
+
284
+ test('serializes link and span in same paragraph', () => {
285
+ const content = [
286
+ {
287
+ type: 'text',
288
+ text: 'Link',
289
+ marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
290
+ },
291
+ { type: 'text', text: ' and ' },
292
+ {
293
+ type: 'text',
294
+ text: 'span',
295
+ marks: [{ type: 'span', attrs: { class: 'highlight' } }],
296
+ },
297
+ ]
298
+ expect(serializeInlineContent(content)).toBe(
299
+ '[Link](https://example.com) and [span]{.highlight}'
300
+ )
301
+ })
302
+
303
+ test('returns empty string for empty content', () => {
304
+ expect(serializeInlineContent([])).toBe('')
305
+ expect(serializeInlineContent(null)).toBe('')
306
+ expect(serializeInlineContent(undefined)).toBe('')
307
+ })
308
+
309
+ test('serializes text with special characters', () => {
310
+ const content = [
311
+ { type: 'text', text: `text with 'single', "double", & specials` },
312
+ ]
313
+ expect(serializeInlineContent(content)).toBe(`text with 'single', "double", & specials`)
314
+ })
315
+ })