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