mdeck 3.10.1 → 3.12.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 (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
@@ -0,0 +1,249 @@
1
+ // Decks made of other decks. A slide that is nothing but
2
+ //
3
+ // :::include ./lectures/02-graphs.md
4
+ // :::include ./lectures/02-graphs.md slides=3-7
5
+ //
6
+ // stands for the slides of that file (core/parseSlides.js includeOf), which
7
+ // stays a deck of its own: `mdeck run 02-graphs.md` presents it alone, and its
8
+ // settings (theme, title, time …) count only then; included, only its slides
9
+ // are taken. `slides=` keeps some of them, numbered as that file shows them.
10
+ //
11
+ // Everything that reads a deck to show it reads it through expandDeck, which
12
+ // puts the slides in place of the include: the dev server, builds, `mdeck
13
+ // check`, the launch page. What writes into a deck (the editor, saved times,
14
+ // slide ids for drawings) writes into the file a slide is written in; the
15
+ // editor shows an include as a card naming the file.
16
+ //
17
+ // So that a slide keeps its drawings however many slides come before it, the
18
+ // expanded deck gives every slide an id: a slide of the deck itself keeps the
19
+ // one it has in its file (slide-3 stays slide-3), an included slide gets its
20
+ // file's name in front (graphs-slide-3, graphs-dijkstra). `origins` says, for
21
+ // each such id, the file the slide is written in and its id there.
22
+ //
23
+ // What an included deck's slides need from its settings comes along; how
24
+ // it looks and is presented stays the including deck's. `hotspots` and
25
+ // `time` become each included slide's own, unless it has one: a slide
26
+ // without a time gets its share of the included deck's `time`. The
27
+ // references (`bibliography`, `nocite`, and `csl` when the deck has none),
28
+ // the `components` folders (and a components/ folder beside the file) and
29
+ // callout names of its own (`callouts`, where the deck does not name them)
30
+ // are added to the including deck's settings. Theme, palette, size,
31
+ // language, labels, `show`, `meta` and the server stay the including deck's.
32
+ //
33
+ // Paths of pictures and media in an included file are written from its own
34
+ // folder; they are rewritten to work from the deck's. A deck in several
35
+ // languages (talk.de.md) takes an included file's twin in its language
36
+ // (02-graphs.de.md) where there is one.
37
+ import { readFileSync, existsSync } from 'node:fs'
38
+ import { resolve, dirname, relative, basename, sep } from 'node:path'
39
+ import { parseSlides } from '../core/parseSlides.js'
40
+ import { setSlideMeta, setDeckConfig, shareSlides } from '../core/editDeck.js'
41
+ import { diagnostic } from '../core/source.js'
42
+ import { languageOfFile } from '../core/languages.js'
43
+ import { planFor, formatTime } from '../core/timing.js'
44
+ import { CALLOUT_TYPES } from '../core/labels.js'
45
+ import { isPlainObject, DECK_KEYS, SLIDE_KEYS } from '../core/parseSlides.js'
46
+ import { parseSlideNumbers } from '../cli/args.js'
47
+ import { rewriteAssetRefs } from './slidesPlugin.js'
48
+ import { deckTexts } from './bibliography.js'
49
+ import { scanCitations, citedKeys } from '../core/citations.js'
50
+ import { replaceTagsIn } from '../core/tags.js'
51
+
52
+ const read = file => readFileSync(file, 'utf8')
53
+
54
+ /** Whether the deck text has an include at all. */
55
+ export const hasIncludes = source => parseSlides(source).slides.some(slide => slide.include)
56
+
57
+ /**
58
+ * The deck in `file` (or `source`, its text) with its includes in place:
59
+ * { source, files, origins, diagnostics }. `files` are the included files
60
+ * (to watch), `origins` maps each slide id to { file, id } (null when the
61
+ * deck includes nothing), `diagnostics` are problems with the includes:
62
+ * { file, line, severity, code, message }. Without includes, `source` is
63
+ * the deck's text unchanged.
64
+ */
65
+ export function expandDeck(file, { source = null, lang = null } = {}) {
66
+ const abs = resolve(file)
67
+ const text = source ?? read(abs)
68
+ if (!hasIncludes(text)) return { source: text, files: [], origins: null, diagnostics: [] }
69
+ const deck = parseSlides(text)
70
+ const state = { root: dirname(abs), files: new Set(), diagnostics: [], prefixes: new Set(), origins: {}, settings: [] }
71
+ const language = lang ?? languageOfFile(basename(abs))?.lang ?? (typeof deck.deckConfig.lang === 'string' ? deck.deckConfig.lang : null)
72
+ let expanded = expandFile(abs, text, { state, language, stack: [abs], prefix: null, top: true })
73
+ const patch = addedSettings(deck.deckConfig, state)
74
+ if (patch) expanded = setDeckConfig(parseSlides(expanded), patch)
75
+ return { source: expanded, files: [...state.files], origins: state.origins, diagnostics: state.diagnostics }
76
+ }
77
+
78
+ /** The deck's text with its includes in place, or its text as it is when there are none. */
79
+ export const expandedSource = (file, options) => expandDeck(file, options).source
80
+
81
+ // One file's slides, ids given, its own includes in place, paths written from
82
+ // the deck's folder; the top file keeps its settings in front.
83
+ function expandFile(abs, source, { state, language, stack, prefix, top }) {
84
+ let deck = parseSlides(source)
85
+ // Where each include is written, for messages, before ids change the lines.
86
+ const lines = deck.slides.filter(slide => slide.include).map(slide => {
87
+ const at = slide.source.start + source.slice(slide.source.start, slide.source.end).indexOf(':::')
88
+ return diagnostic(source, '', '', at).line
89
+ })
90
+ // Each slide gets the id it has in its own file, written out, with the
91
+ // file's name in front when it is included.
92
+ for (const slide of [...deck.slides]) {
93
+ if (slide.include) continue
94
+ const own = slide.id
95
+ const id = prefix ? `${prefix}-${own}` : own
96
+ state.origins[id] = { file: abs, id: own }
97
+ if (slide.meta.id === id) continue
98
+ source = setSlideMeta(deck, own, { id })
99
+ deck = parseSlides(source)
100
+ }
101
+ const fromHere = dirname(abs)
102
+ const moved = fromHere !== state.root
103
+ const own = text => moved ? rewriteAssetRefs(text, ref => relative(state.root, resolve(fromHere, ref)).split(sep).join('/')) : text
104
+ let included = 0
105
+ const chunks = deck.slides.map(slide => {
106
+ const text = deck.source.slice(slide.source.start, slide.source.end)
107
+ if (!slide.include) return own(text)
108
+ return includeText(abs, slide, lines[included++], { state, language, stack, prefix }) ?? own(text)
109
+ }).filter(chunk => chunk.trim())
110
+ const body = chunks.map(chunk => chunk.replace(/^\s*\n/, '').replace(/\s*$/, '\n')).join('\n---\n\n')
111
+ return top ? deck.source.slice(0, deck.slides[0]?.source.start ?? 0) + body : body
112
+ }
113
+
114
+ // The slides an include stands for, or null (with a diagnostic) when they cannot be had.
115
+ function includeText(abs, slide, line, { state, language, stack, prefix }) {
116
+ const { file, slides } = slide.include
117
+ const problem = (code, message) => {
118
+ state.diagnostics.push({ file: abs, line, severity: 'error', code, message })
119
+ return null
120
+ }
121
+ const target = chooseFile(dirname(abs), file, language)
122
+ if (!existsSync(target)) return problem('missing-include', `Included file not found: ${file}`)
123
+ if (stack.includes(target)) return problem('include-loop', `${file} includes this file again (${[...stack, target].map(f => basename(f)).join(' → ')})`)
124
+ let numbers = null
125
+ if (slides != null) {
126
+ try { numbers = parseSlideNumbers(slides) } catch (error) { return problem('invalid-include', error.message.replace('--slide', 'slides=')) }
127
+ }
128
+ state.files.add(target)
129
+ const childPrefix = uniquePrefix(state, prefix, target)
130
+ const source = read(target)
131
+ const settings = parseSlides(source).deckConfig ?? {}
132
+ state.settings.push({ file: target, settings })
133
+ // `time:` or `hotspots:` alone at the top is the file's, for all its
134
+ // slides; meant for its first slide, it belongs in a :::meta block.
135
+ const keys = Object.keys(settings)
136
+ if (keys.length && keys.every(key => DECK_KEYS.has(key) && SLIDE_KEYS.has(key)) && !state.diagnostics.some(d => d.file === target && d.code === 'include-settings')) {
137
+ state.diagnostics.push({ file: target, line: 1, severity: 'warning', code: 'include-settings', message: `The block at the top (${keys.join(', ')}) is read as this file's settings, for all its slides; for its first slide alone, write it in a :::meta block` })
138
+ }
139
+ let text = ownReferences(inherit(expandFile(target, source, { state, language, stack: [...stack, target], prefix: childPrefix, top: false }), settings), settings)
140
+ if (numbers) {
141
+ try { text = shareSlides(text, { slides: numbers }).source } catch (error) { return problem('invalid-include', `${file}: ${error.message}`) }
142
+ }
143
+ return text
144
+ }
145
+
146
+ // The included deck's `hotspots` and `time`, as each of its slides' own
147
+ // where it has none; `time` is shared out as the deck itself would (its
148
+ // slides' own times first, the rest in equal parts).
149
+ function inherit(text, { hotspots, time }) {
150
+ if (hotspots == null && time == null) return text
151
+ let deck = parseSlides(text)
152
+ const plan = time == null ? null : planFor(deck.slides, { time })
153
+ for (const [i, slide] of [...deck.slides].entries()) {
154
+ if (slide.include) continue
155
+ const patch = {}
156
+ if (hotspots != null && slide.meta.hotspots == null) patch.hotspots = hotspots
157
+ if (plan?.budget && slide.meta.time == null && plan.perSlide[i] >= 1) patch.time = formatTime(plan.perSlide[i])
158
+ if (!Object.keys(patch).length) continue
159
+ text = setSlideMeta(deck, slide.id, patch)
160
+ deck = parseSlides(text)
161
+ }
162
+ return text
163
+ }
164
+
165
+ // An included deck's <bibliography /> lists the works that deck cites (and
166
+ // its `nocite`), as it does when shown alone, not every work the including
167
+ // deck cites: it gets them as `keys`. With `nocite: '*'` it lists them all.
168
+ function ownReferences(text, { nocite }) {
169
+ const named = [nocite ?? []].flat().map(key => String(key).replace(/^@/, ''))
170
+ if (!/<bibliography\b/.test(text) || named.includes('*')) return text
171
+ const keys = [...new Set([...citedKeys(deckTexts(text).flatMap(part => scanCitations(part))), ...named])].join(' ')
172
+ return replaceTagsIn(text, tag => {
173
+ if (tag.closing || tag.name !== 'bibliography' || 'keys' in tag.attrs) return
174
+ return tag.raw.replace(/(\s*)(\/?>)$/, ` keys="${keys}"$1$2`)
175
+ })
176
+ }
177
+
178
+ // What the included decks add to the including deck's settings, or null.
179
+ function addedSettings(config, { root, settings: included }) {
180
+ const fromRoot = (file, path) => {
181
+ if (typeof path !== 'string' || !path.trim()) return null
182
+ const trimmed = path.trim()
183
+ if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('/')) return trimmed
184
+ return relative(root, resolve(dirname(file), trimmed)).split(sep).join('/') || '.'
185
+ }
186
+ const list = value => [value ?? []].flat()
187
+ const patch = {}
188
+ const bibliography = list(config.bibliography)
189
+ const nocite = list(config.nocite)
190
+ const components = list(config.components)
191
+ const callouts = isPlainObject(config.callouts) ? { ...config.callouts } : {}
192
+ const known = new Set([...CALLOUT_TYPES, ...Object.keys(callouts)].map(name => name.toLowerCase()))
193
+ let csl = config.csl ?? null
194
+ for (const { file, settings } of included) {
195
+ for (const path of list(settings.bibliography)) { const own = fromRoot(file, path); if (own && !bibliography.includes(own)) bibliography.push(own) }
196
+ for (const key of list(settings.nocite)) if (!nocite.includes(key)) nocite.push(key)
197
+ if (csl == null && typeof settings.csl === 'string') csl = patch.csl = /\.csl$/i.test(settings.csl) ? fromRoot(file, settings.csl) : settings.csl
198
+ const folders = [...list(settings.components), ...(existsSync(resolve(dirname(file), 'components')) ? ['components'] : [])]
199
+ for (const path of folders) { const own = fromRoot(file, path); if (own && !components.includes(own) && resolve(root, own) !== resolve(root, 'components')) components.push(own) }
200
+ if (isPlainObject(settings.callouts)) {
201
+ for (const [name, title] of Object.entries(settings.callouts)) {
202
+ if (known.has(name.toLowerCase())) continue
203
+ callouts[name] = title
204
+ known.add(name.toLowerCase())
205
+ }
206
+ }
207
+ }
208
+ if (bibliography.length > list(config.bibliography).length) patch.bibliography = bibliography
209
+ if (nocite.length > list(config.nocite).length) patch.nocite = nocite
210
+ if (components.length > list(config.components).length) patch.components = components
211
+ if (Object.keys(callouts).length > Object.keys(isPlainObject(config.callouts) ? config.callouts : {}).length) patch.callouts = callouts
212
+ return Object.keys(patch).length ? patch : null
213
+ }
214
+
215
+ // The file in the deck's language where there is one: ./02-graphs.md in a
216
+ // German deck is ./02-graphs.de.md beside it.
217
+ function chooseFile(dir, ref, language) {
218
+ const abs = resolve(dir, ref)
219
+ if (language && /\.md$/i.test(abs) && !languageOfFile(basename(abs))) {
220
+ const twin = abs.replace(/\.md$/i, `.${language}.md`)
221
+ if (existsSync(twin)) return twin
222
+ }
223
+ return abs
224
+ }
225
+
226
+ // An id prefix from the file's name, unique in the deck: 02-graphs.md → graphs.
227
+ function uniquePrefix(state, parent, file) {
228
+ const name = languageOfFile(basename(file))?.name ?? basename(file).replace(/\.md$/i, '')
229
+ const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+|-+$/g, '') || 'part'
230
+ const full = parent ? `${parent}-${base}` : base
231
+ let prefix = full, n = 2
232
+ while (state.prefixes.has(prefix)) prefix = `${full}-${n++}`
233
+ state.prefixes.add(prefix)
234
+ return prefix
235
+ }
236
+
237
+ /**
238
+ * An included file's problems without those in its settings, which count
239
+ * only when it is shown alone (its theme need not be installed for the
240
+ * deck that includes it).
241
+ */
242
+ export function withoutSettings(source, diagnostics) {
243
+ const range = parseSlides(source).configSource
244
+ if (!range) return diagnostics
245
+ return diagnostics.filter(d => d.offset == null || d.offset < range.start || d.offset > range.end)
246
+ }
247
+
248
+ /** Include problems as lines for the terminal, file:line: severity [code] message. */
249
+ export const formatIncludeDiagnostics = diagnostics => diagnostics.map(d => `${d.file}:${d.line}:1: ${d.severity} [${d.code}] ${d.message}`).join('\n')
@@ -20,6 +20,7 @@ import { slug } from '../live/code.js'
20
20
  import { inkFileFor, normalizeInk, emptyInk, applyOp, serializeInk } from '../core/ink.js'
