@uniweb/content-writer 0.2.7 → 0.2.8

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.
@@ -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,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', () => {
@@ -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
+ })
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Source-fidelity round trip: does `source.md` survive reader → writer as the
3
+ * *same text*, not merely as the same ProseMirror structure?
4
+ *
5
+ * `roundtrip.test.js` already asserts the structural fixed point (reparse the
6
+ * output, compare documents). That test cannot catch a whole class of defect:
7
+ * the file-lane bug where the second pass is stable but the *first* pass has
8
+ * already rewritten the author's source. D1 was exactly that shape — a code
9
+ * span holding `<name>` re-serialized as `&lt;name&gt;`, which reparses to
10
+ * the same document forever after.
11
+ *
12
+ * That matters beyond tidiness. `content-writer` is what the editor uses to
13
+ * sync ProseMirror back to markdown files, so anything this test tolerates is
14
+ * something the editor will eventually write into a developer's repository.
15
+ * The app lane has no source file to diff against, so this is the only place
16
+ * the codec can be checked at all.
17
+ *
18
+ * Known defects are an explicit allowlist below. They assert the *current*
19
+ * wrong output, so fixing one fails this test and forces the entry to be
20
+ * removed — the allowlist can only shrink.
21
+ */
22
+
23
+ import { readFileSync, readdirSync } from 'node:fs'
24
+ import { join, dirname } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { markdownToProseMirror } from '@uniweb/content-reader'
27
+ import { proseMirrorToMarkdown } from '../src/index.js'
28
+
29
+ const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'roundtrip')
30
+
31
+ /**
32
+ * Cosmetic normalization applied to BOTH sides before comparing.
33
+ *
34
+ * Declared, not incidental — each entry is a difference we have decided is
35
+ * not a defect. Keep this list short; anything added here is fidelity given
36
+ * up permanently.
37
+ */
38
+ function normalize(markdown) {
39
+ return (
40
+ markdown
41
+ // Table cell padding and delimiter width. Markdown tables are
42
+ // whitespace-insensitive between pipes, so `|---|---|` and
43
+ // `| --- | --- |` are the same table — as are `| |` and `| |`.
44
+ .replace(/^\|.*\|$/gm, normalizeTableRow)
45
+ // Hard breaks have two spellings. Two trailing spaces is the classic
46
+ // one; the serializer deliberately emits a backslash instead, because it
47
+ // survives editors and pre-commit hooks that strip trailing whitespace.
48
+ // Fold both to the backslash so a real hard break is not read as a diff
49
+ // — and so stripping trailing space below cannot silently delete one.
50
+ .replace(/[ \t]{2,}$/gm, '\\')
51
+ // Remaining trailing whitespace, and the final newline.
52
+ .replace(/[ \t]+$/gm, '')
53
+ .trim()
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Canonicalize one table row: trim every cell, collapse delimiter runs.
59
+ * @param {string} line
60
+ * @returns {string}
61
+ */
62
+ function normalizeTableRow(line) {
63
+ const cells = line.slice(1, -1).split('|').map(cell => {
64
+ const trimmed = cell.trim()
65
+ return /^:?-{2,}:?$/.test(trimmed) ? trimmed.replace(/-+/, '---') : trimmed
66
+ })
67
+ return `| ${cells.join(' | ')} |`
68
+ }
69
+
70
+ /**
71
+ * Intentional canonicalizations: the projection differs from the source and
72
+ * that is the designed behavior, not a bug. Separate from the defect list
73
+ * below because these never "get fixed" — they are the serializer's chosen
74
+ * spelling, and the entry exists so the choice stays deliberate.
75
+ */
76
+ const CANONICAL_FORMS = {
77
+ 'canonical-math.md': {
78
+ reason: 'single-line display math canonicalizes to the compact $$…$$ spelling',
79
+ // `serializeMathDisplay` documents this: compact for one line, fenced
80
+ // ```math for multi-line. Both read back as the same node.
81
+ current: '$$E = mc^2$$'
82
+ },
83
+ }
84
+
85
+ /**
86
+ * Defects known to corrupt source today, each with the output it currently
87
+ * produces. Fixing one makes its assertion fail, which is the point: the entry
88
+ * then has to come out, so this list can only shrink.
89
+ */
90
+ const KNOWN_DEFECTS = {
91
+ // Empty, and that is the point of the mechanism rather than a claim of
92
+ // perfection. Four entries lived here and each left by being fixed:
93
+ //
94
+ // D1 — a code span's `<name>` written back as `&lt;name&gt;`
95
+ // D2 — bold mishandled around a code span (and dropped outright when it
96
+ // wrapped only one)
97
+ // D5 — a bold link re-nested to [**x**](url)
98
+ // D4 — a tagged data block's YAML rewritten as JSON
99
+ // D3 — a loose list re-serialized tight
100
+ //
101
+ // D2 and D5 turned out to be one bug (the serializer ignoring `node.marks`
102
+ // order); D1, D3 and D4 were all the same shape as each other — the reader
103
+ // dropping something the writer then had to guess at.
104
+ //
105
+ // Add an entry when you find a defect you are not fixing today, with the
106
+ // wrong output it currently produces. Fixing it will break the entry, which
107
+ // is what forces the removal: this list can only shrink.
108
+ }
109
+
110
+ const fixtures = readdirSync(FIXTURE_DIR).filter(f => f.endsWith('.md')).sort()
111
+
112
+ describe('source-fidelity round trip', () => {
113
+ test('the fixture corpus is not empty', () => {
114
+ expect(fixtures.length).toBeGreaterThan(0)
115
+ })
116
+
117
+ for (const name of fixtures) {
118
+ const source = readFileSync(join(FIXTURE_DIR, name), 'utf8')
119
+ const canonical = CANONICAL_FORMS[name]
120
+ const known = KNOWN_DEFECTS[name]
121
+
122
+ if (canonical) {
123
+ test(`${name} — canonicalized: ${canonical.reason}`, () => {
124
+ const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
125
+ expect(normalize(projected)).toBe(normalize(canonical.current))
126
+ })
127
+ continue
128
+ }
129
+
130
+ if (known) {
131
+ test(`${name} — known defect: ${known.defect}`, () => {
132
+ const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
133
+ expect(normalize(projected)).toBe(normalize(known.current))
134
+ // Guard the guard: if this ever equals the source, the defect is
135
+ // fixed and the allowlist entry must be deleted.
136
+ expect(normalize(projected)).not.toBe(normalize(source))
137
+ })
138
+ continue
139
+ }
140
+
141
+ test(`${name} round-trips to its source`, () => {
142
+ const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
143
+ expect(normalize(projected)).toBe(normalize(source))
144
+ })
145
+ }
146
+
147
+ test('every allowlist entry names a real fixture', () => {
148
+ for (const name of [...Object.keys(KNOWN_DEFECTS), ...Object.keys(CANONICAL_FORMS)]) {
149
+ expect(fixtures).toContain(name)
150
+ }
151
+ })
152
+ })
153
+
154
+ describe('structural fixed point holds for every fixture', () => {
155
+ // Even a fixture with a known source-level defect must be stable on the
156
+ // second pass — otherwise the defect compounds on each sync.
157
+ for (const name of fixtures) {
158
+ test(`${name} is stable on re-serialization`, () => {
159
+ const source = readFileSync(join(FIXTURE_DIR, name), 'utf8')
160
+ const once = proseMirrorToMarkdown(markdownToProseMirror(source))
161
+ const twice = proseMirrorToMarkdown(markdownToProseMirror(once))
162
+ expect(twice).toBe(once)
163
+ })
164
+ }
165
+ })
166
+
167
+ describe('list looseness', () => {
168
+ const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
169
+
170
+ test('a tight list stays tight', () => {
171
+ expect(rt('- one\n- two')).toBe('- one\n- two')
172
+ })
173
+
174
+ test('a loose list keeps its blank lines', () => {
175
+ expect(rt('- one\n\n- two')).toBe('- one\n\n- two')
176
+ })
177
+
178
+ test('looseness applies inside an item too, not just between items', () => {
179
+ const source = '- parent\n\n - child\n - sibling\n\n- next'
180
+ expect(rt(source)).toBe(source)
181
+ })
182
+
183
+ test('ordered lists carry looseness alongside start', () => {
184
+ expect(rt('1. a\n\n2. b')).toBe('1. a\n\n2. b')
185
+ })
186
+
187
+ test('a nested list is loose independently of its parent', () => {
188
+ expect(rt('- parent\n - child\n - sibling\n- next')).toBe('- parent\n - child\n - sibling\n- next')
189
+ })
190
+
191
+ test('a list node with no loose flag serializes tight', () => {
192
+ // Documents written before the reader recorded looseness.
193
+ const doc = {
194
+ type: 'doc',
195
+ content: [{
196
+ type: 'bulletList',
197
+ content: ['one', 'two'].map(t => ({
198
+ type: 'listItem',
199
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }]
200
+ }))
201
+ }]
202
+ }
203
+ expect(proseMirrorToMarkdown(doc).trim()).toBe('- one\n- two')
204
+ })
205
+ })
206
+
207
+ describe('blocks inside list items', () => {
208
+ const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
209
+ const itemChildren = md => markdownToProseMirror(md).content[0].content[0].content.map(n => n.type)
210
+
211
+ test('a fenced block under a bullet stays a code block', () => {
212
+ // It used to be lexed as inline and folded into the item's paragraph,
213
+ // losing the fence, the language and the line breaks — in the rendered
214
+ // page, not only on a round trip.
215
+ expect(itemChildren('- Use it:\n ```jsx\n const x = 1\n ```')).toEqual(['paragraph', 'codeBlock'])
216
+ })
217
+
218
+ test('a blockquote under a bullet stays a blockquote', () => {
219
+ expect(itemChildren('- Item\n > quoted')).toEqual(['paragraph', 'blockquote'])
220
+ })
221
+
222
+ test('a nested list is still a nested list', () => {
223
+ expect(itemChildren('- parent\n - child')).toEqual(['paragraph', 'bulletList'])
224
+ })
225
+
226
+ test('multiple paragraphs in one item survive', () => {
227
+ expect(itemChildren('- First\n\n Second\n\n- Next')).toEqual(['paragraph', 'paragraph'])
228
+ })
229
+
230
+ test('a code block round-trips inside a tight list', () => {
231
+ const source = '- Use the hook:\n ```jsx\n const x = 1\n ```\n- Next item'
232
+ expect(rt(source)).toBe(source)
233
+ })
234
+
235
+ test('a code block round-trips inside a loose list', () => {
236
+ const source = '- Use the hook:\n\n ```jsx\n const x = 1\n ```\n\n- Next item'
237
+ expect(rt(source)).toBe(source)
238
+ })
239
+
240
+ test('an ordered item indents its block to the wider bullet', () => {
241
+ const source = '1. Step one\n ```sh\n run it\n ```\n2. Step two'
242
+ expect(rt(source)).toBe(source)
243
+ })
244
+ })
@@ -349,18 +349,28 @@ describe('Code Blocks', () => {
349
349
  expect(serializeDoc(doc)).toBe('```\nline 1\n\nline 2\n```')
350
350
  })
