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,208 @@
1
+ // Launch page API for `mdeck run`: what the deck contains, what `mdeck check`
2
+ // says about it, the state of its server, and buttons that start the
3
+ // editor and the guides or run a build. Like the editor API it answers loopback
4
+ // requests from its own pages only, even when the server is shared on the
5
+ // network for phones.
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { resolve, dirname, basename } from 'node:path'
9
+ import { loadRegistry } from '../extensions/discover.js'
10
+ import { parseSlides } from '../core/parseSlides.js'
11
+ import { checkDeck } from './check.js'
12
+ import { sessionCode } from '../live/code.js'
13
+ import { roomsIn } from '../live/roomTag.js'
14
+ import { isAllowedRequest, send, readJson } from './editorPlugin.js'
15
+ import { findChrome } from './chrome.js'
16
+ import { frameworkRoot } from '../paths.js'
17
+
18
+ // Each output is one ordinary CLI run beside the deck, so the launch page
19
+ // builds exactly what the command line would.
20
+ export function outputsFor(slidesPath) {
21
+ const abs = resolve(slidesPath), dir = dirname(abs), name = basename(abs).replace(/\.md$/i, '')
22
+ return {
23
+ folder: { title: 'Folder to host', args: ['build', abs, '--reader'], file: resolve(dir, 'dist/index.html') },
24
+ send: { title: 'One file to send', args: ['send', abs, '-o', `${name}.html`], file: resolve(dir, `${name}.html`), chrome: true },
25
+ pdf: { title: 'PDF', args: ['pdf', abs, '-o', `${name}.pdf`], file: resolve(dir, `${name}.pdf`), chrome: true },
26
+ }
27
+ }
28
+
29
+ export function runCli(args, cwd) {
30
+ return new Promise(done => {
31
+ const child = spawn(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), ...args], { cwd, env: { ...process.env, FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'] })
32
+ let output = ''
33
+ child.stdout.on('data', data => { output += data })
34
+ child.stderr.on('data', data => { output += data })
35
+ child.on('error', error => done({ code: 1, output: error.message }))
36
+ child.on('close', code => done({ code, output: output.replace(/\x1b\[[0-9;]*m/g, '').trim() }))
37
+ })
38
+ }
39
+
40
+ function reveal(file) {
41
+ const [command, args] = process.platform === 'darwin' ? ['open', ['-R', file]]
42
+ : process.platform === 'win32' ? ['explorer', [`/select,${file}`]]
43
+ : ['xdg-open', [dirname(file)]]
44
+ spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
45
+ }
46
+
47
+ // The server named in the `server` setting: whether it answers, and whether it
48
+ // accepts the key (MDECK_SERVER_KEY, as given to the server).
49
+ export async function liveStatus(live, { key = null, fetch: get = fetch, timeoutMs = 2500 } = {}) {
50
+ const started = Date.now()
51
+ try {
52
+ const response = await get(`${live.server.replace(/\/+$/, '')}/info`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) })
53
+ if (!response.ok) return { reachable: false, error: `It answered with status ${response.status}` }
54
+ const body = await response.json()
55
+ return { reachable: true, ms: Date.now() - started, keyAccepted: key ? !!body.canReset : null }
56
+ } catch (error) {
57
+ return { reachable: false, error: error.name === 'TimeoutError' ? `No answer within ${timeoutMs / 1000} s` : error.cause?.code ?? error.message }
58
+ }
59
+ }
60
+
61
+ // `urls()` returns the server's addresses; `services` start the editor and the
62
+ // guides; `fetch` reaches the server. All are injected so tests can run
63
+ // without a browser or network.
64
+ export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network: [] }), services = {}, pairing = null, relay = null, server: serverAddress = null, run = runCli, open = reveal, serverKey = process.env.MDECK_SERVER_KEY || null, fetch: get = fetch } = {}) {
65
+ const abs = resolve(slidesPath)
66
+ const outputs = outputsFor(abs)
67
+ const jobs = {}
68
+ const started = {}, serviceUrls = {}
69
+
70
+ async function info(request) {
71
+ const source = readFileSync(abs, 'utf8')
72
+ let registry = null, registryError = null
73
+ try { registry = loadRegistry(abs) } catch (error) { registryError = error.message }
74
+ const { deck, diagnostics } = registry ? checkDeck(abs, registry, source) : { deck: parseSlides(source), diagnostics: [] }
75
+ const config = deck.deckConfig ?? {}
76
+ const { local: serverLocal = [], network = [] } = urls() ?? {}
77
+ // The address this page was opened with, such as a local proxy's name.
78
+ const local = request?.headers?.host ? [`http://${request.headers.host}/`, ...serverLocal] : serverLocal
79
+ return {
80
+ file: abs,
81
+ name: basename(abs),
82
+ modified: statSync(abs).mtime.toISOString(),
83
+ title: config.meta?.title ?? basename(abs).replace(/\.md$/i, ''),
84
+ author: config.meta?.author ?? null,
85
+ slides: deck.slides.length,
86
+ width: config.width ?? 1920,
87
+ height: config.height ?? 1080,
88
+ notes: deck.slides.filter(slide => slide.meta.notes).length,
89
+ theme: config.theme ?? 'neue',
90
+ palette: config.palette ?? null,
91
+ diagnostics: [...(registry?.warnings ?? []).map(message => ({ severity: 'warning', code: 'extension', message })), ...(registryError ? [{ severity: 'error', code: 'extension', message: registryError }] : []), ...diagnostics],
92
+ live: await live(config, source, { local, network }),
93
+ presence: presenceUrl(config),
94
+ // Presenting from an iPad needs an address it can reach (`--host`).
95
+ pairing: pairing ? { available: !!relay || !!network[0], devices: pairing.devices } : null,
96
+ relay: relay ? { url: relay.url, state: relay.state, reason: relay.reason } : null,
97
+ chrome: !!findChrome(),
98
+ services: Object.fromEntries(Object.keys(services).map(key => [key, serviceUrls[key] ?? null])),
99
+ outputs: Object.fromEntries(Object.entries(outputs).map(([key, output]) => [key, {
100
+ title: output.title,
101
+ file: output.file,
102
+ exists: existsSync(output.file),
103
+ needsChrome: !!output.chrome,
104
+ ...(jobs[key] ?? { status: 'idle' }),
105
+ }])),
106
+ }
107
+ }
108
+
109
+ // Where the launch page asks who is connected: the deck's stage room, on
110
+ // the deck's own server, or here when this server passes the rooms on
111
+ // (with MDECK_SERVER_KEY) or keeps them itself. Relative to the page.
112
+ function presenceUrl(config) {
113
+ const address = serverAddress ?? config.server
114
+ const room = `rooms/${encodeURIComponent(`${sessionCode(config)}.stage`)}/presence`
115
+ return typeof address === 'string' && !serverKey ? `${address.replace(/\/+$/, '')}/${room}` : `__mdeck/live/${room}`
116
+ }
117
+
118
+ // Decks with activities, or with a server of their own, get this
119
+ // section: the server and its health, the join link phones open and,
120
+ // for a server of their own, the presenter code.
121
+ async function live(config, source, { local, network }) {
122
+ const address = serverAddress ?? config.server
123
+ const settings = { server: address }
124
+ const own = typeof address === 'string'
125
+ if (!own && roomsIn(source).length === 0) return null
126
+ const code = sessionCode(config)
127
+ const builtIn = base => new URL(`__mdeck/live/${code}`, base).href
128
+ const joinUrl = own ? `${settings.server.replace(/\/+$/, '')}/${code}` : network[0] ? builtIn(network[0]) : null
129
+ return {
130
+ server: own ? settings.server : null,
131
+ code,
132
+ ...(own ? await liveStatus(settings, { key: serverKey, fetch: get }) : { reachable: true }),
133
+ key: own ? serverKey : null,
134
+ joinUrl,
135
+ localJoinUrl: joinUrl ?? (local[0] ? builtIn(local[0]) : null),
136
+ }
137
+ }
138
+
139
+ // Each service starts once; a failed start can be retried.
140
+ async function start(key) {
141
+ started[key] ??= services[key]().then(service => { serviceUrls[key] = service.url; return service }, error => { delete started[key]; throw error })
142
+ return (await started[key]).url
143
+ }
144
+
145
+ // The one-time address an iPad opens to present from: the presenter view,
146
+ // with a one-time pairing offer; the server's master key stays here.
147
+ function pairUrl() {
148
+ const network = relay?.url ?? urls()?.network?.[0]
149
+ if (!pairing || !network) throw Object.assign(new Error('Start mdeck run with --network or --server so an iPad can reach it'), { status: 409 })
150
+ const url = new URL(network)
151
+ url.searchParams.set('view', 'presenter')
152
+ url.searchParams.set('pair', pairing.offer())
153
+ return url.href
154
+ }
155
+
156
+ async function act({ action, output } = {}) {
157
+ if (Object.hasOwn(services, action)) return { url: await start(action) }
158
+ if (action === 'pair') return { url: pairUrl() }
159
+ if (action === 'unpair') { pairing?.revoke(); return { ok: true } }
160
+ if (action === 'build') {
161
+ if (!Object.hasOwn(outputs, output)) throw Object.assign(new Error(`Unknown output: ${output}`), { status: 400 })
162
+ if (jobs[output]?.status === 'running') throw Object.assign(new Error('That build is already running'), { status: 409 })
163
+ jobs[output] = { status: 'running', startedAt: new Date().toISOString() }
164
+ const { code, output: log } = await run(outputs[output].args, dirname(abs))
165
+ jobs[output] = { status: code === 0 ? 'done' : 'failed', log, finishedAt: new Date().toISOString() }
166
+ return jobs[output]
167
+ }
168
+ if (action === 'reveal') {
169
+ const target = outputs[output]?.file ?? (output === 'deck' ? abs : null)
170
+ if (!target || !existsSync(target)) throw Object.assign(new Error('Nothing to show yet'), { status: 404 })
171
+ open(target)
172
+ return { ok: true }
173
+ }
174
+ throw Object.assign(new Error(`Unknown action: ${action}`), { status: 400 })
175
+ }
176
+
177
+ return async (request, response, next) => {
178
+ const pathname = new URL(request.url, 'http://localhost').pathname
179
+ if (!['/info', '/action'].includes(pathname)) return next()
180
+ if (!isAllowedRequest(request)) return send(response, 403, { error: 'The launch page only answers on this computer' })
181
+ try {
182
+ if (pathname === '/info' && request.method === 'GET') return send(response, 200, await info(request))
183
+ if (pathname === '/action' && request.method === 'POST') return send(response, 200, await act(await readJson(request)))
184
+ response.setHeader('Allow', pathname === '/info' ? 'GET' : 'POST')
185
+ return send(response, 405, { error: 'Method not allowed' })
186
+ } catch (error) {
187
+ return send(response, error.status ?? 500, { error: error.message })
188
+ }
189
+ }
190
+ }
191
+
192
+ export function homePlugin(slidesPath, { services, pairing, relay = null, server: serverAddress = null } = {}) {
193
+ return {
194
+ name: 'vite-plugin-mdeck-home',
195
+ configureServer(server) {
196
+ // The bare address is the launch page; the slides are at ?view=deck.
197
+ // Only a plain page visit is redirected: every view, preview and editor
198
+ // frame carries a query.
199
+ const base = server.config.base
200
+ server.middlewares.use((request, response, next) => {
201
+ if (request.method !== 'GET' || ![base, `${base}index.html`].includes(request.url) || !/text\/html/.test(request.headers.accept ?? '')) return next()
202
+ response.writeHead(302, { Location: `${base}home.html` })
203
+ response.end()
204
+ })
205
+ server.middlewares.use('/__mdeck/home', homeMiddleware(slidesPath, { urls: () => server.resolvedUrls, services, pairing, relay, server: serverAddress }))
206
+ },
207
+ }
208
+ }
@@ -0,0 +1,192 @@
1
+ // Saving ink while `mdeck run` runs: the file beside the deck is the source of
2
+ // truth, and devices send changes as operations (see applyOp in core/ink.js),
3
+ // so strokes drawn on two devices at once never overwrite each other.
4
+ //
5
+ // GET /__mdeck/ink { ink, deckHash }
6
+ // POST /__mdeck/ink/ops { deckHash, ops } → { deckHash, renamed }
7
+ //
8
+ // A slide that gets ink but has no `id:` of its own gets one, written into
9
+ // its settings, so the ink stays with it when slides are inserted. Ids by
10
+ // position (slide-3) are only trusted when the device saw the current deck
11
+ // (deckHash); otherwise the answer is 409 and the device reloads. The server's
12
+ // own writes do not reload the open windows (ownWrites.js); instead it sends
13
+ // `mdeck:ink`, and the pages fetch the ink again.
14
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'
15
+ import { resolve, dirname, basename } from 'node:path'
16
+ import { parseSlides } from '../core/parseSlides.js'
17
+ import { setSlideMeta } from '../core/editDeck.js'
18
+ import { slideTitle } from '../core/outline.js'
19
+ import { slug } from '../live/code.js'
20
+ import { inkFileFor, normalizeInk, emptyInk, applyOp, serializeInk } from '../core/ink.js'
21
+ import { hashSource, isAllowedRequest, send, readJson, BACKUP_DIR } from './editorPlugin.js'
22
+ import { markOwnWrite } from './ownWrites.js'
23
+
24
+ const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/
25
+ const BACKUPS_KEPT = 10
26
+
27
+ function writeAtomic(path, text) {
28
+ const temp = `${path}.${process.pid}.tmp`
29
+ writeFileSync(temp, text, 'utf8')
30
+ renameSync(temp, path)
31
+ }
32
+
33
+ // A readable, unique id from the slide's heading, as ids must start with a letter.
34
+ function newSlideId(deck, slide, index) {
35
+ const taken = new Set(deck.slides.map(s => s.id))
36
+ let base = slug(slideTitle(slide, index, null)) || 'slide'
37
+ if (!/^[a-z]/.test(base)) base = `slide-${base}`
38
+ let id = base, n = 2
39
+ while (taken.has(id) || !ID_RE.test(id)) id = `${base}-${n++}`
40
+ return id
41
+ }
42
+
43
+ export function createInkFile(deckPath, { debounceMs = 300, onChange = () => {} } = {}) {
44
+ const abs = resolve(deckPath)
45
+ const inkPath = inkFileFor(abs)
46
+ let ink = null, timer = null, backedUp = false
47
+ // Ids given to slides, for devices that still saw the deck from before:
48
+ // { from, to, before } applies only to requests made against `before`.
49
+ const renames = []
50
+ let knownIds = null
51
+
52
+ const readDeck = () => { const source = readFileSync(abs, 'utf8'); return { source, deck: parseSlides(source), hash: hashSource(source) } }
53
+ const size = deck => ({ width: deck.deckConfig?.width ?? 1920, height: deck.deckConfig?.height ?? 1080 })
54
+
55
+ // The file on disk, unless changes are still waiting to be written.
56
+ function current(deck) {
57
+ if (timer && ink) return ink
58
+ try { ink = existsSync(inkPath) ? normalizeInk(JSON.parse(readFileSync(inkPath, 'utf8')), size(deck)) : emptyInk(size(deck)) }
59
+ catch { ink ??= emptyInk(size(deck)) }
60
+ return ink
61
+ }
62
+
63
+ function backupOnce() {
64
+ if (backedUp || !existsSync(inkPath)) { backedUp = true; return }
65
+ const dir = resolve(dirname(abs), BACKUP_DIR)
66
+ mkdirSync(dir, { recursive: true })
67
+ const name = basename(inkPath, '.json')
68
+ writeFileSync(resolve(dir, `${name}-${new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z')}.json`), readFileSync(inkPath))
69
+ const copies = readdirSync(dir).filter(entry => entry.startsWith(`${name}-`) && entry.endsWith('.json')).sort()
70
+ for (const old of copies.slice(0, -BACKUPS_KEPT)) unlinkSync(resolve(dir, old))
71
+ backedUp = true
72
+ }
73
+
74
+ function flush() {
75
+ clearTimeout(timer)
76
+ timer = null
77
+ if (!ink) return
78
+ backupOnce()
79
+ const text = serializeInk(ink)
80
+ markOwnWrite(inkPath, text)
81
+ writeAtomic(inkPath, text)
82
+ }
83
+
84
+ // A slide whose `id:` was changed in the editor or by hand, at the same
85
+ // place in a deck with the same number of slides, keeps its ink. Ink of a
86
+ // removed slide stays in the file (mdeck check reports it), so undoing the
87
+ // removal brings it back.
88
+ function deckChanged() {
89
+ let deck
90
+ try { deck = readDeck().deck } catch { return [] }
91
+ const ids = deck.slides.map(slide => slide.id)
92
+ const before = knownIds
93
+ knownIds = ids
94
+ if (!before || before.length !== ids.length) return []
95
+ current(deck)
96
+ const moved = []
97
+ ids.forEach((id, i) => {
98
+ const from = before[i]
99
+ if (from !== id && !ids.includes(from) && !before.includes(id) && ink.slides[from]) {
100
+ ink = applyOp(ink, { type: 'rename', from, to: id })
101
+ moved.push({ from, to: id })
102
+ }
103
+ })
104
+ if (moved.length) flush()
105
+ return moved
106
+ }
107
+ try { knownIds = readDeck().deck.slides.map(slide => slide.id) } catch {}
108
+
109
+ return {
110
+ path: inkPath,
111
+ deckChanged,
112
+ read() {
113
+ const { deck, hash } = readDeck()
114
+ return { ink: current(deck), deckHash: hash }
115
+ },
116
+ apply(ops, deckHash) {
117
+ let { source, deck, hash } = readDeck()
118
+ current(deck)
119
+ const renamed = []
120
+ const seen = deckHash
121
+ const alias = new Map(renames.filter(r => r.before === seen).map(r => [r.from, r.to]))
122
+ for (const raw of Array.isArray(ops) ? ops : []) {
123
+ const op = { ...raw, slideId: alias.get(raw?.slideId) ?? raw?.slideId }
124
+ if (op.type === 'add') {
125
+ const index = deck.slides.findIndex(slide => slide.id === op.slideId)
126
+ if (index < 0) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
127
+ const slide = deck.slides[index]
128
+ if (slide.meta.id == null) {
129
+ // An id by position: only trusted for the deck the device saw.
130
+ if (deckHash !== hash) return { status: 409, error: 'The deck changed; reload', deckHash: hash }
131
+ const id = newSlideId(deck, slide, index)
132
+ renames.push({ from: op.slideId, to: id, before: hash })
133
+ source = setSlideMeta(deck, op.slideId, { id })
134
+ markOwnWrite(abs, source)
135
+ writeAtomic(abs, source)
136
+ deck = parseSlides(source)
137
+ hash = hashSource(source)
138
+ deckHash = hash
139
+ ink = applyOp(ink, { type: 'rename', from: op.slideId, to: id })
140
+ renamed.push({ from: op.slideId, to: id })
141
+ alias.set(op.slideId, id)
142
+ op.slideId = id
143
+ }
144
+ }
145
+ ink = applyOp(ink, op)
146
+ }
147
+ knownIds = deck.slides.map(slide => slide.id)
148
+ clearTimeout(timer)
149
+ timer = setTimeout(flush, debounceMs)
150
+ onChange({ renamed })
151
+ return { status: 200, deckHash: hash, renamed }
152
+ },
153
+ flush,
154
+ }
155
+ }
156
+
157
+ // `authorize(request)` may allow requests beyond this computer (pairing).
158
+ export function inkMiddleware(file, { authorize = isAllowedRequest } = {}) {
159
+ return async (request, response, next) => {
160
+ const { pathname } = new URL(request.url, 'http://localhost')
161
+ if (pathname !== '/' && pathname !== '/ops') return next()
162
+ if (!authorize(request)) return send(response, 403, { error: 'Only the presenter can change the ink' })
163
+ try {
164
+ if (pathname === '/' && request.method === 'GET') return send(response, 200, file.read())
165
+ if (pathname === '/ops' && request.method === 'POST') {
166
+ const { deckHash, ops } = await readJson(request)
167
+ const result = file.apply(ops, deckHash)
168
+ return send(response, result.status, result)
169
+ }
170
+ response.setHeader('Allow', pathname === '/' ? 'GET' : 'POST')
171
+ return send(response, 405, { error: 'Method not allowed' })
172
+ } catch (error) {
173
+ return send(response, error.status ?? 500, { error: error.message })
174
+ }
175
+ }
176
+ }
177
+
178
+ export function inkPlugin(slidesPath, { authorize } = {}) {
179
+ return {
180
+ name: 'vite-plugin-mdeck-ink',
181
+ configureServer(server) {
182
+ const file = createInkFile(slidesPath, { onChange: ({ renamed }) => server.ws.send('mdeck:ink', { renamed }) })
183
+ server.middlewares.use('/__mdeck/ink', inkMiddleware(file, { authorize }))
184
+ server.watcher.on('change', changed => {
185
+ if (resolve(changed) !== resolve(slidesPath)) return
186
+ const renamed = file.deckChanged()
187
+ if (renamed.length) server.ws.send('mdeck:ink', { renamed })
188
+ })
189
+ server.httpServer?.once('close', () => file.flush())
190
+ },
191
+ }
192
+ }
@@ -0,0 +1,18 @@
1
+ // Files mdeck itself just wrote while the dev server runs, so the watcher can
2
+ // tell its own writes (a new slide id, saved ink) from edits by the author:
3
+ // its own writes must not reload the open windows in the middle of a talk.
4
+ import { createHash } from 'node:crypto'
5
+ import { resolve } from 'node:path'
6
+
7
+ const written = new Map()
8
+ const hash = content => createHash('sha1').update(content).digest('hex')
9
+
10
+ export function markOwnWrite(path, content) {
11
+ written.set(resolve(path), hash(content))
12
+ }
13
+
14
+ // True when `content` is what mdeck last wrote to `path`. The mark stays, as
15
+ // watchers may report one write twice; the same content needs no reload.
16
+ export function isOwnWrite(path, content) {
17
+ return written.get(resolve(path)) === hash(content)
18
+ }
@@ -0,0 +1,81 @@
1
+ // Pairing another device (an iPad) with `mdeck run`, so it may do what only
2
+ // this computer may: save ink and steer the rooms. The launch page, which
3
+ // only answers on this computer, offers a one-time address as a QR code; the
4
+ // device trades the one-time token in it for a token of its own, which it
5
+ // sends as `Authorization: Bearer …`. Tokens live in memory: restarting
6
+ // `mdeck run` or "Unpair" on the launch page ends every pairing.
7
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
8
+ import { send, readJson } from './editorPlugin.js'
9
+
10
+ const OFFER_MS = 10 * 60 * 1000
11
+ const token = () => randomBytes(24).toString('base64url')
12
+ const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y) }
13
+
14
+ export function createPairing({ now = Date.now } = {}) {
15
+ const offers = new Map() // one-time token → expiry
16
+ const devices = new Set()
17
+ const listeners = new Set()
18
+ const changed = () => { for (const listener of listeners) listener() }
19
+ const bearer = request => (request.headers?.authorization ?? '').replace(/^Bearer\s+/i, '')
20
+
21
+ return {
22
+ /** A one-time token for the QR code, valid for ten minutes. */
23
+ offer() {
24
+ for (const [offer, expires] of offers) if (expires < now()) offers.delete(offer)
25
+ const offer = token()
26
+ offers.set(offer, now() + OFFER_MS)
27
+ return offer
28
+ },
29
+ /** The device's own token for a one-time token, or null. */
30
+ claim(offer) {
31
+ if (typeof offer !== 'string') return null
32
+ for (const [known, expires] of offers) {
33
+ if (!same(known, offer)) continue
34
+ offers.delete(known)
35
+ if (expires < now()) return null
36
+ const device = token()
37
+ devices.add(device)
38
+ changed()
39
+ return device
40
+ }
41
+ return null
42
+ },
43
+ /** Whether a request comes from a paired device. */
44
+ allows(request) {
45
+ const given = bearer(request)
46
+ if (!given) return false
47
+ for (const device of devices) if (same(device, given)) return true
48
+ return false
49
+ },
50
+ revoke() { devices.clear(); offers.clear(); changed() },
51
+ /** The paired devices' tokens, for the server of `mdeck run --server`. */
52
+ tokens: () => [...devices],
53
+ /** Calls back when a device pairs or all are unpaired; returns a function that stops it. */
54
+ onChange(listener) { listeners.add(listener); return () => listeners.delete(listener) },
55
+ get devices() { return devices.size },
56
+ }
57
+ }
58
+
59
+ // POST /claim { token } → { token }: open to the network, like the page the
60
+ // QR code leads to; everything else about pairing is on the launch page.
61
+ export function pairingMiddleware(pairing) {
62
+ return async (request, response, next) => {
63
+ const { pathname } = new URL(request.url, 'http://localhost')
64
+ if (pathname !== '/claim') return next()
65
+ if (request.method !== 'POST') { response.setHeader('Allow', 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
66
+ try {
67
+ const device = pairing.claim((await readJson(request))?.token)
68
+ if (!device) return send(response, 403, { error: 'This pairing code was used or is too old; show a new one on the launch page' })
69
+ return send(response, 200, { token: device })
70
+ } catch (error) {
71
+ return send(response, error.status ?? 500, { error: error.message })
72
+ }
73
+ }
74
+ }
75
+
76
+ export function pairingPlugin(pairing) {
77
+ return {
78
+ name: 'vite-plugin-mdeck-pairing',
79
+ configureServer(server) { server.middlewares.use('/__mdeck/pair', pairingMiddleware(pairing)) },
80
+ }
81
+ }
@@ -0,0 +1,34 @@
1
+ // Renders a built deck to PDF with a local headless Chrome, using the deck's
2
+ // own print rules: one slide per page at the design size, vector text.
3
+ import { readFileSync, writeFileSync } from 'node:fs'
4
+ import { resolve, dirname, basename } from 'node:path'
5
+ import { setTimeout as delay } from 'node:timers/promises'
6
+ import { launchChrome, findChrome } from './chrome.js'
7
+
8
+ export { findChrome }
9
+
10
+ export async function renderPdf({ htmlFile, output, chrome = findChrome(), timeout = 90000 }) {
11
+ const html = resolve(htmlFile)
12
+ const browser = await launchChrome({ dir: dirname(html), chrome, timeout })
13
+ try {
14
+ const page = await browser.open(`${encodeURIComponent(basename(html))}?view=deck&embedded=1`)
15
+ await page.waitForDeck()
16
+ // Reveal every step and let interactive slides render their final state.
17
+ await page.evaluate("document.querySelector('deck-stage').printing = true")
18
+ await delay(300)
19
+ const { data } = await page.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0 })
20
+ writeFileSync(output, Buffer.from(data, 'base64'))
21
+ return output
22
+ } finally {
23
+ await browser.close()
24
+ }
25
+ }
26
+
27
+ // Points the share view at the PDF: a relative link for folders, an embedded
28
+ // data URI for single files.
29
+ export function attachPdf(htmlFile, pdfFile, { embed = false } = {}) {
30
+ const html = readFileSync(htmlFile, 'utf8').replace(/<link rel="alternate" type="application\/pdf"[^>]*>/, '')
31
+ const href = embed ? `data:application/pdf;base64,${readFileSync(pdfFile).toString('base64')}` : encodeURIComponent(basename(pdfFile))
32
+ const link = `<link rel="alternate" type="application/pdf" href="${href}">`
33
+ writeFileSync(htmlFile, html.includes('</head>') ? html.replace('</head>', `${link}</head>`) : link + html)
34
+ }
@@ -0,0 +1,85 @@
1
+ import { readFileSync, realpathSync } from 'node:fs'
2
+ import { resolve, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { shareable, resourceKey } from './tunnelClient.js'
5
+ import { collectLocalAssetRefs } from './slidesPlugin.js'
6
+ import { parseSlides } from '../core/parseSlides.js'
7
+ import { sessionCode } from '../live/code.js'
8
+
9
+ const canonical = file => { try { return realpathSync(file) } catch { return null } }
10
+
11
+ // Use the entry's import graph, rather than Vite's filesystem allow list.
12
+ // Unrelated files and modules that a local editor loaded stay private.
13
+ export function createRelayAccess(server, slidesPath) {
14
+ const base = server.config.base
15
+ const root = server.config.root
16
+ const graph = server.environments.client.moduleGraph
17
+ const at = path => `${base}${path.replace(/^\//, '')}`
18
+ const bootstrap = ['main.jsx', '@vite/client', '@vite/env', '@id/__x00__virtual:prefresh']
19
+ const knownFiles = new Map()
20
+ let knownSource = null
21
+ return async (address, method) => {
22
+ let source
23
+ try { source = readFileSync(slidesPath, 'utf8') } catch { return false }
24
+ if (source !== knownSource) { knownFiles.clear(); knownSource = source }
25
+ const resources = new Set(bootstrap.map(path => resourceKey(at(path))))
26
+ const files = new Map()
27
+ const addFile = (file, address) => {
28
+ const real = canonical(file)
29
+ if (!real) return
30
+ if (!knownFiles.has(file)) knownFiles.set(file, real)
31
+ if (knownFiles.get(file) !== real) return
32
+ resources.add(resourceKey(address))
33
+ files.set(resourceKey(address), { file, real })
34
+ }
35
+ const seen = new Set()
36
+ const visit = mod => {
37
+ if (!mod || seen.has(mod)) return
38
+ seen.add(mod)
39
+ const url = mod.url.startsWith(base) ? mod.url : at(mod.url)
40
+ if (mod.file && !mod.id?.startsWith('\0')) addFile(mod.file, url)
41
+ else resources.add(resourceKey(url))
42
+ if (mod.id?.startsWith('\0')) resources.add(resourceKey(at(`@id/__x00__${mod.id.slice(1)}`)))
43
+ for (const child of mod.importedModules) visit(child)
44
+ // Vite rewrites CSS url() references without adding them as JS imports.
45
+ if (mod.file?.endsWith('.css')) {
46
+ let css
47
+ try { css = readFileSync(mod.file, 'utf8') } catch { return }
48
+ for (const match of css.matchAll(/url\(\s*["']?([^\s"')]+)["']?\s*\)/g)) {
49
+ const ref = match[1]
50
+ if (/^(?:[a-z]+:|\/\/|#)/i.test(ref)) continue
51
+ const file = ref.startsWith('/') ? resolve(root, `.${ref}`) : resolve(dirname(mod.file), ref.split(/[?#]/)[0])
52
+ addFile(file, at(`@fs/${file.replace(/^\//, '')}`))
53
+ }
54
+ }
55
+ }
56
+ visit(graph.getModuleById(resolve(root, 'main.jsx')))
57
+ // The HTML-injected Vite client is a second trusted entry point.
58
+ visit(await graph.getModuleByUrl(at('@vite/client')))
59
+ const viteEnv = fileURLToPath(new URL('../client/env.mjs', import.meta.resolve('vite')))
60
+ addFile(viteEnv, at(`@fs/${viteEnv.replace(/^\//, '')}`))
61
+ addFile(resolve(root, 'main.jsx'), at('main.jsx'))
62
+ addFile(resolve(root, 'index.html'), at('index.html'))
63
+ addFile(fileURLToPath(new URL('../client/client.mjs', import.meta.resolve('vite'))), at('@vite/client'))
64
+ for (const ref of collectLocalAssetRefs(source)) {
65
+ // Root-relative assets belong to the deck's public directory.
66
+ const file = resolve(dirname(slidesPath), ref.replace(/^\//, ''))
67
+ const path = ref.replace(/^\.\//, '').replace(/^\//, '')
68
+ addFile(file, at(path))
69
+ addFile(file, at(`@fs/${file.replace(/^\//, '')}`))
70
+ }
71
+ const allowed = shareable(address, method, { base, resources, session: sessionCode(parseSlides(source).deckConfig) })
72
+ if (!allowed) return false
73
+ // Recheck symlinks before forwarding, even if Vite cached an older graph.
74
+ const entry = files.get(resourceKey(address))
75
+ if (entry && canonical(entry.file) !== entry.real) return false
76
+ // Vite serves public files ahead of framework modules. A file beside the
77
+ // deck must not shadow an approved runtime URL and become shareable.
78
+ const path = decodeURIComponent(new URL(address, 'http://localhost').pathname).slice(base.length)
79
+ if (path && !path.startsWith('__mdeck/')) {
80
+ const publicFile = canonical(resolve(dirname(slidesPath), path))
81
+ if (publicFile && publicFile !== entry?.real) return false
82
+ }
83
+ return true
84
+ }
85
+ }