返回 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 import('public-ip').then(r => r.publicIpv4())
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 openTunnel(port: number) {
227 const { startTunnel } = await import('untun')
228 const tunnel = await startTunnel({
229 port,
230 acceptCloudflareNotice: true,
231 })
232 return await tunnel?.getURL() ?? ''
233 }
234
235 const SHORTCUTS = [
236 {
237 name: 'r',
238 fullname: 'restart',
239 action() {
240 restartServer()
241 },
242 },
243 {
244 name: 'o',
245 fullname: 'open',
246 action() {
247 openSlidevInBrowser()
248 },
249 },
250 {
251 name: 'e',
252 fullname: 'edit',
253 action() {
254 const editor = process.env.EDITOR || 'code'
255 execFile(editor, [entry])
256 },
257 },
258 {
259 name: 'q',
260 fullname: 'quit',
261 action() {
262 try {
263 server?.close()
264 }
265 finally {
266 process.exit()
267 }
268 },
269 },
270 {
271 name: 'c',
272 fullname: 'qrcode',
273 async action() {
274 if (!lastRemoteUrl)
275 return
276 await import('uqr')
277 .then(async (r) => {
278 const code = r.renderUnicodeCompact(lastRemoteUrl!)
279 console.log(`\n${dim(' QR Code for remote control: ')}\n ${blue(lastRemoteUrl!)}\n`)
280 console.log(code.split('\n').map(i => ` ${i}`).join('\n'))
281 const publicIp = await import('public-ip').then(r => r.publicIpv4())
282 if (publicIp)
283 console.log(`\n${dim(' Public IP: ')} ${blue(publicIp)}\n`)
284 })
285 },
286 },
287 ]
288
289 function bindShortcut() {
290 if (!process.stdin.isTTY)
291 return
292 process.stdin.resume()
293 process.stdin.setEncoding('utf8')
294 readline.emitKeypressEvents(process.stdin)
295 if (process.stdin.isTTY)
296 process.stdin.setRawMode(true)
297
298 const onKeyPress = (str: string, key: { ctrl: boolean, name: string }) => {
299 if (key.ctrl && key.name === 'c') {
300 process.exit()
301 }
302 else {
303 const [sh] = SHORTCUTS.filter(item => item.name === str)
304 if (sh) {
305 try {
306 sh.action()
307 }
308 catch (err) {
309 console.error(`Failed to execute shortcut ${sh.fullname}`, err)
310 }
311 }
312 }
313 }
314
315 process.stdin.on('keypress', onKeyPress)
316 }
317
318 const { roots } = await initServer()
319 bindShortcut()
320
321 // Start watcher to restart server on file changes
322 const { watch } = await import('chokidar')
323 const watchGlobs = roots
324 .filter(i => !i.includes('node_modules'))
325 .flatMap(root => FILES_CHANGE_RESTART.map(i => path.join(root, i)))
326 const watcher = watch(watchGlobs, {
327 ignored: ['node_modules', '.git'],
328 ignoreInitial: true,
329 ignorePermissionErrors: true,
330 })
331 watcher.on('unlink', (file) => {
332 console.log(yellow(`\n file ${file} removed, restarting...\n`))
333 restartServer()
334 })
335 watcher.on('add', (file) => {
336 console.log(yellow(`\n file ${file} added, restarting...\n`))
337 restartServer()
338 })
339 watcher.on('change', (file) => {
340 console.log(yellow(`\n file ${file} changed, restarting...\n`))
341 restartServer()
342 })
343 },
344 )
345
346 cli.command(
347 'build [entry..]',
348 'Build hostable SPA',
349 args => exportOptions(commonOptions(args))
350 .option('out', {
351 alias: 'o',
352 type: 'string',
353 default: 'dist',
354 describe: 'output dir',
355 })
356 .option('base', {
357 type: 'string',
358 describe: 'output base. Example: /demo/',
359 })
360 .option('download', {
361 alias: 'd',
362 type: 'boolean',
363 describe: 'allow download as PDF',
364 })
365 .option('without-notes', {
366 type: 'boolean',
367 describe: 'exclude speaker notes from the built output',
368 })
369 .option('router-mode', {
370 type: 'string',
371 choices: ['hash', 'history', 'memory'],
372 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)',
373 })
374 .option('inspect', {
375 default: false,
376 type: 'boolean',
377 describe: 'enable the inspect plugin for debugging',
378 })
379 .strict()
380 .help(),
381 async (args) => {
382 const { entry, theme, base, download, out, inspect, 'without-notes': withoutNotes, 'router-mode': routerMode } = args
383 const { build } = await import('./commands/build')
384
385 for (const entryFile of entry as unknown as string[]) {
386 const options = await resolveOptions({ entry: entryFile, theme, inspect, download, base, withoutNotes, routerMode: routerMode as 'hash' | 'history' | 'memory' | undefined }, 'build')
387
388 printInfo(options)
389 await build(
390 options,
391 {
392 base,
393 build: {
394 outDir: entry.length === 1 ? out : path.join(out, path.basename(entryFile, '.md')),
395 },
396 },
397 { ...args, entry: entryFile },
398 )
399 }
400 },
401 )
402
403 cli.command(
404 'format [entry..]',
405 'Format the markdown file',
406 args => commonOptions(args)
407 .strict()
408 .help(),
409 async ({ entry }) => {
410 for (const entryFile of entry as unknown as string[]) {
411 const md = await parser.parse(await fs.readFile(entryFile, 'utf-8'), entryFile)
412 parser.prettify(md)
413 await parser.save(md)
414 }
415 },
416 )
417
418 cli.command(
419 'mcp [entry]',
420 'Start an MCP (Model Context Protocol) server over stdio for AI agents to inspect and edit the slides',
421 args => commonOptions(args)
422 .strict()
423 .help(),
424 async ({ entry }) => {
425 const { startMcpStdioServer } = await import('./mcp/stdio')
426 await startMcpStdioServer(await resolveEntry(entry))
427 },
428 )
429
430 cli.command(
431 'theme [subcommand]',
432 'Theme related operations',
433 (command) => {
434 return command
435 .command(
436 'eject',
437 'Eject current theme into local file system',
438 args => commonOptions(args)
439 .option('dir', {
440 type: 'string',
441 default: 'theme',
442 }),
443 async ({ entry: entryRaw, dir, theme: themeInput }) => {
444 const entry = await resolveEntry(entryRaw)
445 const options = await resolveOptions({ entry }, 'dev')
446 const data = await parser.load(options, entry)
447 let themeRaw = themeInput || data.headmatter.theme as string | null | undefined
448 themeRaw = themeRaw === null ? 'none' : (themeRaw || 'default')
449 if (themeRaw === 'none') {
450 console.error('Cannot eject theme "none"')
451 process.exit(1)
452 }
453 if ('/.'.includes(themeRaw[0]) || (themeRaw[0] !== '@' && themeRaw.includes('/'))) {
454 console.error('Theme is already ejected')
455 process.exit(1)
456 }
457 const [name, root] = (await resolveTheme(themeRaw, entry)) as [string, string]
458
459 await fs.mkdir(path.resolve(dir), { recursive: true })
460 await fs.cp(
461 root,
462 path.resolve(dir),
463 {
464 recursive: true,
465 filter: i => !RE_NODE_MODULES_OR_GIT.test(path.relative(root, i)),
466 },
467 )
468
469 const dirPath = `./${dir}`
470 const firstSlide = data.entry.slides[0]
471 updateFrontmatterPatch(firstSlide, { theme: dirPath })
472 parser.prettifySlide(firstSlide)
473 await parser.save(data.entry)
474
475 console.log(`Theme "${name}" ejected successfully to "${dirPath}"`)
476 },
477 )
478 },
479 () => {
480 cli.showHelp()
481 process.exit(1)
482 },
483 )
484
485 cli.command(
486 'export [entry..]',
487 'Export slides to PDF',
488 args => exportOptions(commonOptions(args))
489 .strict()
490 .help(),
491 async (args) => {
492 const { entry, theme } = args
493 const { exportSlides, getExportOptions } = await import('./commands/export')
494 const candidatePort = await getPort(12445)
495
496 let warned = false
497 for (const entryFile of entry as unknown as string) {
498 const options = await resolveOptions({ entry: entryFile, theme }, 'export')
499
500 if (options.data.config.browserExporter !== false && !warned) {
501 warned = true
502 console.log(cyanBright('[Slidev] Try the new browser exporter!'))
503 console.log(
504 cyanBright('You can use the browser exporter instead by starting the dev server as normal and visit'),
505 `${blue('localhost:')}${dim('<port>')}${blue('/export')}\n`,
506 )
507 }
508
509 let server: ViteDevServer | undefined
510 try {
511 server = await createServer(
512 options,
513 {
514 server: { port: candidatePort },
515 clearScreen: false,
516 },
517 )
518 await server.listen(candidatePort)
519 const port = getViteServerPort(server)
520 printInfo(options)
521 const result = await exportSlides({
522 port,
523 ...getExportOptions({ ...args, entry: entryFile }, options),
524 })
525 console.log(`${green(' ✓ ')}${dim('exported to ')}${result}\n`)
526 }
527 finally {
528 await server?.close()
529 }
530 }
531
532 process.exit(0)
533 },
534 )
535
536 cli.command(
537 'export-notes [entry..]',
538 'Export slide notes to PDF',
539 args => args
540 .positional('entry', {
541 default: 'slides.md',
542 type: 'string',
543 describe: 'path to the slides markdown entry',
544 })
545 .option('output', {
546 type: 'string',
547 describe: 'path to the output',
548 })
549 .option('timeout', {
550 default: 30000,
551 type: 'number',
552 describe: 'timeout for rendering the print page',
553 })
554 .option('wait', {
555 default: 0,
556 type: 'number',
557 describe: 'wait for the specified ms before exporting',
558 })
559 .strict()
560 .help(),
561 async ({
562 entry,
563 output,
564 timeout,
565 wait,
566 }) => {
567 const { exportNotes } = await import('./commands/export')
568 const candidatePort = await getPort(12445)
569
570 for (const entryFile of entry as unknown as string[]) {
571 const options = await resolveOptions({ entry: entryFile }, 'export')
572 let server: ViteDevServer | undefined
573 try {
574 server = await createServer(
575 options,
576 {
577 server: { port: candidatePort },
578 clearScreen: false,
579 },
580 )
581 await server.listen(candidatePort)
582 const port = getViteServerPort(server)
583
584 printInfo(options)
585
586 const result = await exportNotes({
587 port,
588 output: output || (options.data.config.exportFilename ? `${options.data.config.exportFilename}-notes` : `${path.basename(entryFile, '.md')}-export-notes`),
589 timeout,
590 wait,
591 })
592 console.log(`${green(' ✓ ')}${dim('exported to ')}${result}\n`)
593 }
594 finally {
595 await server?.close()
596 }
597 }
598
599 process.exit(0)
600 },
601 )
602
603 cli
604 .help()
605 .parse()
606
607 function getViteServerPort(server: ViteDevServer): number {
608 const address = server.httpServer?.address()
609 if (address && typeof address === 'object')
610 return address.port
611 throw new Error('Failed to get Vite server port')
612 }
613
614 function commonOptions(args: Argv<object>) {
615 return args
616 .positional('entry', {
617 default: 'slides.md',
618 type: 'string',
619 describe: 'path to the slides markdown entry',
620 })
621 .option('theme', {
622 alias: 't',
623 type: 'string',
624 describe: 'override theme',
625 })
626 }
627
628 function exportOptions<T>(args: Argv<T>) {
629 return args
630 .option('output', {
631 type: 'string',
632 describe: 'path to the output',
633 })
634 .option('format', {
635 type: 'string',
636 choices: ['pdf', 'png', 'pptx', 'md'],
637 describe: 'output format',
638 })
639 .option('timeout', {
640 type: 'number',
641 describe: 'timeout for rendering the print page',
642 })
643 .option('wait', {
644 type: 'number',
645 describe: 'wait for the specified ms before exporting',
646 })
647 .option('wait-until', {
648 type: 'string',
649 choices: ['networkidle', 'load', 'domcontentloaded', 'none'],
650 describe: 'wait until the specified event before exporting each slide',
651 })
652 .option('range', {
653 type: 'string',
654 describe: 'page ranges to export, for example "1,4-5,6"',
655 })
656 .option('dark', {
657 type: 'boolean',
658 describe: 'export as dark theme',
659 })
660 .option('with-clicks', {
661 alias: 'c',
662 type: 'boolean',
663 describe: 'export pages for every clicks',
664 })
665 .option('executable-path', {
666 type: 'string',
667 describe: 'executable to override playwright bundled browser',
668 })
669 .option('with-toc', {
670 type: 'boolean',
671 describe: 'export pages with outline',
672 })
673 .option('per-slide', {
674 type: 'boolean',
675 describe: 'slide slides slide by slide. Works better with global components, but will break cross slide links and TOC in PDF',
676 })
677 .option('scale', {
678 type: 'number',
679 describe: 'scale factor for image export',
680 })
681 .option('omit-background', {
682 type: 'boolean',
683 describe: 'export png pages without the default browser background',
684 })
685 }
686
687 function printInfo(
688 options: ResolvedSlidevOptions,
689 port?: number,
690 base?: string,
691 remote?: string,
692 tunnelUrl?: string,
693 publicIp?: string,
694 ) {
695 if (base && (!base.startsWith('/') || !base.endsWith('/'))) {
696 console.error('Base URL must start and end with a slash "/"')
697 process.exit(1)
698 }
699
700 console.log()
701 console.log()
702 console.log(` ${cyan('●') + blue('■') + yellow('▲')}`)
703 console.log(`${bold(' Slidev')} ${blue(`v${version}`)} ${isInstalledGlobally.value ? yellow('(global)') : ''}`)
704 console.log()
705
706 verifyConfig(options.data.config, options.data.themeMeta, v => console.warn(yellow(` ! ${v}`)))
707
708 console.log(dim(' theme ') + (options.theme ? green(options.theme) : gray('none')))
709 console.log(dim(' css engine ') + blue('unocss'))
710 console.log(dim(' entry ') + dim(path.normalize(path.dirname(options.entry)) + path.sep) + path.basename(options.entry))
711
712 if (port) {
713 const baseText = base?.slice(0, -1) || ''
714 const portAndBase = port + baseText
715 const baseUrl = `http://localhost:${bold(portAndBase)}`
716 const query = remote ? `?password=${remote}` : ''
717 const presenterPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}presenter/${query}`
718 const entryPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}entry${query}/`
719 const overviewPath = `${options.data.config.routerMode === 'hash' ? '/#/' : '/'}overview${query}/`
720 console.log()
721 console.log(`${dim(' public slide show ')} > ${cyan(`${baseUrl}/`)}`)
722 if (query)
723 console.log(`${dim(' private slide show ')} > ${cyan(`${baseUrl}/${query}`)}`)
724 if (options.utils.define.__SLIDEV_FEATURE_PRESENTER__)
725 console.log(`${dim(' presenter mode ')} > ${blue(`${baseUrl}${presenterPath}`)}`)
726 console.log(`${dim(' slides overview ')} > ${blue(`${baseUrl}${overviewPath}`)}`)
727 if (options.utils.define.__SLIDEV_FEATURE_BROWSER_EXPORTER__)
728 console.log(`${dim(' export slides')} > ${blue(`${baseUrl}/export/`)}`)
729 if (options.mode === 'dev' && options.data.config.mcp !== false)
730 console.log(`${dim(' mcp server ')} > ${blue(`http://localhost:${bold(port)}/__mcp`)}`)
731 if (options.inspect)
732 console.log(`${dim(' vite inspector')} > ${yellow(`${baseUrl}/__inspect/`)}`)
733
734 let lastRemoteUrl = ''
735
736 if (remote !== undefined) {
737 Object.values(os.networkInterfaces())
738 .forEach(v => (v || [])
739 .filter(details => String(details.family).endsWith('4') && !details.address.includes('127.0.0.1'))
740 .forEach(({ address }) => {
741 lastRemoteUrl = `http://${address}:${portAndBase}${entryPath}`
742 console.log(`${dim(' remote control ')} > ${blue(lastRemoteUrl)}`)
743 }))
744
745 if (publicIp) {
746 lastRemoteUrl = `http://${publicIp}:${portAndBase}${entryPath}`
747 console.log(`${dim(' remote control ')} > ${blue(lastRemoteUrl)}`)
748 }
749
750 if (tunnelUrl) {
751 lastRemoteUrl = `${tunnelUrl}${baseText}${entryPath}`
752 console.log(`${dim(' remote via tunnel')} > ${yellow(lastRemoteUrl)}`)
753 }
754 }
755 else {
756 console.log(`${dim(' remote control ')} > ${dim('pass --remote to enable')}`)
757 }
758
759 console.log()
760 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')}` : ''}`)
761
762 return lastRemoteUrl
763 }
764 }
765
765 lines TYPESCRIPT