Files

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 = '![alt text](/images/photo.jpg)'
const images = extractImagesUsage(content, {})
expect(images).toEqual(['/images/photo.jpg'])
})
it('extracts multiple markdown images', () => {
const content = `
![first](/img1.png)
Some text
![second](https://example.com/img2.jpg)
![third](./relative/path.svg)
`
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 = '![inline](data:image/png;base64,abc123)'
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
![real image](/real.png)
\`\`\`markdown
![fake image](/fake.png)
<img src="/also-fake.jpg" />
\`\`\`
![another real](/real2.jpg)
`
const images = extractImagesUsage(content, {})
expect(images).toEqual(['/real.png', '/real2.jpg'])
})
it('handles multiple sources combined', () => {
const content = `
# Title
![markdown](/md.png)
<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](/same.png)
![img2](/same.png)
<img src="/same.png" />
`
const images = extractImagesUsage(content, {})
expect(images).toEqual(['/same.png'])
})
it('trims whitespace from extracted URLs', () => {
const content = '![img]( /path/with/spaces.png )'
const images = extractImagesUsage(content, {})
expect(images).toEqual(['/path/with/spaces.png'])
})
it('handles various image extensions', () => {
const content = `
![png](/img.png)
![jpg](/img.jpg)
![jpeg](/img.jpeg)
![gif](/img.gif)
![svg](/img.svg)
![webp](/img.webp)
![avif](/img.avif)
![ico](/img.ico)
![bmp](/img.bmp)
![tiff](/img.tiff)
`
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)
})
})