@uniweb/content-writer 0.2.8 → 0.2.9
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 +59 -12
- package/src/normalize.js +71 -0
- package/src/serializer.js +2 -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/serializer.test.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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.1.
|
|
23
|
+
"@uniweb/content-reader": "1.1.17"
|
|
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,36 @@ 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
|
+
|
|
98
129
|
/**
|
|
99
130
|
* Serialize a data block node.
|
|
100
131
|
*
|
|
@@ -154,30 +185,35 @@ export function serializeBlockquote(node) {
|
|
|
154
185
|
|
|
155
186
|
/**
|
|
156
187
|
* Serialize a bullet list node.
|
|
188
|
+
*
|
|
189
|
+
* `pad` is the literal indentation this list sits at — the parent item's
|
|
190
|
+
* CONTENT COLUMN, not a level count. See serializeListItem for why.
|
|
191
|
+
*
|
|
157
192
|
* @param {Object} node - Bullet list node with content (listItem nodes)
|
|
158
|
-
* @param {
|
|
193
|
+
* @param {string} [pad=''] - Indentation prefix for every marker in this list
|
|
159
194
|
* @returns {string} Markdown bullet list
|
|
160
195
|
*/
|
|
161
|
-
export function serializeBulletList(node,
|
|
196
|
+
export function serializeBulletList(node, pad = '') {
|
|
162
197
|
if (!node.content) return ''
|
|
163
|
-
const prefix = ' '.repeat(indent)
|
|
164
198
|
return node.content
|
|
165
|
-
.map(item => serializeListItem(item, `${
|
|
199
|
+
.map(item => serializeListItem(item, `${pad}- `, isLoose(node)))
|
|
166
200
|
.join(itemSeparator(node))
|
|
167
201
|
}
|
|
168
202
|
|
|
169
203
|
/**
|
|
170
204
|
* Serialize an ordered list node.
|
|
205
|
+
*
|
|
206
|
+
* `pad` is the literal indentation this list sits at — see serializeBulletList.
|
|
207
|
+
*
|
|
171
208
|
* @param {Object} node - Ordered list node with attrs.start and content
|
|
172
|
-
* @param {
|
|
209
|
+
* @param {string} [pad=''] - Indentation prefix for every marker in this list
|
|
173
210
|
* @returns {string} Markdown ordered list
|
|
174
211
|
*/
|
|
175
|
-
export function serializeOrderedList(node,
|
|
212
|
+
export function serializeOrderedList(node, pad = '') {
|
|
176
213
|
if (!node.content) return ''
|
|
177
214
|
const start = node.attrs?.start || 1
|
|
178
|
-
const prefix = ' '.repeat(indent)
|
|
179
215
|
return node.content
|
|
180
|
-
.map((item, i) => serializeListItem(item, `${
|
|
216
|
+
.map((item, i) => serializeListItem(item, `${pad}${start + i}. `, isLoose(node)))
|
|
181
217
|
.join(itemSeparator(node))
|
|
182
218
|
}
|
|
183
219
|
|
|
@@ -207,13 +243,24 @@ function isLoose(node) {
|
|
|
207
243
|
|
|
208
244
|
/**
|
|
209
245
|
* Serialize a list item.
|
|
246
|
+
*
|
|
247
|
+
* A nested list is indented to this item's CONTENT COLUMN — `bullet.length` —
|
|
248
|
+
* not by a fixed two spaces per level. The marker's width varies (`- ` is 2,
|
|
249
|
+
* `9. ` is 3, `10. ` is 4), and a child indented less than the parent's
|
|
250
|
+
* content column is not a child at all: markdown reads it as ending the
|
|
251
|
+
* outer list. That is a source-corrupting bug, not a cosmetic one — a
|
|
252
|
+
* three-item ordered list with a nested bullet list came back as three
|
|
253
|
+
* separate top-level nodes.
|
|
254
|
+
*
|
|
255
|
+
* `pad` already computes that column for every other block in the item; nested
|
|
256
|
+
* lists now use the same value instead of their own count.
|
|
257
|
+
*
|
|
210
258
|
* @param {Object} node - List item node with content
|
|
211
259
|
* @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
|
|
212
|
-
* @param {number} indent - Current indentation level
|
|
213
260
|
* @param {boolean} [loose=false] - Whether the parent list is loose
|
|
214
261
|
* @returns {string} Markdown list item
|
|
215
262
|
*/
|
|
216
|
-
function serializeListItem(node, bullet,
|
|
263
|
+
function serializeListItem(node, bullet, loose = false) {
|
|
217
264
|
if (!node.content) return bullet
|
|
218
265
|
|
|
219
266
|
const parts = []
|
|
@@ -224,11 +271,11 @@ function serializeListItem(node, bullet, indent, loose = false) {
|
|
|
224
271
|
|
|
225
272
|
// Nested lists carry their own indentation.
|
|
226
273
|
if (child.type === 'bulletList') {
|
|
227
|
-
parts.push(serializeBulletList(child,
|
|
274
|
+
parts.push(serializeBulletList(child, pad))
|
|
228
275
|
continue
|
|
229
276
|
}
|
|
230
277
|
if (child.type === 'orderedList') {
|
|
231
|
-
parts.push(serializeOrderedList(child,
|
|
278
|
+
parts.push(serializeOrderedList(child, pad))
|
|
232
279
|
continue
|
|
233
280
|
}
|
|
234
281
|
|
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,7 @@ import {
|
|
|
9
9
|
serializeParagraph,
|
|
10
10
|
serializeImage,
|
|
11
11
|
serializeInsetRef,
|
|
12
|
+
serializeInsetBlock,
|
|
12
13
|
serializeCodeBlock,
|
|
13
14
|
serializeDataBlock,
|
|
14
15
|
serializeBlockquote,
|
|
@@ -27,6 +28,7 @@ const NODE_SERIALIZERS = {
|
|
|
27
28
|
paragraph: serializeParagraph,
|
|
28
29
|
image: serializeImage,
|
|
29
30
|
inset_ref: serializeInsetRef,
|
|
31
|
+
inset_block: serializeInsetBlock,
|
|
30
32
|
codeBlock: serializeCodeBlock,
|
|
31
33
|
dataBlock: serializeDataBlock,
|
|
32
34
|
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/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
|
],
|