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,96 @@
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). A slide from another file the deck includes (include.js)
13
+ // gets its time in that file, as that file's own slide.
14
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs'
15
+ import { resolve } from 'node:path'
16
+ import { parseSlides } from '../core/parseSlides.js'
17
+ import { setSlideMeta } from '../core/editDeck.js'
18
+ import { parseTime, formatTime } from '../core/timing.js'
19
+ import { isAllowedRequest, send, readJson, backupDeck } from './editorPlugin.js'
20
+ import { markOwnWrite } from './ownWrites.js'
21
+ import { expandDeck } from './include.js'
22
+
23
+ export function createTimingFile(deckPath) {
24
+ const abs = resolve(deckPath)
25
+ const backedUp = new Set()
26
+ // The deck as shown, and for each slide the file it is written in.
27
+ const shown = () => {
28
+ const { source, origins } = expandDeck(abs)
29
+ return { deck: parseSlides(source), origins }
30
+ }
31
+
32
+ return {
33
+ path: abs,
34
+ ids: () => shown().deck.slides.map(slide => slide.id),
35
+ save(ids, times) {
36
+ const { deck: whole, origins } = shown()
37
+ const current = whole.slides.map(slide => slide.id)
38
+ 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 }
39
+ // The new times, by the file the slide is written in and its id there.
40
+ const files = new Map()
41
+ for (const [id, value] of Object.entries(times ?? {})) {
42
+ if (!current.includes(id)) return { status: 400, error: `No slide "${id}" in the deck` }
43
+ const seconds = value == null ? null : parseTime(value)
44
+ if (value != null && seconds == null) return { status: 400, error: `"${value}" is not a time` }
45
+ const time = seconds == null ? undefined : formatTime(seconds)
46
+ if (whole.slides.find(slide => slide.id === id).meta.time === time) continue
47
+ const { file, id: own } = origins?.[id] ?? { file: abs, id }
48
+ if (!files.has(file)) files.set(file, [])
49
+ files.get(file).push([own, time])
50
+ }
51
+ let changed = 0
52
+ for (const [file, entries] of files) {
53
+ const before = readFileSync(file, 'utf8')
54
+ let source = before, deck = parseSlides(source)
55
+ for (const [id, time] of entries) {
56
+ source = setSlideMeta(deck, id, { time })
57
+ deck = parseSlides(source)
58
+ changed++
59
+ }
60
+ if (source === before) continue
61
+ if (!backedUp.has(file)) { backupDeck(file, before); backedUp.add(file) }
62
+ markOwnWrite(file, source)
63
+ const temp = `${file}.${process.pid}.tmp`
64
+ writeFileSync(temp, source, 'utf8')
65
+ renameSync(temp, file)
66
+ }
67
+ return { status: 200, ok: true, changed }
68
+ },
69
+ }
70
+ }
71
+
72
+ export function timingMiddleware(file, { authorize = isAllowedRequest } = {}) {
73
+ return async (request, response, next) => {
74
+ const { pathname } = new URL(request.url, 'http://localhost')
75
+ if (pathname !== '/') return next()
76
+ if (!authorize(request)) return send(response, 403, { error: 'Only the presenter can save times' })
77
+ try {
78
+ if (request.method === 'GET') return send(response, 200, { ids: file.ids() })
79
+ if (request.method !== 'POST') { response.setHeader('Allow', 'GET, POST'); return send(response, 405, { error: 'Method not allowed' }) }
80
+ const { ids, times } = await readJson(request)
81
+ const { status, ...result } = file.save(ids, times)
82
+ return send(response, status, result)
83
+ } catch (error) {
84
+ return send(response, error.status ?? 500, { error: error.message })
85
+ }
86
+ }
87
+ }
88
+
89
+ export function timingPlugin(slidesPath, { authorize } = {}) {
90
+ return {
91
+ name: 'vite-plugin-mdeck-timing',
92
+ configureServer(server) {
93
+ server.middlewares.use('/__mdeck/timing', timingMiddleware(createTimingFile(slidesPath), { authorize }))
94
+ },
95
+ }
96
+ }
package/src/cli/args.js CHANGED
@@ -4,14 +4,15 @@ const common = { '--port': value, '--no-open': flag }
4
4
  const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--no-results': flag }
