返回 slidev
parser.test.ts
根目录 / test / parser.test.ts
1 import type { SlidevConfig, SlidevPreparserExtension } from '../packages/types/src'
2 import { basename, relative, resolve } from 'node:path'
3 import { objectMap, slash } from '@antfu/utils'
4 import fg from 'fast-glob'
5 import { describe, expect, it } from 'vitest'
6 import { extractImagesUsage } from '../packages/parser/src/core'
7 import { getDefaultConfig, load, parse, prettify, resolveConfig, stringify } from '../packages/parser/src/fs'
8
9 function configDiff(v: SlidevConfig) {
10 const defaults = getDefaultConfig()
11 const res: Record<string, any> = {}
12 for (const key of Object.keys(v) as (keyof SlidevConfig)[]) {
13 if (JSON.stringify(v[key]) !== JSON.stringify(defaults[key]))
14 res[key] = v[key]
15 }
16 return res
17 }
18
19 function replaceCRLF(s: string) {
20 return s.replace(/\r\n/g, '\n')
21 }
22
23 describe('md parser', () => {
24 const userRoot = resolve(__dirname, 'fixtures/markdown')
25 const files = fg.sync('*.md', {
26 cwd: userRoot,
27 absolute: true,
28 })
29
30 for (const file of files) {
31 it(basename(file), async () => {
32 const data = await load({ userRoot, roots: [userRoot] }, file)
33
34 expect(stringify(data.entry).trim()).toEqual(replaceCRLF(data.entry.raw.trim()))
35
36 prettify(data.entry)
37
38 // File path tests & convert to relative paths
39 data.markdownFiles = objectMap(data.markdownFiles, (path, md) => {
40 expect(md.filepath).toBe(path)
41 const relativePath = slash(relative(userRoot, path))
42 md.slides.forEach((slide) => {
43 expect(slide.filepath).toBe(path)
44 slide.filepath = relativePath
45 })
46 md.filepath = relativePath
47 return [relativePath, md]
48 })
49
50 expect(data.slides).toMatchSnapshot('slides')
51 expect(configDiff(resolveConfig(data.headmatter, {}))).toMatchSnapshot('config')
52 expect(data.features).toMatchSnapshot('features')
53 })
54 }
55
56 it('parse', async () => {
57 const data = await parse(`
58 a
59
60 ---
61
62 b
63
64 ---
65 layout: z
66 ---
67 c
68 ----
69 d
70 ----
71 e
72
73 ---
74
75 f
76
77 `, 'file.md')
78 expect(data.slides.map(i => i.content.trim()))
79 .toEqual(Array.from('abcdef'))
80 expect(data.slides[2].frontmatter)
81 .toEqual({ layout: 'z' })
82 expect(data.slides[3].frontmatter)
83 .toEqual({ })
84 })
85
86 it('parse section matter', async () => {
87 const data = await parse(`
88 a
89
90 ---
91
92 b
93
94 ---section2
95 layout: z
96 ---
97 c
98 ---- section 3
99 d
100 ---- section-4
101 e
102
103 ---
104
105 f
106
107 `, 'file.md')
108 expect(data.slides.map(i => i.content.trim()))
109 .toEqual(Array.from('abcdef'))
110 expect(data.slides[2].frontmatter)
111 .toEqual({ layout: 'z' })
112 expect(data.slides[3].frontmatter)
113 .toEqual({ })
114 })
115
116 it('does not take the slide title from a heading inside a code block', async () => {
117 const data = await parse([
118 '```bash',
119 '# Install the CLI',
120 'npm i -g @slidev/cli',
121 '```',
122 '',
123 '## Getting started',
124 '',
125 '---',
126 '',
127 '```bash',
128 '# Only a code comment',
129 '```',
130 '',
131 ].join('\n'), 'file.md')
132
133 expect(data.slides[0].title).toBe('Getting started')
134 expect(data.slides[0].level).toBe(2)
135 expect(data.slides[1].title).toBe(undefined)
136 })
137
138 it('ignores slide separators inside HTML comments', async () => {
139 const data = await parse(`---
140 src: ./pages/one.md
141 ---
142
143 <!--
144 ---
145 src: ./pages/two.md
146 ---
147 -->
148
149 ---
150 src: ./pages/three.md
151 ---
152 `, 'slides.md')
153
154 expect(data.slides).toHaveLength(2)
155 expect(data.slides.map(slide => slide.frontmatter.src))
156 .toEqual(['./pages/one.md', './pages/three.md'])
157 })
158
159 it('detects slide separator even when comment opens on the same line', async () => {
160 const data = await parse(`a
161
162 ----<!--
163 hidden
164 -->
165
166 b
167 `, 'file.md')
168
169 expect(data.slides).toHaveLength(2)
170 expect(data.slides.map(s => s.content.trim())).toEqual(['a', 'hidden\n-->\n\nb'])
171 })
172
173 async function parseWithExtension(
174 src: string,
175 transformRawLines: (lines: string[]) => void | Promise<void> = () => {},
176 more = {},
177 moreExts: SlidevPreparserExtension[] = [],
178 ) {
179 return await parse(
180 src,
181 'file.md',
182 [{ transformRawLines, ...more }, ...moreExts] as any,
183 )
184 }
185
186 it('parse with-extension replace', async () => {
187 const data = await parseWithExtension(`---
188 ga: bu
189 ---
190 a @@v@@
191
192 ---
193
194 b
195 @@v@@
196
197 @@v@@ = @@v@@
198 `, (lines) => {
199 for (const i in lines)
200 lines[i] = lines[i].replace(/@@v@@/g, 'thing')
201 })
202
203 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
204 .toEqual('a thing/b%thing%%thing = thing')
205 })
206
207 it('parse with-extension custom-separator', async () => {
208 const data = await parseWithExtension(`---
209 ga: bu
210 ---
211 a @@v@@
212
213 SEPARATOR
214
215 b
216 @@v@@
217
218 @@v@@ = @@v@@
219 `, (lines) => {
220 for (const i in lines)
221 lines[i] = lines[i].replace(/^SEPARATOR$/g, '---')
222 })
223
224 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
225 .toEqual('a @@v@@/b%@@v@@%%@@v@@ = @@v@@')
226 })
227
228 it('parse with-extension eg-easy-cover', async () => {
229 function cov(i: string, more = '.jpg') {
230 return `---
231 layout: cover
232 background: ${i}${more}
233 ---
234
235 `
236 }
237 const data = await parseWithExtension(`@cov 1.jpg
238 @cov 2.jpg
239 # 2
240 ---
241
242 # 3
243 @cov 4.jpg
244 `, (lines) => {
245 let i = 0
246 while (i < lines.length) {
247 if (lines[i].startsWith('@cov ')) {
248 const repl = [...cov(lines[i].substring(5), '').split('\n')]
249 lines.splice(i, 1, ...repl)
250 }
251 i++
252 }
253 })
254
255 expect(data.slides.map(s => s.content.trim().replace(/\n/g, '%')).join('/'))
256 .toEqual('/# 2/# 3/'.replace(/\n/g, '%'))
257 expect(data.slides[0].frontmatter)
258 .toEqual({ layout: 'cover', background: '1.jpg' })
259 expect(data.slides[1].frontmatter)
260 .toEqual({ layout: 'cover', background: '2.jpg' })
261 expect(data.slides[3].frontmatter)
262 .toEqual({ layout: 'cover', background: '4.jpg' })
263 })
264
265 it('parse with-extension sequence', async () => {
266 const data = await parseWithExtension(`
267 a..A
268 a.a.A.A
269 .a.A.
270 `, undefined, {}, [{
271 name: 'test',
272 transformRawLines(lines: string[]) {
273 for (const i in lines)
274 lines[i] = lines[i].replace(/A/g, 'B').replace(/a/g, 'A')
275 },
276 }, {
277 name: 'test',
278 transformRawLines(lines: string[]) {
279 for (const i in lines)
280 lines[i] = lines[i].replace(/A/g, 'C')
281 },
282 }])
283 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
284 .toEqual('C..B%C.C.B.B%.C.B.')
285 })
286
287 // Generate cartesian product of given iterables:
288 function* cartesian(...all: any[]): Generator<any[], void, void> {
289 const [head, ...tail] = all
290 const remainder = tail.length ? cartesian(...tail) : [[]]
291 for (const r of remainder) {
292 for (const h of head)
293 yield [h, ...r]
294 }
295 }
296 const B = [0, 1]
297 const Bs = [B, B, B, B, B, B]
298 const bNames = '_swScCF'
299
300 for (const desc of cartesian(...Bs)) {
301 const [withSlideBefore, withFrontmatter, withSlideAfter, prependContent, appendContent, addFrontmatter] = desc
302 it(`parse with-extension wrap ${desc.map((b, i) => bNames[b * (i + 1)]).join('')}`, async () => {
303 const code = [
304 withSlideBefore
305 ? [
306 '.',
307 '',
308 '---',
309 ]
310 : [],
311 (!withSlideBefore && withFrontmatter)
312 ? [
313 '---',
314 ]
315 : [],
316 withFrontmatter
317 ? [
318 'm: M',
319 'n: N',
320 '---',
321 ]
322 : [],
323 '',
324 'ccc',
325 '@a',
326 '@b',
327 'ddd',
328 '',
329 withSlideAfter
330 ? [
331 '',
332 '---',
333 '',
334 '..',
335 ]
336 : [],
337 ].flat().join('\n')
338
339 const data = await parseWithExtension(
340 code,
341 undefined,
342 {
343 transformSlide(content: string, frontmatter: any) {
344 const lines = content.split('\n')
345 let i = 0
346 let appendBeforeCount = 0
347 let appendAfterCount = 0
348 while (i < lines.length) {
349 const l = lines[i]
350 if (l.startsWith('@')) {
351 const t = l.substring(1)
352 lines.splice(i, 1)
353 if (prependContent)
354 lines.splice(appendBeforeCount++, 0, `<${t}>`)
355 if (appendContent)
356 lines.splice(lines.length - appendAfterCount++, 0, `</${t}>`)
357 if (addFrontmatter)
358 frontmatter[`add${t}`] = 'add'
359 i--
360 }
361 i++
362 }
363 return lines.join('\n')
364 },
365 },
366 )
367
368 function project(s: string) {
369 // like the trim in other tests, the goal is not to test newlines here
370 return s.replace(/%{2,}/g, '%')
371 }
372 expect(project(data.slides.map(i => i.content.replace(/\n/g, '%')).join('/')))
373 .toEqual(project([
374 ...withSlideBefore ? ['./'] : [],
375 ...prependContent ? ['<a>%<b>%'] : [],
376 'ccc%ddd',
377 ...appendContent ? ['%</b>%</a>'] : [],
378 ...withSlideAfter ? ['/..'] : [],
379 ].join('')))
380
381 if (withFrontmatter || addFrontmatter) {
382 expect(data.slides[withSlideBefore ? 1 : 0].frontmatter)
383 .toEqual({
384 ...withFrontmatter ? { m: 'M', n: 'N' } : {},
385 ...addFrontmatter ? { adda: 'add', addb: 'add' } : {},
386 })
387 }
388 })
389 }
390
391 describe('extractImagesUsage', () => {
392 it('extracts from frontmatter image key', () => {
393 const images = extractImagesUsage('', { image: '/path/to/image.png' })
394 expect(images).toEqual(['/path/to/image.png'])
395 })
396
397 it('extracts from frontmatter backgroundImage key', () => {
398 const images = extractImagesUsage('', { backgroundImage: 'https://example.com/bg.jpg' })
399 expect(images).toEqual(['https://example.com/bg.jpg'])
400 })
401
402 it('extracts from frontmatter background key with image extension', () => {
403 const images = extractImagesUsage('', { background: '/assets/bg.webp' })
404 expect(images).toEqual(['/assets/bg.webp'])
405 })
406
407 it('extracts from frontmatter background key with URL', () => {
408 const images = extractImagesUsage('', { background: 'https://example.com/image.png' })
409 expect(images).toEqual(['https://example.com/image.png'])
410 })
411
412 it('ignores frontmatter background key without image extension or URL', () => {
413 const images = extractImagesUsage('', { background: 'gradient-to-r' })
414 expect(images).toEqual([])
415 })
416
417 it('ignores data URLs in frontmatter', () => {
418 const images = extractImagesUsage('', { image: 'data:image/png;base64,abc123' })
419 expect(images).toEqual([])
420 })
421
422 it('extracts markdown image syntax', () => {
423 const content = '![alt text](/images/photo.jpg)'
424 const images = extractImagesUsage(content, {})
425 expect(images).toEqual(['/images/photo.jpg'])
426 })
427
428 it('extracts multiple markdown images', () => {
429 const content = `
430 ![first](/img1.png)
431 Some text
432 ![second](https://example.com/img2.jpg)
433 ![third](./relative/path.svg)
434 `
435 const images = extractImagesUsage(content, {})
436 expect(images).toEqual(['/img1.png', 'https://example.com/img2.jpg', './relative/path.svg'])
437 })
438
439 it('ignores the optional title in markdown images', () => {
440 const content = `![alt](/images/photo.jpg "A photo")
441 ![alt](/images/other.png 'Another')`
442 const images = extractImagesUsage(content, {})
443 expect(images).toEqual(['/images/photo.jpg', '/images/other.png'])
444 })
445
446 it('unwraps angle-bracketed markdown image destinations', () => {
447 const content = '![alt](</images/my photo.jpg>)'
448 const images = extractImagesUsage(content, {})
449 expect(images).toEqual(['/images/my photo.jpg'])
450 })
451
452 it('ignores data URLs in markdown images', () => {
453 const content = '![inline](data:image/png;base64,abc123)'
454 const images = extractImagesUsage(content, {})
455 expect(images).toEqual([])
456 })
457
458 it('extracts Vue component src prop', () => {
459 const content = '<img src="/assets/logo.png" />'
460 const images = extractImagesUsage(content, {})
461 expect(images).toEqual(['/assets/logo.png'])
462 })
463
464 it('extracts Vue component image prop', () => {
465 const content = '<MyImage image="/path/to/image.jpg" />'
466 const images = extractImagesUsage(content, {})
467 expect(images).toEqual(['/path/to/image.jpg'])
468 })
469
470 it('ignores Vue props without image extensions', () => {
471 const content = '<div src="/some/path" />'
472 const images = extractImagesUsage(content, {})
473 expect(images).toEqual([])
474 })
475
476 it('ignores Vue props with template expressions', () => {
477 const content = '<img src="{{imagePath}}.png" />'
478 const images = extractImagesUsage(content, {})
479 expect(images).toEqual([])
480 })
481
482 it('extracts Vue bound props with string literals', () => {
483 const content = `<img :src="'/static/image.png'" />`
484 const images = extractImagesUsage(content, {})
485 expect(images).toEqual(['/static/image.png'])
486 })
487
488 it('extracts CSS url()', () => {
489 const content = `
490 <div style="background: url(/bg/image.png)">
491 <style>
492 .class { background-image: url('/another/image.jpg'); }
493 </style>
494 `
495 const images = extractImagesUsage(content, {})
496 expect(images).toEqual(['/bg/image.png', '/another/image.jpg'])
497 })
498
499 it('ignores CSS url() without image extensions', () => {
500 const content = `<div style="background: url(/fonts/font.woff2)">`
501 const images = extractImagesUsage(content, {})
502 expect(images).toEqual([])
503 })
504
505 it('strips code blocks to avoid false positives', () => {
506 const content = `
507 Some content
508 ![real image](/real.png)
509
510 \`\`\`markdown
511 ![fake image](/fake.png)
512 <img src="/also-fake.jpg" />
513 \`\`\`
514
515 ![another real](/real2.jpg)
516 `
517 const images = extractImagesUsage(content, {})
518 expect(images).toEqual(['/real.png', '/real2.jpg'])
519 })
520
521 it('handles multiple sources combined', () => {
522 const content = `
523 # Title
524 ![markdown](/md.png)
525 <img src="/vue.jpg" />
526 <div style="background: url(/css.webp)">
527 `
528 const frontmatter = {
529 image: '/frontmatter.png',
530 background: 'https://example.com/bg.svg',
531 }
532 const images = extractImagesUsage(content, frontmatter)
533 expect(images).toContain('/frontmatter.png')
534 expect(images).toContain('https://example.com/bg.svg')
535 expect(images).toContain('/md.png')
536 expect(images).toContain('/vue.jpg')
537 expect(images).toContain('/css.webp')
538 expect(images).toHaveLength(5)
539 })
540
541 it('deduplicates identical URLs', () => {
542 const content = `
543 ![img](/same.png)
544 ![img2](/same.png)
545 <img src="/same.png" />
546 `
547 const images = extractImagesUsage(content, {})
548 expect(images).toEqual(['/same.png'])
549 })
550
551 it('trims whitespace from extracted URLs', () => {
552 const content = '![img]( /path/with/spaces.png )'
553 const images = extractImagesUsage(content, {})
554 expect(images).toEqual(['/path/with/spaces.png'])
555 })
556
557 it('handles various image extensions', () => {
558 const content = `
559 ![png](/img.png)
560 ![jpg](/img.jpg)
561 ![jpeg](/img.jpeg)
562 ![gif](/img.gif)
563 ![svg](/img.svg)
564 ![webp](/img.webp)
565 ![avif](/img.avif)
566 ![ico](/img.ico)
567 ![bmp](/img.bmp)
568 ![tiff](/img.tiff)
569 `
570 const images = extractImagesUsage(content, {})
571 expect(images).toHaveLength(10)
572 })
573
574 it('handles case-insensitive image extensions', () => {
575 const content = `
576 <img src="/image.PNG" />
577 <img src="/image.JpG" />
578 <img src="/image.WEBP" />
579 `
580 const images = extractImagesUsage(content, {})
581 expect(images).toEqual(['/image.PNG', '/image.JpG', '/image.WEBP'])
582 })
583 })
584
585 describe('resolveConfig drawings', () => {
586 it('applies drawings defaults from the theme', () => {
587 const config = resolveConfig({}, { defaults: { drawings: { presenterOnly: true } } } as any)
588 expect(config.drawings).toEqual({
589 enabled: true,
590 persist: false,
591 presenterOnly: true,
592 syncAll: true,
593 })
594 })
595
596 it('applies drawings from the nested `config` headmatter key', () => {
597 const config = resolveConfig({ config: { drawings: { syncAll: false } } })
598 expect(config.drawings.syncAll).toBe(false)
599 })
600
601 it('lets headmatter override the theme defaults', () => {
602 const config = resolveConfig(
603 { drawings: { enabled: false } },
604 { defaults: { drawings: { enabled: true, presenterOnly: true } } } as any,
605 )
606 expect(config.drawings.enabled).toBe(false)
607 expect(config.drawings.presenterOnly).toBe(true)
608 })
609 })
610
611 it('detects circular src imports without overflowing', async () => {
612 const root = resolve(__dirname, 'fixtures/markdown/circular')
613 const data = await load({ userRoot: root, roots: [root] }, resolve(root, 'a.md'))
614 // Must return (not hang / overflow) and record a circular-import diagnostic
615 const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
616 expect(errors.some(e => /circular/i.test(e.message))).toBe(true)
617 })
618
619 it('records an error when a src: import selects a slide below the first one', async () => {
620 const root = resolve(__dirname, 'fixtures/markdown/out-of-range')
621 const data = await load({ userRoot: root, roots: [root] }, resolve(root, 'entry.md'))
622 const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
623 expect(errors.map(e => e.message)).toEqual([
624 expect.stringContaining('Slide 0 does not exist'),
625 ])
626 })
627
628 it('records an error when a src: import escapes the allowed roots', async () => {
629 const root = resolve(__dirname, 'fixtures/markdown/escaping/root')
630 const data = await load({ userRoot: root, roots: [root], allowedRoots: [root] }, resolve(root, 'entry.md'))
631 const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
632 expect(errors.some(e => /escapes the project root/i.test(e.message))).toBe(true)
633 })
634 })
635
635 lines TYPESCRIPT