返回 slidev
loaders.ts
根目录 / packages / slidev / node / vite / loaders.ts
1 import type { ResolvedSlidevOptions, SlideInfo, SlidePatch, SlidevData, SlidevServerOptions } from '@slidev/types'
2 import type { ModuleNode, Plugin, Rolldown, ViteDevServer } from 'vite'
3 import type { VirtualModuleContext } from '../virtual/types'
4 import { notNullish, range } from '@antfu/utils'
5 import * as parser from '@slidev/parser/fs'
6 import equal from 'fast-deep-equal'
7 import MarkdownExit from 'markdown-exit'
8 import YAML from 'yaml'
9 import { createDataUtils } from '../options'
10 import MarkdownItKatex from '../syntax/katex'
11 import markdownItLink from '../syntax/link'
12 import { createMakeAbsoluteImportGlob, getBodyJson, updateFrontmatterPatch } from '../utils'
13 import { templates } from '../virtual'
14 import { templateConfigs } from '../virtual/configs'
15 import { templateMonacoRunDeps } from '../virtual/monaco-deps'
16 import { templateMonacoTypes } from '../virtual/monaco-types'
17 import { templateSlides, VIRTUAL_SLIDE_PREFIX } from '../virtual/slides'
18 import { templateTitleRendererMd } from '../virtual/titles'
19 import { regexSlideFacadeId, regexSlideReqPath, regexSlideSourceId } from './common'
20
21 const RE_WORD_CHARS_ONLY = /^[\w-]+$/
22
23 export function createSlidesLoader(
24 options: ResolvedSlidevOptions,
25 serverOptions: SlidevServerOptions,
26 ): Plugin {
27 const { data, mode, utils, withoutNotes } = options
28
29 const notesMd = MarkdownExit({ html: true })
30 notesMd.use(markdownItLink)
31 if (data.features.katex)
32 notesMd.use(MarkdownItKatex, utils.katexOptions)
33
34 const hmrSlidesIndexes = new Set<number>()
35 let server: ViteDevServer | undefined
36 let skipHmr: { filePath: string, fileContent: string } | null = null
37 const makeAbsoluteImportGlob = createMakeAbsoluteImportGlob(options.userRoot)
38
39 interface ResolvedSourceIds {
40 md: string[]
41 frontmatter: string[]
42 }
43 let sourceIds = resolveSourceIds(data)
44
45 function resolveSourceIds(data: SlidevData) {
46 const ids: ResolvedSourceIds = {
47 md: [],
48 frontmatter: [],
49 }
50 for (const type of ['md', 'frontmatter'] as const) {
51 for (let i = 0; i < data.slides.length; i++) {
52 ids[type].push(`${data.slides[i].source.filepath}__slidev_${i + 1}.${type}`)
53 }
54 }
55 return ids
56 }
57
58 function updateServerWatcher() {
59 if (!server)
60 return
61 server.watcher.add(Object.keys(data.watchFiles))
62 }
63
64 function getFrontmatter(pageNo: number) {
65 return {
66 ...(data.headmatter?.defaults as object || {}),
67 ...(data.slides[pageNo]?.frontmatter || {}),
68 }
69 }
70
71 return {
72 name: 'slidev:loader',
73 enforce: 'pre',
74
75 configureServer(_server) {
76 server = _server
77 updateServerWatcher()
78
79 server.middlewares.use(async (req, res, next) => {
80 const match = req.url?.match(regexSlideReqPath)
81 if (!match)
82 return next()
83
84 const [, no] = match
85 const idx = Number.parseInt(no) - 1
86 if (req.method === 'GET') {
87 res.write(JSON.stringify(withRenderedNote(data.slides[idx])))
88 return res.end()
89 }
90 else if (req.method === 'POST') {
91 const body: SlidePatch = await getBodyJson(req)
92 const slide = data.slides[idx]
93
94 if (body.content && body.content !== slide.source.content)
95 hmrSlidesIndexes.add(idx)
96
97 if (body.content)
98 slide.content = slide.source.content = body.content
99 if (body.frontmatterRaw != null) {
100 if (body.frontmatterRaw.trim() === '') {
101 slide.source.frontmatterDoc = slide.source.frontmatterStyle = undefined
102 }
103 else {
104 const parsed = YAML.parseDocument(body.frontmatterRaw)
105 if (parsed.errors.length)
106 console.error('ERROR when saving frontmatter', parsed.errors)
107 else
108 slide.source.frontmatterDoc = parsed
109 }
110 }
111 if (body.note != null)
112 slide.note = slide.source.note = body.note
113 if (body.frontmatter) {
114 updateFrontmatterPatch(slide.source, body.frontmatter)
115 Object.assign(slide.frontmatter, body.frontmatter)
116 }
117
118 parser.prettifySlide(slide.source)
119 const fileContent = await parser.save(data.markdownFiles[slide.source.filepath])
120 if (body.skipHmr) {
121 skipHmr = {
122 filePath: slide.source.filepath,
123 fileContent,
124 }
125 server?.moduleGraph.invalidateModule(
126 server.moduleGraph.getModuleById(sourceIds.md[idx])!,
127 )
128 if (body.frontmatter) {
129 server?.moduleGraph.invalidateModule(
130 server.moduleGraph.getModuleById(sourceIds.frontmatter[idx])!,
131 )
132 }
133 }
134
135 res.statusCode = 200
136 res.write(JSON.stringify(withRenderedNote(slide)))
137 return res.end()
138 }
139
140 next()
141 })
142 },
143
144 async handleHotUpdate(ctx) {
145 const forceChangedSlides = data.watchFiles[ctx.file]
146 if (!forceChangedSlides)
147 return
148
149 for (const index of forceChangedSlides) {
150 hmrSlidesIndexes.add(index)
151 }
152
153 const newData = await serverOptions.loadData?.({
154 [ctx.file]: await ctx.read(),
155 })
156
157 if (!newData)
158 return []
159
160 if (skipHmr && newData.markdownFiles[skipHmr.filePath]?.raw === skipHmr.fileContent) {
161 skipHmr = null
162 return []
163 }
164
165 const moduleIds = new Set<string>()
166
167 const newSourceIds = resolveSourceIds(newData)
168 for (const type of ['md', 'frontmatter'] as const) {
169 const old = sourceIds[type]
170 const newIds = newSourceIds[type]
171 for (let i = 0; i < newIds.length; i++) {
172 if (old[i] !== newIds[i]) {
173 moduleIds.add(`${VIRTUAL_SLIDE_PREFIX}${i + 1}/${type}`)
174 }
175 }
176 }
177 sourceIds = newSourceIds
178
179 if (data.slides.length !== newData.slides.length) {
180 moduleIds.add(templateSlides.id)
181 }
182
183 if (!equal(data.headmatter.defaults, newData.headmatter.defaults)) {
184 moduleIds.add(templateSlides.id)
185 range(data.slides.length).map(i => hmrSlidesIndexes.add(i))
186 }
187
188 if (!equal(data.config, newData.config))
189 moduleIds.add(templateConfigs.id)
190
191 if (!equal(data.features, newData.features)) {
192 setTimeout(() => {
193 ctx.server.hot.send({ type: 'full-reload' })
194 }, 1)
195 }
196
197 const length = Math.min(data.slides.length, newData.slides.length)
198
199 for (let i = 0; i < length; i++) {
200 const a = data.slides[i]
201 const b = newData.slides[i]
202
203 if (
204 !hmrSlidesIndexes.has(i)
205 && a.content.trim() === b.content.trim()
206 && a.title?.trim() === b.title?.trim()
207 && equal(a.frontmatter, b.frontmatter)
208 ) {
209 if (a.note !== b.note) {
210 ctx.server.hot.send(
211 'slidev:update-note',
212 {
213 no: i + 1,
214 note: b!.note || '',
215 noteHTML: renderNote(b!.note || ''),
216 },
217 )
218 }
219 continue
220 }
221
222 ctx.server.hot.send(
223 'slidev:update-slide',
224 {
225 no: i + 1,
226 data: withRenderedNote(newData.slides[i]),
227 },
228 )
229 hmrSlidesIndexes.add(i)
230 }
231
232 Object.assign(data, newData)
233 Object.assign(utils, createDataUtils(options))
234
235 if (hmrSlidesIndexes.size > 0)
236 moduleIds.add(templateTitleRendererMd.id)
237
238 for (const idx of hmrSlidesIndexes) {
239 moduleIds.add(sourceIds.frontmatter[idx])
240 }
241
242 const reloadBeforeOthers: ModuleNode[] = []
243 const vueModules: ModuleNode[] = []
244 for (const idx of hmrSlidesIndexes) {
245 const main = ctx.server.moduleGraph.getModuleById(sourceIds.md[idx])
246 if (main) {
247 const styles = [...main.clientImportedModules].filter(m => m.id?.includes(`&type=style`))
248 if (styles.length) {
249 // `pluginVue.transform(mainModule)` must be called before `pluginVue.load(styleModule)`
250 // to refresh the internal descriptor cache of `@vitejs/plugin-vue`
251 reloadBeforeOthers.push(main)
252 vueModules.push(...styles)
253 }
254 else {
255 vueModules.push(main)
256 }
257 }
258 }
259
260 hmrSlidesIndexes.clear()
261
262 await Promise.all(reloadBeforeOthers.map(m => ctx.server.reloadModule(m)))
263
264 const moduleEntries = [
265 ...ctx.modules.filter(i => i.id === templateMonacoRunDeps.id || i.id === templateMonacoTypes.id),
266 ...vueModules,
267 ...Array.from(moduleIds).map(id => ctx.server.moduleGraph.getModuleById(id)),
268 ]
269 .filter(notNullish)
270 .filter(i => !i.id?.startsWith('/@id/@vite-icons'))
271
272 updateServerWatcher()
273
274 return moduleEntries
275 },
276
277 resolveId: {
278 order: 'pre',
279 handler(id) {
280 if (id.startsWith('/@slidev/') || id.includes('__slidev_'))
281 return id
282 return null
283 },
284 },
285
286 async load(id): Promise<Rolldown.LoadResult> {
287 const template = templates.find(i => i.id === id)
288 if (template) {
289 const templateContext: VirtualModuleContext = {
290 resolve: this.resolve.bind(this),
291 makeAbsoluteImportGlob,
292 }
293 return {
294 code: await template.getContent.call(templateContext, options),
295 map: { mappings: '' },
296 }
297 }
298
299 const matchFacade = id.match(regexSlideFacadeId)
300 if (matchFacade) {
301 const [, no, type] = matchFacade
302 const idx = +no - 1
303 const sourceId = JSON.stringify(sourceIds[type as 'md' | 'frontmatter'][idx])
304 return [
305 `export * from ${sourceId}`,
306 `export { default } from ${sourceId}`,
307 ].join('\n')
308 }
309
310 const matchSource = id.match(regexSlideSourceId)
311 if (matchSource) {
312 const [, no, type] = matchSource
313 const idx = +no - 1
314 const slide = data.slides[idx]
315 if (!slide)
316 return
317
318 if (type === 'md') {
319 return {
320 code: slide.content,
321 map: { mappings: '' },
322 }
323 }
324 else if (type === 'frontmatter') {
325 const slideBase = {
326 ...withRenderedNote(slide),
327 frontmatter: undefined,
328 source: undefined,
329 importChain: undefined,
330 // The runtime image preloader reads `slide.images`, but `source` is
331 // stripped just above — carry the extracted image URLs onto the client
332 // slide so runtime preloading actually receives them.
333 images: slide.images ?? slide.source?.images,
334 // remove raw content in build, optimize the bundle size
335 ...(mode === 'build' ? { raw: '', content: '', note: '' } : {}),
336 }
337 const fontmatter = getFrontmatter(idx)
338
339 return {
340 code: [
341 '// @unocss-include',
342 'import { computed, reactive, shallowReactive } from "vue"',
343 `export const frontmatterData = ${JSON.stringify(fontmatter)}`,
344 // handle HMR, update frontmatter with update
345 'if (import.meta.hot) {',
346 ' import.meta.hot.data.frontmatter ??= reactive(frontmatterData)',
347 ' import.meta.hot.accept(({ frontmatterData: update }) => {',
348 ' const frontmatter = import.meta.hot.data.frontmatter',
349 ' Object.keys(frontmatter).forEach(key => {',
350 ' if (!(key in update)) delete frontmatter[key]',
351 ' })',
352 ' Object.assign(frontmatter, update)',
353 ' })',
354 '}',
355 'export const frontmatter = import.meta.hot ? import.meta.hot.data.frontmatter : reactive(frontmatterData)',
356 'export default frontmatter',
357 'export const meta = shallowReactive({',
358 ' get layout(){ return frontmatter.layout },',
359 ' get transition(){ return frontmatter.transition },',
360 ' get class(){ return frontmatter.class },',
361 ' get clicks(){ return frontmatter.clicks },',
362 ' get name(){ return frontmatter.name },',
363 ' get preload(){ return frontmatter.preload },',
364 // No need to be reactive, as it's only used once after reload
365 ' slide: {',
366 ` ...(${JSON.stringify(slideBase)}),`,
367 ` frontmatter,`,
368 ` filepath: ${JSON.stringify(mode === 'dev' ? slide.source.filepath : '')},`,
369 ` start: ${JSON.stringify(slide.source.start)},`,
370 ` sourceIndex: ${JSON.stringify(slide.source.index)},`,
371 ` id: ${idx},`,
372 ` no: ${no},`,
373 ' },',
374 ' __clicksContext: null,',
375 ' __preloaded: false,',
376 '})',
377 ].join('\n'),
378 map: { mappings: '' },
379 }
380 }
381 }
382
383 // Entry files, shouldn't be processed by MarkdownIt
384 if (data.markdownFiles[id])
385 return ''
386 },
387 }
388
389 function renderNote(text: string = '') {
390 if (withoutNotes)
391 return ''
392
393 let clickCount = 0
394 const notesAutoRuby: Record<string, string | undefined> = (data.headmatter as any).notesAutoRuby || {}
395
396 // Apply [click] marker
397 let md = text
398 // replace [click] marker with span
399 .replace(/\[click(?::(\d+))?\]/gi, (_, count = 1) => {
400 clickCount += Number(count)
401 return `<span class="slidev-note-click-mark" data-clicks="${clickCount}"></span>`
402 })
403
404 // Apply notesAutoRuby
405 const keys = Object.keys(notesAutoRuby)
406 .sort((b, a) => b.length - a.length)
407 // Add word boundaries to the keys when they are simple alphabets or numbers
408 .map(i => RE_WORD_CHARS_ONLY.test(i) ? `\\b${i}\\b` : i)
409
410 if (keys.length) {
411 const regex = new RegExp(`(${keys.join('|')})`, 'g')
412 md = md.replace(
413 regex,
414 (match) => {
415 if (notesAutoRuby[match])
416 return `<ruby>${match}<rt>${notesAutoRuby[match]}</rt></ruby>`
417 return match
418 },
419 )
420 }
421
422 const html = notesMd.render(md)
423 return html
424 }
425
426 function withRenderedNote(data: SlideInfo): SlideInfo {
427 return {
428 ...data,
429 ...withoutNotes && { note: '' },
430 noteHTML: renderNote(data?.note),
431 }
432 }
433 }
434
434 lines TYPESCRIPT