5
5
  const commands = {
6
6
  new: { max: 0 },
7
- run: { ...common, '--network': flag, '--server': optional, max: 1 },
7
+ run: { ...common, '--network': flag, '--server': optional, '--no-server': flag, max: 1 },
8
+ rehearse: { ...common, '--network': flag, '--server': optional, '--no-server': flag, 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 },
@@ -21,7 +22,7 @@ const commands = {
21
22
  server: { '--port': value, '--host': optional, max: 0 },
22
23
  skill: { '--print': flag, '--install': 'list', '--project': flag, max: 0 },
23
24
  migrate: { '--dry-run': flag, max: 1 },
24
- results: { '--output': value, '-o': '--output', max: 1 },
25
+ results: { '--output': value, '-o': '--output', '--summary': flag, '--json': flag, max: 1 },
25
26
  }
26
27
 
27
28
  export function validateServerOrigin(address) {
package/src/cli/index.js CHANGED
@@ -13,9 +13,11 @@ import { BACKUP_DIR } from '../build/editorPlugin.js'
13
13
  import { createEditorServer, createDesignServer } from '../build/editorServer.js'
14
14
  import { homePlugin } from '../build/homePlugin.js'
15
15
  import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
16
+ import { expandedSource } from '../build/include.js'
16
17
  import { livePlugin, startLiveServer } from '../live/server.js'
17
18
  import { inkPlugin } from '../build/inkPlugin.js'
18
19
  import { resultsPlugin } from '../build/resultsPlugin.js'
20
+ import { timingPlugin } from '../build/timingPlugin.js'
19
21
  import { createPairing, pairingPlugin } from '../build/pairing.js'
20
22
  import { isAllowedRequest } from '../build/editorPlugin.js'
21
23
  import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
@@ -26,10 +28,11 @@ import { paramKind } from '../extensions/params.js'
26
28
 
27
29
  import { frameworkRoot } from '../paths.js'
28
30
  import { parseSlides } from '../core/parseSlides.js'
29
- import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
31
+ import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
30
32
  import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
31
33
  import { assertDrawings } from '../build/drawings.js'
32
- import { resultsFileFor, normalizeResults, resultsCsv } from '../core/results.js'
34
+ import { resultsFileFor, normalizeResults } from '../core/results.js'
35
+ import { resultsReport, reportAnswersCsv, reportSummaryCsv, reportJson } from '../core/resultsReport.js'
33
36
  import { createRelayAccess } from '../build/relayAccess.js'
34
37
  import { sessionCode } from '../live/code.js'
35
38
  import { migrateDeck } from './migrate.js'
@@ -58,7 +61,25 @@ function hasFlag(name, short = null) { return !!(parsed.options[name] ?? parsed.
58
61
  const positionals = () => parsed.positionals
59
62
  const outputOption = () => parsed.options['--output'] ?? null
60
63
  const serverOption = () => parsed.options['--server']
64
+ // Whether a poll server answers at `address`, within a few seconds.
65
+ async function serverAnswers(address) {
66
+ try {
67
+ validateServerOrigin(address)
68
+ const response = await fetch(new URL('info', address.replace(/\/*$/, '/')), { signal: AbortSignal.timeout(3000) })
69
+ return response.ok
70
+ } catch { return false }
71
+ }
61
72
  const portOption = () => parsed.options['--port'] ?? null
73
+ // --slide 3,5-7 for build, send and pdf: only these slides, numbered as written.
74
+ function slideOption(input) {
75
+ const given = parsed.options['--slide']
76
+ if (given == null) return null
77
+ try {
78
+ const slides = parseSlideNumbers(given)
79
+ shareSlides(readFileSync(resolve(input), 'utf-8'), { slides })
80
+ return slides
81
+ } catch (error) { err(error.message); process.exit(1) }
82
+ }
62
83
 
63
84
  // Layouts, themes and palettes all come from one registry: built-ins plus the
64
85
  // extensions/ folder beside the deck. Manifest problems are reported and stop
@@ -181,13 +202,14 @@ async function runNewWizard() {
181
202
  }
182
203
  }
183
204
 
184
- async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
205
+ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false, slides = null } = {}) {
185
206
  const absSlides = resolve(slidesPath)
186
207
  const deckDir = dirname(absSlides)
187
208
  const absOutDir = resolve(outDir)
188
- const raw = readFileSync(absSlides, 'utf-8')
209
+ // The deck as shown: pictures of included slides are copied too.
210
+ const raw = expandedSource(absSlides)
189
211
  let markdown = stripNotes ? stripNotesFrom(raw) : raw
190
- if (stripActivities) markdown = stripActivitiesFrom(markdown).source
212
+ if (stripActivities || slides) markdown = shareSlides(markdown, { activities: stripActivities, slides }).source
191
213
 
192
214
  for (const ref of collectLocalAssetRefs(markdown)) {
193
215
  const source = resolve(deckDir, ref)
@@ -312,8 +334,13 @@ const HELP = `
312
334
  --network reach the slides from phones and tablets on the same network
313
335
  --server [address] present from an iPad on any network, through your server
314
336
  (the address, or the deck's server setting, or MDECK_SERVER;
315
- the key in MDECK_SERVER_KEY)
337
+ the key in MDECK_SERVER_KEY). With both MDECK_SERVER and
338
+ MDECK_SERVER_KEY set, the server is used without --server
339
+ when it answers
340
+ --no-server present from this computer only, even with MDECK_SERVER set
316
341
  --no-open --port <number>
342
+ ${c.green}mdeck rehearse${c.reset} [slides.md] Like run, but opens the presenter view rehearsing: it times
343
+ each slide and saves the times into the deck (time: 1m30s)
317
344
  ${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
318
345
  ${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
319
346
  saves to the folder's extensions/ (default: this folder)
@@ -332,6 +359,8 @@ const HELP = `
332
359
  --notes keep the speaker notes in the file
333
360
  --no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
334
361
  --no-pdf do not render the PDF inside
362
+ --slide 3,5-7 for build, send and pdf: only these slides (numbered as in the deck,
363
+ with their drawings; styles from slides left out stay)
335
364
  ${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
336
365
  --no-drawings for build, send and pdf: leave out <slides>.drawings.json
337
366
  --no-results for build, send and pdf: leave out <slides>.results.json (the answers kept in the talk)
@@ -346,7 +375,9 @@ const HELP = `
346
375
  --slide <numbers> only these slides: 3, 3,5 or 2-4
347
376
  --sheet one picture of them all in a grid, each under its number and heading
348
377
  --dark | --light in this appearance instead of the deck's own -o <folder>
349
- ${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV (room, phone, time, answer)
378
+ ${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV: slide, question, phone, time, answer
379
+ --summary one row per option, number or word, with how many chose it
380
+ --json everything as JSON: each question, its summary and its answers (also -o answers.json)
350
381
  ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
351
382
  List built-in and deck-local layouts, themes and palettes
352
383
  ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
@@ -402,7 +433,7 @@ if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); process
402
433
  let parsed
403
434
  try {
404
435
  parsed = parseArgs(command, argv)
405
- if (['run', 'server'].includes(command) && Object.hasOwn(process.env, 'MDECK_LIVE_KEY')) throw new Error('MDECK_LIVE_KEY is now MDECK_SERVER_KEY. Rename the environment variable before starting mdeck.')
436
+ if (['run', 'rehearse', 'server'].includes(command) && Object.hasOwn(process.env, 'MDECK_LIVE_KEY')) throw new Error('MDECK_LIVE_KEY is now MDECK_SERVER_KEY. Rename the environment variable before starting mdeck.')
406
437
  } catch (error) {
407
438
  err(error.message)
408
439
  if (Object.hasOwn(RENAMED_COMMANDS, command)) tip(`mdeck ${command} is now ${RENAMED_COMMANDS[command]}.`)
@@ -476,11 +507,18 @@ if (command === 'new') {
476
507
  const input = requireInput('results')
477
508
  const file = resultsFileFor(resolve(input))
478
509
  if (!existsSync(file)) { err(`No answers kept for ${basename(input)} yet: ${basename(file)} appears beside it once people answer during mdeck run.`); process.exit(1) }
479
- let csv
480
- try { csv = resultsCsv(normalizeResults(JSON.parse(readFileSync(file, 'utf8')))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
510
+ let results
511
+ try { results = normalizeResults(JSON.parse(readFileSync(file, 'utf8'))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
512
+ // Read against the deck as shown, included slides too, for the slide and the question.
513
+ let deck
514
+ try { deck = parseSlides(expandedSource(resolve(input))) } catch { deck = parseSlides(readFileSync(resolve(input), 'utf8')) }
515
+ const report = resultsReport(results, deck)
481
516
  const out = outputOption()
482
- if (out) { writeFileSync(out, csv); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
483
- else process.stdout.write(csv)
517
+ const json = hasFlag('--json') || /\.json$/i.test(out ?? '')
518
+ const text = json ? reportJson(report, { title: deck.deckConfig?.meta?.title ?? basename(input).replace(/\.md$/i, '') })
519
+ : hasFlag('--summary') ? reportSummaryCsv(report) : reportAnswersCsv(report)
520
+ if (out) { writeFileSync(out, text); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
521
+ else process.stdout.write(text)
484
522
  } else if (command === 'check') {
485
523
  const input = requireInput('check')
486
524
  const registry = registryFor(input)
@@ -538,7 +576,7 @@ if (command === 'new') {
538
576
  else process.stdout.write(layouts[name].starter)
539
577
 
540
578
  // ── run ───────────────────────────────────────────────────────────────────────
541
- } else if (command === 'run') {
579
+ } else if (command === 'run' || command === 'rehearse') {
542
580
  const input = requireInput(command)
543
581
  const abs = resolve(input)
544
582
  try { assertDrawings(abs) } catch (error) { err(error.message); process.exit(1) }
@@ -550,8 +588,18 @@ if (command === 'new') {
550
588
  // (src/build/tunnelClient.js). The dev server then serves under /t/<id>/, the
551
589
  // address the iPad uses on the server, and its own addresses follow it. The
552
590
  // address is the flag's value, else the deck's server setting, else MDECK_SERVER.
591
+ // With MDECK_SERVER and MDECK_SERVER_KEY set, the server is used without
592
+ // --server when it answers; otherwise this computer presents alone.
593
+ // --no-server keeps it out.
553
594
  let relay = null
554
- const serverFlag = serverOption()
595
+ let serverFlag = serverOption()
596
+ if (serverFlag === undefined && !hasFlag('--no-server') && process.env.MDECK_SERVER && process.env.MDECK_SERVER_KEY) {
597
+ const address = process.env.MDECK_SERVER
598
+ if (await serverAnswers(address)) {
599
+ serverFlag = address
600
+ tip(`Using your server from MDECK_SERVER (${address}); --no-server presents without it.`)
601
+ } else tip(`Your server (MDECK_SERVER, ${address}) does not answer: presenting from this computer only.`)
602
+ }
555
603
  if (serverFlag !== undefined) {
556
604
  const inDeck = parseSlides(readFileSync(abs, 'utf8')).deckConfig?.server
557
605
  const address = serverFlag ?? (typeof inDeck === 'string' ? inDeck : null) ?? process.env.MDECK_SERVER ?? null
@@ -584,13 +632,13 @@ if (command === 'new') {
584
632
  ...base,
585
633
  // A paired iPad may save ink and steer the rooms, like this computer.
586
634
  ...(relay ? { base: relay.base } : {}),
587
- plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), resultsPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
635
+ plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), resultsPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), timingPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
588
636
  publicDir: dirname(abs),
589
637
  server: {
590
638
  ...base.server,
591
639
  ...(port ? { port, strictPort: true } : {}),
592
640
  host: exposed ? true : base.server.host,
593
- open: hasFlag('--no-open') ? false : `${mountBase}home.html`,
641
+ open: hasFlag('--no-open') ? false : command === 'rehearse' ? `${mountBase}?view=presenter&rehearse=1` : `${mountBase}home.html`,
594
642
  },
595
643
  })
596
644
  await server.listen()
@@ -624,11 +672,12 @@ if (command === 'new') {
624
672
  console.log()
625
673
  ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
626
674
  tip(`Presenter: ${new URL('?view=presenter', local).href}`)
675
+ tip(`Rehearse: ${new URL('?view=presenter&rehearse=1', local).href}`)
627
676
  tip(`Audience: ${new URL('?view=audience', local).href}`)
628
677
  // Phones in the room follow the talk (the reader view would show slides ahead).
629
678
  if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
630
679
  console.log()
631
- if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
680
+ if (relay) tip(`iPad: in the presenter view, click the connection icons and choose "iPad" or "Phone clicker"; it opens ${relay.url}`)
632
681
  ok(`Watching ${c.cyan}${abs}${c.reset}`)
633
682
  tip('Edit and save to reload.\n')
634
683
 
@@ -696,6 +745,7 @@ if (command === 'new') {
696
745
  // Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
697
746
  const stripActivities = reader && hasFlag('--no-polls')
698
747
  const wantPdf = sending && !hasFlag('--no-pdf')
748
+ const slides = slideOption(input)
699
749
 
700
750
  if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
701
751
  const given = outputOption()
@@ -709,7 +759,7 @@ if (command === 'new') {
709
759
  try {
710
760
  await build({
711
761
  ...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
712
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
762
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, slides, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
713
763
  build: {
714
764
  outDir,
715
765
  emptyOutDir: !tempDir,
@@ -727,7 +777,7 @@ if (command === 'new') {
727
777
  await mkdir(finalOutDir, { recursive: true })
728
778
 
729
779
  if (!selfContained) {
730
- await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
780
+ await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities, slides })
731
781
  if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
732
782
  }
733
783
 
@@ -751,7 +801,12 @@ if (command === 'new') {
751
801
  } finally { await rm(pdfDir, { recursive: true, force: true }) }
752
802
  }
753
803
  if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
754
- if (stripActivities && stripActivitiesFrom(readFileSync(resolve(input), 'utf-8')).removed.length) tip('Slides with polls were left out (--no-polls).')
804
+ // Only polls among the slides chosen with --slide were left out for --no-polls.
805
+ if (stripActivities) {
806
+ const raw = readFileSync(resolve(input), 'utf-8')
807
+ if (shareSlides(raw, { activities: true, slides }).removed.length > (slides ? shareSlides(raw, { slides }).removed.length : 0)) tip('Slides with polls were left out (--no-polls).')
808
+ }
809
+ if (slides) tip(`Only slides ${parsed.options['--slide']} (--slide).`)
755
810
  ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
756
811
 
757
812
  // ── skill ─────────────────────────────────────────────────────────────────────
@@ -777,11 +832,12 @@ if (command === 'new') {
777
832
  const pdfFile = resolve(process.cwd(), outputOption() ?? basename(input).replace(/\.md$/i, '') + '.pdf')
778
833
  if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
779
834
  if (!findChrome()) { err('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.'); process.exit(1) }
835
+ const slides = slideOption(input)
780
836
  const tempDir = mkdtempSync(resolve(tmpdir(), 'mdeck-pdf-'))
781
837
  try {
782
838
  await build({
783
839
  ...baseConfig(input, { selfContained: true }),
784
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: true }), viteSingleFile()],
840
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), slides, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: true }), viteSingleFile()],
785
841
  build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
786
842
  logLevel: 'warn',
787
843
  })
@@ -4,6 +4,9 @@ import { currentBibliography, onBibliography } from '../runtime/citations.js'
4
4
  // <bibliography /> on a slide: the deck's reference list, the works it cites
5
5
  // plus `nocite`, in the citation style's order. A long list goes over several
6
6
  // slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
7
+ // `keys="a b"` lists only these works; in a deck that includes another, that
8
+ // deck's own list gets the keys it cites (build/include.js), so it shows what
9
+ // it shows alone.
7
10
 
8
11
  function slice(order, part) {
9
12
  const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
@@ -14,10 +17,12 @@ function slice(order, part) {
14
17
  return order.slice((index - 1) * size, index * size)
15
18
  }
16
19
 
17
- export default function Bibliography({ part }) {
20
+ export default function Bibliography({ part, keys }) {
18
21
  const [bib, setBib] = useState(currentBibliography)
19
22
  useEffect(() => onBibliography(setBib), [])
20
23
  if (!bib) return <p class="bibliography-hint">Name a reference file in the deck settings, e.g. <code>bibliography: refs.bib</code>.</p>
21
- const html = slice(bib.order, part).map(id => bib.entries[id]).join('')
24
+ const only = keys == null ? null : new Set(String(keys).split(/[\s,]+/).map(key => key.replace(/^@/, '')).filter(Boolean))
25
+ const order = only ? bib.order.filter(id => only.has(id)) : bib.order
26
+ const html = slice(order, part).map(id => bib.entries[id]).join('')
22
27
  return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
23
28
  }
@@ -7,8 +7,8 @@ import { pollChoices } from '../core/pollList.js'
7
7
  import './poll.css'
8
8
 
9
9
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
10
- // On the slide: live bars, the number of answers and the deck's join code
11
- // (`qr="false"` leaves it out, when the code was on an earlier slide).
10
+ // On the slide: live bars and the number of answers; the deck's join code
11
+ // beside them only with `qr` (the presenter shows it over the slide, J).
12
12
  // On the phones, the server's answer page shows one button per option,
13
13
  // as described by Poll.phone. Each device has one vote and can change it; the
14
14
  // latest one counts.
@@ -7,8 +7,8 @@ import './poll.css'
7
7
 
8
8
  // <qrcode url="https://example.com" size="240" /> on a slide.
9
9
  // <qrcode join />: the deck's join link for audience questions, with the
10
- // short link below it, so a slide early in the talk can invite everyone once
11
- // and the questions after it can leave out their own code (`qr="false"`).
10
+ // short link below it, for a slide that invites everyone (the presenter view
11
+ // can also put it over any slide, J).
12
12
  // <qrcode follow />: the link for following the slides on a phone or laptop.
13
13
 
14
14
  function JoinQr({ size }) {
@@ -169,7 +169,12 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
169
169
  >
170
170
  {url ? (
171
171
  iframeSrc
172
- ? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen
172
+ // A deck served through a server (an iPad paired over the internet)
173
+ // comes with "Referrer-Policy: no-referrer", so pairing links never
174
+ // leak; YouTube refuses to play an embed that names no page
175
+ // ("This video is unavailable"). The player alone sends the
176
+ // deck's origin, never its path.
177
+ ? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen referrerpolicy="strict-origin"
173
178
  style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
174
179
  : <div class="video-player-placeholder" />
175
180
  ) : (
@@ -1,15 +1,15 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  import cloud from 'd3-cloud'
4
- import { useRoom } from '../live/client.js'
4
+ import { useRoom, latestByDevice } from '../live/client.js'
5
5
  import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
6
6
  import { phoneQuestion } from './inlineText.js'
7
7
  import { t } from '../core/labels.js'
8
8
  import './poll.css'
9
9
 
10
10
  // <wordcloud room="mood" question="One word for today?" />
11
- // Phones send a word or two, as often as they like. The slide packs every
12
- // answer into a cloud around its middle: the more often it was sent, the
11
+ // Each phone sends a word or two; a new one replaces its earlier word. The
12
+ // slide packs the answers into a cloud around its middle: the more often it was sent, the
13
13
  // larger; some stand upright. Answers that differ only in case or spacing
14
14
  // count together. `height` is the cloud's height in slide pixels (520),
15
15
  // `limit` the most words shown (60).
@@ -66,7 +66,7 @@ function layout(words, { width, height, font }) {
66
66
  export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
67
67
  const live = useRoom(room)
68
68
  const controls = useControls(room, live)
69
- const all = countWords(controls.counted(live.messages))
69
+ const all = countWords(latestByDevice(controls.counted(live.messages)))
70
70
  const words = all.slice(0, Math.max(1, Number(limit) || 60))
71
71
  const total = all.reduce((sum, word) => sum + word.n, 0)
72
72
  const cloudHeight = Math.max(200, Number(height) || 520)
@@ -99,7 +99,7 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
99
99
  </svg>
100
100
  : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
101
101
  </div>
102
- <ActivityFooter count={total} {...live} reset={controls.reset} closed={controls.closed}>
102
+ <ActivityFooter count={total} {...live} perPhone reset={controls.reset} closed={controls.closed}>
103
103
  <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
104
104
  </ActivityFooter>
105
105
  </div>
@@ -107,4 +107,5 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
107
107
  </div>
108
108
  }
109
109
 
110
- WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40 })
110
+ // `once`: the phone's latest word counts (a server before 3.12 ignores it and lets phones send more).
111
+ WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40, once: true })
@@ -12,9 +12,10 @@ import 'katex/dist/katex.min.css'
12
12
  // the number of answers, the live dot, Reset and the presenter's controls.
13
13
 
14
14
  export const shortLink = url => url.replace(/^https?:\/\//, '')
15
- // `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
16
- // slide (<qrcode join />), and people's phones follow along.
17
- export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
15
+ // An activity shows no join code of its own: the presenter puts it over the
16
+ // slide when it is wanted (J), and phones that joined follow along. `qr` (or
17
+ // `qr="true"`) still puts the code beside the question.
18
+ export const wantsQr = qr => qr != null && !/^(false|no|off|0)$/i.test(String(qr))
18
19
  // A switch such as `multiple`: on when given, unless it says false.
19
20
  export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
20
21
 
@@ -86,11 +87,13 @@ export function useControls(room, live, { results = true, solution = null } = {}
86
87
  * A record of the talk (`offline`) can still show the answer and results.
87
88
  */
88
89
  export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
89
- const button = (name, on, label, onClick, icon) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} onClick={onClick}>{icon}</button>
90
+ // data-presenter-control: the phone clicker offers them too (runtime/slideControls.js).
91
+ const button = (name, on, label, onClick, icon, iconName) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} data-presenter-control="" data-presenter-icon={iconName} onClick={onClick}>{icon}</button>
92
+ const lock = controls.closed ? 'lock' : 'lock-open', eye = controls.results ? 'eye' : 'eye-off'
90
93
  return <>
91
- {!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={controls.closed ? 'lock' : 'lock-open'} size={22} />)}
92
- {results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={controls.results ? 'eye' : 'eye-off'} size={24} />)}
93
- {solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>)}
94
+ {!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={lock} size={22} />, lock)}
95
+ {results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={eye} size={24} />, eye)}
96
+ {solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>, 'check')}
94
97
  </>
95
98
  }
