mdeck 3.2.0 → 3.3.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 (51) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/extensions/themes/academic/styles.css +15 -0
  3. package/docs/reference/authoring.md +62 -2
  4. package/docs/site/content/audience.md +6 -0
  5. package/docs/site/content/commands.md +28 -4
  6. package/docs/site/content/drawing.md +1 -1
  7. package/docs/site/content/more-content.md +22 -0
  8. package/docs/site/content/sharing.md +1 -0
  9. package/docs/site/content/troubleshooting.md +1 -1
  10. package/docs/site/content/views.md +3 -1
  11. package/examples/citations/refs.bib +30 -0
  12. package/examples/citations/refs.yml +21 -0
  13. package/examples/citations/slides.md +43 -0
  14. package/examples/citations/slides.pdf +0 -0
  15. package/package.json +5 -1
  16. package/skills/write-slides/SKILL.md +6 -2
  17. package/src/build/bibliography.js +289 -0
  18. package/src/build/check.js +12 -0
  19. package/src/build/renderCheck.js +98 -0
  20. package/src/build/slidesPlugin.js +47 -7
  21. package/src/build/themeCheck.js +6 -4
  22. package/src/cli/args.js +20 -4
  23. package/src/cli/index.js +39 -10
  24. package/src/components/Bibliography.jsx +23 -0
  25. package/src/components/Poll.jsx +25 -2
  26. package/src/components/QrCodeTag.jsx +11 -2
  27. package/src/components/VideoPlayer.jsx +16 -0
  28. package/src/components/activity.jsx +2 -2
  29. package/src/components/citation.css +28 -0
  30. package/src/components/poll.css +12 -2
  31. package/src/core/citations.js +108 -0
  32. package/src/core/editDeck.js +43 -0
  33. package/src/core/ink.js +16 -0
  34. package/src/core/labels.js +16 -0
  35. package/src/core/parseSlides.js +1 -1
  36. package/src/core/urls.js +2 -2
  37. package/src/core/validateDeck.js +7 -1
  38. package/src/layouts/layoutApi.jsx +13 -3
  39. package/src/live/answer/answer.css +1 -0
  40. package/src/live/answer/answer.js +2 -0
  41. package/src/live/client.js +25 -2
  42. package/src/runtime/ReaderView.jsx +2 -1
  43. package/src/runtime/citations.js +60 -0
  44. package/src/runtime/deck-stage.js +5 -2
  45. package/src/runtime/editorBridge.js +7 -1
  46. package/src/runtime/follow.js +89 -0
  47. package/src/runtime/ink/bus.js +7 -0
  48. package/src/runtime/ink/room.js +8 -0
  49. package/src/runtime/ink/select.js +4 -2
  50. package/src/runtime/main.jsx +19 -7
  51. package/src/runtime/registry.jsx +2 -0
