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