@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.
- package/LICENSE +202 -0
- package/README.md +24 -2
- package/package.json +58 -3
- package/src/deck/style.css +215 -0
- package/src/node/html.ts +15 -0
- package/src/node/markdown.ts +20 -0
- package/src/node/presmium.ts +276 -0
- package/src/node/slidev.ts +550 -0
- package/theme/layouts/cover.vue +12 -0
- package/theme/layouts/default.vue +20 -0
- package/theme/package.json +24 -0
- package/theme/parts/SlideFooter.vue +28 -0
- package/theme/setup/mermaid.ts +9 -0
- package/theme/setup/shiki.ts +36 -0
- package/theme/setup/vite-plugins.ts +25 -0
- package/theme/styles/book.css +282 -0
- package/theme/styles/index.ts +2 -0
|
@@ -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, ' ')}"`
|
|
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, '-->')
|
|
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
|
+
}
|