@@ -0,0 +1,289 @@
1
+ import { readFileSync, statSync } from 'fs'
2
+ import { resolve, dirname, extname } from 'path'
3
+ import yaml from 'js-yaml'
4
+ import { parseSlides } from '../core/parseSlides.js'
5
+ import { scanCitations, citedKeys } from '../core/citations.js'
6
+
7
+ // The deck's references, formatted here in Node with citeproc so that none of
8
+ // it travels in the deck: the page receives finished HTML for every citation
9
+ // as written, the full entry of every cited work, and the reference list.
10
+ //
11
+ // Reference files: BibTeX/BibLaTeX (.bib), CSL-JSON (.json), and YAML (.yml,
12
+ // .yaml) holding either Hayagriva (Typst's format, a map of entries) or
13
+ // CSL-YAML (a list of entries with ids).
14
+
15
+ export const bibliographyFiles = (deckPath, deckConfig = {}) =>
16
+ [deckConfig.bibliography ?? []].flat().filter(file => typeof file === 'string' && file).map(file => resolve(dirname(resolve(deckPath)), file))
17
+
18
+ export const styleFile = (deckPath, deckConfig = {}) =>
19
+ typeof deckConfig.csl === 'string' && /\.csl$/i.test(deckConfig.csl) ? resolve(dirname(resolve(deckPath)), deckConfig.csl) : null
20
+
21
+ // ─── Reading reference files ──────────────────────────────────────────────
22
+
23
+ const parsed = new Map()
24
+
25
+ async function readItems(file) {
26
+ const { mtimeMs } = statSync(file)
27
+ const cached = parsed.get(file)
28
+ if (cached?.mtimeMs === mtimeMs) return cached.items
29
+ const text = readFileSync(file, 'utf-8')
30
+ const ext = extname(file).toLowerCase()
31
+ let items
32
+ if (ext === '.json') items = JSON.parse(text)
33
+ else if (ext === '.yml' || ext === '.yaml') {
34
+ // The core schema keeps dates as written (2003-06-21), not as Date objects.
35
+ const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA })
36
+ items = Array.isArray(doc) ? doc : hayagrivaToCsl(doc ?? {})
37
+ } else if (ext === '.bib' || ext === '.bibtex') {
38
+ const { Cite } = await import('@citation-js/core')
39
+ await import('@citation-js/plugin-bibtex')
40
+ items = new Cite(text, { forceType: '@biblatex/text' }).data
41
+ } else throw new Error(`unknown reference format "${ext}" (use .bib, .json or .yml)`)
42
+ if (!Array.isArray(items)) throw new Error('expected a list of references')
43
+ items = items.filter(item => item && item.id != null).map(item => ({ ...item, id: String(item.id) }))
44
+ parsed.set(file, { mtimeMs, items })
45
+ return items
46
+ }
47
+
48
+ export async function loadBibliography(files) {
49
+ const items = []
50
+ const seen = new Set()
51
+ const diagnostics = []
52
+ for (const file of files) {
53
+ let found
54
+ try { found = await readItems(file) } catch (error) {
55
+ diagnostics.push({ severity: 'warning', code: 'bibliography-file', message: error.code === 'ENOENT' ? `Bibliography ${file} not found` : `Bibliography ${file}: ${error.message}` })
56
+ continue
57
+ }
58
+ for (const item of found) {
59
+ if (seen.has(item.id)) { diagnostics.push({ severity: 'warning', code: 'bibliography-duplicate', message: `Reference "${item.id}" appears twice; using the first (${file})` }); continue }
60
+ seen.add(item.id)
61
+ items.push(item)
62
+ }
63
+ }
64
+ return { items, diagnostics }
65
+ }
66
+
67
+ // ─── Hayagriva → CSL-JSON ─────────────────────────────────────────────────
68
+
69
+ const text = value => value == null ? undefined : typeof value === 'object' && !Array.isArray(value) ? text(value.value) : String(value)
70
+
71
+ function person(value) {
72
+ if (value && typeof value === 'object') {
73
+ return { family: value.name, given: value['given-name'], 'non-dropping-particle': value.prefix, suffix: value.suffix }
74
+ }
75
+ const parts = String(value).split(',').map(part => part.trim())
76
+ if (parts.length === 1) return { literal: parts[0] }
77
+ const [family, given, suffix] = parts
78
+ const particle = family.match(/^((?:[a-z]+\s+)+)(.+)$/)
79
+ return particle ? { 'non-dropping-particle': particle[1].trim(), family: particle[2], given, suffix } : { family, given, suffix }
80
+ }
81
+ const people = value => value == null ? undefined : [value].flat().map(person)
82
+
83
+ function date(value) {
84
+ if (value == null) return undefined
85
+ const parts = String(value).match(/^(-?\d{1,4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?/)
86
+ return parts ? { 'date-parts': [parts.slice(1).filter(Boolean).map(Number)] } : { literal: String(value) }
87
+ }
88
+
89
+ const kind = value => String(value ?? 'misc').toLowerCase()
90
+
91
+ function cslType(type, parentType) {
92
+ if (type === 'article') return { periodical: 'article-journal', newspaper: 'article-newspaper', proceedings: 'paper-conference', conference: 'paper-conference', blog: 'post-weblog', web: 'webpage', book: 'chapter', anthology: 'chapter' }[parentType] ?? 'article-journal'
93
+ return {
94
+ book: 'book', anthology: 'book', proceedings: 'book', chapter: 'chapter', anthos: 'chapter', thesis: 'thesis', report: 'report',
95
+ web: 'webpage', blog: 'post-weblog', thread: 'post', video: 'motion_picture', audio: 'song', repository: 'software',
96
+ patent: 'patent', case: 'legal_case', legislation: 'legislation', manuscript: 'manuscript', newspaper: 'article-newspaper',
97
+ periodical: 'periodical', reference: 'entry-encyclopedia', entry: 'entry', artwork: 'graphic', performance: 'performance',
98
+ exhibition: 'event', conference: 'event', original: 'document', misc: 'document',
99
+ }[type] ?? 'document'
100
+ }
101
+
102
+ function publisher(value) {
103
+ if (value == null) return {}
104
+ if (typeof value === 'object') return { publisher: text(value.name), 'publisher-place': text(value.location) }
105
+ return { publisher: String(value) }
106
+ }
107
+
108
+ const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value != null && value !== '' && !(Array.isArray(value) && !value.length)))
109
+
110
+ export function hayagrivaToCsl(doc) {
111
+ return Object.entries(doc).map(([id, entry]) => {
112
+ entry ??= {}
113
+ const parent = [entry.parent ?? []].flat()[0] ?? {}
114
+ const serial = { ...(typeof parent['serial-number'] === 'object' ? parent['serial-number'] : {}), ...(typeof entry['serial-number'] === 'object' ? entry['serial-number'] : {}) }
115
+ const url = entry.url ?? parent.url
116
+ return clean({
117
+ id,
118
+ type: cslType(kind(entry.type), parent.type ? kind(parent.type) : undefined),
119
+ title: text(entry.title),
120
+ 'title-short': typeof entry.title === 'object' ? text(entry.title.short) : undefined,
121
+ author: people(entry.author),
122
+ editor: people(entry.editor ?? parent.editor),
123
+ translator: people(entry.translator),
124
+ issued: date(entry.date ?? parent.date),
125
+ 'container-title': text(parent.title),
126
+ 'collection-title': text([parent.parent ?? []].flat()[0]?.title),
127
+ volume: text(entry.volume ?? parent.volume),
128
+ issue: text(entry.issue ?? parent.issue),
129
+ page: text(entry['page-range'] ?? parent['page-range']),
130
+ edition: text(entry.edition ?? parent.edition),
131
+ ...publisher(entry.publisher ?? parent.publisher),
132
+ 'publisher-place': text(entry.location ?? parent.location) ?? publisher(entry.publisher ?? parent.publisher)['publisher-place'],
133
+ genre: text(entry.genre),
134
+ 'event-title': kind(parent.type) === 'conference' ? text(parent.title) : undefined,
135
+ DOI: text(serial.doi),
136
+ ISBN: text(serial.isbn),
137
+ ISSN: text(serial.issn),
138
+ PMID: text(serial.pmid),
139
+ number: typeof entry['serial-number'] === 'string' ? entry['serial-number'] : undefined,
140
+ URL: text(url),
141
+ accessed: typeof url === 'object' ? date(url.date) : undefined,
142
+ language: text(entry.language),
143
+ note: text(entry.note),
144
+ abstract: text(entry.abstract),
145
+ ...(entry.organization && !entry.publisher ? { publisher: text(entry.organization) } : {}),
146
+ })
147
+ })
148
+ }
149
+
150
+ // ─── Formatting ───────────────────────────────────────────────────────────
151
+
152
+ // The texts a deck shows, in the order the audience sees them: each slide's
153
+ // regions, then its notes. The runtime renders citations in the same texts.
154
+ export function deckTexts(source) {
155
+ const { slides } = parseSlides(source)
156
+ const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
157
+ return slides.flatMap(slide => [
158
+ ...Object.values(slide.regions ?? { body: { content: slide.content } }).sort((a, b) => position(a) - position(b)).map(region => region.content ?? ''),
159
+ slide.meta?.notes ?? '',
160
+ ])
161
+ }
162
+
163
+ function pickLocale(lang, available) {
164
+ if (!lang) return 'en-US'
165
+ const wanted = String(lang).replace('_', '-')
166
+ const exact = available.find(id => id.toLowerCase() === wanted.toLowerCase())
167
+ if (exact) return exact
168
+ const language = wanted.split('-')[0].toLowerCase()
169
+ return available.find(id => id.toLowerCase().startsWith(language + '-')) ?? null
170
+ }
171
+
172
+ // A name for a narrative citation when the style has no author-only form
173
+ // (numeric styles): Smith, Smith and Doe, Smith et al.
174
+ function namesOf(item) {
175
+ const names = (item.author ?? item.editor ?? []).map(name => name.family ?? name.literal).filter(Boolean)
176
+ if (!names.length) return item['title-short'] ?? item.title ?? item.id
177
+ if (names.length === 1) return names[0]
178
+ if (names.length === 2) return `${names[0]} & ${names[1]}`
179
+ return `${names[0]} et al.`
180
+ }
181
+
182
+ const toCiteItem = item => clean({ id: item.id, locator: item.locator, label: item.locator ? item.label : undefined, prefix: item.prefix, suffix: item.suffix, 'suppress-author': item.suppressAuthor || undefined })
183
+
184
+ // `{ ids, clusters, singles, entries, order, diagnostics }`: `clusters` maps
185
+ // each citation as written to its HTML; `singles` and `entries` map keys to a
186
+ // one-work citation and to the full reference; `order` is the reference list.
187
+ export async function formatBibliography({ items, texts, csl, lang, nocite = [], deckDir = '.' }) {
188
+ const diagnostics = []
189
+ const byId = new Map(items.map(item => [item.id, item]))
190
+ const result = { ids: [...byId.keys()], clusters: {}, singles: {}, entries: {}, order: [], diagnostics }
191
+
192
+ const clusters = texts.flatMap(text => scanCitations(text, { isKey: id => byId.has(id) }))
193
+ const unknown = new Set(clusters.flatMap(cluster => cluster.items.map(item => item.id)).filter(id => !byId.has(id)))
194
+ for (const id of unknown) diagnostics.push({ severity: 'warning', code: 'citation-unknown', message: `Citation @${id} is not in the bibliography` })
195
+
196
+ const wanted = [nocite].flat().map(id => String(id).replace(/^@/, ''))
197
+ const uncited = wanted.includes('*') ? [...byId.keys()] : wanted.filter(id => {
198
+ if (byId.has(id)) return true
199
+ diagnostics.push({ severity: 'warning', code: 'citation-unknown', message: `nocite: ${id} is not in the bibliography` })
200
+ return false
201
+ })
202
+ if (!clusters.length && !uncited.length) return result
203
+
204
+ const { plugins } = await import('@citation-js/core')
205
+ await import('@citation-js/plugin-csl')
206
+ const { default: CSL } = await import('citeproc')
207
+ const config = plugins.config.get('@csl')
208
+
209
+ let style = config.styles.get('apa')
210
+ const styleName = csl ?? 'apa'
211
+ if (/\.csl$/i.test(styleName)) {
212
+ try { style = readFileSync(resolve(deckDir, styleName), 'utf-8') } catch (error) {
213
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName}: ${error.code === 'ENOENT' ? 'file not found' : error.message}; using apa` })
214
+ }
215
+ } else if (config.styles.has(styleName)) style = config.styles.get(styleName)
216
+ else diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Unknown citation style "${styleName}" (built in: ${config.styles.list().join(', ')}, or a .csl file); using apa` })
217
+
218
+ let locale = pickLocale(lang, config.locales.list())
219
+ if (!locale) {
220
+ diagnostics.push({ severity: 'warning', code: 'citation-locale', message: `No citation locale for "${lang}" (available: ${config.locales.list().join(', ')}); using en-US` })
221
+ locale = 'en-US'
222
+ }
223
+
224
+ let engine
225
+ try {
226
+ engine = new CSL.Engine({
227
+ retrieveItem: id => byId.get(id),
228
+ retrieveLocale: id => config.locales.get(id) ?? config.locales.get('en-US'),
229
+ }, style, locale, true)
230
+ } catch (error) {
231
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName}: ${error.message}` })
232
+ return result
233
+ }
234
+ engine.setOutputFormat('html')
235
+ engine.opt.development_extensions.wrap_url_and_doi = true
236
+
237
+ // In order, so numbering and disambiguation (2020a, 2020b) come out right.
238
+ const html = new Map()
239
+ const done = []
240
+ clusters.forEach((cluster, index) => {
241
+ // A mixed citation formats its known works; the runtime marks the others.
242
+ const known = cluster.items.filter(item => byId.has(item.id))
243
+ if (!known.length) return
244
+ const citationItems = known.map(item => toCiteItem(cluster.narrative ? { ...item, suppressAuthor: true } : item))
245
+ const citation = { citationID: `c${index}`, citationItems, properties: { noteIndex: 0 } }
246
+ const [, updates] = engine.processCitationCluster(citation, done, [])
247
+ done.push([citation.citationID, 0])
248
+ for (const [, text, id] of updates) html.set(id, text)
249
+ })
250
+ clusters.forEach((cluster, index) => {
251
+ const text = html.get(`c${index}`)
252
+ if (text == null || Object.hasOwn(result.clusters, cluster.raw)) return
253
+ if (!cluster.narrative) { result.clusters[cluster.raw] = text; return }
254
+ const { id } = cluster.items[0]
255
+ let author = engine.makeCitationCluster([{ id, 'author-only': true }])
256
+ if (!author || /NO_PRINTED_FORM/.test(author)) author = namesOf(byId.get(id))
257
+ result.clusters[cluster.raw] = `${author} ${text}`
258
+ })
259
+
260
+ const cited = citedKeys(clusters).filter(id => byId.has(id))
261
+ for (const id of cited) result.singles[id] = engine.makeCitationCluster([{ id }])
262
+ if (uncited.length) engine.updateUncitedItems(uncited)
263
+ const bibliography = engine.makeBibliography()
264
+ if (!bibliography) {
265
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName} has no reference list` })
266
+ return result
267
+ }
268
+ const [meta, entries] = bibliography
269
+ meta.entry_ids.forEach(([id], i) => {
270
+ result.entries[id] = entries[i].trim()
271
+ result.order.push(id)
272
+ })
273
+ return result
274
+ }
275
+
276
+ // Everything the deck's `bibliography`, `csl`, `nocite` and `lang` ask for,
277
+ // or null when the deck has no bibliography.
278
+ export async function resolveBibliography(deckPath, source) {
279
+ let deckConfig
280
+ try { deckConfig = parseSlides(source).deckConfig ?? {} } catch { return null }
281
+ const files = bibliographyFiles(deckPath, deckConfig)
282
+ if (!files.length) return null
283
+ const loaded = await loadBibliography(files)
284
+ const formatted = await formatBibliography({
285
+ items: loaded.items, texts: deckTexts(source), csl: deckConfig.csl, lang: deckConfig.lang,
286
+ nocite: deckConfig.nocite ?? [], deckDir: dirname(resolve(deckPath)),
287
+ })
288
+ return { ...formatted, diagnostics: [...loaded.diagnostics, ...formatted.diagnostics], files, style: styleFile(deckPath, deckConfig) }
289
+ }
@@ -10,6 +10,7 @@ import { paletteProblems } from '../extensions/contrast.js'
10
10
  import { collectLocalAssetRefs } from './slidesPlugin.js'
