mdeck 3.11.0 → 3.12.1

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 (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +2 -0
  4. package/docs/reference/authoring.md +48 -7
  5. package/docs/site/content/appearance.md +1 -1
  6. package/docs/site/content/audience.md +36 -8
  7. package/docs/site/content/commands.md +4 -2
  8. package/docs/site/content/drawing.md +4 -4
  9. package/docs/site/content/getting-started.md +2 -32
  10. package/docs/site/content/install.md +3 -1
  11. package/docs/site/content/presenting.md +29 -8
  12. package/docs/site/content/writing.md +27 -0
  13. package/examples/showcase/slides.ar.md +5 -5
  14. package/examples/showcase/slides.de.md +5 -5
  15. package/examples/showcase/slides.de.results.json +27 -0
  16. package/examples/showcase/slides.en.md +5 -5
  17. package/package.json +1 -1
  18. package/skills/write-slides/SKILL.md +4 -1
  19. package/src/build/check.js +27 -6
  20. package/src/build/components.js +5 -3
  21. package/src/build/homePlugin.js +64 -14
  22. package/src/build/include.js +249 -0
  23. package/src/build/inkPlugin.js +12 -2
  24. package/src/build/relayAccess.js +3 -1
  25. package/src/build/renderCheck.js +23 -3
  26. package/src/build/slidesPlugin.js +64 -16
  27. package/src/build/timingPlugin.js +34 -18
  28. package/src/cli/args.js +3 -3
  29. package/src/cli/index.js +49 -10
  30. package/src/components/Bibliography.jsx +7 -2
  31. package/src/components/Poll.jsx +2 -2
  32. package/src/components/QrCodeTag.jsx +2 -2
  33. package/src/components/WordCloud.jsx +7 -6
  34. package/src/components/activity.jsx +11 -8
  35. package/src/components/poll.css +2 -0
  36. package/src/core/icons.js +6 -1
  37. package/src/core/labels.js +3 -0
  38. package/src/core/parseSlides.js +14 -4
  39. package/src/core/reactions.js +27 -0
  40. package/src/core/resultsReport.js +153 -0
  41. package/src/core/source.js +8 -1
  42. package/src/core/validateDeck.js +6 -2
  43. package/src/home/Home.jsx +38 -93
  44. package/src/home/home.css +5 -17
  45. package/src/layouts/renderSlide.jsx +13 -1
  46. package/src/live/answer/answer.css +12 -1
  47. package/src/live/answer/answer.js +32 -4
  48. package/src/live/client.js +23 -4
  49. package/src/live/rooms.js +29 -7
  50. package/src/live/server.js +15 -1
  51. package/src/runtime/KeyHelp.jsx +115 -0
  52. package/src/runtime/PresenterChrome.jsx +168 -0
  53. package/src/runtime/Reactions.jsx +56 -0
  54. package/src/runtime/deck-stage.js +322 -17
  55. package/src/runtime/helpKeys.js +75 -0
  56. package/src/runtime/ink/room.js +6 -6
  57. package/src/runtime/main.jsx +396 -303
  58. package/src/runtime/slideControls.js +60 -0
  59. package/src/runtime/useTalkTimer.js +4 -2
package/src/cli/index.js CHANGED
@@ -13,6 +13,7 @@ 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'
@@ -30,7 +31,8 @@ import { parseSlides } from '../core/parseSlides.js'
30
31
  import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
31
32
  import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
32
33
  import { assertDrawings } from '../build/drawings.js'
33
- 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'
34
36
  import { createRelayAccess } from '../build/relayAccess.js'
35
37
  import { sessionCode } from '../live/code.js'
36
38
  import { migrateDeck } from './migrate.js'
@@ -59,6 +61,14 @@ function hasFlag(name, short = null) { return !!(parsed.options[name] ?? parsed.
59
61
  const positionals = () => parsed.positionals
60
62
  const outputOption = () => parsed.options['--output'] ?? null
61
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
+ }
62
72
  const portOption = () => parsed.options['--port'] ?? null
63
73
  // --slide 3,5-7 for build, send and pdf: only these slides, numbered as written.
64
74
  function slideOption(input) {
@@ -196,7 +206,8 @@ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripAc
196
206
  const absSlides = resolve(slidesPath)
197
207
  const deckDir = dirname(absSlides)
198
208
  const absOutDir = resolve(outDir)
199
- const raw = readFileSync(absSlides, 'utf-8')
209
+ // The deck as shown: pictures of included slides are copied too.
210
+ const raw = expandedSource(absSlides)
200
211
  let markdown = stripNotes ? stripNotesFrom(raw) : raw
201
212
  if (stripActivities || slides) markdown = shareSlides(markdown, { activities: stripActivities, slides }).source
202
213
 
@@ -323,7 +334,10 @@ const HELP = `
323
334
  --network reach the slides from phones and tablets on the same network
324
335
  --server [address] present from an iPad on any network, through your server
325
336
  (the address, or the deck's server setting, or MDECK_SERVER;
326
- 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
327
341
  --no-open --port <number>
328
342
  ${c.green}mdeck rehearse${c.reset} [slides.md] Like run, but opens the presenter view rehearsing: it times
329
343
  each slide and saves the times into the deck (time: 1m30s)
@@ -361,7 +375,9 @@ const HELP = `
361
375
  --slide <numbers> only these slides: 3, 3,5 or 2-4
362
376
  --sheet one picture of them all in a grid, each under its number and heading
363
377
  --dark | --light in this appearance instead of the deck's own -o <folder>
364
- ${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)
365
381
  ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
366
382
  List built-in and deck-local layouts, themes and palettes
367
383
  ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
@@ -375,6 +391,7 @@ const HELP = `
375
391
  through it (MDECK_SERVER_KEY); put a web server in front
376
392
 
377
393
  Commands that take ${c.bold}slides.md${c.reset} use the one in the current folder when you leave it out.
394
+ ${c.green}mdeck --version${c.reset} shows which mdeck this is.
378
395
 
379
396
  ${c.dim}Install the${c.reset} ${c.bold}mdeck${c.reset} ${c.dim}command globally:${c.reset}
380
397
  npm link
@@ -386,6 +403,11 @@ const HELP = `
386
403
  // ── Argument parsing ──────────────────────────────────────────────────────────
387
404
  const [command, ...argv] = process.argv.slice(2)
388
405
 
406
+ if (command === '--version' || command === '-v' || command === 'version') {
407
+ console.log(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version)
408
+ process.exit(0)
409
+ }
410
+
389
411
  if (!command || command === '--help' || command === '-h') {
390
412
  console.log(HELP)
391
413
  process.exit(0)
@@ -491,11 +513,18 @@ if (command === 'new') {
491
513
  const input = requireInput('results')
492
514
  const file = resultsFileFor(resolve(input))
493
515
  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) }
494
- let csv
495
- try { csv = resultsCsv(normalizeResults(JSON.parse(readFileSync(file, 'utf8')))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
516
+ let results
517
+ try { results = normalizeResults(JSON.parse(readFileSync(file, 'utf8'))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
518
+ // Read against the deck as shown, included slides too, for the slide and the question.
519
+ let deck
520
+ try { deck = parseSlides(expandedSource(resolve(input))) } catch { deck = parseSlides(readFileSync(resolve(input), 'utf8')) }
521
+ const report = resultsReport(results, deck)
496
522
  const out = outputOption()
497
- if (out) { writeFileSync(out, csv); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
498
- else process.stdout.write(csv)
523
+ const json = hasFlag('--json') || /\.json$/i.test(out ?? '')
524
+ const text = json ? reportJson(report, { title: deck.deckConfig?.meta?.title ?? basename(input).replace(/\.md$/i, '') })
525
+ : hasFlag('--summary') ? reportSummaryCsv(report) : reportAnswersCsv(report)
526
+ if (out) { writeFileSync(out, text); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
527
+ else process.stdout.write(text)
499
528
  } else if (command === 'check') {
500
529
  const input = requireInput('check')
501
530
  const registry = registryFor(input)
@@ -565,8 +594,18 @@ if (command === 'new') {
565
594
  // (src/build/tunnelClient.js). The dev server then serves under /t/<id>/, the
566
595
  // address the iPad uses on the server, and its own addresses follow it. The
567
596
  // address is the flag's value, else the deck's server setting, else MDECK_SERVER.
597
+ // With MDECK_SERVER and MDECK_SERVER_KEY set, the server is used without
598
+ // --server when it answers; otherwise this computer presents alone.
599
+ // --no-server keeps it out.
568
600
  let relay = null
569
- const serverFlag = serverOption()
601
+ let serverFlag = serverOption()
602
+ if (serverFlag === undefined && !hasFlag('--no-server') && process.env.MDECK_SERVER && process.env.MDECK_SERVER_KEY) {
603
+ const address = process.env.MDECK_SERVER
604
+ if (await serverAnswers(address)) {
605
+ serverFlag = address
606
+ tip(`Using your server from MDECK_SERVER (${address}); --no-server presents without it.`)
607
+ } else tip(`Your server (MDECK_SERVER, ${address}) does not answer: presenting from this computer only.`)
608
+ }
570
609
  if (serverFlag !== undefined) {
571
610
  const inDeck = parseSlides(readFileSync(abs, 'utf8')).deckConfig?.server
572
611
  const address = serverFlag ?? (typeof inDeck === 'string' ? inDeck : null) ?? process.env.MDECK_SERVER ?? null
@@ -644,7 +683,7 @@ if (command === 'new') {
644
683
  // Phones in the room follow the talk (the reader view would show slides ahead).
645
684
  if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
646
685
  console.log()
647
- if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
686
+ if (relay) tip(`iPad: in the presenter view, click the connection icons and choose "iPad" or "Phone clicker"; it opens ${relay.url}`)
648
687
  ok(`Watching ${c.cyan}${abs}${c.reset}`)
649
688
  tip('Edit and save to reload.\n')
650
689
 
@@ -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 }) {
@@ -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. */
package/src/core/icons.js CHANGED
@@ -50,11 +50,14 @@ export const ICONS = {
50
50
  highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
51
51
  marker: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>', // sparkles
52
52
  select: '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>', // square-dashed
53
- laser: '<line x1="2" x2="5" y1="12" y2="12"/><line x1="19" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="5"/><line x1="12" x2="12" y1="19" y2="22"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/>', // locate-fixed
53
+ laser: '<rect width="5" height="13" x="3.5" y="9.5" rx="2.5" transform="rotate(45 6 16)"/><path d="m11.5 12.5 3-3" stroke-dasharray="0.1 2.6"/><circle cx="17.5" cy="6.5" r="3"/><path d="M17.5 1v0.5"/><path d="M23 6.5h-.5"/><path d="m21.4 2.6-.35.35"/>', // drawn for mdeck on Lucide's grid: a laser pointer (Lucide has none; its "locate" read as a map pin)
54
54
  fold: '<path d="m7 7 10 10"/><path d="M17 7v10H7"/>', // arrow-down-right
55
55
  eraser: '<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/>', // eraser
56
+ join: '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>', // qr-code
57
+ settings: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>', // sliders-horizontal
56
58
  people: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>', // users
57
59
  tablet: '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>', // tablet
60
+ phone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', // smartphone
58
61
  speaker: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>', // panel-right
59
62
  fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', // maximize
60
63
  sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', // sun
@@ -63,6 +66,8 @@ export const ICONS = {
63
66
  play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
64
67
  hotspots: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/>', // scan-search
65
68
  rehearse: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>', // timer
69
+ blank: '<path d="M17 17H4a2 2 0 0 1-2-2V5c0-1.5 1-2 1-2"/><path d="M22 15V5a2 2 0 0 0-2-2H9"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m2 2 20 20"/>', // monitor-off
70
+ overview: '<rect width="18" height="14" x="3" y="3" rx="2"/><path d="M4 21h1"/><path d="M9 21h1"/><path d="M14 21h1"/><path d="M19 21h1"/>', // gallery-horizontal-end
66
71
  pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
67
72
  loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
68
73
  }
@@ -51,6 +51,7 @@ export const LABELS = {
51
51
  'respond.waiting': 'The next question will appear here.',
52
52
  'respond.send': 'Send',
53
53
  'respond.sent': 'Sent. Thank you!',
54
+ 'respond.react': 'React',
54
55
  'join.scan': 'Scan to join',
55
56
  'follow.scan': 'Scan to follow the slides',
56
57
  'follow.open': 'Follow the slides',
@@ -124,6 +125,7 @@ export const LABELS = {
124
125
  'respond.waiting': 'Die nächste Frage erscheint hier.',
125
126
  'respond.send': 'Senden',
126
127
  'respond.sent': 'Gesendet. Danke!',
128
+ 'respond.react': 'Reagieren',
127
129
  'join.scan': 'Scannen und mitmachen',
128
130
  'follow.scan': 'Scannen und Folien mitverfolgen',
129
131
  'follow.open': 'Folien mitverfolgen',
@@ -197,6 +199,7 @@ export const LABELS = {
197
199
  'respond.waiting': 'سيظهر السؤال التالي هنا.',
198
200
  'respond.send': 'إرسال',
199
201
  'respond.sent': 'أُرسلت. شكرًا لك!',
202
+ 'respond.react': 'تفاعل',
200
203
  'join.scan': 'امسح الرمز للانضمام',
201
204
  'follow.scan': 'امسح الرمز لمتابعة الشرائح',
202
205
  'follow.open': 'متابعة الشرائح',
@@ -1,8 +1,8 @@
1
1
  import yaml from 'js-yaml'
2
- import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
2
+ import { sourceLines, fenceState, scanDirectives, diagnostic, isIncludeLine } from './source.js'
3
3
  export { sourceLines, fenceState } from './source.js'
4
4
 
5
- export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time'.split(' '))
5
+ export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time reactions'.split(' '))
6
6
  export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots time'.split(' '))
7
7
 
8
8
  // Names from before 2.0. They are still recognised, so that an old deck gets a
@@ -32,7 +32,7 @@ function segmentsOf(source) {
32
32
  const before = fence
33
33
  fence = fenceState(line.text, fence)
34
34
  if (before || fence) continue
35
- if (/^:::\s*[\w-]+/.test(line.text)) depth++
35
+ if (/^:::\s*[\w-]+/.test(line.text) && !isIncludeLine(line.text)) depth++
36
36
  else if (/^:::\s*$/.test(line.text)) depth = Math.max(0, depth - 1)
37
37
  if (!depth && /^---[ \t]*$/.test(line.text)) {
38
38
  segments.push({ start, end: line.start, delimiterEnd: line.end })
@@ -103,6 +103,15 @@ function extractBody(source, body, diagnostics) {
103
103
  return { content, notes: notes.join('\n\n'), regions, blocks }
104
104
  }
105
105
 
106
+ // What a slide that is nothing but `:::include <file> [slides=3-7]` includes:
107
+ // { file, slides } (slides as written, or null), or null for any other slide.
108
+ export function includeOf(content) {
109
+ const match = /^:::\s*include\s+(?:"([^"]+)"|(\S+))((?:\s+[\w-]+=\S+)*)\s*$/.exec(String(content ?? '').trim())
110
+ if (!match) return null
111
+ const options = Object.fromEntries([...match[3].matchAll(/([\w-]+)=(\S+)/g)].map(([, key, value]) => [key, value]))
112
+ return { file: match[1] ?? match[2], slides: options.slides ?? null, options }
113
+ }
114
+
106
115
  export function parseSlides(source) {
107
116
  const segments = segmentsOf(source)
108
117
  const diagnostics = []
@@ -150,9 +159,10 @@ export function parseSlides(source) {
150
159
  // source editing and validation, separate from the resolved block notes.
151
160
  delete meta.notes
152
161
  if (notes) meta.notes = notes
162
+ const include = Object.keys(authoredMeta).length ? null : includeOf(content)
153
163
  slides.push({ id: meta.id ?? `slide-${slides.length + 1}`, meta, authoredMeta, content, regions, blocks,
154
164
  source: { start: segment.start, end: body?.end ?? segment.end },
155
- metaSource, bodySource: body ? { start: body.start, end: body.end } : null })
165
+ metaSource, bodySource: body ? { start: body.start, end: body.end } : null, ...(include ? { include } : {}) })
156
166
  }
157
167
  let currentSection = null
158
168
  for (const slide of slides) {
@@ -0,0 +1,27 @@
1
+ // Reactions: people tap an emoji on the answer page, and it floats up on the
2
+ // presenter's screen for a moment, never on the projector. A deck turns them
3
+ // on with `reactions: true` (the set below) or its own list of up to eight,
4
+ // such as `reactions: [👍, 🤔, 🐢]`. They go to the session's `reactions`
5
+ // room, which the server passes on without keeping (src/live/rooms.js).
6
+
7
+ export const REACTIONS_ROOM = 'reactions'
8
+ // 🐢: "slower, please".
9
+ export const DEFAULT_REACTIONS = ['👍', '👏', '❤️', '😮', '🤔', '🐢']
10
+ const MAX = 8
11
+ const MAX_LENGTH = 16
12
+
13
+ /** Whether `value` is a usable `reactions` setting; a problem as text, or null. */
14
+ export function reactionsProblem(value) {
15
+ if (value == null || typeof value === 'boolean') return null
16
+ if (!Array.isArray(value) || !value.length || value.length > MAX) return `reactions must be true, false, or a list of 1 to ${MAX} emoji or short words`
17
+ const bad = value.find(item => typeof item !== 'string' && typeof item !== 'number' || !String(item).trim() || String(item).trim().length > MAX_LENGTH)
18
+ return bad !== undefined ? `reactions: each one is an emoji or a word of up to ${MAX_LENGTH} characters, not ${JSON.stringify(bad)}` : null
19
+ }
20
+
21
+ /** The reactions a deck offers, or null when it offers none. */
22
+ export function reactionsOf(config) {
23
+ const value = config?.reactions
24
+ if (value === true) return DEFAULT_REACTIONS
25
+ if (reactionsProblem(value) || !Array.isArray(value)) return null
26
+ return [...new Set(value.map(item => String(item).trim()))]
27
+ }
@@ -0,0 +1,153 @@
1
+ // The answers kept in a talk (<deck>.results.json, results.js) read against
2
+ // the deck they were given in: which slide asked, what, and how it came out.
3
+ // `mdeck results` and the launch page export them from here, as the answers
4
+ // one by one (CSV), one row per option, word or number (summary CSV), or
5
+ // all of it (JSON).
6
+ //
7
+ // The summary counts as the slide does: for a poll, a scale, a number and a
8
+ // word cloud, each phone's latest answer; for open questions, every answer. Phones stay numbered per question; no device ids are kept.
9
+ import { tagsIn } from './tags.js'
10
+ import { expandPollLists, pollChoices } from './pollList.js'
11
+ import { choicesOf } from '../components/inlineText.js'
12
+ import { parseNumber, groupNumbers } from '../components/numbers.js'
13
+
14
+ const KINDS = { poll: 'poll', scale: 'scale', numeric: 'number', wordcloud: 'words', question: 'question' }
15
+
16
+ const plain = text => String(text ?? '').replace(/\$([^$]+)\$/g, '$1').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
17
+
18
+ function headingOf(slide) {
19
+ const heading = String(slide?.content ?? '').replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]
20
+ return heading ? plain(heading) : plain(slide?.meta?.title ?? '')
21
+ }
22
+
23
+ const scaleBounds = ({ min, max }) => {
24
+ const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
25
+ const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
26
+ return high > low && high - low <= 10 ? [low, high] : [1, 5]
27
+ }
28
+
29
+ /**
30
+ * The activities of a parsed deck, in order: [{ room, slide, kind, tag,
31
+ * question, options?, multiple?, min?, max? }]. `slide` counts from 1; a
32
+ * room asked on several slides is listed where it is first asked.
33
+ */
34
+ export function activitiesIn(deck) {
35
+ const found = new Map()
36
+ ;(deck?.slides ?? []).forEach((slide, i) => {
37
+ const texts = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
38
+ for (const text of texts) {
39
+ for (const tag of tagsIn(expandPollLists(text))) {
40
+ const room = tag.attrs.room
41
+ if (tag.closing || !room || found.has(room)) continue
42
+ const kind = KINDS[tag.name] ?? tag.name
43
+ const entry = { room, slide: i + 1, kind, tag: tag.name, question: plain(tag.attrs.question) || headingOf(slide) }
44
+ if (tag.name === 'poll') {
45
+ const listed = tag.attrs['data-choices'] != null && pollChoices(tag.attrs['data-choices'])
46
+ entry.options = listed ? listed.map(choice => choice.text) : choicesOf(tag.attrs.options)
47
+ if (tag.attrs.multiple != null && tag.attrs.multiple !== 'false') entry.multiple = true
48
+ }
49
+ if (tag.name === 'scale') [entry.min, entry.max] = scaleBounds(tag.attrs)
50
+ found.set(room, entry)
51
+ }
52
+ }
53
+ })
54
+ return [...found.values()]
55
+ }
56
+
57
+ // Each phone's last answer, in the order the phones first answered.
58
+ function latestPerPhone(answers) {
59
+ const latest = new Map()
60
+ for (const answer of answers) latest.set(answer.phone, answer)
61
+ return [...latest.values()]
62
+ }
63
+
64
+ const round = value => Math.round(value * 100) / 100
65
+ function stats(values) {
66
+ if (!values.length) return {}
67
+ const sorted = [...values].sort((a, b) => a - b)
68
+ const middle = sorted.length >> 1
69
+ const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
70
+ return { average: round(values.reduce((sum, value) => sum + value, 0) / values.length), median: round(median), lowest: sorted[0], highest: sorted.at(-1) }
71
+ }
72
+
73
+ /** How one activity came out: { phones, answers, counts: [{ answer, count }], average?, median?, lowest?, highest? }. */
74
+ export function summarize(activity, answers = []) {
75
+ const phones = new Set(answers.map(answer => answer.phone)).size
76
+ const base = { phones, answers: answers.length }
77
+ const tag = activity?.tag
78
+ if (tag === 'poll') {
79
+ const options = activity.options ?? []
80
+ const votes = latestPerPhone(answers)
81
+ .map(answer => activity.multiple ? (Array.isArray(answer.value) ? answer.value : [answer.value]) : (Array.isArray(answer.value) ? [] : [answer.value]))
82
+ .map(picks => picks.filter(pick => options.includes(pick)))
83
+ .filter(picks => picks.length)
84
+ return { ...base, voted: votes.length, counts: options.map(option => ({ answer: option, count: votes.filter(picks => picks.includes(option)).length })) }
85
+ }
86
+ if (tag === 'scale') {
87
+ const steps = Array.from({ length: activity.max - activity.min + 1 }, (_, i) => activity.min + i)
88
+ const values = latestPerPhone(answers).map(answer => Number(answer.value)).filter(value => steps.includes(value))
89
+ return { ...base, voted: values.length, counts: steps.map(step => ({ answer: String(step), count: values.filter(value => value === step).length })), ...stats(values) }
90
+ }
91
+ if (tag === 'numeric') {
92
+ const texts = latestPerPhone(answers).map(answer => answer.value).filter(value => parseNumber(value) != null)
93
+ return { ...base, voted: texts.length, counts: groupNumbers(texts).map(group => ({ answer: group.text, count: group.n })), ...stats(texts.map(parseNumber)) }
94
+ }
95
+ // Words (each phone's latest) and open answers (every one), the same words together.
96
+ const counts = new Map()
97
+ for (const answer of tag === 'wordcloud' ? latestPerPhone(answers) : answers) {
98
+ const text = String(answer.value ?? '').trim().replace(/\s+/g, ' ')
99
+ if (!text) continue
100
+ const key = text.toLocaleLowerCase()
101
+ const entry = counts.get(key) ?? { answer: text, count: 0 }
102
+ entry.count++
103
+ counts.set(key, entry)
104
+ }
105
+ return { ...base, counts: [...counts.values()].sort((a, b) => b.count - a.count || a.answer.localeCompare(b.answer)) }
106
+ }
107
+
108
+ /**
109
+ * The kept answers against the deck: [{ room, slide, kind, question,
110
+ * closed, savedAt, summary, answers }], in the order of the slides; rooms
111
+ * the deck no longer asks come last, with what is known of them.
112
+ */
113
+ export function resultsReport(results, deck) {
114
+ const rooms = results?.rooms ?? {}
115
+ const activities = activitiesIn(deck)
116
+ const asked = activities.filter(activity => rooms[activity.room])
117
+ const gone = Object.keys(rooms).filter(room => !activities.some(activity => activity.room === room)).map(room => ({ room, slide: null, kind: null, tag: null, question: '' }))
118
+ return [...asked, ...gone].map(activity => {
119
+ const entry = rooms[activity.room]
120
+ const { tag, options, multiple, min, max, ...shown } = activity
121
+ return { ...shown, closed: entry.closed, savedAt: entry.savedAt, summary: summarize(activity, entry.answers), answers: entry.answers }
122
+ })
123
+ }
124
+
125
+ const csvCell = value => {
126
+ const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
127
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
128
+ }
129
+ const csv = rows => rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
130
+
131
+ /** Every answer with its question: slide, room, kind, question, phone, time, answer (several picks joined by |). */
132
+ export function reportAnswersCsv(report) {
133
+ const rows = [['slide', 'room', 'kind', 'question', 'phone', 'at', 'answer']]
134
+ for (const entry of report) for (const answer of entry.answers) rows.push([entry.slide, entry.room, entry.kind, entry.question, answer.phone, answer.at, answer.value])
135
+ return csv(rows)
136
+ }
137
+
138
+ /** One row per option, step, number or word: slide, room, kind, question, answer, count, and its share of the phones that answered. */
139
+ export function reportSummaryCsv(report) {
140
+ const rows = [['slide', 'room', 'kind', 'question', 'answer', 'count', 'share']]
141
+ for (const entry of report) {
142
+ const { counts = [], voted } = entry.summary
143
+ const total = voted ?? counts.reduce((sum, row) => sum + row.count, 0)
144
+ for (const row of counts) rows.push([entry.slide, entry.room, entry.kind, entry.question, row.answer, row.count, total ? `${Math.round((row.count / total) * 100)}%` : ''])
145
+ for (const key of ['average', 'median']) if (entry.summary[key] != null) rows.push([entry.slide, entry.room, entry.kind, entry.question, key, entry.summary[key], ''])
146
+ }
147
+ return csv(rows)
148
+ }
149
+
150
+ /** All of it, for other programs: the deck's title, when, and each question with its summary and answers. */
151
+ export function reportJson(report, { title = '', exportedAt = new Date().toISOString() } = {}) {
152
+ return JSON.stringify({ version: 1, title, exportedAt, questions: report }, null, 2) + '\n'
153
+ }
@@ -24,6 +24,11 @@ export function diagnostic(source, code, message, offset = 0, severity = 'error'
24
24
  }
25
25
 
26
26
  // Balanced directive scanner, shared by the document parser and Markdown lexer.
27
+ // `:::include ./part.md`, alone on a slide, stands for the slides of another
28
+ // deck file (build/include.js puts them in its place). Without a path, `:::include`
29
+ // opens a block as any other name does (a callout of the deck's own).
30
+ export const isIncludeLine = text => /^:::\s*include\s+\S/.test(text)
31
+
27
32
  export function scanDirectives(source) {
28
33
  const nodes = []
29
34
  const stack = []
@@ -38,7 +43,9 @@ export function scanDirectives(source) {
38
43
  const node = { name: open[1], argument: open[2].trim(), start: line.start, bodyStart: line.end, children: [] }
39
44
  if (stack.length) stack.at(-1).children.push(node)
40
45
  else nodes.push(node)
41
- stack.push(node)
46
+ // :::include is one line: it has no body and no closing fence.
47
+ if (isIncludeLine(line.text)) Object.assign(node, { bodyEnd: line.end, end: line.end })
48
+ else stack.push(node)
42
49
  } else if (/^:::[ \t]*$/.test(line.text)) {
43
50
  const node = stack.pop()
44
51
  if (!node) diagnostics.push(diagnostic(source, 'unexpected-close', 'Directive closing fence has no opening fence', line.start))