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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- 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
|
+
}
|
package/src/core/urls.js
ADDED
|
@@ -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
|
+
}
|