21
21
  import { hashSource, isAllowedRequest, send, readJson, BACKUP_DIR } from './editorPlugin.js'
22
22
  import { markOwnWrite } from './ownWrites.js'
23
+ import { expandDeck } from './include.js'
23
24
 
24
25
  const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/
25
26
  const BACKUPS_KEPT = 10
@@ -119,13 +120,22 @@ export function createInkFile(deckPath, { debounceMs = 300, onChange = () => {}
119
120
  const renamed = []
120
121
  const seen = deckHash
121
122
  const alias = new Map(renames.filter(r => r.before === seen).map(r => [r.from, r.to]))
123
+ // The slides of files the deck includes (include.js) are not in its
124
+ // text; their ids carry their file's name and stay where they are.
125
+ let fromIncludes = null
126
+ const included = id => {
127
+ if (!fromIncludes) {
128
+ try { fromIncludes = new Set(Object.entries(expandDeck(abs, { source }).origins ?? {}).filter(([, origin]) => origin.file !== abs).map(([shown]) => shown)) } catch { fromIncludes = new Set() }
129
+ }
130
+ return fromIncludes.has(id)
131
+ }
122
132
  for (const raw of Array.isArray(ops) ? ops : []) {
123
133
  const op = { ...raw, slideId: alias.get(raw?.slideId) ?? raw?.slideId }
124
134
  if (op.type === 'add') {
125
135
  const index = deck.slides.findIndex(slide => slide.id === op.slideId)
126
- if (index < 0) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
136
+ if (index < 0 && !included(op.slideId)) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
127
137
  const slide = deck.slides[index]
128
- if (slide.meta.id == null) {
138
+ if (slide && slide.meta.id == null) {
129
139
  // An id by position: only trusted for the deck the device saw.
130
140
  if (deckHash !== hash) return { status: 409, error: 'The deck changed; reload', deckHash: hash }
131
141
  const id = newSlideId(deck, slide, index)
@@ -5,6 +5,7 @@ import { shareable, resourceKey } from './tunnelClient.js'
5
5
  import { collectLocalAssetRefs } from './slidesPlugin.js'
6
6
  import { parseSlides } from '../core/parseSlides.js'
7
7
  import { sessionCode } from '../live/code.js'
8
+ import { expandedSource } from './include.js'
8
9
 
9
10
  const canonical = file => { try { return realpathSync(file) } catch { return null } }
10
11
 
@@ -20,7 +21,8 @@ export function createRelayAccess(server, slidesPath) {
20
21
  let knownSource = null
21
22
  return async (address, method) => {
22
23
  let source
23
- try { source = readFileSync(slidesPath, 'utf8') } catch { return false }
24
+ // The deck as shown, so the pictures of included slides can be reached too.
25
+ try { source = expandedSource(slidesPath) } catch { return false }
24
26
  if (source !== knownSource) { knownFiles.clear(); knownSource = source }
25
27
  const resources = new Set(bootstrap.map(path => resourceKey(at(path))))
26
28
  const files = new Map()
@@ -13,8 +13,28 @@ import { launchChrome } from './chrome.js'
13
13
  import { frameworkRoot } from '../paths.js'
14
14
  import { parseSlides } from '../core/parseSlides.js'
15
15
  import { MEASURE, RECORDER, settle } from './themeCheck.js'
16
+ import { expandDeck } from './include.js'
16
17
 
17
18
  // Each slide's number, line and heading, for messages.
19
+ // The labels of the deck as shown, its includes in place (include.js); a
20
+ // slide from an included file names that file and its line there.
21
+ export function deckLabels(slidesPath) {
22
+ const abs = resolve(slidesPath)
23
+ const { source, origins } = expandDeck(abs)
24
+ const labels = slideLabels(source)
25
+ if (!origins) return labels
26
+ const byFile = new Map()
27
+ const lineIn = (file, id) => {
28
+ if (!byFile.has(file)) byFile.set(file, slideLabels(readFileSync(file, 'utf8')))
29
+ return byFile.get(file).find(label => label.id === id)?.line ?? 1
30
+ }
31
+ return labels.map(label => {
32
+ const origin = origins[label.id]
33
+ if (!origin) return label
34
+ return { ...label, line: lineIn(origin.file, origin.id), ...(origin.file !== abs ? { file: origin.file } : {}) }
35
+ })
36
+ }
37
+
18
38
  export function slideLabels(source) {
19
39
  return parseSlides(source).slides.map((slide, index) => {
20
40
  // The slide's first written line, past its settings and blank lines.
@@ -58,7 +78,7 @@ async function withDeckPage(slidesPath, { appearance = '', width = 1600, height
58
78
  // The problems of each slide, in the deck's own look, as diagnostics
59
79
  // ({ severity, code, message, line }) at the line the slide starts.
60
80
  export function renderCheck(slidesPath) {
61
- const labels = slideLabels(readFileSync(slidesPath, 'utf8'))
81
+ const labels = deckLabels(slidesPath)
62
82
  return withDeckPage(slidesPath, {}, async ({ page, count, show }) => {
63
83
  const diagnostics = []
64
84
  for (let index = 0; index < count; index++) {
@@ -70,7 +90,7 @@ export function renderCheck(slidesPath) {
70
90
  const cut = problems.filter(problem => problem.endsWith(' is cut off at the edge'))
71
91
  const shown = problems.filter(problem => !cut.includes(problem))
72
92
  if (cut.length) shown.push(cut.length === 1 ? cut[0] : `${cut[0].replace(/ is cut off at the edge$/, '')} and ${cut.length - 1} more ${cut.length === 2 ? 'line is' : 'lines are'} cut off at the edge`)
73
- for (const problem of shown) diagnostics.push({ severity: 'warning', code: 'render', message: `${where}: ${problem}`, line: label.line })
93
+ for (const problem of shown) diagnostics.push({ severity: 'warning', code: 'render', message: `${where}: ${problem}`, line: label.line, ...(label.file ? { file: label.file } : {}) })
74
94
  }
75
95
  // The deck's own warning about content that does not fit is said above, per slide.
76
96
  for (const line of (await page.evaluate('window.__report')).filter(line => !/has more content than fits/.test(line))) diagnostics.push({ severity: line.startsWith('error') ? 'error' : 'warning', code: 'render', message: `the page reported ${line}`, line: 1 })
@@ -85,7 +105,7 @@ export function renderCheck(slidesPath) {
85
105
  // written.
86
106
  export async function snapshot(slidesPath, { slides = [], outDir, width = 1280, height = 720, appearance = '', sheet = false } = {}) {
87
107
  const name = basename(slidesPath).replace(/\.md$/i, '')
88
- const labels = sheet ? slideLabels(readFileSync(slidesPath, 'utf8')) : []
108
+ const labels = sheet ? deckLabels(slidesPath) : []
89
109
  mkdirSync(outDir, { recursive: true })
90
110
  return withDeckPage(slidesPath, { appearance, width, height }, async ({ page, count, show }) => {
91
111
  const wanted = slides.length ? slides : Array.from({ length: count }, (_, i) => i + 1)
@@ -5,7 +5,7 @@ import { validateDeck, formatDiagnostics } from '../core/validateDeck.js'
5
5
  import { loadRegistry, extensionRoots, manifestsOf } from '../extensions/discover.js'
6
6
  import { resolvePalette } from '../extensions/appearance.js'
7
7
  import { embedFonts } from './fonts.js'
8
- import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
8
+ import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
9
9
  import { componentFolders, componentFiles } from './components.js'
10
10
  import { inkFileFor, normalizeInk, emptyInk } from '../core/ink.js'
11
11
  import { resultsFileFor, normalizeResults } from '../core/results.js'
@@ -16,6 +16,7 @@ import { marked } from 'marked'
16
16
  import { scanTags, replaceTagsIn, withAttribute } from '../core/tags.js'
17
17
  import { resolveBibliography, sampleBibliography } from './bibliography.js'
18
18
  import { languageOfFile, languageFiles } from '../core/languages.js'
19
+ import { expandDeck, withoutSettings } from './include.js'
19
20
 
20
21
  const VIRTUAL_ID = 'virtual:slides'
21
22
  const RESOLVED_ID = '\0virtual:slides'
@@ -174,6 +175,27 @@ function outsideCodeFences(markdown, fn) {
174
175
  return out + fn(text)
175
176
  }
176
177
 
178
+ // The local files a part of a deck names, each changed by `map(ref)` (the new
179
+ // ref, or null to keep it): Markdown pictures (also those in a tag's
180
+ // attributes, such as a poll's options), the `src` of pictures and media, and
181
+ // `image:` and `logo:` settings. For an included file (include.js), whose
182
+ // paths are written from its own folder. Paths from the root (/…) stay.
183
+ export function rewriteAssetRefs(markdown, map) {
184
+ const swap = ref => (isLocalAssetRef(ref) && !ref.startsWith('/') ? map(ref) ?? ref : ref)
185
+ return outsideCodeFences(markdown, text => {
186
+ let out = text.replace(/(!\[[^\]]*\]\()([^)\s]+)/g, (m, head, ref) => head + swap(ref))
187
+ out = replaceTagsIn(out, tag => {
188
+ if (tag.closing || !(PICTURE_TAGS.has(tag.name) || MEDIA_TAGS.has(tag.name)) || !tag.attrs.src) return
189
+ const next = swap(tag.attrs.src)
190
+ return next === tag.attrs.src ? undefined : withAttribute(tag, 'src', next)
191
+ })
192
+ return out.replace(/^(\s*)(image|logo):\s*(["']?)([^"'\n]+)\3\s*$/gm, (m, indent, key, quote, value) => {
193
+ const next = swap(value.trim())
194
+ return next === value.trim() ? m : `${indent}${key}: ${quote || '"'}${next}${quote || '"'}`
195
+ })
196
+ })
197
+ }
198
+
177
199
  export function maybeInlineAssets(markdown, abs, { inlineImages, inlineMedia }) {
178
200
  if (!inlineImages && !inlineMedia) return markdown
179
201
  const baseDir = dirname(abs)
@@ -230,13 +252,23 @@ export function deckLanguages(abs) {
230
252
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
231
253
  // `slidesPath` then only says where its folder is. `languages` also brings
232
254
  // the deck's other languages along, for `mdeck run`; a built file has one.
233
- export function slidesPlugin(slidesPath, { languages = false, inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, ink = true, results = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
255
+ export function slidesPlugin(slidesPath, { languages = false, inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, slides = null, ink = true, results = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
234
256
  const abs = resolve(slidesPath)
235
- const readDeck = () => given ?? readFileSync(abs, 'utf-8')
257
+ // The deck as shown: other files it includes in place (include.js).
258
+ const readRaw = () => given ?? readFileSync(abs, 'utf-8')
259
+ const expand = (file, source) => given === null ? expandDeck(file, { source }) : { source, files: [], diagnostics: [] }
260
+ const readDeck = () => expand(abs, readRaw()).source
261
+ // The files the deck and its other languages include, from the last load;
262
+ // the dev server may serve their folders, for their pictures.
263
+ let included = [], devServer = null
264
+ const allowIncluded = () => {
265
+ const allow = devServer?.config.server.fs.allow
266
+ if (allow) for (const dir of included.map(file => dirname(file))) if (!allow.includes(dir)) allow.push(dir)
267
+ }
236
268
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
237
269
  const sourceFor = raw => {
238
270
  const shared = stripNotes ? stripNotesFrom(raw) : raw
239
- return stripActivities ? stripActivitiesFrom(shared).source : shared
271
+ return stripActivities || slides ? shareSlides(shared, { activities: stripActivities, slides }).source : shared
240
272
  }
241
273
  const inkPath = inkFileFor(abs)
242
274
  // The language the file name gives, for a deck that sets no `lang`.
@@ -286,6 +318,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
286
318
  name: 'vite-plugin-slides',
287
319
  configResolved(config) { serving = config.command === 'serve' },
288
320
  configureServer(server) {
321
+ devServer = server
289
322
  server.watcher.add([abs, inkPath, ...watchDirs()])
290
323
  const refresh = file => {
291
324
  if (isWatched(file)) reload(server, ALL_IDS, resolve(file))
@@ -308,28 +341,35 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
308
341
  load(id) {
309
342
  if (id === RESOLVED_ID) {
310
343
  watchDeck(this)
311
- const raw = readDeck()
344
+ const raw = readRaw()
312
345
  const registry = loadRegistry(abs)
313
346
  for (const warning of registry.warnings) this.warn(warning)
314
- const diagnostics = validateDeck(parseSlides(raw), {
315
- layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette'),
316
- })
347
+ const manifests = { layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette') }
348
+ // Each file is checked as it is written, so that messages name its lines.
349
+ const expansion = expand(abs, raw)
350
+ const diagnostics = [...validateDeck(parseSlides(raw), manifests), ...expansion.diagnostics]
351
+ for (const file of expansion.files) {
352
+ const text = readFileSync(file, 'utf-8')
353
+ diagnostics.push(...withoutSettings(text, validateDeck(parseSlides(text), manifests)).map(d => ({ ...d, file })))
354
+ }
317
355
  const errors = diagnostics.filter(d => d.severity === 'error')
318
356
  if (errors.length && !editor) throw new Error(formatDiagnostics(errors, abs))
319
357
  if (diagnostics.length) this.warn(formatDiagnostics(diagnostics, abs))
320
- const source = maybeInlineAssets(sourceFor(raw), abs, { inlineImages, inlineMedia })
358
+ const source = maybeInlineAssets(sourceFor(expansion.source), abs, { inlineImages, inlineMedia })
321
359
  // The other languages are read as they are: `mdeck check` checks each file.
322
360
  const others = otherLanguages()
323
361
  languageWatch = others.map(({ file }) => file)
324
362
  for (const file of languageWatch) this.addWatchFile(file)
363
+ const otherSources = others.map(({ lang, file }) => ({ lang, file, ...expand(file, readFileSync(file, 'utf-8')) }))
364
+ included = [...new Set([...expansion.files, ...otherSources.flatMap(other => other.files)])]
365
+ for (const file of included) this.addWatchFile(file)
366
+ allowIncluded()
325
367
  for (const { file } of others) {
326
- const found = validateDeck(parseSlides(readFileSync(file, 'utf-8')), {
327
- layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette'),
328
- })
368
+ const found = validateDeck(parseSlides(readFileSync(file, 'utf-8')), manifests)
329
369
  if (found.length) this.warn(formatDiagnostics(found, file))
330
370
  }
331
371
  const translations = others.length && fileLanguage
332
- ? { [fileLanguage]: source, ...Object.fromEntries(others.map(({ lang, file }) => [lang, maybeInlineAssets(sourceFor(readFileSync(file, 'utf-8')), file, { inlineImages, inlineMedia })])) }
372
+ ? { [fileLanguage]: source, ...Object.fromEntries(otherSources.map(({ lang, file, source: text }) => [lang, maybeInlineAssets(sourceFor(text), file, { inlineImages, inlineMedia })])) }
333
373
  : {}
334
374
  return `export default ${JSON.stringify(source)}\nexport const language = ${JSON.stringify(fileLanguage)}\nexport const languages = ${JSON.stringify(translations)}`
335
375
  }
@@ -341,15 +381,15 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
341
381
  // import. The dev server's watcher notices the file once it appears.
342
382
  if (existsSync(inkPath)) this.addWatchFile(inkPath)
343
383
  let size = {}
344
- try { const config = parseSlides(readDeck()).deckConfig ?? {}; size = { width: config.width ?? 1920, height: config.height ?? 1080 } } catch {}
384
+ try { const config = parseSlides(readRaw()).deckConfig ?? {}; size = { width: config.width ?? 1920, height: config.height ?? 1080 } } catch {}
345
385
  let data = emptyInk(size)
346
386
  if (ink && existsSync(inkPath)) {
347
387
  try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
348
388
  }
349
- // Without the activity slides, drawings follow their slides to their new ids.
350
- if (stripActivities) {
389
+ // Without the activity slides or those not chosen, drawings follow their slides to their new ids.
390
+ if (stripActivities || slides) {
351
391
  try {
352
- const { ids, removed } = stripActivitiesFrom(readDeck())
392
+ const { ids, removed } = shareSlides(readDeck(), { activities: stripActivities, slides })
353
393
  const gone = new Set(removed)
354
394
  data = { ...data, slides: Object.fromEntries(Object.entries(data.slides).filter(([id]) => !gone.has(id)).map(([id, strokes]) => [ids[id] ?? id, strokes])) }
355
395
  } catch {}
@@ -389,7 +429,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
389
429
  for (const record of registry.records) this.addWatchFile(record.file)
390
430
  if (!deckLook) return generateExtensionsModule(registry)
391
431
  let config = {}
392
- try { config = parseSlides(readDeck()).deckConfig ?? {} } catch {}
432
+ try { config = parseSlides(readRaw()).deckConfig ?? {} } catch {}
393
433
  return generateExtensionsModule(deckLookOnly(registry, config))
394
434
  }
395
435
  if (id === RESOLVED_COMPONENTS_ID) {
@@ -409,7 +449,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
409
449
  async transformIndexHtml(html, context) {
410
450
  if (!/(^|[\/])index\.html$/.test(context.filename ?? context.path ?? '')) return html
411
451
  let config = {}
412
- try { config = parseSlides(readDeck()).deckConfig ?? {} } catch {}
452
+ try { config = parseSlides(readRaw()).deckConfig ?? {} } catch {}
413
453
  const lang = config.lang ?? fileLanguage
414
454
  if (typeof lang === 'string' && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) html = html.replace(/<html lang="[^"]*"/, `<html lang="${lang}"`)
415
455
  if (embedThemeFonts) {
@@ -430,10 +470,12 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
430
470
  // windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
431
471
  // The results file is written during the talk and read only by builds.
432
472
  if (changed === resultsPath) return []
433
- const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
473
+ // An included file counts as the deck (saved times are written into it too).
474
+ const deckFile = changed === abs || included.includes(changed)
475
+ const own = (changed === inkPath || deckFile) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
434
476
  if (own) {
435
- invalidate(server, changed === abs ? SOURCE_IDS : [RESOLVED_INK_ID])
436
- return changed === abs ? bibUpdate(server) : []
477
+ invalidate(server, deckFile ? SOURCE_IDS : [RESOLVED_INK_ID])
478
+ return deckFile ? bibUpdate(server) : []
437
479
  }
438
480
  if (changed === inkPath) {
439
481
  reload(server, [RESOLVED_INK_ID], changed)
@@ -450,6 +492,12 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
450
492
  server.ws.send({ type: 'full-reload' })
451
493
  return []
452
494
  }
495
+ if (included.includes(changed)) {
496
+ invalidate(server, SOURCE_IDS)
497
+ if (editor) return bibUpdate(server)
498
+ server.ws.send({ type: 'full-reload' })
499
+ return []
500
+ }
453
501
  if (changed === abs) {
454
502
  if (followComponentFolders(server)) {
455
503
  reload(server, ALL_IDS, changed)
@@ -0,0 +1,65 @@
1
+ // What `mdeck check` says about the deck's SVG drawings. A drawing that uses
2
+ // the palette (var(--…)) is drawn into the page, where it should follow the
3
+ // palette everywhere, work without it, and leave the rest of the page alone.
4
+ // One that does not is shown as a picture and cannot build up or be zoomed to.
5
+
6
+ const COLOUR_ATTRIBUTE = /\s(fill|stroke|stop-color|flood-color|lighting-color|color)\s*=\s*(["'])([^"']*)\2/gi
7
+ const COLOUR_PROPERTY = /(?<![\w-])(fill|stroke|stop-color|flood-color|lighting-color|color)\s*:\s*([^;}"']+)/gi
8
+ const FOLLOWS = /^(none|transparent|currentcolor|inherit|initial|unset|context-fill|context-stroke)$|^(var|url)\(/i
9
+
10
+ // [{ code, message }] for one drawing's markup.
11
+ export function svgProblems(markup) {
12
+ markup = markup.replace(/<!--[\s\S]*?-->/g, '')
13
+ const problems = []
14
+ const themed = /var\(--/.test(markup)
15
+ const steps = [...markup.matchAll(/\sdata-step(?:\s*=\s*(["'])([^"']*)\1)?/g)].map(match => (match[2] ?? '').trim())
16
+ if (!themed) {
17
+ if (steps.length || /\sdata-hotspot\b/.test(markup)) problems.push({ code: 'svg-not-inline', message: 'has data-step or data-hotspot, but uses no palette colour (var(--…)), so it is shown as a picture and they do nothing; give one colour as var(--ink, #222)' })
18
+ return problems
19
+ }
20
+ const bare = [...markup.matchAll(/var\(\s*(--[\w-]+)\s*\)/g)].map(match => match[1])
21
+ if (bare.length) problems.push({ code: 'svg-fallback', message: `uses ${unique(bare).map(name => `var(${name})`).join(', ')} without a fallback, which shows nothing where the drawing is opened on its own; write var(${bare[0]}, #…)` })
22
+ const fixed = [
23
+ ...[...markup.matchAll(COLOUR_ATTRIBUTE)].map(match => [match[1], match[3].trim()]),
24
+ ...[...styleText(markup).matchAll(COLOUR_PROPERTY), ...inlineStyles(markup).flatMap(style => [...style.matchAll(COLOUR_PROPERTY)])].map(match => [match[1], match[2].trim()]),
25
+ ].filter(([, value]) => value && !FOLLOWS.test(value))
26
+ if (fixed.length) {
27
+ const examples = unique(fixed.map(([name, value]) => `${name}="${value}"`)).slice(0, 3).join(', ')
28
+ problems.push({ code: 'svg-fixed-colour', message: `has ${fixed.length === 1 ? 'a fixed colour' : `${fixed.length} fixed colours`} (${examples}) that stay${fixed.length === 1 ? 's' : ''} the same in every palette and in dark mode; use var(--ink, …) or another palette colour where it should follow` })
29
+ }
30
+ const unscoped = unique(selectors(styleText(markup)).filter(selector => !selector.startsWith('.')))
31
+ if (unscoped.length) problems.push({ code: 'svg-style', message: `has style rules that reach the whole page (${unscoped.slice(0, 3).join(', ')}), its icons, QR codes and other drawings included; start each with a class of this drawing, such as .flow-` })
32
+ if (steps.some(Boolean) && steps.some(step => !step)) problems.push({ code: 'svg-steps', message: 'has numbered and bare data-step parts: the numbered ones show together in their order, the bare ones in document order. Number all of them' })
33
+ return problems
34
+ }
35
+
36
+ const unique = items => [...new Set(items)]
37
+
38
+ const styleText = markup => [...markup.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map(match => match[1].replace(/<!\[CDATA\[|\]\]>/g, '')).join('\n')
39
+ const inlineStyles = markup => [...markup.matchAll(/\sstyle\s*=\s*(["'])([^"']*)\1/gi)].map(match => match[2])
40
+
41
+ // The selectors of a stylesheet's rules, also inside @media; not the steps
42
+ // of @keyframes.
43
+ function selectors(css) {
44
+ css = css.replace(/\/\*[\s\S]*?\*\//g, '')
45
+ const found = []
46
+ let depth = 0
47
+ let skipUntil = -1
48
+ let start = 0
49
+ for (let i = 0; i < css.length; i++) {
50
+ if (css[i] === '{') {
51
+ const prelude = css.slice(start, i).trim()
52
+ if (skipUntil < 0) {
53
+ if (/^@keyframes\b|^@font-face\b/i.test(prelude)) skipUntil = depth
54
+ else if (prelude && !prelude.startsWith('@')) found.push(...prelude.split(',').map(s => s.trim()).filter(Boolean))
55
+ }
56
+ depth++
57
+ start = i + 1
58
+ } else if (css[i] === '}') {
59
+ depth--
60
+ if (depth === skipUntil) skipUntil = -1
61
+ start = i + 1
62
+ } else if (css[i] === ';' && depth === 0) start = i + 1
63
+ }
64
+ return found
65
+ }