@uniweb/content-writer 0.3.2 → 0.3.4
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 +2 -2
- package/src/nodes.js +6 -0
- package/src/normalize.js +10 -95
- package/tests/pretitle.test.js +36 -0
- package/tests/roundtrip-source.test.js +0 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "ProseMirror document structure to Markdown converter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"vitest": "^4.1.7",
|
|
23
|
-
"@uniweb/content-reader": "1.2.
|
|
23
|
+
"@uniweb/content-reader": "1.2.4"
|
|
24
24
|
},
|
|
25
25
|
"directories": {
|
|
26
26
|
"test": "tests"
|
package/src/nodes.js
CHANGED
|
@@ -21,6 +21,12 @@ export { serializeInsetRef }
|
|
|
21
21
|
export function serializeHeading(node) {
|
|
22
22
|
const prefix = '#'.repeat(node.attrs?.level || 1)
|
|
23
23
|
const text = serializeInlineContent(node.content)
|
|
24
|
+
// A `#>` label line (pretitle). The hash count carries the authored
|
|
25
|
+
// spelling only — `#>` and `##>` mean the same thing — so emitting the
|
|
26
|
+
// stored level keeps the file byte-identical through a round-trip.
|
|
27
|
+
if (node.attrs?.role === 'pretitle') {
|
|
28
|
+
return `${prefix}> ${text}`
|
|
29
|
+
}
|
|
24
30
|
return `${prefix} ${text}`
|
|
25
31
|
}
|
|
26
32
|
|
package/src/normalize.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* link/button/code/span`), and
|
|
10
10
|
* - the **editor (TipTap) dialect** (`ImageBlock`, `Video`,
|
|
11
11
|
* `UniwebIcon`/`Icon`, `DividerBlock`, `card-group`, `FormBlock`,
|
|
12
|
-
*
|
|
12
|
+
* colour/highlight marks …).
|
|
13
13
|
*
|
|
14
14
|
* A ProseMirror document handed to content-writer may be in either dialect,
|
|
15
15
|
* or a mix (file-origin content is framework-dialect; editor-authored
|
|
@@ -46,8 +46,15 @@
|
|
|
46
46
|
* - `FormBlock` — the editor node carries `activeSchemaId` + filled
|
|
47
47
|
* `data`; the framework `yaml:form` block carries a form *definition*.
|
|
48
48
|
* Different shapes; mapping needs a decision, not a guess.
|
|
49
|
-
* - `card-group`/`document-group
|
|
50
|
-
*
|
|
49
|
+
* - `card-group`/`document-group` — open mapping decisions (🟥 gaps).
|
|
50
|
+
*
|
|
51
|
+
* The editor's pre-concept containers — `WarningBlock`, `details`,
|
|
52
|
+
* `detailsSummary`, `detailsContent` — used to be mapped here. The editor
|
|
53
|
+
* now registers `concept_block` and its rows directly, so it never emits
|
|
54
|
+
* them and the conversion was dead code; it was deleted when the
|
|
55
|
+
* regenerated fixture in `_contracts/editor-vocabulary-diff.test.js`
|
|
56
|
+
* stopped listing them. That fixture is the signal, and it is the only one
|
|
57
|
+
* there is — no message announces it.
|
|
51
58
|
* - colour/highlight/strike/underline marks — need the named-inline-
|
|
52
59
|
* style convention decision.
|
|
53
60
|
*
|
|
@@ -192,9 +199,6 @@ function mapEditorNode(node) {
|
|
|
192
199
|
case 'UniwebIcon':
|
|
193
200
|
case 'Icon':
|
|
194
201
|
return mapUniwebIcon(node)
|
|
195
|
-
case 'WarningBlock':
|
|
196
|
-
case 'details':
|
|
197
|
-
return EDITOR_CONTAINERS[node.type](node)
|
|
198
202
|
case 'emoji':
|
|
199
203
|
// The node carries the character alongside a name; the character is the
|
|
200
204
|
// whole markdown form. Guarded rather than assumed to be populated.
|
|
@@ -240,95 +244,6 @@ function mapEditorNode(node) {
|
|
|
240
244
|
*/
|
|
241
245
|
const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
|
|
242
246
|
|
|
243
|
-
/**
|
|
244
|
-
* Editor container nodes → the framework's `inset_block`, as an explicit table.
|
|
245
|
-
*
|
|
246
|
-
* Deliberately not derived from the node name. The editor's node is
|
|
247
|
-
* `WarningBlock` while the component is `Alert`, and its `type` defaults to
|
|
248
|
-
* `info` rather than `warning` — a name-derived mapping drops exactly that row,
|
|
249
|
-
* which is how `mapUniwebIcon` came to lose every icon the editor produces. A
|
|
250
|
-
* missing entry here is a visible gap rather than a silent assumption.
|
|
251
|
-
*/
|
|
252
|
-
const EDITOR_CONTAINERS = {
|
|
253
|
-
// A callout's SEVERITY is the concept, not a parameter on one — a warning and
|
|
254
|
-
// a note are different kinds of thing, so they are different tags. This is
|
|
255
|
-
// where the editor's `type` attribute goes.
|
|
256
|
-
WarningBlock: node => ({
|
|
257
|
-
type: 'concept_block',
|
|
258
|
-
attrs: { tag: node.attrs?.type || 'info' },
|
|
259
|
-
content: asBlocks(node.content),
|
|
260
|
-
}),
|
|
261
|
-
// The summary becomes the leading HEADING rather than a paragraph, which is
|
|
262
|
-
// what makes the mapping lossless in both directions: a concept block's items
|
|
263
|
-
// come from its headings, so the summary recovers as that item's title and
|
|
264
|
-
// the body as its paragraphs. Flattening it to a paragraph — what this did
|
|
265
|
-
// while the target was an inset — made the two indistinguishable on the way
|
|
266
|
-
// back.
|
|
267
|
-
details: node => ({
|
|
268
|
-
type: 'concept_block',
|
|
269
|
-
attrs: { tag: 'details' },
|
|
270
|
-
content: detailsBody(node),
|
|
271
|
-
}),
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Inline node types, for deciding whether a container's body needs wrapping. */
|
|
275
|
-
const INLINE_TYPES = new Set([
|
|
276
|
-
'text',
|
|
277
|
-
'hardBreak',
|
|
278
|
-
'image',
|
|
279
|
-
'math_inline',
|
|
280
|
-
'inset_ref',
|
|
281
|
-
'emoji',
|
|
282
|
-
'UniwebIcon',
|
|
283
|
-
])
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* A container body as block content.
|
|
287
|
-
*
|
|
288
|
-
* `WarningBlock` holds `text*` today and `block+` after the editor's widening,
|
|
289
|
-
* so both shapes arrive here. Inline content is wrapped in one paragraph;
|
|
290
|
-
* block content passes through.
|
|
291
|
-
*
|
|
292
|
-
* @param {Array} content
|
|
293
|
-
* @returns {Array}
|
|
294
|
-
*/
|
|
295
|
-
function asBlocks(content) {
|
|
296
|
-
if (!content?.length) return []
|
|
297
|
-
return content.every(node => INLINE_TYPES.has(node.type))
|
|
298
|
-
? [{ type: 'paragraph', content }]
|
|
299
|
-
: content
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Flatten the editor's `details` › `detailsSummary` + `detailsContent` into a
|
|
304
|
-
* concept block's body, summary first — as a HEADING.
|
|
305
|
-
*
|
|
306
|
-
* The heading is what makes the mapping lossless. A concept block's items come
|
|
307
|
-
* from its headings, so a summary written as `# …` recovers as that item's
|
|
308
|
-
* title and the body as its paragraphs; written as a paragraph (which is what
|
|
309
|
-
* this did while the target was an inset) the two become indistinguishable on
|
|
310
|
-
* the way back, and a disclosure loses which half was its label.
|
|
311
|
-
*
|
|
312
|
-
* The summary stays inline content rather than moving to an attribute for the
|
|
313
|
-
* original reason: `detailsSummary` can carry marks, and `summary="…"` would
|
|
314
|
-
* flatten emphasis and links.
|
|
315
|
-
*
|
|
316
|
-
* @param {Object} node
|
|
317
|
-
* @returns {Array}
|
|
318
|
-
*/
|
|
319
|
-
function detailsBody(node) {
|
|
320
|
-
const body = []
|
|
321
|
-
for (const child of node.content || []) {
|
|
322
|
-
if (child.type === 'detailsSummary') {
|
|
323
|
-
if (child.content?.length) {
|
|
324
|
-
body.push({ type: 'heading', attrs: { level: 1 }, content: child.content })
|
|
325
|
-
}
|
|
326
|
-
} else if (child.type === 'detailsContent') body.push(...(child.content || []))
|
|
327
|
-
else body.push(child)
|
|
328
|
-
}
|
|
329
|
-
return body
|
|
330
|
-
}
|
|
331
|
-
|
|
332
247
|
/** A mark that carries nothing and should be removed rather than reported. */
|
|
333
248
|
const DROP_MARK = Symbol('drop-mark')
|
|
334
249
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `#>` label lines (pretitles) round-trip through read → write.
|
|
3
|
+
*
|
|
4
|
+
* The hash count means nothing but is preserved — an authored `##>` comes
|
|
5
|
+
* back as `##>`, so a sync never rewrites a file over a spelling choice.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from 'vitest'
|
|
8
|
+
import { proseMirrorToMarkdown } from '../src/index.js'
|
|
9
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
10
|
+
|
|
11
|
+
const md = s => proseMirrorToMarkdown(markdownToProseMirror(s)).trim()
|
|
12
|
+
|
|
13
|
+
describe('pretitle round-trip', () => {
|
|
14
|
+
test('#> comes back as #>', () => {
|
|
15
|
+
expect(md('#> New in v2')).toBe('#> New in v2')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('the authored hash count is preserved', () => {
|
|
19
|
+
expect(md('##> Most popular')).toBe('##> Most popular')
|
|
20
|
+
expect(md('###> Deep label')).toBe('###> Deep label')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a label above a headline survives in place', () => {
|
|
24
|
+
const source = '#> New in v2\n\n# Build the system\n\n## Not every page'
|
|
25
|
+
expect(md(source)).toBe(source)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('inline marks inside a label survive', () => {
|
|
29
|
+
expect(md('#> New in *v2*')).toBe('#> New in *v2*')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('serialization is a fixed point', () => {
|
|
33
|
+
const once = md('##> Label')
|
|
34
|
+
expect(md(once)).toBe(once)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -262,35 +262,4 @@ describe('containers — the block form of an inset', () => {
|
|
|
262
262
|
const source = '````@Alert{type=info}\nDo not:\n\n```js\nconst x = 1\n```\n````'
|
|
263
263
|
expect(rt(source)).toBe(source)
|
|
264
264
|
})
|
|
265
|
-
|
|
266
|
-
test('the editor containers normalize onto CONCEPT blocks, not insets', () => {
|
|
267
|
-
// Changed 2026-07-31. These used to become `@Alert` and `@Details` — an
|
|
268
|
-
// inset naming the component that renders it, which is a rendering
|
|
269
|
-
// decision living in content. A concept block names what the content IS
|
|
270
|
-
// and leaves rendering to the foundation.
|
|
271
|
-
const text = t => ({ type: 'text', text: t })
|
|
272
|
-
const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
|
|
273
|
-
|
|
274
|
-
// The editor's `type` becomes the TAG: a warning and a note are different
|
|
275
|
-
// kinds of thing, not one kind with a parameter. `info` is still the
|
|
276
|
-
// default, and still not `warning` — a name-derived mapping drops that.
|
|
277
|
-
expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
|
|
278
|
-
.toBe('```md:info\nCareful.\n```')
|
|
279
|
-
expect(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] }))
|
|
280
|
-
.toBe('```md:warning\nCareful.\n```')
|
|
281
|
-
|
|
282
|
-
// The summary becomes the leading HEADING, which is what makes this
|
|
283
|
-
// lossless: a concept block's items come from its headings, so the summary
|
|
284
|
-
// recovers as the item's title and the body as its paragraphs. As a
|
|
285
|
-
// paragraph — what this produced while the target was an inset — the two
|
|
286
|
-
// were indistinguishable coming back.
|
|
287
|
-
expect(doc({
|
|
288
|
-
type: 'details',
|
|
289
|
-
content: [
|
|
290
|
-
{ type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
|
|
291
|
-
{ type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
|
|
292
|
-
],
|
|
293
|
-
})).toBe('```md:details\n# Breaking **changes**\n\nBody.\n```')
|
|
294
|
-
})
|
|
295
|
-
|
|
296
265
|
})
|