@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/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 `![${altPart}](${iconSrc})${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 `![${altPart}](icon:${src}${captionPart})${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 `![${altPart}](${iconSrc})${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 `![${altPart}](${srcPart}${captionPart})${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 `![${altPart}](@${component})${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
+ }