@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.
- package/LICENSE +674 -0
- package/package.json +43 -0
- package/src/attributes.js +49 -0
- package/src/frontmatter.js +34 -0
- package/src/index.js +36 -0
- package/src/marks.js +254 -0
- package/src/nodes.js +232 -0
- package/src/plain-text.js +123 -0
- package/src/serializer.js +62 -0
- package/tests/attributes.test.js +103 -0
- package/tests/frontmatter.test.js +69 -0
- package/tests/marks.test.js +315 -0
- package/tests/plain-text.test.js +218 -0
- package/tests/roundtrip.test.js +307 -0
- package/tests/serializer.test.js +652 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniweb/content-writer",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "ProseMirror document structure to Markdown converter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"markdown",
|
|
12
|
+
"prosemirror",
|
|
13
|
+
"serializer"
|
|
14
|
+
],
|
|
15
|
+
"author": "Proximify Inc.",
|
|
16
|
+
"license": "GPL-3.0-or-later",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"js-yaml": "^4.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"jest": "^29.7.0",
|
|
22
|
+
"@uniweb/content-reader": "1.1.4"
|
|
23
|
+
},
|
|
24
|
+
"jest": {
|
|
25
|
+
"testEnvironment": "node",
|
|
26
|
+
"verbose": true
|
|
27
|
+
},
|
|
28
|
+
"directories": {
|
|
29
|
+
"test": "tests"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/uniweb/content-writer.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/uniweb/content-writer/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/uniweb/content-writer#readme",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
41
|
+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Serialize attributes to curly brace syntax
|
|
3
|
+
*
|
|
4
|
+
* Inverse of content-reader's attributes.js.
|
|
5
|
+
* Produces syntax like: {role=hero width=1200 .class #id autoplay}
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serialize an attributes object to curly brace syntax.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} attrs - Attributes object
|
|
12
|
+
* @param {string[]} [skipKeys=[]] - Keys to skip (already encoded elsewhere)
|
|
13
|
+
* @returns {string} Serialized attributes string (e.g., "{role=hero width=1200}") or empty string
|
|
14
|
+
*/
|
|
15
|
+
export function serializeAttributes(attrs, skipKeys = []) {
|
|
16
|
+
if (!attrs || typeof attrs !== 'object') return ''
|
|
17
|
+
|
|
18
|
+
const parts = []
|
|
19
|
+
const skipSet = new Set(skipKeys)
|
|
20
|
+
|
|
21
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
22
|
+
if (skipSet.has(key)) continue
|
|
23
|
+
if (value === null || value === undefined) continue
|
|
24
|
+
|
|
25
|
+
if (key === 'class') {
|
|
26
|
+
// Split class string into individual .class entries
|
|
27
|
+
const classes = String(value).split(/\s+/).filter(Boolean)
|
|
28
|
+
for (const cls of classes) {
|
|
29
|
+
parts.push(`.${cls}`)
|
|
30
|
+
}
|
|
31
|
+
} else if (key === 'id') {
|
|
32
|
+
parts.push(`#${value}`)
|
|
33
|
+
} else if (value === true) {
|
|
34
|
+
// Boolean attribute
|
|
35
|
+
parts.push(key)
|
|
36
|
+
} else {
|
|
37
|
+
// Key=value pair
|
|
38
|
+
const strValue = String(value)
|
|
39
|
+
if (strValue.includes(' ')) {
|
|
40
|
+
parts.push(`${key}="${strValue}"`)
|
|
41
|
+
} else {
|
|
42
|
+
parts.push(`${key}=${strValue}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (parts.length === 0) return ''
|
|
48
|
+
return `{${parts.join(' ')}}`
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview YAML frontmatter serialization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import yaml from 'js-yaml'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Serialize a params object to YAML frontmatter.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} params - Frontmatter parameters
|
|
11
|
+
* @returns {string} YAML frontmatter block (with --- fences) or empty string
|
|
12
|
+
*/
|
|
13
|
+
export function serializeFrontmatter(params) {
|
|
14
|
+
if (!params || typeof params !== 'object') return ''
|
|
15
|
+
|
|
16
|
+
// Filter out null/undefined values
|
|
17
|
+
const filtered = {}
|
|
18
|
+
for (const [key, value] of Object.entries(params)) {
|
|
19
|
+
if (value !== null && value !== undefined) {
|
|
20
|
+
filtered[key] = value
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (Object.keys(filtered).length === 0) return ''
|
|
25
|
+
|
|
26
|
+
const yamlStr = yaml.dump(filtered, {
|
|
27
|
+
lineWidth: -1, // Don't wrap long lines
|
|
28
|
+
quotingType: "'", // Use single quotes when quoting is needed
|
|
29
|
+
forceQuotes: false, // Only quote when necessary
|
|
30
|
+
noRefs: true, // Don't use YAML references
|
|
31
|
+
}).trimEnd()
|
|
32
|
+
|
|
33
|
+
return `---\n${yamlStr}\n---`
|
|
34
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview ProseMirror document to Markdown converter
|
|
3
|
+
*
|
|
4
|
+
* The inverse of @uniweb/content-reader. Serializes ProseMirror JSON
|
|
5
|
+
* documents back to Markdown text.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { serializeDoc as proseMirrorToMarkdown } from './serializer.js'
|
|
9
|
+
export { serializeFrontmatter } from './frontmatter.js'
|
|
10
|
+
export { docToPlainText } from './plain-text.js'
|
|
11
|
+
|
|
12
|
+
import { serializeDoc } from './serializer.js'
|
|
13
|
+
import { serializeFrontmatter } from './frontmatter.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Serialize a complete section file (frontmatter + body).
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} params - Frontmatter parameters (type, alignment, etc.)
|
|
19
|
+
* @param {Object} doc - ProseMirror document JSON
|
|
20
|
+
* @returns {string} Complete markdown file content
|
|
21
|
+
*/
|
|
22
|
+
export function serializeSection(params, doc) {
|
|
23
|
+
const frontmatter = serializeFrontmatter(params)
|
|
24
|
+
const body = serializeDoc(doc)
|
|
25
|
+
|
|
26
|
+
if (frontmatter && body) {
|
|
27
|
+
return `${frontmatter}\n\n${body}\n`
|
|
28
|
+
}
|
|
29
|
+
if (frontmatter) {
|
|
30
|
+
return `${frontmatter}\n`
|
|
31
|
+
}
|
|
32
|
+
if (body) {
|
|
33
|
+
return `${body}\n`
|
|
34
|
+
}
|
|
35
|
+
return ''
|
|
36
|
+
}
|
package/src/marks.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Serialize inline ProseMirror content (text nodes with marks) to markdown
|
|
3
|
+
*
|
|
4
|
+
* Handles mark interleaving, link/button/span wrapping, and bold/italic nesting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { serializeAttributes } from './attributes.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a node has a specific mark type.
|
|
11
|
+
* @param {Object} node - ProseMirror inline node
|
|
12
|
+
* @param {string} type - Mark type
|
|
13
|
+
* @returns {Object|undefined} The mark object if found
|
|
14
|
+
*/
|
|
15
|
+
function findMark(node, type) {
|
|
16
|
+
return node.marks?.find(m => m.type === type)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if two marks are the same (same type and same attrs for link/button/span).
|
|
21
|
+
*/
|
|
22
|
+
function marksEqual(a, b) {
|
|
23
|
+
if (a.type !== b.type) return false
|
|
24
|
+
if (a.type === 'link' || a.type === 'button' || a.type === 'span') {
|
|
25
|
+
return JSON.stringify(a.attrs) === JSON.stringify(b.attrs)
|
|
26
|
+
}
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serialize a link mark's suffix: (href "title"){attrs}
|
|
32
|
+
*/
|
|
33
|
+
function serializeLinkSuffix(mark) {
|
|
34
|
+
const { href, title, ...rest } = mark.attrs || {}
|
|
35
|
+
const titlePart = title ? ` "${title}"` : ''
|
|
36
|
+
const attrStr = serializeAttributes(rest, ['href', 'title'])
|
|
37
|
+
return `(${href}${titlePart})${attrStr}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serialize a button mark's suffix: (button:href "title"){attrs}
|
|
42
|
+
*/
|
|
43
|
+
function serializeButtonSuffix(mark) {
|
|
44
|
+
const { href, title, variant, ...rest } = mark.attrs || {}
|
|
45
|
+
const titlePart = title ? ` "${title}"` : ''
|
|
46
|
+
// Build attrs with variant first for consistent ordering
|
|
47
|
+
const extraAttrs = {}
|
|
48
|
+
if (variant && variant !== 'primary') {
|
|
49
|
+
extraAttrs.variant = variant
|
|
50
|
+
}
|
|
51
|
+
Object.assign(extraAttrs, rest)
|
|
52
|
+
const attrStr = serializeAttributes(extraAttrs, ['href', 'title'])
|
|
53
|
+
return `(button:${href}${titlePart})${attrStr}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Serialize a span mark's suffix: {.class #id attrs}
|
|
58
|
+
*/
|
|
59
|
+
function serializeSpanSuffix(mark) {
|
|
60
|
+
return serializeAttributes(mark.attrs || {})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize an inline image node (icon within a paragraph).
|
|
65
|
+
*/
|
|
66
|
+
function serializeInlineImage(node) {
|
|
67
|
+
const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
|
|
68
|
+
|
|
69
|
+
// Icon with library+name → compact dash format
|
|
70
|
+
if (library && name) {
|
|
71
|
+
const iconSrc = `${library}-${name}`
|
|
72
|
+
const extraAttrs = { ...rest }
|
|
73
|
+
// Remove icon-derived attrs from extra attrs
|
|
74
|
+
delete extraAttrs.size
|
|
75
|
+
delete extraAttrs.color
|
|
76
|
+
// Add back size and color if they exist
|
|
77
|
+
if (node.attrs.size) extraAttrs.size = node.attrs.size
|
|
78
|
+
if (node.attrs.color) extraAttrs.color = node.attrs.color
|
|
79
|
+
const attrStr = serializeAttributes(extraAttrs)
|
|
80
|
+
const altPart = alt || ''
|
|
81
|
+
return `${attrStr}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Icon with src and role=icon → icon:src prefix
|
|
85
|
+
if (role === 'icon' && src) {
|
|
86
|
+
const attrStr = serializeAttributes(rest, ['role'])
|
|
87
|
+
const altPart = alt || ''
|
|
88
|
+
const captionPart = caption ? ` "${caption}"` : ''
|
|
89
|
+
return `${attrStr}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Regular image (shouldn't normally appear inline, but handle it)
|
|
93
|
+
return serializeBlockImage(node)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialize a block-level image node.
|
|
98
|
+
*/
|
|
99
|
+
export function serializeBlockImage(node) {
|
|
100
|
+
const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
|
|
101
|
+
|
|
102
|
+
// Icon with library+name → compact dash format
|
|
103
|
+
if (library && name) {
|
|
104
|
+
const iconSrc = `${library}-${name}`
|
|
105
|
+
const extraAttrs = { ...rest }
|
|
106
|
+
const attrStr = serializeAttributes(extraAttrs)
|
|
107
|
+
const altPart = alt || ''
|
|
108
|
+
return `${attrStr}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Build the src part
|
|
112
|
+
let srcPart = src || ''
|
|
113
|
+
|
|
114
|
+
// For non-default roles, use role:src prefix format (except for video/pdf which use attrs)
|
|
115
|
+
// Build attrs with role first for consistent ordering
|
|
116
|
+
const attrsToSerialize = {}
|
|
117
|
+
if (role && role !== 'image') {
|
|
118
|
+
if (role === 'icon' && src) {
|
|
119
|
+
srcPart = `icon:${src}`
|
|
120
|
+
} else {
|
|
121
|
+
attrsToSerialize.role = role
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
Object.assign(attrsToSerialize, rest)
|
|
125
|
+
|
|
126
|
+
const altPart = alt || ''
|
|
127
|
+
const captionPart = caption ? ` "${caption}"` : ''
|
|
128
|
+
const attrStr = serializeAttributes(attrsToSerialize)
|
|
129
|
+
return `${attrStr}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Serialize an array of inline ProseMirror nodes to a markdown string.
|
|
134
|
+
*
|
|
135
|
+
* @param {Array} content - Array of text/image nodes with optional marks
|
|
136
|
+
* @returns {string} Markdown string
|
|
137
|
+
*/
|
|
138
|
+
export function serializeInlineContent(content) {
|
|
139
|
+
if (!content || content.length === 0) return ''
|
|
140
|
+
|
|
141
|
+
// Pre-process: group nodes by wrapping marks (link, button, span)
|
|
142
|
+
const segments = groupByWrappingMarks(content)
|
|
143
|
+
return segments.map(seg => serializeSegment(seg)).join('')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Group consecutive nodes by shared wrapping marks (link, button, span).
|
|
148
|
+
* Returns an array of segments, where each segment is either:
|
|
149
|
+
* - { type: 'link'|'button'|'span', mark, nodes } — wrapped group
|
|
150
|
+
* - { type: 'plain', nodes } — unwrapped nodes
|
|
151
|
+
*/
|
|
152
|
+
function groupByWrappingMarks(content) {
|
|
153
|
+
const segments = []
|
|
154
|
+
let i = 0
|
|
155
|
+
|
|
156
|
+
while (i < content.length) {
|
|
157
|
+
const node = content[i]
|
|
158
|
+
|
|
159
|
+
// Check for wrapping marks: link, button, span
|
|
160
|
+
const linkMark = findMark(node, 'link')
|
|
161
|
+
const buttonMark = findMark(node, 'button')
|
|
162
|
+
const spanMark = findMark(node, 'span')
|
|
163
|
+
const wrappingMark = buttonMark || linkMark || spanMark
|
|
164
|
+
|
|
165
|
+
if (wrappingMark && node.type === 'text') {
|
|
166
|
+
// Collect consecutive nodes with the same wrapping mark
|
|
167
|
+
const group = [node]
|
|
168
|
+
let j = i + 1
|
|
169
|
+
while (j < content.length && content[j].type === 'text') {
|
|
170
|
+
const nextMark = findMark(content[j], wrappingMark.type)
|
|
171
|
+
if (nextMark && marksEqual(nextMark, wrappingMark)) {
|
|
172
|
+
group.push(content[j])
|
|
173
|
+
j++
|
|
174
|
+
} else {
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
segments.push({ type: wrappingMark.type, mark: wrappingMark, nodes: group })
|
|
179
|
+
i = j
|
|
180
|
+
} else {
|
|
181
|
+
// Plain node (no wrapping mark, or an image node)
|
|
182
|
+
segments.push({ type: 'plain', nodes: [node] })
|
|
183
|
+
i++
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return segments
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Serialize a segment (group of nodes with a common wrapping mark, or plain nodes).
|
|
192
|
+
*/
|
|
193
|
+
function serializeSegment(segment) {
|
|
194
|
+
if (segment.type === 'plain') {
|
|
195
|
+
return segment.nodes.map(n => serializePlainNode(n)).join('')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Wrapped segment: serialize inner content (with the wrapping mark stripped)
|
|
199
|
+
const innerText = segment.nodes.map(node => {
|
|
200
|
+
// Strip the wrapping mark from this node's marks for inner serialization
|
|
201
|
+
const innerMarks = (node.marks || []).filter(m => !marksEqual(m, segment.mark))
|
|
202
|
+
return serializeTextWithMarks(node.text, innerMarks)
|
|
203
|
+
}).join('')
|
|
204
|
+
|
|
205
|
+
if (segment.type === 'link') {
|
|
206
|
+
return `[${innerText}]${serializeLinkSuffix(segment.mark)}`
|
|
207
|
+
}
|
|
208
|
+
if (segment.type === 'button') {
|
|
209
|
+
return `[${innerText}]${serializeButtonSuffix(segment.mark)}`
|
|
210
|
+
}
|
|
211
|
+
if (segment.type === 'span') {
|
|
212
|
+
return `[${innerText}]${serializeSpanSuffix(segment.mark)}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return innerText
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Serialize a plain node (no wrapping mark).
|
|
220
|
+
*/
|
|
221
|
+
function serializePlainNode(node) {
|
|
222
|
+
if (node.type === 'image') {
|
|
223
|
+
return serializeInlineImage(node)
|
|
224
|
+
}
|
|
225
|
+
if (node.type !== 'text') return ''
|
|
226
|
+
return serializeTextWithMarks(node.text, node.marks || [])
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Serialize text with formatting marks (bold, italic, code).
|
|
231
|
+
*/
|
|
232
|
+
function serializeTextWithMarks(text, marks) {
|
|
233
|
+
if (!marks || marks.length === 0) return text
|
|
234
|
+
|
|
235
|
+
const hasCode = marks.some(m => m.type === 'code')
|
|
236
|
+
if (hasCode) {
|
|
237
|
+
return `\`${text}\``
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hasBold = marks.some(m => m.type === 'bold')
|
|
241
|
+
const hasItalic = marks.some(m => m.type === 'italic')
|
|
242
|
+
|
|
243
|
+
if (hasBold && hasItalic) {
|
|
244
|
+
return `***${text}***`
|
|
245
|
+
}
|
|
246
|
+
if (hasBold) {
|
|
247
|
+
return `**${text}**`
|
|
248
|
+
}
|
|
249
|
+
if (hasItalic) {
|
|
250
|
+
return `*${text}*`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return text
|
|
254
|
+
}
|
package/src/nodes.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Block-level node serializers
|
|
3
|
+
*
|
|
4
|
+
* Each function takes a ProseMirror node and returns a markdown string.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { serializeInlineContent, serializeBlockImage } from './marks.js'
|
|
8
|
+
import { serializeAttributes } from './attributes.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serialize a heading node.
|
|
12
|
+
* @param {Object} node - Heading node with attrs.level and content
|
|
13
|
+
* @returns {string} Markdown heading
|
|
14
|
+
*/
|
|
15
|
+
export function serializeHeading(node) {
|
|
16
|
+
const prefix = '#'.repeat(node.attrs?.level || 1)
|
|
17
|
+
const text = serializeInlineContent(node.content)
|
|
18
|
+
return `${prefix} ${text}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serialize a paragraph node.
|
|
23
|
+
* @param {Object} node - Paragraph node with content
|
|
24
|
+
* @returns {string} Markdown paragraph text
|
|
25
|
+
*/
|
|
26
|
+
export function serializeParagraph(node) {
|
|
27
|
+
return serializeInlineContent(node.content)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serialize an image node (block-level).
|
|
32
|
+
* @param {Object} node - Image node with attrs
|
|
33
|
+
* @returns {string} Markdown image
|
|
34
|
+
*/
|
|
35
|
+
export function serializeImage(node) {
|
|
36
|
+
return serializeBlockImage(node)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serialize an inset_ref node (component reference).
|
|
41
|
+
* @param {Object} node - Inset ref node with attrs.component
|
|
42
|
+
* @returns {string} Markdown component reference
|
|
43
|
+
*/
|
|
44
|
+
export function serializeInsetRef(node) {
|
|
45
|
+
const { component, alt, ...rest } = node.attrs || {}
|
|
46
|
+
const altPart = alt || ''
|
|
47
|
+
const attrStr = serializeAttributes(rest)
|
|
48
|
+
return `${attrStr}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Serialize a code block node.
|
|
53
|
+
* @param {Object} node - Code block node with attrs.language and content
|
|
54
|
+
* @returns {string} Fenced code block
|
|
55
|
+
*/
|
|
56
|
+
export function serializeCodeBlock(node) {
|
|
57
|
+
const lang = node.attrs?.language || ''
|
|
58
|
+
const tag = node.attrs?.tag ? `:${node.attrs.tag}` : ''
|
|
59
|
+
const text = node.content?.[0]?.text || ''
|
|
60
|
+
return `\`\`\`${lang}${tag}\n${text}\n\`\`\``
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize a data block node.
|
|
65
|
+
* @param {Object} node - Data block node with attrs.tag and attrs.data
|
|
66
|
+
* @returns {string} Tagged fenced code block with serialized data
|
|
67
|
+
*/
|
|
68
|
+
export function serializeDataBlock(node) {
|
|
69
|
+
const { tag, data } = node.attrs || {}
|
|
70
|
+
const serialized = JSON.stringify(data, null, 2)
|
|
71
|
+
return `\`\`\`json:${tag}\n${serialized}\n\`\`\``
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Serialize a blockquote node.
|
|
76
|
+
* @param {Object} node - Blockquote node with content
|
|
77
|
+
* @returns {string} Blockquote with > prefix
|
|
78
|
+
*/
|
|
79
|
+
export function serializeBlockquote(node) {
|
|
80
|
+
if (!node.content) return '>'
|
|
81
|
+
|
|
82
|
+
// Recursively serialize the blockquote's content
|
|
83
|
+
const { serializeNode } = await_serializer()
|
|
84
|
+
const innerLines = node.content
|
|
85
|
+
.map(child => serializeNode(child))
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join('\n\n')
|
|
88
|
+
|
|
89
|
+
return innerLines
|
|
90
|
+
.split('\n')
|
|
91
|
+
.map(line => line ? `> ${line}` : '>')
|
|
92
|
+
.join('\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Serialize a bullet list node.
|
|
97
|
+
* @param {Object} node - Bullet list node with content (listItem nodes)
|
|
98
|
+
* @param {number} [indent=0] - Indentation level
|
|
99
|
+
* @returns {string} Markdown bullet list
|
|
100
|
+
*/
|
|
101
|
+
export function serializeBulletList(node, indent = 0) {
|
|
102
|
+
if (!node.content) return ''
|
|
103
|
+
const prefix = ' '.repeat(indent)
|
|
104
|
+
return node.content
|
|
105
|
+
.map(item => serializeListItem(item, `${prefix}- `, indent))
|
|
106
|
+
.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Serialize an ordered list node.
|
|
111
|
+
* @param {Object} node - Ordered list node with attrs.start and content
|
|
112
|
+
* @param {number} [indent=0] - Indentation level
|
|
113
|
+
* @returns {string} Markdown ordered list
|
|
114
|
+
*/
|
|
115
|
+
export function serializeOrderedList(node, indent = 0) {
|
|
116
|
+
if (!node.content) return ''
|
|
117
|
+
const start = node.attrs?.start || 1
|
|
118
|
+
const prefix = ' '.repeat(indent)
|
|
119
|
+
return node.content
|
|
120
|
+
.map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent))
|
|
121
|
+
.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Serialize a list item.
|
|
126
|
+
* @param {Object} node - List item node with content
|
|
127
|
+
* @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
|
|
128
|
+
* @param {number} indent - Current indentation level
|
|
129
|
+
* @returns {string} Markdown list item
|
|
130
|
+
*/
|
|
131
|
+
function serializeListItem(node, bullet, indent) {
|
|
132
|
+
if (!node.content) return bullet
|
|
133
|
+
|
|
134
|
+
const parts = []
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < node.content.length; i++) {
|
|
137
|
+
const child = node.content[i]
|
|
138
|
+
|
|
139
|
+
if (i === 0 && child.type === 'paragraph') {
|
|
140
|
+
// First child paragraph is the item text
|
|
141
|
+
parts.push(bullet + serializeInlineContent(child.content))
|
|
142
|
+
} else if (child.type === 'bulletList') {
|
|
143
|
+
parts.push(serializeBulletList(child, indent + 1))
|
|
144
|
+
} else if (child.type === 'orderedList') {
|
|
145
|
+
parts.push(serializeOrderedList(child, indent + 1))
|
|
146
|
+
} else if (child.type === 'paragraph') {
|
|
147
|
+
// Additional paragraphs in the same list item
|
|
148
|
+
const pad = ' '.repeat(bullet.length)
|
|
149
|
+
parts.push(pad + serializeInlineContent(child.content))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parts.join('\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Serialize a divider node.
|
|
158
|
+
* @returns {string} Markdown horizontal rule
|
|
159
|
+
*/
|
|
160
|
+
export function serializeDivider() {
|
|
161
|
+
return '---'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Serialize a table node.
|
|
166
|
+
* @param {Object} node - Table node with content (tableRow nodes)
|
|
167
|
+
* @returns {string} GFM table
|
|
168
|
+
*/
|
|
169
|
+
export function serializeTable(node) {
|
|
170
|
+
if (!node.content || node.content.length === 0) return ''
|
|
171
|
+
|
|
172
|
+
const rows = node.content
|
|
173
|
+
const headerRow = rows[0]
|
|
174
|
+
const bodyRows = rows.slice(1)
|
|
175
|
+
|
|
176
|
+
// Serialize header cells
|
|
177
|
+
const headerCells = headerRow.content.map(cell => serializeTableCell(cell))
|
|
178
|
+
|
|
179
|
+
// Build alignment row from header cell attrs
|
|
180
|
+
const alignments = headerRow.content.map(cell => {
|
|
181
|
+
const align = cell.attrs?.align
|
|
182
|
+
if (align === 'left') return ':---'
|
|
183
|
+
if (align === 'center') return ':---:'
|
|
184
|
+
if (align === 'right') return '---:'
|
|
185
|
+
return '---'
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Serialize body rows
|
|
189
|
+
const bodyLines = bodyRows.map(row =>
|
|
190
|
+
'| ' + row.content.map(cell => serializeTableCell(cell)).join(' | ') + ' |'
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const headerLine = '| ' + headerCells.join(' | ') + ' |'
|
|
194
|
+
const alignLine = '| ' + alignments.join(' | ') + ' |'
|
|
195
|
+
|
|
196
|
+
return [headerLine, alignLine, ...bodyLines].join('\n')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Serialize a table cell's content.
|
|
201
|
+
* @param {Object} cell - Table cell node
|
|
202
|
+
* @returns {string} Cell content as inline markdown
|
|
203
|
+
*/
|
|
204
|
+
function serializeTableCell(cell) {
|
|
205
|
+
if (!cell.content) return ''
|
|
206
|
+
// Table cells contain paragraphs; serialize their inline content
|
|
207
|
+
return cell.content
|
|
208
|
+
.map(child => {
|
|
209
|
+
if (child.type === 'paragraph') {
|
|
210
|
+
return serializeInlineContent(child.content)
|
|
211
|
+
}
|
|
212
|
+
return ''
|
|
213
|
+
})
|
|
214
|
+
.join(' ')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Lazy reference to serializeNode to handle circular dependency with blockquote
|
|
218
|
+
let _serializeNode = null
|
|
219
|
+
function await_serializer() {
|
|
220
|
+
if (!_serializeNode) {
|
|
221
|
+
throw new Error('Serializer not initialized — call setSerializer() first')
|
|
222
|
+
}
|
|
223
|
+
return { serializeNode: _serializeNode }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Set the serializer reference for recursive node serialization (blockquotes).
|
|
228
|
+
* @param {Function} fn - The serializeNode function from serializer.js
|
|
229
|
+
*/
|
|
230
|
+
export function setSerializer(fn) {
|
|
231
|
+
_serializeNode = fn
|
|
232
|
+
}
|