@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.4",
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
- "jest": "^29.7.0",
22
- "@uniweb/content-reader": "1.1.5"
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": "NODE_OPTIONS=--experimental-vm-modules jest",
41
- "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --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) → ![label](@Component){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') return ''
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
- import { serializeAttributes } from './attributes.js'
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 `![${altPart}](@${component})${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
@@ -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 (`![](lib-name)`)
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. `![](lu-home)`) 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) return null
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![Hero](./hero.jpg){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
+ '![Hero](./hero.jpg){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
+ '![Photo](./photo.jpg)'
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
+ '![Demo](./demo.mp4){role=video poster=./poster.jpg}'
94
+ )
95
+ })
96
+
97
+ test('UniwebIcon (library+name) → ![](lib-name)', () => {
98
+ expectMarkdown(
99
+ doc(para({ type: 'UniwebIcon', attrs: { library: 'lu', name: 'home' } })),
100
+ '![](lu-home)'
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
+ })
@@ -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 ![alt text](@Badge){size=sm} mid prose.',
259
+ 'inline visual inset mid-paragraph'
260
+ )
261
+
262
+ testRoundTrip(
263
+ 'Keyed visual ![@darwin](@Cite) 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```',
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.js']
8
+ }
9
+ })