@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,276 @@
1
+ import { spawn } from 'node:child_process'
2
+ import type { ChildProcess } from 'node:child_process'
3
+ import { existsSync, realpathSync } from 'node:fs'
4
+ import { copyFile, mkdir, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
5
+ import { createRequire } from 'node:module'
6
+ import { dirname, join, resolve, sep } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+ import { errorMessage, irBlocks, isRecord, nodeMdast, previewFrameAncestors, PREVIEW_REVISION } from '@lincyaw/kernel'
9
+ import type { IRDocument, LocalAsset, MdastNode, PresmiumNodePlugin, RenderContext } from '@lincyaw/kernel'
10
+ import { copyPublished, FONT_LICENSE, fontLicense, optimizeDeps, TYPESCRIPT_IMPORT } from '@lincyaw/kernel/node'
11
+ import { addonDirs, hasExtension, previewDocument, slidevMarkdown } from './slidev.ts'
12
+ import type { SlidevDeck } from './slidev.ts'
13
+
14
+ export const SLIDEV_DIR = '.presmium/slides'
15
+
16
+ const ADDON_CORE = '@lincyaw/slidev-addon-core'
17
+ const PREVIEW_FILE = 'preview.json'
18
+
19
+ const STYLE = fileURLToPath(new URL('../deck/style.css', import.meta.url))
20
+ const PACKAGE_DIR = fileURLToPath(new URL('../..', import.meta.url))
21
+ const require = createRequire(import.meta.url)
22
+ const { version } = require('../../package.json') as { version: string }
23
+
24
+ const plugin: PresmiumNodePlugin = {
25
+ id: 'presmium.slides',
26
+ version,
27
+ kernel: '^1.0.0',
28
+ renderer: {
29
+ id: 'slides',
30
+ site: true,
31
+ support: (node, extensions) => node.type === 'component' && !node.plugin && !hasExtension(node, extensions) ? 'degraded' : 'full',
32
+ async prepare(doc, ctx) {
33
+ await writeDeck(ctx.workDir, ctx, slidevMarkdown(doc, ctx, ctx.workDir), doc)
34
+ },
35
+ async build(doc, ctx) {
36
+ const dir = ctx.workDir
37
+ await writeDeck(dir, ctx, slidevMarkdown(doc, ctx, dir), doc)
38
+ await rm(ctx.outDir, { recursive: true, force: true })
39
+ await slidev(['build', 'slides.md', '--out', ctx.outDir, ...base(ctx)], dir).catch((error: unknown) => {
40
+ throw located(error, doc, ctx)
41
+ })
42
+ ctx.report({
43
+ checkId: 'presmium.slides',
44
+ severity: 'info',
45
+ message: ctx.base === '/'
46
+ ? `Slidev deck in ${SLIDEV_DIR}/slides.md; export it with \`slidev export ${SLIDEV_DIR}/slides.md\``
47
+ : `Slidev deck in ${SLIDEV_DIR}/slides.md, written for the base ${ctx.base}; \`slidev export\` serves it at the root, where the local files it links are not found: export with \`presmium-export\` (@lincyaw/slidev-addon-publish)`,
48
+ })
49
+ return { outDir: ctx.outDir, skipped: [] }
50
+ },
51
+ async dev(doc, ctx) {
52
+ const dir = ctx.workDir
53
+ await writeDeck(dir, ctx, slidevMarkdown(doc, ctx, dir), doc)
54
+ await writeRevision(dir, ctx, doc)
55
+ const port = Number(ctx.config.port)
56
+ const server = await slidevDev(dir, [...Number.isInteger(port) && port > 0 ? ['--port', String(port)] : [], ...base(ctx)])
57
+ return {
58
+ url: server.url,
59
+ async update(next) {
60
+ const deck = slidevMarkdown(next, ctx, dir)
61
+ await publish(dir, deck.assets)
62
+ const file = join(dir, 'slides.md')
63
+ if (await readFile(file, 'utf8').catch(() => '') !== deck.markdown) await writeFile(file, deck.markdown)
64
+ await writeRevision(dir, ctx, next)
65
+ },
66
+ close: server.close,
67
+ }
68
+ },
69
+ },
70
+ }
71
+
72
+ export default plugin
73
+
74
+ const hasHtml = (node: MdastNode): boolean => node.type === 'html' || (node.children ?? []).some(hasHtml)
75
+
76
+ function located(error: unknown, doc: IRDocument, ctx: RenderContext): unknown {
77
+ const output = error instanceof SlidevError ? error.output : errorMessage(error)
78
+ const at = /slides\.md__slidev_(\d+)\.md(?::\d+(?::\d+)?)?\s*\n(?:\w*Error: )?([^\n]+)/.exec(output)
79
+ const section = at && doc.sections[Number(at[1]) - 1]
80
+ if (!at || !section) return error
81
+ const reason = at[2]!.replace(/^\w*Error: /, '').trim()
82
+ const raw = [...irBlocks(section.blocks)].filter(block => block.role === 'visual' && nodeMdast(block.node).some(hasHtml))
83
+ const source = raw.length === 1 ? raw[0]!.source : section.source
84
+ ctx.report({
85
+ checkId: 'presmium.slides',
86
+ severity: 'error',
87
+ message: `slide ${at[1]} (${section.title || section.id}) does not build: ${reason}`,
88
+ source: { file: source.file, line: source.line },
89
+ })
90
+ return new Error(`slidev build failed at slide ${at[1]}: ${reason}`)
91
+ }
92
+
93
+ function base(ctx: RenderContext): string[] {
94
+ return ctx.base !== '/' ? ['--base', ctx.base] : []
95
+ }
96
+
97
+ async function writeDeck(dir: string, ctx: RenderContext, deck: SlidevDeck, doc: IRDocument): Promise<void> {
98
+ await mkdir(dir, { recursive: true })
99
+ await writeFile(join(dir, '.gitignore'), '*\n')
100
+ const packages = [PACKAGE_DIR, ...addonDirs(doc, ctx)]
101
+ const optimize = optimizeDeps(dir, packages)
102
+ await writeFile(join(dir, 'vite.config.ts'), viteConfig(ctx, await vitePluginEntries(ctx, doc.config.presmium), optimize, allowed(ctx, packages)))
103
+ await copyFile(STYLE, join(dir, 'style.css'))
104
+ await rm(join(dir, 'public'), { recursive: true, force: true })
105
+ await mkdir(join(dir, 'public'))
106
+ const shared = join(ctx.deckDir, 'public')
107
+ for (const entry of await readdir(shared).catch(() => [])) await symlink(join(shared, entry), join(dir, 'public', entry), 'junction')
108
+ await publish(dir, deck.assets)
109
+ const license = join(dir, 'public', FONT_LICENSE)
110
+ if (!existsSync(join(dir, 'public', FONT_LICENSE.split('/')[0]!))) {
111
+ await mkdir(dirname(license), { recursive: true })
112
+ await writeFile(license, fontLicense(import.meta.url))
113
+ }
114
+ await rm(join(dir, 'components'), { recursive: true, force: true })
115
+ if (existsSync(join(ctx.deckDir, 'components'))) await symlink(join(ctx.deckDir, 'components'), join(dir, 'components'), 'junction')
116
+ await writePreviewSetup(dir, ctx)
117
+ await writeFile(join(dir, 'slides.md'), deck.markdown)
118
+ }
119
+
120
+ async function writePreviewSetup(dir: string, ctx: RenderContext): Promise<void> {
121
+ await rm(join(dir, 'setup'), { recursive: true, force: true })
122
+ await rm(join(dir, PREVIEW_FILE), { force: true })
123
+ const core = ctx.pluginDirs[ADDON_CORE]
124
+ if (!ctx.preview || !core) return
125
+ const agent = join(core, 'src/client/preview.ts')
126
+ const options = { origins: ctx.preview.origins, document: previewDocument(ctx) }
127
+ await mkdir(join(dir, 'setup'))
128
+ await writeFile(join(dir, 'setup/root.ts'), `// Generated by @lincyaw/render-slides for a preview of the workspace service; do not edit.
129
+ import { startSlidesPreview } from ${JSON.stringify(agent)}
130
+
131
+ export default function () {
132
+ startSlidesPreview(${JSON.stringify(options)})
133
+ }
134
+ `)
135
+ }
136
+
137
+ async function writeRevision(dir: string, ctx: RenderContext, doc: IRDocument): Promise<void> {
138
+ if (!ctx.preview) return
139
+ const revision = doc.meta[PREVIEW_REVISION]
140
+ const json = `${JSON.stringify(typeof revision === 'string' ? { revision } : {})}\n`
141
+ const file = join(dir, PREVIEW_FILE)
142
+ if (await readFile(file, 'utf8').catch(() => '') !== json) await writeFile(file, json)
143
+ }
144
+
145
+ interface VitePluginsEntry {
146
+ file: string
147
+ config: Record<string, unknown>
148
+ }
149
+
150
+ async function nodeEntry(dir: string): Promise<string | undefined> {
151
+ const manifest: unknown = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8').catch(() => '{}'))
152
+ const exports = isRecord(manifest) ? manifest.exports : undefined
153
+ const entry = isRecord(exports) ? exports['./presmium'] : undefined
154
+ return typeof entry === 'string' ? resolve(dir, entry) : undefined
155
+ }
156
+
157
+ async function vitePluginEntries(ctx: RenderContext, presmium: Record<string, unknown>): Promise<VitePluginsEntry[]> {
158
+ if (ctx.vitePlugins.length === 0) return []
159
+ const entries: VitePluginsEntry[] = []
160
+ for (const name of ctx.plugins) {
161
+ const dir = ctx.pluginDirs[name]
162
+ const file = dir && await nodeEntry(dir)
163
+ if (!file) continue
164
+ const plugin = (await import(pathToFileURL(file).href).catch(() => ({}))).default as PresmiumNodePlugin | undefined
165
+ if (typeof plugin?.vitePlugins !== 'function') continue
166
+ const config = presmium[plugin.id]
167
+ entries.push({ file, config: isRecord(config) ? config : {} })
168
+ }
169
+ return entries
170
+ }
171
+
172
+ function allowed(ctx: RenderContext, packages: string[]): string[] {
173
+ const installed = packages.flatMap((dir) => {
174
+ const parts = realpathSync(dir).split(sep)
175
+ const at = parts.indexOf('node_modules')
176
+ return at < 0 ? [] : [parts.slice(0, at + 1).join(sep)]
177
+ })
178
+ return [...new Set([ctx.deckDir, ...installed])]
179
+ }
180
+
181
+ function viteConfig(ctx: RenderContext, entries: VitePluginsEntry[], optimize: { include: string[], exclude: string[] }, allow: string[]): string {
182
+ const imports = entries.map(({ file }, i) => `import plugin${i} from ${JSON.stringify(file)}\n`).join('')
183
+ const deck = JSON.stringify({ deckDir: ctx.deckDir, workspaceRoot: ctx.workspaceRoot })
184
+ const plugins = entries.map(({ config }, i) => ` ...(plugin${i}.vitePlugins?.({ ...deck, config: ${JSON.stringify(config)} }) ?? []),\n`).join('')
185
+ const server = {
186
+ ...ctx.server ? { proxy: { '^/__presmium/': { target: ctx.server.url, ws: true } }, fs: { allow } } : {},
187
+ ...ctx.preview ? { headers: { 'Content-Security-Policy': previewFrameAncestors(ctx.preview.origins) } } : {},
188
+ }
189
+ const served = Object.keys(server).length > 0 ? ` server: ${JSON.stringify(server)},\n` : ''
190
+ const optimized = optimize.exclude.length > 0 ? ` optimizeDeps: ${JSON.stringify(optimize)},\n` : ''
191
+ const preview = ctx.preview ? ` presmiumPreview(),\n` : ''
192
+ return `${imports}${ctx.preview ? previewPlugin() : ''}\nconst deck = ${deck}\n\nexport default {\n cacheDir: 'node_modules/.vite',\n plugins: [\n${preview}${plugins} ],\n${served}${optimized}}\n`
193
+ }
194
+
195
+ function previewPlugin(): string {
196
+ return `import { readFileSync } from 'node:fs'
197
+ import { fileURLToPath } from 'node:url'
198
+
199
+ function presmiumPreview() {
200
+ const file = fileURLToPath(new URL(${JSON.stringify(`./${PREVIEW_FILE}`)}, import.meta.url))
201
+ const read = () => {
202
+ try {
203
+ return JSON.parse(readFileSync(file, 'utf8'))
204
+ } catch {
205
+ return {}
206
+ }
207
+ }
208
+ return {
209
+ name: 'presmium:preview',
210
+ configureServer(server) {
211
+ server.ws.on('presmium:preview', (_data, client) => client.send('presmium:revision', read()))
212
+ server.watcher.add(file)
213
+ let timer
214
+ server.watcher.on('change', (changed) => {
215
+ if (changed !== file) return
216
+ clearTimeout(timer)
217
+ timer = setTimeout(() => server.ws.send('presmium:revision', read()), 150)
218
+ })
219
+ },
220
+ }
221
+ }
222
+ `
223
+ }
224
+
225
+ async function publish(dir: string, assets: Map<string, LocalAsset>): Promise<void> {
226
+ for (const found of assets.values()) copyPublished(found, join(dir, 'public'))
227
+ }
228
+
229
+ function slidevBin(): string {
230
+ const pkg = require.resolve('@slidev/cli/package.json')
231
+ return join(dirname(pkg), (require(pkg) as { bin: { slidev: string } }).bin.slidev)
232
+ }
233
+
234
+ class SlidevError extends Error {
235
+ readonly output: string
236
+ constructor(message: string, output: string) {
237
+ super(`${message}\n${output.slice(-4000)}`)
238
+ this.output = output
239
+ }
240
+ }
241
+
242
+ function slidev(args: string[], cwd: string): Promise<void> {
243
+ return new Promise((resolve, reject) => {
244
+ const child = spawn(process.execPath, [...TYPESCRIPT_IMPORT, slidevBin(), ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
245
+ let output = ''
246
+ child.stdout.on('data', (chunk: Buffer) => output += chunk)
247
+ child.stderr.on('data', (chunk: Buffer) => output += chunk)
248
+ child.on('error', reject)
249
+ child.on('close', code => code === 0 ? resolve() : reject(new SlidevError(`slidev ${args[0]} exited with code ${code}`, output)))
250
+ })
251
+ }
252
+
253
+ function slidevDev(dir: string, args: string[]): Promise<{ url: string, close(): Promise<void> }> {
254
+ const child: ChildProcess = spawn(process.execPath, [...TYPESCRIPT_IMPORT, slidevBin(), 'slides.md', ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
255
+ const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
256
+ const close = async () => {
257
+ if (child.exitCode === null) child.kill('SIGTERM')
258
+ await exited
259
+ }
260
+ return new Promise((resolve, reject) => {
261
+ let output: string | undefined = ''
262
+ child.stderr!.on('data', (chunk: Buffer) => {
263
+ if (output !== undefined) output += chunk
264
+ })
265
+ child.stdout!.on('data', (chunk: Buffer) => {
266
+ if (output === undefined) return
267
+ output += chunk
268
+ const url = /(http:\/\/localhost:\d+\/\S*)/.exec(output.replace(/\x1B\[[\d;]*m/g, ''))?.[1]
269
+ if (!url) return
270
+ output = undefined
271
+ resolve({ url, close })
272
+ })
273
+ child.once('error', reject)
274
+ child.once('exit', code => reject(new Error(`slidev exited with code ${code}\n${output?.slice(-4000) ?? ''}`)))
275
+ })
276
+ }