11
11
  import { componentFolders, isFolder } from './components.js'
12
12
  import { inkFileFor, validateInk, normalizeInk, orphanIds } from '../core/ink.js'
13
+ import { resolveBibliography } from './bibliography.js'
13
14
 
14
15
  export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath, 'utf8')) {
15
16
  const deck = parseSlides(source)
@@ -40,3 +41,14 @@ export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath
40
41
  }
41
42
  return { deck, diagnostics }
42
43
  }
44
+
45
+ // What the deck's references cannot use: missing or unreadable files, an
46
+ // unknown style or language, citations of keys the files do not have.
47
+ export async function bibliographyDiagnostics(slidesPath, source = readFileSync(slidesPath, 'utf8')) {
48
+ const bibliography = await resolveBibliography(slidesPath, source)
49
+ return (bibliography?.diagnostics ?? []).map(d => {
50
+ const at = d.code === 'citation-unknown' ? source.indexOf('@' + d.message.match(/@(\S+)/)?.[1]) : -1
51
+ const line = at < 0 ? 1 : source.slice(0, at).split('\n').length
52
+ return { ...d, line, column: 1 }
53
+ })
54
+ }
@@ -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
+ }
@@ -5,13 +5,14 @@ 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
11
  import { isOwnWrite } from './ownWrites.js'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { assertDrawings } from './drawings.js'