96
99
 
@@ -110,7 +113,7 @@ export function ActivityFooter({ count, connected, canReset, reset, offline, pre
110
113
  {of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
111
114
  {closed && <span class="poll-closed">{t('poll.closed')}</span>}
112
115
  <span class="poll-spacer" />
113
- {canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
116
+ {canReset && <button class="poll-reset" disabled={count === 0 && !closed} data-presenter-control="" data-presenter-icon="reset" aria-label={t('poll.reset')} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
114
117
  {children}
115
118
  </p>
116
119
  }
@@ -31,6 +31,8 @@ deck-stage[data-follower] .poll-reset { display: none; }
31
31
  .poll-control:hover, .poll-control:focus-visible { opacity: 1; }
32
32
  .poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
33
33
  deck-stage[data-follower] .poll-control { display: none; }
34
+ /* The presenter view has them in its toolbar (runtime/PresenterChrome.jsx). */
35
+ deck-stage[data-presenter] :is(.poll-control, .poll-reset) { display: none; }
34
36
  /* Closed: no more answers count. */
35
37
  .poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
36
38
  /* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
@@ -4,7 +4,8 @@
4
4
  // identical; callers reparse the result.
5
5
  import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
6
6
  import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
7
- import { openTagsIn } from './tags.js'
7
+ import { openTagsIn, tagsIn, closingTagOf } from './tags.js'
8
+ import { planFor, formatTime } from './timing.js'
8
9
 
9
10
  const REGION_RE = /^[a-z][a-z0-9-]*$/
10
11
 
@@ -247,11 +248,27 @@ export function hasActivity(slide) {
247
248
  // to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
248
249
  // that are gone, so saved drawings can follow their slides.
249
250
  export function stripActivities(source) {
251
+ return shareSlides(source, { activities: true })
252
+ }
253
+
254
+ // The slides a shared build keeps: those numbered in `slides` (1-based, as
255
+ // written; null for all) and, with `activities`, none with a live activity.
256
+ // A deck of nothing but activities keeps them rather than becoming empty. A
257
+ // <style> on a slide left out styles the whole deck, so it moves to the
258
+ // first kept slide. The deck's `time` becomes what the kept slides were
259
+ // planned to take, so each keeps its share. Returns { source, ids, removed }
260
+ // as stripActivities.
261
+ export function shareSlides(source, { activities = false, slides = null } = {}) {
250
262
  const deck = parseSlides(source)
251
- const dropped = deck.slides.filter(hasActivity)
252
263
  const same = () => ({ source, ids: Object.fromEntries(deck.slides.map(slide => [slide.id, slide.id])), removed: [] })
253
- // A deck of nothing but activities stays as it is rather than becoming empty.
254
- if (!dropped.length || dropped.length === deck.slides.length) return same()
264
+ if (slides) {
265
+ const beyond = slides.filter(n => n > deck.slides.length)
266
+ if (beyond.length) throw new Error(`The deck has ${deck.slides.length} slides; there is no slide ${beyond.join(', ')}`)
267
+ }
268
+ let chosen = deck.slides.filter((slide, i) => !slides || slides.includes(i + 1))
269
+ if (activities && !chosen.every(hasActivity)) chosen = chosen.filter(slide => !hasActivity(slide))
270
+ const dropped = deck.slides.filter(slide => !chosen.includes(slide))
271
+ if (!dropped.length) return same()
255
272
  const ranges = dropped.map(slide => {
256
273
  const bounds = slideBounds(deck, slide.id)
257
274
  const next = deck.slides[deck.slides.indexOf(slide) + 1]
@@ -266,8 +283,28 @@ export function stripActivities(source) {
266
283
  if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
267
284
  else merged.push({ ...range })
268
285
  }
269
- const next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
286
+ let next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
287
+ const styles = dropped.flatMap(slide => styleBlocks(deck.source.slice(slide.source.start, slide.source.end)))
288
+ if (styles.length) {
289
+ const kept = parseSlides(next)
290
+ const nl = detectNewline(next)
291
+ next = appendBlock(kept, kept.slides[0], styles.join(nl) + nl)
292
+ }
293
+ if (deck.deckConfig.time != null) {
294
+ const plan = planFor(deck.slides, deck.deckConfig)
295
+ const kept = chosen.reduce((sum, slide) => sum + plan.perSlide[deck.slides.indexOf(slide)], 0)
296
+ if (plan.budget) next = setDeckConfig(parseSlides(next), { time: kept ? formatTime(kept) : undefined })
297
+ }
270
298
  const after = parseSlides(next).slides
271
- const kept = deck.slides.filter(slide => !dropped.includes(slide))
272
- return { source: next, ids: Object.fromEntries(kept.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
299
+ return { source: next, ids: Object.fromEntries(chosen.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
300
+ }
301
+
302
+ // The <style> elements in a slide's text, found as the slide finds them (not in code).
303
+ function styleBlocks(text) {
304
+ const tags = tagsIn(text)
305
+ return tags.flatMap((tag, at) => {
306
+ if (tag.name !== 'style' || tag.closing || tag.start == null) return []
307
+ const close = closingTagOf(tags, at)
308
+ return close?.end != null ? [text.slice(tag.start, close.end)] : []
309
+ })
273
310
  }