mdeck 2.3.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.
Files changed (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,758 @@
1
+ import { createServer, build, preview } from 'vite'
2
+ import { existsSync, copyFileSync, mkdtempSync, readFileSync, statSync } from 'fs'
3
+ import { chmod, rm, mkdir, writeFile } from 'fs/promises'
4
+ import { resolve, dirname, basename, relative, isAbsolute } from 'path'
5
+ import { tmpdir } from 'os'
6
+ import readline from 'readline/promises'
7
+ import preact from '@preact/preset-vite'
8
+ import { viteSingleFile } from 'vite-plugin-singlefile'
9
+ import { collectLocalAssetRefs, slidesPlugin } from '../build/slidesPlugin.js'
10
+ import { formatDiagnostics } from '../core/validateDeck.js'
11
+ import { loadRegistry, manifestsOf, serializeRegistry } from '../extensions/discover.js'
12
+ import { BACKUP_DIR } from '../build/editorPlugin.js'
13
+ import { createEditorServer, createDesignServer } from '../build/editorServer.js'
14
+ import { homePlugin } from '../build/homePlugin.js'
15
+ import { checkDeck } from '../build/check.js'
16
+ import { livePlugin, startLiveServer } from '../live/server.js'
17
+ import { inkPlugin } from '../build/inkPlugin.js'
18
+ import { createPairing, pairingPlugin } from '../build/pairing.js'
19
+ import { isAllowedRequest } from '../build/editorPlugin.js'
20
+ import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
21
+ import { installSkill, readSkill, TARGETS } from './skill.js'
22
+ import { runThemes } from './themes.js'
23
+ import { ManifestError, KINDS } from '../extensions/manifest.js'
24
+
25
+ import { frameworkRoot } from '../paths.js'
26
+ import { parseSlides } from '../core/parseSlides.js'
27
+ import { stripNotes as stripNotesFrom } from '../core/editDeck.js'
28
+ import { parseArgs, validateServerOrigin } from './args.js'
29
+ import { assertDrawings } from '../build/drawings.js'
30
+ import { createRelayAccess } from '../build/relayAccess.js'
31
+ import { sessionCode } from '../live/code.js'
32
+ import { migrateDeck } from './migrate.js'
33
+ import { startTunnel, newTunnelId, loopbackUrl } from '../build/tunnelClient.js'
34
+ import { baseConfig } from '../build/config.js'
35
+
36
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
37
+ const tty = process.stdout.isTTY
38
+ const c = tty
39
+ ? { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[32m', cyan: '\x1b[36m', yellow: '\x1b[33m', red: '\x1b[31m' }
40
+ : Object.fromEntries(['reset','bold','dim','green','cyan','yellow','red'].map(k => [k, '']))
41
+
42
+ const ok = msg => console.log(` ${c.green}✓${c.reset} ${msg}`)
43
+ const err = msg => console.error(` ${c.red}✗${c.reset} ${msg}`)
44
+ const tip = msg => console.log(` ${c.dim}${msg}${c.reset}`)
45
+
46
+ const ASPECT_RATIOS = [
47
+ { label: '16:9 (widescreen)', w: 16, h: 9 },
48
+ { label: '16:10 (widescreen)', w: 16, h: 10 },
49
+ { label: '4:3 (classic)', w: 4, h: 3 },
50
+ ]
51
+
52
+
53
+
54
+ function hasFlag(name, short = null) { return !!(parsed.options[name] ?? parsed.options[short]) }
55
+ const positionals = () => parsed.positionals
56
+ const outputOption = () => parsed.options['--output'] ?? null
57
+ const serverOption = () => parsed.options['--server']
58
+ const portOption = () => parsed.options['--port'] ?? null
59
+
60
+ // Layouts, themes and palettes all come from one registry: built-ins plus the
61
+ // extensions/ folder beside the deck. Manifest problems are reported and stop
62
+ // the command instead of being skipped silently.
63
+ function registryFor(slidesPath) {
64
+ try {
65
+ const registry = loadRegistry(slidesPath)
66
+ for (const warning of registry.warnings) console.warn(` ${c.yellow}!${c.reset} ${warning}`)
67
+ return registry
68
+ } catch (error) {
69
+ if (!(error instanceof ManifestError)) throw error
70
+ err(error.message)
71
+ process.exit(1)
72
+ }
73
+ }
74
+
75
+ function parseSelection(input, max) {
76
+ const picks = [...new Set(
77
+ String(input || '')
78
+ .split(',')
79
+ .map(v => parseInt(v.trim(), 10))
80
+ .filter(n => Number.isInteger(n) && n >= 1 && n <= max)
81
+ )]
82
+ return picks
83
+ }
84
+
85
+
86
+ async function runNewWizard() {
87
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
88
+ try {
89
+ console.log()
90
+ console.log(` ${c.bold}Create a new deck${c.reset}`)
91
+ const rawPath = (await rl.question(' Deck file path (default: slides.md): ')).trim() || 'slides.md'
92
+ const outPath = resolve(process.cwd(), rawPath)
93
+ if (existsSync(outPath)) {
94
+ err(`File already exists: ${outPath}`)
95
+ process.exit(1)
96
+ }
97
+
98
+ const registry = registryFor(outPath)
99
+ const THEMES = Object.keys(registry.themes)
100
+ const palettes = Object.keys(registry.palettes)
101
+ const describe = record => `${record.id} — ${record.title}${record.description ? ` (${record.description})` : ''}`
102
+ console.log('\n Theme:')
103
+ THEMES.forEach((name, i) => console.log(` ${i + 1}) ${describe(registry.themes[name])}`))
104
+ const tIdx = parseInt((await rl.question(' Pick a theme [1]: ')).trim() || '1', 10)
105
+ const theme = THEMES[tIdx - 1] || THEMES[0]
106
+
107
+ console.log('\n Palette:')
108
+ console.log(' 0) none')
109
+ palettes.forEach((name, i) => console.log(` ${i + 1}) ${describe(registry.palettes[name])}`))
110
+ const pRaw = (await rl.question(' Pick a palette [0]: ')).trim() || '0'
111
+ const pIdx = parseInt(pRaw, 10)
112
+ const palette = pIdx > 0 ? (palettes[pIdx - 1] || '') : ''
113
+
114
+ const LAYOUT_LIBRARY = Object.values(manifestsOf(registry, 'layout')).map(manifest => ({ key: manifest.id, label: manifest.title, starter: manifest.starter }))
115
+ console.log('\n Slide layouts (comma-separated numbers):')
116
+ LAYOUT_LIBRARY.forEach((opt, i) => console.log(` ${i + 1}) ${opt.label}`))
117
+ const defaultLayouts = '1,2,3,4'
118
+ const picks = parseSelection((await rl.question(` Pick layouts [${defaultLayouts}]: `)).trim() || defaultLayouts, LAYOUT_LIBRARY.length)
119
+ const chosen = (picks.length ? picks : parseSelection(defaultLayouts, LAYOUT_LIBRARY.length))
120
+ .map(i => LAYOUT_LIBRARY[i - 1])
121
+
122
+ const title = (await rl.question(' Presentation title [My Talk]: ')).trim() || 'My Talk'
123
+ const author = (await rl.question(' Author [Your Name]: ')).trim() || 'Your Name'
124
+ const org = (await rl.question(' Organization [FHNW]: ')).trim() || 'FHNW'
125
+
126
+ console.log('\n Aspect ratio (uses 1920px width baseline):')
127
+ ASPECT_RATIOS.forEach((opt, i) => console.log(` ${i + 1}) ${opt.label}`))
128
+ console.log(` ${ASPECT_RATIOS.length + 1}) Custom`)
129
+ const rIdx = parseInt((await rl.question(' Pick an aspect ratio [1]: ')).trim() || '1', 10)
130
+ let width = 1920
131
+ let ratioW = ASPECT_RATIOS[0].w
132
+ let ratioH = ASPECT_RATIOS[0].h
133
+ if (rIdx >= 1 && rIdx <= ASPECT_RATIOS.length) {
134
+ ratioW = ASPECT_RATIOS[rIdx - 1].w
135
+ ratioH = ASPECT_RATIOS[rIdx - 1].h
136
+ } else if (rIdx === ASPECT_RATIOS.length + 1) {
137
+ const rawW = parseInt((await rl.question(' Ratio width [16]: ')).trim() || '16', 10)
138
+ const rawH = parseInt((await rl.question(' Ratio height [9]: ')).trim() || '9', 10)
139
+ ratioW = Number.isInteger(rawW) && rawW > 0 ? rawW : ratioW
140
+ ratioH = Number.isInteger(rawH) && rawH > 0 ? rawH : ratioH
141
+ }
142
+ const height = Math.round((width * ratioH) / ratioW)
143
+
144
+ const lines = [
145
+ '---',
146
+ `theme: ${theme}`,
147
+ ...(palette ? [`palette: ${palette}`] : []),
148
+ 'meta:',
149
+ ` title: "${title.replace(/"/g, '\\"')}"`,
150
+ ` author: "${author.replace(/"/g, '\\"')}"`,
151
+ ` organization: "${org.replace(/"/g, '\\"')}"`,
152
+ ` date: "${new Date().toISOString().slice(0, 10)}"`,
153
+ ' logo: ./img/logo.png',
154
+ `width: ${width}`,
155
+ `height: ${height}`,
156
+ '---',
157
+ '',
158
+ ]
159
+
160
+ chosen.forEach((opt, i) => {
161
+ lines.push('---\n' + opt.starter)
162
+ lines.push('')
163
+ })
164
+
165
+ await mkdir(dirname(outPath), { recursive: true })
166
+ await writeFile(outPath, lines.join('\n'), 'utf-8')
167
+
168
+ const assetDir = resolve(dirname(outPath), 'img')
169
+ await mkdir(assetDir, { recursive: true })
170
+ await writeFile(resolve(assetDir, '.gitkeep'), '', 'utf-8')
171
+
172
+ console.log()
173
+ ok(`Created ${c.cyan}${outPath}${c.reset}`)
174
+ tip(`Added assets folder: ${assetDir}`)
175
+ tip(`Next: mdeck run ${basename(outPath)}\n`)
176
+ } finally {
177
+ rl.close()
178
+ }
179
+ }
180
+
181
+ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false } = {}) {
182
+ const absSlides = resolve(slidesPath)
183
+ const deckDir = dirname(absSlides)
184
+ const absOutDir = resolve(outDir)
185
+ const raw = readFileSync(absSlides, 'utf-8')
186
+ const markdown = stripNotes ? stripNotesFrom(raw) : raw
187
+
188
+ for (const ref of collectLocalAssetRefs(markdown)) {
189
+ const source = resolve(deckDir, ref)
190
+ const destination = resolve(absOutDir, ref)
191
+ const outputRelativePath = relative(absOutDir, destination)
192
+
193
+ // Preserve deck-relative paths, but never let a reference write outside
194
+ // the selected build directory.
195
+ if (outputRelativePath.startsWith('..') || isAbsolute(outputRelativePath)) continue
196
+ if (!existsSync(source)) continue
197
+
198
+ await mkdir(dirname(destination), { recursive: true })
199
+ copyFileSync(source, destination)
200
+ }
201
+ }
202
+
203
+ function posixPresenterLauncher(htmlFilename) {
204
+ const page = encodeURIComponent(htmlFilename)
205
+ return [
206
+ '#!/usr/bin/env sh',
207
+ 'set -eu',
208
+ '',
209
+ 'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
210
+ 'cd "$SCRIPT_DIR"',
211
+ '',
212
+ 'PORT=${1:-8765}',
213
+ 'if command -v python3 >/dev/null 2>&1; then',
214
+ ' PYTHON=python3',
215
+ 'elif command -v python >/dev/null 2>&1; then',
216
+ ' PYTHON=python',
217
+ 'else',
218
+ ' echo "Python 3 is required to start the presenter view." >&2',
219
+ ' exit 1',
220
+ 'fi',
221
+ '',
222
+ `URL="http://127.0.0.1:$PORT/${page}?view=presenter"`,
223
+ '"$PYTHON" -m http.server "$PORT" --bind 127.0.0.1 &',
224
+ 'SERVER_PID=$!',
225
+ 'cleanup() {',
226
+ ' kill "$SERVER_PID" 2>/dev/null || true',
227
+ '}',
228
+ 'trap cleanup EXIT INT TERM',
229
+ '',
230
+ 'sleep 1',
231
+ 'case "$(uname -s)" in',
232
+ ' Darwin) open "$URL" ;;',
233
+ ' Linux)',
234
+ ' if command -v xdg-open >/dev/null 2>&1; then',
235
+ ' xdg-open "$URL"',
236
+ ' else',
237
+ ' echo "Open $URL in a browser."',
238
+ ' fi',
239
+ ' ;;',
240
+ ' *) echo "Open $URL in a browser." ;;',
241
+ 'esac',
242
+ '',
243
+ 'echo "Presenter server running at $URL"',
244
+ 'echo "Press Ctrl+C to stop."',
245
+ 'wait "$SERVER_PID"',
246
+ '',
247
+ ].join('\n')
248
+ }
249
+
250
+ function windowsPresenterLauncher(htmlFilename) {
251
+ return [
252
+ '@echo off',
253
+ 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0present.ps1" %*',
254
+ '',
255
+ ].join('\r\n')
256
+ }
257
+
258
+ function powershellPresenterLauncher(htmlFilename) {
259
+ const page = encodeURIComponent(htmlFilename)
260
+ return [
261
+ 'param([int]$Port = 8765)',
262
+ '$ErrorActionPreference = "Stop"',
263
+ 'Set-Location $PSScriptRoot',
264
+ '',
265
+ '$Py = Get-Command py -ErrorAction SilentlyContinue',
266
+ 'if ($Py) {',
267
+ ' $Python = $Py.Source',
268
+ ' $Prefix = @("-3")',
269
+ '} else {',
270
+ ' $Py = Get-Command python -ErrorAction SilentlyContinue',
271
+ ' if (-not $Py) { throw "Python 3 is required to start the presenter view." }',
272
+ ' $Python = $Py.Source',
273
+ ' $Prefix = @()',
274
+ '}',
275
+ '',
276
+ `$Url = "http://127.0.0.1:$Port/${page}?view=presenter"`,
277
+ '$Arguments = $Prefix + @("-m", "http.server", "$Port", "--bind", "127.0.0.1")',
278
+ '$Server = Start-Process -FilePath $Python -ArgumentList $Arguments -PassThru -NoNewWindow',
279
+ 'try {',
280
+ ' Start-Sleep -Milliseconds 750',
281
+ ' Start-Process $Url',
282
+ ' Write-Host "Presenter server running at $Url"',
283
+ ' Write-Host "Press Ctrl+C to stop."',
284
+ ' Wait-Process -Id $Server.Id',
285
+ '} finally {',
286
+ ' $Server.Refresh()',
287
+ ' if (-not $Server.HasExited) { Stop-Process -Id $Server.Id -Force }',
288
+ '}',
289
+ '',
290
+ ].join('\n')
291
+ }
292
+
293
+ async function writePresenterLaunchers(outDir, htmlFilename) {
294
+ const shellPath = resolve(outDir, 'present.sh')
295
+ await writeFile(shellPath, posixPresenterLauncher(htmlFilename), 'utf-8')
296
+ await chmod(shellPath, 0o755)
297
+ await writeFile(resolve(outDir, 'present.bat'), windowsPresenterLauncher(htmlFilename), 'utf-8')
298
+ await writeFile(resolve(outDir, 'present.ps1'), powershellPresenterLauncher(htmlFilename), 'utf-8')
299
+ }
300
+
301
+ // ── Help ──────────────────────────────────────────────────────────────────────
302
+ const HELP = `
303
+ ${c.bold}mdeck${c.reset} — markdown slide deck
304
+
305
+ ${c.dim}Make and present${c.reset}
306
+ ${c.green}mdeck new${c.reset} Make a presentation with guided questions
307
+ ${c.green}mdeck run${c.reset} [slides.md] Launch page: present, edit, check and send
308
+ --network reach the slides from phones and tablets on the same network
309
+ --server [address] present from an iPad on any network, through your server
310
+ (the address, or the deck's server setting, or MDECK_SERVER;
311
+ the key in MDECK_SERVER_KEY)
312
+ --no-open --port <number>
313
+ ${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
314
+ ${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
315
+ saves to the folder's extensions/ (default: this folder)
316
+ ${c.green}mdeck themes${c.reset} search [words] Themes and palettes from the online repository
317
+ install <pack> [slides.md | folder] into the deck's extensions/ (--global: for every deck)
318
+ remove <pack>, update [pack], list --force replaces files changed by hand
319
+
320
+ ${c.dim}Make something to hand out${c.reset}
321
+ ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
322
+ --reader reader view, notes removed unless --notes
323
+ --single-file everything inlined into one file instead
324
+ --launchers add macOS/Linux and Windows launchers (folders only)
325
+ ${c.green}mdeck send${c.reset} [slides.md] [-o talk.html] One file to send: the reader view, speaker notes removed,
326
+ with a PDF inside
327
+ --notes keep the speaker notes in the file
328
+ --no-pdf do not render the PDF inside
329
+ ${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
330
+ --no-drawings for build, send and pdf: leave out <slides>.drawings.json
331
+ ${c.green}mdeck preview${c.reset} [folder] Preview a built folder (default: dist)
332
+
333
+ ${c.dim}Check and look things up${c.reset}
334
+ ${c.green}mdeck migrate${c.reset} [slides.md] [--dry-run] Update settings, local layouts and drawings to API 2.0
335
+ ${c.green}mdeck check${c.reset} [slides.md] [--strict] Validate source and local assets
336
+ ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
337
+ List built-in and deck-local layouts, themes and palettes
338
+ ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
339
+ ${c.green}mdeck docs${c.reset} [guide] Open the local documentation
340
+ --no-open --port <number> --build Serve without opening, choose port, or build site
341
+ ${c.green}mdeck skill${c.reset} [--print] [--install <assistant>...] [--project]
342
+ Slide-writing skill for AI assistants (claude, codex, cursor, copilot, gemini)
343
+
344
+ ${c.dim}Serve${c.reset}
345
+ ${c.green}mdeck server${c.reset} [--port 8787] [--host [addr]] The server for polls and for presenting from an iPad
346
+ through it (MDECK_SERVER_KEY); put a web server in front
347
+
348
+ Commands that take ${c.bold}slides.md${c.reset} use the one in the current folder when you leave it out.
349
+
350
+ ${c.dim}Install the${c.reset} ${c.bold}mdeck${c.reset} ${c.dim}command globally:${c.reset}
351
+ npm link
352
+
353
+ ${c.dim}Or run without installing:${c.reset}
354
+ npm start -- slides.md
355
+ `
356
+
357
+ // ── Argument parsing ──────────────────────────────────────────────────────────
358
+ const [command, ...argv] = process.argv.slice(2)
359
+
360
+ if (!command || command === '--help' || command === '-h') {
361
+ console.log(HELP)
362
+ process.exit(0)
363
+ }
364
+
365
+ // Names from before 2.0. They stop with the new name instead of being ignored.
366
+ const RENAMED_COMMANDS = { dev: 'mdeck run', present: 'mdeck run', live: 'mdeck server', templates: 'mdeck list layouts', extensions: 'mdeck list' }
367
+ const RENAMED_OPTIONS = [
368
+ ['run', '--host', '--network'],
369
+ ['run', '--share', '--server'],
370
+ ['build', '--share', 'mdeck build --reader (a folder) or mdeck send (one file)'],
371
+ ['build', '--self-contained', '--single-file'],
372
+ ['build', '-S', '--single-file'],
373
+ ['build', '--inline-images', '--single-file'],
374
+ ['build', '-I', '--single-file'],
375
+ ['build', '--presenter-launchers', '--launchers'],
376
+ ['build', '--with-notes', 'mdeck send --notes'],
377
+ ['build', '--pdf', 'mdeck pdf'],
378
+ ['build', '--no-pdf', 'mdeck send --no-pdf'],
379
+ ['build', '--no-ink', '--no-drawings'],
380
+ ['pdf', '--no-ink', '--no-drawings'],
381
+ ['send', '--no-ink', '--no-drawings'],
382
+ ]
383
+ for (const [name, was, now] of RENAMED_OPTIONS) {
384
+ if (command === name && argv.some(arg => arg === was || arg.startsWith(was + '='))) { err(`${was} is now ${now}.`); process.exit(1) }
385
+ }
386
+
387
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); process.exit(0) }
388
+ let parsed
389
+ try {
390
+ parsed = parseArgs(command, argv)
391
+ if (['run', 'server'].includes(command) && Object.hasOwn(process.env, 'MDECK_LIVE_KEY')) throw new Error('MDECK_LIVE_KEY is now MDECK_SERVER_KEY. Rename the environment variable before starting mdeck.')
392
+ } catch (error) {
393
+ err(error.message)
394
+ if (Object.hasOwn(RENAMED_COMMANDS, command)) tip(`mdeck ${command} is now ${RENAMED_COMMANDS[command]}.`)
395
+ process.exit(1)
396
+ }
397
+
398
+ // With --server the dev server serves under /t/<id>/, but its own addresses are
399
+ // mounted at /__mdeck/…: map the first onto the second before anything else.
400
+ function stripRelayBase(base) {
401
+ return {
402
+ name: 'mdeck-relay-base',
403
+ configureServer(server) {
404
+ server.middlewares.use((request, response, next) => {
405
+ if (request.url.startsWith(`${base}__mdeck/`)) request.url = `/${request.url.slice(base.length)}`
406
+ next()
407
+ })
408
+ },
409
+ }
410
+ }
411
+
412
+ // The slides file: the first argument that is not an option, or slides.md in
413
+ // the current folder.
414
+ function requireInput(cmd) {
415
+ const input = positionals()[0] ?? (existsSync('slides.md') ? 'slides.md' : null)
416
+ if (!input) {
417
+ err('No slides file specified.')
418
+ tip(`Usage: mdeck ${cmd} <slides.md> (or run it in a folder that has a slides.md)`)
419
+ process.exit(1)
420
+ }
421
+ if (!existsSync(input)) {
422
+ err(`File not found: ${input}`)
423
+ process.exit(1)
424
+ }
425
+ return input
426
+ }
427
+
428
+ // ── new ───────────────────────────────────────────────────────────────────────
429
+ if (command === 'new') {
430
+ await runNewWizard()
431
+
432
+ } else if (command === 'docs') {
433
+ try {
434
+ const options = { page: (positionals()[0] ?? 'index').replace(/\.html$/, ''), open: !hasFlag('--no-open'), port: portOption() ?? 4174 }
435
+ const buildOnly = hasFlag('--build')
436
+ const { pageFor } = await import('../../docs/site/pages.js')
437
+ if (!pageFor(options.page)) throw new Error(`No guide named "${options.page}". Try mdeck docs getting-started.`)
438
+ if (buildOnly) {
439
+ const { buildDocs } = await import('../../docs/site/build.js')
440
+ ok(`Documentation built: ${await buildDocs()}`)
441
+ } else {
442
+ tip('Preparing the guides and slide examples…')
443
+ const { startDocs } = await import('../../docs/site/server.js')
444
+ const docs = await startDocs(options)
445
+ ok(`Documentation: ${docs.url}`)
446
+ tip('Press Ctrl+C to close the documentation server.')
447
+ const stop = async () => { await docs.close(); process.exit(0) }
448
+ process.once('SIGINT', stop)
449
+ process.once('SIGTERM', stop)
450
+ }
451
+ } catch (error) { err(error.message); process.exitCode = 1 }
452
+
453
+ } else if (command === 'migrate') {
454
+ try {
455
+ const changes = migrateDeck(requireInput('migrate'), { dryRun: hasFlag('--dry-run') })
456
+ for (const change of changes) ok(`${hasFlag('--dry-run') ? 'Would update' : 'Updated'} ${relative(process.cwd(), change.file)}${change.to ? ` → ${basename(change.to)}` : ''}`)
457
+ if (!changes.length) ok('Already uses API 2.0.')
458
+ else if (hasFlag('--dry-run')) tip('No files changed. Run without --dry-run to apply these changes.')
459
+ } catch (error) { err(error.message); process.exitCode = 1 }
460
+
461
+ } else if (command === 'check') {
462
+ const input = requireInput('check')
463
+ const registry = registryFor(input)
464
+ const { diagnostics } = checkDeck(input, registry)
465
+ if (diagnostics.length) console.log(formatDiagnostics(diagnostics, input))
466
+ const warned = diagnostics.length || registry.warnings.length
467
+ if (diagnostics.some(d => d.severity === 'error') || (warned && hasFlag('--strict'))) process.exitCode = 1
468
+ else ok(`Checked ${input}${warned ? ' (with warnings)' : ''}`)
469
+
470
+ } else if (command === 'list') {
471
+ const KIND_WORDS = { layouts: 'layout', themes: 'theme', palettes: 'palette' }
472
+ const [first, ...others] = positionals()
473
+ const word = Object.hasOwn(KIND_WORDS, first) ? first : null
474
+ if (first && !word && !/\.md$/i.test(first)) { err(`Unknown kind: ${first}`); tip('Use layouts, themes or palettes.'); process.exit(1) }
475
+ const file = (word ? others[0] : first) ?? 'slides.md'
476
+ const registry = registryFor(file)
477
+ if (hasFlag('--json')) {
478
+ console.log(JSON.stringify(word ? manifestsOf(registry, KIND_WORDS[word]) : serializeRegistry(registry, { relativeTo: process.cwd() }), null, 2))
479
+ } else for (const kind of KINDS) {
480
+ if (word && KIND_WORDS[word] !== kind) continue
481
+ console.log(`\n ${c.bold}${kind}s${c.reset}`)
482
+ for (const record of Object.values(registry[`${kind}s`])) console.log(` ${c.cyan}${record.id}${c.reset} — ${record.title} ${c.dim}(${record.source})${record.description ? ' ' + record.description : ''}${c.reset}`)
483
+ }
484
+
485
+ } else if (command === 'starter') {
486
+ const [name, file = 'slides.md'] = positionals()
487
+ if (!name) { err('Name a layout.'); tip('Usage: mdeck starter <layout> [slides.md]; mdeck list layouts shows the names.'); process.exit(1) }
488
+ const layouts = manifestsOf(registryFor(file), 'layout')
489
+ if (!Object.hasOwn(layouts, name)) { err(`Unknown layout: ${name}`); process.exitCode = 1 }
490
+ else process.stdout.write(layouts[name].starter)
491
+
492
+ // ── run ───────────────────────────────────────────────────────────────────────
493
+ } else if (command === 'run') {
494
+ const input = requireInput(command)
495
+ const abs = resolve(input)
496
+ try { assertDrawings(abs) } catch (error) { err(error.message); process.exit(1) }
497
+ const port = portOption()
498
+ const exposed = hasFlag('--network')
499
+ const pairing = createPairing()
500
+
501
+ // --server: the server relays an iPad's requests to this computer
502
+ // (src/build/tunnelClient.js). The dev server then serves under /t/<id>/, the
503
+ // address the iPad uses on the server, and its own addresses follow it. The
504
+ // address is the flag's value, else the deck's server setting, else MDECK_SERVER.
505
+ let relay = null
506
+ const serverFlag = serverOption()
507
+ if (serverFlag !== undefined) {
508
+ const inDeck = parseSlides(readFileSync(abs, 'utf8')).deckConfig?.server
509
+ const address = serverFlag ?? (typeof inDeck === 'string' ? inDeck : null) ?? process.env.MDECK_SERVER ?? null
510
+ const key = process.env.MDECK_SERVER_KEY
511
+ if (!address) { err('--server needs the address of your server.'); tip('Give it as --server https://…, add server: https://… to the deck, or set MDECK_SERVER.'); process.exit(1) }
512
+ try { validateServerOrigin(address) } catch (error) { err(error.message); process.exit(1) }
513
+ if (!key) { err('--server needs the key of the server.'); tip('Start it as MDECK_SERVER_KEY=<key> mdeck run <slides.md> --server'); process.exit(1) }
514
+ const id = newTunnelId()
515
+ relay = { server: address, key, id, base: `/t/${id}/`, url: `${address.replace(/\/+$/, '')}/t/${id}/`, state: 'connecting', reason: null }
516
+ }
517
+ const upstream = relay?.server ?? (() => {
518
+ const address = parseSlides(readFileSync(abs, 'utf8')).deckConfig.server
519
+ return typeof address === 'string' ? address : null
520
+ })
521
+ const base = baseConfig(abs, { server: relay?.server ?? null, proxyControls: !!process.env.MDECK_SERVER_KEY })
522
+ const mountBase = relay?.base ?? '/'
523
+ // The launch page starts these on first use, inside this process.
524
+ const services = {
525
+ async editor() {
526
+ const editor = await createEditorServer(abs, { port: (port ?? 5173) + 10 })
527
+ await editor.listen()
528
+ return { url: new URL('editor.html', editor.resolvedUrls.local[0]).href }
529
+ },
530
+ async docs() {
531
+ const { startDocs } = await import('../../docs/site/server.js')
532
+ return startDocs({ open: false })
533
+ },
534
+ }
535
+ const server = await createServer({
536
+ ...base,
537
+ // A paired iPad may save ink and steer the rooms, like this computer.
538
+ ...(relay ? { base: relay.base } : {}),
539
+ plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
540
+ publicDir: dirname(abs),
541
+ server: {
542
+ ...base.server,
543
+ ...(port ? { port, strictPort: true } : {}),
544
+ host: exposed ? true : base.server.host,
545
+ open: hasFlag('--no-open') ? false : `${mountBase}home.html`,
546
+ },
547
+ })
548
+ await server.listen()
549
+ const local = server.resolvedUrls.local[0]
550
+ let tunnel = null
551
+ if (relay) {
552
+ let warned = false
553
+ tunnel = startTunnel({
554
+ // On the loopback address the dev server listens on: `localhost` may
555
+ // resolve to the other one (IPv4 or IPv6), as in containers.
556
+ server: relay.server, key: relay.key, id: relay.id, target: loopbackUrl(local, server.httpServer?.address()), base: relay.base,
557
+ allow: createRelayAccess(server, abs),
558
+ session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig),
559
+ tokens: () => pairing.tokens(),
560
+ log: message => { if (!message.startsWith('tunnel:')) tip(message) },
561
+ onState(state, reason) {
562
+ const before = relay.state
563
+ relay.state = state
564
+ relay.reason = reason ?? null
565
+ if (state === 'up') { warned = false; ok(`Connected to ${c.cyan}${relay.server}${c.reset}`) }
566
+ else if (state === 'refused') err(`Connecting to the server failed: ${reason}`)
567
+ else if (state === 'down' && !warned) { warned = true; tip(before === 'up' ? 'Lost the connection to the server; reconnecting…' : `Cannot reach ${relay.server}; trying again until it answers.`) }
568
+ },
569
+ })
570
+ pairing.onChange(() => tunnel.push())
571
+ server.watcher.on('change', file => { if (resolve(file) === abs) tunnel.push() })
572
+ const stop = () => { tunnel.stop(); process.exit(0) }
573
+ process.once('SIGINT', stop)
574
+ process.once('SIGTERM', stop)
575
+ }
576
+ console.log()
577
+ ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
578
+ tip(`Presenter: ${new URL('?view=presenter', local).href}`)
579
+ tip(`Audience: ${new URL('?view=audience', local).href}`)
580
+ if (exposed) for (const url of server.resolvedUrls.network) tip(`On a phone: ${new URL('?view=reader', url).href}`)
581
+ console.log()
582
+ if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
583
+ ok(`Watching ${c.cyan}${abs}${c.reset}`)
584
+ tip('Edit and save to reload.\n')
585
+
586
+ // ── server ────────────────────────────────────────────────────────────────────
587
+ } else if (command === 'server') {
588
+ const port = portOption() ?? 8787
589
+ const host = parsed.options['--host'] === null ? '0.0.0.0' : parsed.options['--host'] ?? '127.0.0.1'
590
+ const key = process.env.MDECK_SERVER_KEY || null
591
+ try {
592
+ const server = await startLiveServer({ port, host, key })
593
+ ok(`Server: ${c.cyan}${server.url}${c.reset}`)
594
+ tip('Point decks at it with server: https://your.host behind a web server that passes WebSocket connections on.')
595
+ tip(key ? 'Presenters reset rooms after opening the deck once with ?serverkey=<MDECK_SERVER_KEY>, and mdeck run --server can present through it.' : 'Set MDECK_SERVER_KEY to let presenters reset rooms and to allow mdeck run --server.')
596
+ tip('Rooms live in memory only. Press Ctrl+C to stop.\n')
597
+ const stop = async () => { await server.close(); process.exit(0) }
598
+ process.once('SIGINT', stop)
599
+ process.once('SIGTERM', stop)
600
+ } catch (error) { err(error.message); process.exitCode = 1 }
601
+
602
+ // ── edit ──────────────────────────────────────────────────────────────────────
603
+ } else if (command === 'edit') {
604
+ const input = requireInput('edit')
605
+ const abs = resolve(input)
606
+ registryFor(input)
607
+ const port = portOption()
608
+ const server = await createEditorServer(abs, { port: port ?? 5173, strictPort: !!port, open: !hasFlag('--no-open') })
609
+ await server.listen()
610
+ server.printUrls()
611
+ console.log()
612
+ ok(`Editing ${c.cyan}${abs}${c.reset}`)
613
+ tip(`Changes are saved to the file as you type. Before the first change, a copy goes to ${BACKUP_DIR}/ beside it.\n`)
614
+
615
+ // ── themes ────────────────────────────────────────────────────────────────────
616
+ } else if (command === 'themes') {
617
+ await runThemes({ positionals: positionals(), flag: name => hasFlag(name), output: outputOption, ui: { ok, err, tip, c } })
618
+
619
+ // ── design ────────────────────────────────────────────────────────────────────
620
+ // The design page: with a deck, beside its editor; with a folder (or nothing),
621
+ // on the sample deck alone, saving into that folder's extensions/.
622
+ } else if (command === 'design') {
623
+ const input = positionals()[0] ?? '.'
624
+ if (!existsSync(input)) { err(`Not found: ${input}`); process.exit(1) }
625
+ const withDeck = /\.md$/i.test(input) && !statSync(input).isDirectory()
626
+ if (!withDeck && !statSync(input).isDirectory()) { err(`${input} is neither a folder nor a slides file`); process.exit(1) }
627
+ const abs = resolve(input)
628
+ registryFor(withDeck ? abs : resolve(abs, 'design.md'))
629
+ const port = portOption()
630
+ const options = { port: port ?? 5173, strictPort: !!port, open: hasFlag('--no-open') ? false : '/design.html' }
631
+ const server = withDeck ? await createEditorServer(abs, options) : await createDesignServer(abs, options)
632
+ await server.listen()
633
+ console.log(`\n ${c.green}➜${c.reset} Design: ${c.cyan}${new URL('design.html', server.resolvedUrls.local[0]).href}${c.reset}\n`)
634
+ ok(withDeck ? `Designing for ${c.cyan}${abs}${c.reset}` : `Designing in ${c.cyan}${abs}${c.reset}, on a sample deck`)
635
+ tip(`Themes and palettes are saved as you type, in ${resolve(withDeck ? dirname(abs) : abs, 'extensions')}/. Decks in ${withDeck ? 'that' : 'this'} folder find them.\n`)
636
+
637
+ // ── build and send ────────────────────────────────────────────────────────────
638
+ } else if (command === 'build' || command === 'send') {
639
+ const input = requireInput(command)
640
+ const sending = command === 'send'
641
+ // build: a folder to host, or one file with --single-file. send: one file for
642
+ // readers, in the reader view, without speaker notes unless asked, with a PDF.
643
+ const selfContained = sending || hasFlag('--single-file')
644
+ const launchers = !sending && hasFlag('--launchers')
645
+ const reader = sending || hasFlag('--reader')
646
+ const stripNotes = reader && !hasFlag('--notes')
647
+ const wantPdf = sending && !hasFlag('--no-pdf')
648
+
649
+ if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
650
+ const given = outputOption()
651
+ const outputPath = given ? resolve(process.cwd(), given) : sending ? resolve(process.cwd(), basename(input).replace(/\.md$/i, '') + '.html') : null
652
+
653
+ const tempDir = outputPath ? mkdtempSync(resolve(tmpdir(), 'mdeck-')) : null
654
+ const outDir = tempDir ?? resolve(process.cwd(), 'dist')
655
+ const finalOutDir = outputPath ? dirname(outputPath) : outDir
656
+ const htmlFilename = outputPath ? basename(outputPath) : 'index.html'
657
+
658
+ try {
659
+ await build({
660
+ ...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
661
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
662
+ build: {
663
+ outDir,
664
+ emptyOutDir: !tempDir,
665
+ target: 'esnext',
666
+ assetsInlineLimit: 100 * 1024 * 1024,
667
+ },
668
+ })
669
+ } catch (error) {
670
+ if (tempDir) await rm(tempDir, { recursive: true, force: true })
671
+ // The deck's own problems arrive as one error whose text lists them.
672
+ err((error.errors?.[0]?.message ?? error.message).replace(/^\[[^\]]+\]\s*/, '').replace(/^Error: /, ''))
673
+ process.exit(1)
674
+ }
675
+
676
+ await mkdir(finalOutDir, { recursive: true })
677
+
678
+ if (!selfContained) {
679
+ await copyLocalAssets(input, finalOutDir, { stripNotes })
680
+ if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
681
+ }
682
+
683
+ let htmlFile = resolve(outDir, 'index.html')
684
+ if (outputPath && tempDir) {
685
+ copyFileSync(resolve(tempDir, 'index.html'), outputPath)
686
+ await rm(tempDir, { recursive: true })
687
+ htmlFile = outputPath
688
+ }
689
+ if (wantPdf) {
690
+ const pdfDir = mkdtempSync(resolve(tmpdir(), 'mdeck-send-pdf-'))
691
+ const pdfFile = resolve(pdfDir, 'slides.pdf')
692
+ try {
693
+ await renderPdf({ htmlFile, output: pdfFile })
694
+ attachPdf(htmlFile, pdfFile, { embed: true })
695
+ await rm(pdfFile)
696
+ ok(`PDF: ${c.cyan}embedded in ${htmlFile}${c.reset}`)
697
+ } catch (error) {
698
+ console.warn(` ${c.yellow}!${c.reset} PDF not rendered: ${error.message}`)
699
+ tip('The reader view will offer the browser\'s "Save as PDF" dialog instead.')
700
+ } finally { await rm(pdfDir, { recursive: true, force: true }) }
701
+ }
702
+ if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
703
+ ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
704
+
705
+ // ── skill ─────────────────────────────────────────────────────────────────────
706
+ } else if (command === 'skill') {
707
+ const installIndex = parsed.options['--install'] ? 0 : -1
708
+ if (hasFlag('--print') || installIndex < 0) {
709
+ if (installIndex < 0 && !hasFlag('--print')) {
710
+ console.log(`\n The write-slides skill drafts a complete deck for an AI coding assistant.\n`)
711
+ for (const [key, spec] of Object.entries(TARGETS)) console.log(` ${c.cyan}${key.padEnd(8)}${c.reset} ${spec.title.padEnd(16)} ${c.dim}${spec.scope === 'home' ? `~/${spec.path} (or --project)` : spec.projectPath}${c.reset}`)
712
+ console.log(`\n ${c.dim}mdeck skill --install claude codex install for one or more assistants\n mdeck skill --print print the skill for any other tool${c.reset}\n`)
713
+ } else process.stdout.write(readSkill().text)
714
+ } else {
715
+ const targets = parsed.options['--install']
716
+ if (!targets.length) { err(`Name at least one assistant: ${Object.keys(TARGETS).join(', ')}`); process.exit(1) }
717
+ for (const target of targets) {
718
+ try { const result = installSkill(target, { project: hasFlag('--project') }); ok(`${result.title}: ${c.cyan}${result.file}${c.reset}`) } catch (error) { err(error.message); process.exitCode = 1 }
719
+ }
720
+ }
721
+
722
+ // ── pdf ───────────────────────────────────────────────────────────────────────
723
+ } else if (command === 'pdf') {
724
+ const input = requireInput('pdf')
725
+ const pdfFile = resolve(process.cwd(), outputOption() ?? basename(input).replace(/\.md$/i, '') + '.pdf')
726
+ if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
727
+ if (!findChrome()) { err('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.'); process.exit(1) }
728
+ const tempDir = mkdtempSync(resolve(tmpdir(), 'mdeck-pdf-'))
729
+ try {
730
+ await build({
731
+ ...baseConfig(input, { selfContained: true }),
732
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
733
+ build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
734
+ logLevel: 'warn',
735
+ })
736
+ await mkdir(dirname(pdfFile), { recursive: true })
737
+ await renderPdf({ htmlFile: resolve(tempDir, 'index.html'), output: pdfFile })
738
+ ok(`PDF: ${c.cyan}${pdfFile}${c.reset}\n`)
739
+ } catch (error) { err(error.message); process.exitCode = 1 } finally { await rm(tempDir, { recursive: true, force: true }) }
740
+
741
+ // ── preview ───────────────────────────────────────────────────────────────────
742
+ } else if (command === 'preview') {
743
+ const folder = resolve(process.cwd(), positionals()[0] ?? 'dist')
744
+ if (!existsSync(resolve(folder, 'index.html'))) { err(`No built presentation in ${folder}. Make one with mdeck build.`); process.exit(1) }
745
+ const server = await preview({
746
+ configFile: false,
747
+ root: frameworkRoot,
748
+ build: { outDir: folder },
749
+ preview: { open: !hasFlag('--no-open'), ...(portOption() ? { port: portOption(), strictPort: true } : {}) },
750
+ })
751
+ server.printUrls()
752
+
753
+ } else {
754
+ err(`Unknown command: "${command}"`)
755
+ if (Object.hasOwn(RENAMED_COMMANDS, command)) tip(`mdeck ${command} is now ${RENAMED_COMMANDS[command]}.`)
756
+ tip('Run mdeck --help for usage.\n')
757
+ process.exit(1)
758
+ }