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,144 @@
1
+ // Words the audience sees — in the reader, the deck controls, polls and the
2
+ // answer page — in the deck's language. `lang` picks a set (the part before a
3
+ // hyphen, so `de-CH` uses `de`); `labels` in the deck settings replaces single
4
+ // entries. Tools for the presenter (presenter view, launch page, editor) stay
5
+ // in English.
6
+
7
+ export const LABELS = {
8
+ en: {
9
+ 'reader.outline': 'Outline',
10
+ 'reader.untitled': 'Slides',
11
+ 'reader.slides': 'Slides',
12
+ 'reader.read': 'Read',
13
+ 'reader.appearance': 'Light or dark',
14
+ 'reader.light': 'Light',
15
+ 'reader.dark': 'Dark',
16
+ 'reader.downloadPdf': 'Download PDF',
17
+ 'reader.savePdf': 'Save as PDF…',
18
+ 'reader.savePdfHint': "Opens the browser's print dialog; choose Save as PDF",
19
+ 'reader.present': 'Present',
20
+ 'reader.previous': 'Previous',
21
+ 'reader.next': 'Next',
22
+ 'reader.copyLink': 'Copy link to this slide',
23
+ 'reader.linkCopied': 'Link copied',
24
+ 'reader.slide': 'Slide {n}',
25
+ 'deck.overview': 'Overview',
26
+ 'slide.chapter': 'Chapter',
27
+ 'deck.controls': 'Deck controls',
28
+ 'deck.previous': 'Previous slide',
29
+ 'deck.next': 'Next slide',
30
+ 'deck.reset': 'Reset',
31
+ 'deck.resetHint': 'Reset to first slide',
32
+ 'poll.scan': 'Scan to vote',
33
+ 'poll.answer': '1 answer',
34
+ 'poll.answers': '{n} answers',
35
+ 'poll.reset': 'Reset',
36
+ 'poll.showAnswer': 'Show the right answer',
37
+ 'poll.hideAnswer': 'Hide the right answer',
38
+ 'poll.live': 'Live',
39
+ 'poll.offline': 'Not connected to the server',
40
+ 'poll.unreachable': 'Phones cannot reach this computer. Start with {command}, or set {setting}.',
41
+ 'poll.tryHere': 'Try the answer page here',
42
+ 'poll.static': 'Answered live during the talk.',
43
+ 'poll.pick': 'Tap one answer.',
44
+ 'poll.thanks': 'Thanks! You chose “{choice}”. Tap another to change.',
45
+ 'respond.waiting': 'The next question will appear here.',
46
+ 'respond.send': 'Send',
47
+ 'respond.sent': 'Sent. Thank you!',
48
+ 'join.scan': 'Scan to join',
49
+ 'join.static': 'During the talk, phones joined here to answer.',
50
+ 'question.empty': 'Answers appear here.',
51
+ 'scale.average': 'Average {n}',
52
+ },
53
+ de: {
54
+ 'reader.outline': 'Gliederung',
55
+ 'reader.untitled': 'Folien',
56
+ 'reader.slides': 'Folien',
57
+ 'reader.read': 'Lesen',
58
+ 'reader.appearance': 'Hell oder dunkel',
59
+ 'reader.light': 'Hell',
60
+ 'reader.dark': 'Dunkel',
61
+ 'reader.downloadPdf': 'PDF herunterladen',
62
+ 'reader.savePdf': 'Als PDF sichern…',
63
+ 'reader.savePdfHint': 'Öffnet den Druckdialog des Browsers; dort „Als PDF sichern“ wählen',
64
+ 'reader.present': 'Präsentieren',
65
+ 'reader.previous': 'Zurück',
66
+ 'reader.next': 'Weiter',
67
+ 'reader.copyLink': 'Link zu dieser Folie kopieren',
68
+ 'reader.linkCopied': 'Link kopiert',
69
+ 'reader.slide': 'Folie {n}',
70
+ 'deck.overview': 'Übersicht',
71
+ 'slide.chapter': 'Kapitel',
72
+ 'deck.controls': 'Foliensteuerung',
73
+ 'deck.previous': 'Vorherige Folie',
74
+ 'deck.next': 'Nächste Folie',
75
+ 'deck.reset': 'Neustart',
76
+ 'deck.resetHint': 'Zurück zur ersten Folie',
77
+ 'poll.scan': 'Scannen und abstimmen',
78
+ 'poll.answer': '1 Antwort',
79
+ 'poll.answers': '{n} Antworten',
80
+ 'poll.reset': 'Zurücksetzen',
81
+ 'poll.showAnswer': 'Richtige Antwort zeigen',
82
+ 'poll.hideAnswer': 'Richtige Antwort ausblenden',
83
+ 'poll.live': 'Live',
84
+ 'poll.offline': 'Keine Verbindung zum Server',
85
+ 'poll.unreachable': 'Handys erreichen diesen Computer nicht. Mit {command} starten oder {setting} setzen.',
86
+ 'poll.tryHere': 'Antwortseite hier ausprobieren',
87
+ 'poll.static': 'Im Vortrag live beantwortet.',
88
+ 'poll.pick': 'Eine Antwort antippen.',
89
+ 'poll.thanks': 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.',
90
+ 'respond.waiting': 'Die nächste Frage erscheint hier.',
91
+ 'respond.send': 'Senden',
92
+ 'respond.sent': 'Gesendet. Danke!',
93
+ 'join.scan': 'Scannen und mitmachen',
94
+ 'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
95
+ 'question.empty': 'Hier erscheinen die Antworten.',
96
+ 'scale.average': 'Durchschnitt {n}',
97
+ },
98
+ }
99
+
100
+ export const LABEL_KEYS = Object.keys(LABELS.en)
101
+
102
+ // Callout titles by type (`::: tip`), in the deck's language; the deck's
103
+ // `callouts` setting replaces single titles.
104
+ export const CALLOUT_LABELS = {
105
+ en: { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution',
106
+ definition: 'Definition', theorem: 'Theorem', lemma: 'Lemma', corollary: 'Corollary', proof: 'Proof', example: 'Example', remark: 'Remark' },
107
+ de: { note: 'Hinweis', tip: 'Tipp', important: 'Wichtig', warning: 'Achtung', caution: 'Vorsicht',
108
+ definition: 'Definition', theorem: 'Satz', lemma: 'Lemma', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel', remark: 'Bemerkung' },
109
+ }
110
+
111
+ export const CALLOUT_TYPES = Object.keys(CALLOUT_LABELS.en)
112
+
113
+ let language = 'en'
114
+ let current = LABELS.en
115
+
116
+ export function labelSetFor(lang) {
117
+ const base = String(lang ?? '').toLowerCase().split('-')[0]
118
+ return Object.hasOwn(LABELS, base) ? base : 'en'
119
+ }
120
+
121
+ export function setDeckLanguage({ lang, labels } = {}) {
122
+ language = typeof lang === 'string' && lang.trim() ? lang.trim() : 'en'
123
+ const overrides = labels && typeof labels === 'object' && !Array.isArray(labels) ? labels : {}
124
+ current = { ...LABELS[labelSetFor(language)], ...Object.fromEntries(Object.entries(overrides).filter(([, value]) => typeof value === 'string')) }
125
+ }
126
+
127
+ /** Words for the <deck-stage> control bar. */
128
+ export function stageLabels() {
129
+ return { controls: t('deck.controls'), previous: t('deck.previous'), next: t('deck.next'), reset: t('deck.reset'), resetHint: t('deck.resetHint') }
130
+ }
131
+
132
+ /** The deck's `lang`, for the page's language attribute. */
133
+ export function deckLanguage() {
134
+ return language
135
+ }
136
+
137
+ // Fills {name} placeholders. With only strings it returns a string; when a
138
+ // value is anything else (such as a Preact element) it returns an array of
139
+ // parts, ready to render.
140
+ export function t(key, vars = {}) {
141
+ const text = current[key] ?? LABELS.en[key] ?? key
142
+ const parts = text.split(/\{(\w+)\}/).map((part, i) => i % 2 ? (Object.hasOwn(vars, part) ? vars[part] : `{${part}}`) : part)
143
+ return parts.every(part => typeof part === 'string' || typeof part === 'number') ? parts.join('') : parts.filter(part => part !== '')
144
+ }
@@ -0,0 +1,33 @@
1
+ // Slide titles and the deck outline, shared by the editor and the share view.
2
+ import { fenceState } from './source.js'
3
+
4
+ function firstProseLine(content = '') {
5
+ let fence = null
6
+ for (const raw of content.split(/\r?\n/)) {
7
+ const before = fence
8
+ fence = fenceState(raw, fence)
9
+ if (before || fence) continue
10
+ const line = raw.trim()
11
+ if (line && !/^(:::|\||<|!\[|---)/.test(line)) return line
12
+ }
13
+ return null
14
+ }
15
+
16
+ // `slideName(n)` names a slide without a heading or text for readers
17
+ // ("Slide 4"); without it, the layout's title is used, as the editor wants.
18
+ export function slideTitle(slide, index, manifest, { slideName = null } = {}) {
19
+ if (slide.meta.title) return slide.meta.title
20
+ const heading = slide.content?.match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]
21
+ if (heading) return heading.replace(/[*_`]/g, '')
22
+ const first = firstProseLine(slide.content)
23
+ if (first) return first.replace(/[*_`#>]/g, '').slice(0, 60)
24
+ if (slideName) return slideName(index + 1)
25
+ return manifest?.title ?? slide.meta.layout ?? `Slide ${index + 1}`
26
+ }
27
+
28
+ export function deckOutline(deck, layouts = {}, options = {}) {
29
+ return deck.slides.map((slide, index) => {
30
+ const layout = slide.meta.layout ?? 'generic'
31
+ return { index, id: slide.id, title: slideTitle(slide, index, layouts[layout], options), layout, chapter: layout === 'chapter', part: slide.meta.part ?? null, section: slide.meta.section ?? null, notes: slide.meta.notes ?? '' }
32
+ })
33
+ }
@@ -0,0 +1,152 @@
1
+ import yaml from 'js-yaml'
2
+ import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
3
+ export { sourceLines, fenceState } from './source.js'
4
+
5
+ export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session'.split(' '))
6
+ export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.split(' '))
7
+
8
+ // Names from before 2.0. They are still recognised, so that an old deck gets a
9
+ // message naming the new setting (see validateDeck) instead of being read as
10
+ // a slide, but nothing uses them.
11
+ export const RENAMED_DECK_KEYS = {
12
+ design: 'theme',
13
+ institution: 'show.organization',
14
+ authorDate: 'show.author',
15
+ pageNumbers: 'show.numbers',
16
+ sections: 'show.sections',
17
+ share: 'reader',
18
+ live: 'server and session',
19
+ }
20
+ const ANY_DECK_KEYS = new Set([...DECK_KEYS, ...Object.keys(RENAMED_DECK_KEYS)])
21
+
22
+ export function isPlainObject(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ }
25
+
26
+ function segmentsOf(source) {
27
+ const segments = []
28
+ let start = 0
29
+ let fence = null
30
+ let depth = 0
31
+ for (const line of sourceLines(source)) {
32
+ const before = fence
33
+ fence = fenceState(line.text, fence)
34
+ if (before || fence) continue
35
+ if (/^:::\s*[\w-]+/.test(line.text)) depth++
36
+ else if (/^:::\s*$/.test(line.text)) depth = Math.max(0, depth - 1)
37
+ if (!depth && /^---[ \t]*$/.test(line.text)) {
38
+ segments.push({ start, end: line.start, delimiterEnd: line.end })
39
+ start = line.end
40
+ }
41
+ }
42
+ segments.push({ start, end: source.length, delimiterEnd: source.length })
43
+ return segments.filter(s => source.slice(s.start, s.end).trim())
44
+ }
45
+
46
+ export function looksLikeMeta(text, keys, { frontmatter = false } = {}) {
47
+ const first = sourceLines(text).find(line => line.text.trim() && !/^\s*#/.test(line.text))
48
+ const key = first?.text.match(/^([\w-]+):/)
49
+ return !!key && keys.has(key[1]) && (frontmatter || !/^\s*#{1,6}\s/.test(text))
50
+ }
51
+
52
+ function extractBody(source, body, diagnostics) {
53
+ const raw = body ? source.slice(body.start, body.end) : ''
54
+ const offset = body?.start ?? 0
55
+ const scan = scanDirectives(raw)
56
+ diagnostics.push(...scan.diagnostics.map(d => diagnostic(source, d.code, d.message, offset + d.offset)))
57
+ const regions = Object.create(null)
58
+ const notes = []
59
+ const blocks = []
60
+ const bodyParts = []
61
+ let cursor = 0
62
+ for (const node of scan.nodes) {
63
+ if (node.end == null || !['slot', 'notes', 'meta'].includes(node.name)) continue
64
+ if (node.start > cursor) bodyParts.push({ start: offset + cursor, end: offset + node.start })
65
+ const range = { start: offset + node.bodyStart, end: offset + node.bodyEnd }
66
+ const block = { type: node.name, name: node.argument, source: { start: offset + node.start, end: offset + node.end }, bodySource: range }
67
+ blocks.push(block)
68
+ const value = source.slice(range.start, range.end)
69
+ if (node.name === 'notes') notes.push(value.trim())
70
+ if (node.name === 'meta') diagnostics.push(diagnostic(source, 'misplaced-meta', ':::meta must be the first block of a slide', offset + node.start))
71
+ if (node.name === 'slot') {
72
+ const name = node.argument
73
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) diagnostics.push(diagnostic(source, 'invalid-region', 'Region names must use lowercase letters, digits and hyphens', offset + node.start))
74
+ else if (regions[name]) diagnostics.push(diagnostic(source, 'duplicate-region', `Duplicate region "${name}"`, offset + node.start))
75
+ else regions[name] = { name, content: value.trim(), raw: value, source: range, explicit: true }
76
+ const checkChildren = children => {
77
+ for (const child of children) {
78
+ if (['slot', 'notes', 'meta'].includes(child.name)) diagnostics.push(diagnostic(source, 'nested-region', 'Slots, notes and metadata must be top-level slide blocks', offset + child.start))
79
+ checkChildren(child.children)
80
+ }
81
+ }
82
+ checkChildren(node.children)
83
+ }
84
+ cursor = node.end
85
+ }
86
+ if (cursor < raw.length) bodyParts.push({ start: offset + cursor, end: offset + raw.length })
87
+ const content = bodyParts.map(range => source.slice(range.start, range.end)).join('').trim()
88
+ // Unassigned content is always available as the default body region.
89
+ if (!regions.body) regions.body = { name: 'body', content, explicit: false, ranges: bodyParts }
90
+ else if (content) diagnostics.push(diagnostic(source, 'ambiguous-body', 'An explicit body slot cannot be combined with unassigned slide content', offset))
91
+ return { content, notes: notes.join('\n\n'), regions, blocks }
92
+ }
93
+
94
+ export function parseSlides(source) {
95
+ const segments = segmentsOf(source)
96
+ const diagnostics = []
97
+ const text = segment => source.slice(segment.start, segment.end).trim()
98
+ const readMeta = segment => {
99
+ try {
100
+ const value = yaml.load(source.slice(segment.start, segment.end))
101
+ if (!isPlainObject(value)) throw new Error('Metadata must be a YAML mapping')
102
+ return value
103
+ } catch (error) {
104
+ const offset = segment.start + (error.mark?.position ?? 0)
105
+ diagnostics.push(diagnostic(source, 'invalid-yaml', error.reason ?? error.message, offset))
106
+ return {}
107
+ }
108
+ }
109
+ let deckConfig = {}
110
+ let configSource = null
111
+ let i = 0
112
+ if (segments[0] && /^\s*---[ \t]*\r?\n/.test(source) && looksLikeMeta(text(segments[0]), ANY_DECK_KEYS, { frontmatter: true }) && !looksLikeMeta(text(segments[0]), SLIDE_KEYS)) {
113
+ deckConfig = readMeta(segments[0])
114
+ configSource = { start: segments[0].start, end: segments[0].end }
115
+ i++
116
+ }
117
+ const slides = []
118
+ while (i < segments.length) {
119
+ const segment = segments[i++]
120
+ let meta = {}
121
+ let body = segment
122
+ let metaSource = null
123
+ const explicit = scanDirectives(source.slice(segment.start, segment.end)).nodes[0]
124
+ if (explicit?.name === 'meta' && explicit.end != null && !source.slice(segment.start, segment.start + explicit.start).trim()) {
125
+ metaSource = { start: segment.start + explicit.bodyStart, end: segment.start + explicit.bodyEnd }
126
+ meta = readMeta(metaSource)
127
+ body = { start: segment.start + explicit.end, end: segment.end }
128
+ } else if (looksLikeMeta(text(segment), SLIDE_KEYS)) {
129
+ meta = readMeta(segment)
130
+ metaSource = { start: segment.start, end: segment.end }
131
+ body = segments[i]
132
+ if (body && !looksLikeMeta(text(body), SLIDE_KEYS) && !/^:::meta\b/.test(text(body))) i++
133
+ else body = null
134
+ }
135
+ const { content, notes, regions, blocks } = extractBody(source, body, diagnostics)
136
+ const authoredMeta = { ...meta }
137
+ // Speaker notes come only from body blocks. Keep the YAML snapshot for
138
+ // source editing and validation, separate from the resolved block notes.
139
+ delete meta.notes
140
+ if (notes) meta.notes = notes
141
+ slides.push({ id: meta.id ?? `slide-${slides.length + 1}`, meta, authoredMeta, content, regions, blocks,
142
+ source: { start: segment.start, end: body?.end ?? segment.end },
143
+ metaSource, bodySource: body ? { start: body.start, end: body.end } : null })
144
+ }
145
+ let currentSection = null
146
+ for (const slide of slides) {
147
+ if (slide.meta.layout === 'chapter' && slide.meta.part) currentSection = slide.meta.part
148
+ else if (Object.hasOwn(slide.meta, 'section')) currentSection = slide.meta.section
149
+ else if (currentSection) slide.meta.section = currentSection
150
+ }
151
+ return { source, configSource, deckConfig, slides, diagnostics }
152
+ }
@@ -0,0 +1,137 @@
1
+ import yaml from 'js-yaml'
2
+ // All ranges are half-open UTF-16 offsets into the original source string.
3
+ export function sourceLines(source) {
4
+ const lines = []
5
+ let offset = 0
6
+ for (const raw of source.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
7
+ lines.push({ text: raw.replace(/\r?\n$/, ''), start: offset, end: offset + raw.length })
8
+ offset += raw.length
9
+ }
10
+ return lines
11
+ }
12
+
13
+ export function fenceState(text, fence) {
14
+ const match = text.match(/^ {0,3}(`{3,}|~{3,})(.*)$/)
15
+ if (!match) return fence
16
+ if (!fence) return { char: match[1][0], length: match[1].length }
17
+ if (match[1][0] === fence.char && match[1].length >= fence.length && !match[2].trim()) return null
18
+ return fence
19
+ }
20
+
21
+ export function diagnostic(source, code, message, offset = 0, severity = 'error') {
22
+ const before = source.slice(0, offset)
23
+ return { severity, code, message, offset, line: before.split('\n').length, column: offset - before.lastIndexOf('\n') }
24
+ }
25
+
26
+ // Balanced directive scanner, shared by the document parser and Markdown lexer.
27
+ export function scanDirectives(source) {
28
+ const nodes = []
29
+ const stack = []
30
+ const diagnostics = []
31
+ let fence = null
32
+ for (const line of sourceLines(source)) {
33
+ const before = fence
34
+ fence = fenceState(line.text, fence)
35
+ if (before || fence) continue
36
+ const open = line.text.match(/^:::\s*([\w-]+)(.*)$/)
37
+ if (open) {
38
+ const node = { name: open[1], argument: open[2].trim(), start: line.start, bodyStart: line.end, children: [] }
39
+ if (stack.length) stack.at(-1).children.push(node)
40
+ else nodes.push(node)
41
+ stack.push(node)
42
+ } else if (/^:::[ \t]*$/.test(line.text)) {
43
+ const node = stack.pop()
44
+ if (!node) diagnostics.push(diagnostic(source, 'unexpected-close', 'Directive closing fence has no opening fence', line.start))
45
+ else Object.assign(node, { bodyEnd: line.start, end: line.end })
46
+ }
47
+ }
48
+ for (const node of stack) diagnostics.push(diagnostic(source, 'unclosed-directive', `Unclosed :::${node.name} block`, node.start))
49
+ return { nodes, diagnostics }
50
+ }
51
+
52
+ export function applySourceEdits(source, edits) {
53
+ const sorted = [...edits].sort((a, b) => a.start - b.start)
54
+ let end = 0
55
+ for (const edit of sorted) {
56
+ if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) || edit.start < end || edit.end < edit.start || edit.end > source.length) throw new Error('Invalid or overlapping source edits')
57
+ if (edit.expected != null && source.slice(edit.start, edit.end) !== edit.expected) throw new Error('Source changed since this edit was prepared')
58
+ end = edit.end
59
+ }
60
+ for (const edit of sorted.reverse()) source = source.slice(0, edit.start) + edit.text + source.slice(edit.end)
61
+ return source
62
+ }
63
+
64
+ export function serializeDeck(deck) { return deck.source }
65
+
66
+ export function replaceRegion(deck, slideId, name, markdown) {
67
+ const slide = deck.slides.find(slide => slide.id === slideId)
68
+ const region = slide?.regions[name]
69
+ if (!region || !region.explicit) throw new Error(`No explicit region "${name}" on slide "${slideId}"`)
70
+ const newline = deck.source.includes('\r\n') ? '\r\n' : '\n'
71
+ const text = markdown.replace(/\r?\n/g, newline).replace(/[\r\n]*$/, '') + newline
72
+ return applySourceEdits(deck.source, [{ ...region.source, text, expected: region.raw }])
73
+ }
74
+
75
+ // ─── Byte-preserving editing primitives ────────────────────────────────────
76
+
77
+ export function detectNewline(source) { return source.includes('\r\n') ? '\r\n' : '\n' }
78
+
79
+ // Normalizes line endings and guarantees exactly one trailing newline; '' stays ''.
80
+ export function normalizeBlock(text, newline = '\n') {
81
+ if (!text || !text.trim()) return ''
82
+ return text.replace(/\r?\n/g, newline).replace(/[\r\n]*$/, '') + newline
83
+ }
84
+
85
+ const KEY_LINE_RE = /^([\w-]+):(\s|$)/
86
+ const TRAIL_LINE_RE = /^\s*(#.*)?$/
87
+
88
+ export function firstYamlKey(text) {
89
+ for (const line of sourceLines(text)) {
90
+ if (TRAIL_LINE_RE.test(line.text)) continue
91
+ return line.text.match(KEY_LINE_RE)?.[1] ?? null
92
+ }
93
+ return null
94
+ }
95
+
96
+ // Patches top-level keys of a YAML mapping given as full lines. Only the
97
+ // blocks of the patched keys are rewritten; every other line stays
98
+ // byte-identical, including comments. `undefined` deletes a key. Returns ''
99
+ // when no keys remain. `leadKeys` moves a recognized key to the top so
100
+ // legacy slide metadata and deck frontmatter keep being detected.
101
+ export function patchYamlMapping(text, patch, { newline = detectNewline(text), leadKeys = null } = {}) {
102
+ const existing = text.trim() ? yaml.load(text) : null
103
+ if (existing != null && (typeof existing !== 'object' || Array.isArray(existing))) throw new Error('Metadata must be a YAML mapping')
104
+ const blocks = [{ key: null, lines: [] }]
105
+ for (const line of sourceLines(text)) {
106
+ const key = line.text.match(KEY_LINE_RE)?.[1]
107
+ if (key) blocks.push({ key, lines: [] })
108
+ blocks.at(-1).lines.push(line.text)
109
+ }
110
+ const split = lines => {
111
+ let end = lines.length
112
+ while (end > 0 && TRAIL_LINE_RE.test(lines[end - 1])) end--
113
+ return [lines.slice(0, end), lines.slice(end)]
114
+ }
115
+ const dump = (key, value) => yaml.dump({ [key]: value }, { flowLevel: 2, lineWidth: -1, noRefs: true, quotingType: '"' }).replace(/\n$/, '').split('\n')
116
+ for (const [key, value] of Object.entries(patch)) {
117
+ if (!KEY_LINE_RE.test(`${key}:`)) throw new Error(`Invalid metadata key "${key}"`)
118
+ const index = blocks.findIndex(block => block.key === key)
119
+ if (index < 0) {
120
+ if (value !== undefined) blocks.push({ key, lines: dump(key, value) })
121
+ continue
122
+ }
123
+ const [, trailing] = split(blocks[index].lines)
124
+ if (value === undefined) blocks[index] = { key: null, lines: trailing }
125
+ else blocks[index].lines = [...dump(key, value), ...trailing]
126
+ }
127
+ const keyed = blocks.filter(block => block.key)
128
+ if (!keyed.length) return ''
129
+ if (leadKeys && !leadKeys.has(keyed[0].key)) {
130
+ const lead = keyed.find(block => leadKeys.has(block.key))
131
+ if (lead) {
132
+ blocks.splice(blocks.indexOf(lead), 1)
133
+ blocks.splice(1, 0, lead)
134
+ }
135
+ }
136
+ return blocks.flatMap(block => block.lines).join(newline) + newline
137
+ }
@@ -0,0 +1,34 @@
1
+ const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience'])
2
+ const OLD_VIEWS = { d: 'deck', s: 'reader', p: 'presenter', a: 'audience', share: 'reader' }
3
+
4
+ // Old URLs must be corrected explicitly: guessing a view can expose notes or
5
+ // put the wrong window on the projector. Never include credentials in errors.
6
+ export function validatePageUrl(url) {
7
+ const replacements = []
8
+ const corrected = new URL(url)
9
+ if (url.searchParams.has('v')) {
10
+ corrected.searchParams.set('view', OLD_VIEWS[url.searchParams.get('v')] ?? url.searchParams.get('v'))
11
+ corrected.searchParams.delete('v')
12
+ replacements.push('v → view')
13
+ }
14
+ if (corrected.searchParams.get('view') === 'share') { corrected.searchParams.set('view', 'reader'); replacements.push('view=share → view=reader') }
15
+ for (const [old, name] of [['design', 'theme'], ['livekey', 'serverkey']]) {
16
+ if (url.searchParams.has(old)) {
17
+ corrected.searchParams.set(name, url.searchParams.get(old)); corrected.searchParams.delete(old)
18
+ replacements.push(`${old} → ${name}`)
19
+ }
20
+ }
21
+ for (const secret of ['serverkey', 'livekey', 'pair']) corrected.searchParams.delete(secret)
22
+ if (replacements.length) return { message: `This URL uses settings from before API 2.0: ${replacements.join(', ')}. Open the updated address below. If you supplied a server key, add it again as ?serverkey=… on your own computer.`, corrected: corrected.href }
23
+ const view = url.searchParams.get('view')
24
+ if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter or audience.` }
25
+ return null
26
+ }
27
+
28
+ export function readerLink(address, slideId) {
29
+ const url = new URL(address)
30
+ for (const name of ['v', 'serverkey', 'livekey', 'pair', 'session', 'embedded', 'draw']) url.searchParams.delete(name)
31
+ url.searchParams.set('view', 'reader')
32
+ url.hash = encodeURIComponent(slideId)
33
+ return url.href
34
+ }
@@ -0,0 +1,139 @@
1
+ import { diagnostic, scanDirectives } from './source.js'
2
+ import { isPlainObject, RENAMED_DECK_KEYS } from './parseSlides.js'
3
+ import { palettesFor } from '../extensions/tokens.js'
4
+ import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
5
+ import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
6
+
7
+ // `layouts`, `themes` and `palettes` are manifest maps from the extension
8
+ // registry (see src/extensions/discover.js). Checks for a kind are skipped when
9
+ // its map is not supplied.
10
+ export function validateDeck(deck, { layouts = null, themes = null, palettes = null } = {}) {
11
+ const diagnostics = [...deck.diagnostics]
12
+ const add = (code, message, offset, severity) => diagnostics.push(diagnostic(deck.source, code, message, offset, severity))
13
+ const config = deck.deckConfig
14
+ for (const key of ['width', 'height']) {
15
+ if (config[key] != null && (typeof config[key] !== 'number' || !Number.isFinite(config[key]) || config[key] <= 0)) add('invalid-config', `${key} must be a positive number`, deck.configSource?.start)
16
+ }
17
+ for (const [old, now] of Object.entries(RENAMED_DECK_KEYS)) {
18
+ if (config[old] != null) add('renamed-setting', `${old} is now ${now}${old === 'live' ? ': the address is server, and id and code are session.id and session.code' : ''}`, deck.configSource?.start)
19
+ }
20
+ // Colours come only from the palette now.
21
+ for (const key of ['accent', 'accent2']) {
22
+ if (config[key] != null) add('removed-setting', `${key} is no longer a setting: colours come from the palette; choose one with palette (see mdeck list palettes)`, deck.configSource?.start)
23
+ }
24
+ if (config.appearance != null && !['light', 'dark'].includes(config.appearance)) add('invalid-config', 'appearance must be light or dark', deck.configSource?.start)
25
+ for (const key of ['meta', 'params', 'callouts', 'labels', 'show', 'reader', 'session']) {
26
+ if (config[key] != null && !isPlainObject(config[key])) add('invalid-config', `${key} must be a mapping`, deck.configSource?.start)
27
+ }
28
+ if (isPlainObject(config.reader)) {
29
+ for (const key of Object.keys(config.reader)) if (!['themes', 'notes'].includes(key)) add('invalid-config', `reader.${key} is not a setting; available: themes, notes`, deck.configSource?.start, 'warning')
30
+ for (const key of ['themes', 'notes']) if (config.reader[key] != null && typeof config.reader[key] !== 'boolean') add('invalid-config', `reader.${key} must be true or false`, deck.configSource?.start)
31
+ }
32
+ if (config.components != null && (!Array.isArray(config.components) || config.components.some(path => typeof path !== 'string' || !path.trim()))) add('invalid-config', 'components must be a list of folder paths', deck.configSource?.start)
33
+ if (config.lang != null && (typeof config.lang !== 'string' || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(config.lang))) add('invalid-config', 'lang must be a language code such as en, de or de-CH', deck.configSource?.start)
34
+ if (isPlainObject(config.labels)) {
35
+ for (const [key, value] of Object.entries(config.labels)) {
36
+ if (!LABEL_KEYS.includes(key)) add('invalid-config', `labels.${key} is not a label; see the authoring reference for the list`, deck.configSource?.start, 'warning')
37
+ else if (typeof value !== 'string') add('invalid-config', `labels.${key} must be text`, deck.configSource?.start)
38
+ }
39
+ }
40
+ if (config.server != null && (typeof config.server !== 'string' || !/^https?:\/\//.test(config.server))) add('invalid-config', 'server must be an http:// or https:// address', deck.configSource?.start)
41
+ if (isPlainObject(config.session)) {
42
+ for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
43
+ if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
44
+ }
45
+ const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
46
+ if (isPlainObject(config.show)) {
47
+ for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key)) add('invalid-config', `show.${key} is not a setting; available: ${Object.keys(enums).join(', ')}`, deck.configSource?.start, 'warning')
48
+ for (const [key, values] of Object.entries(enums)) {
49
+ if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
50
+ }
51
+ }
52
+ if (themes && config.theme != null && !Object.hasOwn(themes, config.theme)) add('unknown-theme', `Unknown theme "${config.theme}"; available: ${Object.keys(themes).join(', ')}`, deck.configSource?.start)
53
+ if (palettes && themes && config.palette != null) {
54
+ const theme = themes[config.theme ?? 'neue']
55
+ const offered = theme ? palettesFor(theme, palettes).map(palette => palette.id) : Object.keys(palettes)
56
+ if (!Object.hasOwn(palettes, config.palette)) add('unknown-palette', `Unknown palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
57
+ else if (theme && !offered.includes(config.palette)) add('unknown-palette', `The theme "${theme.id}" does not offer the palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
58
+ }
59
+ if (themes && config.params != null && isPlainObject(config.params) && Object.hasOwn(themes, config.theme ?? 'neue')) {
60
+ const theme = themes[config.theme ?? 'neue']
61
+ for (const name of Object.keys(config.params)) if (!Object.hasOwn(theme.params ?? {}, name)) add('unknown-param', `Theme "${theme.id}" has no parameter "${name}"; available: ${Object.keys(theme.params ?? {}).join(', ') || 'none'}`, deck.configSource?.start, 'warning')
62
+ }
63
+ // Callout types the deck names under `callouts:` are its own, on purpose.
64
+ const calloutTypes = [...CALLOUT_TYPES, ...(isPlainObject(config.callouts) ? Object.keys(config.callouts).map(key => key.toLowerCase()) : [])]
65
+ const ids = new Set()
66
+ for (const slide of deck.slides) {
67
+ const offset = slide.metaSource?.start ?? slide.source.start
68
+ if (typeof slide.id !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(slide.id)) add('invalid-id', 'Slide IDs must start with a letter and contain letters, digits, hyphens or underscores', offset)
69
+ if (ids.has(slide.id)) add('duplicate-id', `Duplicate slide ID "${slide.id}"`, offset)
70
+ ids.add(slide.id)
71
+ for (const key of ['note', 'notes']) {
72
+ if (Object.hasOwn(slide.authoredMeta, key)) add('invalid-metadata', `${key} is not a slide setting; write speaker notes in a :::notes block`, offset)
73
+ }
74
+ for (const key of ['layout', 'title', 'image', 'alt', 'section', 'part', 'eyebrow', 'attribution']) {
75
+ if (slide.meta[key] != null && typeof slide.meta[key] !== 'string') add('invalid-metadata', `${key} must be a string`, offset)
76
+ }
77
+ if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
78
+ if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
79
+ for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
80
+ if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
81
+ const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
82
+ add('unknown-callout', `Unknown block "${node.name}"${near ? `; did you mean "${near}"?` : ''} It shows as a plain callout. Known: ${CALLOUT_TYPES.join(', ')}; name your own under callouts: in the settings`, slide.source.start + node.start, 'warning')
83
+ }
84
+ if (!layouts) continue
85
+ const layout = slide.meta.layout ?? 'generic'
86
+ if (!Object.hasOwn(layouts, layout)) {
87
+ add('unknown-layout', `Unknown layout "${layout}"; using the generic renderer`, offset, 'warning')
88
+ continue
89
+ }
90
+ const manifest = layouts[layout]
91
+ for (const [name, region] of Object.entries(slide.regions)) {
92
+ if (region.explicit && !Object.hasOwn(manifest.regions, name)) add('unknown-region', `Layout "${layout}" has no region "${name}"`, region.source.start)
93
+ }
94
+ for (const [name, schema] of Object.entries(manifest.regions)) {
95
+ if (schema.required && !slide.regions[name]?.content.trim()) add('missing-region', `Layout "${layout}" requires region "${name}"`, offset)
96
+ }
97
+ if (slide.meta.props != null && !isPlainObject(slide.meta.props)) continue
98
+ const props = resolveLayoutProps(manifest, slide.meta)
99
+ for (const [name, schema] of Object.entries(manifest.properties ?? {})) {
100
+ if (props[name] === undefined) {
101
+ if (schema.required) add('missing-property', `Layout "${layout}" requires props.${name}`, offset)
102
+ } else for (const message of propertyErrors(props[name], schema, `props.${name}`)) add('invalid-property', message, offset)
103
+ }
104
+ for (const name of Object.keys(slide.meta.props ?? {})) {
105
+ if (!Object.hasOwn(manifest.properties ?? {}, name)) add('unknown-property', `Layout "${layout}" has no property "${name}"`, offset)
106
+ }
107
+ }
108
+ return diagnostics
109
+ }
110
+
111
+ // The `:::` blocks mdeck reads itself; any other name is a callout type.
112
+ const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns']
113
+
114
+ function directivesIn(source) {
115
+ const all = []
116
+ const visit = nodes => { for (const node of nodes) { all.push(node); visit(node.children) } }
117
+ visit(scanDirectives(source).nodes)
118
+ return all
119
+ }
120
+
121
+ // The known name a misspelling is most likely meant to be: at most two
122
+ // letters added, left out or changed.
123
+ function closest(name, names) {
124
+ const distance = (a, b) => {
125
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i)
126
+ for (let i = 1; i <= a.length; i++) {
127
+ const next = [i]
128
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
129
+ row = next
130
+ }
131
+ return row[b.length]
132
+ }
133
+ const [best] = names.map(candidate => [candidate, distance(name, candidate)]).sort((x, y) => x[1] - y[1])
134
+ return best && best[1] <= 2 ? best[0] : null
135
+ }
136
+
137
+ export function formatDiagnostics(diagnostics, filename = 'slides.md') {
138
+ return diagnostics.map(d => `${filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
139
+ }