| 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:  |
| 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 |