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,371 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'fs'
2
+ import { resolve, dirname, basename, sep } from 'path'
3
+ import { parseSlides } from '../core/parseSlides.js'
4
+ import { validateDeck, formatDiagnostics } from '../core/validateDeck.js'
5
+ import { loadRegistry, extensionRoots, manifestsOf } from '../extensions/discover.js'
6
+ import { resolvePalette } from '../extensions/appearance.js'
7
+ import { embedFonts } from './fonts.js'
8
+ import { stripNotes as stripNotesFrom } from '../core/editDeck.js'
9
+ import { componentFolders, componentFiles } from './components.js'
10
+ import { inkFileFor, normalizeInk, emptyInk } from '../core/ink.js'
11
+ import { isOwnWrite } from './ownWrites.js'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { assertDrawings } from './drawings.js'
14
+ import { marked } from 'marked'
15
+
16
+ const VIRTUAL_ID = 'virtual:slides'
17
+ const RESOLVED_ID = '\0virtual:slides'
18
+
19
+ const COMPONENTS_ID = 'virtual:deck-components'
20
+ const RESOLVED_COMPONENTS_ID = '\0virtual:deck-components'
21
+ const EXTENSIONS_ID = 'virtual:mdeck-extensions'
22
+ const RESOLVED_EXTENSIONS_ID = '\0virtual:mdeck-extensions'
23
+ const INK_ID = 'virtual:deck-ink'
24
+ const RESOLVED_INK_ID = '\0virtual:deck-ink'
25
+ const SVGS_ID = 'virtual:deck-svgs'
26
+ const RESOLVED_SVGS_ID = '\0virtual:deck-svgs'
27
+
28
+ const MIME_BY_EXT = {
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.gif': 'image/gif',
33
+ '.svg': 'image/svg+xml',
34
+ '.webp': 'image/webp',
35
+ '.avif': 'image/avif',
36
+ '.mp4': 'video/mp4',
37
+ '.m4v': 'video/x-m4v',
38
+ '.webm': 'video/webm',
39
+ '.ogv': 'video/ogg',
40
+ '.mp3': 'audio/mpeg',
41
+ '.m4a': 'audio/mp4',
42
+ '.ogg': 'audio/ogg',
43
+ '.wav': 'audio/wav',
44
+ }
45
+
46
+ function isLocalAssetRef(ref = '') {
47
+ return ref
48
+ && !/^([a-z]+:)?\/\//i.test(ref)
49
+ && !ref.startsWith('data:')
50
+ && !ref.startsWith('#')
51
+ }
52
+
53
+ function cleanAssetRef(ref) {
54
+ const q = ref.indexOf('?')
55
+ const h = ref.indexOf('#')
56
+ const end = [q, h].filter(i => i !== -1).sort((a, b) => a - b)[0] ?? ref.length
57
+ return ref.slice(0, end)
58
+ }
59
+
60
+ // An SVG picture that uses the theme's colour variables (var(--accent) …) is
61
+ // drawn inline by the picture layouts, so it follows the theme and palette.
62
+ // Inside an <img> it could not see them.
63
+ const themedSvg = file => /\.svg$/i.test(file) && existsSync(file) && /var\(--/.test(readFileSync(file, 'utf8'))
64
+
65
+ export function themedSvgs(deckFile, source = readFileSync(deckFile, 'utf8')) {
66
+ const svgs = {}
67
+ for (const ref of collectLocalAssetRefs(source)) {
68
+ const file = resolve(dirname(deckFile), ref)
69
+ if (themedSvg(file)) svgs[ref] = { file, markup: readFileSync(file, 'utf8').replace(/^\s*<\?xml[^>]*>\s*/, '').replace(/<!DOCTYPE[^>]*>\s*/i, '') }
70
+ }
71
+ return svgs
72
+ }
73
+
74
+ function toDataUrl(ref, baseDir) {
75
+ if (!isLocalAssetRef(ref)) return null
76
+ const cleanRef = cleanAssetRef(ref)
77
+ const abs = resolve(baseDir, cleanRef)
78
+ if (!existsSync(abs) || themedSvg(abs)) return null
79
+ const ext = cleanRef.toLowerCase().slice(cleanRef.lastIndexOf('.'))
80
+ const mime = MIME_BY_EXT[ext]
81
+ if (!mime) return null
82
+ const b64 = readFileSync(abs).toString('base64')
83
+ return `data:${mime};base64,${b64}`
84
+ }
85
+
86
+ function inlineMarkdownImages(markdown, baseDir) {
87
+ return markdown.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, alt, src, title) => {
88
+ const dataUrl = toDataUrl(src, baseDir)
89
+ if (!dataUrl) return m
90
+ return `![${alt}](${dataUrl}${title ? ` "${title}"` : ''})`
91
+ })
92
+ }
93
+
94
+ function inlineHtmlImgSources(markdown, baseDir) {
95
+ return markdown.replace(/(<img\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
96
+ const dataUrl = toDataUrl(src, baseDir)
97
+ if (!dataUrl) return m
98
+ return `${pre}${quote}${dataUrl}${endQuote}`
99
+ })
100
+ }
101
+
102
+ function inlineHtmlMediaSources(markdown, baseDir) {
103
+ return markdown.replace(/(<(?:video|audio|source|videoplayer)\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
104
+ const dataUrl = toDataUrl(src, baseDir)
105
+ if (!dataUrl) return m
106
+ return `${pre}${quote}${dataUrl}${endQuote}`
107
+ })
108
+ }
109
+
110
+ function inlineYamlImageFields(markdown, baseDir) {
111
+ return markdown.replace(/^(\s*)(image|logo):\s*(["']?)([^"'\n]+)\3\s*$/gm, (m, indent, key, quote, value) => {
112
+ const dataUrl = toDataUrl(value.trim(), baseDir)
113
+ if (!dataUrl) return m
114
+ const q = quote || '"'
115
+ return `${indent}${key}: ${q}${dataUrl}${q}`
116
+ })
117
+ }
118
+
119
+ export function collectLocalAssetRefs(markdown) {
120
+ const refs = new Set()
121
+ const add = ref => {
122
+ const clean = cleanAssetRef(String(ref || '').trim())
123
+ if (isLocalAssetRef(clean)) refs.add(clean)
124
+ }
125
+
126
+ const deck = parseSlides(markdown)
127
+ add(deck.deckConfig.meta?.logo)
128
+ for (const slide of deck.slides) {
129
+ add(slide.meta.image)
130
+ add(slide.meta.logo)
131
+ add(slide.meta.props?.image)
132
+ const bodies = [...Object.values(slide.regions).map(r => r.content), slide.meta.notes ?? '']
133
+ for (const body of bodies) {
134
+ if (typeof body !== 'string') continue
135
+ marked.walkTokens(marked.lexer(body), token => {
136
+ if (token.type === 'image') add(token.href)
137
+ if (token.type === 'html') {
138
+ for (const match of token.text.matchAll(/<(?:img|video|audio|source|videoplayer)\b[^>]*\bsrc=(["'])([^"']+)\1/gi)) add(match[2])
139
+ }
140
+ })
141
+ }
142
+ }
143
+
144
+ return [...refs]
145
+ }
146
+
147
+ // Applies fn to the text outside fenced code blocks, so a path shown in a code
148
+ // example stays text instead of becoming an embedded file.
149
+ function outsideCodeFences(markdown, fn) {
150
+ const lines = markdown.split('\n')
151
+ let out = '', text = '', fence = null
152
+ for (const [i, line] of lines.entries()) {
153
+ const chunk = line + (i < lines.length - 1 ? '\n' : '')
154
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]
155
+ if (fence) {
156
+ out += chunk
157
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length && !line.trim().slice(marker.length).trim()) fence = null
158
+ } else if (marker) {
159
+ out += fn(text) + chunk
160
+ text = ''
161
+ fence = marker
162
+ } else text += chunk
163
+ }
164
+ return out + fn(text)
165
+ }
166
+
167
+ export function maybeInlineAssets(markdown, abs, { inlineImages, inlineMedia }) {
168
+ if (!inlineImages && !inlineMedia) return markdown
169
+ const baseDir = dirname(abs)
170
+ return outsideCodeFences(markdown, text => {
171
+ let out = text
172
+ if (inlineImages) out = inlineMarkdownImages(out, baseDir)
173
+ if (inlineImages) out = inlineHtmlImgSources(out, baseDir)
174
+ if (inlineImages) out = inlineYamlImageFields(out, baseDir)
175
+ if (inlineMedia) out = inlineHtmlMediaSources(out, baseDir)
176
+ return out
177
+ })
178
+ }
179
+
180
+ // ─── Extension registry module ────────────────────────────────────────────
181
+ // Layouts, themes and palettes are discovered in Node and handed to the
182
+ // browser as one generated module. Layout code and styles are imported
183
+ // eagerly; theme stylesheets stay lazy so only the chosen theme is loaded.
184
+ // Layout JSX is trusted code, not sandboxed data.
185
+
186
+ export function generateExtensionsModule(registry) {
187
+ const imports = []
188
+ const layouts = Object.values(registry.layouts).map((t, i) => {
189
+ imports.push(`import L${i} from ${JSON.stringify(t.files.layout)}`, ...t.files.styles.map(s => `import ${JSON.stringify(s)}`))
190
+ return `${JSON.stringify(t.id)}: { manifest: ${JSON.stringify(t.manifest)}, render: L${i} }`
191
+ })
192
+ const themes = Object.values(registry.themes).map(t => {
193
+ const loads = t.files.styles.map(s => `import(${JSON.stringify(s + '?inline')})`)
194
+ return `${JSON.stringify(t.id)}: { manifest: ${JSON.stringify(t.manifest)}, load: () => Promise.all([${loads.join(', ')}]).then(mods => mods.map(m => m.default).join('\\n')) }`
195
+ })
196
+ const palettes = Object.values(registry.palettes).map(p => `${JSON.stringify(p.id)}: { manifest: ${JSON.stringify(p.manifest)} }`)
197
+ return `${imports.join('\n')}\nexport const layouts = {\n${layouts.join(',\n')}\n}\nexport const themes = {\n${themes.join(',\n')}\n}\nexport const palettes = {\n${palettes.join(',\n')}\n}\n`
198
+ }
199
+
200
+ // A file for readers carries only the deck's own theme and the palette it
201
+ // shows: the reader view offers light and dark, nothing else, and only that
202
+ // theme's fonts travel with the file.
203
+ export function deckLookOnly(registry, deckConfig = {}) {
204
+ const theme = registry.themes[deckConfig.theme ?? 'neue']
205
+ if (!theme) return registry
206
+ const palettes = Object.fromEntries(Object.entries(registry.palettes).map(([id, record]) => [id, record.manifest]))
207
+ const { palette } = resolvePalette({ theme: theme.manifest, palettes, palette: deckConfig.palette })
208
+ return { ...registry, themes: { [theme.id]: theme }, palettes: palette ? { [palette.id]: registry.palettes[palette.id] } : {} }
209
+ }
210
+
211
+ // `editor` keeps the page alive while `mdeck edit` rewrites the deck: deck
212
+ // changes only invalidate the module, validation errors are warnings, and
213
+ // extension changes are announced instead of forcing a reload. `source` is
214
+ // the deck's text where there is no file (`mdeck design`, on the sample deck);
215
+ // `slidesPath` then only says where its folder is.
216
+ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, ink = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
217
+ const abs = resolve(slidesPath)
218
+ const readDeck = () => given ?? readFileSync(abs, 'utf-8')
219
+ const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
220
+ const sourceFor = raw => stripNotes ? stripNotesFrom(raw) : raw
221
+ const inkPath = inkFileFor(abs)
222
+ const extensionDirs = extensionRoots(abs).map(root => root.dir)
223
+ let componentDirs = componentFolders(abs, readDeck()).map(folder => folder.dir)
224
+ const watchDirs = () => [...extensionDirs, ...componentDirs]
225
+ const isWatched = file => watchDirs().some(dir => resolve(file).startsWith(dir + sep))
226
+ // A changed `components:` list takes effect without restarting the server.
227
+ const followComponentFolders = server => {
228
+ const next = componentFolders(abs, readDeck()).map(folder => folder.dir)
229
+ if (next.join('\n') === componentDirs.join('\n')) return false
230
+ componentDirs = next
231
+ server.watcher.add(next)
232
+ const allow = server.config.server.fs.allow
233
+ for (const dir of next) if (!allow.includes(dir)) allow.push(dir)
234
+ return true
235
+ }
236
+ const ALL_IDS = [RESOLVED_ID, RESOLVED_COMPONENTS_ID, RESOLVED_EXTENSIONS_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID]
237
+ const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID]
238
+ const invalidate = (server, ids) => {
239
+ for (const id of ids) {
240
+ const mod = server.moduleGraph.getModuleById(id)
241
+ if (mod) server.moduleGraph.invalidateModule(mod)
242
+ }
243
+ }
244
+ const reload = (server, ids, file) => {
245
+ invalidate(server, ids)
246
+ if (!editor) server.ws.send({ type: 'full-reload' })
247
+ else if (file) server.ws.send('mdeck:extensions-changed', { file })
248
+ }
249
+
250
+ return {
251
+ name: 'vite-plugin-slides',
252
+ configureServer(server) {
253
+ server.watcher.add([abs, inkPath, ...watchDirs()])
254
+ const refresh = file => {
255
+ if (isWatched(file)) reload(server, ALL_IDS, resolve(file))
256
+ else if (resolve(file) === inkPath && !(() => { try { return isOwnWrite(inkPath, readFileSync(inkPath, 'utf-8')) } catch { return false } })()) reload(server, [RESOLVED_INK_ID], inkPath)
257
+ }
258
+ for (const event of ['add', 'unlink', 'addDir', 'unlinkDir']) server.watcher.on(event, refresh)
259
+ },
260
+ resolveId(id) {
261
+ if (id === 'mdeck/template-api') throw new Error('mdeck/template-api is now mdeck/layout. Run mdeck migrate to update local imports.')
262
+ if (id === 'mdeck/layout') return fileURLToPath(new URL('../layouts/layoutApi.jsx', import.meta.url))
263
+ if (id === 'mdeck/live') return fileURLToPath(new URL('../live/client.js', import.meta.url))
264
+ if (id === VIRTUAL_ID) return RESOLVED_ID
265
+ if (id === COMPONENTS_ID) return RESOLVED_COMPONENTS_ID
266
+ if (id === EXTENSIONS_ID) return RESOLVED_EXTENSIONS_ID
267
+ if (id === INK_ID) return RESOLVED_INK_ID
268
+ if (id === SVGS_ID) return RESOLVED_SVGS_ID
269
+ },
270
+ load(id) {
271
+ if (id === RESOLVED_ID) {
272
+ watchDeck(this)
273
+ const raw = readDeck()
274
+ const registry = loadRegistry(abs)
275
+ for (const warning of registry.warnings) this.warn(warning)
276
+ const diagnostics = validateDeck(parseSlides(raw), {
277
+ layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette'),
278
+ })
279
+ const errors = diagnostics.filter(d => d.severity === 'error')
280
+ if (errors.length && !editor) throw new Error(formatDiagnostics(errors, abs))
281
+ if (diagnostics.length) this.warn(formatDiagnostics(diagnostics, abs))
282
+ const source = maybeInlineAssets(sourceFor(raw), abs, { inlineImages, inlineMedia })
283
+ return `export default ${JSON.stringify(source)}`
284
+ }
285
+ // The deck's saved ink (<deck>.drawings.json), scaled to its design size.
286
+ // Builds bundle it, so the reader view and PDFs show it too.
287
+ if (id === RESOLVED_INK_ID) {
288
+ if (ink) assertDrawings(abs)
289
+ // Only an existing file: Vite treats a missing watch file as a missing
290
+ // import. The dev server's watcher notices the file once it appears.
291
+ if (existsSync(inkPath)) this.addWatchFile(inkPath)
292
+ let size = {}
293
+ try { const config = parseSlides(readDeck()).deckConfig ?? {}; size = { width: config.width ?? 1920, height: config.height ?? 1080 } } catch {}
294
+ let data = emptyInk(size)
295
+ if (ink && existsSync(inkPath)) {
296
+ try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
297
+ }
298
+ return `export default ${JSON.stringify(data)}\nexport const inkFileName = ${JSON.stringify(basename(inkPath))}`
299
+ }
300
+ if (id === RESOLVED_SVGS_ID) {
301
+ watchDeck(this)
302
+ const svgs = themedSvgs(abs, sourceFor(readDeck()))
303
+ for (const { file } of Object.values(svgs)) this.addWatchFile(file)
304
+ return `export default ${JSON.stringify(Object.fromEntries(Object.entries(svgs).map(([ref, { markup }]) => [ref, markup])))}`
305
+ }
306
+ if (id === RESOLVED_EXTENSIONS_ID) {
307
+ const registry = loadRegistry(abs)
308
+ for (const record of registry.records) this.addWatchFile(record.file)
309
+ if (!deckLook) return generateExtensionsModule(registry)
310
+ let config = {}
311
+ try { config = parseSlides(readDeck()).deckConfig ?? {} } catch {}
312
+ return generateExtensionsModule(deckLookOnly(registry, config))
313
+ }
314
+ if (id === RESOLVED_COMPONENTS_ID) {
315
+ const files = componentFiles(abs, componentFolders(abs, readDeck()))
316
+ const imports = files
317
+ .map((f, i) => `import C${i} from ${JSON.stringify(f.file)}`)
318
+ .join('\n')
319
+ const entries = files
320
+ .map((f, i) => ` ${JSON.stringify(f.tag)}: C${i},`)
321
+ .join('\n')
322
+ return `${imports}\nexport default {\n${entries}\n}\n`
323
+ }
324
+ },
325
+ // The deck page carries the deck's language before any script runs, for
326
+ // screen readers, hyphenation and search engines.
327
+ // A single-file build also carries the theme's fonts (fonts.js).
328
+ async transformIndexHtml(html, context) {
329
+ if (!/(^|[\/])index\.html$/.test(context.filename ?? context.path ?? '')) return html
330
+ let config = {}
331
+ try { config = parseSlides(readDeck()).deckConfig ?? {} } catch {}
332
+ const lang = config.lang
333
+ if (typeof lang === 'string' && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) html = html.replace(/<html lang="[^"]*"/, `<html lang="${lang}"`)
334
+ if (embedThemeFonts) {
335
+ const theme = loadRegistry(abs).themes[config.theme ?? 'neue']?.manifest
336
+ const { css, warnings } = await embedFonts(theme?.fonts ?? [])
337
+ for (const warning of warnings) console.warn(` ! ${warning}`)
338
+ if (css) html = html.replace('</head>', `<style data-mdeck-fonts>\n${css}\n</style>\n</head>`)
339
+ }
340
+ return html
341
+ },
342
+ handleHotUpdate({ file, server }) {
343
+ const changed = resolve(file)
344
+ if (isWatched(changed)) {
345
+ reload(server, ALL_IDS, changed)
346
+ return []
347
+ }
348
+ // mdeck's own writes (saved ink, a new slide id) must not reload the
349
+ // windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
350
+ const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
351
+ if (own) {
352
+ invalidate(server, changed === abs ? SOURCE_IDS : [RESOLVED_INK_ID])
353
+ return []
354
+ }
355
+ if (changed === inkPath) {
356
+ reload(server, [RESOLVED_INK_ID], changed)
357
+ return []
358
+ }
359
+ if (changed === abs) {
360
+ if (followComponentFolders(server)) {
361
+ reload(server, ALL_IDS, changed)
362
+ return []
363
+ }
364
+ // The deck sets the ink's design size and which SVGs are inlined.
365
+ invalidate(server, SOURCE_IDS)
366
+ if (editor) return []
367
+ server.ws.send({ type: 'full-reload' })
368
+ }
369
+ },
370
+ }
371
+ }
@@ -0,0 +1,151 @@
1
+ // Themes and palettes on the sample deck (src/editor/sampleDeck.js), in a
2
+ // real browser: the check behind `npm run test:themes` and `mdeck themes
3
+ // build --check`, and the preview pictures of the theme repository. Each
4
+ // look is checked light and dark on every slide, and fails when content does
5
+ // not fit, text is cut off at the slide's edge, text is hard to read on what
6
+ // is behind it, or the page reports an error. Needs Chrome (MDECK_CHROME).
7
+ import { execFileSync } from 'node:child_process'
8
+ import { cpSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { resolve } from 'node:path'
11
+ import { launchChrome } from './chrome.js'
12
+ import { frameworkRoot } from '../paths.js'
13
+ import { sampleDeck, SAMPLE_PICTURE } from '../editor/sampleDeck.js'
14
+
15
+ // Runs in the page before the deck: keeps what the page reports.
16
+ const RECORDER = `window.__report = [];
17
+ for (const level of ['warn', 'error']) { const original = console[level]; console[level] = (...args) => { window.__report.push(level + ': ' + args.map(String).join(' ')); original.apply(console, args) } }
18
+ addEventListener('error', event => window.__report.push('error: ' + event.message))
19
+ addEventListener('unhandledrejection', event => window.__report.push('error: ' + (event.reason?.message ?? event.reason)))`
20
+
21
+ // Runs in the page for the slide on screen: what is cut off or hard to read.
22
+ const MEASURE = `(() => {
23
+ const slide = document.querySelector('deck-stage').querySelectorAll(':scope > section')[window.__index]
24
+ const problems = []
25
+ const frame = slide.getBoundingClientRect()
26
+ const scale = frame.width / slide.offsetWidth
27
+ const body = slide.querySelector('.slide-body')
28
+ if (body && body.scrollHeight > body.clientHeight + 2) problems.push('the content is ' + Math.round(body.scrollHeight - body.clientHeight) + 'px taller than its place')
29
+ const parse = value => { const m = value.match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const [r, g, b, a = 1] = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); return { r, g, b, a } }
30
+ const lum = ({ r, g, b }) => { const c = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }; return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b) }
31
+ const ratio = (x, y) => { const [a, b] = [lum(x), lum(y)].sort((p, q) => q - p); return (a + 0.05) / (b + 0.05) }
32
+ // The colour behind an element, when it is a plain colour; null behind a
33
+ // picture or a gradient, where it cannot be told.
34
+ // The slide's own ::before and ::after fields (duet's chapter colour field),
35
+ // as rectangles in slide coordinates.
36
+ const fields = ['::before', '::after'].map(pseudo => getComputedStyle(slide, pseudo)).filter(drawn => drawn.content !== 'none' && drawn.position === 'absolute'
37
+ && (drawn.backgroundImage !== 'none' || (parse(drawn.backgroundColor)?.a ?? 0) > 0.05))
38
+ .map(drawn => { const left = parseFloat(drawn.left) || 0, top = parseFloat(drawn.top) || 0; return { left, top, right: left + (parseFloat(drawn.width) || slide.offsetWidth), bottom: top + (parseFloat(drawn.height) || slide.offsetHeight) } })
39
+ const behind = (element, box) => {
40
+ if (element.closest('.slide--full-bleed-image')) return null
41
+ const x = (box.left + box.width / 2 - frame.left) / scale, y = (box.top + box.height / 2 - frame.top) / scale
42
+ if (fields.some(field => x >= field.left && x <= field.right && y >= field.top && y <= field.bottom)) return null
43
+ for (let node = element; node && node !== document.documentElement; node = node.parentElement) {
44
+ const style = getComputedStyle(node)
45
+ // A field drawn by ::before or ::after (duet's chapter) is behind it too.
46
+ for (const pseudo of ['::before', '::after']) {
47
+ const drawn = getComputedStyle(node, pseudo)
48
+ const painted = drawn.backgroundImage !== 'none' || (parse(drawn.backgroundColor)?.a ?? 0) > 0.05
49
+ const area = (parseFloat(drawn.width) || 0) * (parseFloat(drawn.height) || 0)
50
+ if (node !== slide && drawn.content !== 'none' && painted && area >= 0.25 * node.offsetWidth * node.offsetHeight) return null
51
+ }
52
+ if (style.backgroundImage !== 'none' && !(node === slide && /radial-gradient/.test(style.backgroundImage) && !/linear/.test(style.backgroundImage))) return null
53
+ const colour = parse(style.backgroundColor)
54
+ if (colour && colour.a >= 0.95) return colour
55
+ if (colour && colour.a > 0.05) return null
56
+ if (node === slide) return colour
57
+ }
58
+ return null
59
+ }
60
+ const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT)
61
+ const seen = new Set()
62
+ for (let text = walker.nextNode(); text; text = walker.nextNode()) {
63
+ const element = text.parentElement
64
+ if (!text.textContent.trim() || seen.has(element) || element.closest('.slide-ink, .katex-mathml, [aria-hidden="true"], .slide--full-bleed-image .slide-bg')) continue
65
+ seen.add(element)
66
+ const style = getComputedStyle(element)
67
+ if (style.visibility === 'hidden' || Number(style.opacity) === 0 || element.closest('[data-step]:not([data-step-visible])')) continue
68
+ const range = document.createRange()
69
+ range.selectNodeContents(text)
70
+ const box = range.getBoundingClientRect()
71
+ if (!box.width || !box.height) continue
72
+ const words = text.textContent.trim().slice(0, 40)
73
+ if (box.left < frame.left - 2 * scale || box.right > frame.right + 2 * scale || box.top < frame.top - 2 * scale || box.bottom > frame.bottom + 2 * scale) problems.push('"' + words + '" is cut off at the edge')
74
+ const colour = parse(style.color), back = behind(element, box)
75
+ if (!colour || !back || colour.a < 0.5 || element.closest('.code-block, .katex')) continue
76
+ const large = parseFloat(style.fontSize) / scale >= 40 || (parseFloat(style.fontSize) / scale >= 30 && Number(style.fontWeight) >= 600)
77
+ const value = ratio(colour, back)
78
+ if (value < (large ? 2.6 : 3.4)) problems.push('"' + words + '" has a contrast of ' + value.toFixed(1) + ':1')
79
+ }
80
+ return problems
81
+ })()`
82
+
83
+ // Builds the sample deck with `extensions` ({ id: folder }) beside it and
84
+ // opens it in Chrome for `use({ open, ids })`. Installed user extensions are
85
+ // left out, so the result depends only on what is given.
86
+ export async function withSampleDeck({ extensions = {} } = {}, use) {
87
+ const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-sample-'))
88
+ try {
89
+ writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
90
+ writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
91
+ for (const [id, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', id), { recursive: true })
92
+ mkdirSync(resolve(temp, 'home'))
93
+ execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(temp, 'out/index.html')],
94
+ { cwd: temp, stdio: 'pipe', env: { ...process.env, MDECK_HOME: resolve(temp, 'home') } })
95
+ const browser = await launchChrome({ dir: resolve(temp, 'out') })
96
+ try {
97
+ // A page with the deck in a look, recording what it reports.
98
+ const open = async ({ theme, palette = '', appearance = '' }) => {
99
+ const page = await browser.open('about:blank')
100
+ await page.send('Page.enable')
101
+ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: RECORDER })
102
+ await page.send('Page.navigate', { url: `${browser.origin}/index.html?embedded=1&theme=${theme}&palette=${palette}&appearance=${appearance}#1` })
103
+ await page.waitFor("location.href.includes('index.html')")
104
+ await page.waitForDeck()
105
+ return page
106
+ }
107
+ return await use({ open })
108
+ } finally { await browser.close() }
109
+ } finally { rmSync(temp, { recursive: true, force: true }) }
110
+ }
111
+
112
+ const settle = page => page.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 60))))')
113
+
114
+ // Every slide of each look ({ theme, palette }), light and dark; returns the
115
+ // problems found, one line each.
116
+ export function checkLooks(looks, { extensions = {} } = {}) {
117
+ return withSampleDeck({ extensions }, async ({ open }) => {
118
+ const failures = []
119
+ for (const { theme, palette = '' } of looks) {
120
+ for (const appearance of ['light', 'dark']) {
121
+ const label = `${theme}${palette ? ` with ${palette}` : ''} (${appearance})`
122
+ const page = await open({ theme, palette, appearance })
123
+ await page.send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 900, deviceScaleFactor: 1, mobile: false })
124
+ const count = await page.evaluate("document.querySelector('deck-stage').length")
125
+ for (let index = 0; index < count; index++) {
126
+ await page.evaluate(`window.__index = ${index}; document.querySelector('deck-stage').goTo(${index})`)
127
+ // Two frames: the stage shows the slide, then fit.js measures it.
128
+ await settle(page)
129
+ for (const problem of await page.evaluate(MEASURE)) failures.push(`${label}, slide ${index + 1}: ${problem}`)
130
+ }
131
+ for (const line of await page.evaluate('window.__report')) failures.push(`${label}: ${line}`)
132
+ await page.close()
133
+ }
134
+ }
135
+ return failures
136
+ })
137
+ }
138
+
139
+ // Pictures of looks on the sample deck, as WebP: { file, theme, palette,
140
+ // slide } each, slide counting from 1.
141
+ export function renderPreviews(shots, { extensions = {}, width = 960, height = 540 } = {}) {
142
+ return withSampleDeck({ extensions }, async ({ open }) => {
143
+ for (const { file, theme, palette = '', slide = 1 } of shots) {
144
+ const page = await open({ theme, palette })
145
+ if (slide > 1) { await page.evaluate(`document.querySelector('deck-stage').goTo(${slide - 1})`); await settle(page) }
146
+ mkdirSync(resolve(file, '..'), { recursive: true })
147
+ writeFileSync(file, await page.screenshot({ width, height, format: 'webp' }))
148
+ await page.close()
149
+ }
150
+ })
151
+ }