@uniweb/content-writer 0.2.4 → 0.2.5
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 +5 -9
- package/src/diagnostics.js +77 -0
- package/src/index.js +2 -0
- package/src/marks.js +68 -1
- package/src/nodes.js +6 -14
- package/src/normalize.js +216 -0
- package/src/serializer.js +14 -1
- package/tests/normalize.test.js +198 -0
- package/tests/roundtrip.test.js +53 -0
- package/vitest.config.js +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "ProseMirror document structure to Markdown converter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,12 +18,8 @@
|
|
|
18
18
|
"js-yaml": "^4.1.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"
|
|
22
|
-
"@uniweb/content-reader": "1.1.
|
|
23
|
-
},
|
|
24
|
-
"jest": {
|
|
25
|
-
"testEnvironment": "node",
|
|
26
|
-
"verbose": true
|
|
21
|
+
"vitest": "^4.1.7",
|
|
22
|
+
"@uniweb/content-reader": "1.1.12"
|
|
27
23
|
},
|
|
28
24
|
"directories": {
|
|
29
25
|
"test": "tests"
|
|
@@ -37,7 +33,7 @@
|
|
|
37
33
|
},
|
|
38
34
|
"homepage": "https://github.com/uniweb/content-writer#readme",
|
|
39
35
|
"scripts": {
|
|
40
|
-
"test": "
|
|
41
|
-
"test:watch": "
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
42
38
|
}
|
|
43
39
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview No-silent-drop diagnostics for the markdown serializer.
|
|
3
|
+
*
|
|
4
|
+
* content-writer used to return `null` for any node type it did not
|
|
5
|
+
* recognize and `serializeDoc` filtered those nulls out — so an
|
|
6
|
+
* editor-authored node the serializer didn't know about (a `Video`,
|
|
7
|
+
* `card-group`, `FormBlock`, an unmapped mark …) vanished on write with
|
|
8
|
+
* no signal. That silent loss was the original sin this module closes.
|
|
9
|
+
*
|
|
10
|
+
* The contract now: a node or mark the serializer can't represent is
|
|
11
|
+
* still dropped from the output (we can't invent a markdown form for it),
|
|
12
|
+
* but it is reported loudly instead of silently. Editor-dialect content
|
|
13
|
+
* should be run through `normalizeDialect` (normalize.js) first, which
|
|
14
|
+
* maps the constructs that DO have a faithful markdown form; whatever
|
|
15
|
+
* reaches the serializer unmapped is a genuine, tracked capability gap.
|
|
16
|
+
*
|
|
17
|
+
* Warnings dedupe per type so a build pipeline processing many documents
|
|
18
|
+
* reports each unmapped construct once, not once per occurrence.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const warnedNodes = new Set()
|
|
22
|
+
const warnedMarks = new Set()
|
|
23
|
+
|
|
24
|
+
/* The reporter is injectable so callers (and tests) can collect rather
|
|
25
|
+
* than print. Defaults to console.warn. */
|
|
26
|
+
let report = (message) => console.warn(message)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Override the diagnostics reporter (e.g. to collect warnings in tests
|
|
30
|
+
* or route them into a build log). Pass no argument to restore the
|
|
31
|
+
* default console.warn reporter.
|
|
32
|
+
* @param {(message: string) => void} [fn]
|
|
33
|
+
*/
|
|
34
|
+
export function setDiagnosticsReporter(fn) {
|
|
35
|
+
report = typeof fn === 'function' ? fn : (message) => console.warn(message)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clear the dedupe memory. Tests call this between cases so a warning
|
|
40
|
+
* emitted once earlier doesn't suppress the assertion in a later case.
|
|
41
|
+
*/
|
|
42
|
+
export function resetDiagnostics() {
|
|
43
|
+
warnedNodes.clear()
|
|
44
|
+
warnedMarks.clear()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Report a ProseMirror node the serializer has no markdown form for.
|
|
49
|
+
* @param {Object} node - The unmapped node ({ type, attrs, ... })
|
|
50
|
+
*/
|
|
51
|
+
export function warnUnmappedNode(node) {
|
|
52
|
+
const type = node?.type || 'unknown'
|
|
53
|
+
if (warnedNodes.has(type)) return
|
|
54
|
+
warnedNodes.add(type)
|
|
55
|
+
report(
|
|
56
|
+
`[content-writer] No markdown serializer for node type "${type}" — ` +
|
|
57
|
+
`it was omitted from the output. If this is editor-dialect content, ` +
|
|
58
|
+
`normalize it first (see @uniweb/content-writer normalizeDialect); ` +
|
|
59
|
+
`otherwise this is a tracked capability gap.`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Report an inline mark the serializer has no markdown form for. The
|
|
65
|
+
* marked text itself is still emitted (unstyled); only the styling is
|
|
66
|
+
* dropped.
|
|
67
|
+
* @param {string} type - The unmapped mark type
|
|
68
|
+
*/
|
|
69
|
+
export function warnUnmappedMark(type) {
|
|
70
|
+
const key = type || 'unknown'
|
|
71
|
+
if (warnedMarks.has(key)) return
|
|
72
|
+
warnedMarks.add(key)
|
|
73
|
+
report(
|
|
74
|
+
`[content-writer] No markdown serializer for mark "${key}" — the text ` +
|
|
75
|
+
`was kept but its styling was dropped. Tracked capability gap.`
|
|
76
|
+
)
|
|
77
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
export { serializeDoc as proseMirrorToMarkdown } from './serializer.js'
|
|
9
9
|
export { serializeFrontmatter } from './frontmatter.js'
|
|
10
10
|
export { docToPlainText } from './plain-text.js'
|
|
11
|
+
export { normalizeDialect } from './normalize.js'
|
|
12
|
+
export { setDiagnosticsReporter, resetDiagnostics } from './diagnostics.js'
|
|
11
13
|
|
|
12
14
|
import { serializeDoc } from './serializer.js'
|
|
13
15
|
import { serializeFrontmatter } from './frontmatter.js'
|
package/src/marks.js
CHANGED
|
@@ -5,6 +5,26 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { serializeAttributes } from './attributes.js'
|
|
8
|
+
import { warnUnmappedMark, warnUnmappedNode } from './diagnostics.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Marks the markdown serializer can represent. Any other mark on an
|
|
12
|
+
* inline node has its styling dropped (the text is still emitted) and is
|
|
13
|
+
* reported by the no-silent-drop guard — e.g. editor-dialect `highlight`,
|
|
14
|
+
* `textStyle` (colour), `strike`, `underline`, `uniqueID`, which need a
|
|
15
|
+
* named-inline-style mapping decision.
|
|
16
|
+
*/
|
|
17
|
+
const KNOWN_MARKS = new Set(['bold', 'italic', 'code', 'link', 'button', 'span'])
|
|
18
|
+
|
|
19
|
+
/** Report any inline mark we have no markdown form for (deduped per type). */
|
|
20
|
+
function reportUnknownMarks(content) {
|
|
21
|
+
for (const node of content) {
|
|
22
|
+
if (!node?.marks) continue
|
|
23
|
+
for (const mark of node.marks) {
|
|
24
|
+
if (!KNOWN_MARKS.has(mark.type)) warnUnmappedMark(mark.type)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
/**
|
|
10
30
|
* Check if a node has a specific mark type.
|
|
@@ -60,6 +80,41 @@ function serializeSpanSuffix(mark) {
|
|
|
60
80
|
return serializeAttributes(mark.attrs || {})
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Serialize an inset_ref node (a foundation-component reference) back to
|
|
85
|
+
* markdown. Shared by both the block path (the document-level node
|
|
86
|
+
* serializer) and the inline path (mid-prose insets, cites, refs) so a
|
|
87
|
+
* single definition stays the inverse of the content-reader parser.
|
|
88
|
+
*
|
|
89
|
+
* visual (the `!` form, default) → {attrs}
|
|
90
|
+
* text (embedKind: 'text') → [label](@Component){attrs}
|
|
91
|
+
* Cite/Ref text-inset with a key → [@key] / [#id] {attrs} (sigil shorthand)
|
|
92
|
+
*
|
|
93
|
+
* `label` is the reference key (keyed form) when present, else the alt
|
|
94
|
+
* text. `component`/`embedKind`/`alt`/`key` are structural and consumed
|
|
95
|
+
* here; everything else in attrs becomes curly-brace attributes.
|
|
96
|
+
*
|
|
97
|
+
* Visual is the parser's default (block.js treats `embedKind !== 'text'`
|
|
98
|
+
* as visual, and the inset extractor omits the default), so the `!` is
|
|
99
|
+
* emitted unless `embedKind` is explicitly `'text'`.
|
|
100
|
+
*/
|
|
101
|
+
export function serializeInsetRef(node) {
|
|
102
|
+
const { component, embedKind, alt, key, ...rest } = node.attrs || {}
|
|
103
|
+
const attrStr = serializeAttributes(rest)
|
|
104
|
+
const isText = embedKind === 'text'
|
|
105
|
+
|
|
106
|
+
// A keyed Cite/Ref text-inset round-trips to its sigil shorthand —
|
|
107
|
+
// [@key] / [#id] — the cleanest canonical form and exactly what the
|
|
108
|
+
// cite/ref tokenizers produce. (key already carries its @/# sigil.)
|
|
109
|
+
if (isText && key && (component === 'Cite' || component === 'Ref')) {
|
|
110
|
+
return `[${key}]${attrStr}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const label = key ?? alt ?? ''
|
|
114
|
+
const bang = isText ? '' : '!'
|
|
115
|
+
return `${bang}[${label}](@${component})${attrStr}`
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
/**
|
|
64
119
|
* Serialize an inline image node (icon within a paragraph).
|
|
65
120
|
*/
|
|
@@ -138,6 +193,9 @@ export function serializeBlockImage(node) {
|
|
|
138
193
|
export function serializeInlineContent(content) {
|
|
139
194
|
if (!content || content.length === 0) return ''
|
|
140
195
|
|
|
196
|
+
// No silent drops: flag any mark we can't represent before serializing.
|
|
197
|
+
reportUnknownMarks(content)
|
|
198
|
+
|
|
141
199
|
// Pre-process: group nodes by wrapping marks (link, button, span)
|
|
142
200
|
const segments = groupByWrappingMarks(content)
|
|
143
201
|
return segments.map(seg => serializeSegment(seg)).join('')
|
|
@@ -222,11 +280,20 @@ function serializePlainNode(node) {
|
|
|
222
280
|
if (node.type === 'image') {
|
|
223
281
|
return serializeInlineImage(node)
|
|
224
282
|
}
|
|
283
|
+
if (node.type === 'inset_ref') {
|
|
284
|
+
return serializeInsetRef(node)
|
|
285
|
+
}
|
|
225
286
|
if (node.type === 'math_inline') {
|
|
226
287
|
const latex = node.attrs?.latex || ''
|
|
227
288
|
return node.attrs?.display ? '$$' + latex + '$$' : '$' + latex + '$'
|
|
228
289
|
}
|
|
229
|
-
if (node.type !== 'text')
|
|
290
|
+
if (node.type !== 'text') {
|
|
291
|
+
// No silent drops: an inline node we have no markdown form for is
|
|
292
|
+
// still omitted (we can't invent one) but reported loudly, the same
|
|
293
|
+
// contract the block path follows.
|
|
294
|
+
warnUnmappedNode(node)
|
|
295
|
+
return ''
|
|
296
|
+
}
|
|
230
297
|
return serializeTextWithMarks(node.text, node.marks || [])
|
|
231
298
|
}
|
|
232
299
|
|
package/src/nodes.js
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* Each function takes a ProseMirror node and returns a markdown string.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { serializeInlineContent, serializeBlockImage } from './marks.js'
|
|
8
|
-
|
|
7
|
+
import { serializeInlineContent, serializeBlockImage, serializeInsetRef } from './marks.js'
|
|
8
|
+
|
|
9
|
+
// inset_ref serialization is shared with the inline path; it lives in
|
|
10
|
+
// marks.js (alongside the other inline-attribute serializers) and is
|
|
11
|
+
// re-exported here so the document-level node serializer can register it.
|
|
12
|
+
export { serializeInsetRef }
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* Serialize a heading node.
|
|
@@ -36,18 +40,6 @@ export function serializeImage(node) {
|
|
|
36
40
|
return serializeBlockImage(node)
|
|
37
41
|
}
|
|
38
42
|
|
|
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
43
|
/**
|
|
52
44
|
* Serialize a code block node.
|
|
53
45
|
* @param {Object} node - Code block node with attrs.language and content
|
package/src/normalize.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Editor-dialect → framework-dialect ProseMirror normalizer.
|
|
3
|
+
*
|
|
4
|
+
* Two ProseMirror dialects flow through Uniweb:
|
|
5
|
+
*
|
|
6
|
+
* - the **framework-markdown dialect** that content-reader produces and
|
|
7
|
+
* content-writer's serializers are written against (`image` with a
|
|
8
|
+
* `role`, `divider`, `dataBlock`, `inset_ref`, marks `bold/italic/
|
|
9
|
+
* link/button/code/span`), and
|
|
10
|
+
* - the **editor (TipTap) dialect** (`ImageBlock`, `Video`,
|
|
11
|
+
* `UniwebIcon`/`Icon`, `DividerBlock`, `card-group`, `FormBlock`,
|
|
12
|
+
* `WarningBlock`, `details`, colour/highlight marks …).
|
|
13
|
+
*
|
|
14
|
+
* A ProseMirror document handed to content-writer may be in either dialect,
|
|
15
|
+
* or a mix (file-origin content is framework-dialect; editor-authored
|
|
16
|
+
* content is editor-dialect). Rather than teach the serializers both
|
|
17
|
+
* vocabularies, we normalize the editor dialect onto the framework dialect
|
|
18
|
+
* FIRST, then serialize one canonical dialect. semantic-parser's
|
|
19
|
+
* `sequence.js` is the reference for what each editor construct means.
|
|
20
|
+
*
|
|
21
|
+
* This is the "normalize-first" half of the codec. It is **idempotent on
|
|
22
|
+
* framework-dialect input**: every framework node
|
|
23
|
+
* passes through unchanged, so running it on already-canonical content is
|
|
24
|
+
* a structural no-op (the existing round-trip tests stay green).
|
|
25
|
+
*
|
|
26
|
+
* ## What this maps today (zero-guess cells only)
|
|
27
|
+
*
|
|
28
|
+
* - `DividerBlock` / `horizontalRule` → `divider`
|
|
29
|
+
* - `Video` → `image{role:'video'}` — only when a real media URL
|
|
30
|
+
* is present (`src`)
|
|
31
|
+
* - `ImageBlock` → `image` — only when a real URL is
|
|
32
|
+
* present (`url`/`src`)
|
|
33
|
+
* - `UniwebIcon` → `image{role:'icon'}` — only the `library`+`name`
|
|
34
|
+
* form (``)
|
|
35
|
+
*
|
|
36
|
+
* ## What it deliberately does NOT map (left for the serializer's
|
|
37
|
+
* no-silent-drop guard to report — a tracked capability gap)
|
|
38
|
+
*
|
|
39
|
+
* - Media that carries only a structured asset identifier (no direct
|
|
40
|
+
* `url`/`src`) or an inline `svg` blob. Media references are written
|
|
41
|
+
* VERBATIM — the codec does not resolve or render; the kit Image/Video
|
|
42
|
+
* components resolve the reference at render time. The common case (a
|
|
43
|
+
* `url`/`src` on the node) is handled by writing it verbatim; an
|
|
44
|
+
* identifier-only node is the edge (carry it verbatim once its form is
|
|
45
|
+
* confirmed), and inline `svg` blobs hit the guard.
|
|
46
|
+
* - `FormBlock` — the editor node carries `activeSchemaId` + filled
|
|
47
|
+
* `data`; the framework `yaml:form` block carries a form *definition*.
|
|
48
|
+
* Different shapes; mapping needs a decision, not a guess.
|
|
49
|
+
* - `card-group`/`document-group`, `details`, `WarningBlock` — open
|
|
50
|
+
* mapping decisions (items vs insets; 🟥 gaps).
|
|
51
|
+
* - colour/highlight/strike/underline marks — need the named-inline-
|
|
52
|
+
* style convention decision.
|
|
53
|
+
*
|
|
54
|
+
* Each unmapped construct reaches content-writer's serializer as-is and
|
|
55
|
+
* is reported by the no-silent-drop guard (diagnostics.js) — never lost
|
|
56
|
+
* silently.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Editor-transient image attrs that have no on-disk markdown meaning and
|
|
61
|
+
* are dropped by design (not data loss — they're editor UI/upload state
|
|
62
|
+
* or layout hints the markdown form doesn't carry).
|
|
63
|
+
*/
|
|
64
|
+
const DROPPED_IMAGE_ATTRS = new Set([
|
|
65
|
+
'url',
|
|
66
|
+
'src',
|
|
67
|
+
'info',
|
|
68
|
+
'direction',
|
|
69
|
+
'credit',
|
|
70
|
+
'theme',
|
|
71
|
+
'filter',
|
|
72
|
+
'targetId',
|
|
73
|
+
'contentId',
|
|
74
|
+
'uploading',
|
|
75
|
+
'uploadingObject',
|
|
76
|
+
'loading_progress',
|
|
77
|
+
'aspect_ratio',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Markdown-meaningful image/media attrs we carry through, when present
|
|
82
|
+
* with a real value. Mirrors content-reader's image schema + the media
|
|
83
|
+
* attrs documented in content-structure.md (role/poster/href/etc.).
|
|
84
|
+
*/
|
|
85
|
+
const KEPT_IMAGE_ATTRS = [
|
|
86
|
+
'alt',
|
|
87
|
+
'caption',
|
|
88
|
+
'href',
|
|
89
|
+
'target',
|
|
90
|
+
'width',
|
|
91
|
+
'height',
|
|
92
|
+
'loading',
|
|
93
|
+
'poster',
|
|
94
|
+
'preview',
|
|
95
|
+
'autoplay',
|
|
96
|
+
'muted',
|
|
97
|
+
'loop',
|
|
98
|
+
'controls',
|
|
99
|
+
'fit',
|
|
100
|
+
'position',
|
|
101
|
+
'class',
|
|
102
|
+
'id',
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/** Pick the non-empty markdown-meaningful attrs from an editor image node. */
|
|
106
|
+
function keptImageAttrs(attrs) {
|
|
107
|
+
const out = {}
|
|
108
|
+
for (const key of KEPT_IMAGE_ATTRS) {
|
|
109
|
+
const value = attrs[key]
|
|
110
|
+
if (value === null || value === undefined || value === '') continue
|
|
111
|
+
out[key] = value
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map an editor `ImageBlock` to a framework `image` node.
|
|
118
|
+
* @returns {Object|null} framework image node, or null if unresolvable
|
|
119
|
+
* (identifier-only asset → leave for the guard to report).
|
|
120
|
+
*/
|
|
121
|
+
function mapImageBlock(node) {
|
|
122
|
+
const attrs = node.attrs || {}
|
|
123
|
+
const src = attrs.url || attrs.src
|
|
124
|
+
if (!src) return null // identifier-only / not yet uploaded → can't resolve to a URL
|
|
125
|
+
|
|
126
|
+
const out = { src, ...keptImageAttrs(attrs) }
|
|
127
|
+
if (attrs.role && attrs.role !== 'image') out.role = attrs.role
|
|
128
|
+
return { type: 'image', attrs: out }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Map an editor `Video` to a framework `image{role:'video'}` node
|
|
133
|
+
* (the framework expresses video as media-by-role; there is no Video
|
|
134
|
+
* node by design).
|
|
135
|
+
* @returns {Object|null}
|
|
136
|
+
*/
|
|
137
|
+
function mapVideo(node) {
|
|
138
|
+
const attrs = node.attrs || {}
|
|
139
|
+
const src = attrs.src
|
|
140
|
+
if (!src) return null // identifier-only → asset channel, not a URL
|
|
141
|
+
|
|
142
|
+
const out = { src, role: 'video', ...keptImageAttrs(attrs) }
|
|
143
|
+
// The editor carries the poster as a coverImg object; only a real URL
|
|
144
|
+
// (not an identifier) has a markdown home.
|
|
145
|
+
const poster = attrs.coverImg?.src
|
|
146
|
+
if (poster) out.poster = poster
|
|
147
|
+
return { type: 'image', attrs: out }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Map an editor `UniwebIcon` to a framework `image{role:'icon'}` node.
|
|
152
|
+
* Only the `library`+`name` form (e.g. ``) is mapped — an
|
|
153
|
+
* inline `svg` blob or a bare `url` has no clean markdown home yet.
|
|
154
|
+
* @returns {Object|null}
|
|
155
|
+
*/
|
|
156
|
+
function mapUniwebIcon(node) {
|
|
157
|
+
const attrs = node.attrs || {}
|
|
158
|
+
if (!attrs.library || !attrs.name) return null
|
|
159
|
+
|
|
160
|
+
const out = { role: 'icon', library: attrs.library, name: attrs.name }
|
|
161
|
+
if (attrs.size) out.size = attrs.size
|
|
162
|
+
if (attrs.color) out.color = attrs.color
|
|
163
|
+
return { type: 'image', attrs: out }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Map a single node from editor dialect to framework dialect.
|
|
168
|
+
* @returns {Object|null} a framework-dialect node, or null when there is
|
|
169
|
+
* no faithful mapping (the original node is then kept and reported by
|
|
170
|
+
* the serializer's no-silent-drop guard).
|
|
171
|
+
*/
|
|
172
|
+
function mapEditorNode(node) {
|
|
173
|
+
switch (node.type) {
|
|
174
|
+
case 'DividerBlock':
|
|
175
|
+
case 'horizontalRule':
|
|
176
|
+
return { type: 'divider', attrs: {} }
|
|
177
|
+
case 'ImageBlock':
|
|
178
|
+
return mapImageBlock(node)
|
|
179
|
+
case 'Video':
|
|
180
|
+
return mapVideo(node)
|
|
181
|
+
case 'UniwebIcon':
|
|
182
|
+
return mapUniwebIcon(node)
|
|
183
|
+
default:
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Recursively normalize one node: map it to the framework dialect (if it
|
|
190
|
+
* is a recognized editor node with a faithful mapping), then normalize
|
|
191
|
+
* its children. Nodes without a mapping pass through unchanged so any
|
|
192
|
+
* editor-dialect descendants are still caught.
|
|
193
|
+
*/
|
|
194
|
+
function normalizeNode(node) {
|
|
195
|
+
if (!node || typeof node !== 'object') return node
|
|
196
|
+
|
|
197
|
+
const result = mapEditorNode(node) || node
|
|
198
|
+
|
|
199
|
+
if (Array.isArray(result.content)) {
|
|
200
|
+
return { ...result, content: result.content.map(normalizeNode) }
|
|
201
|
+
}
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Normalize an editor-dialect (or mixed-dialect) ProseMirror document to
|
|
207
|
+
* the framework dialect that content-writer's serializers consume.
|
|
208
|
+
* Idempotent on framework-dialect input.
|
|
209
|
+
*
|
|
210
|
+
* @param {Object} doc - ProseMirror document ({ type:'doc', content:[…] })
|
|
211
|
+
* @returns {Object} a framework-dialect document
|
|
212
|
+
*/
|
|
213
|
+
export function normalizeDialect(doc) {
|
|
214
|
+
if (!doc || !Array.isArray(doc.content)) return doc
|
|
215
|
+
return { ...doc, content: doc.content.map(normalizeNode) }
|
|
216
|
+
}
|
package/src/serializer.js
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
serializeMathDisplay,
|
|
20
20
|
setSerializer,
|
|
21
21
|
} from './nodes.js'
|
|
22
|
+
import { normalizeDialect } from './normalize.js'
|
|
23
|
+
import { warnUnmappedNode } from './diagnostics.js'
|
|
22
24
|
|
|
23
25
|
const NODE_SERIALIZERS = {
|
|
24
26
|
heading: serializeHeading,
|
|
@@ -42,7 +44,14 @@ const NODE_SERIALIZERS = {
|
|
|
42
44
|
*/
|
|
43
45
|
export function serializeNode(node) {
|
|
44
46
|
const serializer = NODE_SERIALIZERS[node.type]
|
|
45
|
-
if (!serializer)
|
|
47
|
+
if (!serializer) {
|
|
48
|
+
// No silent drops: an unmapped node is still omitted (we have no
|
|
49
|
+
// markdown form for it) but reported loudly. Editor-dialect content
|
|
50
|
+
// should be normalized first (normalizeDialect); whatever reaches
|
|
51
|
+
// here unmapped is a tracked capability gap (§8).
|
|
52
|
+
warnUnmappedNode(node)
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
46
55
|
return serializer(node)
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -57,6 +66,10 @@ setSerializer(serializeNode)
|
|
|
57
66
|
export function serializeDoc(doc) {
|
|
58
67
|
if (!doc?.content) return ''
|
|
59
68
|
|
|
69
|
+
// Normalize editor-dialect (or mixed-dialect) input onto the framework
|
|
70
|
+
// dialect the node serializers consume. No-op for framework-dialect docs.
|
|
71
|
+
doc = normalizeDialect(doc)
|
|
72
|
+
|
|
60
73
|
return doc.content
|
|
61
74
|
.map(node => serializeNode(node))
|
|
62
75
|
.filter(s => s !== null && s !== undefined)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor-dialect normalization + no-silent-drop guard.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that content-writer maps the editor (TipTap) dialect onto the
|
|
5
|
+
* framework markdown dialect for the zero-guess constructs, that it stays
|
|
6
|
+
* a no-op on framework-dialect input, and that anything it can't map is
|
|
7
|
+
* reported loudly instead of silently dropped.
|
|
8
|
+
*
|
|
9
|
+
* Editor-node attr shapes are modelled on the editor block definitions and
|
|
10
|
+
* the committed semantic-parser fixtures.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
15
|
+
import {
|
|
16
|
+
proseMirrorToMarkdown,
|
|
17
|
+
normalizeDialect,
|
|
18
|
+
setDiagnosticsReporter,
|
|
19
|
+
resetDiagnostics,
|
|
20
|
+
} from '../src/index.js'
|
|
21
|
+
|
|
22
|
+
const doc = (...content) => ({ type: 'doc', content })
|
|
23
|
+
const para = (...content) => ({ type: 'paragraph', content })
|
|
24
|
+
|
|
25
|
+
/** Assert an editor doc serializes to expected markdown and is a fixed point. */
|
|
26
|
+
function expectMarkdown(editorDoc, expected) {
|
|
27
|
+
const md = proseMirrorToMarkdown(editorDoc)
|
|
28
|
+
expect(md).toBe(expected)
|
|
29
|
+
// Re-parsing and re-serializing the framework markdown must be stable.
|
|
30
|
+
const md2 = proseMirrorToMarkdown(markdownToProseMirror(md))
|
|
31
|
+
expect(md2).toBe(md)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('normalizeDialect: idempotent on framework dialect', () => {
|
|
35
|
+
test('a framework-dialect document is unchanged through normalization', () => {
|
|
36
|
+
const md =
|
|
37
|
+
'# Title\n\nSome **bold** text.\n\n---\n\n{role=banner}'
|
|
38
|
+
const frameworkDoc = markdownToProseMirror(md)
|
|
39
|
+
// normalize then serialize === serialize (serializeDoc normalizes internally)
|
|
40
|
+
expect(proseMirrorToMarkdown(normalizeDialect(frameworkDoc))).toBe(
|
|
41
|
+
proseMirrorToMarkdown(frameworkDoc)
|
|
42
|
+
)
|
|
43
|
+
// and the round trip is preserved
|
|
44
|
+
expect(markdownToProseMirror(proseMirrorToMarkdown(frameworkDoc))).toEqual(
|
|
45
|
+
frameworkDoc
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('normalizeDialect: editor blocks → framework markdown', () => {
|
|
51
|
+
test('DividerBlock → ---', () => {
|
|
52
|
+
expectMarkdown(doc({ type: 'DividerBlock', attrs: { type: 'dot' } }), '---')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('horizontalRule → ---', () => {
|
|
56
|
+
expectMarkdown(doc({ type: 'horizontalRule' }), '---')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('ImageBlock (url) → ![](){role}', () => {
|
|
60
|
+
expectMarkdown(
|
|
61
|
+
doc({
|
|
62
|
+
type: 'ImageBlock',
|
|
63
|
+
attrs: {
|
|
64
|
+
url: './hero.jpg',
|
|
65
|
+
role: 'banner',
|
|
66
|
+
alt: 'Hero',
|
|
67
|
+
direction: 'center', // editor layout state — intentionally dropped
|
|
68
|
+
info: { identifier: '' },
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
'{role=banner}'
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('ImageBlock without a role serializes as a plain image', () => {
|
|
76
|
+
expectMarkdown(
|
|
77
|
+
doc({ type: 'ImageBlock', attrs: { url: './photo.jpg', alt: 'Photo' } }),
|
|
78
|
+
''
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('Video (src + coverImg) → ![](){role=video poster}', () => {
|
|
83
|
+
expectMarkdown(
|
|
84
|
+
doc({
|
|
85
|
+
type: 'Video',
|
|
86
|
+
attrs: {
|
|
87
|
+
src: './demo.mp4',
|
|
88
|
+
alt: 'Demo',
|
|
89
|
+
coverImg: { src: './poster.jpg', identifier: null },
|
|
90
|
+
direction: 'center',
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
'{role=video poster=./poster.jpg}'
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('UniwebIcon (library+name) → ', () => {
|
|
98
|
+
expectMarkdown(
|
|
99
|
+
doc(para({ type: 'UniwebIcon', attrs: { library: 'lu', name: 'home' } })),
|
|
100
|
+
''
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('no-silent-drop guard: unmappable nodes are reported, not lost', () => {
|
|
106
|
+
let warnings
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
warnings = []
|
|
110
|
+
resetDiagnostics()
|
|
111
|
+
setDiagnosticsReporter((m) => warnings.push(m))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
setDiagnosticsReporter() // restore console.warn
|
|
116
|
+
resetDiagnostics()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('an unmapped editor container (card-group) warns', () => {
|
|
120
|
+
proseMirrorToMarkdown(
|
|
121
|
+
doc({
|
|
122
|
+
type: 'card-group',
|
|
123
|
+
content: [{ type: 'card', attrs: { title: 'A' } }],
|
|
124
|
+
})
|
|
125
|
+
)
|
|
126
|
+
expect(warnings.some((m) => m.includes('card-group'))).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('FormBlock warns (activeSchemaId has no markdown home yet)', () => {
|
|
130
|
+
proseMirrorToMarkdown(
|
|
131
|
+
doc({
|
|
132
|
+
type: 'FormBlock',
|
|
133
|
+
attrs: { activeSchemaId: 'stats', data: [{ number: '42' }] },
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
expect(warnings.some((m) => m.includes('FormBlock'))).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('media with only an asset identifier (no URL) warns rather than guessing a src', () => {
|
|
140
|
+
proseMirrorToMarkdown(
|
|
141
|
+
doc({
|
|
142
|
+
type: 'ImageBlock',
|
|
143
|
+
attrs: { url: null, src: null, info: { identifier: 'v1/base.jpg' } },
|
|
144
|
+
})
|
|
145
|
+
)
|
|
146
|
+
expect(warnings.some((m) => m.includes('ImageBlock'))).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('an unmapped mark (highlight) warns but keeps the text', () => {
|
|
150
|
+
const md = proseMirrorToMarkdown(
|
|
151
|
+
doc(
|
|
152
|
+
para({
|
|
153
|
+
type: 'text',
|
|
154
|
+
text: 'important',
|
|
155
|
+
marks: [{ type: 'highlight' }],
|
|
156
|
+
})
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
expect(md).toBe('important') // text preserved, styling dropped
|
|
160
|
+
expect(warnings.some((m) => m.includes('highlight'))).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('a known framework document produces no warnings', () => {
|
|
164
|
+
proseMirrorToMarkdown(
|
|
165
|
+
markdownToProseMirror('# Hi\n\nSome **bold** and a [link](/x).')
|
|
166
|
+
)
|
|
167
|
+
expect(warnings).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('an inline inset_ref is serialized, not silently dropped', () => {
|
|
171
|
+
const md = proseMirrorToMarkdown(
|
|
172
|
+
doc(
|
|
173
|
+
para(
|
|
174
|
+
{ type: 'text', text: 'See ' },
|
|
175
|
+
{ type: 'inset_ref', attrs: { component: 'Cite', embedKind: 'text', key: '@darwin', alt: null } },
|
|
176
|
+
{ type: 'text', text: ' here.' }
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
expect(md).toBe('See [@darwin] here.')
|
|
181
|
+
expect(warnings).toEqual([]) // a known inline node — no warning
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('an unmapped inline node warns rather than vanishing', () => {
|
|
185
|
+
const md = proseMirrorToMarkdown(
|
|
186
|
+
doc(
|
|
187
|
+
para(
|
|
188
|
+
{ type: 'text', text: 'before ' },
|
|
189
|
+
{ type: 'FancyInlineWidget', attrs: { id: 'x' } },
|
|
190
|
+
{ type: 'text', text: ' after' }
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
// the surrounding text survives; the unmapped node is omitted but reported
|
|
195
|
+
expect(md).toBe('before after')
|
|
196
|
+
expect(warnings.some((m) => m.includes('FancyInlineWidget'))).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
})
|
package/tests/roundtrip.test.js
CHANGED
|
@@ -228,6 +228,59 @@ describe('Round-trip: Component References', () => {
|
|
|
228
228
|
)
|
|
229
229
|
})
|
|
230
230
|
|
|
231
|
+
describe('Round-trip: Inline insets, cites, and refs', () => {
|
|
232
|
+
testRoundTrip(
|
|
233
|
+
'Text with [@darwin1859] citation.',
|
|
234
|
+
'inline cite shorthand'
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
testRoundTrip(
|
|
238
|
+
'Multi [@a;@b]{page=42} cite.',
|
|
239
|
+
'multi-cite with locator'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
testRoundTrip(
|
|
243
|
+
'See [#fig1] here.',
|
|
244
|
+
'inline cross-reference shorthand'
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
testRoundTrip(
|
|
248
|
+
'See [#a;#b]{class=compact} together.',
|
|
249
|
+
'multi-ref with attrs'
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
testRoundTrip(
|
|
253
|
+
'Inline [the label](@Tooltip){variant=compact} here.',
|
|
254
|
+
'inline textual inset with label and params'
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
testRoundTrip(
|
|
258
|
+
'Visual {size=sm} mid prose.',
|
|
259
|
+
'inline visual inset mid-paragraph'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
testRoundTrip(
|
|
263
|
+
'Keyed visual  mid prose.',
|
|
264
|
+
'inline keyed visual inset'
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// A keyed Cite/Ref written in the explicit link form normalizes to the
|
|
268
|
+
// sigil shorthand — both parse to the same node.
|
|
269
|
+
test('keyed cite in link form normalizes to shorthand', () => {
|
|
270
|
+
const markdown = 'Keyed [@darwin](@Cite){page=10} ref.'
|
|
271
|
+
const parsed = markdownToProseMirror(markdown)
|
|
272
|
+
const serialized = proseMirrorToMarkdown(parsed)
|
|
273
|
+
expect(serialized).toBe('Keyed [@darwin]{page=10} ref.')
|
|
274
|
+
const reparsed = markdownToProseMirror(serialized)
|
|
275
|
+
expect(reparsed).toEqual(parsed)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
testRoundTrip(
|
|
279
|
+
'A [@key] cite and an [#id] ref in one paragraph.',
|
|
280
|
+
'cite and ref in the same paragraph'
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
231
284
|
describe('Round-trip: Code', () => {
|
|
232
285
|
testRoundTrip(
|
|
233
286
|
'```javascript\nconst x = 1;\nconsole.log(\'x:\', x);\n```',
|