| 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 |