返回 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('ignores slide separators inside HTML comments', async () => {
117 const data = await parse(`---
118 src: ./pages/one.md
119 ---
120
121 <!--
122 ---
123 src: ./pages/two.md
124 ---
125 -->
126
127 ---
128 src: ./pages/three.md
129 ---
130 `, 'slides.md')
131
132 expect(data.slides).toHaveLength(2)
133 expect(data.slides.map(slide => slide.frontmatter.src))
134 .toEqual(['./pages/one.md', './pages/three.md'])
135 })
136
137 it('detects slide separator even when comment opens on the same line', async () => {
138 const data = await parse(`a
139
140 ----<!--
141 hidden
142 -->
143
144 b
145 `, 'file.md')
146
147 expect(data.slides).toHaveLength(2)
148 expect(data.slides.map(s => s.content.trim())).toEqual(['a', 'hidden\n-->\n\nb'])
149 })
150
151 async function parseWithExtension(
152 src: string,
153 transformRawLines: (lines: string[]) => void | Promise<void> = () => {},
154 more = {},
155 moreExts: SlidevPreparserExtension[] = [],
156 ) {
157 return await parse(
158 src,
159 'file.md',
160 [{ transformRawLines, ...more }, ...moreExts] as any,
161 )
162 }
163
164 it('parse with-extension replace', async () => {
165 const data = await parseWithExtension(`---
166 ga: bu
167 ---
168 a @@v@@
169
170 ---
171
172 b
173 @@v@@
174
175 @@v@@ = @@v@@
176 `, (lines) => {
177 for (const i in lines)
178 lines[i] = lines[i].replace(/@@v@@/g, 'thing')
179 })
180
181 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
182 .toEqual('a thing/b%thing%%thing = thing')
183 })
184
185 it('parse with-extension custom-separator', async () => {
186 const data = await parseWithExtension(`---
187 ga: bu
188 ---
189 a @@v@@
190
191 SEPARATOR
192
193 b
194 @@v@@
195
196 @@v@@ = @@v@@
197 `, (lines) => {
198 for (const i in lines)
199 lines[i] = lines[i].replace(/^SEPARATOR$/g, '---')
200 })
201
202 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
203 .toEqual('a @@v@@/b%@@v@@%%@@v@@ = @@v@@')
204 })
205
206 it('parse with-extension eg-easy-cover', async () => {
207 function cov(i: string, more = '.jpg') {
208 return `---
209 layout: cover
210 background: ${i}${more}
211 ---
212
213 `
214 }
215 const data = await parseWithExtension(`@cov 1.jpg
216 @cov 2.jpg
217 # 2
218 ---
219
220 # 3
221 @cov 4.jpg
222 `, (lines) => {
223 let i = 0
224 while (i < lines.length) {
225 if (lines[i].startsWith('@cov ')) {
226 const repl = [...cov(lines[i].substring(5), '').split('\n')]
227 lines.splice(i, 1, ...repl)
228 }
229 i++
230 }
231 })
232
233 expect(data.slides.map(s => s.content.trim().replace(/\n/g, '%')).join('/'))
234 .toEqual('/# 2/# 3/'.replace(/\n/g, '%'))
235 expect(data.slides[0].frontmatter)
236 .toEqual({ layout: 'cover', background: '1.jpg' })
237 expect(data.slides[1].frontmatter)
238 .toEqual({ layout: 'cover', background: '2.jpg' })
239 expect(data.slides[3].frontmatter)
240 .toEqual({ layout: 'cover', background: '4.jpg' })
241 })
242
243 it('parse with-extension sequence', async () => {
244 const data = await parseWithExtension(`
245 a..A
246 a.a.A.A
247 .a.A.
248 `, undefined, {}, [{
249 name: 'test',
250 transformRawLines(lines: string[]) {
251 for (const i in lines)
252 lines[i] = lines[i].replace(/A/g, 'B').replace(/a/g, 'A')
253 },
254 }, {
255 name: 'test',
256 transformRawLines(lines: string[]) {
257 for (const i in lines)
258 lines[i] = lines[i].replace(/A/g, 'C')
259 },
260 }])
261 expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
262 .toEqual('C..B%C.C.B.B%.C.B.')
263 })
264
265 // Generate cartesian product of given iterables:
266 function* cartesian(...all: any[]): Generator<any[], void, void> {
267 const [head, ...tail] = all
268 const remainder = tail.length ? cartesian(...tail) : [[]]
269 for (const r of remainder) {
270 for (const h of head)
271 yield [h, ...r]
272 }
273 }
274 const B = [0, 1]
275 const Bs = [B, B, B, B, B, B]
276 const bNames = '_swScCF'
277
278 for (const desc of cartesian(...Bs)) {
279 const [withSlideBefore, withFrontmatter, withSlideAfter, prependContent, appendContent, addFrontmatter] = desc
280 it(`parse with-extension wrap ${desc.map((b, i) => bNames[b * (i + 1)]).join('')}`, async () => {
281 const code = [
282 withSlideBefore
283 ? [
284 '.',
285 '',
286 '---',
287 ]
288 : [],
289 (!withSlideBefore && withFrontmatter)
290 ? [
291 '---',
292 ]
293 : [],
294 withFrontmatter
295 ? [
296 'm: M',
297 'n: N',
298 '---',
299 ]
300 : [],
301 '',
302 'ccc',
303 '@a',
304 '@b',
305 'ddd',
306 '',
307 withSlideAfter
308 ? [
309 '',
310 '---',
311 '',
312 '..',
313 ]
314 : [],
315 ].flat().join('\n')
316
317 const data = await parseWithExtension(
318 code,
319 undefined,
320 {
321 transformSlide(content: string, frontmatter: any) {
322 const lines = content.split('\n')
323 let i = 0
324 let appendBeforeCount = 0
325 let appendAfterCount = 0
326 while (i < lines.length) {
327 const l = lines[i]
328 if (l.startsWith('@')) {
329 const t = l.substring(1)
330 lines.splice(i, 1)
331 if (prependContent)
332 lines.splice(appendBeforeCount++, 0, `<${t}>`)
333 if (appendContent)
334 lines.splice(lines.length - appendAfterCount++, 0, `</${t}>`)
335 if (addFrontmatter)
336 frontmatter[`add${t}`] = 'add'
337 i--
338 }
339 i++
340 }
341 return lines.join('\n')
342 },
343 },
344 )
345
346 function project(s: string) {
347 // like the trim in other tests, the goal is not to test newlines here
348 return s.replace(/%{2,}/g, '%')
349 }
350 expect(project(data.slides.map(i => i.content.replace(/\n/g, '%')).join('/')))
351 .toEqual(project([
352 ...withSlideBefore ? ['./'] : [],
353 ...prependContent ? ['<a>%<b>%'] : [],
354 'ccc%ddd',
355 ...appendContent ? ['%</b>%</a>'] : [],
356 ...withSlideAfter ? ['/..'] : [],
357 ].join('')))
358
359 if (withFrontmatter || addFrontmatter) {
360 expect(data.slides[withSlideBefore ? 1 : 0].frontmatter)
361 .toEqual({
362 ...withFrontmatter ? { m: 'M', n: 'N' } : {},
363 ...addFrontmatter ? { adda: 'add', addb: 'add' } : {},
364 })
365 }
366 })
367 }
368
369 describe('extractImagesUsage', () => {
370 it('extracts from frontmatter image key', () => {
371 const images = extractImagesUsage('', { image: '/path/to/image.png' })
372 expect(images).toEqual(['/path/to/image.png'])
373 })
374
375 it('extracts from frontmatter backgroundImage key', () => {
376 const images = extractImagesUsage('', { backgroundImage: 'https://example.com/bg.jpg' })
377 expect(images).toEqual(['https://example.com/bg.jpg'])
378 })
379
380 it('extracts from frontmatter background key with image extension', () => {
381 const images = extractImagesUsage('', { background: '/assets/bg.webp' })
382 expect(images).toEqual(['/assets/bg.webp'])
383 })
384
385 it('extracts from frontmatter background key with URL', () => {
386 const images = extractImagesUsage('', { background: 'https://example.com/image.png' })
387 expect(images).toEqual(['https://example.com/image.png'])
388 })
389
390 it('ignores frontmatter background key without image extension or URL', () => {
391 const images = extractImagesUsage('', { background: 'gradient-to-r' })
392 expect(images).toEqual([])
393 })
394
395 it('ignores data URLs in frontmatter', () => {
396 const images = extractImagesUsage('', { image: 'data:image/png;base64,abc123' })
397 expect(images).toEqual([])
398 })
399
400 it('extracts markdown image syntax', () => {
401 const content = '![alt text](/images/photo.jpg)'
402 const images = extractImagesUsage(content, {})
403 expect(images).toEqual(['/images/photo.jpg'])
404 })
405
406 it('extracts multiple markdown images', () => {
407 const content = `
408 ![first](/img1.png)
409 Some text
410 ![second](https://example.com/img2.jpg)
411 ![third](./relative/path.svg)
412 `
413 const images = extractImagesUsage(content, {})
414 expect(images).toEqual(['/img1.png', 'https://example.com/img2.jpg', './relative/path.svg'])
415 })
416
417 it('ignores data URLs in markdown images', () => {
418 const content = '![inline](data:image/png;base64,abc123)'
419 const images = extractImagesUsage(content, {})
420 expect(images).toEqual([])
421 })
422
423 it('extracts Vue component src prop', () => {
424 const content = '<img src="/assets/logo.png" />'
425 const images = extractImagesUsage(content, {})
426 expect(images).toEqual(['/assets/logo.png'])
427 })
428
429 it('extracts Vue component image prop', () => {
430 const content = '<MyImage image="/path/to/image.jpg" />'
431 const images = extractImagesUsage(content, {})
432 expect(images).toEqual(['/path/to/image.jpg'])
433 })
434
435 it('ignores Vue props without image extensions', () => {
436 const content = '<div src="/some/path" />'
437 const images = extractImagesUsage(content, {})
438 expect(images).toEqual([])
439 })
440
441 it('ignores Vue props with template expressions', () => {
442 const content = '<img src="{{imagePath}}.png" />'
443 const images = extractImagesUsage(content, {})
444 expect(images).toEqual([])
445 })
446
447 it('extracts Vue bound props with string literals', () => {
448 const content = `<img :src="'/static/image.png'" />`
449 const images = extractImagesUsage(content, {})
450 expect(images).toEqual(['/static/image.png'])
451 })
452
453 it('extracts CSS url()', () => {
454 const content = `
455 <div style="background: url(/bg/image.png)">
456 <style>
457 .class { background-image: url('/another/image.jpg'); }
458 </style>
459 `
460 const images = extractImagesUsage(content, {})
461 expect(images).toEqual(['/bg/image.png', '/another/image.jpg'])
462 })
463
464 it('ignores CSS url() without image extensions', () => {
465 const content = `<div style="background: url(/fonts/font.woff2)">`
466 const images = extractImagesUsage(content, {})
467 expect(images).toEqual([])
468 })
469
470 it('strips code blocks to avoid false positives', () => {
471 const content = `
472 Some content
473 ![real image](/real.png)
474
475 \`\`\`markdown
476 ![fake image](/fake.png)
477 <img src="/also-fake.jpg" />
478 \`\`\`
479
480 ![another real](/real2.jpg)
481 `
482 const images = extractImagesUsage(content, {})
483 expect(images).toEqual(['/real.png', '/real2.jpg'])
484 })
485
486 it('handles multiple sources combined', () => {
487 const content = `
488 # Title
489 ![markdown](/md.png)
490 <img src="/vue.jpg" />
491 <div style="background: url(/css.webp)">
492 `
493 const frontmatter = {
494 image: '/frontmatter.png',
495 background: 'https://example.com/bg.svg',
496 }
497 const images = extractImagesUsage(content, frontmatter)
498 expect(images).toContain('/frontmatter.png')
499 expect(images).toContain('https://example.com/bg.svg')
500 expect(images).toContain('/md.png')
501 expect(images).toContain('/vue.jpg')
502 expect(images).toContain('/css.webp')
503 expect(images).toHaveLength(5)
504 })
505
506 it('deduplicates identical URLs', () => {
507 const content = `
508 ![img](/same.png)
509 ![img2](/same.png)
510 <img src="/same.png" />
511 `
512 const images = extractImagesUsage(content, {})
513 expect(images).toEqual(['/same.png'])
514 })
515
516 it('trims whitespace from extracted URLs', () => {
517 const content = '![img]( /path/with/spaces.png )'
518 const images = extractImagesUsage(content, {})
519 expect(images).toEqual(['/path/with/spaces.png'])
520 })
521
522 it('handles various image extensions', () => {
523 const content = `
524 ![png](/img.png)
525 ![jpg](/img.jpg)
526 ![jpeg](/img.jpeg)
527 ![gif](/img.gif)
528 ![svg](/img.svg)
529 ![webp](/img.webp)
530 ![avif](/img.avif)
531 ![ico](/img.ico)
532 ![bmp](/img.bmp)
533 ![tiff](/img.tiff)
534 `
535 const images = extractImagesUsage(content, {})
536 expect(images).toHaveLength(10)
537 })
538
539 it('handles case-insensitive image extensions', () => {
540 const content = `
541 <img src="/image.PNG" />
542 <img src="/image.JpG" />
543 <img src="/image.WEBP" />
544 `
545 const images = extractImagesUsage(content, {})
546 expect(images).toEqual(['/image.PNG', '/image.JpG', '/image.WEBP'])
547 })
548 })
549
550 it('detects circular src imports without overflowing', async () => {
551 const root = resolve(__dirname, 'fixtures/markdown/circular')
552 const data = await load({ userRoot: root, roots: [root] }, resolve(root, 'a.md'))
553 // Must return (not hang / overflow) and record a circular-import diagnostic
554 const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
555 expect(errors.some(e => /circular/i.test(e.message))).toBe(true)
556 })
557
558 it('records an error when a src: import escapes the allowed roots', async () => {
559 const root = resolve(__dirname, 'fixtures/markdown/escaping/root')
560 const data = await load({ userRoot: root, roots: [root], allowedRoots: [root] }, resolve(root, 'entry.md'))
561 const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
562 expect(errors.some(e => /escapes the project root/i.test(e.message))).toBe(true)
563 })
564 })
565
565 lines TYPESCRIPT