返回 slidev
cli.ts
根目录 / packages / slidev / node / cli.ts
1 import type { ResolvedSlidevOptions, SlidevConfig, SlidevData } from '@slidev/types'
2 import type { LogLevel, ViteDevServer } from 'vite'
3 import type { Argv } from 'yargs'
4 import { execFile } from 'node:child_process'
5 import fs from 'node:fs/promises'
6 import os from 'node:os'
7 import process from 'node:process'
8 import * as readline from 'node:readline'
9 import { verifyConfig } from '@slidev/parser'
10 import { blue, bold, cyan, cyanBright, dim, gray, green, underline, yellow } from 'ansis'
11 import equal from 'fast-deep-equal'
12 import { getPort } from 'get-port-please'
13 import openBrowser from 'open'
14 import path from 'pathe'
15 import yargs from 'yargs'
16 import { version } from '../package.json'
17 import { createServer } from './commands/serve'
18 import { getThemeMeta, resolveTheme } from './integrations/themes'
19 import { resolveOptions } from './options'
20 import { parser } from './parser'
21 import { isInstalledGlobally, resolveEntry } from './resolver'
22 import setupPreparser from './setups/preparser'
23 import { updateFrontmatterPatch } from './utils'
24
25 const RE_NODE_MODULES_OR_GIT = /node_modules|\.git/
26
27 const CONFIG_RESTART_FIELDS: (keyof SlidevConfig)[] = [
28 'monaco',
29 'routerMode',
30 'fonts',
31 'css',
32 'mdc',
33 'editor',
34 'theme',
35 'seoMeta',
36 ]
37
38 const FILES_CHANGE_RESTART = [
39 'setup/shiki.ts',
40 'setup/katex.ts',
41 'setup/preparser.ts',
42 'setup/transformers.ts',
43 'setup/unocss.ts',
44 'setup/vite-plugins.ts',
45 'uno.config.ts',
46 'unocss.config.ts',
47 'vite.config.{js,ts,mjs,mts}',
48 ]
49
50 setupPreparser()
51
52 const cli = yargs(process.argv.slice(2))
53 .scriptName('slidev')
54 .usage('$0 [args]')
55 .version(version)
56 .strict()
57 .showHelpOnFail(false)
58 .alias('h', 'help')
59 .alias('v', 'version')
60
61 cli.command(
62 '* [entry]',
63 'Start a local server for Slidev',
64 args => commonOptions(args)
65 .option('port', {
66 alias: 'p',
67 type: 'number',
68 describe: 'port',
69 })
70 .option('open', {
71 alias: 'o',
72 default: false,
73 type: 'boolean',
74 describe: 'open in browser',
75 })
76 .option('remote', {
77 type: 'string',
78 describe: 'listen public host and enable remote control',
79 })
80 .option('tunnel', {
81 default: false,
82 type: 'boolean',
83 describe: 'open a Cloudflare Quick Tunnel to make Slidev available on the internet',
84 })
85 .option('log', {
86 default: 'warn',
87 type: 'string',
88 choices: ['error', 'warn', 'info', 'silent'],
89 describe: 'log level',
90 })
91 .option('inspect', {
92 default: false,
93 type: 'boolean',
94 describe: 'enable the inspect plugin for debugging',
95 })
96 .option('force', {
97 alias: 'f',
98 default: false,
99 type: 'boolean',
100 describe: 'force the optimizer to ignore the cache and re-bundle',
101 })
102 .option('bind', {
103 type: 'string',
104 default: '0.0.0.0',
105 describe: 'specify which IP addresses the server should listen on in remote mode',
106 })
107 .option('base', {
108 type: 'string',
109 describe: 'base URL. Example: /demo/',
110 default: '/',
111 })
112 .strict()
113 .help(),
114 async ({ entry, theme, port: userPort, open, log, remote, tunnel, force, inspect, bind, base }) => {
115 let server: ViteDevServer | undefined
116 let port = 3030
117
118 let lastRemoteUrl: string | undefined
119
120 let restartTimer: ReturnType<typeof setTimeout> | undefined
121 async function restartServer() {
122 await server?.close()
123 server = undefined
124 clearTimeout(restartTimer)
125 restartTimer = setTimeout(() => {
126 console.log(yellow('\n restarting...\n'))
127 initServer()
128 }, 500)
129 }
130
131 async function initServer() {
132 const options = await resolveOptions({ entry, remote, theme, inspect, base }, 'dev')
133 const host = remote !== undefined ? bind : 'localhost'
134 port = userPort || await getPort({
135 port: 3030,
136 random: false,
137 portRange: [3030, 4000],
138 host,
139 })
140 server = (await createServer(
141 options,
142 {
143 server: {
144 port,
145 strictPort: true,
146 host,
147 // @ts-expect-error Vite <= 4
148 force,
149 // Allow Cloudflare Quick Tunnel domains when tunneling is enabled
150 ...(tunnel && remote != null ? { allowedHosts: ['.trycloudflare.com'] } : {}),
151 },
152 optimizeDeps: {
153 // Vite 5
154 force,
155 },
156 logLevel: log as LogLevel,
157 base,
158 },
159 {
160 async loadData(loadedSource) {
161 const { data: oldData, entry } = options
162 const loaded = await parser.load(options, entry, loadedSource, 'dev')
163
164 const themeRaw = theme || loaded.headmatter.theme as string || 'default'
165 if (options.themeRaw !== themeRaw) {
166 console.log(yellow('\n restarting on theme change\n'))
167 restartServer()
168 return false
169 }
170 // Because themeRaw is not changed, we don't resolve it again
171 const themeMeta = options.themeRoots[0] ? await getThemeMeta(themeRaw, options.themeRoots[0]) : undefined
172 const newData: SlidevData = {
173 ...loaded,
174 themeMeta,
175 config: parser.resolveConfig(loaded.headmatter, themeMeta, entry),
176 }
177
178 if (CONFIG_RESTART_FIELDS.some(i => !equal(newData.config[i], oldData.config[i]))) {
179 console.log(yellow('\n restarting on config change\n'))
180 restartServer()
181 return false
182 }
183
184 if ((newData.features.katex && !oldData.features.katex) || (newData.features.monaco && !oldData.features.monaco)) {
185 console.log(yellow('\n restarting on feature change\n'))
186 restartServer()
187 return false
188 }
189
190 return newData
191 },
192 },
193 ))
194
195 await server.listen()
196
197 let tunnelUrl = ''
198 if (tunnel) {
199 if (remote != null)
200 tunnelUrl = await openTunnel(port)
201 else
202 console.log(yellow('\n --remote is required for tunneling, Cloudflare Quick Tunnel is not enabled.\n'))
203 }
204
205 let publicIp: string | undefined
206 if (remote)
207 publicIp = await resolvePublicIp()
208
209 lastRemoteUrl = printInfo(options, port, base, remote, tunnelUrl, publicIp)
210 if (open)
211 await openSlidevInBrowser()
212
213 return options
214 }
215
216 async function openSlidevInBrowser() {
217 const url = `http://localhost:${port}${base}`
218 try {
219 await openBrowser(url)
220 }
221 catch {
222 console.log(yellow(`\n Could not open the browser automatically. Please open ${url} in your browser.\n`))
223 }
224 }
225
226 async function resolvePublicIp() {
227 try {
228 return await import('public-ip').then(r => r.publicIpv4())
229 }
230 catch {
231 console.log(yellow('\n Could not determine the public IP address.\n'))
232 }
233 }
234
235 async function openTunnel(port: number) {
236 const { startTunnel } = await import('untun')
237 const tunnel = await startTunnel({
238 port,
239 acceptCloudflareNotice: true,
240 })
241 return await tunnel?.getURL() ?? ''
242 }
243
244 const SHORTCUTS = [
245 {
246 name: 'r',
247 fullname: 'restart',
248 action() {
249 restartServer()
250 },
251 },
252 {
253 name: 'o',
254 fullname: 'open',
255 action() {
256 openSlidevInBrowser()
257 },
258 },
259 {
260 name: 'e',
261 fullname: 'edit',
262 action() {
263 const editor = process.env.EDITOR || 'code'
264 execFile(editor, [entry])
265 },
266 },
267 {
268 name: 'q',
269 fullname: 'quit',
270 action() {
271 try {
272 server?.close()
273 }
274 finally {
275 process.exit()
276 }
277 },
278 },
279 {
280 name: 'c',
281 fullname: 'qrcode',
282 async action() {
283 if (!lastRemoteUrl)
284 return
285 await import('uqr')
286 .then(async (r) => {
287 const code = r.renderUnicodeCompact(lastRemoteUrl!)
288 console.log(`\n${dim(' QR Code for remote control: ')}\n ${blue(lastRemoteUrl!)}\n`)
289 console.log(code.split('\n').map(i => ` ${i}`).join('\n'))
290 const publicIp = await import('public-ip').then(r => r.publicIpv4())
291 if (publicIp)
292 console.log(`\n${dim(' Public IP: ')} ${blue(publicIp)}\n`)
293 })
294 },
295 },
296 ]
297
298 function bindShortcut() {
299 if (!process.stdin.isTTY)
300 return
301 process.stdin.resume()
302 process.stdin.setEncoding('utf8')
303 readline.emitKeypressEvents(process.stdin)
304 if (process.stdin.isTTY)
305 process.stdin.setRawMode(true)
306
307 const onKeyPress = (str: string, key: { ctrl: boolean, name: string }) => {
308 if (key.ctrl && key.name === 'c') {
309 process.exit()
310 }
311 else {
312 const [sh] = SHORTCUTS.filter(item => item.name === str)
313 if (sh) {
314 try {
315 sh.action()
316 }
317 catch (err) {
318 console.error(`Failed to execute shortcut ${sh.fullname}`, err)
319 }
320 }
321 }
322 }
323
324 process.stdin.on('keypress', onKeyPress)
325 }
326
327 const { roots } = await initServer()
328 bindShortcut()
329
330 // Start watcher to restart server on file changes
331 const { watch } = await import('chokidar')
332 const watchGlobs = roots
333 .filter(i => !i.includes('node_modules'))
334 .flatMap(root => FILES_CHANGE_RESTART.map(i => path.join(root, i)))
335 const watcher = watch(watchGlobs, {
336 ignored: ['node_modules', '.git'],
337 ignoreInitial: true,
338 ignorePermissionErrors: true,
339 })
340 watcher.on('unlink', (file) => {
341 console.log(yellow(`\n file ${file} removed, restarting...\n`))
342 restartServer()
343 })
344 watcher.on('add', (file) => {
345 console.log(yellow(`\n file ${file} added, restarting...\n`))
346 restartServer()
347 })
348 watcher.on('change', (file) => {
349 console.log(yellow(`\n file ${file} changed, restarting...\n`))
350 restartServer()
351 })
352 },
353 )
354
355 cli.command(
356 'build [entry..]',
357 'Build hostable SPA',
358 args => exportOptions(commonOptions(args))
359 .option('out', {
360 alias: 'o',
361 type: 'string',
362 default: 'dist',
363 describe: 'output dir',
364 })
365 .option('base', {
366 type: 'string',
367 describe: 'output base. Example: /demo/',
368 })
369 .option('download', {
370 alias: 'd',
371 type: 'boolean',
372 describe: 'allow download as PDF',
373 })
374 .option('without-notes', {
375 type: 'boolean',
376 describe: 'exclude speaker notes from the built output',
377 })
378 .option('router-mode', {
379 type: 'string',
380 choices: ['hash', 'history', 'memory'],
381 describe: 'override routerMode in the built output (hash for subdirectory deploys like GitHub Pages; memory keeps the slide number out of the URL, for kiosk/follower decks)',
382 })
383 .option('inspect', {
384 default: false,
385 type: 'boolean',
386 describe: 'enable the inspect plugin for debugging',
387 })
388 .strict()
389 .help(),
390 async (args) => {
391 const { entry, theme, base, download, out, inspect, 'without-notes': withoutNotes, 'router-mode': routerMode } = args
392 const { build } = await import('./commands/build')
393
394 for (const entryFile of entry as unknown as string[]) {
395 const options = await resolveOptions({ entry: entryFile, theme, inspect, download, base, withoutNotes, routerMode: routerMode as 'hash' | 'history' | 'memory' | undefined }, 'build')
396
397 printInfo(options)
398 await build(
399 options,
400 {
401 base,
402 build: {
403 outDir: entry.length === 1 ? out : path.join(out, path.basename(entryFile, '.md')),
404 },
405 },
406 { ...args, entry: entryFile },
407 )
408 }
409 },
410 )
411
412 cli.command(
413 'format [entry..]',
414 'Format the markdown file',
415 args => commonOptions(args)
416 .strict()
417 .help(),
418 async ({ entry }) => {
419 for (const entryFile of entry as unknown as string[]) {
420 const md = await parser.parse(await fs.readFile(entryFile, 'utf-8'), entryFile)
421 parser.prettify(md)
422 await parser.save(md)
423 }
424 },
425 )
426
427 cli.command(
428 'mcp [entry]',
429 'Start an MCP (Model Context Protocol) server over stdio for AI agents to inspect and edit the slides',
430 args => commonOptions(args)
431 .strict()
432 .help(),
433 async ({ entry }) => {
434 const { startMcpStdioServer } = await import('./mcp/stdio')
435 await startMcpStdioServer(await resolveEntry(entry))
436 },
437 )
438
439 cli.command(
440 'theme [subcommand]',
441 'Theme related operations',
442 (command) => {
443 return command
444 .command(
445 'eject',
446 'Eject current theme into local file system',
447 args => commonOptions(args)
448 .option('dir', {
449 type: 'string',
450 default: 'theme',
451 }),
452 async ({ entry: entryRaw, dir, theme: themeInput }) => {
453 const entry = await resolveEntry(entryRaw)
454 const options = await resolveOptions({ entry }, 'dev')
455 const data = await parser.load(options, entry)
456 let themeRaw = themeInput || data.headmatter.theme as string | null | undefined
457 themeRaw = themeRaw === null ? 'none' : (themeRaw || 'default')
458 if (themeRaw === 'none') {
459 console.error('Cannot eject theme "none"')
460 process.exit(1)
461 }
462 if ('/.'.includes(themeRaw[0]) || (themeRaw[0] !== '@' && themeRaw.includes('/'))) {
463 console.error('Theme is already ejected')
464 process.exit(1)
465 }
466 const [name, root] = (await resolveTheme(themeRaw, entry)) as [string, string]
467
468 await fs.mkdir(path.resolve(dir), { recursive: true })
469 await fs.cp(
470 root,
471 path.resolve(dir),
472 {
473 recursive: true,
474 filter: i => !RE_NODE_MODULES_OR_GIT.test(path.relative(root, i)),
475 },
476 )
477
478 const dirPath = `./${dir}`
479 const firstSlide = data.entry.slides[0]
480 updateFrontmatterPatch(firstSlide, { theme: dirPath })
481 parser.prettifySlide(firstSlide)
482 await parser.save(data.entry)
483
484 console.log(`Theme "${name}" ejected successfully to "${dirPath}"`)
485 },
486 )
487 },
488 () => {
489 cli.showHelp()
490 process.exit(1)
491 },
492 )
493
494 cli.command(
495 'export [entry..]',
496 'Export slides to PDF',
497 args => exportOptions(commonOptions(args))
498 .strict()
499 .help(),
500 async (args) => {
501 const { entry, theme } = args
502 const { exportSlides, getExportOptions } = await import('./commands/export')
503 const candidatePort = await getPort(12445)
504
505 let warned = false
506 for (const entryFile of entry as unknown as string) {
507 const options = await resolveOptions({ entry: entryFile, theme }, 'export')
508
509 if (options.data.config.browserExporter !== false && !warned) {
510 warned = true
511 console.log(cyanBright('[Slidev] Try the new browser exporter!'))
512 console.log(
513 cyanBright('You can use the browser exporter instead by starting the dev server as normal and visit'),
514 `${blue('localhost:')}${dim('<port>')}${blue('/export')}\n`,
515 )
516 }
517
518 let server: ViteDevServer | undefined
519 try {
520 server = await createServer(
521 options,
522 {
523 server: { port: candidatePort },
524 clearScreen: false,
525 },
526 )
527 await server.listen(candidatePort)
528 const port = getViteServerPort(server)
529 printInfo(options)
530 const result = await exportSlides({
531 port,
532 ...getExportOptions({ ...args, entry: entryFile }, options),
533 })
534 console.log(`${green(' ✓ ')}${dim('exported to ')}${result}\n`)
535 }
536 finally {
537 await server?.close()
538 }
539 }
540
541 process.exit(0)
542 },
543 )
544
545 cli.command(
546 'export-notes [entry..]',
547 'Export slide notes to PDF',
548 args => args
549 .positional('entry', {
550 default: 'slides.md',
551 type: 'string',
552 describe: 'path to the slides markdown entry',
553 })
554 .option('output', {
555 type: 'string',
556 describe: 'path to the output',
557 })
558 .option('timeout', {
559 default: 30000,
560 type: 'number',
561 describe: 'timeout for rendering the print page',
562 })
563 .option('wait', {
564 default: 0,
565 type: 'number',
566 describe: 'wait for the specified ms before exporting',
567 })
568 .strict()
569 .help(),
570 async ({
571 entry,
572 output,
573 timeout,
574 wait,
575 }) => {
576 const { exportNotes } = await import('./commands/export')
577 const candidatePort = await getPort(12445)
578
579 for (const entryFile of entry as unknown as string[]) {
580 const options = await resolveOptions({ entry: entryFile }, 'export')
581 let server: ViteDevServer | undefined
582 try {
583 server = await createServer(
584 options,
585 {
586 server: { port: candidatePort },
587 clearScreen: false,
588 },
589 )
590 await server.listen(candidatePort)
591 const port = getViteServerPort(server)
592
593 printInfo(options)
594
595 const result = await exportNotes({
596 port,
597 output: output || (options.data.config.exportFilename ? `${options.data.config.exportFilename}-notes` : `${path.basename(entryFile, '.md')}-export-notes`),
598 timeout,
599 wait,
600 })
601 console.log(`${green(' ✓ ')}${dim('exported to ')}${result}\n`)
602 }
603 finally {
604 await server?.close()
605 }
606 }
607
608 process.exit(0)
609 },
610 )
611
612 cli
613 .help()
614 .parse()
615
616 function getViteServerPort(server: ViteDevServer): number {
617 const address = server.httpServer?.address()
618 if (address && typeof address === 'object')
619 return address.port
620 throw new Error('Failed to get Vite server port')
621 }
622
623 function commonOptions(args: Argv<object>) {
624 return args
625 .positional('entry', {
626 default: 'slides.md',
627 type: 'string',
628 describe: 'path to the slides markdown entry',
629 })
630 .option('theme', {
631 alias: 't',
632 type: 'string',
633 describe: 'override theme',
634 })
635 }
636
637 function exportOptions<T>(args: Argv<T>) {
638 return args
639 .option('output', {
640 type: 'string',
641 describe: 'path to the output',
642 })
643 .option('format', {
644 type: 'string',
645 choices: ['pdf', 'png', 'pptx', 'pptx-editable', 'md'],
646 describe: 'output format',
647 })
648 .option('timeout', {
649 type: 'number',
650 describe: 'timeout for rendering the print page',
651 })
652 .option('wait', {
653 type: 'number',
654 describe: 'wait for the specified ms before exporting',
655 })
656 .option('wait-until', {
657 type: 'string',
658 choices: ['networkidle', 'load', 'domcontentloaded', 'none'],
659 describe: 'wait until the specified event before exporting each slide',
660 })
661 .option('range', {
662 type: 'string',
663 describe: 'page ranges to export, for example "1,4-5,6"',
664 })
665 .option('dark', {
666 type: 'boolean',
667 describe: 'export as dark theme',
668 })
669 .option('with-clicks', {
670 alias: 'c',
671 type: 'boolean',
672 describe: 'export pages for every clicks',
673 })
674 .option('executable-path', {
675 type: 'string',
676 describe: 'executable to override playwright bundled browser',
677 })
678 .option('with-toc', {
679 type: 'boolean',
680 describe: 'export pages with outline',
681 })
682 .option('per-slide', {
683 type: 'boolean',
684 describe: 'slide slides slide by slide. Works better with global components, but will break cross slide links and TOC in PDF',
685 })
686 .option('scale', {
687 type: 'number',
688 describe: 'scale factor for image export',
689 })
690 .option('omit-background', {
691 type: 'boolean',
692 describe: 'export png pages without the default browser background',
693 })
694 }
695
696 function printInfo(
697 options: ResolvedSlidevOptions,
698 port?: number,
699 base?: string,
700 remote?: string,
701 tunnelUrl?: string,
702 publicIp?: string,
703 ) {
704 if (base && (!base.startsWith('/') || !base.endsWith('/'))) {
705 console.error('Base URL must start and end with a slash "/"')
706 process.exit(1)
707 }
708
709 console.log()
710 console.log()
711 console.log(` ${cyan('●') + blue('■') + yellow('▲')}`)
712 console.log(`${bold(' Slidev')} ${blue(`v${version}`)} ${isInstalledGlobally.value ? yellow('(global)') : ''}`)
713 console.log()
714
715 verifyConfig(options.data.config, options.data.themeMeta, v => console.warn(yellow(` ! ${v}`)))
716
717 console.log(dim(' theme ') + (options.theme ? green(options.theme) : gray('none')))
718 console.log(dim(' css engine ') + blue('unocss'))
719 console.log(dim(' entry ') + dim(path.normalize(path.dirname(options.entry)) + path.sep) + path.basename(options.entry))
720
721 if (port) {
722 const baseText = base?.slice(0, -1) || ''
723 const portAndBase = port + baseText
724 const baseUrl = `http://localhost:${bold(portAndBase)}`
725 const query = remote ? `?password=${remote}` : ''
726 const presenterPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}presenter/${query}`
727 const entryPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}entry${query}/`
728 const overviewPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}overview${query}/`
729 console.log()
730 console.log(`${dim(' public slide show ')} > ${cyan(`${baseUrl}/`)}`)
731 if (query)
732 console.log(`${dim(' private slide show ')} > ${cyan(`${baseUrl}/${query}`)}`)
733 if (options.utils.define.__SLIDEV_FEATURE_PRESENTER__)
734 console.log(`${dim(' presenter mode ')} > ${blue(`${baseUrl}${presenterPath}`)}`)
735 console.log(`${dim(' slides overview ')} > ${blue(`${baseUrl}${overviewPath}`)}`)
736 if (options.utils.define.__SLIDEV_FEATURE_BROWSER_EXPORTER__)
737 console.log(`${dim(' export slides')} > ${blue(`${baseUrl}/export/`)}`)
738 if (options.mode === 'dev' && options.data.config.mcp !== false)
739 console.log(`${dim(' mcp server ')} > ${blue(`http://localhost:${bold(port)}/__mcp`)}`)
740 if (options.inspect)
741 console.log(`${dim(' vite inspector')} > ${yellow(`${baseUrl}/__inspect/`)}`)
742
743 let lastRemoteUrl = ''
744
745 if (remote !== undefined) {
746 Object.values(os.networkInterfaces())
747 .forEach(v => (v || [])
748 .filter(details => String(details.family).endsWith('4') && !details.address.includes('127.0.0.1'))
749 .forEach(({ address }) => {
750 lastRemoteUrl = `http://${address}:${portAndBase}${entryPath}`
751 console.log(`${dim(' remote control ')} > ${blue(lastRemoteUrl)}`)
752 }))
753
754 if (publicIp) {
755 lastRemoteUrl = `http://${publicIp}:${portAndBase}${entryPath}`
756 console.log(`${dim(' remote control ')} > ${blue(lastRemoteUrl)}`)
757 }
758
759 if (tunnelUrl) {
760 lastRemoteUrl = `${tunnelUrl}${baseText}${entryPath}`
761 console.log(`${dim(' remote via tunnel')} > ${yellow(lastRemoteUrl)}`)
762 }
763 }
764 else {
765 console.log(`${dim(' remote control ')} > ${dim('pass --remote to enable')}`)
766 }
767
768 console.log()
769 console.log(`${dim(' shortcuts ')} > ${underline('r')}${dim('estart | ')}${underline('o')}${dim('pen | ')}${underline('e')}${dim('dit | ')}${underline('q')}${dim('uit')}${lastRemoteUrl ? ` | ${dim('qr')}${underline('c')}${dim('ode')}` : ''}`)
770
771 return lastRemoteUrl
772 }
773 }
774
774 lines TYPESCRIPT