@lincyaw/render-slides 0.0.0-stage → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,550 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { DEFAULT_LANG, deckLocale, docBlocks, errorMessage, escapeHtml, languageId, lowerMyst, nodeMdast, publishedUrl, roleMark, withProp } from '@lincyaw/kernel'
6
+ import type { CodeNode, ComponentNode, IRBlock, IRDocument, IRSection, LinkedType, LocalAsset, Locale, MdastNode, RenderContext, RunOutput, RunResult } from '@lincyaw/kernel'
7
+ import { fromMarkdown } from 'mdast-util-from-markdown'
8
+ import { gfmFromMarkdown } from 'mdast-util-gfm'
9
+ import { gfm } from 'micromark-extension-gfm'
10
+ import { originParts, outputNotes } from '@lincyaw/viewer/domain'
11
+ import type { OriginPart } from '@lincyaw/viewer/domain'
12
+ import { stringify } from 'yaml'
13
+ import { sanitizedHtml } from './html.ts'
14
+ import { codeLang, markdown } from './markdown.ts'
15
+
16
+ const MESSAGES = {
17
+ 'zh-CN': {
18
+ webOnly: (name: string) => `组件 ${name} 只能在网页中查看。`,
19
+ },
20
+ 'en': {
21
+ webOnly: (name: string) => `The component ${name} can only be viewed on the web.`,
22
+ },
23
+ } satisfies Record<Locale, unknown>
24
+
25
+ type Messages = (typeof MESSAGES)[Locale]
26
+
27
+ export interface SlidevDeck {
28
+ markdown: string
29
+ assets: Map<string, LocalAsset>
30
+ }
31
+
32
+ interface Generation {
33
+ ctx: RenderContext
34
+ dir: string
35
+ locale: Locale
36
+ t: Messages
37
+ assets: Map<string, LocalAsset>
38
+ files: Set<string>
39
+ links: (label: string) => string | undefined
40
+ section: string
41
+ live: boolean
42
+ chapters: Map<string, string>
43
+ }
44
+
45
+ const FAVICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="3" fill="#0f5f5c"/></svg>')}`
46
+
47
+ export function deckLang(doc: IRDocument): string {
48
+ return typeof doc.meta.lang === 'string' && doc.meta.lang ? doc.meta.lang : DEFAULT_LANG
49
+ }
50
+
51
+ const RENDERER_KEYS = new Set(['package', 'base', 'port', 'liveCode'])
52
+
53
+ const WEB_PACKAGE = '@lincyaw/web'
54
+
55
+ const THEME_DIR = fileURLToPath(new URL('../../theme', import.meta.url))
56
+
57
+ export function slidevMarkdown(doc: IRDocument, ctx: RenderContext, dir: string): SlidevDeck {
58
+ const locale = deckLocale(ctx.lang ?? deckLang(doc))
59
+ const generation: Generation = { ctx, dir, locale, t: MESSAGES[locale], assets: new Map(), files: new Set(), links: slideLinks(doc), section: '', live: ctx.config.liveCode !== false, chapters: chapters(doc) }
60
+ const markdown = doc.sections.map((section, i) => slide(section, i === 0 ? headmatter(doc, generation) : {}, generation)).join('\n')
61
+ return { markdown, assets: generation.assets }
62
+ }
63
+
64
+ export function hasExtension(node: ComponentNode, extensions: RenderContext['extensions']): boolean {
65
+ return extensions.some(e => (!node.plugin || e.plugin === node.plugin) && e.components?.[node.name])
66
+ }
67
+
68
+ const REFERENCES = new Set(['crossReference', 'footnoteReference', 'footnoteDefinition', 'linkReference', 'imageReference', 'definition', 'cite'])
69
+
70
+ function slideLinks(doc: IRDocument): (label: string) => string | undefined {
71
+ const slides = new Map<string, number>()
72
+ const add = (label: unknown, slide: number) => {
73
+ if (typeof label === 'string' && label && !slides.has(label.toLowerCase())) slides.set(label.toLowerCase(), slide)
74
+ }
75
+ const visit = (node: MdastNode, slide: number) => {
76
+ if (!REFERENCES.has(node.type)) {
77
+ add(node.identifier, slide)
78
+ add(node.label, slide)
79
+ }
80
+ node.children?.forEach(child => visit(child, slide))
81
+ }
82
+ for (const section of doc.sections) add(section.id, section.index)
83
+ for (const { section, block } of docBlocks(doc)) {
84
+ add(block.id, section.index)
85
+ for (const tree of nodeMdast(block.node)) visit(tree, section.index)
86
+ }
87
+ return (label) => {
88
+ const slide = slides.get(label.toLowerCase())
89
+ return slide === undefined ? undefined : `/${slide}`
90
+ }
91
+ }
92
+
93
+ function chapters(doc: IRDocument): Map<string, string> {
94
+ const found = new Map<string, string>()
95
+ let open: IRSection[] = []
96
+ for (const section of doc.sections) {
97
+ open = open.filter(outer => outer.depth < section.depth)
98
+ const chapter = open.find(outer => outer.depth >= 1 && outer.title)
99
+ if (chapter) found.set(section.id, chapter.title)
100
+ open.push(section)
101
+ }
102
+ return found
103
+ }
104
+
105
+ function headmatter(doc: IRDocument, gen: Generation): Record<string, unknown> {
106
+ const { ctx } = gen
107
+ const { theme, ...rest } = Object.fromEntries(Object.entries(ctx.config).filter(([key]) => !RENDERER_KEYS.has(key) && key !== 'addons'))
108
+ const local = (name: unknown) => typeof name === 'string' && name.startsWith('.') ? resolve(ctx.deckDir, name) : name
109
+ const { htmlAttrs, ...slidev } = rest
110
+ return {
111
+ theme: theme === undefined ? relativePath(gen.dir, THEME_DIR) : local(theme),
112
+ title: doc.meta.title,
113
+ favicon: FAVICON,
114
+ ...typesSource(doc, gen),
115
+ ...slidev,
116
+ htmlAttrs: { lang: deckLang(doc), ...typeof htmlAttrs === 'object' ? htmlAttrs : {} },
117
+ addons: [...new Set(addonNames(doc, ctx).map(name => addonPath(name, gen)))],
118
+ presmium: ctx.portable(doc.config.presmium, gen.dir),
119
+ mcp: false,
120
+ ...ctx.server || ctx.preview ? { presmiumServer: true } : {},
121
+ ...ctx.preview ? { presmiumDocument: previewDocument(ctx) } : {},
122
+ ...doc.config.execute?.environments ? { presmiumEnvironments: ctx.portable(doc.config.execute.environments, gen.dir) } : {},
123
+ ...runnerOptions(doc, gen),
124
+ }
125
+ }
126
+
127
+ export function previewDocument(ctx: RenderContext): string {
128
+ return relative(ctx.workspaceRoot, ctx.deckDir).split('\\').join('/')
129
+ }
130
+
131
+ function addonNames(doc: IRDocument, ctx: RenderContext): string[] {
132
+ const { addons } = ctx.config
133
+ const plugins = doc.config.plugins.filter(name => ctx.pluginDirs[name] !== undefined)
134
+ const listed = [...plugins, ...Array.isArray(addons) ? addons : [], ...formAddons(doc, ctx)]
135
+ return listed.filter((name): name is string => typeof name === 'string')
136
+ }
137
+
138
+ export function addonDirs(doc: IRDocument, ctx: RenderContext): string[] {
139
+ return addonNames(doc, ctx).flatMap(name => ctx.pluginDirs[name] ?? packageDir(name, ctx.deckDir) ?? [])
140
+ }
141
+
142
+ function addonPath(name: string, gen: Generation): string {
143
+ const dir = gen.ctx.pluginDirs[name] ?? packageDir(name, gen.ctx.deckDir)
144
+ return dir ? relativePath(dirname(gen.dir), dir) : name
145
+ }
146
+
147
+ function relativePath(from: string, dir: string): string {
148
+ const path = relative(from, dir).split('\\').join('/')
149
+ return path.startsWith('.') ? path : `./${path}`
150
+ }
151
+
152
+ function packageDir(name: string, deckDir: string): string | undefined {
153
+ if (name.startsWith('.') || isAbsolute(name)) {
154
+ const dir = resolve(deckDir, name)
155
+ return existsSync(join(dir, 'package.json')) ? dir : undefined
156
+ }
157
+ for (let d = resolve(deckDir); ; d = dirname(d)) {
158
+ const dir = join(d, 'node_modules', name)
159
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir)
160
+ if (dirname(d) === d) return undefined
161
+ }
162
+ }
163
+
164
+ function formAddons(doc: IRDocument, ctx: RenderContext): string[] {
165
+ const { renderers } = doc.config
166
+ if (Object.keys(renderers).length > 0 && !renderers.web) return []
167
+ const name = typeof renderers.web?.package === 'string' ? renderers.web.package : WEB_PACKAGE
168
+ return packageDir(name, ctx.deckDir) ? [name] : []
169
+ }
170
+
171
+ function slide(section: IRSection, head: Record<string, unknown>, gen: Generation): string {
172
+ gen.section = section.id
173
+ const config = section.config ? { [section.index === 1 ? 'presmiumSection' : 'presmium']: gen.ctx.portable(section.config, gen.dir) } : {}
174
+ const chapter = gen.chapters.get(section.id)
175
+ const frontmatter = { ...head, ...section.hints.slides, ...config, ...chapter ? { presmiumChapter: chapter } : {} }
176
+ const notes: IRBlock[] = []
177
+ const parts = section.title ? [md({ type: 'heading', depth: 1, children: [{ type: 'text', value: section.title }] }, undefined, gen)] : []
178
+ parts.push(...blocks(section.blocks, gen, notes, false))
179
+ const body = [`<div class="pm-slide-section" data-section="${escapeHtml(section.id)}">\n\n${parts.join('\n\n')}\n\n</div>`]
180
+ if (notes.length > 0) body.push(`<!--\n${notesText(notes, gen)}\n-->`)
181
+ const open = Object.keys(frontmatter).length > 0 ? `---\n${stringify(frontmatter).trim()}\n---` : '---'
182
+ return `${open}\n\n${body.join('\n\n')}\n`
183
+ }
184
+
185
+ function blocks(list: IRBlock[], gen: Generation, notes: IRBlock[], owned: boolean): string[] {
186
+ const out: string[] = []
187
+ for (const block of list) {
188
+ if (block.role === 'narration') notes.push(block)
189
+ else out.push(visual(block, gen, notes, owned))
190
+ }
191
+ return out
192
+ }
193
+
194
+ function visual(block: IRBlock, gen: Generation, notes: IRBlock[], owned: boolean): string {
195
+ const { node } = block
196
+ const key = owned ? ` key="${escapeHtml(block.id)}"` : ''
197
+ const wrap = (content: string) => (owned || unanchored(block) ? content : `<div class="pm-block" data-block="${escapeHtml(block.id)}" data-section="${escapeHtml(gen.section)}" data-step="${block.at}">\n\n${content}\n\n</div>`)
198
+ if (node.type === 'markdown' && node.reveal === 'items') {
199
+ return wrap(`<v-clicks at="${block.at === 0 ? '+0' : block.at}"${key}>\n\n${md(node.mdast, block, gen)}\n\n</v-clicks>`)
200
+ }
201
+ const content = node.type === 'markdown' ? md(node.mdast, block, gen)
202
+ : node.type === 'code' ? code(node, block, gen, owned)
203
+ : scoped(component(node, block, gen, notes), block.at)
204
+ if (block.at === 0 && block.until === undefined) return wrap(content)
205
+ const click = block.until === undefined ? `v-click="${block.at}"`
206
+ : block.at === 0 ? `v-click.hide="${block.until}"`
207
+ : `v-click="[${block.at}, ${block.until}]"`
208
+ return wrap(`<div ${click}${block.until === undefined ? '' : ' class="pm-step-range"'}${key}>\n\n${content}\n\n</div>`)
209
+ }
210
+
211
+ const NOT_CONTENT = new Set(['html', 'footnoteDefinition', 'definition'])
212
+
213
+ function unanchored(block: IRBlock): boolean {
214
+ const { node } = block
215
+ if (node.type !== 'markdown') return false
216
+ const children = node.mdast.type === 'root' ? node.mdast.children ?? [] : [node.mdast]
217
+ return children.length > 0 && children.every(child => NOT_CONTENT.has(child.type))
218
+ }
219
+
220
+ function md(tree: MdastNode, block: IRBlock | undefined, gen: Generation, notes = false): string {
221
+ const { mdast } = lowerMyst(buttons(verbatim(gen.ctx.preview && !notes ? authorMarks(tree) : tree)), {
222
+ footnotes: 'gfm',
223
+ iframes: 'element',
224
+ cards: 'element',
225
+ links: notes ? undefined : gen.links,
226
+ urls: block ? (url, type) => asset(url, block, gen, type, notes) : undefined,
227
+ htmlUrls: true,
228
+ roles: notes ? 'marks' : 'children',
229
+ })
230
+ return markdown(slidevNodes(mdast, gen))
231
+ }
232
+
233
+ function authorMarks(node: MdastNode): MdastNode {
234
+ if (node.type === 'presmiumRole' && !node.children?.length) return { type: 'html', value: `<span class="pm-role-mark" data-mark="${escapeHtml(roleMark(node))}"></span>` }
235
+ return node.children ? { ...node, children: node.children.map(authorMarks) } : node
236
+ }
237
+
238
+ function verbatim(node: MdastNode): MdastNode {
239
+ if (node.type === 'text' && node.value?.includes('{{')) {
240
+ return { type: 'html', value: `<span v-pre>${escapeHtml(node.value.replace(/[\\`*_[\]$~|]/g, '\\$&'))}</span>` }
241
+ }
242
+ return node.children ? { ...node, children: node.children.map(verbatim) } : node
243
+ }
244
+
245
+ function buttons(node: MdastNode): MdastNode {
246
+ if (!node.children) return node
247
+ const button = (child: MdastNode) => child.type === 'span' && typeof child.class === 'string' && child.class.split(/\s+/).includes('button')
248
+ return {
249
+ ...node,
250
+ children: node.children.flatMap(child => button(child)
251
+ ? [{ type: 'html', value: '<span class="pm-button pm-button--text">' }, ...(child.children ?? []).map(buttons), { type: 'html', value: '</span>' }]
252
+ : [buttons(child)]),
253
+ }
254
+ }
255
+
256
+ function slidevNodes(node: MdastNode, gen: Generation): MdastNode {
257
+ if (node.type === 'code') return { ...node, lang: codeLang(node.lang), meta: null }
258
+ const html = (value: string): MdastNode => ({ type: 'html', value })
259
+ const children = node.children?.flatMap((child): MdastNode[] => {
260
+ if (child.type !== 'link') return [slidevNodes(child, gen)]
261
+ const url = String(child.url)
262
+ const inner = (child.children ?? []).map(c => slidevNodes(c, gen))
263
+ const slide = /^\/(\d+)$/.exec(url)?.[1]
264
+ if (slide) return [html(`<Link to="${slide}">`), ...inner, html('</Link>')]
265
+ if (!gen.files.has(url)) return [{ ...child, children: inner }]
266
+ const title = typeof child.title === 'string' ? ` title="${escapeHtml(child.title)}"` : ''
267
+ return [html(`<a href="${escapeHtml(url)}"${title} target="_blank" rel="noopener">`), ...inner, html('</a>')]
268
+ })
269
+ return children ? { ...node, children } : node
270
+ }
271
+
272
+ function asset(url: string, block: IRBlock, gen: Generation, type: LinkedType | 'prop', notes = false): string | undefined {
273
+ const found = gen.ctx.localAsset(block.source, url)
274
+ if (!found) return undefined
275
+ gen.assets.set(found.name, found)
276
+ if (type === 'image' && !notes) return publishedUrl(found, url, { prefix: '/' })
277
+ const published = publishedUrl(found, url, { prefix: gen.ctx.base })
278
+ if (type === 'link') gen.files.add(published)
279
+ return published
280
+ }
281
+
282
+ function scoped(content: string, at: number): string {
283
+ return at > 0 ? `<PmStepScope :at="${at}">\n\n${content}\n\n</PmStepScope>` : content
284
+ }
285
+
286
+ function link(className: string, href: string | undefined, text: string, attrs = ''): string {
287
+ return href
288
+ ? `<a class="${className}" href="${escapeHtml(href)}" target="_blank" rel="noopener"${attrs}>${escapeHtml(text)}</a>`
289
+ : `<span class="${className}"${attrs}>${escapeHtml(text)}</span>`
290
+ }
291
+
292
+ function originPart(part: OriginPart, attrs = ''): string {
293
+ const own = [
294
+ part.title ? ` title="${escapeHtml(part.title)}"` : '',
295
+ part.status ? ` data-status="${part.status}"` : '',
296
+ part.drift ? ` data-drift="${part.drift}"` : '',
297
+ ].join('')
298
+ return link(`pm-code-origin__${part.kind}`, part.href, part.text, `${own}${attrs}`)
299
+ }
300
+
301
+ function originBar(node: CodeNode, at: number, locale: Locale): string {
302
+ const bar = node.origin && originParts(node, undefined, locale)
303
+ if (!bar) return ''
304
+ const variants = node.variants ?? []
305
+ const shown = `Math.min(Math.max($clicks - ${at}, 0), ${variants.length - 1})`
306
+ const parts = bar.parts.flatMap((part) => {
307
+ if (part.kind === 'file' && variants.length) {
308
+ return variants.map((_, k) => originPart(originParts(node, k, locale)!.parts[0]!, ` v-show="${shown} === ${k}"`))
309
+ }
310
+ if (part.kind === 'variant') {
311
+ const k = bar.parts.filter(p => p.kind === 'variant').indexOf(part)
312
+ return [originPart(part, ` :data-active="${shown} === ${k} ? '' : undefined"`)]
313
+ }
314
+ return [originPart(part)]
315
+ })
316
+ return `<div class="pm-code-origin"${bar.anchor ? ` data-anchor="${escapeHtml(bar.anchor)}"` : ''}>\n${parts.join('\n')}\n</div>`
317
+ }
318
+
319
+ const RUNNER_LANGS: Record<string, string> = {
320
+ 'python': 'python',
321
+ 'javascript': 'javascript',
322
+ 'typescript': 'typescript',
323
+ 'c': 'c',
324
+ 'cpp': 'cpp',
325
+ 'c++': 'cpp',
326
+ 'cc': 'cpp',
327
+ 'cxx': 'cpp',
328
+ 'ruby': 'ruby',
329
+ 'rb': 'ruby',
330
+ 'sql': 'sql',
331
+ 'sqlite': 'sql',
332
+ }
333
+
334
+ const runnerLang = (lang: string): string | undefined => Object.hasOwn(RUNNER_LANGS, languageId(lang)) ? RUNNER_LANGS[languageId(lang)] : undefined
335
+ const INLINE_IMAGE = 16_384
336
+ const IMAGE_TYPES: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif' }
337
+
338
+ const joined = (value: unknown): string => (Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : JSON.stringify(value, null, 2))
339
+ // eslint-disable-next-line no-control-regex
340
+ const plain = (text: string) => text.replace(/\u001B\[[0-9;]*[A-Za-z]/g, '')
341
+
342
+ function imageUrl(type: string, base64: string, gen: Generation): string {
343
+ if (base64.length <= INLINE_IMAGE) return `data:${type};base64,${base64}`
344
+ const data = Buffer.from(base64, 'base64')
345
+ const hash = createHash('sha256').update(data).digest('hex')
346
+ const name = `${hash.slice(0, 8)}-output.${IMAGE_TYPES[type]}`
347
+ const file = join(gen.ctx.workDir, 'outputs', `${hash}.${IMAGE_TYPES[type]}`)
348
+ if (!existsSync(file)) {
349
+ mkdirSync(dirname(file), { recursive: true })
350
+ writeFileSync(file, data)
351
+ }
352
+ gen.assets.set(name, { file, name })
353
+ return `${gen.ctx.base}${name}`
354
+ }
355
+
356
+ function markdownOutput(text: string, block: IRBlock, gen: Generation): string {
357
+ const literal = (node: MdastNode): MdastNode => node.type === 'html' ? { type: 'text', value: String(node.value ?? '') } : node.children ? { ...node, children: node.children.map(literal) } : node
358
+ return md(literal(fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode), block, gen)
359
+ }
360
+
361
+ function richOutput(output: RunOutput, block: IRBlock, gen: Generation): string {
362
+ if (output.output_type === 'stream') return fence('text', plain(joined(output.text)).replace(/\n$/, ''), '')
363
+ if (output.output_type === 'error') return fence('text', plain(output.traceback.join('\n')) || `${output.ename}: ${output.evalue}`, '')
364
+ const { data } = output
365
+ const image = Object.keys(IMAGE_TYPES).find(type => data[type] !== undefined)
366
+ if (image) return `<img class="pm-output__image" alt="" src="${escapeHtml(imageUrl(image, joined(data[image]).replace(/\s/g, ''), gen))}">`
367
+ if (data['image/svg+xml'] !== undefined) return `<img class="pm-output__image" alt="" src="data:image/svg+xml;charset=utf-8,${encodeURIComponent(joined(data['image/svg+xml']))}">`
368
+ if (data['text/markdown'] !== undefined) return markdownOutput(joined(data['text/markdown']), block, gen)
369
+ if (data['text/html'] !== undefined) return `<div class="pm-output__html" v-pre>${sanitizedHtml(joined(data['text/html'])).replace(/\n\s*\n/g, '\n')}</div>`
370
+ if (data['text/latex'] !== undefined) return `$$\n${joined(data['text/latex']).trim().replace(/^\$\$?|\$\$?$/g, '')}\n$$`
371
+ const text = data['text/plain'] ?? data['application/json']
372
+ return text === undefined ? '' : fence('text', plain(joined(text)).replace(/\n$/, ''), '')
373
+ }
374
+
375
+ function output(run: RunResult, block: IRBlock, gen: Generation, prose = false): string {
376
+ const parts = run.outputs
377
+ ? run.outputs.map(o => richOutput(o, block, gen))
378
+ : [[run.stdout, run.stderr].filter(Boolean).join('\n').replace(/\n$/, '')].filter(Boolean).map(text => fence('text', text, ''))
379
+ if (prose) return parts.filter(Boolean).join('\n\n')
380
+ const notes = outputNotes(run, gen.locale)
381
+ if (parts.every(part => !part) && notes.length === 0) return ''
382
+ if (notes.length) parts.push(`<p class="pm-output__note">${escapeHtml(notes.join(' · '))}</p>`)
383
+ return `<div class="pm-output">\n\n${parts.filter(Boolean).join('\n\n')}\n\n</div>`
384
+ }
385
+
386
+ const runnerKey = (section: string, block: string) => `${section}/${block}`
387
+
388
+ function runsLive(node: CodeNode, gen: Generation): boolean {
389
+ return Boolean(gen.live && node.run && node.run.when !== 'compile' && node.echo !== false && !node.variants?.length && !node.highlight?.length && runnerLang(node.lang))
390
+ }
391
+
392
+ function importedPackages(code: string): string[] {
393
+ const names = [...code.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*(['"])([^'"\n]+)\1/g)].map(match => match[2]!)
394
+ return names
395
+ .filter(name => !/^[./]/.test(name) && !name.includes(':'))
396
+ .map(name => name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/'))
397
+ }
398
+
399
+ function typesSource(doc: IRDocument, gen: Generation): Record<string, unknown> {
400
+ const scripts = doc.sections.flatMap(section => section.blocks).map(block => block.node)
401
+ .filter((node): node is CodeNode => node.type === 'code' && runsLive(node, gen) && ['javascript', 'typescript'].includes(runnerLang(node.lang)!))
402
+ return scripts.some(node => importedPackages(node.value).some(name => !packageDir(name, gen.dir))) ? { monacoTypesSource: 'none' } : {}
403
+ }
404
+
405
+ function runnerRecording(run: RunResult, gen: Generation): Record<string, unknown> {
406
+ const outputs = run.outputs?.map((output): RunOutput => {
407
+ if (output.output_type !== 'display_data' && output.output_type !== 'execute_result') return output
408
+ const image = Object.keys(IMAGE_TYPES).find(type => output.data[type] !== undefined)
409
+ const base64 = image && joined(output.data[image]).replace(/\s/g, '')
410
+ if (!image || !base64 || base64.length <= INLINE_IMAGE) return output
411
+ return { ...output, data: { 'text/html': `<img class="pm-run-image" alt="" src="${escapeHtml(imageUrl(image, base64, gen))}">` } }
412
+ })
413
+ return { ok: run.ok, stdout: run.stdout, stderr: run.stderr, ...outputs ? { outputs } : {} }
414
+ }
415
+
416
+ function runnerOptions(doc: IRDocument, gen: Generation): Record<string, unknown> {
417
+ const runs: Record<string, Record<string, unknown>> = {}
418
+ for (const section of doc.sections) {
419
+ for (const block of section.blocks) {
420
+ const { node } = block
421
+ if (node.type !== 'code' || !runsLive(node, gen)) continue
422
+ const { executor, session, timeoutMs } = node.run!
423
+ const recording = node.outputs?.at(-1)
424
+ const key = runnerKey(section.id, block.id)
425
+ runs[key] = {
426
+ block: key,
427
+ ...executor ? { executor } : {},
428
+ ...session ? { session } : {},
429
+ ...timeoutMs ? { timeout: timeoutMs } : {},
430
+ ...recording ? { recording: runnerRecording(recording, gen) } : {},
431
+ }
432
+ }
433
+ }
434
+ return Object.keys(runs).length ? { presmiumRuns: runs } : {}
435
+ }
436
+
437
+ function monacoRun(node: CodeNode, block: IRBlock, gen: Generation): string {
438
+ const options = `runnerOptions:$slidev.configs.presmiumRuns['${runnerKey(gen.section, block.id).replace(/['\\]/g, '\\$&')}']`
439
+ return fence(runnerLang(node.lang)!, node.value, ` {monaco-run} {autorun:'once',height:'auto',${options}}`)
440
+ }
441
+
442
+ function code(node: CodeNode, block: IRBlock, gen: Generation, owned: boolean): string {
443
+ const at = block.at
444
+ const stages = (sets: number[][]) => `{${sets.map(lineRanges).join('|')}}${sets.length > 1 ? `{at:${at + 1}}` : ''}`
445
+ const title = (label?: string) => label ? ` [${label}]` : ''
446
+ let out: string
447
+ if (node.echo === false) {
448
+ const runs = node.outputs ?? []
449
+ const prose = runs.length > 0 && runs.every(r => r.outputs?.length && r.outputs.every(o => o.output_type !== 'stream' && o.output_type !== 'error' && o.data['text/markdown'] !== undefined))
450
+ return runs.map(run => output(run, block, gen, prose)).filter(Boolean).join('\n\n')
451
+ } else if (!owned && runsLive(node, gen)) {
452
+ return [originBar(node, at, gen.locale), monacoRun(node, block, gen)].filter(Boolean).join('\n\n')
453
+ } else if (node.variants?.length) {
454
+ const steps = node.variants.map((variant, k) => {
455
+ const lines = node.highlight?.[k]
456
+ return fence(node.lang, variant.value, `${title(variant.label)}${lines ? ` ${stages([lines])}` : ''}`)
457
+ })
458
+ out = `${'`'.repeat(fenceLength(steps.join('\n')))}md magic-move {at:${at + 1}}\n${steps.join('\n')}\n${'`'.repeat(fenceLength(steps.join('\n')))}`
459
+ } else {
460
+ out = fence(node.lang, node.value, `${title(node.title)}${node.highlight?.length ? ` ${stages(node.highlight)}` : ''}`)
461
+ }
462
+ const outputs = owned ? [] : (node.outputs ?? []).map(run => output(run, block, gen))
463
+ return [originBar(node, at, gen.locale), out, ...outputs].filter(Boolean).join('\n\n')
464
+ }
465
+
466
+ function published(node: ComponentNode, block: IRBlock, gen: Generation): ComponentNode {
467
+ if (!node.assets) return node
468
+ let props = node.props
469
+ for (const [path, url] of Object.entries(node.assets)) props = withProp(props, path, asset(url, block, gen, 'prop') ?? url)
470
+ return { ...node, props }
471
+ }
472
+
473
+ function empty(fallback: MdastNode | undefined): boolean {
474
+ return !fallback || (fallback.type === 'root' && !fallback.children?.length)
475
+ }
476
+
477
+ function component(original: ComponentNode, block: IRBlock, gen: Generation, notes: IRBlock[]): string {
478
+ const node = published(original, block, gen)
479
+ const extension = gen.ctx.extensionFor(node)
480
+ if (!extension && !node.plugin) return node.fallback ? md(node.fallback, block, gen) : `> ${gen.t.webOnly(node.name)}`
481
+ let live: string | undefined
482
+ if (extension) {
483
+ try {
484
+ const output = extension(node, block)
485
+ if (output !== undefined && output !== null) live = String(output)
486
+ } catch (error) {
487
+ gen.ctx.report({
488
+ checkId: 'presmium.slides',
489
+ severity: 'warning',
490
+ message: `the slides extension of ${node.name} (plugin ${node.plugin || 'unknown'}) failed: ${errorMessage(error)}; the slide shows the component without it`,
491
+ source: { file: block.source.file, line: block.source.line },
492
+ })
493
+ }
494
+ }
495
+ live ??= tag(node, gen, notes)
496
+ const content = empty(node.fallback) && (node.layer === 'overlay' || !node.fallback)
497
+ ? live
498
+ : `<PmStaticFallback>\n\n${live}\n\n<template v-slot:fallback>\n\n${md(unfolded(node.fallback!), block, gen)}\n\n</template>\n</PmStaticFallback>`
499
+ return `<PmBoundary name="${escapeHtml(node.name)}">\n\n${content}\n\n</PmBoundary>`
500
+ }
501
+
502
+ function unfolded(node: MdastNode): MdastNode {
503
+ if (node.type === 'html' && typeof node.value === 'string') return { ...node, value: node.value.replace(/<details(?=[^>]*\sdata-static="open")/g, '<details open') }
504
+ return node.children ? { ...node, children: node.children.map(unfolded) } : node
505
+ }
506
+
507
+ function tag(node: ComponentNode, gen: Generation, notes: IRBlock[]): string {
508
+ const attrs = Object.entries(node.props).map(([name, value]) => typeof value === 'string' && !node.assets?.[name]
509
+ ? ` ${name}="${escapeHtml(value).replace(/\n/g, '&#10;')}"`
510
+ : ` :${name}='${JSON.stringify(value).replace(/'/g, '\\u0027').replace(/&/g, '\\u0026')}'`).join('')
511
+ const children = node.children ? blocks(node.children, gen, notes, true) : []
512
+ return children.length > 0 ? `<${node.name}${attrs}>\n\n${children.join('\n\n')}\n\n</${node.name}>` : `<${node.name}${attrs} />`
513
+ }
514
+
515
+ function notesText(notes: IRBlock[], gen: Generation): string {
516
+ let step = 0
517
+ return [...notes].sort((a, b) => a.at - b.at).map((block) => {
518
+ const { node } = block
519
+ let text = node.type === 'markdown' ? md(node.mdast, block, gen, true)
520
+ : node.type === 'code' ? fence(node.lang, node.value, '')
521
+ : node.fallback ? md(node.fallback, block, gen, true) : ''
522
+ if (block.at > step) {
523
+ const marker = block.at - step === 1 ? '[click]' : `[click:${block.at - step}]`
524
+ text = node.type === 'markdown' && node.mdast.children?.[0]?.type === 'paragraph' ? `${marker} ${text}` : `${marker}\n\n${text}`
525
+ step = block.at
526
+ }
527
+ return text
528
+ }).join('\n\n').replace(/-->/g, '--&gt;')
529
+ }
530
+
531
+ function fence(lang: string, value: string, info: string): string {
532
+ const ticks = '`'.repeat(fenceLength(value))
533
+ return `${ticks}${codeLang(lang)}${info}\n${value}\n${ticks}`
534
+ }
535
+
536
+ function fenceLength(value: string): number {
537
+ return Math.max(3, ...[...value.matchAll(/`+/g)].map(m => m[0].length + 1))
538
+ }
539
+
540
+ function lineRanges(lines: number[]): string {
541
+ if (lines.length === 0) return 'all'
542
+ const sorted = [...new Set(lines)].sort((a, b) => a - b)
543
+ const ranges: string[] = []
544
+ for (let i = 0; i < sorted.length; i++) {
545
+ const start = sorted[i]!
546
+ while (sorted[i + 1] === sorted[i]! + 1) i++
547
+ ranges.push(sorted[i] === start ? `${start}` : `${start}-${sorted[i]}`)
548
+ }
549
+ return ranges.join(',')
550
+ }
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import SlideFooter from '../parts/SlideFooter.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <div class="slidev-layout cover pm-book pm-book--cover">
7
+ <div class="pm-book__content">
8
+ <slot />
9
+ </div>
10
+ <SlideFooter :title="false" />
11
+ </div>
12
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { useSlideContext } from '@slidev/client'
3
+ import { computed } from 'vue'
4
+ import SlideFooter from '../parts/SlideFooter.vue'
5
+
6
+ const { $frontmatter } = useSlideContext()
7
+ const chapter = computed(() => (typeof $frontmatter.presmiumChapter === 'string' ? $frontmatter.presmiumChapter : ''))
8
+ </script>
9
+
10
+ <template>
11
+ <div class="slidev-layout default pm-book">
12
+ <p v-if="chapter" class="pm-book__chapter">
13
+ {{ chapter }}
14
+ </p>
15
+ <div class="pm-book__content">
16
+ <slot />
17
+ </div>
18
+ <SlideFooter />
19
+ </div>
20
+ </template>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@lincyaw/slidev-theme-book",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Presmium's default Slidev theme, 书卷: the slides renderer writes it into the deck when renderers.slides names no theme",
6
+ "keywords": [
7
+ "slidev-theme",
8
+ "slidev"
9
+ ],
10
+ "engines": {
11
+ "slidev": ">=53.0.0"
12
+ },
13
+ "slidev": {
14
+ "colorSchema": "both",
15
+ "defaults": {
16
+ "fonts": {
17
+ "sans": "IBM Plex Sans",
18
+ "serif": "Source Serif 4",
19
+ "mono": "IBM Plex Mono",
20
+ "provider": "none"
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { useMessages } from '@lincyaw/host'
3
+ import { useSlideContext } from '@slidev/client'
4
+ import { computed } from 'vue'
5
+
6
+ const props = withDefaults(defineProps<{ title?: boolean }>(), { title: true })
7
+
8
+ const MESSAGES = {
9
+ 'zh-CN': { steps: (at: number, of: number) => `第 ${at} 步,共 ${of} 步` },
10
+ 'en': { steps: (at: number, of: number) => `Step ${at} of ${of}` },
11
+ }
12
+
13
+ const t = useMessages(MESSAGES)
14
+ const { $slidev, $page, $clicksContext } = useSlideContext()
15
+ const total = computed(() => $clicksContext.total)
16
+ const done = computed(() => Math.min($clicksContext.current, total.value))
17
+ const deck = computed(() => (props.title ? String($slidev.configs.title ?? '') : ''))
18
+ </script>
19
+
20
+ <template>
21
+ <footer class="pm-book-footer">
22
+ <span v-if="deck" class="pm-book-footer__title">{{ deck }}</span>
23
+ <span v-if="total > 0" class="pm-book-footer__steps" role="img" :aria-label="t.steps(done, total)" :style="{ '--steps': total }">
24
+ <span v-for="i in total" :key="i" :data-done="i <= done ? '' : undefined" />
25
+ </span>
26
+ <span class="pm-book-footer__page">{{ $page }} / {{ $slidev.nav.total }}</span>
27
+ </footer>
28
+ </template>
@@ -0,0 +1,9 @@
1
+ const LABELS = `
2
+ .edgeLabel, .edgeLabel p, .edgeLabel span, .labelBkg { background-color: var(--pm-color-surface) !important; color: var(--pm-color-ink) !important; }
3
+ .edgeLabel rect { fill: var(--pm-color-surface) !important; }
4
+ .edgeLabel text, .edgeLabel tspan { fill: var(--pm-color-ink) !important; }
5
+ `
6
+
7
+ export default function setup() {
8
+ return { themeCSS: LABELS }
9
+ }