mdeck 3.1.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.
- package/CHANGELOG.md +16 -0
- package/assets/base.css +24 -0
- package/assets/extensions/themes/academic/styles.css +15 -0
- package/docs/reference/authoring.md +75 -2
- package/docs/reference/extensions.md +3 -1
- package/docs/site/content/audience.md +6 -0
- package/docs/site/content/commands.md +28 -4
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/more-content.md +22 -0
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/troubleshooting.md +1 -1
- package/docs/site/content/views.md +3 -1
- package/examples/citations/refs.bib +30 -0
- package/examples/citations/refs.yml +21 -0
- package/examples/citations/slides.md +43 -0
- package/examples/citations/slides.pdf +0 -0
- package/package.json +5 -1
- package/skills/write-slides/SKILL.md +6 -2
- package/src/build/bibliography.js +289 -0
- package/src/build/check.js +12 -0
- package/src/build/renderCheck.js +98 -0
- package/src/build/slidesPlugin.js +47 -7
- package/src/build/themeCheck.js +18 -6
- package/src/cli/args.js +20 -4
- package/src/cli/index.js +39 -10
- package/src/cli/themes.js +9 -3
- package/src/components/Bibliography.jsx +23 -0
- package/src/components/Poll.jsx +25 -2
- package/src/components/QrCodeTag.jsx +11 -2
- package/src/components/VideoPlayer.jsx +16 -0
- package/src/components/activity.jsx +2 -2
- package/src/components/citation.css +28 -0
- package/src/components/poll.css +12 -2
- package/src/core/citations.js +108 -0
- package/src/core/editDeck.js +43 -0
- package/src/core/ink.js +16 -0
- package/src/core/labels.js +16 -0
- package/src/core/parseSlides.js +1 -1
- package/src/core/urls.js +2 -2
- package/src/core/validateDeck.js +7 -1
- package/src/layouts/layoutApi.jsx +13 -3
- package/src/live/answer/answer.css +1 -0
- package/src/live/answer/answer.js +2 -0
- package/src/live/client.js +25 -2
- package/src/runtime/ReaderView.jsx +2 -1
- package/src/runtime/citations.js +60 -0
- package/src/runtime/deck-stage.js +5 -2
- package/src/runtime/editorBridge.js +7 -1
- package/src/runtime/follow.js +89 -0
- package/src/runtime/ink/bus.js +7 -0
- package/src/runtime/ink/room.js +8 -0
- package/src/runtime/ink/select.js +4 -2
- package/src/runtime/main.jsx +19 -7
- package/src/runtime/markedSetup.js +7 -0
- 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
|
+
}
|
package/src/build/check.js
CHANGED
|
@@ -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 =>
|
|
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
|
},
|
package/src/build/themeCheck.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
@@ -147,15 +149,25 @@ export function checkLooks(looks, { extensions = {} } = {}) {
|
|
|
147
149
|
})
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// The size of a theme's icon in lists, in CSS pixels; drawn at twice that
|
|
153
|
+
// for sharp screens.
|
|
154
|
+
export const ICON = { width: 96, height: 54, scale: 2 }
|
|
155
|
+
|
|
150
156
|
// Pictures of looks on the sample deck, as WebP: { file, theme, palette,
|
|
151
|
-
// slide } each, slide counting from 1.
|
|
157
|
+
// slide, icon } each, slide counting from 1. `icon`, if given, is a second
|
|
158
|
+
// file with the same slide at icon size: small enough for a list, and still
|
|
159
|
+
// showing the theme's type, colours and shapes.
|
|
152
160
|
export function renderPreviews(shots, { extensions = {}, width = 960, height = 540 } = {}) {
|
|
153
161
|
return withSampleDeck({ extensions }, async ({ open }) => {
|
|
154
|
-
for (const { file, theme, palette = '', slide = 1 } of shots) {
|
|
162
|
+
for (const { file, theme, palette = '', slide = 1, icon = null } of shots) {
|
|
155
163
|
const page = await open({ theme, palette })
|
|
156
164
|
if (slide > 1) { await page.evaluate(`document.querySelector('deck-stage').goTo(${slide - 1})`); await settle(page) }
|
|
157
165
|
mkdirSync(resolve(file, '..'), { recursive: true })
|
|
158
166
|
writeFileSync(file, await page.screenshot({ width, height, format: 'webp' }))
|
|
167
|
+
if (icon) {
|
|
168
|
+
mkdirSync(resolve(icon, '..'), { recursive: true })
|
|
169
|
+
writeFileSync(icon, await page.screenshot({ ...ICON, format: 'webp' }))
|
|
170
|
+
}
|
|
159
171
|
await page.close()
|
|
160
172
|
}
|
|
161
173
|
})
|
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
|
}
|