14
14
  import { marked } from 'marked'
15
+ import { resolveBibliography } from './bibliography.js'
15
16
 
16
17
  const VIRTUAL_ID = 'virtual:slides'
17
18
  const RESOLVED_ID = '\0virtual:slides'
@@ -24,6 +25,8 @@ const INK_ID = 'virtual:deck-ink'
24
25
  const RESOLVED_INK_ID = '\0virtual:deck-ink'
25
26
  const SVGS_ID = 'virtual:deck-svgs'
26
27
  const RESOLVED_SVGS_ID = '\0virtual:deck-svgs'
28
+ const BIB_ID = 'virtual:bibliography'
29
+ const RESOLVED_BIB_ID = '\0virtual:bibliography'
27
30
 
28
31
  const MIME_BY_EXT = {
29
32
  '.png': 'image/png',
@@ -213,11 +216,14 @@ export function deckLookOnly(registry, deckConfig = {}) {
213
216
  // extension changes are announced instead of forcing a reload. `source` is
214
217
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
215
218
  // `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 } = {}) {
219
+ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, ink = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
217
220
  const abs = resolve(slidesPath)
218
221
  const readDeck = () => given ?? readFileSync(abs, 'utf-8')
219
222
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
220
- const sourceFor = raw => stripNotes ? stripNotesFrom(raw) : raw
223
+ const sourceFor = raw => {
224
+ const shared = stripNotes ? stripNotesFrom(raw) : raw
225
+ return stripActivities ? stripActivitiesFrom(shared).source : shared
226
+ }
221
227
  const inkPath = inkFileFor(abs)
222
228
  const extensionDirs = extensionRoots(abs).map(root => root.dir)
223
229
  let componentDirs = componentFolders(abs, readDeck()).map(folder => folder.dir)
@@ -233,8 +239,15 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
233
239
  for (const dir of next) if (!allow.includes(dir)) allow.push(dir)
234
240
  return true
235
241
  }
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]
242
+ const ALL_IDS = [RESOLVED_ID, RESOLVED_COMPONENTS_ID, RESOLVED_EXTENSIONS_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
243
+ const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
244
+ // The reference files (and .csl style) the deck names, from the last load.
245
+ let bibFiles = []
246
+ // The editor keeps its page: the formatted references arrive as a hot update.
247
+ const bibUpdate = server => {
248
+ const mod = server.moduleGraph.getModuleById(RESOLVED_BIB_ID)
249
+ return editor && mod ? [mod] : []
250
+ }
238
251
  const invalidate = (server, ids) => {
239
252
  for (const id of ids) {
240
253
  const mod = server.moduleGraph.getModuleById(id)
@@ -266,6 +279,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
266
279
  if (id === EXTENSIONS_ID) return RESOLVED_EXTENSIONS_ID
267
280
  if (id === INK_ID) return RESOLVED_INK_ID
268
281
  if (id === SVGS_ID) return RESOLVED_SVGS_ID
282
+ if (id === BIB_ID) return RESOLVED_BIB_ID
269
283
  },
270
284
  load(id) {
271
285
  if (id === RESOLVED_ID) {
@@ -295,6 +309,14 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
295
309
  if (ink && existsSync(inkPath)) {
296
310
  try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
297
311
  }
312
+ // Without the activity slides, drawings follow their slides to their new ids.
313
+ if (stripActivities) {
314
+ try {
315
+ const { ids, removed } = stripActivitiesFrom(readDeck())
316
+ const gone = new Set(removed)
317
+ data = { ...data, slides: Object.fromEntries(Object.entries(data.slides).filter(([id]) => !gone.has(id)).map(([id, strokes]) => [ids[id] ?? id, strokes])) }
318
+ } catch {}
319
+ }
298
320
  return `export default ${JSON.stringify(data)}\nexport const inkFileName = ${JSON.stringify(basename(inkPath))}`
299
321
  }
300
322
  if (id === RESOLVED_SVGS_ID) {
@@ -303,6 +325,18 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
303
325
  for (const { file } of Object.values(svgs)) this.addWatchFile(file)
304
326
  return `export default ${JSON.stringify(Object.fromEntries(Object.entries(svgs).map(([ref, { markup }]) => [ref, markup])))}`
305
327
  }
328
+ // The deck's citations and references, formatted with citeproc here so
329
+ // that none of it travels in the deck (bibliography.js).
330
+ if (id === RESOLVED_BIB_ID) {
331
+ watchDeck(this)
332
+ return resolveBibliography(abs, sourceFor(readDeck())).then(bibliography => {
333
+ bibFiles = bibliography ? [...bibliography.files, bibliography.style].filter(Boolean) : []
334
+ for (const file of bibFiles) if (existsSync(file)) this.addWatchFile(file)
335
+ if (bibliography?.diagnostics.length) this.warn(bibliography.diagnostics.map(d => d.message).join('\n'))
336
+ const data = bibliography && { ids: bibliography.ids, clusters: bibliography.clusters, singles: bibliography.singles, entries: bibliography.entries, order: bibliography.order }
337
+ return `export default ${JSON.stringify(data)}`
338
+ })
339
+ }
306
340
  if (id === RESOLVED_EXTENSIONS_ID) {
307
341
  const registry = loadRegistry(abs)
308
342
  for (const record of registry.records) this.addWatchFile(record.file)
@@ -350,12 +384,18 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
350
384
  const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
351
385
  if (own) {
352
386
  invalidate(server, changed === abs ? SOURCE_IDS : [RESOLVED_INK_ID])
353
- return []
387
+ return changed === abs ? bibUpdate(server) : []
354
388
  }
355
389
  if (changed === inkPath) {
356
390
  reload(server, [RESOLVED_INK_ID], changed)
357
391
  return []
358
392
  }
393
+ if (bibFiles.includes(changed)) {
394
+ invalidate(server, [RESOLVED_BIB_ID])
395
+ if (editor) return bibUpdate(server)
396
+ server.ws.send({ type: 'full-reload' })
397
+ return []
398
+ }
359
399
  if (changed === abs) {
360
400
  if (followComponentFolders(server)) {
361
401
  reload(server, ALL_IDS, changed)
@@ -363,7 +403,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
363
403
  }
364
404
  // The deck sets the ink's design size and which SVGs are inlined.
365
405
  invalidate(server, SOURCE_IDS)
366
- if (editor) return []
406
+ if (editor) return bibUpdate(server)
367
407
  server.ws.send({ type: 'full-reload' })
368
408
  }
369
409
  },
@@ -14,13 +14,13 @@ import { sampleDeck, SAMPLE_PICTURE } 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)
@@ -120,7 +122,7 @@ export async function withSampleDeck({ extensions = {} } = {}, use) {
120
122
  } finally { rmSync(temp, { recursive: true, force: true }) }
121
123
  }
122
124
 
123
- const settle = page => page.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 60))))')
125
+ export const settle = page => page.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 60))))')
124
126
 
