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,130 @@
1
+ // Headless Chrome over the DevTools protocol, used for PDF rendering and
2
+ // screenshots. Serves a folder over loopback so relative assets resolve.
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync, readFileSync, mkdtempSync, rmSync, statSync } from 'node:fs'
5
+ import { createServer } from 'node:http'
6
+ import { tmpdir } from 'node:os'
7
+ import { resolve, extname, relative, isAbsolute } from 'node:path'
8
+ import { setTimeout as delay } from 'node:timers/promises'
9
+
10
+ const CANDIDATES = {
11
+ darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
12
+ linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
13
+ win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'],
14
+ }
15
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff2': 'font/woff2', '.woff': 'font/woff', '.pdf': 'application/pdf' }
16
+
17
+ export function findChrome() {
18
+ if (process.env.MDECK_CHROME) return existsSync(process.env.MDECK_CHROME) ? process.env.MDECK_CHROME : null
19
+ return (CANDIDATES[process.platform] ?? []).find(existsSync) ?? null
20
+ }
21
+
22
+ export function serveDirectory(root) {
23
+ return createServer((request, response) => {
24
+ try {
25
+ const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
26
+ const file = resolve(root, '.' + pathname)
27
+ const rel = relative(root, file)
28
+ if (rel.startsWith('..') || isAbsolute(rel) || !statSync(file).isFile()) throw new Error('not found')
29
+ response.writeHead(200, { 'Content-Type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream' })
30
+ response.end(readFileSync(file))
31
+ } catch { response.writeHead(404); response.end() }
32
+ })
33
+ }
34
+
35
+ // Starts Chrome and a static server for `dir`. `open(path)` returns a page
36
+ // with evaluate(), send() and waitFor(); call close() when done.
37
+ export async function launchChrome({ dir, chrome = findChrome(), timeout = 90000 } = {}) {
38
+ if (!chrome) throw new Error('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.')
39
+ const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-chrome-'))
40
+ const server = serveDirectory(dir)
41
+ await new Promise(done => server.listen(0, '127.0.0.1', done))
42
+ const origin = `http://127.0.0.1:${server.address().port}`
43
+ const flags = ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--hide-scrollbars', '--remote-debugging-port=0', `--user-data-dir=${resolve(temp, 'profile')}`,
44
+ // Every tab keeps rendering, like the separate windows of a talk: Chrome
45
+ // otherwise pauses tabs behind the front one, and an input event or an
46
+ // animation frame there waited for a frame that did not come.
47
+ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows']
48
+ // Chrome refuses to start as root with its sandbox on, as in CI containers.
49
+ if (process.getuid?.() === 0) flags.push('--no-sandbox')
50
+ const browser = spawn(chrome, [...flags, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
51
+ const pending = new Map()
52
+ let socket
53
+ // `timeout` limits each step (starting Chrome, each command), not the whole
54
+ // session: a long job with many quick steps must not fail at an arbitrary
55
+ // step once a total budget runs out.
56
+ const close = async () => {
57
+ for (const task of pending.values()) clearTimeout(task.timer)
58
+ socket?.close()
59
+ server.close()
60
+ if (browser.exitCode === null) { browser.kill(); await new Promise(done => browser.once('exit', done)) }
61
+ // Chrome's helper processes may still write to the profile for a moment
62
+ // after the browser exits; retry, and never fail a finished job over a
63
+ // temporary folder.
64
+ try { rmSync(temp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } catch {}
65
+ }
66
+ try {
67
+ const endpoint = await new Promise((resolveEndpoint, reject) => {
68
+ let log = ''
69
+ const startup = setTimeout(() => reject(new Error('Timed out while starting Chrome')), timeout)
70
+ browser.on('error', reject)
71
+ browser.on('exit', code => reject(new Error(`Chrome exited early (${code})`)))
72
+ browser.stderr.on('data', data => { log += data; const match = log.match(/DevTools listening on (ws:\/\/\S+)/); if (match) { clearTimeout(startup); resolveEndpoint(match[1]) } })
73
+ })
74
+ socket = new WebSocket(endpoint)
75
+ await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }) })
76
+ let nextId = 0
77
+ socket.addEventListener('message', ({ data }) => {
78
+ const message = JSON.parse(data)
79
+ if (!message.id) return
80
+ const task = pending.get(message.id)
81
+ pending.delete(message.id)
82
+ clearTimeout(task?.timer)
83
+ if (message.error) task?.reject(new Error(message.error.message))
84
+ else task?.resolve(message.result)
85
+ })
86
+ const send = (method, params = {}, sessionId) => new Promise((resolveTask, reject) => {
87
+ const id = ++nextId
88
+ // Where the command came from, so a timeout names the step that hung.
89
+ const caller = new Error().stack?.split('\n').slice(2).find(line => !line.includes('/src/build/chrome.js'))?.trim() ?? ''
90
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timed out while driving Chrome: ${method} did not answer within ${timeout / 1000} s ${caller}`)) }, timeout)
91
+ pending.set(id, { resolve: resolveTask, reject, timer })
92
+ socket.send(JSON.stringify({ id, method, params, sessionId }))
93
+ })
94
+ const open = async path => {
95
+ const url = path.startsWith('http') ? path : origin + '/' + path.replace(/^\//, '')
96
+ const { targetId } = await send('Target.createTarget', { url })
97
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
98
+ const page = {
99
+ url, sessionId,
100
+ send: (method, params) => send(method, params, sessionId),
101
+ async evaluate(expression) {
102
+ const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)
103
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.text)
104
+ return result.result.value
105
+ },
106
+ async waitFor(expression, { attempts = 600, interval = 100 } = {}) {
107
+ for (let attempt = 0; attempt < attempts; attempt++) {
108
+ if (await page.evaluate(expression)) return true
109
+ await delay(interval)
110
+ }
111
+ return false
112
+ },
113
+ // Waits until the deck stage exists, fonts are loaded and images decoded.
114
+ waitForDeck: () => page.waitFor("document.querySelector('deck-stage')?.length > 0 && document.fonts.status === 'loaded' && [...document.images].every(image => image.complete)"),
115
+ async screenshot({ width = 1600, height = 900, scale = 1, format = 'png' } = {}) {
116
+ await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile: false }, sessionId)
117
+ await delay(150)
118
+ const { data } = await send('Page.captureScreenshot', { format, ...(format === 'png' ? {} : { quality: 82 }), captureBeyondViewport: false }, sessionId)
119
+ return Buffer.from(data, 'base64')
120
+ },
121
+ close: () => send('Target.closeTarget', { targetId }),
122
+ }
123
+ return page
124
+ }
125
+ return { origin, open, close }
126
+ } catch (error) {
127
+ await close()
128
+ throw error
129
+ }
130
+ }
@@ -0,0 +1,44 @@
1
+ // Where a deck's own Preact components come from: its `components/` folder,
2
+ // then the folders its `components:` setting lists, in order. Each `*.jsx`
3
+ // file's default export is registered under its lowercased file name, so
4
+ // `Tokenizer.jsx` becomes `<tokenizer>`. The first folder that has a name wins,
5
+ // and all of them override built-ins of the same name.
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { resolve, dirname, basename } from 'node:path'
9
+ import { parseSlides } from '../core/parseSlides.js'
10
+
11
+ function expand(path, deckDir) {
12
+ if (path === '~' || path.startsWith('~/')) return resolve(homedir(), path.slice(2))
13
+ return resolve(deckDir, path)
14
+ }
15
+
16
+ // Folder paths as written in the deck, resolved; entries that are not strings
17
+ // are left to validateDeck to report.
18
+ export function componentFolders(slidesPath, source = readFileSync(slidesPath, 'utf8')) {
19
+ const abs = resolve(slidesPath)
20
+ const deckDir = dirname(abs)
21
+ let listed = []
22
+ try { listed = parseSlides(source).deckConfig?.components ?? [] } catch {}
23
+ return [
24
+ { dir: resolve(deckDir, 'components'), source: 'deck', path: 'components' },
25
+ ...(Array.isArray(listed) ? listed : []).filter(path => typeof path === 'string' && path.trim())
26
+ .map(path => ({ dir: expand(path.trim(), deckDir), source: 'shared', path: path.trim() })),
27
+ ]
28
+ }
29
+
30
+ export function isFolder(dir) {
31
+ try { return statSync(dir).isDirectory() } catch { return false }
32
+ }
33
+
34
+ export function componentFiles(slidesPath, folders = componentFolders(slidesPath)) {
35
+ const seen = new Map()
36
+ for (const folder of folders) {
37
+ if (!existsSync(folder.dir) || !isFolder(folder.dir)) continue
38
+ for (const name of readdirSync(folder.dir).filter(name => /\.jsx$/.test(name)).sort()) {
39
+ const tag = basename(name, '.jsx').toLowerCase()
40
+ if (!seen.has(tag)) seen.set(tag, { tag, file: resolve(folder.dir, name), source: folder.source, folder: folder.path })
41
+ }
42
+ }
43
+ return [...seen.values()]
44
+ }
@@ -0,0 +1,61 @@
1
+ import { resolve, dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import preact from '@preact/preset-vite'
4
+ import { slidesPlugin } from './slidesPlugin.js'
5
+ import { frameworkRoot, runtimeRoot } from '../paths.js'
6
+ import { componentFolders } from './components.js'
7
+ import { userExtensionsDir } from '../extensions/discover.js'
8
+
9
+ // Resolve preact's entry points through Node so the package's `exports` map is
10
+ // honoured — `preact/jsx-dev-runtime` has no directory of its own, so a
11
+ // hand-written path alias would break the dev server. Exact-match regexes keep
12
+ // `preact` from swallowing its own subpaths.
13
+ const PREACT_ENTRIES = [
14
+ 'preact',
15
+ 'preact/hooks',
16
+ 'preact/jsx-runtime',
17
+ 'preact/jsx-dev-runtime',
18
+ 'preact/compat',
19
+ ]
20
+
21
+ function preactAliases() {
22
+ const aliases = []
23
+ for (const spec of PREACT_ENTRIES) {
24
+ let file
25
+ try {
26
+ file = fileURLToPath(import.meta.resolve(spec))
27
+ } catch {
28
+ continue // not installed in this framework copy — skip it
29
+ }
30
+ aliases.push({ find: new RegExp(`^${spec.replace(/\//g, '\\/')}$`), replacement: file })
31
+ }
32
+ return aliases
33
+ }
34
+
35
+ // ── Vite config ───────────────────────────────────────────────────────────────
36
+ export function baseConfig(slidesPath, { selfContained = false, defaultView = 'deck', server = null, proxyControls = false, source = undefined } = {}) {
37
+ const abs = resolve(slidesPath)
38
+ return {
39
+ configFile: false,
40
+ root: runtimeRoot,
41
+ plugins: [preact(), slidesPlugin(abs, source === undefined ? {} : { source })],
42
+ // Deck-local components live outside the framework root and have no
43
+ // node_modules of their own, so their preact imports must resolve back to
44
+ // the framework's copy — and to the *same* instance, or hooks break.
45
+ resolve: {
46
+ dedupe: ['preact'],
47
+ alias: preactAliases(),
48
+ },
49
+ server: {
50
+ port: 5173,
51
+ strictPort: false,
52
+ fs: { allow: [frameworkRoot, dirname(abs), userExtensionsDir(), ...componentFolders(abs, source).map(folder => folder.dir)] },
53
+ },
54
+ define: {
55
+ __MDECK_SELF_CONTAINED__: JSON.stringify(selfContained),
56
+ __MDECK_DEFAULT_VIEW__: JSON.stringify(defaultView),
57
+ __MDECK_SERVER__: JSON.stringify(server),
58
+ __MDECK_PROXY_CONTROLS__: JSON.stringify(proxyControls),
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { basename, resolve } from 'node:path'
3
+ import { inkFileFor, oldInkFileFor } from '../core/ink.js'
4
+
5
+ export function drawingsDiagnostic(slidesPath) {
6
+ const file = inkFileFor(resolve(slidesPath)), old = oldInkFileFor(resolve(slidesPath))
7
+ if (existsSync(old) && !existsSync(file)) return { severity: 'error', code: 'renamed-file', message: `${basename(old)} is now ${basename(file)}: run mdeck migrate to keep your drawings`, line: 1, column: 1 }
8
+ return null
9
+ }
10
+
11
+ export function assertDrawings(slidesPath) {
12
+ const problem = drawingsDiagnostic(slidesPath)
13
+ if (problem) throw new Error(problem.message)
14
+ }
@@ -0,0 +1,272 @@
1
+ // Local editing API for `mdeck edit`. The server is a guarded file writer:
2
+ // it only ever writes the one deck path, rejects stale writes with the
3
+ // current content, and tells the editor when the file changed on disk.
4
+ // Before its first write it copies the file into .mdeck-backups/ beside it.
5
+ import { createHash } from 'node:crypto'
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
7
+ import { resolve, basename, dirname, relative } from 'node:path'
8
+ import { builtInRegistry, loadRegistry, serializeRegistry } from '../extensions/discover.js'
9
+ import { fetchIndex, findPack, installedPacks, installFromIndex, PackError } from '../extensions/packs.js'
10
+ import { createRequire } from 'node:module'
11
+ import { ManifestError, KINDS, ID_RE, MANIFEST_FILENAME, parseManifestText, validateManifest } from '../extensions/manifest.js'
12
+
13
+ const VERSION = createRequire(import.meta.url)('../../package.json').version
14
+
15
+ const BODY_LIMIT = 32 * 1024 * 1024
16
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
17
+
18
+ export function hashSource(source) {
19
+ return createHash('sha1').update(source, 'utf8').digest('hex')
20
+ }
21
+
22
+ export const BACKUP_DIR = '.mdeck-backups'
23
+ const BACKUPS_KEPT = 10
24
+
25
+ // Copies the deck as it is now to .mdeck-backups/<name>-<time>.md and keeps
26
+ // the newest few copies of this deck.
27
+ export function backupDeck(abs, source, now = new Date()) {
28
+ const dir = resolve(dirname(abs), BACKUP_DIR)
29
+ mkdirSync(dir, { recursive: true })
30
+ const name = basename(abs).replace(/\.md$/i, '')
31
+ const stamp = now.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z')
32
+ const file = resolve(dir, `${name}-${stamp}.md`)
33
+ writeFileSync(file, source, 'utf8')
34
+ const prefix = `${name}-`
35
+ const copies = readdirSync(dir).filter(entry => entry.startsWith(prefix) && /^\d{4}-\d{2}-\d{2}T[\d-]+Z\.md$/.test(entry.slice(prefix.length))).sort()
36
+ for (const old of copies.slice(0, -BACKUPS_KEPT)) unlinkSync(resolve(dir, old))
37
+ return file
38
+ }
39
+
40
+ export function createDeckFile(abs, { backup = true } = {}) {
41
+ let lastWritten = null
42
+ let backupFile = null
43
+ const read = () => {
44
+ const source = readFileSync(abs, 'utf8')
45
+ return { source, hash: hashSource(source) }
46
+ }
47
+ return {
48
+ path: abs,
49
+ read,
50
+ write(source, base) {
51
+ if (!existsSync(abs)) return { ok: false, conflict: true, source: null, hash: null }
52
+ const current = read()
53
+ if (base !== current.hash) return { ok: false, conflict: true, ...current }
54
+ if (backup && !backupFile && source !== current.source) backupFile = backupDeck(abs, current.source)
55
+ writeFileSync(abs, source, 'utf8')
56
+ lastWritten = source
57
+ return { ok: true, hash: hashSource(source) }
58
+ },
59
+ isExternalChange(content) { return content !== lastWritten },
60
+ get backupFile() { return backupFile },
61
+ }
62
+ }
63
+
64
+ function hostname(host = '') {
65
+ const match = host.match(/^(\[[^\]]*\]|[^:]+)(?::\d+)?$/)
66
+ return match ? match[1].toLowerCase() : ''
67
+ }
68
+
69
+ // Names under .localhost always mean this computer and are never looked up
70
+ // in DNS (RFC 6761), so local proxies such as `name.localhost` count too.
71
+ const isLoopbackName = name => LOOPBACK.has(name) || name.endsWith('.localhost')
72
+
73
+ // Browsers send Origin on cross-site requests; a page served by this server
74
+ // has the same host. Everything else is refused, and only loopback hosts are
75
+ // served at all.
76
+ export const TUNNEL_MARK = 'x-mdeck-tunnel'
77
+
78
+ export function isAllowedRequest(request) {
79
+ // Through `mdeck run --server` every request arrives from this computer; the
80
+ // tunnel marks them (src/build/tunnelClient.js), and they must show a paired
81
+ // device's token instead.
82
+ if (request.headers[TUNNEL_MARK] != null) return false
83
+ const peer = request.socket?.remoteAddress
84
+ if (peer && peer !== '::1' && !/^(?:::ffff:)?127\.\d+\.\d+\.\d+$/.test(peer)) return false
85
+ const host = request.headers.host ?? ''
86
+ if (!isLoopbackName(hostname(host))) return false
87
+ const origin = request.headers.origin
88
+ if (origin == null) return true
89
+ try { return new URL(origin).host.toLowerCase() === host.toLowerCase() } catch { return false }
90
+ }
91
+
92
+ export function send(response, status, body) {
93
+ const text = JSON.stringify(body)
94
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
95
+ response.end(text)
96
+ }
97
+
98
+ export function readJson(request) {
99
+ return new Promise((resolvePromise, reject) => {
100
+ const type = request.headers['content-type'] ?? ''
101
+ if (!/^application\/json\b/i.test(type)) return reject(Object.assign(new Error('Send JSON with Content-Type: application/json'), { status: 415 }))
102
+ const chunks = []
103
+ let size = 0
104
+ request.on('data', chunk => {
105
+ size += chunk.length
106
+ if (size > BODY_LIMIT) { reject(Object.assign(new Error('Request body too large'), { status: 413 })); request.destroy(); return }
107
+ chunks.push(chunk)
108
+ })
109
+ request.on('end', () => {
110
+ try { resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { reject(Object.assign(new Error('Request body is not valid JSON'), { status: 400 })) }
111
+ })
112
+ request.on('error', reject)
113
+ })
114
+ }
115
+
116
+ // ─── Deck-local extensions ────────────────────────────────────────────────
117
+ // Extension folders beside the deck can be read, written and removed. Built-in
118
+ // extensions are readable only. File names are plain basenames inside the
119
+ // extension folder; the manifest must validate before anything is written.
120
+
121
+ const FILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
122
+ const TEXT_FILE_RE = /\.(toml|jsx|js|css|md|txt|json)$/i
123
+
124
+ export function createExtensionStore(deckDir, { registryFor }) {
125
+ const root = resolve(deckDir, 'extensions')
126
+ const folder = id => resolve(root, id)
127
+ const check = (kind, id) => {
128
+ if (!KINDS.includes(kind)) throw Object.assign(new Error('Unknown extension kind'), { status: 400 })
129
+ if (typeof id !== 'string' || !ID_RE.test(id)) throw Object.assign(new Error('Extension ids use lowercase letters, digits and hyphens and start with a letter'), { status: 400 })
130
+ }
131
+ const readFolder = dir => Object.fromEntries(readdirSync(dir, { withFileTypes: true })
132
+ .filter(entry => entry.isFile() && TEXT_FILE_RE.test(entry.name) && FILE_NAME_RE.test(entry.name))
133
+ .map(entry => [entry.name, readFileSync(resolve(dir, entry.name), 'utf8')]))
134
+ return {
135
+ root,
136
+ read(kind, id) {
137
+ check(kind, id)
138
+ const record = registryFor()[`${kind}s`][id]
139
+ if (!record) throw Object.assign(new Error(`No ${kind} "${id}"`), { status: 404 })
140
+ return { kind, id, source: record.source, dir: record.dir, files: readFolder(record.dir), manifest: record.manifest }
141
+ },
142
+ // Validates the manifest against the files that will exist, then writes.
143
+ write(kind, id, files) {
144
+ check(kind, id)
145
+ if (!files || typeof files !== 'object' || Array.isArray(files)) throw Object.assign(new Error('Expected files: { name: text | null }'), { status: 400 })
146
+ for (const [name, text] of Object.entries(files)) {
147
+ if (!FILE_NAME_RE.test(name) || !TEXT_FILE_RE.test(name)) throw Object.assign(new Error(`Cannot write "${name}"`), { status: 400 })
148
+ if (text !== null && typeof text !== 'string') throw Object.assign(new Error(`"${name}" must be text or null`), { status: 400 })
149
+ }
150
+ const registry = registryFor()
151
+ const existing = registry[`${kind}s`][id]
152
+ if (existing && existing.source !== 'local') throw Object.assign(new Error(`"${id}" is ${existing.source === 'user' ? 'installed for every deck' : 'built into mdeck'}; copy it into this deck under a new id to change it`), { status: 403 })
153
+ for (const other of KINDS.filter(k => k !== kind)) if (registry[`${other}s`][id]) throw Object.assign(new Error(`"${id}" is already a ${other}`), { status: 409 })
154
+ const dir = folder(id)
155
+ const current = existsSync(dir) ? readFolder(dir) : {}
156
+ const next = { ...current }
157
+ for (const [name, text] of Object.entries(files)) { if (text === null) delete next[name]; else next[name] = text }
158
+ if (typeof next[MANIFEST_FILENAME] !== 'string') throw Object.assign(new Error(`${MANIFEST_FILENAME} is required`), { status: 400 })
159
+ const manifestPath = resolve(dir, MANIFEST_FILENAME)
160
+ const record = validateManifest(parseManifestText(next[MANIFEST_FILENAME], manifestPath), {
161
+ file: manifestPath, dir, folderName: id, fileExists: path => Object.hasOwn(next, relative(dir, path)),
162
+ })
163
+ if (record.kind !== kind) throw Object.assign(new Error(`The manifest says kind = "${record.kind}", not "${kind}"`), { status: 400 })
164
+ mkdirSync(dir, { recursive: true })
165
+ for (const [name, text] of Object.entries(files)) {
166
+ const path = resolve(dir, name)
167
+ if (text === null) { if (existsSync(path)) unlinkSync(path) } else writeFileSync(path, text, 'utf8')
168
+ }
169
+ return { kind, id, dir, files: readFolder(dir) }
170
+ },
171
+ remove(kind, id) {
172
+ check(kind, id)
173
+ const record = registryFor()[`${kind}s`][id]
174
+ if (!record) throw Object.assign(new Error(`No ${kind} "${id}"`), { status: 404 })
175
+ if (record.source !== 'local') throw Object.assign(new Error(`"${id}" is ${record.source === 'user' ? 'installed for every deck; remove it with mdeck themes remove --global' : 'built into mdeck and cannot be removed'}`), { status: 403 })
176
+ rmSync(record.dir, { recursive: true, force: true })
177
+ return { kind, id }
178
+ },
179
+ }
180
+ }
181
+
182
+ export function editorMiddleware(deckFile, { registryFor, extensions = createExtensionStore(dirname(deckFile.path), { registryFor }) }) {
183
+ const extensionsDir = resolve(dirname(deckFile.path), 'extensions')
184
+ return async (request, response, next) => {
185
+ const pathname = new URL(request.url, 'http://localhost').pathname
186
+ if (!isAllowedRequest(request)) return send(response, 403, { error: 'Requests are only accepted from this editor on this computer' })
187
+ try {
188
+ const extension = pathname.match(/^\/extension\/([a-z]+)\/([^/]+)$/)
189
+ if (extension) {
190
+ const [, kind, id] = extension
191
+ if (request.method === 'GET') return send(response, 200, extensions.read(kind, id))
192
+ if (request.method === 'PUT') {
193
+ const body = await readJson(request)
194
+ const result = extensions.write(kind, id, body?.files)
195
+ return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
196
+ }
197
+ if (request.method === 'DELETE') {
198
+ const result = extensions.remove(kind, id)
199
+ return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
200
+ }
201
+ response.setHeader('Allow', 'GET, PUT, DELETE')
202
+ return send(response, 405, { error: 'Method not allowed' })
203
+ }
204
+ // The theme repository: what it offers, and installing a pack into the
205
+ // deck's extensions folder.
206
+ if (pathname === '/themes/index' && request.method === 'GET') {
207
+ const index = await fetchIndex()
208
+ return send(response, 200, { ...index, installed: installedPacks(extensionsDir) })
209
+ }
210
+ if (pathname === '/themes/install' && request.method === 'POST') {
211
+ const body = await readJson(request)
212
+ const index = await fetchIndex()
213
+ const registry = builtInRegistry()
214
+ const result = await installFromIndex(index, findPack(index, String(body?.id ?? '')), { root: extensionsDir, known: { themes: registry.themes, palettes: registry.palettes }, mdeckVersion: VERSION })
215
+ return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
216
+ }
217
+ if (pathname === '/deck' && request.method === 'GET') {
218
+ const registry = registryFor()
219
+ const where = deckFile.designOnly ? { path: dirname(deckFile.path), name: basename(dirname(deckFile.path)), designOnly: true } : { path: deckFile.path, name: basename(deckFile.path) }
220
+ return send(response, 200, { ...where, ...deckFile.read(), registry: serializeRegistry(registry), warnings: registry.warnings })
221
+ }
222
+ if (pathname === '/source' && request.method === 'POST' && deckFile.designOnly) return send(response, 403, { error: 'There is no deck to save: the design page was started without one' })
223
+ if (pathname === '/source' && request.method === 'POST') {
224
+ const body = await readJson(request)
225
+ if (typeof body?.source !== 'string' || typeof body?.base !== 'string') return send(response, 400, { error: 'Expected { source: string, base: string }' })
226
+ const result = deckFile.write(body.source, body.base)
227
+ if (result.ok) return send(response, 200, { hash: result.hash })
228
+ return send(response, 409, { error: 'conflict', source: result.source, hash: result.hash })
229
+ }
230
+ if (['/deck', '/source'].includes(pathname)) { response.setHeader('Allow', pathname === '/deck' ? 'GET' : 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
231
+ return next()
232
+ } catch (error) {
233
+ if (error instanceof PackError) return send(response, 400, { error: error.message })
234
+ if (error instanceof ManifestError) return send(response, 500, { error: error.message })
235
+ return send(response, error.status ?? 500, { error: error.message })
236
+ }
237
+ }
238
+ }
239
+
240
+ // A deck that is only text and is never written: what the design page shows
241
+ // when `mdeck design` starts it without a deck. `abs` places it in the folder
242
+ // whose extensions are edited.
243
+ export function createSampleDeck(abs, source) {
244
+ return { path: abs, designOnly: true, read: () => ({ source, hash: hashSource(source) }), write: () => ({ ok: false, conflict: true, source, hash: hashSource(source) }), isExternalChange: () => false, backupFile: null }
245
+ }
246
+
247
+ // `source` is given for `mdeck design` without a deck: the editing API then
248
+ // serves it read-only and only extensions are written.
249
+ export function editorPlugin(slidesPath, { source = null } = {}) {
250
+ const abs = resolve(slidesPath)
251
+ const deckFile = source === null ? createDeckFile(abs) : createSampleDeck(abs, source)
252
+ return {
253
+ name: 'vite-plugin-mdeck-editor',
254
+ configureServer(server) {
255
+ server.middlewares.use('/__mdeck', editorMiddleware(deckFile, { registryFor: () => loadRegistry(abs) }))
256
+ if (source !== null) return
257
+ server.watcher.add(abs)
258
+ let timer
259
+ const changed = file => {
260
+ if (resolve(file) !== abs) return
261
+ clearTimeout(timer)
262
+ timer = setTimeout(() => {
263
+ let content
264
+ try { content = readFileSync(abs, 'utf8') } catch { return }
265
+ if (deckFile.isExternalChange(content)) server.ws.send('mdeck:deck-changed', { hash: hashSource(content) })
266
+ }, 75)
267
+ }
268
+ server.watcher.on('change', changed)
269
+ server.watcher.on('add', changed)
270
+ },
271
+ }
272
+ }
@@ -0,0 +1,36 @@
1
+ // The `mdeck edit` server, also started on demand from the launch page. It
2
+ // listens on loopback only and never reloads pages when the deck changes.
3
+ // `open` is true for the editor, or the path of another page to open.
4
+ import { createServer } from 'vite'
5
+ import preact from '@preact/preset-vite'
6
+ import { dirname, resolve } from 'node:path'
7
+ import { baseConfig } from './config.js'
8
+ import { slidesPlugin } from './slidesPlugin.js'
9
+ import { editorPlugin, BACKUP_DIR } from './editorPlugin.js'
10
+ import { sampleDeck, sampleDataUrl } from '../editor/sampleDeck.js'
11
+
12
+ export function createEditorServer(slidesPath, { port = 5173, strictPort = false, open = false } = {}) {
13
+ const abs = resolve(slidesPath)
14
+ const base = baseConfig(abs)
15
+ return createServer({
16
+ ...base,
17
+ plugins: [preact(), slidesPlugin(abs, { editor: true }), editorPlugin(abs)],
18
+ publicDir: dirname(abs),
19
+ server: { ...base.server, host: '127.0.0.1', cors: false, port, strictPort, open: open === true ? '/editor.html' : open || false, watch: { ignored: [`**/${BACKUP_DIR}/**`] } },
20
+ })
21
+ }
22
+
23
+ // `mdeck design` without a deck: the design page on the sample deck, making
24
+ // and changing the extensions in `folder`/extensions, where a deck in that
25
+ // folder finds them. No deck file is read or written.
26
+ export function createDesignServer(folder, { port = 5173, strictPort = false, open = false } = {}) {
27
+ const abs = resolve(folder, 'design.md')
28
+ const source = sampleDeck(sampleDataUrl())
29
+ const base = baseConfig(abs, { source })
30
+ return createServer({
31
+ ...base,
32
+ plugins: [preact(), slidesPlugin(abs, { editor: true, source, ink: false }), editorPlugin(abs, { source })],
33
+ publicDir: false,
34
+ server: { ...base.server, host: '127.0.0.1', cors: false, port, strictPort, open: open === true ? '/design.html' : open || false },
35
+ })
36
+ }
@@ -0,0 +1,84 @@
1
+ // A theme's web fonts, embedded in a single-file build (mdeck send, --single-file,
2
+ // and the page mdeck pdf prints), so a file opened offline and its PDF look
3
+ // like the slides on the presenter's screen instead of falling back to system
4
+ // fonts.
5
+ //
6
+ // The theme's font stylesheets (Google Fonts and the like) are fetched once,
7
+ // limited to the Latin character sets, and every font file becomes a data URI.
8
+ // Fetched files are kept in a cache, so later builds need no network. Without
9
+ // network and cache the build goes on with the fallback fonts and says so.
10
+ import { createHash } from 'node:crypto'
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ // A current browser gets WOFF2, the smallest format.
16
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
17
+ // The character sets slides in Western languages need; Google Fonts names
18
+ // each block of a stylesheet after its set.
19
+ const SUBSETS = new Set(['latin', 'latin-ext'])
20
+ const FONT_TYPES = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' }
21
+
22
+ export const fontCacheDir = () => process.env.MDECK_CACHE_DIR
23
+ ? join(process.env.MDECK_CACHE_DIR, 'fonts')
24
+ : join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'mdeck', 'fonts')
25
+
26
+ const key = text => createHash('sha256').update(text).digest('hex').slice(0, 32)
27
+
28
+ async function cached(url, { fetch: get, cacheDir, binary, timeoutMs }) {
29
+ const file = join(cacheDir, key(url) + (binary ? '.bin' : '.css'))
30
+ if (existsSync(file)) return binary ? readFileSync(file) : readFileSync(file, 'utf8')
31
+ const response = await get(url, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(timeoutMs) })
32
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`)
33
+ const body = binary ? Buffer.from(await response.arrayBuffer()) : await response.text()
34
+ mkdirSync(cacheDir, { recursive: true })
35
+ writeFileSync(file, body)
36
+ return body
37
+ }
38
+
39
+ // The @font-face blocks of a stylesheet, without the character sets that
40
+ // slides rarely need (Cyrillic, Greek, Vietnamese …). A variable font is
41
+ // listed once per weight with the same file; those become one face with a
42
+ // weight range, so the file is embedded once.
43
+ export function latinFaces(css) {
44
+ const blocks = [...css.matchAll(/(?:\/\*\s*([\w-]+)\s*\*\/\s*)?(@font-face\s*\{[^}]*\})/g)]
45
+ const faces = blocks.filter(([, subset]) => !subset || SUBSETS.has(subset)).map(([, , face]) => face)
46
+ const groups = new Map()
47
+ for (const face of faces) {
48
+ const src = face.match(/src:\s*([^;]+);/)?.[1]
49
+ const style = face.match(/font-style:\s*([^;]+);/)?.[1] ?? 'normal'
50
+ const weights = (face.match(/font-weight:\s*([^;]+);/)?.[1] ?? '400').trim().split(/\s+/).map(Number)
51
+ const id = `${src}|${style}`
52
+ if (!src || weights.some(Number.isNaN)) { groups.set(face, { face }); continue }
53
+ const group = groups.get(id)
54
+ if (!group) groups.set(id, { face, min: Math.min(...weights), max: Math.max(...weights) })
55
+ else { group.min = Math.min(group.min, ...weights); group.max = Math.max(group.max, ...weights) }
56
+ }
57
+ return [...groups.values()].map(({ face, min, max }) => min == null ? face
58
+ : face.replace(/font-weight:\s*[^;]+;/, `font-weight: ${min === max ? min : `${min} ${max}`};`))
59
+ }
60
+
61
+ /**
62
+ * CSS with the fonts of these stylesheet URLs embedded, and warnings for
63
+ * whatever could not be fetched.
64
+ */
65
+ export async function embedFonts(urls = [], { fetch: get = fetch, cacheDir = fontCacheDir(), timeoutMs = 15000 } = {}) {
66
+ const faces = [], warnings = []
67
+ for (const url of urls) {
68
+ try {
69
+ const css = await cached(url, { fetch: get, cacheDir, binary: false, timeoutMs })
70
+ for (const face of latinFaces(css)) {
71
+ let embedded = face
72
+ for (const [match, fontUrl] of face.matchAll(/url\((?:'|")?([^'")]+)(?:'|")?\)/g)) {
73
+ const type = FONT_TYPES[fontUrl.split(/[?#]/)[0].split('.').pop()] ?? 'font/woff2'
74
+ const data = await cached(new URL(fontUrl, url).href, { fetch: get, cacheDir, binary: true, timeoutMs })
75
+ embedded = embedded.replace(match, `url(data:${type};base64,${data.toString('base64')})`)
76
+ }
77
+ faces.push(embedded)
78
+ }
79
+ } catch (error) {
80
+ warnings.push(`The font ${url} could not be embedded (${error.cause?.code ?? error.message}); the file uses the fallback fonts.`)
81
+ }
82
+ }
83
+ return { css: faces.join('\n'), warnings }
84
+ }