mirror of
https://github.com/slidevjs/slidev.git
synced 2026-09-14 20:46:32 +08:00
565 lines
15 KiB
TypeScript
565 lines
15 KiB
TypeScript
import type { SlidevConfig, SlidevPreparserExtension } from '../packages/types/src'
|
|
import { basename, relative, resolve } from 'node:path'
|
|
import { objectMap, slash } from '@antfu/utils'
|
|
import fg from 'fast-glob'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { extractImagesUsage } from '../packages/parser/src/core'
|
|
import { getDefaultConfig, load, parse, prettify, resolveConfig, stringify } from '../packages/parser/src/fs'
|
|
|
|
function configDiff(v: SlidevConfig) {
|
|
const defaults = getDefaultConfig()
|
|
const res: Record<string, any> = {}
|
|
for (const key of Object.keys(v) as (keyof SlidevConfig)[]) {
|
|
if (JSON.stringify(v[key]) !== JSON.stringify(defaults[key]))
|
|
res[key] = v[key]
|
|
}
|
|
return res
|
|
}
|
|
|
|
function replaceCRLF(s: string) {
|
|
return s.replace(/\r\n/g, '\n')
|
|
}
|
|
|
|
describe('md parser', () => {
|
|
const userRoot = resolve(__dirname, 'fixtures/markdown')
|
|
const files = fg.sync('*.md', {
|
|
cwd: userRoot,
|
|
absolute: true,
|
|
})
|
|
|
|
for (const file of files) {
|
|
it(basename(file), async () => {
|
|
const data = await load({ userRoot, roots: [userRoot] }, file)
|
|
|
|
expect(stringify(data.entry).trim()).toEqual(replaceCRLF(data.entry.raw.trim()))
|
|
|
|
prettify(data.entry)
|
|
|
|
// File path tests & convert to relative paths
|
|
data.markdownFiles = objectMap(data.markdownFiles, (path, md) => {
|
|
expect(md.filepath).toBe(path)
|
|
const relativePath = slash(relative(userRoot, path))
|
|
md.slides.forEach((slide) => {
|
|
expect(slide.filepath).toBe(path)
|
|
slide.filepath = relativePath
|
|
})
|
|
md.filepath = relativePath
|
|
return [relativePath, md]
|
|
})
|
|
|
|
expect(data.slides).toMatchSnapshot('slides')
|
|
expect(configDiff(resolveConfig(data.headmatter, {}))).toMatchSnapshot('config')
|
|
expect(data.features).toMatchSnapshot('features')
|
|
})
|
|
}
|
|
|
|
it('parse', async () => {
|
|
const data = await parse(`
|
|
a
|
|
|
|
---
|
|
|
|
b
|
|
|
|
---
|
|
layout: z
|
|
---
|
|
c
|
|
----
|
|
d
|
|
----
|
|
e
|
|
|
|
---
|
|
|
|
f
|
|
|
|
`, 'file.md')
|
|
expect(data.slides.map(i => i.content.trim()))
|
|
.toEqual(Array.from('abcdef'))
|
|
expect(data.slides[2].frontmatter)
|
|
.toEqual({ layout: 'z' })
|
|
expect(data.slides[3].frontmatter)
|
|
.toEqual({ })
|
|
})
|
|
|
|
it('parse section matter', async () => {
|
|
const data = await parse(`
|
|
a
|
|
|
|
---
|
|
|
|
b
|
|
|
|
---section2
|
|
layout: z
|
|
---
|
|
c
|
|
---- section 3
|
|
d
|
|
---- section-4
|
|
e
|
|
|
|
---
|
|
|
|
f
|
|
|
|
`, 'file.md')
|
|
expect(data.slides.map(i => i.content.trim()))
|
|
.toEqual(Array.from('abcdef'))
|
|
expect(data.slides[2].frontmatter)
|
|
.toEqual({ layout: 'z' })
|
|
expect(data.slides[3].frontmatter)
|
|
.toEqual({ })
|
|
})
|
|
|
|
it('ignores slide separators inside HTML comments', async () => {
|
|
const data = await parse(`---
|
|
src: ./pages/one.md
|
|
---
|
|
|
|
<!--
|
|
---
|
|
src: ./pages/two.md
|
|
---
|
|
-->
|
|
|
|
---
|
|
src: ./pages/three.md
|
|
---
|
|
`, 'slides.md')
|
|
|
|
expect(data.slides).toHaveLength(2)
|
|
expect(data.slides.map(slide => slide.frontmatter.src))
|
|
.toEqual(['./pages/one.md', './pages/three.md'])
|
|
})
|
|
|
|
it('detects slide separator even when comment opens on the same line', async () => {
|
|
const data = await parse(`a
|
|
|
|
----<!--
|
|
hidden
|
|
-->
|
|
|
|
b
|
|
`, 'file.md')
|
|
|
|
expect(data.slides).toHaveLength(2)
|
|
expect(data.slides.map(s => s.content.trim())).toEqual(['a', 'hidden\n-->\n\nb'])
|
|
})
|
|
|
|
async function parseWithExtension(
|
|
src: string,
|
|
transformRawLines: (lines: string[]) => void | Promise<void> = () => {},
|
|
more = {},
|
|
moreExts: SlidevPreparserExtension[] = [],
|
|
) {
|
|
return await parse(
|
|
src,
|
|
'file.md',
|
|
[{ transformRawLines, ...more }, ...moreExts] as any,
|
|
)
|
|
}
|
|
|
|
it('parse with-extension replace', async () => {
|
|
const data = await parseWithExtension(`---
|
|
ga: bu
|
|
---
|
|
a @@v@@
|
|
|
|
---
|
|
|
|
b
|
|
@@v@@
|
|
|
|
@@v@@ = @@v@@
|
|
`, (lines) => {
|
|
for (const i in lines)
|
|
lines[i] = lines[i].replace(/@@v@@/g, 'thing')
|
|
})
|
|
|
|
expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
|
|
.toEqual('a thing/b%thing%%thing = thing')
|
|
})
|
|
|
|
it('parse with-extension custom-separator', async () => {
|
|
const data = await parseWithExtension(`---
|
|
ga: bu
|
|
---
|
|
a @@v@@
|
|
|
|
SEPARATOR
|
|
|
|
b
|
|
@@v@@
|
|
|
|
@@v@@ = @@v@@
|
|
`, (lines) => {
|
|
for (const i in lines)
|
|
lines[i] = lines[i].replace(/^SEPARATOR$/g, '---')
|
|
})
|
|
|
|
expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
|
|
.toEqual('a @@v@@/b%@@v@@%%@@v@@ = @@v@@')
|
|
})
|
|
|
|
it('parse with-extension eg-easy-cover', async () => {
|
|
function cov(i: string, more = '.jpg') {
|
|
return `---
|
|
layout: cover
|
|
background: ${i}${more}
|
|
---
|
|
|
|
`
|
|
}
|
|
const data = await parseWithExtension(`@cov 1.jpg
|
|
@cov 2.jpg
|
|
# 2
|
|
---
|
|
|
|
# 3
|
|
@cov 4.jpg
|
|
`, (lines) => {
|
|
let i = 0
|
|
while (i < lines.length) {
|
|
if (lines[i].startsWith('@cov ')) {
|
|
const repl = [...cov(lines[i].substring(5), '').split('\n')]
|
|
lines.splice(i, 1, ...repl)
|
|
}
|
|
i++
|
|
}
|
|
})
|
|
|
|
expect(data.slides.map(s => s.content.trim().replace(/\n/g, '%')).join('/'))
|
|
.toEqual('/# 2/# 3/'.replace(/\n/g, '%'))
|
|
expect(data.slides[0].frontmatter)
|
|
.toEqual({ layout: 'cover', background: '1.jpg' })
|
|
expect(data.slides[1].frontmatter)
|
|
.toEqual({ layout: 'cover', background: '2.jpg' })
|
|
expect(data.slides[3].frontmatter)
|
|
.toEqual({ layout: 'cover', background: '4.jpg' })
|
|
})
|
|
|
|
it('parse with-extension sequence', async () => {
|
|
const data = await parseWithExtension(`
|
|
a..A
|
|
a.a.A.A
|
|
.a.A.
|
|
`, undefined, {}, [{
|
|
name: 'test',
|
|
transformRawLines(lines: string[]) {
|
|
for (const i in lines)
|
|
lines[i] = lines[i].replace(/A/g, 'B').replace(/a/g, 'A')
|
|
},
|
|
}, {
|
|
name: 'test',
|
|
transformRawLines(lines: string[]) {
|
|
for (const i in lines)
|
|
lines[i] = lines[i].replace(/A/g, 'C')
|
|
},
|
|
}])
|
|
expect(data.slides.map(i => i.content.trim().replace(/\n/g, '%')).join('/'))
|
|
.toEqual('C..B%C.C.B.B%.C.B.')
|
|
})
|
|
|
|
// Generate cartesian product of given iterables:
|
|
function* cartesian(...all: any[]): Generator<any[], void, void> {
|
|
const [head, ...tail] = all
|
|
const remainder = tail.length ? cartesian(...tail) : [[]]
|
|
for (const r of remainder) {
|
|
for (const h of head)
|
|
yield [h, ...r]
|
|
}
|
|
}
|
|
const B = [0, 1]
|
|
const Bs = [B, B, B, B, B, B]
|
|
const bNames = '_swScCF'
|
|
|
|
for (const desc of cartesian(...Bs)) {
|
|
const [withSlideBefore, withFrontmatter, withSlideAfter, prependContent, appendContent, addFrontmatter] = desc
|
|
it(`parse with-extension wrap ${desc.map((b, i) => bNames[b * (i + 1)]).join('')}`, async () => {
|
|
const code = [
|
|
withSlideBefore
|
|
? [
|
|
'.',
|
|
'',
|
|
'---',
|
|
]
|
|
: [],
|
|
(!withSlideBefore && withFrontmatter)
|
|
? [
|
|
'---',
|
|
]
|
|
: [],
|
|
withFrontmatter
|
|
? [
|
|
'm: M',
|
|
'n: N',
|
|
'---',
|
|
]
|
|
: [],
|
|
'',
|
|
'ccc',
|
|
'@a',
|
|
'@b',
|
|
'ddd',
|
|
'',
|
|
withSlideAfter
|
|
? [
|
|
'',
|
|
'---',
|
|
'',
|
|
'..',
|
|
]
|
|
: [],
|
|
].flat().join('\n')
|
|
|
|
const data = await parseWithExtension(
|
|
code,
|
|
undefined,
|
|
{
|
|
transformSlide(content: string, frontmatter: any) {
|
|
const lines = content.split('\n')
|
|
let i = 0
|
|
let appendBeforeCount = 0
|
|
let appendAfterCount = 0
|
|
while (i < lines.length) {
|
|
const l = lines[i]
|
|
if (l.startsWith('@')) {
|
|
const t = l.substring(1)
|
|
lines.splice(i, 1)
|
|
if (prependContent)
|
|
lines.splice(appendBeforeCount++, 0, `<${t}>`)
|
|
if (appendContent)
|
|
lines.splice(lines.length - appendAfterCount++, 0, `</${t}>`)
|
|
if (addFrontmatter)
|
|
frontmatter[`add${t}`] = 'add'
|
|
i--
|
|
}
|
|
i++
|
|
}
|
|
return lines.join('\n')
|
|
},
|
|
},
|
|
)
|
|
|
|
function project(s: string) {
|
|
// like the trim in other tests, the goal is not to test newlines here
|
|
return s.replace(/%{2,}/g, '%')
|
|
}
|
|
expect(project(data.slides.map(i => i.content.replace(/\n/g, '%')).join('/')))
|
|
.toEqual(project([
|
|
...withSlideBefore ? ['./'] : [],
|
|
...prependContent ? ['<a>%<b>%'] : [],
|
|
'ccc%ddd',
|
|
...appendContent ? ['%</b>%</a>'] : [],
|
|
...withSlideAfter ? ['/..'] : [],
|
|
].join('')))
|
|
|
|
if (withFrontmatter || addFrontmatter) {
|
|
expect(data.slides[withSlideBefore ? 1 : 0].frontmatter)
|
|
.toEqual({
|
|
...withFrontmatter ? { m: 'M', n: 'N' } : {},
|
|
...addFrontmatter ? { adda: 'add', addb: 'add' } : {},
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('extractImagesUsage', () => {
|
|
it('extracts from frontmatter image key', () => {
|
|
const images = extractImagesUsage('', { image: '/path/to/image.png' })
|
|
expect(images).toEqual(['/path/to/image.png'])
|
|
})
|
|
|
|
it('extracts from frontmatter backgroundImage key', () => {
|
|
const images = extractImagesUsage('', { backgroundImage: 'https://example.com/bg.jpg' })
|
|
expect(images).toEqual(['https://example.com/bg.jpg'])
|
|
})
|
|
|
|
it('extracts from frontmatter background key with image extension', () => {
|
|
const images = extractImagesUsage('', { background: '/assets/bg.webp' })
|
|
expect(images).toEqual(['/assets/bg.webp'])
|
|
})
|
|
|
|
it('extracts from frontmatter background key with URL', () => {
|
|
const images = extractImagesUsage('', { background: 'https://example.com/image.png' })
|
|
expect(images).toEqual(['https://example.com/image.png'])
|
|
})
|
|
|
|
it('ignores frontmatter background key without image extension or URL', () => {
|
|
const images = extractImagesUsage('', { background: 'gradient-to-r' })
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('ignores data URLs in frontmatter', () => {
|
|
const images = extractImagesUsage('', { image: 'data:image/png;base64,abc123' })
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('extracts markdown image syntax', () => {
|
|
const content = ''
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/images/photo.jpg'])
|
|
})
|
|
|
|
it('extracts multiple markdown images', () => {
|
|
const content = `
|
|

|
|
Some text
|
|

|
|

|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/img1.png', 'https://example.com/img2.jpg', './relative/path.svg'])
|
|
})
|
|
|
|
it('ignores data URLs in markdown images', () => {
|
|
const content = ''
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('extracts Vue component src prop', () => {
|
|
const content = '<img src="/assets/logo.png" />'
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/assets/logo.png'])
|
|
})
|
|
|
|
it('extracts Vue component image prop', () => {
|
|
const content = '<MyImage image="/path/to/image.jpg" />'
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/path/to/image.jpg'])
|
|
})
|
|
|
|
it('ignores Vue props without image extensions', () => {
|
|
const content = '<div src="/some/path" />'
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('ignores Vue props with template expressions', () => {
|
|
const content = '<img src="{{imagePath}}.png" />'
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('extracts Vue bound props with string literals', () => {
|
|
const content = `<img :src="'/static/image.png'" />`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/static/image.png'])
|
|
})
|
|
|
|
it('extracts CSS url()', () => {
|
|
const content = `
|
|
<div style="background: url(/bg/image.png)">
|
|
<style>
|
|
.class { background-image: url('/another/image.jpg'); }
|
|
</style>
|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/bg/image.png', '/another/image.jpg'])
|
|
})
|
|
|
|
it('ignores CSS url() without image extensions', () => {
|
|
const content = `<div style="background: url(/fonts/font.woff2)">`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual([])
|
|
})
|
|
|
|
it('strips code blocks to avoid false positives', () => {
|
|
const content = `
|
|
Some content
|
|

|
|
|
|
\`\`\`markdown
|
|

|
|
<img src="/also-fake.jpg" />
|
|
\`\`\`
|
|
|
|

|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/real.png', '/real2.jpg'])
|
|
})
|
|
|
|
it('handles multiple sources combined', () => {
|
|
const content = `
|
|
# Title
|
|

|
|
<img src="/vue.jpg" />
|
|
<div style="background: url(/css.webp)">
|
|
`
|
|
const frontmatter = {
|
|
image: '/frontmatter.png',
|
|
background: 'https://example.com/bg.svg',
|
|
}
|
|
const images = extractImagesUsage(content, frontmatter)
|
|
expect(images).toContain('/frontmatter.png')
|
|
expect(images).toContain('https://example.com/bg.svg')
|
|
expect(images).toContain('/md.png')
|
|
expect(images).toContain('/vue.jpg')
|
|
expect(images).toContain('/css.webp')
|
|
expect(images).toHaveLength(5)
|
|
})
|
|
|
|
it('deduplicates identical URLs', () => {
|
|
const content = `
|
|

|
|

|
|
<img src="/same.png" />
|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/same.png'])
|
|
})
|
|
|
|
it('trims whitespace from extracted URLs', () => {
|
|
const content = ''
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/path/with/spaces.png'])
|
|
})
|
|
|
|
it('handles various image extensions', () => {
|
|
const content = `
|
|

|
|

|
|

|
|

|
|

|
|

|
|

|
|

|
|

|
|

|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toHaveLength(10)
|
|
})
|
|
|
|
it('handles case-insensitive image extensions', () => {
|
|
const content = `
|
|
<img src="/image.PNG" />
|
|
<img src="/image.JpG" />
|
|
<img src="/image.WEBP" />
|
|
`
|
|
const images = extractImagesUsage(content, {})
|
|
expect(images).toEqual(['/image.PNG', '/image.JpG', '/image.WEBP'])
|
|
})
|
|
})
|
|
|
|
it('detects circular src imports without overflowing', async () => {
|
|
const root = resolve(__dirname, 'fixtures/markdown/circular')
|
|
const data = await load({ userRoot: root, roots: [root] }, resolve(root, 'a.md'))
|
|
// Must return (not hang / overflow) and record a circular-import diagnostic
|
|
const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
|
|
expect(errors.some(e => /circular/i.test(e.message))).toBe(true)
|
|
})
|
|
|
|
it('records an error when a src: import escapes the allowed roots', async () => {
|
|
const root = resolve(__dirname, 'fixtures/markdown/escaping/root')
|
|
const data = await load({ userRoot: root, roots: [root], allowedRoots: [root] }, resolve(root, 'entry.md'))
|
|
const errors = Object.values(data.markdownFiles).flatMap(md => md.errors ?? [])
|
|
expect(errors.some(e => /escapes the project root/i.test(e.message))).toBe(true)
|
|
})
|
|
})
|