125
127
  // Every slide of each look ({ theme, palette }), light and dark; returns the
126
128
  // problems found, one line each.
package/src/cli/args.js CHANGED
@@ -9,11 +9,12 @@ 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 },
@@ -28,6 +29,19 @@ export function validateServerOrigin(address) {
28
29
  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
30
  }
30
31
 
32
+ // Slide numbers from "3", "3,5" or "2-4": in order, each once.
33
+ export function parseSlideNumbers(text) {
34
+ const numbers = []
35
+ for (const part of String(text).split(',').map(part => part.trim()).filter(Boolean)) {
36
+ const range = /^(\d+)(?:-(\d+))?$/.exec(part)
37
+ if (!range) throw new Error(`--slide takes slide numbers such as 3, 3,5 or 2-4, not "${part}"`)
38
+ const [from, to] = [Number(range[1]), Number(range[2] ?? range[1])]
39
+ if (!from || to < from) throw new Error(`--slide takes slide numbers from 1, with the smaller one first: not "${part}"`)
40
+ for (let n = from; n <= to; n++) if (!numbers.includes(n)) numbers.push(n)
41
+ }
42
+ return numbers
43
+ }
44
+
31
45
  export function parseArgs(command, argv) {
32
46
  const schema = commands[command]
33
47
  if (!schema) throw new Error(`Unknown command: "${command}"`)
@@ -70,8 +84,10 @@ export function parseArgs(command, argv) {
70
84
  validateServerOrigin(options['--server'])
71
85
  }
72
86
  if (command === 'build' && options['--notes'] && !options['--reader']) throw new Error('--notes requires --reader for mdeck build')
87
+ if (command === 'build' && options['--no-polls'] && !options['--reader']) throw new Error('--no-polls requires --reader for mdeck build')
73
88
  if (command === 'build' && options['--launchers'] && options['--single-file']) throw new Error('--launchers is only available for folders, not with --single-file.')
74
89
  if (command === 'list' && positionals.length === 2 && !['layouts', 'themes', 'palettes'].includes(positionals[0])) throw new Error('Use mdeck list [layouts|themes|palettes] [slides.md]')
90
+ if (command === 'snapshot' && options['--dark'] && options['--light']) throw new Error('Choose --dark or --light')
75
91
  if (command === 'skill' && options['--print'] && options['--install']) throw new Error('Choose --print or --install')
76
92
  return { options, positionals }
77
93
  }