mdeck 3.2.0 → 3.4.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 (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +33 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +89 -6
  8. package/docs/site/content/audience.md +41 -4
  9. package/docs/site/content/commands.md +30 -4
  10. package/docs/site/content/drawing.md +1 -1
  11. package/docs/site/content/more-content.md +22 -0
  12. package/docs/site/content/sharing.md +1 -0
  13. package/docs/site/content/troubleshooting.md +1 -1
  14. package/docs/site/content/views.md +3 -1
  15. package/examples/citations/refs.bib +30 -0
  16. package/examples/citations/refs.yml +21 -0
  17. package/examples/citations/slides.md +43 -0
  18. package/examples/citations/slides.pdf +0 -0
  19. package/package.json +5 -1
  20. package/skills/write-slides/SKILL.md +7 -3
  21. package/src/build/bibliography.js +307 -0
  22. package/src/build/check.js +12 -0
  23. package/src/build/renderCheck.js +98 -0
  24. package/src/build/resultsPlugin.js +77 -0
  25. package/src/build/slidesPlugin.js +70 -7
  26. package/src/build/themeCheck.js +8 -5
  27. package/src/cli/args.js +22 -5
  28. package/src/cli/index.js +54 -12
  29. package/src/components/Bibliography.jsx +23 -0
  30. package/src/components/Numeric.jsx +59 -0
  31. package/src/components/Poll.jsx +59 -33
  32. package/src/components/QrCodeTag.jsx +11 -2
  33. package/src/components/Question.jsx +10 -6
  34. package/src/components/Scale.jsx +13 -8
  35. package/src/components/VideoPlayer.jsx +16 -0
  36. package/src/components/WordCloud.jsx +10 -6
  37. package/src/components/activity.jsx +90 -9
  38. package/src/components/citation.css +28 -0
  39. package/src/components/inlineText.js +40 -0
  40. package/src/components/numbers.js +39 -0
  41. package/src/components/poll.css +27 -7
  42. package/src/core/citations.js +108 -0
  43. package/src/core/editDeck.js +43 -0
  44. package/src/core/icons.js +2 -0
  45. package/src/core/ink.js +16 -0
  46. package/src/core/labels.js +48 -0
  47. package/src/core/parseSlides.js +1 -1
  48. package/src/core/results.js +64 -0
  49. package/src/core/urls.js +2 -2
  50. package/src/core/validateDeck.js +7 -1
  51. package/src/editor/sampleDeck.js +34 -8
  52. package/src/layouts/layoutApi.jsx +14 -3
  53. package/src/live/answer/answer.css +20 -0
  54. package/src/live/answer/answer.js +149 -24
  55. package/src/live/client.js +75 -7
  56. package/src/live/roomTag.js +6 -2
  57. package/src/live/rooms.js +6 -1
  58. package/src/runtime/ReaderView.jsx +2 -1
  59. package/src/runtime/citations.js +70 -0
  60. package/src/runtime/deck-stage.js +5 -2
  61. package/src/runtime/editorBridge.js +7 -1
  62. package/src/runtime/follow.js +89 -0
  63. package/src/runtime/ink/bus.js +7 -6
  64. package/src/runtime/ink/room.js +8 -0
  65. package/src/runtime/ink/select.js +4 -2
  66. package/src/runtime/main.jsx +22 -8
  67. package/src/runtime/registry.jsx +4 -0
@@ -0,0 +1,98 @@
1
+ // A deck as it shows, in a real browser: `mdeck check --render` finds slides
2
+ // whose content does not fit, text cut off at the slide's edge or hard to
3
+ // read on what is behind it, and errors the page reports; `mdeck snapshot`
4
+ // saves pictures of slides, so a person or an assistant can look at them.
5
+ // Both build the deck as it is (with the extensions installed here), show
6
+ // every slide with all its steps revealed, as in a PDF, and need Chrome
7
+ // (MDECK_CHROME). The measuring is the theme check's (themeCheck.js).
8
+ import { execFileSync } from 'node:child_process'
9
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { basename, dirname, resolve } from 'node:path'
12
+ import { launchChrome } from './chrome.js'
13
+ import { frameworkRoot } from '../paths.js'
14
+ import { parseSlides } from '../core/parseSlides.js'
15
+ import { MEASURE, RECORDER, settle } from './themeCheck.js'
16
+
17
+ // Each slide's number, line and heading, for messages.
18
+ export function slideLabels(source) {
19
+ return parseSlides(source).slides.map((slide, index) => {
20
+ // The slide's first written line, past its settings and blank lines.
21
+ const body = slide.bodySource ?? slide.source ?? { start: 0, end: 0 }
22
+ let start = body.start
23
+ while (/\s/.test(source[start] ?? '') && start < body.end) start++
24
+ const line = source.slice(0, start).split('\n').length
25
+ const heading = /^#{1,2}\s+(.+)$/m.exec(slide.content ?? '')?.[1]?.replace(/[*_`]/g, '').trim() ?? null
26
+ return { number: index + 1, id: slide.id, line, heading }
27
+ })
28
+ }
29
+
30
+ // Builds the deck into a temporary folder and opens it in Chrome for
31
+ // `use({ page, count })`, at `appearance` ('' for the deck's own), with every
32
+ // step revealed.
33
+ async function withDeckPage(slidesPath, { appearance = '', width = 1600, height = 900 } = {}, use) {
34
+ const abs = resolve(slidesPath)
35
+ const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-render-'))
36
+ try {
37
+ try {
38
+ execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', abs, '-o', resolve(temp, 'out/index.html')], { cwd: dirname(abs), stdio: 'pipe' })
39
+ } catch (error) {
40
+ throw new Error(`The deck could not be built: ${String(error.stderr ?? error.message).replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n').slice(-3).join(' ')}`)
41
+ }
42
+ const browser = await launchChrome({ dir: resolve(temp, 'out') })
43
+ try {
44
+ const page = await browser.open('about:blank')
45
+ await page.send('Page.enable')
46
+ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: RECORDER })
47
+ await page.send('Page.navigate', { url: `${browser.origin}/index.html?embedded=1${appearance ? `&appearance=${appearance}` : ''}#1` })
48
+ await page.waitFor("location.href.includes('index.html')")
49
+ await page.waitForDeck()
50
+ await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false })
51
+ await page.evaluate("document.querySelector('deck-stage').printing = true")
52
+ const count = await page.evaluate("document.querySelector('deck-stage').length")
53
+ return await use({ page, count, show: async index => { await page.evaluate(`window.__index = ${index}; document.querySelector('deck-stage').goTo(${index})`); await settle(page) } })
54
+ } finally { await browser.close() }
55
+ } finally { rmSync(temp, { recursive: true, force: true }) }
56
+ }
57
+
58
+ // The problems of each slide, in the deck's own look, as diagnostics
59
+ // ({ severity, code, message, line }) at the line the slide starts.
60
+ export function renderCheck(slidesPath) {
61
+ const labels = slideLabels(readFileSync(slidesPath, 'utf8'))
62
+ return withDeckPage(slidesPath, {}, async ({ page, count, show }) => {
63
+ const diagnostics = []
64
+ for (let index = 0; index < count; index++) {
65
+ await show(index)
66
+ const label = labels[index] ?? { number: index + 1, line: 1, heading: null }
67
+ const where = `slide ${label.number}${label.heading ? ` (“${label.heading}”)` : ''}`
68
+ // Content that does not fit cuts off line after line; the first says which.
69
+ const problems = await page.evaluate(MEASURE)
70
+ const cut = problems.filter(problem => problem.endsWith(' is cut off at the edge'))
71
+ const shown = problems.filter(problem => !cut.includes(problem))
72
+ if (cut.length) shown.push(cut.length === 1 ? cut[0] : `${cut[0].replace(/ is cut off at the edge$/, '')} and ${cut.length - 1} more ${cut.length === 2 ? 'line is' : 'lines are'} cut off at the edge`)
73
+ for (const problem of shown) diagnostics.push({ severity: 'warning', code: 'render', message: `${where}: ${problem}`, line: label.line })
74
+ }
75
+ // The deck's own warning about content that does not fit is said above, per slide.
76
+ for (const line of (await page.evaluate('window.__report')).filter(line => !/has more content than fits/.test(line))) diagnostics.push({ severity: line.startsWith('error') ? 'error' : 'warning', code: 'render', message: `the page reported ${line}`, line: 1 })
77
+ return diagnostics
78
+ })
79
+ }
80
+
81
+ // Pictures of slides (numbers from 1; all when none are given) as PNG files
82
+ // in `outDir`, named <deck>-<number>.png. Returns the files written.
83
+ export async function snapshot(slidesPath, { slides = [], outDir, width = 1280, height = 720, appearance = '' } = {}) {
84
+ const name = basename(slidesPath).replace(/\.md$/i, '')
85
+ mkdirSync(outDir, { recursive: true })
86
+ return withDeckPage(slidesPath, { appearance, width, height }, async ({ page, count, show }) => {
87
+ const wanted = slides.length ? slides : Array.from({ length: count }, (_, i) => i + 1)
88
+ const files = []
89
+ for (const number of wanted) {
90
+ if (number < 1 || number > count) throw new Error(`There is no slide ${number}; the deck has ${count}`)
91
+ await show(number - 1)
92
+ const file = resolve(outDir, `${name}-${String(number).padStart(2, '0')}.png`)
93
+ writeFileSync(file, await page.screenshot({ width, height }))
94
+ files.push(file)
95
+ }
96
+ return files
97
+ })
98
+ }
@@ -0,0 +1,77 @@
1
+ // Keeping the answers while `mdeck run` runs: the presenter's screens send
2
+ // each activity's answers as they come in (src/live/client.js), and they go
3
+ // into <deck>.results.json beside the deck (src/core/results.js).
4
+ //
5
+ // POST /__mdeck/results { room, closed, answers } → { ok }
6
+ //
7
+ // Answers of none take the room out of the file, as Reset does. Writes wait
8
+ // a moment, so a burst of votes is one write, and do not reload the open
9
+ // windows (ownWrites.js).
10
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+ import { resultsFileFor, normalizeResults, withRoom, serializeResults } from '../core/results.js'
13
+ import { isAllowedRequest, send, readJson } from './editorPlugin.js'
14
+ import { markOwnWrite } from './ownWrites.js'
15
+
16
+ export function createResultsFile(deckPath, { debounceMs = 500 } = {}) {
17
+ const path = resultsFileFor(resolve(deckPath))
18
+ let results = null, timer = null
19
+
20
+ const current = () => {
21
+ if (results) return results
22
+ try { results = existsSync(path) ? normalizeResults(JSON.parse(readFileSync(path, 'utf8'))) : normalizeResults({}) }
23
+ catch { results = normalizeResults({}) }
24
+ return results
25
+ }
26
+
27
+ function flush() {
28
+ clearTimeout(timer)
29
+ timer = null
30
+ if (!results) return
31
+ // A deck that never kept answers gets no empty file.
32
+ if (!Object.keys(results.rooms).length && !existsSync(path)) return
33
+ const text = serializeResults(results)
34
+ markOwnWrite(path, text)
35
+ const temp = `${path}.${process.pid}.tmp`
36
+ writeFileSync(temp, text, 'utf8')
37
+ renameSync(temp, path)
38
+ }
39
+
40
+ return {
41
+ path,
42
+ read: current,
43
+ save(room, entry) {
44
+ results = withRoom(current(), room, entry)
45
+ clearTimeout(timer)
46
+ timer = setTimeout(flush, debounceMs)
47
+ },
48
+ flush,
49
+ }
50
+ }
51
+
52
+ export function resultsMiddleware(file, { authorize = isAllowedRequest } = {}) {
53
+ return async (request, response, next) => {
54
+ const { pathname } = new URL(request.url, 'http://localhost')
55
+ if (pathname !== '/') return next()
56
+ if (request.method !== 'POST') { response.setHeader('Allow', 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
57
+ if (!authorize(request)) return send(response, 403, { error: 'Only the presenter keeps the results' })
58
+ try {
59
+ const { room, closed, answers } = await readJson(request)
60
+ file.save(String(room ?? ''), { closed, answers })
61
+ return send(response, 200, { ok: true })
62
+ } catch (error) {
63
+ return send(response, error.status ?? 500, { error: error.message })
64
+ }
65
+ }
66
+ }
67
+
68
+ export function resultsPlugin(slidesPath, { authorize } = {}) {
69
+ return {
70
+ name: 'vite-plugin-mdeck-results',
71
+ configureServer(server) {
72
+ const file = createResultsFile(slidesPath)
73
+ server.middlewares.use('/__mdeck/results', resultsMiddleware(file, { authorize }))
74
+ server.httpServer?.once('close', () => file.flush())
75
+ },
76
+ }
77
+ }
@@ -5,13 +5,15 @@ 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 } from '../core/editDeck.js'
8
+ import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
9
9
  import { componentFolders, componentFiles } from './components.js'
10
10
  import { inkFileFor, normalizeInk, emptyInk } from '../core/ink.js'
11
+ import { resultsFileFor, normalizeResults } from '../core/results.js'
11
12
  import { isOwnWrite } from './ownWrites.js'
12
13
  import { fileURLToPath } from 'node:url'
13
14
  import { assertDrawings } from './drawings.js'
14
15
  import { marked } from 'marked'
16
+ import { resolveBibliography, sampleBibliography } from './bibliography.js'
15
17
 
16
18
  const VIRTUAL_ID = 'virtual:slides'
17
19
  const RESOLVED_ID = '\0virtual:slides'
@@ -24,6 +26,10 @@ const INK_ID = 'virtual:deck-ink'
24
26
  const RESOLVED_INK_ID = '\0virtual:deck-ink'
25
27
  const SVGS_ID = 'virtual:deck-svgs'
26
28
  const RESOLVED_SVGS_ID = '\0virtual:deck-svgs'
29
+ const BIB_ID = 'virtual:bibliography'
30
+ const RESOLVED_BIB_ID = '\0virtual:bibliography'
31
+ const RESULTS_ID = 'virtual:deck-results'
32
+ const RESOLVED_RESULTS_ID = '\0virtual:deck-results'
27
33
 
28
34
  const MIME_BY_EXT = {
29
35
  '.png': 'image/png',
@@ -136,6 +142,8 @@ export function collectLocalAssetRefs(markdown) {
136
142
  if (token.type === 'image') add(token.href)
137
143
  if (token.type === 'html') {
138
144
  for (const match of token.text.matchAll(/<(?:img|video|audio|source|videoplayer)\b[^>]*\bsrc=(["'])([^"']+)\1/gi)) add(match[2])
145
+ // Markdown pictures in a tag's attributes, such as a poll's options.
146
+ for (const match of token.text.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) add(match[1])
139
147
  }
140
148
  })
141
149
  }
@@ -213,12 +221,16 @@ export function deckLookOnly(registry, deckConfig = {}) {
213
221
  // extension changes are announced instead of forcing a reload. `source` is
214
222
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
215
223
  // `slidesPath` then only says where its folder is.
216
- export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, ink = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
224
+ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, ink = true, results = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
217
225
  const abs = resolve(slidesPath)
218
226
  const readDeck = () => given ?? readFileSync(abs, 'utf-8')
219
227
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
220
- const sourceFor = raw => stripNotes ? stripNotesFrom(raw) : raw
228
+ const sourceFor = raw => {
229
+ const shared = stripNotes ? stripNotesFrom(raw) : raw
230
+ return stripActivities ? stripActivitiesFrom(shared).source : shared
231
+ }
221
232
  const inkPath = inkFileFor(abs)
233
+ const resultsPath = resultsFileFor(abs)
222
234
  const extensionDirs = extensionRoots(abs).map(root => root.dir)
223
235
  let componentDirs = componentFolders(abs, readDeck()).map(folder => folder.dir)
224
236
  const watchDirs = () => [...extensionDirs, ...componentDirs]
@@ -233,8 +245,18 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
233
245
  for (const dir of next) if (!allow.includes(dir)) allow.push(dir)
234
246
  return true
235
247
  }
236
- const ALL_IDS = [RESOLVED_ID, RESOLVED_COMPONENTS_ID, RESOLVED_EXTENSIONS_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID]
237
- const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID]
248
+ const ALL_IDS = [RESOLVED_ID, RESOLVED_COMPONENTS_ID, RESOLVED_EXTENSIONS_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
249
+ const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
250
+ // The reference files (and .csl style) the deck names, from the last load.
251
+ let bibFiles = []
252
+ // The dev and edit servers also show the sample deck (?sample=1, the
253
+ // design page), with its references from memory; builds never do.
254
+ let serving = false
255
+ // The editor keeps its page: the formatted references arrive as a hot update.
256
+ const bibUpdate = server => {
257
+ const mod = server.moduleGraph.getModuleById(RESOLVED_BIB_ID)
258
+ return editor && mod ? [mod] : []
259
+ }
238
260
  const invalidate = (server, ids) => {
239
261
  for (const id of ids) {
240
262
  const mod = server.moduleGraph.getModuleById(id)
@@ -249,6 +271,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
249
271
 
250
272
  return {
251
273
  name: 'vite-plugin-slides',
274
+ configResolved(config) { serving = config.command === 'serve' },
252
275
  configureServer(server) {
253
276
  server.watcher.add([abs, inkPath, ...watchDirs()])
254
277
  const refresh = file => {
@@ -266,6 +289,8 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
266
289
  if (id === EXTENSIONS_ID) return RESOLVED_EXTENSIONS_ID
267
290
  if (id === INK_ID) return RESOLVED_INK_ID
268
291
  if (id === SVGS_ID) return RESOLVED_SVGS_ID
292
+ if (id === BIB_ID) return RESOLVED_BIB_ID
293
+ if (id === RESULTS_ID) return RESOLVED_RESULTS_ID
269
294
  },
270
295
  load(id) {
271
296
  if (id === RESOLVED_ID) {
@@ -295,14 +320,44 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
295
320
  if (ink && existsSync(inkPath)) {
296
321
  try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
297
322
  }
323
+ // Without the activity slides, drawings follow their slides to their new ids.
324
+ if (stripActivities) {
325
+ try {
326
+ const { ids, removed } = stripActivitiesFrom(readDeck())
327
+ const gone = new Set(removed)
328
+ data = { ...data, slides: Object.fromEntries(Object.entries(data.slides).filter(([id]) => !gone.has(id)).map(([id, strokes]) => [ids[id] ?? id, strokes])) }
329
+ } catch {}
330
+ }
298
331
  return `export default ${JSON.stringify(data)}\nexport const inkFileName = ${JSON.stringify(basename(inkPath))}`
299
332
  }
333
+ // The answers kept in the talk (<deck>.results.json): builds show them
334
+ // where no room server is (src/live/client.js). `mdeck run` shows the
335
+ // live answers, and writes the file, so it does not read it here.
336
+ if (id === RESOLVED_RESULTS_ID) {
337
+ let data = normalizeResults({})
338
+ if (results && !serving && existsSync(resultsPath)) {
339
+ try { data = normalizeResults(JSON.parse(readFileSync(resultsPath, 'utf-8'))) } catch (error) { this.warn(`${resultsPath}: ${error.message}; showing no results`) }
340
+ }
341
+ return `export default ${JSON.stringify(data)}`
342
+ }
300
343
  if (id === RESOLVED_SVGS_ID) {
301
344
  watchDeck(this)
302
345
  const svgs = themedSvgs(abs, sourceFor(readDeck()))
303
346
  for (const { file } of Object.values(svgs)) this.addWatchFile(file)
304
347
  return `export default ${JSON.stringify(Object.fromEntries(Object.entries(svgs).map(([ref, { markup }]) => [ref, markup])))}`
305
348
  }
349
+ // The deck's citations and references, formatted with citeproc here so
350
+ // that none of it travels in the deck (bibliography.js).
351
+ if (id === RESOLVED_BIB_ID) {
352
+ watchDeck(this)
353
+ const data = bibliography => bibliography && { ids: bibliography.ids, clusters: bibliography.clusters, singles: bibliography.singles, entries: bibliography.entries, order: bibliography.order }
354
+ return Promise.all([resolveBibliography(abs, sourceFor(readDeck()), { sample: serving }), serving ? sampleBibliography() : null]).then(([bibliography, sample]) => {
355
+ bibFiles = bibliography ? [...bibliography.files, bibliography.style].filter(Boolean) : []
356
+ for (const file of bibFiles) if (existsSync(file)) this.addWatchFile(file)
357
+ if (bibliography?.diagnostics.length) this.warn(bibliography.diagnostics.map(d => d.message).join('\n'))
358
+ return `export default ${JSON.stringify(data(bibliography))}\nexport const sample = ${JSON.stringify(data(sample))}`
359
+ })
360
+ }
306
361
  if (id === RESOLVED_EXTENSIONS_ID) {
307
362
  const registry = loadRegistry(abs)
308
363
  for (const record of registry.records) this.addWatchFile(record.file)
@@ -347,15 +402,23 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
347
402
  }
348
403
  // mdeck's own writes (saved ink, a new slide id) must not reload the
349
404
  // windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
405
+ // The results file is written during the talk and read only by builds.
406
+ if (changed === resultsPath) return []
350
407
  const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
351
408
  if (own) {
352
409
  invalidate(server, changed === abs ? SOURCE_IDS : [RESOLVED_INK_ID])
353
- return []
410
+ return changed === abs ? bibUpdate(server) : []
354
411
  }
355
412
  if (changed === inkPath) {
356
413
  reload(server, [RESOLVED_INK_ID], changed)
357
414
  return []
358
415
  }
416
+ if (bibFiles.includes(changed)) {
417
+ invalidate(server, [RESOLVED_BIB_ID])
418
+ if (editor) return bibUpdate(server)
419
+ server.ws.send({ type: 'full-reload' })
420
+ return []
421
+ }
359
422
  if (changed === abs) {
360
423
  if (followComponentFolders(server)) {
361
424
  reload(server, ALL_IDS, changed)
@@ -363,7 +426,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
363
426
  }
364
427
  // The deck sets the ink's design size and which SVGs are inlined.
365
428
  invalidate(server, SOURCE_IDS)
366
- if (editor) return []
429
+ if (editor) return bibUpdate(server)
367
430
  server.ws.send({ type: 'full-reload' })
368
431
  }
369
432
  },
@@ -10,17 +10,17 @@ import { tmpdir } from 'node:os'
10
10
  import { resolve } from 'node:path'
11
11
  import { launchChrome } from './chrome.js'
12
12
  import { frameworkRoot } from '../paths.js'
13
- import { sampleDeck, SAMPLE_PICTURE } from '../editor/sampleDeck.js'
13
+ import { sampleDeck, SAMPLE_PICTURE, SAMPLE_REFERENCES, SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
14
14
  import { loadRegistry } from '../extensions/discover.js'
15
15
 
16
16
  // Runs in the page before the deck: keeps what the page reports.
17
- const RECORDER = `window.__report = [];
17
+ export const RECORDER = `window.__report = [];
18
18
  for (const level of ['warn', 'error']) { const original = console[level]; console[level] = (...args) => { window.__report.push(level + ': ' + args.map(String).join(' ')); original.apply(console, args) } }
19
19
  addEventListener('error', event => window.__report.push('error: ' + event.message))
20
20
  addEventListener('unhandledrejection', event => window.__report.push('error: ' + (event.reason?.message ?? event.reason)))`
21
21
 
22
22
  // Runs in the page for the slide on screen: what is cut off or hard to read.
23
- const MEASURE = `(() => {
23
+ export const MEASURE = `(() => {
24
24
  const slide = document.querySelector('deck-stage').querySelectorAll(':scope > section')[window.__index]
25
25
  const problems = []
26
26
  const frame = slide.getBoundingClientRect()
@@ -71,7 +71,9 @@ const MEASURE = `(() => {
71
71
  const box = range.getBoundingClientRect()
72
72
  if (!box.width || !box.height) continue
73
73
  const words = text.textContent.trim().slice(0, 40)
74
- if (box.left < frame.left - 2 * scale || box.right > frame.right + 2 * scale || box.top < frame.top - 2 * scale || box.bottom > frame.bottom + 2 * scale) problems.push('"' + words + '" is cut off at the edge')
74
+ // Text in a box that scrolls (a component's long list) is meant to be out of view.
75
+ const scrolls = node => { for (; node && node !== slide; node = node.parentElement) if (/auto|scroll/.test(getComputedStyle(node).overflowY + getComputedStyle(node).overflowX)) return true; return false }
76
+ if (!scrolls(element) && (box.left < frame.left - 2 * scale || box.right > frame.right + 2 * scale || box.top < frame.top - 2 * scale || box.bottom > frame.bottom + 2 * scale)) problems.push('"' + words + '" is cut off at the edge')
75
77
  const colour = parse(style.color), back = behind(element, box)
76
78
  if (!colour || !back || colour.a < 0.5 || element.closest('.code-block, .katex')) continue
77
79
  const large = parseFloat(style.fontSize) / scale >= 40 || (parseFloat(style.fontSize) / scale >= 30 && Number(style.fontWeight) >= 600)
@@ -89,6 +91,7 @@ export function buildSampleDeck({ extensions = {}, outDir }) {
89
91
  try {
90
92
  writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
91
93
  writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
94
+ writeFileSync(resolve(temp, SAMPLE_REFERENCES_FILE), SAMPLE_REFERENCES)
92
95
  // Keyed by id, or by "kind:id" to keep a theme and a palette of one name apart.
93
96
  for (const [key, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', ...key.split(':').map((part, i, all) => all.length > 1 && i === 0 ? `${part}s` : part)), { recursive: true })
94
97
  mkdirSync(resolve(temp, 'home'))
@@ -120,7 +123,7 @@ export async function withSampleDeck({ extensions = {} } = {}, use) {
120
123
  } finally { rmSync(temp, { recursive: true, force: true }) }
121
124
  }
122
125
 
123
- const settle = page => page.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 60))))')
126
+ export const settle = page => page.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 60))))')
124
127
 
125
128
  // Every slide of each look ({ theme, palette }), light and dark; returns the
126
129
  // problems found, one line each.
package/src/cli/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Parse the entire command before it can start a server or change any files.
2
2
  const value = 'value', flag = 'flag', optional = 'optional'
3
3
  const common = { '--port': value, '--no-open': flag }
4
- const output = { '--output': value, '-o': '--output', '--no-drawings': flag }
4
+ const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--no-results': flag }
5
5
  const commands = {
6
6
  new: { max: 0 },
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
@@ -9,17 +9,19 @@ const commands = {
9
9
  design: { ...common, max: 1 },
10
10
  themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
11
11
  palettes: { '--local': flag, '--force': flag, max: 4 },
12
- build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, max: 1 },
13
- send: { ...output, '--notes': flag, '--no-pdf': flag, max: 1 },
14
- pdf: { ...output, max: 1 },
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 },
15
15
  preview: { ...common, max: 1 },
16
- check: { '--strict': flag, max: 1 },
16
+ check: { '--strict': flag, '--render': flag, max: 1 },
17
+ snapshot: { '--output': value, '-o': '--output', '--slide': value, '--dark': flag, '--light': flag, max: 1 },
17
18
  list: { '--json': flag, max: 2 },
18
19
  starter: { max: 2 },
19
20
  docs: { ...common, '--build': flag, max: 1 },
20
21
  server: { '--port': value, '--host': optional, max: 0 },
21
22
  skill: { '--print': flag, '--install': 'list', '--project': flag, max: 0 },
22
23
  migrate: { '--dry-run': flag, max: 1 },
24
+ results: { '--output': value, '-o': '--output', max: 1 },
23
25
  }
24
26
 
25
27
  export function validateServerOrigin(address) {
@@ -28,6 +30,19 @@ export function validateServerOrigin(address) {
28
30
  if (!url || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('--server needs an http:// or https:// server origin, for example https://slides.example.org')
29
31
  }
30
32
 
33
+ // Slide numbers from "3", "3,5" or "2-4": in order, each once.
34
+ export function parseSlideNumbers(text) {
35
+ const numbers = []
36
+ for (const part of String(text).split(',').map(part => part.trim()).filter(Boolean)) {
37
+ const range = /^(\d+)(?:-(\d+))?$/.exec(part)
38
+ if (!range) throw new Error(`--slide takes slide numbers such as 3, 3,5 or 2-4, not "${part}"`)
39
+ const [from, to] = [Number(range[1]), Number(range[2] ?? range[1])]
40
+ if (!from || to < from) throw new Error(`--slide takes slide numbers from 1, with the smaller one first: not "${part}"`)
41
+ for (let n = from; n <= to; n++) if (!numbers.includes(n)) numbers.push(n)
42
+ }
43
+ return numbers
44
+ }
45
+
31
46
  export function parseArgs(command, argv) {
32
47
  const schema = commands[command]
33
48
  if (!schema) throw new Error(`Unknown command: "${command}"`)
@@ -70,8 +85,10 @@ export function parseArgs(command, argv) {
70
85
  validateServerOrigin(options['--server'])
71
86
  }
72
87
  if (command === 'build' && options['--notes'] && !options['--reader']) throw new Error('--notes requires --reader for mdeck build')
88
+ if (command === 'build' && options['--no-polls'] && !options['--reader']) throw new Error('--no-polls requires --reader for mdeck build')
73
89
  if (command === 'build' && options['--launchers'] && options['--single-file']) throw new Error('--launchers is only available for folders, not with --single-file.')
74
90
  if (command === 'list' && positionals.length === 2 && !['layouts', 'themes', 'palettes'].includes(positionals[0])) throw new Error('Use mdeck list [layouts|themes|palettes] [slides.md]')
91
+ if (command === 'snapshot' && options['--dark'] && options['--light']) throw new Error('Choose --dark or --light')
75
92
  if (command === 'skill' && options['--print'] && options['--install']) throw new Error('Choose --print or --install')
76
93
  return { options, positionals }
77
94
  }
package/src/cli/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer, build, preview } from 'vite'
2
- import { existsSync, copyFileSync, mkdtempSync, readFileSync, statSync } from 'fs'
2
+ import { existsSync, copyFileSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'fs'
3
3
  import { chmod, rm, mkdir, writeFile } from 'fs/promises'
4
4
  import { resolve, dirname, basename, relative, isAbsolute } from 'path'
5
5
  import { tmpdir } from 'os'
@@ -12,9 +12,10 @@ import { loadRegistry, manifestsOf, serializeRegistry } from '../extensions/disc
12
12
  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
- import { checkDeck } from '../build/check.js'
15
+ import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
16
16
  import { livePlugin, startLiveServer } from '../live/server.js'
17
17
  import { inkPlugin } from '../build/inkPlugin.js'
18
+ import { resultsPlugin } from '../build/resultsPlugin.js'
18
19
  import { createPairing, pairingPlugin } from '../build/pairing.js'
19
20
  import { isAllowedRequest } from '../build/editorPlugin.js'
20
21
  import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
@@ -24,9 +25,10 @@ import { ManifestError, KINDS } from '../extensions/manifest.js'
24
25
 
25
26
  import { frameworkRoot } from '../paths.js'
26
27
  import { parseSlides } from '../core/parseSlides.js'
27
- import { stripNotes as stripNotesFrom } from '../core/editDeck.js'
28
- import { parseArgs, validateServerOrigin } from './args.js'
28
+ import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
29
+ import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
29
30
  import { assertDrawings } from '../build/drawings.js'
31
+ import { resultsFileFor, normalizeResults, resultsCsv } from '../core/results.js'
30
32
  import { createRelayAccess } from '../build/relayAccess.js'
31
33
  import { sessionCode } from '../live/code.js'
32
34
  import { migrateDeck } from './migrate.js'
@@ -178,12 +180,13 @@ async function runNewWizard() {
178
180
  }
179
181
  }
180
182
 
181
- async function copyLocalAssets(slidesPath, outDir, { stripNotes = false } = {}) {
183
+ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
182
184
  const absSlides = resolve(slidesPath)
183
185
  const deckDir = dirname(absSlides)
184
186
  const absOutDir = resolve(outDir)
185
187
  const raw = readFileSync(absSlides, 'utf-8')
186
- const markdown = stripNotes ? stripNotesFrom(raw) : raw
188
+ let markdown = stripNotes ? stripNotesFrom(raw) : raw
189
+ if (stripActivities) markdown = stripActivitiesFrom(markdown).source
187
190
 
188
191
  for (const ref of collectLocalAssetRefs(markdown)) {
189
192
  const source = resolve(deckDir, ref)
@@ -320,20 +323,28 @@ const HELP = `
320
323
 
321
324
  ${c.dim}Make something to hand out${c.reset}
322
325
  ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
323
- --reader reader view, notes removed unless --notes
326
+ --reader reader view, notes removed unless --notes, polls kept as a record unless --no-polls
324
327
  --single-file everything inlined into one file instead
325
328
  --launchers add macOS/Linux and Windows launchers (folders only)
326
329
  ${c.green}mdeck send${c.reset} [slides.md] [-o talk.html] One file to send: the reader view, speaker notes removed,
327
330
  with a PDF inside
328
331
  --notes keep the speaker notes in the file
332
+ --no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
329
333
  --no-pdf do not render the PDF inside
330
334
  ${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
331
335
  --no-drawings for build, send and pdf: leave out <slides>.drawings.json
336
+ --no-results for build, send and pdf: leave out <slides>.results.json (the answers kept in the talk)
332
337
  ${c.green}mdeck preview${c.reset} [folder] Preview a built folder (default: dist)
333
338
 
334
339
  ${c.dim}Check and look things up${c.reset}
335
340
  ${c.green}mdeck migrate${c.reset} [slides.md] [--dry-run] Update settings, local layouts and drawings to API 2.0
336
341
  ${c.green}mdeck check${c.reset} [slides.md] [--strict] Validate source and local assets
342
+ --render also show every slide in Chrome, all steps revealed: content that
343
+ does not fit, text cut off or hard to read, errors on the page
344
+ ${c.green}mdeck snapshot${c.reset} [slides.md] Pictures of slides as PNG files (default: .mdeck-snapshots/)
345
+ --slide <numbers> only these slides: 3, 3,5 or 2-4
346
+ --dark | --light in this appearance instead of the deck's own -o <folder>
347
+ ${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV (room, phone, time, answer)
337
348
  ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
338
349
  List built-in and deck-local layouts, themes and palettes
339
350
  ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
@@ -459,15 +470,42 @@ if (command === 'new') {
459
470
  else if (hasFlag('--dry-run')) tip('No files changed. Run without --dry-run to apply these changes.')
460
471
  } catch (error) { err(error.message); process.exitCode = 1 }
461
472
 
473
+ } else if (command === 'results') {
474
+ const input = requireInput('results')
475
+ const file = resultsFileFor(resolve(input))
476
+ 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) }
477
+ let csv
478
+ try { csv = resultsCsv(normalizeResults(JSON.parse(readFileSync(file, 'utf8')))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
479
+ const out = outputOption()
480
+ if (out) { writeFileSync(out, csv); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
481
+ else process.stdout.write(csv)
462
482
  } else if (command === 'check') {
463
483
  const input = requireInput('check')
464
484
  const registry = registryFor(input)
465
485
  const { diagnostics } = checkDeck(input, registry)
486
+ diagnostics.push(...await bibliographyDiagnostics(input))
487
+ if (hasFlag('--render') && !diagnostics.some(d => d.severity === 'error')) {
488
+ tip('Building the deck and looking at every slide in Chrome…')
489
+ try {
490
+ const { renderCheck } = await import('../build/renderCheck.js')
491
+ diagnostics.push(...await renderCheck(input))
492
+ } catch (error) { diagnostics.push({ severity: 'error', code: 'render', message: error.message, line: 1 }) }
493
+ }
466
494
  if (diagnostics.length) console.log(formatDiagnostics(diagnostics, input))
467
495
  const warned = diagnostics.length || registry.warnings.length
468
496
  if (diagnostics.some(d => d.severity === 'error') || (warned && hasFlag('--strict'))) process.exitCode = 1
469
497
  else ok(`Checked ${input}${warned ? ' (with warnings)' : ''}`)
470
498
 
499
+ } else if (command === 'snapshot') {
500
+ const input = requireInput('snapshot')
501
+ try {
502
+ const slides = parseSlideNumbers(parsed.options['--slide'] ?? '')
503
+ const outDir = resolve(outputOption() ?? resolve(dirname(resolve(input)), '.mdeck-snapshots'))
504
+ const appearance = hasFlag('--dark') ? 'dark' : hasFlag('--light') ? 'light' : ''
505
+ const { snapshot } = await import('../build/renderCheck.js')
506
+ for (const file of await snapshot(input, { slides, outDir, appearance })) console.log(relative(process.cwd(), file))
507
+ } catch (error) { err(error.message); process.exitCode = 1 }
508
+
471
509
  } else if (command === 'list') {
472
510
  const KIND_WORDS = { layouts: 'layout', themes: 'theme', palettes: 'palette' }
473
511
  const [first, ...others] = positionals()
@@ -537,7 +575,7 @@ if (command === 'new') {
537
575
  ...base,
538
576
  // A paired iPad may save ink and steer the rooms, like this computer.
539
577
  ...(relay ? { base: relay.base } : {}),
540
- 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) })],
578
+ 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) })],
541
579
  publicDir: dirname(abs),
542
580
  server: {
543
581
  ...base.server,
@@ -578,7 +616,8 @@ if (command === 'new') {
578
616
  ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
579
617
  tip(`Presenter: ${new URL('?view=presenter', local).href}`)
580
618
  tip(`Audience: ${new URL('?view=audience', local).href}`)
581
- if (exposed) for (const url of server.resolvedUrls.network) tip(`On a phone: ${new URL('?view=reader', url).href}`)
619
+ // Phones in the room follow the talk (the reader view would show slides ahead).
620
+ if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
582
621
  console.log()
583
622
  if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
584
623
  ok(`Watching ${c.cyan}${abs}${c.reset}`)
@@ -645,6 +684,8 @@ if (command === 'new') {
645
684
  const launchers = !sending && hasFlag('--launchers')
646
685
  const reader = sending || hasFlag('--reader')
647
686
  const stripNotes = reader && !hasFlag('--notes')
687
+ // Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
688
+ const stripActivities = reader && hasFlag('--no-polls')
648
689
  const wantPdf = sending && !hasFlag('--no-pdf')
649
690
 
650
691
  if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
@@ -659,7 +700,7 @@ if (command === 'new') {
659
700
  try {
660
701
  await build({
661
702
  ...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
662
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
703
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
663
704
  build: {
664
705
  outDir,
665
706
  emptyOutDir: !tempDir,
@@ -677,7 +718,7 @@ if (command === 'new') {
677
718
  await mkdir(finalOutDir, { recursive: true })
678
719
 
679
720
  if (!selfContained) {
680
- await copyLocalAssets(input, finalOutDir, { stripNotes })
721
+ await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
681
722
  if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
682
723
  }
683
724
 
@@ -701,6 +742,7 @@ if (command === 'new') {
701
742
  } finally { await rm(pdfDir, { recursive: true, force: true }) }
702
743
  }
703
744
  if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
745
+ if (stripActivities && stripActivitiesFrom(readFileSync(resolve(input), 'utf-8')).removed.length) tip('Slides with polls were left out (--no-polls).')
704
746
  ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
705
747
 
706
748
  // ── skill ─────────────────────────────────────────────────────────────────────
@@ -730,7 +772,7 @@ if (command === 'new') {
730
772
  try {
731
773
  await build({
732
774
  ...baseConfig(input, { selfContained: true }),
733
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
775
+ 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()],
734
776
  build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
735
777
  logLevel: 'warn',
736
778
  })