mdeck 3.10.1 → 3.11.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 (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +13 -2
  3. package/assets/base.css +4 -1
  4. package/docs/reference/authoring.md +65 -4
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/commands.md +3 -1
  7. package/docs/site/content/drawing.md +1 -1
  8. package/docs/site/content/install.md +47 -0
  9. package/docs/site/content/media.md +20 -1
  10. package/docs/site/content/presenting.md +39 -2
  11. package/docs/site/content/sharing.md +1 -0
  12. package/docs/site/content/welcome.md +21 -1
  13. package/docs/site/images/features/hotspots.webp +0 -0
  14. package/docs/site/images/features/languages.webp +0 -0
  15. package/docs/site/pages.js +1 -0
  16. package/examples/custom-layouts/slides.md +21 -9
  17. package/examples/showcase/refs.bib +21 -0
  18. package/examples/showcase/slides.ar.drawings.json +11 -0
  19. package/examples/showcase/slides.ar.md +567 -0
  20. package/examples/showcase/slides.de.drawings.json +11 -0
  21. package/examples/showcase/slides.de.md +567 -0
  22. package/examples/showcase/slides.en.drawings.json +11 -0
  23. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  24. package/package.json +1 -1
  25. package/skills/write-slides/SKILL.md +10 -1
  26. package/src/build/check.js +7 -1
  27. package/src/build/config.js +2 -0
  28. package/src/build/slidesPlugin.js +6 -6
  29. package/src/build/svgCheck.js +65 -0
  30. package/src/build/timingPlugin.js +80 -0
  31. package/src/cli/args.js +4 -3
  32. package/src/cli/index.js +34 -11
  33. package/src/components/VideoPlayer.jsx +6 -1
  34. package/src/core/editDeck.js +44 -7
  35. package/src/core/icons.js +3 -0
  36. package/src/core/labels.js +75 -0
  37. package/src/core/parseSlides.js +3 -3
  38. package/src/core/timing.js +83 -0
  39. package/src/core/validateDeck.js +5 -0
  40. package/src/editor/components/DeckSettings.jsx +1 -0
  41. package/src/editor/components/MetaFields.jsx +1 -0
  42. package/src/home/Home.jsx +1 -0
  43. package/src/layouts/layoutApi.jsx +51 -8
  44. package/src/runtime/RehearsalSummary.jsx +129 -0
  45. package/src/runtime/component.js +27 -0
  46. package/src/runtime/deck-stage.js +49 -20
  47. package/src/runtime/ink/bus.js +13 -5
  48. package/src/runtime/ink/outbox.js +27 -0
  49. package/src/runtime/ink/room.js +31 -6
  50. package/src/runtime/ink/segments.js +21 -0
  51. package/src/runtime/main.jsx +157 -28
  52. package/src/runtime/shared.js +77 -0
  53. package/src/runtime/steps.js +36 -0
  54. package/src/runtime/useTalkTimer.js +65 -0
  55. package/examples/citations/refs.bib +0 -30
  56. package/examples/citations/refs.yml +0 -21
  57. package/examples/citations/slides.md +0 -43
  58. package/examples/citations/slides.pdf +0 -0
  59. package/examples/poll/slides.md +0 -127
  60. package/examples/showcase/slides.drawings.json +0 -11
@@ -1,10 +1,11 @@
1
1
  ---
2
- theme: glass
2
+ theme: neue
3
3
  meta:
4
4
  title: "A tour of mdeck"
5
5
  author: "Tilman Schieber"
6
6
  organization: "FHNW"
7
7
  date: "2026-10-06"
8
+ bibliography: refs.bib
8
9
  ---
9
10
 
10
11
  ---
@@ -14,14 +15,17 @@ image: ./img/mdeck.svg
14
15
  fit: contain
15
16
  alt: "A Markdown file turning into a stack of slides"
16
17
  id: a-talk-in-a-text-file
18
+ time: 1m
17
19
  :::
18
20
  # A talk in a text file.
19
21
  ## A tour of mdeck: each slide shows *what is written* and what it becomes.
20
22
 
21
23
  :::notes
22
- This deck teaches mdeck by example. Most slides show the Markdown beside what it becomes. Open `slides.md` next to it: every slide here is written exactly as shown.
24
+ This deck teaches mdeck by example. Most slides show the Markdown beside what it becomes. Open `slides.en.md` next to it: every slide here is written exactly as shown.
23
25
 
24
26
  Go through it with the arrow keys or the space bar. These notes are only for you; open the presenter view from the launch page to see them beside the slide.
27
+
28
+ The tour also comes in German (`slides.de.md`) and Arabic (`slides.ar.md`). Start it with `mdeck run slides.en.md`, and **L** in the presenter view switches every window to the next language.
25
29
  :::
26
30
 
27
31
  ---
@@ -30,6 +34,7 @@ layout: chapter
30
34
  number: 1
31
35
  part: Writing
32
36
  description: Plain Markdown, the format AI assistants already write. You can read and change every word.
37
+ time: 15s
33
38
  :::
34
39
  # Write it.
35
40
 
@@ -40,6 +45,7 @@ In practice an AI assistant usually writes this file from your own material: old
40
45
  ---
41
46
  :::meta
42
47
  layout: split
48
+ time: 1m
43
49
  :::
44
50
  # Words become slides
45
51
  :::slot left
@@ -64,12 +70,13 @@ Three dashes on a line of their own start the next slide.
64
70
  :::notes
65
71
  `#` is the slide's heading. A blank line separates paragraphs.
66
72
 
67
- Try it: change a heading in `slides.md` and save. The slide updates while it is open.
73
+ Try it: change a heading in `slides.en.md` and save. The slide updates while it is open.
68
74
  :::
69
75
 
70
76
  ---
71
77
  :::meta
72
78
  layout: split
79
+ time: 45s
73
80
  :::
74
81
  # Lists and emphasis
75
82
  :::slot left
@@ -96,6 +103,9 @@ layout: split
96
103
  :::
97
104
 
98
105
  ---
106
+ :::meta
107
+ time: 1m
108
+ :::
99
109
  # Reveal points one at a time
100
110
 
101
111
  :::columns
@@ -126,6 +136,7 @@ layout: chapter
126
136
  number: 2
127
137
  part: Layouts and content
128
138
  description: Layouts, pictures, code, tables and callouts. Pick a layout per slide; the theme does the rest.
139
+ time: 15s
129
140
  :::
130
141
  # Lay it out.
131
142
 
@@ -134,6 +145,7 @@ description: Layouts, pictures, code, tables and callouts. Pick a layout per sli
134
145
  layout: split
135
146
  props:
136
147
  ratio: [3, 2]
148
+ time: 1m
137
149
  :::
138
150
  # Choose a layout
139
151
  :::slot left
@@ -162,6 +174,7 @@ The built-in layouts are `title`, `chapter`, `focus`, `split`, `image-text` and
162
174
  :::meta
163
175
  layout: focus
164
176
  attribution: "A teacher"
177
+ time: 30s
165
178
  :::
166
179
  # Good questions make better conversations.
167
180
 
@@ -170,6 +183,7 @@ attribution: "A teacher"
170
183
  layout: image-text
171
184
  image: ./img/image.jpg
172
185
  alt: "Contour lines like those on a map"
186
+ time: 45s
173
187
  :::
174
188
  # A picture beside your words
175
189
 
@@ -193,6 +207,7 @@ layout: full-bleed-image
193
207
  image: ./img/image.jpg
194
208
  alt: "Contour lines like those on a map"
195
209
  overlay: true
210
+ time: 30s
196
211
  :::
197
212
  # Or let the picture fill the slide.
198
213
 
@@ -214,6 +229,7 @@ overlay: true
214
229
  ---
215
230
  :::meta
216
231
  layout: split
232
+ time: 1m30s
217
233
  :::
218
234
  # Code you can run
219
235
  :::slot left
@@ -240,6 +256,7 @@ Live Python downloads its runtime the first time, so it needs an internet connec
240
256
  ---
241
257
  :::meta
242
258
  layout: split
259
+ time: 45s
243
260
  :::
244
261
  # Tables and formulas
245
262
  :::slot left
@@ -272,6 +289,7 @@ Formulas use LaTeX. Single dollar signs put a formula inside a sentence; two dol
272
289
  ---
273
290
  :::meta
274
291
  layout: split
292
+ time: 45s
275
293
  :::
276
294
  # Tips and warnings
277
295
  :::slot left
@@ -299,6 +317,32 @@ Check the room's projector.
299
317
  The other callouts are `note`, `important` and `caution`. Add your own label after the type, for example `:::tip Before the talk`.
300
318
  :::
301
319
 
320
+ ---
321
+ :::meta
322
+ layout: split
323
+ time: 1m
324
+ :::
325
+ # Cite your sources
326
+ :::slot left
327
+ ```markdown
328
+ ---
329
+ bibliography: refs.bib
330
+ ---
331
+
332
+ Recalling beats rereading
333
+ [@roediger2006].
334
+ ```
335
+ :::
336
+ :::slot right
337
+ Recalling beats rereading [@roediger2006].
338
+
339
+ Lectures with active learning cut failure rates by a third [@freeman2014].
340
+ :::
341
+
342
+ :::notes
343
+ `refs.bib` is a BibTeX file, as Zotero exports it; CSL-JSON and Hayagriva work too. Each slide lists its own references above the footer, and `<bibliography />` on a slide lists them all. `csl: vancouver` switches to numbers.
344
+ :::
345
+
302
346
  ---
303
347
  :::meta
304
348
  layout: chapter
@@ -306,12 +350,14 @@ number: 3
306
350
  part: Presenting
307
351
  description: Notes for you, drawing on the slides, and questions for the room.
308
352
  id: give-the-talk
353
+ time: 15s
309
354
  :::
310
355
  # Give the talk.
311
356
 
312
357
  ---
313
358
  :::meta
314
359
  layout: split
360
+ time: 1m
315
361
  :::
316
362
  # Notes only you can see
317
363
  :::slot left
@@ -337,12 +383,13 @@ This is what the presenter view shows for this slide. Its header also shows what
337
383
  ---
338
384
  :::meta
339
385
  id: draw-on-any-slide
386
+ time: 1m
340
387
  :::
341
388
  # Draw on any slide
342
389
 
343
390
  Press **D** now, or the pen button. Draw with a pen, a highlighter, or a marker that fades after a moment. **Escape** stops.
344
391
 
345
- Drawings are kept in `slides.drawings.json` beside the slides, and appear in the PDF.
392
+ Drawings are kept in a `.drawings.json` file beside the slides, and appear in the PDF.
346
393
 
347
394
  :::notes
348
395
  The underline and the circle on this slide were drawn with the pen and saved; they show in every view and in the PDF.
@@ -355,6 +402,7 @@ On an iPad, pair it from the launch page: you draw and steer there while the lap
355
402
  layout: split
356
403
  hotspots: auto
357
404
  id: zoom-in-on-a-part
405
+ time: 1m30s
358
406
  :::
359
407
  # Zoom in on a part
360
408
  :::slot left
@@ -384,6 +432,34 @@ The numbered keys show on this slide only here in the presenter view, never on t
384
432
  :::
385
433
 
386
434
  ---
435
+ :::meta
436
+ layout: split
437
+ time: 1m
438
+ :::
439
+ # Time your talk
440
+ :::slot left
441
+ ```markdown
442
+ :::meta
443
+ time: 1m
444
+ :::
445
+ ```
446
+ :::
447
+ :::slot right
448
+ Give each slide a **time**. The presenter view counts it down, and tells you whether you are ahead or behind.
449
+
450
+ Run `mdeck rehearse slides.md` to time the slides as you practise, then save the times into the file.
451
+ :::
452
+
453
+ :::notes
454
+ Every slide in this tour has a time, 21 minutes in all. Press T in the presenter view to start the timer: under it you see what is left of this slide and how the talk stands.
455
+
456
+ A rehearsal measures the times for you. At the end it offers them rounded to 15 seconds; change any of them before you save.
457
+ :::
458
+
459
+ ---
460
+ :::meta
461
+ time: 1m30s
462
+ :::
387
463
  # Which part will you try first?
388
464
 
389
465
  <poll room="tour" options="Writing|Layouts|Drawing|Polls" />
@@ -393,7 +469,34 @@ One line makes a poll; the heading is the question:
393
469
 
394
470
  `<poll room="tour" options="Writing|Layouts|Drawing|Polls" />`
395
471
 
396
- Phones join with the QR code. They can reach this computer when you start `mdeck run slides.md --network`. The poll example shows scales, word clouds and open questions.
472
+ Phones join with the QR code. They can reach this computer when you start `mdeck run slides.md --network`.
473
+ :::
474
+
475
+ ---
476
+ :::meta
477
+ layout: split
478
+ time: 1m
479
+ :::
480
+ # One word from everyone
481
+ :::slot left
482
+ ```markdown
483
+ <wordcloud room="mood"
484
+ qr="false" />
485
+
486
+ <scale room="pace" min="1" max="5"
487
+ low="Too slow" high="Too fast" />
488
+
489
+ <question room="ask" />
490
+ ```
491
+ :::
492
+ :::slot right
493
+ <wordcloud room="mood" placeholder="One word" qr="false" />
494
+ :::
495
+
496
+ :::notes
497
+ Phones that joined with the poll's code stay in: `qr="false"` leaves the code out here. Answers appear as they come in.
498
+
499
+ Besides polls and word clouds there are ratings (`<scale>`), numbers with a right answer (`<numeric>`) and open questions (`<question>`). With several of them, `<qrcode join />` on an early slide shows the code once. `mdeck run` keeps the answers in `slides.results.json` beside the deck.
397
500
  :::
398
501
 
399
502
  ---
@@ -402,12 +505,14 @@ layout: chapter
402
505
  number: 4
403
506
  part: Sharing
404
507
  description: One file to send, a PDF, or a folder for a website.
508
+ time: 15s
405
509
  :::
406
510
  # Share it.
407
511
 
408
512
  ---
409
513
  :::meta
410
514
  layout: split
515
+ time: 45s
411
516
  :::
412
517
  # Change the whole look
413
518
  :::slot left
@@ -431,6 +536,7 @@ mdeck has four built-in themes and five palettes, each light or dark. `mdeck lis
431
536
  ---
432
537
  :::meta
433
538
  layout: split
539
+ time: 45s
434
540
  :::
435
541
  # Send it, print it, host it
436
542
  :::slot left
@@ -449,6 +555,7 @@ mdeck build slides.md
449
555
  ---
450
556
  :::meta
451
557
  layout: title
558
+ time: 1m
452
559
  :::
453
560
  # Now make your own.
454
561
  ## Hand your slides, documents or notes to an *AI assistant*.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdeck",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "type": "module",
5
5
  "description": "Write slides as a plain text file, present them in the browser, share them as one HTML file or PDF.",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@ The mdeck docs are the source of truth for syntax, layouts and looks. Look for t
18
18
  1. `docs/reference/` in the current folder (you are inside the mdeck repository).
19
19
  2. `$(npm root -g)/mdeck/docs/reference/` (mdeck is installed globally).
20
20
 
21
- Read `examples/showcase/slides.md` from the same place as a complete working deck.
21
+ Read `examples/showcase/slides.en.md` from the same place as a complete working deck.
22
22
 
23
23
  Then ask the installed mdeck what this deck can use, because a deck may ship its own layouts, themes and palettes in an `extensions/` folder:
24
24
 
@@ -92,6 +92,7 @@ Right column
92
92
  ```
93
93
 
94
94
  - Slides are separated by `---` on its own line. Settings go in a `:::meta` block at the top of a slide.
95
+ - `time: 2m` in a slide's settings (or `90s`, `1m30s`) is how long to spend on it; `time: 20m` in the deck settings is the length of the talk. Add times only when asked for a talk of a given length.
95
96
  - Built-in layouts: `title`, `chapter`, `focus` (one big statement), `image-text`, `split`, `full-bleed-image`, and plain content slides without a `layout:`.
96
97
  - Named areas use `:::slot name … :::`; the layout's regions and settings come from `mdeck list layouts --json`.
97
98
  - `:::notes … :::` holds speaker notes. `:::steps … :::` reveals list items one at a time. `:::note`, `:::tip`, `:::important`, `:::warning` and `:::caution` are callouts (`mdeck check` warns about other names); for lectures, `:::definition`, `:::theorem`, `:::lemma`, `:::proof`, `:::example` and `:::remark`, numbered in the title (`::: theorem Theorem 2.4 (Lagrange)`). `:::columns … +++ … :::` makes columns inside a slide.
@@ -127,6 +128,14 @@ The request is the message that invoked this skill: usually source material to t
127
128
  - Keep lists to three to five items; prefer a short bold label plus one sentence over long bullets.
128
129
  - Reference pictures under `./img/` and say which files the user still needs to add.
129
130
 
131
+ **Diagrams**
132
+ - When a slide explains a process, a structure or a comparison that the material shows as a figure, or that no picture shows yet, draw it as an SVG file in `./img/` and put it on the slide like a picture. Keep a diagram from the material where it is a photograph, a plot of real data or a figure you cannot redraw faithfully.
133
+ - Colour it only with the palette's names, each with a fallback: `fill="var(--accent, #1f3fd1)"`, `var(--ink, #222)` for text and lines, `var(--surface, #eef1fb)` for boxes, `var(--muted, #667)` for what is secondary. Then it follows the theme, the palette and dark mode. Give the background no fill, and text a font size near the slide's (24–36 px at the drawing's own scale), with a `viewBox` and room around the text.
134
+ - Start every rule in its `<style>` with a class of its own (`.flow-box`), never a bare `text` or `rect`: the drawing is part of the page, and such a rule changes every drawing on it. Markers, gradients and `<use>` with ids work.
135
+ - For a diagram that builds up, number its parts with `data-step="1"`, `"2"` …; parts with the same number appear together, in the order of the numbers, not of the file. With `hotspots`, `data-hotspot` on a group lets the presenter zoom to it. Mention the steps in the notes.
136
+ - On a slide of its own, a diagram goes in a layout's `image` with `fit: contain`, or alone in the slide text.
137
+ - `mdeck check` names fixed colours, missing fallbacks and rules that reach the page; fix them. Then look at the slide with `mdeck snapshot`, in light and `--dark`.
138
+
130
139
  ## 4. Check and hand over
131
140
 
132
141
  Save the deck as a `.md` file named after the topic (for example `enzyme-kinetics.md`), then run:
@@ -11,12 +11,18 @@ import { collectLocalAssetRefs } from './slidesPlugin.js'
11
11
  import { componentFolders, isFolder } from './components.js'
12
12
  import { inkFileFor, validateInk, normalizeInk, orphanIds } from '../core/ink.js'
13
13
  import { resolveBibliography } from './bibliography.js'
14
+ import { svgProblems } from './svgCheck.js'
14
15
 
15
16
  export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath, 'utf8')) {
16
17
  const deck = parseSlides(source)
17
18
  const diagnostics = validateDeck(deck, { layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette') })
18
19
  for (const ref of collectLocalAssetRefs(source)) {
19
- if (!existsSync(resolve(dirname(resolve(slidesPath)), ref))) diagnostics.push({ severity: 'error', code: 'missing-asset', message: `Missing local asset: ${ref}`, line: 1, column: 1 })
20
+ const file = resolve(dirname(resolve(slidesPath)), ref)
21
+ if (!existsSync(file)) { diagnostics.push({ severity: 'error', code: 'missing-asset', message: `Missing local asset: ${ref}`, line: 1, column: 1 }); continue }
22
+ if (!/\.svg$/i.test(ref)) continue
23
+ const at = source.indexOf(ref)
24
+ const line = at < 0 ? 1 : source.slice(0, at).split('\n').length
25
+ for (const problem of svgProblems(readFileSync(file, 'utf8'))) diagnostics.push({ severity: 'warning', code: problem.code, message: `${ref} ${problem.message}`, line, column: 1 })
20
26
  }
21
27
  for (const folder of componentFolders(slidesPath, source).filter(folder => folder.source === 'shared')) {
22
28
  if (!isFolder(folder.dir)) diagnostics.push({ severity: 'error', code: 'missing-components', message: `Components folder not found: ${folder.path} (${folder.dir})`, line: 1, column: 1 })
@@ -29,6 +29,8 @@ function preactAliases() {
29
29
  }
30
30
  aliases.push({ find: new RegExp(`^${spec.replace(/\//g, '\\/')}$`), replacement: file })
31
31
  }
32
+ // What deck components may use from mdeck (src/runtime/component.js).
33
+ aliases.push({ find: /^mdeck\/component$/, replacement: fileURLToPath(new URL('../runtime/component.js', import.meta.url)) })
32
34
  return aliases
33
35
  }
34
36
 
@@ -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'
@@ -230,13 +230,13 @@ export function deckLanguages(abs) {
230
230
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
231
231
  // `slidesPath` then only says where its folder is. `languages` also brings
232
232
  // 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 } = {}) {
233
+ 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
234
  const abs = resolve(slidesPath)
235
235
  const readDeck = () => given ?? readFileSync(abs, 'utf-8')
236
236
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
237
237
  const sourceFor = raw => {
238
238
  const shared = stripNotes ? stripNotesFrom(raw) : raw
239
- return stripActivities ? stripActivitiesFrom(shared).source : shared
239
+ return stripActivities || slides ? shareSlides(shared, { activities: stripActivities, slides }).source : shared
240
240
  }
241
241
  const inkPath = inkFileFor(abs)
242
242
  // The language the file name gives, for a deck that sets no `lang`.
@@ -346,10 +346,10 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
346
346
  if (ink && existsSync(inkPath)) {
347
347
  try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
348
348
  }
349
- // Without the activity slides, drawings follow their slides to their new ids.
350
- if (stripActivities) {
349
+ // Without the activity slides or those not chosen, drawings follow their slides to their new ids.
350
+ if (stripActivities || slides) {
351
351
  try {
352
- const { ids, removed } = stripActivitiesFrom(readDeck())
352
+ const { ids, removed } = shareSlides(readDeck(), { activities: stripActivities, slides })
353
353
  const gone = new Set(removed)
354
354
  data = { ...data, slides: Object.fromEntries(Object.entries(data.slides).filter(([id]) => !gone.has(id)).map(([id, strokes]) => [ids[id] ?? id, strokes])) }
355
355
  } catch {}
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ // Saving a rehearsal's times while `mdeck run` runs: the presenter view
2
+ // sends each slide's new time, and it goes into the slide's settings as
3
+ // `time: 1m30s` (core/timing.js). null takes a slide's time out.
4
+ //
5
+ // GET /__mdeck/timing → { ids }
6
+ // POST /__mdeck/timing { ids, times: { [slideId]: '1m30s' | null } } → { ok, changed }
7
+ //
8
+ // Slides without an `id:` are named by position (slide-3), so the times are
9
+ // only written when the deck still has the slides the page saw (`ids`);
10
+ // otherwise the answer is 409. The deck is copied to .mdeck-backups/ before
11
+ // the first write, and the write does not reload the open windows
12
+ // (ownWrites.js).
13
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs'
14
+ import { resolve } from 'node:path'
15
+ import { parseSlides } from '../core/parseSlides.js'
16
+ import { setSlideMeta } from '../core/editDeck.js'
17
+ import { parseTime, formatTime } from '../core/timing.js'
18
+ import { isAllowedRequest, send, readJson, backupDeck } from './editorPlugin.js'
19
+ import { markOwnWrite } from './ownWrites.js'
20
+
21
+ export function createTimingFile(deckPath) {
22
+ const abs = resolve(deckPath)
23
+ let backedUp = false
24
+ const read = () => { const source = readFileSync(abs, 'utf8'); return { source, deck: parseSlides(source) } }
25
+
26
+ return {
27
+ path: abs,
28
+ ids: () => read().deck.slides.map(slide => slide.id),
29
+ save(ids, times) {
30
+ let { source, deck } = read()
31
+ const current = deck.slides.map(slide => slide.id)
32
+ if (!Array.isArray(ids) || ids.join('\n') !== current.join('\n')) return { status: 409, error: 'The deck changed since the rehearsal began; rehearse again', ids: current }
33
+ const before = source
34
+ let changed = 0
35
+ for (const [id, value] of Object.entries(times ?? {})) {
36
+ if (!current.includes(id)) return { status: 400, error: `No slide "${id}" in the deck` }
37
+ const seconds = value == null ? null : parseTime(value)
38
+ if (value != null && seconds == null) return { status: 400, error: `"${value}" is not a time` }
39
+ const time = seconds == null ? undefined : formatTime(seconds)
40
+ if (deck.slides.find(slide => slide.id === id).meta.time === time) continue
41
+ source = setSlideMeta(deck, id, { time })
42
+ deck = parseSlides(source)
43
+ changed++
44
+ }
45
+ if (source === before) return { status: 200, ok: true, changed: 0 }
46
+ if (!backedUp) { backupDeck(abs, before); backedUp = true }
47
+ markOwnWrite(abs, source)
48
+ const temp = `${abs}.${process.pid}.tmp`
49
+ writeFileSync(temp, source, 'utf8')
50
+ renameSync(temp, abs)
51
+ return { status: 200, ok: true, changed }
52
+ },
53
+ }
54
+ }
55
+
56
+ export function timingMiddleware(file, { authorize = isAllowedRequest } = {}) {
57
+ return async (request, response, next) => {
58
+ const { pathname } = new URL(request.url, 'http://localhost')
59
+ if (pathname !== '/') return next()
60
+ if (!authorize(request)) return send(response, 403, { error: 'Only the presenter can save times' })
61
+ try {
62
+ if (request.method === 'GET') return send(response, 200, { ids: file.ids() })
63
+ if (request.method !== 'POST') { response.setHeader('Allow', 'GET, POST'); return send(response, 405, { error: 'Method not allowed' }) }
64
+ const { ids, times } = await readJson(request)
65
+ const { status, ...result } = file.save(ids, times)
66
+ return send(response, status, result)
67
+ } catch (error) {
68
+ return send(response, error.status ?? 500, { error: error.message })
69
+ }
70
+ }
71
+ }
72
+
73
+ export function timingPlugin(slidesPath, { authorize } = {}) {
74
+ return {
75
+ name: 'vite-plugin-mdeck-timing',
76
+ configureServer(server) {
77
+ server.middlewares.use('/__mdeck/timing', timingMiddleware(createTimingFile(slidesPath), { authorize }))
78
+ },
79
+ }
80
+ }
package/src/cli/args.js CHANGED
@@ -5,13 +5,14 @@ const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--
5
5
  const commands = {
6
6
  new: { max: 0 },
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
8
+ rehearse: { ...common, '--network': flag, '--server': optional, max: 1 },
8
9
  edit: { ...common, max: 1 },
9
10
  design: { ...common, max: 1 },
10
11
  themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
11
12
  palettes: { '--local': flag, '--force': flag, max: 4 },
12
- build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, '--no-polls': flag, max: 1 },
13
- send: { ...output, '--notes': flag, '--no-polls': flag, '--no-pdf': flag, max: 1 },
14
- pdf: { ...output, '--no-polls': flag, max: 1 },
13
+ build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, '--no-polls': flag, '--slide': value, max: 1 },
14
+ send: { ...output, '--notes': flag, '--no-polls': flag, '--no-pdf': flag, '--slide': value, max: 1 },
15
+ pdf: { ...output, '--no-polls': flag, '--slide': value, max: 1 },
15
16
  preview: { ...common, max: 1 },
16
17
  check: { '--strict': flag, '--render': flag, max: 1 },
17
18
  snapshot: { '--output': value, '-o': '--output', '--slide': value, '--dark': flag, '--light': flag, '--sheet': flag, max: 1 },