@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.
Files changed (33) hide show
  1. package/package.json +4 -3
  2. package/probe.tmp.mjs +18 -0
  3. package/src/attributes.js +8 -8
  4. package/src/entities.js +107 -0
  5. package/src/index.js +2 -1
  6. package/src/marks.js +181 -93
  7. package/src/nodes.js +204 -33
  8. package/src/normalize.js +158 -13
  9. package/src/plain-text.js +71 -4
  10. package/src/serializer.js +2 -0
  11. package/tests/attributes.test.js +6 -6
  12. package/tests/entities.test.js +175 -0
  13. package/tests/fixtures/roundtrip/bare-autolink.md +7 -0
  14. package/tests/fixtures/roundtrip/canonical-math.md +3 -0
  15. package/tests/fixtures/roundtrip/code.md +11 -0
  16. package/tests/fixtures/roundtrip/containers.md +22 -0
  17. package/tests/fixtures/roundtrip/defect-bold-code.md +3 -0
  18. package/tests/fixtures/roundtrip/defect-bold-link.md +1 -0
  19. package/tests/fixtures/roundtrip/defect-data-block.md +6 -0
  20. package/tests/fixtures/roundtrip/defect-loose-list.md +5 -0
  21. package/tests/fixtures/roundtrip/fences.md +9 -0
  22. package/tests/fixtures/roundtrip/list-blocks.md +18 -0
  23. package/tests/fixtures/roundtrip/lists.md +13 -0
  24. package/tests/fixtures/roundtrip/media.md +7 -0
  25. package/tests/fixtures/roundtrip/nested-list-indent.md +18 -0
  26. package/tests/fixtures/roundtrip/prose.md +12 -0
  27. package/tests/fixtures/roundtrip/structure.md +16 -0
  28. package/tests/fixtures/roundtrip/tables-escaped.md +6 -0
  29. package/tests/fixtures/roundtrip/tables.md +6 -0
  30. package/tests/marks.test.js +13 -8
  31. package/tests/normalize.test.js +107 -4
  32. package/tests/roundtrip-source.test.js +284 -0
  33. 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/&lt;name&gt;.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 &amp; b')).toBe('a &amp; b')
25
+ expect(decodeMarkupEntities('say &quot;hi&quot;')).toBe('say &quot;hi&quot;')
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('&lt;b&gt; &amp; &quot;q&quot; &#39;a&#39;')).toBe(`<b> & "q" 'a'`)
36
+ })
37
+
38
+ test('decodes &amp; last, so &amp;lt; yields &lt; and not <', () => {
39
+ expect(decodeTextEntities('&amp;lt;')).toBe('&lt;')
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 &lt;name&gt; in prose.')).toBe('Literal &lt;name&gt; 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 &amp; inside a code span survives', () => {
73
+ expect(md('Code `a &amp; b` here.')).toBe('Code `a &amp; 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 &lt;slug&gt; 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 &lt;x&gt; 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/&lt;name&gt;.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('&amp;')).toBe('&amp;')
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 `&lt;div&gt;` both store as `&lt;div&gt;`, 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 `&lt;div&gt;` 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('&lt;div&gt;'))).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,3 @@
1
+ $$
2
+ E = mc^2
3
+ $$
@@ -0,0 +1,11 @@
1
+ # Code
2
+
3
+ Install it first:
4
+
5
+ ```bash
6
+ pnpm add @uniweb/kit
7
+ ```
8
+
9
+ ```js
10
+ const site = { name: 'docs' }
11
+ ```
@@ -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,3 @@
1
+ - **Preview with `pnpm dev`.**
2
+
3
+ Use **`npm install`** first.
@@ -0,0 +1 @@
1
+ - **[Predicates](./predicates.md)** — filtering with `where:` clauses
@@ -0,0 +1,6 @@
1
+ # Navigation
2
+
3
+ ```yaml:nav
4
+ - label: Docs
5
+ href: /docs
6
+ ```
@@ -0,0 +1,5 @@
1
+ - one
2
+
3
+ - two
4
+
5
+ - three
@@ -0,0 +1,9 @@
1
+ # Fences
2
+
3
+ ````markdown
4
+ ```js
5
+ const x = 1
6
+ ```
7
+ ````
8
+
9
+ A ` ```math ` fence, and ` `` ` for nested spans.
@@ -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,13 @@
1
+ # Lists
2
+
3
+ - one
4
+ - two
5
+ - three
6
+
7
+ 1. first
8
+ 2. second
9
+
10
+ - parent
11
+ - child
12
+ - sibling
13
+ - next
@@ -0,0 +1,7 @@
1
+ # Media
2
+
3
+ ![](lu-house)
4
+
5
+ ![A network diagram](@NetworkDiagram){variant=compact}
6
+
7
+ ![Hero image](./hero.jpg)
@@ -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 &lt;name&gt; stays
12
+ escaped in prose, and `a &amp; b` keeps its entity inside code.
@@ -0,0 +1,16 @@
1
+ # Structure
2
+
3
+ > A quoted line.
4
+
5
+ ---
6
+
7
+ A paragraph with math $x^2$ inline.
8
+
9
+ $$E = mc^2$$
10
+
11
+ ```math
12
+ \begin{aligned}
13
+ a &= b + c \\
14
+ d &= e + f
15
+ \end{aligned}
16
+ ```
@@ -0,0 +1,6 @@
1
+ # Escaped pipes
2
+
3
+ | Option | Values | Notes |
4
+ | --- | --- | --- |
5
+ | `mode` | `"a"` \| `"b"` \| `"c"` | A pipe inside a cell must stay escaped |
6
+ | `flag` | `true` | Ordinary cell |
@@ -0,0 +1,6 @@
1
+ # Tables
2
+
3
+ | Field | Meaning |
4
+ | --- | --- |
5
+ | `path` | A local file path |
6
+ | `url` | A remote URL |
@@ -44,7 +44,12 @@ describe('Inline Content Serialization', () => {
44
44
  marks: [{ type: 'italic' }, { type: 'bold' }],
45
45
  },
46
46
  ]
47
- expect(serializeInlineContent(content)).toBe('**bold *****then italic***')
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: { class: 'highlight' } }],
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: { class: 'callout bold' } }],
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: { class: 'highlight', id: 'glossary-term' } }],
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: { class: 'info', 'data-tooltip': 'More info' } }],
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: { class: 'highlight' } }],
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: { class: 'muted' } }],
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: { class: 'highlight' } }],
300
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
296
301
  },
297
302
  ]
298
303
  expect(serializeInlineContent(content)).toBe(
@@ -156,8 +156,12 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
156
156
  })
157
157
  )
158
158
  )
159
- expect(md).toBe('important') // text preserved, styling dropped
160
- expect(warnings.some((m) => m.includes('highlight'))).toBe(true)
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 legacy standalone "\\n" text node self-heals to a hard break', () => {
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('![](lucide:star)')
288
+ })
289
+
290
+ test('a short-code family uses the compact dash spelling', () => {
291
+ expect(inline({ type: 'UniwebIcon', attrs: { name: 'lu:star' } })).toBe('![](lu-star)')
292
+ })
293
+
294
+ test('the separate library+name form still maps', () => {
295
+ expect(inline({ type: 'UniwebIcon', attrs: { library: 'lu', name: 'star' } })).toBe('![](lu-star)')
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
+ // `![](lucide-star)` 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 ['![](lucide:star)', '![](heroicons2:arrow-right)', '![](lu-star)']) {
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
+ })