@uniweb/content-writer 0.2.8 → 0.3.0
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/probe.tmp.mjs +18 -0
- package/src/attributes.js +8 -8
- package/src/marks.js +27 -1
- package/src/nodes.js +109 -14
- package/src/normalize.js +71 -0
- package/src/serializer.js +4 -0
- package/tests/attributes.test.js +6 -6
- package/tests/fixtures/roundtrip/bare-autolink.md +7 -0
- package/tests/fixtures/roundtrip/containers.md +22 -0
- package/tests/fixtures/roundtrip/nested-list-indent.md +18 -0
- package/tests/marks.test.js +7 -7
- package/tests/roundtrip-source.test.js +40 -0
- package/tests/roundtrip.test.js +68 -0
- package/tests/serializer.test.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
23
|
+
"@uniweb/content-reader": "1.2.0"
|
|
24
24
|
},
|
|
25
25
|
"directories": {
|
|
26
26
|
"test": "tests"
|
package/probe.tmp.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { proseMirrorToMarkdown } from './src/index.js'
|
|
2
|
+
const doc = c => ({ type: 'doc', content: [c] })
|
|
3
|
+
const text = t => ({ type: 'text', text: t })
|
|
4
|
+
|
|
5
|
+
console.log('WarningBlock (text*, today):')
|
|
6
|
+
console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [text('Be careful.')] })).trim()))
|
|
7
|
+
|
|
8
|
+
console.log('WarningBlock with type:')
|
|
9
|
+
console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] })).trim()))
|
|
10
|
+
|
|
11
|
+
console.log('WarningBlock (block+, after widening):')
|
|
12
|
+
console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [
|
|
13
|
+
{ type: 'paragraph', content: [text('One.')] }, { type: 'paragraph', content: [text('Two.')] }] })).trim()))
|
|
14
|
+
|
|
15
|
+
console.log('details:')
|
|
16
|
+
console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'details', content: [
|
|
17
|
+
{ type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
|
|
18
|
+
{ type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body text.')] }] }] })).trim()))
|
package/src/attributes.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
* @fileoverview Serialize attributes to curly brace syntax
|
|
3
3
|
*
|
|
4
4
|
* Inverse of content-reader's attributes.js.
|
|
5
|
-
* Produces syntax like: {role=hero width=1200
|
|
5
|
+
* Produces syntax like: {role=hero width=1200 #id autoplay}
|
|
6
|
+
*
|
|
7
|
+
* There is no CSS-class form. A `class` attribute serializes as an ordinary
|
|
8
|
+
* `class=value` pair, and a name that starts with a dot (`{".featured": true}`)
|
|
9
|
+
* serializes through the plain boolean branch as `{.featured}` — the dot is
|
|
10
|
+
* part of the name. Both directions need no special case, which is why this
|
|
11
|
+
* file no longer has one.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -22,13 +28,7 @@ export function serializeAttributes(attrs, skipKeys = []) {
|
|
|
22
28
|
if (skipSet.has(key)) continue
|
|
23
29
|
if (value === null || value === undefined) continue
|
|
24
30
|
|
|
25
|
-
if (key === '
|
|
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') {
|
|
31
|
+
if (key === 'id') {
|
|
32
32
|
parts.push(`#${value}`)
|
|
33
33
|
} else if (value === true) {
|
|
34
34
|
// Boolean attribute
|
package/src/marks.js
CHANGED
|
@@ -38,6 +38,32 @@ function marksEqual(a, b) {
|
|
|
38
38
|
return true
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Would `inner` on its own re-parse to exactly this link?
|
|
43
|
+
*
|
|
44
|
+
* An author who writes a bare `https://example.com` gets a link mark whose
|
|
45
|
+
* href equals its text. Writing that back as `[https://example.com](https://example.com)`
|
|
46
|
+
* is content-identical but rewrites the author's file — and content-writer is
|
|
47
|
+
* what the editor uses to sync back to a developer's repository, so every save
|
|
48
|
+
* would churn every bare URL in it.
|
|
49
|
+
*
|
|
50
|
+
* Kept deliberately narrow. The bare form is only emitted when the mark
|
|
51
|
+
* carries nothing but an href, the text IS that href (or its address, for
|
|
52
|
+
* mailto), and the URL is one GFM autolinking will recapture with the same
|
|
53
|
+
* bounds: no whitespace or brackets, and no trailing punctuation, which
|
|
54
|
+
* autolink detection excludes from the match. Anything else keeps the
|
|
55
|
+
* explicit form, which is always correct if more verbose.
|
|
56
|
+
*/
|
|
57
|
+
const AUTOLINK_SAFE = /^[^\s<>()[\]]*[^\s<>()[\].,;:!?'"]$/
|
|
58
|
+
|
|
59
|
+
function isBareAutolink(mark, inner) {
|
|
60
|
+
const { href, title, ...rest } = mark.attrs || {}
|
|
61
|
+
if (!href || title || Object.keys(rest).length > 0) return false
|
|
62
|
+
if (href === inner) return /^https?:\/\//.test(href) && AUTOLINK_SAFE.test(href)
|
|
63
|
+
if (href === `mailto:${inner}`) return AUTOLINK_SAFE.test(inner) && inner.includes('@')
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
/**
|
|
42
68
|
* Serialize a link mark's suffix: (href "title"){attrs}
|
|
43
69
|
*/
|
|
@@ -313,7 +339,7 @@ function applyMark(inner, mark) {
|
|
|
313
339
|
// rule, Mod-Shift-s) and had no markdown form, so it was dropped.
|
|
314
340
|
return `~~${inner}~~`
|
|
315
341
|
case 'link':
|
|
316
|
-
return `[${inner}]${serializeLinkSuffix(mark)}`
|
|
342
|
+
return isBareAutolink(mark, inner) ? inner : `[${inner}]${serializeLinkSuffix(mark)}`
|
|
317
343
|
case 'button':
|
|
318
344
|
return `[${inner}]${serializeButtonSuffix(mark)}`
|
|
319
345
|
case 'span':
|
package/src/nodes.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import yaml from 'js-yaml'
|
|
8
8
|
import { serializeInlineContent, serializeBlockImage, serializeInsetRef } from './marks.js'
|
|
9
|
+
import { serializeAttributes } from './attributes.js'
|
|
9
10
|
|
|
10
11
|
// inset_ref serialization is shared with the inline path; it lives in
|
|
11
12
|
// marks.js (alongside the other inline-attribute serializers) and is
|
|
@@ -95,6 +96,72 @@ export function serializeMathDisplay(node) {
|
|
|
95
96
|
: '$$' + latex + '$$'
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Serialize a container node back to its fence.
|
|
101
|
+
*
|
|
102
|
+
* The inverse of content-reader's `@Component{params}` fence. The info string
|
|
103
|
+
* is the inset's own token, so a container is byte-for-byte the block form of
|
|
104
|
+
* an inset — same concept, two levels.
|
|
105
|
+
*
|
|
106
|
+
* The fence widens past any backtick run in the body, which is what lets a
|
|
107
|
+
* container hold a code sample: `fenceWidthFor` is the same computation the
|
|
108
|
+
* plain code-block path uses.
|
|
109
|
+
*
|
|
110
|
+
* @param {Object} node - inset_block node with attrs.component + params
|
|
111
|
+
* @returns {string} Fenced container
|
|
112
|
+
*/
|
|
113
|
+
export function serializeInsetBlock(node) {
|
|
114
|
+
const { component, ...params } = node.attrs || {}
|
|
115
|
+
if (!component) return ''
|
|
116
|
+
|
|
117
|
+
const { serializeNode } = await_serializer()
|
|
118
|
+
const body = (node.content || [])
|
|
119
|
+
.map(child => serializeNode(child))
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join('\n\n')
|
|
122
|
+
|
|
123
|
+
const fence = '`'.repeat(fenceWidthFor(body))
|
|
124
|
+
const attrStr = serializeAttributes(params)
|
|
125
|
+
|
|
126
|
+
return `${fence}@${component}${attrStr}\n${body}\n${fence}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Serialize a concept block back to its ```md:<tag> fence.
|
|
131
|
+
*
|
|
132
|
+
* The inverse of content-reader's concept-block branch. The body is real block
|
|
133
|
+
* content, so it serializes through the ordinary node path — a concept block is
|
|
134
|
+
* the section pipeline one level down, and its body is markdown at both ends.
|
|
135
|
+
*
|
|
136
|
+
* The fence widens past any backtick run in the body, for the same reason a
|
|
137
|
+
* container's does: an answer that shows a code sample is ordinary
|
|
138
|
+
* documentation, and a three-backtick fence would close on the sample's own
|
|
139
|
+
* fence and spill the remainder into the page as prose.
|
|
140
|
+
*
|
|
141
|
+
* A missing serializer here is not a cosmetic gap. `serializeNode` omits an
|
|
142
|
+
* unmapped node, so the whole block — the author's prose with it — would
|
|
143
|
+
* vanish from the file on the next write-back and from the per-page markdown
|
|
144
|
+
* projection. That is why the round-trip test asserts the BODY rather than
|
|
145
|
+
* just the fence line.
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} node - concept_block node with attrs.tag + block content
|
|
148
|
+
* @returns {string} Fenced concept block
|
|
149
|
+
*/
|
|
150
|
+
export function serializeConceptBlock(node) {
|
|
151
|
+
const { tag } = node.attrs || {}
|
|
152
|
+
if (!tag) return ''
|
|
153
|
+
|
|
154
|
+
const { serializeNode } = await_serializer()
|
|
155
|
+
const body = (node.content || [])
|
|
156
|
+
.map(child => serializeNode(child))
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join('\n\n')
|
|
159
|
+
|
|
160
|
+
const fence = '`'.repeat(fenceWidthFor(body))
|
|
161
|
+
|
|
162
|
+
return `${fence}md:${tag}\n${body}\n${fence}`
|
|
163
|
+
}
|
|
164
|
+
|
|
98
165
|
/**
|
|
99
166
|
* Serialize a data block node.
|
|
100
167
|
*
|
|
@@ -154,30 +221,35 @@ export function serializeBlockquote(node) {
|
|
|
154
221
|
|
|
155
222
|
/**
|
|
156
223
|
* Serialize a bullet list node.
|
|
224
|
+
*
|
|
225
|
+
* `pad` is the literal indentation this list sits at — the parent item's
|
|
226
|
+
* CONTENT COLUMN, not a level count. See serializeListItem for why.
|
|
227
|
+
*
|
|
157
228
|
* @param {Object} node - Bullet list node with content (listItem nodes)
|
|
158
|
-
* @param {
|
|
229
|
+
* @param {string} [pad=''] - Indentation prefix for every marker in this list
|
|
159
230
|
* @returns {string} Markdown bullet list
|
|
160
231
|
*/
|
|
161
|
-
export function serializeBulletList(node,
|
|
232
|
+
export function serializeBulletList(node, pad = '') {
|
|
162
233
|
if (!node.content) return ''
|
|
163
|
-
const prefix = ' '.repeat(indent)
|
|
164
234
|
return node.content
|
|
165
|
-
.map(item => serializeListItem(item, `${
|
|
235
|
+
.map(item => serializeListItem(item, `${pad}- `, isLoose(node)))
|
|
166
236
|
.join(itemSeparator(node))
|
|
167
237
|
}
|
|
168
238
|
|
|
169
239
|
/**
|
|
170
240
|
* Serialize an ordered list node.
|
|
241
|
+
*
|
|
242
|
+
* `pad` is the literal indentation this list sits at — see serializeBulletList.
|
|
243
|
+
*
|
|
171
244
|
* @param {Object} node - Ordered list node with attrs.start and content
|
|
172
|
-
* @param {
|
|
245
|
+
* @param {string} [pad=''] - Indentation prefix for every marker in this list
|
|
173
246
|
* @returns {string} Markdown ordered list
|
|
174
247
|
*/
|
|
175
|
-
export function serializeOrderedList(node,
|
|
248
|
+
export function serializeOrderedList(node, pad = '') {
|
|
176
249
|
if (!node.content) return ''
|
|
177
250
|
const start = node.attrs?.start || 1
|
|
178
|
-
const prefix = ' '.repeat(indent)
|
|
179
251
|
return node.content
|
|
180
|
-
.map((item, i) => serializeListItem(item, `${
|
|
252
|
+
.map((item, i) => serializeListItem(item, `${pad}${start + i}. `, isLoose(node)))
|
|
181
253
|
.join(itemSeparator(node))
|
|
182
254
|
}
|
|
183
255
|
|
|
@@ -207,13 +279,24 @@ function isLoose(node) {
|
|
|
207
279
|
|
|
208
280
|
/**
|
|
209
281
|
* Serialize a list item.
|
|
282
|
+
*
|
|
283
|
+
* A nested list is indented to this item's CONTENT COLUMN — `bullet.length` —
|
|
284
|
+
* not by a fixed two spaces per level. The marker's width varies (`- ` is 2,
|
|
285
|
+
* `9. ` is 3, `10. ` is 4), and a child indented less than the parent's
|
|
286
|
+
* content column is not a child at all: markdown reads it as ending the
|
|
287
|
+
* outer list. That is a source-corrupting bug, not a cosmetic one — a
|
|
288
|
+
* three-item ordered list with a nested bullet list came back as three
|
|
289
|
+
* separate top-level nodes.
|
|
290
|
+
*
|
|
291
|
+
* `pad` already computes that column for every other block in the item; nested
|
|
292
|
+
* lists now use the same value instead of their own count.
|
|
293
|
+
*
|
|
210
294
|
* @param {Object} node - List item node with content
|
|
211
295
|
* @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
|
|
212
|
-
* @param {number} indent - Current indentation level
|
|
213
296
|
* @param {boolean} [loose=false] - Whether the parent list is loose
|
|
214
297
|
* @returns {string} Markdown list item
|
|
215
298
|
*/
|
|
216
|
-
function serializeListItem(node, bullet,
|
|
299
|
+
function serializeListItem(node, bullet, loose = false) {
|
|
217
300
|
if (!node.content) return bullet
|
|
218
301
|
|
|
219
302
|
const parts = []
|
|
@@ -224,11 +307,11 @@ function serializeListItem(node, bullet, indent, loose = false) {
|
|
|
224
307
|
|
|
225
308
|
// Nested lists carry their own indentation.
|
|
226
309
|
if (child.type === 'bulletList') {
|
|
227
|
-
parts.push(serializeBulletList(child,
|
|
310
|
+
parts.push(serializeBulletList(child, pad))
|
|
228
311
|
continue
|
|
229
312
|
}
|
|
230
313
|
if (child.type === 'orderedList') {
|
|
231
|
-
parts.push(serializeOrderedList(child,
|
|
314
|
+
parts.push(serializeOrderedList(child, pad))
|
|
232
315
|
continue
|
|
233
316
|
}
|
|
234
317
|
|
|
@@ -270,9 +353,21 @@ function indentBlock(text, firstPrefix, pad) {
|
|
|
270
353
|
|
|
271
354
|
/**
|
|
272
355
|
* Serialize a divider node.
|
|
273
|
-
*
|
|
356
|
+
*
|
|
357
|
+
* Emits the attribute spelling only when `type` carries a non-default value, so
|
|
358
|
+
* an ordinary rule stays `---` and no existing document is rewritten. `style` and
|
|
359
|
+
* `size` are deliberately NOT emitted: they are dead attrs (nothing reads them,
|
|
360
|
+
* no spelling sets them) and writing them would put vocabulary into every
|
|
361
|
+
* author's file that the framework is trying to retire.
|
|
362
|
+
*
|
|
363
|
+
* @param {Object} [node] - Divider node
|
|
364
|
+
* @returns {string} Markdown thematic break, with `{type=…}` when meaningful
|
|
274
365
|
*/
|
|
275
|
-
export function serializeDivider() {
|
|
366
|
+
export function serializeDivider(node) {
|
|
367
|
+
const type = node?.attrs?.type
|
|
368
|
+
if (typeof type === 'string' && type && type !== 'hr') {
|
|
369
|
+
return `---{type=${type}}`
|
|
370
|
+
}
|
|
276
371
|
return '---'
|
|
277
372
|
}
|
|
278
373
|
|
package/src/normalize.js
CHANGED
|
@@ -192,6 +192,15 @@ function mapEditorNode(node) {
|
|
|
192
192
|
case 'UniwebIcon':
|
|
193
193
|
case 'Icon':
|
|
194
194
|
return mapUniwebIcon(node)
|
|
195
|
+
case 'WarningBlock':
|
|
196
|
+
case 'details': {
|
|
197
|
+
const attrs = EDITOR_CONTAINERS[node.type](node)
|
|
198
|
+
return {
|
|
199
|
+
type: 'inset_block',
|
|
200
|
+
attrs,
|
|
201
|
+
content: node.type === 'details' ? detailsBody(node) : asBlocks(node.content),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
195
204
|
case 'emoji':
|
|
196
205
|
// The node carries the character alongside a name; the character is the
|
|
197
206
|
// whole markdown form. Guarded rather than assumed to be populated.
|
|
@@ -237,6 +246,68 @@ function mapEditorNode(node) {
|
|
|
237
246
|
*/
|
|
238
247
|
const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
|
|
239
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Editor container nodes → the framework's `inset_block`, as an explicit table.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately not derived from the node name. The editor's node is
|
|
253
|
+
* `WarningBlock` while the component is `Alert`, and its `type` defaults to
|
|
254
|
+
* `info` rather than `warning` — a name-derived mapping drops exactly that row,
|
|
255
|
+
* which is how `mapUniwebIcon` came to lose every icon the editor produces. A
|
|
256
|
+
* missing entry here is a visible gap rather than a silent assumption.
|
|
257
|
+
*/
|
|
258
|
+
const EDITOR_CONTAINERS = {
|
|
259
|
+
WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
|
|
260
|
+
details: () => ({ component: 'Details' }),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Inline node types, for deciding whether a container's body needs wrapping. */
|
|
264
|
+
const INLINE_TYPES = new Set([
|
|
265
|
+
'text',
|
|
266
|
+
'hardBreak',
|
|
267
|
+
'image',
|
|
268
|
+
'math_inline',
|
|
269
|
+
'inset_ref',
|
|
270
|
+
'emoji',
|
|
271
|
+
'UniwebIcon',
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A container body as block content.
|
|
276
|
+
*
|
|
277
|
+
* `WarningBlock` holds `text*` today and `block+` after the editor's widening,
|
|
278
|
+
* so both shapes arrive here. Inline content is wrapped in one paragraph;
|
|
279
|
+
* block content passes through.
|
|
280
|
+
*
|
|
281
|
+
* @param {Array} content
|
|
282
|
+
* @returns {Array}
|
|
283
|
+
*/
|
|
284
|
+
function asBlocks(content) {
|
|
285
|
+
if (!content?.length) return []
|
|
286
|
+
return content.every(node => INLINE_TYPES.has(node.type))
|
|
287
|
+
? [{ type: 'paragraph', content }]
|
|
288
|
+
: content
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
|
|
293
|
+
* container's body, summary first.
|
|
294
|
+
*
|
|
295
|
+
* The summary leads rather than riding on an attribute: `detailsSummary` can
|
|
296
|
+
* carry marks, and `summary="…"` would flatten emphasis and links.
|
|
297
|
+
*
|
|
298
|
+
* @param {Object} node
|
|
299
|
+
* @returns {Array}
|
|
300
|
+
*/
|
|
301
|
+
function detailsBody(node) {
|
|
302
|
+
const body = []
|
|
303
|
+
for (const child of node.content || []) {
|
|
304
|
+
if (child.type === 'detailsSummary') body.push(...asBlocks(child.content))
|
|
305
|
+
else if (child.type === 'detailsContent') body.push(...(child.content || []))
|
|
306
|
+
else body.push(child)
|
|
307
|
+
}
|
|
308
|
+
return body
|
|
309
|
+
}
|
|
310
|
+
|
|
240
311
|
/** A mark that carries nothing and should be removed rather than reported. */
|
|
241
312
|
const DROP_MARK = Symbol('drop-mark')
|
|
242
313
|
|
package/src/serializer.js
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
serializeParagraph,
|
|
10
10
|
serializeImage,
|
|
11
11
|
serializeInsetRef,
|
|
12
|
+
serializeInsetBlock,
|
|
13
|
+
serializeConceptBlock,
|
|
12
14
|
serializeCodeBlock,
|
|
13
15
|
serializeDataBlock,
|
|
14
16
|
serializeBlockquote,
|
|
@@ -27,6 +29,8 @@ const NODE_SERIALIZERS = {
|
|
|
27
29
|
paragraph: serializeParagraph,
|
|
28
30
|
image: serializeImage,
|
|
29
31
|
inset_ref: serializeInsetRef,
|
|
32
|
+
inset_block: serializeInsetBlock,
|
|
33
|
+
concept_block: serializeConceptBlock,
|
|
30
34
|
codeBlock: serializeCodeBlock,
|
|
31
35
|
dataBlock: serializeDataBlock,
|
|
32
36
|
blockquote: serializeBlockquote,
|
package/tests/attributes.test.js
CHANGED
|
@@ -27,12 +27,12 @@ describe('Attribute Serialization', () => {
|
|
|
27
27
|
expect(serializeAttributes({ autoplay: true, muted: true })).toBe('{autoplay muted}')
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
test('serializes
|
|
31
|
-
expect(serializeAttributes({
|
|
30
|
+
test('serializes a dotted NAME through the plain boolean branch', () => {
|
|
31
|
+
expect(serializeAttributes({ '.featured': true })).toBe('{.featured}')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
-
test('serializes
|
|
35
|
-
expect(serializeAttributes({
|
|
34
|
+
test('serializes several dotted names', () => {
|
|
35
|
+
expect(serializeAttributes({ '.featured': true, '.rounded': true, '.shadow': true })).toBe('{.featured .rounded .shadow}')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
test('serializes id as hash notation', () => {
|
|
@@ -41,7 +41,7 @@ describe('Attribute Serialization', () => {
|
|
|
41
41
|
|
|
42
42
|
test('serializes mixed attributes', () => {
|
|
43
43
|
const result = serializeAttributes({
|
|
44
|
-
|
|
44
|
+
'.featured': true,
|
|
45
45
|
id: 'main-logo',
|
|
46
46
|
width: 800,
|
|
47
47
|
loading: 'lazy',
|
|
@@ -95,7 +95,7 @@ describe('Attribute Serialization', () => {
|
|
|
95
95
|
|
|
96
96
|
test('serializes data attributes', () => {
|
|
97
97
|
const result = serializeAttributes({
|
|
98
|
-
|
|
98
|
+
'.info': true,
|
|
99
99
|
'data-tooltip': 'More info',
|
|
100
100
|
})
|
|
101
101
|
expect(result).toBe('{.info data-tooltip="More info"}')
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Open http://localhost:5173 in your browser.
|
|
2
|
+
|
|
3
|
+
Email me at a@b.com when it works.
|
|
4
|
+
|
|
5
|
+
A labelled link to [the docs](https://example.com) stays labelled.
|
|
6
|
+
|
|
7
|
+
A link whose label happens to be a URL, [https://example.com](https://example.com "titled"), keeps its explicit form.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Containers
|
|
2
|
+
|
|
3
|
+
```@Details
|
|
4
|
+
A summary with **marks**
|
|
5
|
+
|
|
6
|
+
Body content, [links](/x), the lot.
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```@Alert{type=warning}
|
|
10
|
+
Be careful with this.
|
|
11
|
+
|
|
12
|
+
- one
|
|
13
|
+
- two
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
````@Alert{type=info}
|
|
17
|
+
Do not do this:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
const x = 1
|
|
21
|
+
```
|
|
22
|
+
````
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
1. Push your repo to the host
|
|
2
|
+
2. Configure:
|
|
3
|
+
- Base directory: `site`
|
|
4
|
+
- Build command: `pnpm build`
|
|
5
|
+
3. Deploy
|
|
6
|
+
|
|
7
|
+
## A two-digit marker is wider
|
|
8
|
+
|
|
9
|
+
8. eight
|
|
10
|
+
9. nine
|
|
11
|
+
10. ten
|
|
12
|
+
- nested under a two-digit marker
|
|
13
|
+
|
|
14
|
+
## A bullet parent stays at two
|
|
15
|
+
|
|
16
|
+
- a
|
|
17
|
+
- b
|
|
18
|
+
- c
|
package/tests/marks.test.js
CHANGED
|
@@ -202,7 +202,7 @@ describe('Inline Content Serialization', () => {
|
|
|
202
202
|
{
|
|
203
203
|
type: 'text',
|
|
204
204
|
text: 'highlighted text',
|
|
205
|
-
marks: [{ type: 'span', attrs: {
|
|
205
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
206
206
|
},
|
|
207
207
|
{ type: 'text', text: ' in a sentence.' },
|
|
208
208
|
]
|
|
@@ -216,7 +216,7 @@ describe('Inline Content Serialization', () => {
|
|
|
216
216
|
{
|
|
217
217
|
type: 'text',
|
|
218
218
|
text: 'important note',
|
|
219
|
-
marks: [{ type: 'span', attrs: {
|
|
219
|
+
marks: [{ type: 'span', attrs: { '.callout': true, '.bold': true } }],
|
|
220
220
|
},
|
|
221
221
|
]
|
|
222
222
|
expect(serializeInlineContent(content)).toBe('[important note]{.callout .bold}')
|
|
@@ -227,7 +227,7 @@ describe('Inline Content Serialization', () => {
|
|
|
227
227
|
{
|
|
228
228
|
type: 'text',
|
|
229
229
|
text: 'key term',
|
|
230
|
-
marks: [{ type: 'span', attrs: {
|
|
230
|
+
marks: [{ type: 'span', attrs: { '.highlight': true, id: 'glossary-term' } }],
|
|
231
231
|
},
|
|
232
232
|
]
|
|
233
233
|
expect(serializeInlineContent(content)).toBe('[key term]{.highlight #glossary-term}')
|
|
@@ -238,7 +238,7 @@ describe('Inline Content Serialization', () => {
|
|
|
238
238
|
{
|
|
239
239
|
type: 'text',
|
|
240
240
|
text: 'tooltip text',
|
|
241
|
-
marks: [{ type: 'span', attrs: {
|
|
241
|
+
marks: [{ type: 'span', attrs: { '.info': true, 'data-tooltip': 'More info' } }],
|
|
242
242
|
},
|
|
243
243
|
]
|
|
244
244
|
expect(serializeInlineContent(content)).toBe('[tooltip text]{.info data-tooltip="More info"}')
|
|
@@ -274,13 +274,13 @@ describe('Inline Content Serialization', () => {
|
|
|
274
274
|
{
|
|
275
275
|
type: 'text',
|
|
276
276
|
text: 'first',
|
|
277
|
-
marks: [{ type: 'span', attrs: {
|
|
277
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
278
278
|
},
|
|
279
279
|
{ type: 'text', text: ' normal ' },
|
|
280
280
|
{
|
|
281
281
|
type: 'text',
|
|
282
282
|
text: 'second',
|
|
283
|
-
marks: [{ type: 'span', attrs: {
|
|
283
|
+
marks: [{ type: 'span', attrs: { '.muted': true } }],
|
|
284
284
|
},
|
|
285
285
|
]
|
|
286
286
|
expect(serializeInlineContent(content)).toBe('[first]{.highlight} normal [second]{.muted}')
|
|
@@ -297,7 +297,7 @@ describe('Inline Content Serialization', () => {
|
|
|
297
297
|
{
|
|
298
298
|
type: 'text',
|
|
299
299
|
text: 'span',
|
|
300
|
-
marks: [{ type: 'span', attrs: {
|
|
300
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
301
301
|
},
|
|
302
302
|
]
|
|
303
303
|
expect(serializeInlineContent(content)).toBe(
|
|
@@ -242,3 +242,43 @@ describe('blocks inside list items', () => {
|
|
|
242
242
|
expect(rt(source)).toBe(source)
|
|
243
243
|
})
|
|
244
244
|
})
|
|
245
|
+
|
|
246
|
+
describe('containers — the block form of an inset', () => {
|
|
247
|
+
const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
248
|
+
|
|
249
|
+
test('a container round-trips with its params', () => {
|
|
250
|
+
const source = '```@Alert{type=warning}\nBe careful.\n```'
|
|
251
|
+
expect(rt(source)).toBe(source)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('a rich body survives — marks, links and blocks', () => {
|
|
255
|
+
const source = '```@Details\nA **summary**\n\nBody with [a link](/x).\n```'
|
|
256
|
+
expect(rt(source)).toBe(source)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('the outer fence widens for a code block inside', () => {
|
|
260
|
+
// What makes a code sample in a callout possible, and why the body
|
|
261
|
+
// cannot be inline-only.
|
|
262
|
+
const source = '````@Alert{type=info}\nDo not:\n\n```js\nconst x = 1\n```\n````'
|
|
263
|
+
expect(rt(source)).toBe(source)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('the editor container shapes normalize onto it', () => {
|
|
267
|
+
const text = t => ({ type: 'text', text: t })
|
|
268
|
+
const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
|
|
269
|
+
|
|
270
|
+
// WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
|
|
271
|
+
// mapping drops exactly this.
|
|
272
|
+
expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
|
|
273
|
+
.toBe('```@Alert{type=info}\nCareful.\n```')
|
|
274
|
+
|
|
275
|
+
// The summary is the first block and keeps its marks.
|
|
276
|
+
expect(doc({
|
|
277
|
+
type: 'details',
|
|
278
|
+
content: [
|
|
279
|
+
{ type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
|
|
280
|
+
{ type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
|
|
281
|
+
],
|
|
282
|
+
})).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
|
|
283
|
+
})
|
|
284
|
+
})
|
package/tests/roundtrip.test.js
CHANGED
|
@@ -389,3 +389,71 @@ describe('Round-trip: Hard breaks', () => {
|
|
|
389
389
|
expect(proseMirrorToMarkdown(parsed).trim()).toBe('wrapped\nparagraph')
|
|
390
390
|
})
|
|
391
391
|
})
|
|
392
|
+
|
|
393
|
+
describe('Round-trip: Concept blocks', () => {
|
|
394
|
+
testRoundTrip(
|
|
395
|
+
'```md:faq\n# A question\nAn answer.\n\n# Another\nAlso answered.\n```',
|
|
396
|
+
'concept block with two pairs'
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
testRoundTrip(
|
|
400
|
+
'```md:warning\nBack up your database **first**.\n\n- Not reversible\n```',
|
|
401
|
+
'headingless concept block (a callout)'
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
testRoundTrip(
|
|
405
|
+
'```md:faq\n# Q\nAn answer with **bold** and a [link](/x).\n```',
|
|
406
|
+
'concept block whose body carries marks and links'
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
test('the BODY survives, not just the fence line', () => {
|
|
410
|
+
// A missing NODE_SERIALIZERS entry omits the whole node, so a test that
|
|
411
|
+
// only checks the fence and tag came back would pass on an empty block —
|
|
412
|
+
// and on a serializer that mangles the prose while the first line looks
|
|
413
|
+
// right. Assert the content.
|
|
414
|
+
const md = '```md:faq\n# What plans do you have?\nWe have three.\n```'
|
|
415
|
+
const out = proseMirrorToMarkdown(markdownToProseMirror(md))
|
|
416
|
+
|
|
417
|
+
expect(out).toContain('md:faq')
|
|
418
|
+
expect(out).toContain('# What plans do you have?')
|
|
419
|
+
expect(out).toContain('We have three.')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('serialization is a FIXED POINT, which is the bar — not byte equality', () => {
|
|
423
|
+
// pass1 may differ from the author's original: the serializer normalizes
|
|
424
|
+
// spacing, so `# Q` immediately followed by its answer comes back with a
|
|
425
|
+
// blank line between them. Nothing is lost. Asserting byte-equality
|
|
426
|
+
// against the original would fail on content that is behaving correctly,
|
|
427
|
+
// and someone would "fix" the serializer to make it pass.
|
|
428
|
+
const original = '```md:faq\n# Q1\nA1\n# Q2\nA2\n```'
|
|
429
|
+
const pass1 = proseMirrorToMarkdown(markdownToProseMirror(original))
|
|
430
|
+
const pass2 = proseMirrorToMarkdown(markdownToProseMirror(pass1))
|
|
431
|
+
|
|
432
|
+
// Guard the guard: a fixed point on NOTHING is trivially true. Verified by
|
|
433
|
+
// canary — unregister the serializer and every other test in this block
|
|
434
|
+
// fails while `'' === ''` sails through. So pin the content first.
|
|
435
|
+
expect(pass1).toContain('# Q1')
|
|
436
|
+
expect(pass1).toContain('A2')
|
|
437
|
+
expect(pass2).toBe(pass1)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('a code sample inside a concept block widens the outer fence', () => {
|
|
441
|
+
// Without fenceWidthFor the outer fence closes on the sample's own fence
|
|
442
|
+
// and the remainder spills into the page as prose. An FAQ answer that
|
|
443
|
+
// shows code is ordinary documentation, so this is the common case, not
|
|
444
|
+
// an edge one.
|
|
445
|
+
const md = '````md:faq\n# How do I start?\nRun this:\n\n```sh\nnpm i\n```\n````'
|
|
446
|
+
const out = proseMirrorToMarkdown(markdownToProseMirror(md))
|
|
447
|
+
|
|
448
|
+
expect(out.startsWith('````md:faq')).toBe(true)
|
|
449
|
+
expect(out).toContain('```sh')
|
|
450
|
+
expect(markdownToProseMirror(out)).toEqual(markdownToProseMirror(md))
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('an unmapped node would be dropped — so this one must stay mapped', () => {
|
|
454
|
+
// The failure mode this suite exists to catch: serializeNode omits a node
|
|
455
|
+
// it has no serializer for, taking the author's prose with it.
|
|
456
|
+
const doc = markdownToProseMirror('```md:faq\n# Q\nA\n```')
|
|
457
|
+
expect(proseMirrorToMarkdown(doc).trim()).not.toBe('')
|
|
458
|
+
})
|
|
459
|
+
})
|
package/tests/serializer.test.js
CHANGED
|
@@ -180,7 +180,7 @@ describe('Image Serialization', () => {
|
|
|
180
180
|
alt: 'Logo',
|
|
181
181
|
caption: null,
|
|
182
182
|
role: 'image',
|
|
183
|
-
|
|
183
|
+
'.featured': true,
|
|
184
184
|
id: 'main-logo',
|
|
185
185
|
},
|
|
186
186
|
},
|
|
@@ -200,7 +200,7 @@ describe('Image Serialization', () => {
|
|
|
200
200
|
alt: 'Gallery',
|
|
201
201
|
caption: null,
|
|
202
202
|
role: 'image',
|
|
203
|
-
|
|
203
|
+
'.featured': true, '.rounded': true, '.shadow': true,
|
|
204
204
|
},
|
|
205
205
|
},
|
|
206
206
|
],
|