@uniweb/content-writer 0.2.7 → 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 +4 -3
- package/probe.tmp.mjs +18 -0
- package/src/attributes.js +8 -8
- package/src/entities.js +107 -0
- package/src/index.js +2 -1
- package/src/marks.js +181 -93
- package/src/nodes.js +204 -33
- package/src/normalize.js +158 -13
- package/src/plain-text.js +71 -4
- package/src/serializer.js +2 -0
- package/tests/attributes.test.js +6 -6
- package/tests/entities.test.js +175 -0
- package/tests/fixtures/roundtrip/bare-autolink.md +7 -0
- package/tests/fixtures/roundtrip/canonical-math.md +3 -0
- package/tests/fixtures/roundtrip/code.md +11 -0
- package/tests/fixtures/roundtrip/containers.md +22 -0
- package/tests/fixtures/roundtrip/defect-bold-code.md +3 -0
- package/tests/fixtures/roundtrip/defect-bold-link.md +1 -0
- package/tests/fixtures/roundtrip/defect-data-block.md +6 -0
- package/tests/fixtures/roundtrip/defect-loose-list.md +5 -0
- package/tests/fixtures/roundtrip/fences.md +9 -0
- package/tests/fixtures/roundtrip/list-blocks.md +18 -0
- package/tests/fixtures/roundtrip/lists.md +13 -0
- package/tests/fixtures/roundtrip/media.md +7 -0
- package/tests/fixtures/roundtrip/nested-list-indent.md +18 -0
- package/tests/fixtures/roundtrip/prose.md +12 -0
- package/tests/fixtures/roundtrip/structure.md +16 -0
- package/tests/fixtures/roundtrip/tables-escaped.md +6 -0
- package/tests/fixtures/roundtrip/tables.md +6 -0
- package/tests/marks.test.js +13 -8
- package/tests/normalize.test.js +107 -4
- package/tests/roundtrip-source.test.js +284 -0
- package/tests/serializer.test.js +24 -14
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity handling on the way out of ProseMirror.
|
|
3
|
+
*
|
|
4
|
+
* The invariant these tests pin down (verified against `content-reader`'s
|
|
5
|
+
* `parser/inline.js`): a PM text node holds HTML. The reader decodes `&`,
|
|
6
|
+
* `"` and `'` and leaves `<`/`>` escaped, because the render
|
|
7
|
+
* path injects the string as HTML. Serializing has to undo exactly that, and
|
|
8
|
+
* exactly there — decoding more, or decoding in prose, corrupts source.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
12
|
+
import { proseMirrorToMarkdown, docSummaryText, docToPlainText } from '../src/index.js'
|
|
13
|
+
import { encodeMarkupEntities, decodeMarkupEntities, decodeTextEntities } from '../src/entities.js'
|
|
14
|
+
|
|
15
|
+
const md = s => proseMirrorToMarkdown(markdownToProseMirror(s)).trim()
|
|
16
|
+
|
|
17
|
+
describe('decodeMarkupEntities', () => {
|
|
18
|
+
test('undoes the pair the reader leaves escaped', () => {
|
|
19
|
+
expect(decodeMarkupEntities('/data/<name>.json')).toBe('/data/<name>.json')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('leaves entities the reader already decoded alone', () => {
|
|
23
|
+
// A PM node holding these means the author typed them.
|
|
24
|
+
expect(decodeMarkupEntities('a & b')).toBe('a & b')
|
|
25
|
+
expect(decodeMarkupEntities('say "hi"')).toBe('say "hi"')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('is a no-op on text with no entities', () => {
|
|
29
|
+
expect(decodeMarkupEntities('plain text')).toBe('plain text')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('decodeTextEntities', () => {
|
|
34
|
+
test('resolves every entity to the character a reader sees', () => {
|
|
35
|
+
expect(decodeTextEntities('<b> & "q" 'a'')).toBe(`<b> & "q" 'a'`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('decodes & last, so &lt; yields < and not <', () => {
|
|
39
|
+
expect(decodeTextEntities('&lt;')).toBe('<')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('D1 — code spans round-trip a literal < >', () => {
|
|
44
|
+
test('the reported defect', () => {
|
|
45
|
+
expect(md('Read `/data/<name>.json` for the cascade.')).toBe(
|
|
46
|
+
'Read `/data/<name>.json` for the cascade.'
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('inside a heading', () => {
|
|
51
|
+
expect(md('# The `<slug>` folder')).toBe('# The `<slug>` folder')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('inside a table cell', () => {
|
|
55
|
+
expect(md('| Col |\n| --- |\n| `<x>` |')).toContain('`<x>`')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('inside a link', () => {
|
|
59
|
+
expect(md('[the `<x>` doc](/a)')).toBe('[the `<x>` doc](/a)')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('D1 — what the fix must NOT touch', () => {
|
|
64
|
+
test('prose entities stay escaped (decoding them would render as a tag)', () => {
|
|
65
|
+
expect(md('Literal <name> in prose.')).toBe('Literal <name> in prose.')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('raw inline HTML in prose is preserved verbatim', () => {
|
|
69
|
+
expect(md('A <name> tag in prose.')).toBe('A <name> tag in prose.')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('an authored & inside a code span survives', () => {
|
|
73
|
+
expect(md('Code `a & b` here.')).toBe('Code `a & b` here.')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('code blocks are raw text and are left alone', () => {
|
|
77
|
+
const source = '```js\nconst x = a < b && c > d;\n```'
|
|
78
|
+
expect(md(source)).toBe(source)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('quotes and apostrophes in code spans are untouched', () => {
|
|
82
|
+
expect(md(`Code \`say "hi" it's\` here.`)).toBe(`Code \`say "hi" it's\` here.`)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('docSummaryText', () => {
|
|
87
|
+
const summary = (source, opts) => docSummaryText(markdownToProseMirror(source), opts)
|
|
88
|
+
|
|
89
|
+
test('skips the H1 and reads the first paragraph', () => {
|
|
90
|
+
expect(summary('# Quickstart\n\nCreate your first Uniweb site in 5 minutes.')).toBe(
|
|
91
|
+
'Create your first Uniweb site in 5 minutes.'
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('returns plain text — no anchors, no mark tags', () => {
|
|
96
|
+
expect(summary('# T\n\nSee **the [docs](/docs)** for *more*.')).toBe(
|
|
97
|
+
'See the docs for more.'
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('resolves entities and drops raw inline HTML', () => {
|
|
102
|
+
expect(summary('# T\n\nUse `<name>` and <slug> and <br> here.')).toBe(
|
|
103
|
+
'Use <name> and <slug> and here.'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('truncates at a sentence boundary', () => {
|
|
108
|
+
const text = 'One sentence here. Two sentence here. Three sentence here.'
|
|
109
|
+
expect(summary(`# T\n\n${text}`, { maxChars: 40 })).toBe('One sentence here. Two sentence here.')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('falls back to a word boundary with an ellipsis', () => {
|
|
113
|
+
const text = 'Collections let you manage repeating content across many files'
|
|
114
|
+
expect(summary(`# T\n\n${text}`, { maxChars: 40 })).toBe(
|
|
115
|
+
'Collections let you manage repeating…'
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a dot inside a filename does not end a sentence', () => {
|
|
120
|
+
expect(summary('# T\n\nEdit site.yml then run the build now please.', { maxChars: 20 }))
|
|
121
|
+
.toBe('Edit site.yml then…')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('returns empty string when there is no paragraph', () => {
|
|
125
|
+
expect(summary('# Only a heading')).toBe('')
|
|
126
|
+
expect(docSummaryText(null)).toBe('')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('collapses newlines into one line', () => {
|
|
130
|
+
expect(summary('# T\n\nA line\nand another.')).toBe('A line and another.')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('docToPlainText also yields plain characters', () => {
|
|
135
|
+
test('strips tags and resolves entities', () => {
|
|
136
|
+
// Note the doubled space where <br> was: this primitive does not
|
|
137
|
+
// normalize whitespace, and its consumers (word counts, search tokens)
|
|
138
|
+
// do not care. `docSummaryText` is the one that promises a single line.
|
|
139
|
+
expect(docToPlainText(markdownToProseMirror('Use <x> and <br> here.'))).toBe(
|
|
140
|
+
'Use <x> and here.'
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('encodeMarkupEntities — the producer-side half', () => {
|
|
146
|
+
test('is the exact inverse of decodeMarkupEntities', () => {
|
|
147
|
+
const raw = '/data/<name>.json'
|
|
148
|
+
expect(encodeMarkupEntities(raw)).toBe('/data/<name>.json')
|
|
149
|
+
expect(decodeMarkupEntities(encodeMarkupEntities(raw))).toBe(raw)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('does not touch &, so an authored entity is not double-encoded', () => {
|
|
153
|
+
expect(encodeMarkupEntities('a & b')).toBe('a & b')
|
|
154
|
+
expect(encodeMarkupEntities('&')).toBe('&')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('round-trips what an author types in a code span', () => {
|
|
158
|
+
for (const raw of ['<div>', 'a < b && c > d', 'a & b', 'plain']) {
|
|
159
|
+
expect(decodeMarkupEntities(encodeMarkupEntities(raw))).toBe(raw)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('the one input it does NOT round-trip: literal entity text', () => {
|
|
164
|
+
// `<div>` and `<div>` both store as `<div>`, so the pair
|
|
165
|
+
// cannot tell them apart and resolves to the decoded form.
|
|
166
|
+
//
|
|
167
|
+
// Accepted deliberately, because the loss happened earlier and elsewhere:
|
|
168
|
+
// a code span is rendered by injecting its text as HTML, so an author who
|
|
169
|
+
// typed `<div>` already sees `<div>` on the page. Decoding makes the
|
|
170
|
+
// file agree with the page rather than destroying something a reader could
|
|
171
|
+
// otherwise see. Escaping `&` as well would make the pair lossless, at the
|
|
172
|
+
// cost of changing what every existing code span stores.
|
|
173
|
+
expect(decodeMarkupEntities(encodeMarkupEntities('<div>'))).toBe('<div>')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -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 @@
|
|
|
1
|
+
- **[Predicates](./predicates.md)** — filtering with `where:` clauses
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Blocks inside list items
|
|
2
|
+
|
|
3
|
+
- Use the hook:
|
|
4
|
+
```jsx
|
|
5
|
+
const { data } = useEntityDetail(article)
|
|
6
|
+
```
|
|
7
|
+
- A quoted aside:
|
|
8
|
+
> Worth knowing.
|
|
9
|
+
- A nested list:
|
|
10
|
+
- child
|
|
11
|
+
- sibling
|
|
12
|
+
- Plain item
|
|
13
|
+
|
|
14
|
+
1. Step one
|
|
15
|
+
```sh
|
|
16
|
+
pnpm install
|
|
17
|
+
```
|
|
18
|
+
2. Step two
|
|
@@ -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
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Working with Collections
|
|
2
|
+
|
|
3
|
+
Collections let you manage repeating content as a set of files.
|
|
4
|
+
|
|
5
|
+
## Declaring a collection
|
|
6
|
+
|
|
7
|
+
Each collection is declared in `site.yml`, and every item compiles into
|
|
8
|
+
`/data/<name>.json`. See the [collections guide](/docs/authoring/collections)
|
|
9
|
+
for the full story.
|
|
10
|
+
|
|
11
|
+
Text can be **bold**, *italic*, or ***both***. A literal <name> stays
|
|
12
|
+
escaped in prose, and `a & b` keeps its entity inside code.
|
package/tests/marks.test.js
CHANGED
|
@@ -44,7 +44,12 @@ describe('Inline Content Serialization', () => {
|
|
|
44
44
|
marks: [{ type: 'italic' }, { type: 'bold' }],
|
|
45
45
|
},
|
|
46
46
|
]
|
|
47
|
-
|
|
47
|
+
// Bold spans both nodes and italic sits inside it, so bold is emitted once
|
|
48
|
+
// around the pair. This used to assert `**bold *****then italic***` — two
|
|
49
|
+
// bold spans butted together — which does not survive a reparse: the run
|
|
50
|
+
// of five asterisks reads back as the literal text `**bold **` with no
|
|
51
|
+
// marks at all, destroying the bold it was meant to carry.
|
|
52
|
+
expect(serializeInlineContent(content)).toBe('**bold *then italic***')
|
|
48
53
|
})
|
|
49
54
|
|
|
50
55
|
test('serializes inline code', () => {
|
|
@@ -197,7 +202,7 @@ describe('Inline Content Serialization', () => {
|
|
|
197
202
|
{
|
|
198
203
|
type: 'text',
|
|
199
204
|
text: 'highlighted text',
|
|
200
|
-
marks: [{ type: 'span', attrs: {
|
|
205
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
201
206
|
},
|
|
202
207
|
{ type: 'text', text: ' in a sentence.' },
|
|
203
208
|
]
|
|
@@ -211,7 +216,7 @@ describe('Inline Content Serialization', () => {
|
|
|
211
216
|
{
|
|
212
217
|
type: 'text',
|
|
213
218
|
text: 'important note',
|
|
214
|
-
marks: [{ type: 'span', attrs: {
|
|
219
|
+
marks: [{ type: 'span', attrs: { '.callout': true, '.bold': true } }],
|
|
215
220
|
},
|
|
216
221
|
]
|
|
217
222
|
expect(serializeInlineContent(content)).toBe('[important note]{.callout .bold}')
|
|
@@ -222,7 +227,7 @@ describe('Inline Content Serialization', () => {
|
|
|
222
227
|
{
|
|
223
228
|
type: 'text',
|
|
224
229
|
text: 'key term',
|
|
225
|
-
marks: [{ type: 'span', attrs: {
|
|
230
|
+
marks: [{ type: 'span', attrs: { '.highlight': true, id: 'glossary-term' } }],
|
|
226
231
|
},
|
|
227
232
|
]
|
|
228
233
|
expect(serializeInlineContent(content)).toBe('[key term]{.highlight #glossary-term}')
|
|
@@ -233,7 +238,7 @@ describe('Inline Content Serialization', () => {
|
|
|
233
238
|
{
|
|
234
239
|
type: 'text',
|
|
235
240
|
text: 'tooltip text',
|
|
236
|
-
marks: [{ type: 'span', attrs: {
|
|
241
|
+
marks: [{ type: 'span', attrs: { '.info': true, 'data-tooltip': 'More info' } }],
|
|
237
242
|
},
|
|
238
243
|
]
|
|
239
244
|
expect(serializeInlineContent(content)).toBe('[tooltip text]{.info data-tooltip="More info"}')
|
|
@@ -269,13 +274,13 @@ describe('Inline Content Serialization', () => {
|
|
|
269
274
|
{
|
|
270
275
|
type: 'text',
|
|
271
276
|
text: 'first',
|
|
272
|
-
marks: [{ type: 'span', attrs: {
|
|
277
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
273
278
|
},
|
|
274
279
|
{ type: 'text', text: ' normal ' },
|
|
275
280
|
{
|
|
276
281
|
type: 'text',
|
|
277
282
|
text: 'second',
|
|
278
|
-
marks: [{ type: 'span', attrs: {
|
|
283
|
+
marks: [{ type: 'span', attrs: { '.muted': true } }],
|
|
279
284
|
},
|
|
280
285
|
]
|
|
281
286
|
expect(serializeInlineContent(content)).toBe('[first]{.highlight} normal [second]{.muted}')
|
|
@@ -292,7 +297,7 @@ describe('Inline Content Serialization', () => {
|
|
|
292
297
|
{
|
|
293
298
|
type: 'text',
|
|
294
299
|
text: 'span',
|
|
295
|
-
marks: [{ type: 'span', attrs: {
|
|
300
|
+
marks: [{ type: 'span', attrs: { '.highlight': true } }],
|
|
296
301
|
},
|
|
297
302
|
]
|
|
298
303
|
expect(serializeInlineContent(content)).toBe(
|
package/tests/normalize.test.js
CHANGED
|
@@ -156,8 +156,12 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
156
156
|
})
|
|
157
157
|
)
|
|
158
158
|
)
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
// `highlight` used to have no markdown form and was reported as an
|
|
160
|
+
// unmapped mark. It is now a named inline style — the editor stores a
|
|
161
|
+
// theme token rather than a colour — so it maps onto the span mark and
|
|
162
|
+
// nothing is dropped or reported.
|
|
163
|
+
expect(md).toBe('[important]{highlight}')
|
|
164
|
+
expect(warnings).toEqual([])
|
|
161
165
|
})
|
|
162
166
|
|
|
163
167
|
test('a known framework document produces no warnings', () => {
|
|
@@ -196,7 +200,11 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
196
200
|
expect(warnings.some((m) => m.includes('FancyInlineWidget'))).toBe(true)
|
|
197
201
|
})
|
|
198
202
|
|
|
199
|
-
test('a
|
|
203
|
+
test('a standalone "\\n" text node is a soft break, not a hard one', () => {
|
|
204
|
+
// content-reader emits a lone "\n" node for a SOFT break whenever the
|
|
205
|
+
// spans on both sides are marked. This used to be healed into a hard
|
|
206
|
+
// break, on the assumption it could only be the legacy spelling of one —
|
|
207
|
+
// which turned a wrapped line into a visible <br> one round trip later.
|
|
200
208
|
const md = proseMirrorToMarkdown(
|
|
201
209
|
doc(
|
|
202
210
|
para(
|
|
@@ -206,8 +214,15 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
206
214
|
)
|
|
207
215
|
)
|
|
208
216
|
)
|
|
217
|
+
expect(md.trim()).toBe('line one\nline two')
|
|
218
|
+
expect(warnings).toEqual([])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a hard break is the hardBreak node, and still serializes as one', () => {
|
|
222
|
+
const md = proseMirrorToMarkdown(
|
|
223
|
+
doc(para({ type: 'text', text: 'line one' }, { type: 'hardBreak' }, { type: 'text', text: 'line two' }))
|
|
224
|
+
)
|
|
209
225
|
expect(md.trim()).toBe('line one\\\nline two')
|
|
210
|
-
expect(warnings).toEqual([]) // healed, not reported as unmapped
|
|
211
226
|
})
|
|
212
227
|
|
|
213
228
|
test('a newline INSIDE a text node is a soft break and must not be converted', () => {
|
|
@@ -219,3 +234,91 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
219
234
|
expect(md.trim()).toBe('wrapped\nparagraph')
|
|
220
235
|
})
|
|
221
236
|
})
|
|
237
|
+
|
|
238
|
+
describe('editor named inline styles map onto the span mark', () => {
|
|
239
|
+
let warnings
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
warnings = []
|
|
243
|
+
resetDiagnostics()
|
|
244
|
+
setDiagnosticsReporter(m => warnings.push(m))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const styled = marks => proseMirrorToMarkdown(doc(para({ type: 'text', text: 'x', marks }))).trim()
|
|
248
|
+
|
|
249
|
+
test('a textStyle token becomes a named inline style', () => {
|
|
250
|
+
// The editor's picker offers a closed set of theme tokens, not colours —
|
|
251
|
+
// the attribute is only *named* `color`. So they round-trip through the
|
|
252
|
+
// span mark the framework already has, and nothing is lost.
|
|
253
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'accent' } }])).toBe('[x]{accent}')
|
|
254
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'muted' } }])).toBe('[x]{muted}')
|
|
255
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'callout' } }])).toBe('[x]{callout}')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('highlight is effectively boolean and maps the same way', () => {
|
|
259
|
+
expect(styled([{ type: 'highlight' }])).toBe('[x]{highlight}')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('the "Normal" option carries no styling', () => {
|
|
263
|
+
expect(styled([{ type: 'textStyle', attrs: { color: '' } }])).toBe('x')
|
|
264
|
+
expect(warnings).toEqual([])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('a raw colour is NOT mapped — it is reported, not written into markdown', () => {
|
|
268
|
+
// Reachable today via a paste carrying inline style; the editor is gating
|
|
269
|
+
// its parseHTML. A hex in markdown would contradict the theming model and
|
|
270
|
+
// would not survive a change of brand, so it must not be invented here.
|
|
271
|
+
expect(styled([{ type: 'textStyle', attrs: { color: '#ff0000' } }])).toBe('x')
|
|
272
|
+
expect(warnings.some(m => m.includes('textStyle'))).toBe(true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('a mapped style round-trips back through the reader', () => {
|
|
276
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'accent' } }])).toBe('[x]{accent}')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('editor icons and emoji', () => {
|
|
281
|
+
const inline = n => proseMirrorToMarkdown(doc(para(n))).trim()
|
|
282
|
+
|
|
283
|
+
test('the insert path stores a colon-joined family:id name', () => {
|
|
284
|
+
// What the editor actually emits: one `name`, no `library`. Requiring
|
|
285
|
+
// both attrs dropped every icon the editor produces.
|
|
286
|
+
expect(inline({ type: 'UniwebIcon', attrs: { name: 'lucide:star', svg: '<svg/>' } }))
|
|
287
|
+
.toBe('')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('a short-code family uses the compact dash spelling', () => {
|
|
291
|
+
expect(inline({ type: 'UniwebIcon', attrs: { name: 'lu:star' } })).toBe('')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('the separate library+name form still maps', () => {
|
|
295
|
+
expect(inline({ type: 'UniwebIcon', attrs: { library: 'lu', name: 'star' } })).toBe('')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('the inlined svg blob is never written into markdown', () => {
|
|
299
|
+
const md = inline({ type: 'UniwebIcon', attrs: { name: 'lucide:star', svg: '<svg viewBox="0 0 24 24"/>' } })
|
|
300
|
+
expect(md).not.toContain('<svg')
|
|
301
|
+
expect(md).not.toContain('viewBox')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('emoji carries its character', () => {
|
|
305
|
+
expect(inline({ type: 'emoji', attrs: { name: 'star', emoji: '⭐' } })).toBe('⭐')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('an emoji with no character is not invented', () => {
|
|
309
|
+
expect(inline({ type: 'emoji', attrs: { name: 'star' } })).toBe('')
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('icon spelling round-trips', () => {
|
|
314
|
+
test('a full family name keeps the colon form', () => {
|
|
315
|
+
// `` reads back as a plain image with src "lucide-star" —
|
|
316
|
+
// the dash spelling is short-codes-only, by design in the reader, so that
|
|
317
|
+
// the form stays unambiguous against a relative path.
|
|
318
|
+
for (const md of ['', '', '']) {
|
|
319
|
+
const out = proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
320
|
+
const attrs = markdownToProseMirror(out).content[0].content[0].attrs
|
|
321
|
+
expect(attrs.role).toBe('icon')
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
})
|