351
351
 
352
- test('serializes data block', () => {
353
- const doc = {
354
- type: 'doc',
355
- content: [
356
- {
357
- type: 'dataBlock',
358
- attrs: { tag: 'nav-links', data: [{ label: 'Home' }] },
359
- },
360
- ],
361
- }
362
- const expected = '```json:nav-links\n[\n {\n "label": "Home"\n }\n]\n```'
363
- expect(serializeDoc(doc)).toBe(expected)
352
+ const dataBlockDoc = attrs => ({ type: 'doc', content: [{ type: 'dataBlock', attrs }] })
353
+
354
+ test('serializes a data block as JSON when that is what the author wrote', () => {
355
+ const doc = dataBlockDoc({ tag: 'nav-links', language: 'json', data: [{ label: 'Home' }] })
356
+ expect(serializeDoc(doc)).toBe('```json:nav-links\n[\n {\n "label": "Home"\n }\n]\n```')
357
+ })
358
+
359
+ test('serializes a data block as YAML when that is what the author wrote', () => {
360
+ const doc = dataBlockDoc({ tag: 'nav-links', language: 'yaml', data: [{ label: 'Home' }] })
361
+ expect(serializeDoc(doc)).toBe('```yaml:nav-links\n- label: Home\n```')
362
+ })
363
+
364
+ test('echoes back the `yml` spelling rather than normalizing it', () => {
365
+ const doc = dataBlockDoc({ tag: 'cfg', language: 'yml', data: { key: 'value' } })
366
+ expect(serializeDoc(doc)).toBe('```yml:cfg\nkey: value\n```')
367
+ })
368
+
369
+ test('falls back to YAML when no language was recorded', () => {
370
+ // A node from before the reader recorded `language`. This used to emit
371
+ // JSON unconditionally, which rewrote every YAML block an author wrote.
372
+ const doc = dataBlockDoc({ tag: 'nav-links', data: [{ label: 'Home' }] })
373
+ expect(serializeDoc(doc)).toBe('```yaml:nav-links\n- label: Home\n```')
364
374
  })
365
375
  })
366
376