返回 slidev
core.ts
根目录 / packages / parser / src / core.ts
1 import type { FrontmatterStyle, SlidevDetectedFeatures, SlidevMarkdown, SlidevPreparserExtension, SourceSlideInfo } from '@slidev/types'
2 import { ensurePrefix } from '@antfu/utils'
3 import YAML from 'yaml'
4
5 const RE_FRONTMATTER = /^---.*\r?\n([\s\S]*?)---/
6 const RE_YAML_CODEBLOCK = /^\s*```ya?ml([\s\S]*?)```/
7 const RE_DOLLAR_INLINE = /\$.*?\$/
8 const RE_DOLLAR_BLOCK = /\$\$/
9 const RE_MONACO_BLOCK = /\{monaco.*\}/
10 const RE_TWEET_TAG = /<Tweet\b/
11 const RE_BLUESKY_TAG = /<BlueSky\b/
12 const RE_MERMAID_CODEBLOCK = /^```mermaid/m
13 const RE_HEADING = /^(#+) (.*)$/m
14 const RE_LEADING_BACKTICKS = /^\s*`+/
15 const RE_CRLF = /\r?\n/g
16
17 export interface SlidevParserOptions {
18 noParseYAML?: boolean
19 preserveCR?: boolean
20 }
21
22 function advanceHtmlCommentState(line: string, inHtmlComment: boolean) {
23 let cursor = 0
24
25 while (cursor < line.length) {
26 if (inHtmlComment) {
27 const end = line.indexOf('-->', cursor)
28 if (end < 0)
29 return true
30 inHtmlComment = false
31 cursor = end + 3
32 }
33 else {
34 const start = line.indexOf('<!--', cursor)
35 if (start < 0)
36 return false
37 const end = line.indexOf('-->', start + 4)
38 if (end < 0)
39 return true
40 cursor = end + 3
41 }
42 }
43
44 return inHtmlComment
45 }
46
47 export function stringify(data: SlidevMarkdown) {
48 return `${data.slides.map(stringifySlide).join('\n').trim()}\n`
49 }
50
51 export function stringifySlide(data: SourceSlideInfo, idx = 0) {
52 return (data.raw.startsWith('---') || idx === 0)
53 ? data.raw
54 : `---\n${ensurePrefix('\n', data.raw)}`
55 }
56
57 export function prettifySlide(data: SourceSlideInfo) {
58 const trimed = data.content.trim()
59 data.content = trimed ? `\n${data.content.trim()}\n` : ''
60 data.raw = data.frontmatterDoc?.contents
61 ? data.frontmatterStyle === 'yaml'
62 ? `\`\`\`yaml\n${data.frontmatterDoc.toString().trim()}\n\`\`\`\n${data.content}`
63 : `---\n${data.frontmatterDoc.toString().trim()}\n---\n${data.content}`
64 : data.content
65 if (data.note)
66 data.raw += `\n<!--\n${data.note.trim()}\n-->\n`
67 return data
68 }
69
70 export function prettify(data: SlidevMarkdown) {
71 data.slides.forEach(prettifySlide)
72 return data
73 }
74
75 function matter(code: string, options: SlidevParserOptions) {
76 let type: FrontmatterStyle | undefined
77 let raw: string | undefined
78
79 let content = code
80 .replace(RE_FRONTMATTER, (_, f) => {
81 type = 'frontmatter'
82 raw = f
83 return ''
84 })
85
86 if (type !== 'frontmatter') {
87 content = content
88 .replace(RE_YAML_CODEBLOCK, (_, f) => {
89 type = 'yaml'
90 raw = f
91 return ''
92 })
93 }
94
95 const doc = raw && !options.noParseYAML ? YAML.parseDocument(raw) : undefined
96
97 return {
98 type,
99 raw,
100 doc,
101 data: doc?.toJSON(),
102 content,
103 }
104 }
105
106 const IMAGE_EXTENSIONS = /\.(?:png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?)$/i
107
108 /**
109 * Extract image URLs from slide content and frontmatter.
110 * Strips code blocks first to avoid false positives.
111 */
112 export function extractImagesUsage(content: string, frontmatter: Record<string, any>): string[] {
113 const images = new Set<string>()
114
115 // Collect from frontmatter keys
116 for (const key of ['image', 'backgroundImage', 'background']) {
117 const val = frontmatter[key]
118 if (typeof val === 'string' && val && !val.startsWith('data:')) {
119 // For `background`, only include if it looks like an image URL
120 if (key === 'background') {
121 if (IMAGE_EXTENSIONS.test(val) || val.startsWith('/') || val.startsWith('http'))
122 images.add(val)
123 }
124 else {
125 images.add(val)
126 }
127 }
128 }
129
130 // Strip code blocks to avoid false positives
131 const stripped = content.replace(/^```[\s\S]+?^```/gm, '')
132
133 // Markdown images: ![alt](url)
134 for (const [, url] of stripped.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
135 if (url && !url.startsWith('data:'))
136 images.add(url.trim())
137 }
138
139 // Vue component props: src="url", image="url"
140 for (const [, url] of stripped.matchAll(/\b(?:src|image)=["']([^"']+)["']/g)) {
141 if (url && !url.startsWith('data:') && !url.includes('{{') && IMAGE_EXTENSIONS.test(url))
142 images.add(url.trim())
143 }
144
145 // Vue bound props: :src="'/path/to/img.png'"
146 for (const [, url] of stripped.matchAll(/:(?:src|image)=["']'([^']+)'["']/g)) {
147 if (url && !url.startsWith('data:') && IMAGE_EXTENSIONS.test(url))
148 images.add(url.trim())
149 }
150
151 // CSS url() with image extension filter
152 for (const [, url] of stripped.matchAll(/url\(["']?([^"')]+)["']?\)/g)) {
153 if (url && !url.startsWith('data:') && IMAGE_EXTENSIONS.test(url))
154 images.add(url.trim())
155 }
156
157 return Array.from(images)
158 }
159
160 export function detectFeatures(code: string): SlidevDetectedFeatures {
161 return {
162 katex: !!code.match(RE_DOLLAR_INLINE) || !!code.match(RE_DOLLAR_BLOCK),
163 monaco: RE_MONACO_BLOCK.test(code) ? scanMonacoReferencedMods(code) : false,
164 tweet: !!code.match(RE_TWEET_TAG),
165 bluesky: !!code.match(RE_BLUESKY_TAG),
166 mermaid: !!code.match(RE_MERMAID_CODEBLOCK),
167 }
168 }
169
170 export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit<SourceSlideInfo, 'filepath' | 'index' | 'start' | 'contentStart' | 'end'> {
171 const matterResult = matter(raw, options)
172 let note: string | undefined
173 const frontmatter = matterResult.data || {}
174 let content = matterResult.content.trim()
175 const revision = hash(raw.trim())
176
177 const comments = Array.from(content.matchAll(/<!--([\s\S]*?)-->/g))
178 if (comments.length) {
179 const last = comments[comments.length - 1]
180 if (last.index !== undefined && last.index + last[0].length >= content.length) {
181 note = last[1].trim()
182 content = content.slice(0, last.index).trim()
183 }
184 }
185
186 let title
187 let level
188 if (frontmatter.title || frontmatter.name) {
189 title = frontmatter.title || frontmatter.name
190 }
191 else {
192 const match = content.match(RE_HEADING)
193 title = match?.[2]?.trim()
194 level = match?.[1]?.length
195 }
196 if (frontmatter.level)
197 level = frontmatter.level || 1
198
199 const images = extractImagesUsage(content, frontmatter)
200
201 return {
202 raw,
203 title,
204 level,
205 revision,
206 content,
207 contentRaw: content,
208 frontmatter,
209 frontmatterStyle: matterResult.type,
210 frontmatterDoc: matterResult.doc,
211 frontmatterRaw: matterResult.raw,
212 note,
213 images,
214 }
215 }
216
217 export async function parse(
218 markdown: string,
219 filepath: string,
220 extensions?: SlidevPreparserExtension[],
221 options: SlidevParserOptions = {},
222 ): Promise<SlidevMarkdown> {
223 const lines = markdown.split(options.preserveCR ? '\n' : RE_CRLF)
224 const slides: SourceSlideInfo[] = []
225
226 let start = 0
227 let contentStart = 0
228 let inHtmlComment = false
229
230 async function slice(end: number) {
231 if (start === end)
232 return
233 const raw = lines.slice(start, end).join('\n')
234 const slide: SourceSlideInfo = {
235 ...parseSlide(raw, options),
236 filepath,
237 index: slides.length,
238 start,
239 contentStart,
240 end,
241 }
242 if (extensions) {
243 for (const e of extensions) {
244 if (e.transformSlide) {
245 const newContent = await e.transformSlide(slide.content, slide.frontmatter)
246 if (newContent !== undefined)
247 slide.content = newContent
248 if (typeof slide.frontmatter.title === 'string') {
249 slide.title = slide.frontmatter.title
250 }
251 if (typeof slide.frontmatter.level === 'number') {
252 slide.level = slide.frontmatter.level
253 }
254 }
255
256 if (e.transformNote) {
257 const newNote = await e.transformNote(slide.note, slide.frontmatter)
258 if (newNote !== undefined)
259 slide.note = newNote
260 }
261 }
262 }
263 slides.push(slide)
264 start = end + 1
265 contentStart = end + 1
266 }
267
268 if (extensions) {
269 for (const e of extensions) {
270 if (e.transformRawLines)
271 await e.transformRawLines(lines)
272 }
273 }
274
275 for (let i = 0; i < lines.length; i++) {
276 const rawLine = lines[i]
277 const line = rawLine.trimEnd()
278 if (inHtmlComment) {
279 inHtmlComment = advanceHtmlCommentState(rawLine, true)
280 continue
281 }
282
283 if (line.startsWith('---')) {
284 await slice(i)
285
286 const next = lines[i + 1]
287 // found frontmatter, skip next dash
288 if (line[3] !== '-' && next?.trim()) {
289 start = i
290 for (i += 1; i < lines.length; i++) {
291 if (lines[i].trimEnd() === '---')
292 break
293 }
294 contentStart = i + 1
295 }
296 }
297 // skip code block
298 else if (line.trimStart().startsWith('```')) {
299 const codeBlockLevel = line.match(RE_LEADING_BACKTICKS)![0]
300 let j = i + 1
301 for (; j < lines.length; j++) {
302 if (lines[j].startsWith(codeBlockLevel))
303 break
304 }
305 // Update i only when code block ends
306 if (j !== lines.length)
307 i = j
308 }
309 else {
310 inHtmlComment = advanceHtmlCommentState(rawLine, false)
311 }
312 }
313
314 if (start <= lines.length - 1)
315 await slice(lines.length)
316
317 return {
318 filepath,
319 raw: markdown,
320 slides,
321 }
322 }
323
324 export function parseSync(
325 markdown: string,
326 filepath: string,
327 options: SlidevParserOptions = {},
328 ): SlidevMarkdown {
329 const lines = markdown.split(options.preserveCR ? '\n' : RE_CRLF)
330 const slides: SourceSlideInfo[] = []
331
332 let start = 0
333 let contentStart = 0
334 let inHtmlComment = false
335
336 function slice(end: number) {
337 if (start === end)
338 return
339 const raw = lines.slice(start, end).join('\n')
340 const slide: SourceSlideInfo = {
341 ...parseSlide(raw, options),
342 filepath,
343 index: slides.length,
344 start,
345 contentStart,
346 end,
347 }
348 slides.push(slide)
349 start = end + 1
350 contentStart = end + 1
351 }
352
353 for (let i = 0; i < lines.length; i++) {
354 const rawLine = lines[i]
355 const line = rawLine.trimEnd()
356 if (inHtmlComment) {
357 inHtmlComment = advanceHtmlCommentState(rawLine, true)
358 continue
359 }
360
361 if (line.startsWith('---')) {
362 slice(i)
363
364 const next = lines[i + 1]
365 // found frontmatter, skip next dash
366 if (line[3] !== '-' && next?.trim()) {
367 start = i
368 for (i += 1; i < lines.length; i++) {
369 if (lines[i].trimEnd() === '---')
370 break
371 }
372 contentStart = i + 1
373 }
374 }
375 // skip code block
376 else if (line.trimStart().startsWith('```')) {
377 const codeBlockLevel = line.match(RE_LEADING_BACKTICKS)![0]
378 let j = i + 1
379 for (; j < lines.length; j++) {
380 if (lines[j].startsWith(codeBlockLevel))
381 break
382 }
383 // Update i only when code block ends
384 if (j !== lines.length)
385 i = j
386 }
387 else {
388 inHtmlComment = advanceHtmlCommentState(rawLine, false)
389 }
390 }
391
392 if (start <= lines.length - 1)
393 slice(lines.length)
394
395 return {
396 filepath,
397 raw: markdown,
398 slides,
399 }
400 }
401
402 function scanMonacoReferencedMods(md: string) {
403 const types = new Set<string>()
404 const deps = new Set<string>()
405 md.replace(
406 /^```(\w+)\s*\{monaco([^}]*)\}\s*(\S[\s\S]*?)^```/gm,
407 (full, lang = 'ts', kind: string, code: string) => {
408 lang = lang.trim()
409 const isDep = kind === '-run'
410 if (['js', 'javascript', 'ts', 'typescript'].includes(lang)) {
411 for (const [, , specifier] of code.matchAll(/\s+from\s+(["'])([/.\w@-]+)\1/g)) {
412 if (specifier) {
413 if (!'./'.includes(specifier))
414 types.add(specifier) // All local TS files are loaded by globbing
415 if (isDep)
416 deps.add(specifier)
417 }
418 }
419 }
420 return ''
421 },
422 )
423 return {
424 types: Array.from(types),
425 deps: Array.from(deps),
426 }
427 }
428
429 function hash(str: string) {
430 let hash = 0
431 for (let i = 0; i < str.length; i++) {
432 hash = ((hash << 5) - hash) + str.charCodeAt(i)
433 hash |= 0
434 }
435 return hash.toString(36).slice(0, 12)
436 }
437
438 export * from './config'
439 export * from './utils'
440
440 lines TYPESCRIPT