mdeck 3.11.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/assets/base.css +2 -0
- package/docs/reference/authoring.md +48 -7
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +4 -2
- package/docs/site/content/drawing.md +4 -4
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +3 -1
- package/docs/site/content/presenting.md +29 -8
- package/docs/site/content/writing.md +27 -0
- package/examples/showcase/slides.ar.md +5 -5
- package/examples/showcase/slides.de.md +5 -5
- package/examples/showcase/slides.de.results.json +27 -0
- package/examples/showcase/slides.en.md +5 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +4 -1
- package/src/build/check.js +27 -6
- package/src/build/components.js +5 -3
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +64 -16
- package/src/build/timingPlugin.js +34 -18
- package/src/cli/args.js +3 -3
- package/src/cli/index.js +49 -10
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/icons.js +6 -1
- package/src/core/labels.js +3 -0
- package/src/core/parseSlides.js +14 -4
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/validateDeck.js +6 -2
- package/src/home/Home.jsx +38 -93
- package/src/home/home.css +5 -17
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +12 -1
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +29 -7
- package/src/live/server.js +15 -1
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/deck-stage.js +322 -17
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/room.js +6 -6
- package/src/runtime/main.jsx +396 -303
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/useTalkTimer.js +4 -2
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Decks made of other decks. A slide that is nothing but
|
|
2
|
+
//
|
|
3
|
+
// :::include ./lectures/02-graphs.md
|
|
4
|
+
// :::include ./lectures/02-graphs.md slides=3-7
|
|
5
|
+
//
|
|
6
|
+
// stands for the slides of that file (core/parseSlides.js includeOf), which
|
|
7
|
+
// stays a deck of its own: `mdeck run 02-graphs.md` presents it alone, and its
|
|
8
|
+
// settings (theme, title, time …) count only then; included, only its slides
|
|
9
|
+
// are taken. `slides=` keeps some of them, numbered as that file shows them.
|
|
10
|
+
//
|
|
11
|
+
// Everything that reads a deck to show it reads it through expandDeck, which
|
|
12
|
+
// puts the slides in place of the include: the dev server, builds, `mdeck
|
|
13
|
+
// check`, the launch page. What writes into a deck (the editor, saved times,
|
|
14
|
+
// slide ids for drawings) writes into the file a slide is written in; the
|
|
15
|
+
// editor shows an include as a card naming the file.
|
|
16
|
+
//
|
|
17
|
+
// So that a slide keeps its drawings however many slides come before it, the
|
|
18
|
+
// expanded deck gives every slide an id: a slide of the deck itself keeps the
|
|
19
|
+
// one it has in its file (slide-3 stays slide-3), an included slide gets its
|
|
20
|
+
// file's name in front (graphs-slide-3, graphs-dijkstra). `origins` says, for
|
|
21
|
+
// each such id, the file the slide is written in and its id there.
|
|
22
|
+
//
|
|
23
|
+
// What an included deck's slides need from its settings comes along; how
|
|
24
|
+
// it looks and is presented stays the including deck's. `hotspots` and
|
|
25
|
+
// `time` become each included slide's own, unless it has one: a slide
|
|
26
|
+
// without a time gets its share of the included deck's `time`. The
|
|
27
|
+
// references (`bibliography`, `nocite`, and `csl` when the deck has none),
|
|
28
|
+
// the `components` folders (and a components/ folder beside the file) and
|
|
29
|
+
// callout names of its own (`callouts`, where the deck does not name them)
|
|
30
|
+
// are added to the including deck's settings. Theme, palette, size,
|
|
31
|
+
// language, labels, `show`, `meta` and the server stay the including deck's.
|
|
32
|
+
//
|
|
33
|
+
// Paths of pictures and media in an included file are written from its own
|
|
34
|
+
// folder; they are rewritten to work from the deck's. A deck in several
|
|
35
|
+
// languages (talk.de.md) takes an included file's twin in its language
|
|
36
|
+
// (02-graphs.de.md) where there is one.
|
|
37
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
38
|
+
import { resolve, dirname, relative, basename, sep } from 'node:path'
|
|
39
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
40
|
+
import { setSlideMeta, setDeckConfig, shareSlides } from '../core/editDeck.js'
|
|
41
|
+
import { diagnostic } from '../core/source.js'
|
|
42
|
+
import { languageOfFile } from '../core/languages.js'
|
|
43
|
+
import { planFor, formatTime } from '../core/timing.js'
|
|
44
|
+
import { CALLOUT_TYPES } from '../core/labels.js'
|
|
45
|
+
import { isPlainObject, DECK_KEYS, SLIDE_KEYS } from '../core/parseSlides.js'
|
|
46
|
+
import { parseSlideNumbers } from '../cli/args.js'
|
|
47
|
+
import { rewriteAssetRefs } from './slidesPlugin.js'
|
|
48
|
+
import { deckTexts } from './bibliography.js'
|
|
49
|
+
import { scanCitations, citedKeys } from '../core/citations.js'
|
|
50
|
+
import { replaceTagsIn } from '../core/tags.js'
|
|
51
|
+
|
|
52
|
+
const read = file => readFileSync(file, 'utf8')
|
|
53
|
+
|
|
54
|
+
/** Whether the deck text has an include at all. */
|
|
55
|
+
export const hasIncludes = source => parseSlides(source).slides.some(slide => slide.include)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The deck in `file` (or `source`, its text) with its includes in place:
|
|
59
|
+
* { source, files, origins, diagnostics }. `files` are the included files
|
|
60
|
+
* (to watch), `origins` maps each slide id to { file, id } (null when the
|
|
61
|
+
* deck includes nothing), `diagnostics` are problems with the includes:
|
|
62
|
+
* { file, line, severity, code, message }. Without includes, `source` is
|
|
63
|
+
* the deck's text unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export function expandDeck(file, { source = null, lang = null } = {}) {
|
|
66
|
+
const abs = resolve(file)
|
|
67
|
+
const text = source ?? read(abs)
|
|
68
|
+
if (!hasIncludes(text)) return { source: text, files: [], origins: null, diagnostics: [] }
|
|
69
|
+
const deck = parseSlides(text)
|
|
70
|
+
const state = { root: dirname(abs), files: new Set(), diagnostics: [], prefixes: new Set(), origins: {}, settings: [] }
|
|
71
|
+
const language = lang ?? languageOfFile(basename(abs))?.lang ?? (typeof deck.deckConfig.lang === 'string' ? deck.deckConfig.lang : null)
|
|
72
|
+
let expanded = expandFile(abs, text, { state, language, stack: [abs], prefix: null, top: true })
|
|
73
|
+
const patch = addedSettings(deck.deckConfig, state)
|
|
74
|
+
if (patch) expanded = setDeckConfig(parseSlides(expanded), patch)
|
|
75
|
+
return { source: expanded, files: [...state.files], origins: state.origins, diagnostics: state.diagnostics }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The deck's text with its includes in place, or its text as it is when there are none. */
|
|
79
|
+
export const expandedSource = (file, options) => expandDeck(file, options).source
|
|
80
|
+
|
|
81
|
+
// One file's slides, ids given, its own includes in place, paths written from
|
|
82
|
+
// the deck's folder; the top file keeps its settings in front.
|
|
83
|
+
function expandFile(abs, source, { state, language, stack, prefix, top }) {
|
|
84
|
+
let deck = parseSlides(source)
|
|
85
|
+
// Where each include is written, for messages, before ids change the lines.
|
|
86
|
+
const lines = deck.slides.filter(slide => slide.include).map(slide => {
|
|
87
|
+
const at = slide.source.start + source.slice(slide.source.start, slide.source.end).indexOf(':::')
|
|
88
|
+
return diagnostic(source, '', '', at).line
|
|
89
|
+
})
|
|
90
|
+
// Each slide gets the id it has in its own file, written out, with the
|
|
91
|
+
// file's name in front when it is included.
|
|
92
|
+
for (const slide of [...deck.slides]) {
|
|
93
|
+
if (slide.include) continue
|
|
94
|
+
const own = slide.id
|
|
95
|
+
const id = prefix ? `${prefix}-${own}` : own
|
|
96
|
+
state.origins[id] = { file: abs, id: own }
|
|
97
|
+
if (slide.meta.id === id) continue
|
|
98
|
+
source = setSlideMeta(deck, own, { id })
|
|
99
|
+
deck = parseSlides(source)
|
|
100
|
+
}
|
|
101
|
+
const fromHere = dirname(abs)
|
|
102
|
+
const moved = fromHere !== state.root
|
|
103
|
+
const own = text => moved ? rewriteAssetRefs(text, ref => relative(state.root, resolve(fromHere, ref)).split(sep).join('/')) : text
|
|
104
|
+
let included = 0
|
|
105
|
+
const chunks = deck.slides.map(slide => {
|
|
106
|
+
const text = deck.source.slice(slide.source.start, slide.source.end)
|
|
107
|
+
if (!slide.include) return own(text)
|
|
108
|
+
return includeText(abs, slide, lines[included++], { state, language, stack, prefix }) ?? own(text)
|
|
109
|
+
}).filter(chunk => chunk.trim())
|
|
110
|
+
const body = chunks.map(chunk => chunk.replace(/^\s*\n/, '').replace(/\s*$/, '\n')).join('\n---\n\n')
|
|
111
|
+
return top ? deck.source.slice(0, deck.slides[0]?.source.start ?? 0) + body : body
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The slides an include stands for, or null (with a diagnostic) when they cannot be had.
|
|
115
|
+
function includeText(abs, slide, line, { state, language, stack, prefix }) {
|
|
116
|
+
const { file, slides } = slide.include
|
|
117
|
+
const problem = (code, message) => {
|
|
118
|
+
state.diagnostics.push({ file: abs, line, severity: 'error', code, message })
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
const target = chooseFile(dirname(abs), file, language)
|
|
122
|
+
if (!existsSync(target)) return problem('missing-include', `Included file not found: ${file}`)
|
|
123
|
+
if (stack.includes(target)) return problem('include-loop', `${file} includes this file again (${[...stack, target].map(f => basename(f)).join(' → ')})`)
|
|
124
|
+
let numbers = null
|
|
125
|
+
if (slides != null) {
|
|
126
|
+
try { numbers = parseSlideNumbers(slides) } catch (error) { return problem('invalid-include', error.message.replace('--slide', 'slides=')) }
|
|
127
|
+
}
|
|
128
|
+
state.files.add(target)
|
|
129
|
+
const childPrefix = uniquePrefix(state, prefix, target)
|
|
130
|
+
const source = read(target)
|
|
131
|
+
const settings = parseSlides(source).deckConfig ?? {}
|
|
132
|
+
state.settings.push({ file: target, settings })
|
|
133
|
+
// `time:` or `hotspots:` alone at the top is the file's, for all its
|
|
134
|
+
// slides; meant for its first slide, it belongs in a :::meta block.
|
|
135
|
+
const keys = Object.keys(settings)
|
|
136
|
+
if (keys.length && keys.every(key => DECK_KEYS.has(key) && SLIDE_KEYS.has(key)) && !state.diagnostics.some(d => d.file === target && d.code === 'include-settings')) {
|
|
137
|
+
state.diagnostics.push({ file: target, line: 1, severity: 'warning', code: 'include-settings', message: `The block at the top (${keys.join(', ')}) is read as this file's settings, for all its slides; for its first slide alone, write it in a :::meta block` })
|
|
138
|
+
}
|
|
139
|
+
let text = ownReferences(inherit(expandFile(target, source, { state, language, stack: [...stack, target], prefix: childPrefix, top: false }), settings), settings)
|
|
140
|
+
if (numbers) {
|
|
141
|
+
try { text = shareSlides(text, { slides: numbers }).source } catch (error) { return problem('invalid-include', `${file}: ${error.message}`) }
|
|
142
|
+
}
|
|
143
|
+
return text
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The included deck's `hotspots` and `time`, as each of its slides' own
|
|
147
|
+
// where it has none; `time` is shared out as the deck itself would (its
|
|
148
|
+
// slides' own times first, the rest in equal parts).
|
|
149
|
+
function inherit(text, { hotspots, time }) {
|
|
150
|
+
if (hotspots == null && time == null) return text
|
|
151
|
+
let deck = parseSlides(text)
|
|
152
|
+
const plan = time == null ? null : planFor(deck.slides, { time })
|
|
153
|
+
for (const [i, slide] of [...deck.slides].entries()) {
|
|
154
|
+
if (slide.include) continue
|
|
155
|
+
const patch = {}
|
|
156
|
+
if (hotspots != null && slide.meta.hotspots == null) patch.hotspots = hotspots
|
|
157
|
+
if (plan?.budget && slide.meta.time == null && plan.perSlide[i] >= 1) patch.time = formatTime(plan.perSlide[i])
|
|
158
|
+
if (!Object.keys(patch).length) continue
|
|
159
|
+
text = setSlideMeta(deck, slide.id, patch)
|
|
160
|
+
deck = parseSlides(text)
|
|
161
|
+
}
|
|
162
|
+
return text
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// An included deck's <bibliography /> lists the works that deck cites (and
|
|
166
|
+
// its `nocite`), as it does when shown alone, not every work the including
|
|
167
|
+
// deck cites: it gets them as `keys`. With `nocite: '*'` it lists them all.
|
|
168
|
+
function ownReferences(text, { nocite }) {
|
|
169
|
+
const named = [nocite ?? []].flat().map(key => String(key).replace(/^@/, ''))
|
|
170
|
+
if (!/<bibliography\b/.test(text) || named.includes('*')) return text
|
|
171
|
+
const keys = [...new Set([...citedKeys(deckTexts(text).flatMap(part => scanCitations(part))), ...named])].join(' ')
|
|
172
|
+
return replaceTagsIn(text, tag => {
|
|
173
|
+
if (tag.closing || tag.name !== 'bibliography' || 'keys' in tag.attrs) return
|
|
174
|
+
return tag.raw.replace(/(\s*)(\/?>)$/, ` keys="${keys}"$1$2`)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// What the included decks add to the including deck's settings, or null.
|
|
179
|
+
function addedSettings(config, { root, settings: included }) {
|
|
180
|
+
const fromRoot = (file, path) => {
|
|
181
|
+
if (typeof path !== 'string' || !path.trim()) return null
|
|
182
|
+
const trimmed = path.trim()
|
|
183
|
+
if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('/')) return trimmed
|
|
184
|
+
return relative(root, resolve(dirname(file), trimmed)).split(sep).join('/') || '.'
|
|
185
|
+
}
|
|
186
|
+
const list = value => [value ?? []].flat()
|
|
187
|
+
const patch = {}
|
|
188
|
+
const bibliography = list(config.bibliography)
|
|
189
|
+
const nocite = list(config.nocite)
|
|
190
|
+
const components = list(config.components)
|
|
191
|
+
const callouts = isPlainObject(config.callouts) ? { ...config.callouts } : {}
|
|
192
|
+
const known = new Set([...CALLOUT_TYPES, ...Object.keys(callouts)].map(name => name.toLowerCase()))
|
|
193
|
+
let csl = config.csl ?? null
|
|
194
|
+
for (const { file, settings } of included) {
|
|
195
|
+
for (const path of list(settings.bibliography)) { const own = fromRoot(file, path); if (own && !bibliography.includes(own)) bibliography.push(own) }
|
|
196
|
+
for (const key of list(settings.nocite)) if (!nocite.includes(key)) nocite.push(key)
|
|
197
|
+
if (csl == null && typeof settings.csl === 'string') csl = patch.csl = /\.csl$/i.test(settings.csl) ? fromRoot(file, settings.csl) : settings.csl
|
|
198
|
+
const folders = [...list(settings.components), ...(existsSync(resolve(dirname(file), 'components')) ? ['components'] : [])]
|
|
199
|
+
for (const path of folders) { const own = fromRoot(file, path); if (own && !components.includes(own) && resolve(root, own) !== resolve(root, 'components')) components.push(own) }
|
|
200
|
+
if (isPlainObject(settings.callouts)) {
|
|
201
|
+
for (const [name, title] of Object.entries(settings.callouts)) {
|
|
202
|
+
if (known.has(name.toLowerCase())) continue
|
|
203
|
+
callouts[name] = title
|
|
204
|
+
known.add(name.toLowerCase())
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (bibliography.length > list(config.bibliography).length) patch.bibliography = bibliography
|
|
209
|
+
if (nocite.length > list(config.nocite).length) patch.nocite = nocite
|
|
210
|
+
if (components.length > list(config.components).length) patch.components = components
|
|
211
|
+
if (Object.keys(callouts).length > Object.keys(isPlainObject(config.callouts) ? config.callouts : {}).length) patch.callouts = callouts
|
|
212
|
+
return Object.keys(patch).length ? patch : null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The file in the deck's language where there is one: ./02-graphs.md in a
|
|
216
|
+
// German deck is ./02-graphs.de.md beside it.
|
|
217
|
+
function chooseFile(dir, ref, language) {
|
|
218
|
+
const abs = resolve(dir, ref)
|
|
219
|
+
if (language && /\.md$/i.test(abs) && !languageOfFile(basename(abs))) {
|
|
220
|
+
const twin = abs.replace(/\.md$/i, `.${language}.md`)
|
|
221
|
+
if (existsSync(twin)) return twin
|
|
222
|
+
}
|
|
223
|
+
return abs
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// An id prefix from the file's name, unique in the deck: 02-graphs.md → graphs.
|
|
227
|
+
function uniquePrefix(state, parent, file) {
|
|
228
|
+
const name = languageOfFile(basename(file))?.name ?? basename(file).replace(/\.md$/i, '')
|
|
229
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+|-+$/g, '') || 'part'
|
|
230
|
+
const full = parent ? `${parent}-${base}` : base
|
|
231
|
+
let prefix = full, n = 2
|
|
232
|
+
while (state.prefixes.has(prefix)) prefix = `${full}-${n++}`
|
|
233
|
+
state.prefixes.add(prefix)
|
|
234
|
+
return prefix
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* An included file's problems without those in its settings, which count
|
|
239
|
+
* only when it is shown alone (its theme need not be installed for the
|
|
240
|
+
* deck that includes it).
|
|
241
|
+
*/
|
|
242
|
+
export function withoutSettings(source, diagnostics) {
|
|
243
|
+
const range = parseSlides(source).configSource
|
|
244
|
+
if (!range) return diagnostics
|
|
245
|
+
return diagnostics.filter(d => d.offset == null || d.offset < range.start || d.offset > range.end)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Include problems as lines for the terminal, file:line: severity [code] message. */
|
|
249
|
+
export const formatIncludeDiagnostics = diagnostics => diagnostics.map(d => `${d.file}:${d.line}:1: ${d.severity} [${d.code}] ${d.message}`).join('\n')
|
package/src/build/inkPlugin.js
CHANGED
|
@@ -20,6 +20,7 @@ import { slug } from '../live/code.js'
|
|
|
20
20
|
import { inkFileFor, normalizeInk, emptyInk, applyOp, serializeInk } from '../core/ink.js'
|
|
21
21
|
import { hashSource, isAllowedRequest, send, readJson, BACKUP_DIR } from './editorPlugin.js'
|
|
22
22
|
import { markOwnWrite } from './ownWrites.js'
|
|
23
|
+
import { expandDeck } from './include.js'
|
|
23
24
|
|
|
24
25
|
const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/
|
|
25
26
|
const BACKUPS_KEPT = 10
|
|
@@ -119,13 +120,22 @@ export function createInkFile(deckPath, { debounceMs = 300, onChange = () => {}
|
|
|
119
120
|
const renamed = []
|
|
120
121
|
const seen = deckHash
|
|
121
122
|
const alias = new Map(renames.filter(r => r.before === seen).map(r => [r.from, r.to]))
|
|
123
|
+
// The slides of files the deck includes (include.js) are not in its
|
|
124
|
+
// text; their ids carry their file's name and stay where they are.
|
|
125
|
+
let fromIncludes = null
|
|
126
|
+
const included = id => {
|
|
127
|
+
if (!fromIncludes) {
|
|
128
|
+
try { fromIncludes = new Set(Object.entries(expandDeck(abs, { source }).origins ?? {}).filter(([, origin]) => origin.file !== abs).map(([shown]) => shown)) } catch { fromIncludes = new Set() }
|
|
129
|
+
}
|
|
130
|
+
return fromIncludes.has(id)
|
|
131
|
+
}
|
|
122
132
|
for (const raw of Array.isArray(ops) ? ops : []) {
|
|
123
133
|
const op = { ...raw, slideId: alias.get(raw?.slideId) ?? raw?.slideId }
|
|
124
134
|
if (op.type === 'add') {
|
|
125
135
|
const index = deck.slides.findIndex(slide => slide.id === op.slideId)
|
|
126
|
-
if (index < 0) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
|
|
136
|
+
if (index < 0 && !included(op.slideId)) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
|
|
127
137
|
const slide = deck.slides[index]
|
|
128
|
-
if (slide.meta.id == null) {
|
|
138
|
+
if (slide && slide.meta.id == null) {
|
|
129
139
|
// An id by position: only trusted for the deck the device saw.
|
|
130
140
|
if (deckHash !== hash) return { status: 409, error: 'The deck changed; reload', deckHash: hash }
|
|
131
141
|
const id = newSlideId(deck, slide, index)
|
package/src/build/relayAccess.js
CHANGED
|
@@ -5,6 +5,7 @@ import { shareable, resourceKey } from './tunnelClient.js'
|
|
|
5
5
|
import { collectLocalAssetRefs } from './slidesPlugin.js'
|
|
6
6
|
import { parseSlides } from '../core/parseSlides.js'
|
|
7
7
|
import { sessionCode } from '../live/code.js'
|
|
8
|
+
import { expandedSource } from './include.js'
|
|
8
9
|
|
|
9
10
|
const canonical = file => { try { return realpathSync(file) } catch { return null } }
|
|
10
11
|
|
|
@@ -20,7 +21,8 @@ export function createRelayAccess(server, slidesPath) {
|
|
|
20
21
|
let knownSource = null
|
|
21
22
|
return async (address, method) => {
|
|
22
23
|
let source
|
|
23
|
-
|
|
24
|
+
// The deck as shown, so the pictures of included slides can be reached too.
|
|
25
|
+
try { source = expandedSource(slidesPath) } catch { return false }
|
|
24
26
|
if (source !== knownSource) { knownFiles.clear(); knownSource = source }
|
|
25
27
|
const resources = new Set(bootstrap.map(path => resourceKey(at(path))))
|
|
26
28
|
const files = new Map()
|
package/src/build/renderCheck.js
CHANGED
|
@@ -13,8 +13,28 @@ import { launchChrome } from './chrome.js'
|
|
|
13
13
|
import { frameworkRoot } from '../paths.js'
|
|
14
14
|
import { parseSlides } from '../core/parseSlides.js'
|
|
15
15
|
import { MEASURE, RECORDER, settle } from './themeCheck.js'
|
|
16
|
+
import { expandDeck } from './include.js'
|
|
16
17
|
|
|
17
18
|
// Each slide's number, line and heading, for messages.
|
|
19
|
+
// The labels of the deck as shown, its includes in place (include.js); a
|
|
20
|
+
// slide from an included file names that file and its line there.
|
|
21
|
+
export function deckLabels(slidesPath) {
|
|
22
|
+
const abs = resolve(slidesPath)
|
|
23
|
+
const { source, origins } = expandDeck(abs)
|
|
24
|
+
const labels = slideLabels(source)
|
|
25
|
+
if (!origins) return labels
|
|
26
|
+
const byFile = new Map()
|
|
27
|
+
const lineIn = (file, id) => {
|
|
28
|
+
if (!byFile.has(file)) byFile.set(file, slideLabels(readFileSync(file, 'utf8')))
|
|
29
|
+
return byFile.get(file).find(label => label.id === id)?.line ?? 1
|
|
30
|
+
}
|
|
31
|
+
return labels.map(label => {
|
|
32
|
+
const origin = origins[label.id]
|
|
33
|
+
if (!origin) return label
|
|
34
|
+
return { ...label, line: lineIn(origin.file, origin.id), ...(origin.file !== abs ? { file: origin.file } : {}) }
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
export function slideLabels(source) {
|
|
19
39
|
return parseSlides(source).slides.map((slide, index) => {
|
|
20
40
|
// The slide's first written line, past its settings and blank lines.
|
|
@@ -58,7 +78,7 @@ async function withDeckPage(slidesPath, { appearance = '', width = 1600, height
|
|
|
58
78
|
// The problems of each slide, in the deck's own look, as diagnostics
|
|
59
79
|
// ({ severity, code, message, line }) at the line the slide starts.
|
|
60
80
|
export function renderCheck(slidesPath) {
|
|
61
|
-
const labels =
|
|
81
|
+
const labels = deckLabels(slidesPath)
|
|
62
82
|
return withDeckPage(slidesPath, {}, async ({ page, count, show }) => {
|
|
63
83
|
const diagnostics = []
|
|
64
84
|
for (let index = 0; index < count; index++) {
|
|
@@ -70,7 +90,7 @@ export function renderCheck(slidesPath) {
|
|
|
70
90
|
const cut = problems.filter(problem => problem.endsWith(' is cut off at the edge'))
|
|
71
91
|
const shown = problems.filter(problem => !cut.includes(problem))
|
|
72
92
|
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 })
|
|
93
|
+
for (const problem of shown) diagnostics.push({ severity: 'warning', code: 'render', message: `${where}: ${problem}`, line: label.line, ...(label.file ? { file: label.file } : {}) })
|
|
74
94
|
}
|
|
75
95
|
// The deck's own warning about content that does not fit is said above, per slide.
|
|
76
96
|
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 })
|
|
@@ -85,7 +105,7 @@ export function renderCheck(slidesPath) {
|
|
|
85
105
|
// written.
|
|
86
106
|
export async function snapshot(slidesPath, { slides = [], outDir, width = 1280, height = 720, appearance = '', sheet = false } = {}) {
|
|
87
107
|
const name = basename(slidesPath).replace(/\.md$/i, '')
|
|
88
|
-
const labels = sheet ?
|
|
108
|
+
const labels = sheet ? deckLabels(slidesPath) : []
|
|
89
109
|
mkdirSync(outDir, { recursive: true })
|
|
90
110
|
return withDeckPage(slidesPath, { appearance, width, height }, async ({ page, count, show }) => {
|
|
91
111
|
const wanted = slides.length ? slides : Array.from({ length: count }, (_, i) => i + 1)
|
|
@@ -16,6 +16,7 @@ import { marked } from 'marked'
|
|
|
16
16
|
import { scanTags, replaceTagsIn, withAttribute } from '../core/tags.js'
|
|
17
17
|
import { resolveBibliography, sampleBibliography } from './bibliography.js'
|
|
18
18
|
import { languageOfFile, languageFiles } from '../core/languages.js'
|
|
19
|
+
import { expandDeck, withoutSettings } from './include.js'
|
|
19
20
|
|
|
20
21
|
const VIRTUAL_ID = 'virtual:slides'
|
|
21
22
|
const RESOLVED_ID = '\0virtual:slides'
|
|
@@ -174,6 +175,27 @@ function outsideCodeFences(markdown, fn) {
|
|
|
174
175
|
return out + fn(text)
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// The local files a part of a deck names, each changed by `map(ref)` (the new
|
|
179
|
+
// ref, or null to keep it): Markdown pictures (also those in a tag's
|
|
180
|
+
// attributes, such as a poll's options), the `src` of pictures and media, and
|
|
181
|
+
// `image:` and `logo:` settings. For an included file (include.js), whose
|
|
182
|
+
// paths are written from its own folder. Paths from the root (/…) stay.
|
|
183
|
+
export function rewriteAssetRefs(markdown, map) {
|
|
184
|
+
const swap = ref => (isLocalAssetRef(ref) && !ref.startsWith('/') ? map(ref) ?? ref : ref)
|
|
185
|
+
return outsideCodeFences(markdown, text => {
|
|
186
|
+
let out = text.replace(/(!\[[^\]]*\]\()([^)\s]+)/g, (m, head, ref) => head + swap(ref))
|
|
187
|
+
out = replaceTagsIn(out, tag => {
|
|
188
|
+
if (tag.closing || !(PICTURE_TAGS.has(tag.name) || MEDIA_TAGS.has(tag.name)) || !tag.attrs.src) return
|
|
189
|
+
const next = swap(tag.attrs.src)
|
|
190
|
+
return next === tag.attrs.src ? undefined : withAttribute(tag, 'src', next)
|
|
191
|
+
})
|
|
192
|
+
return out.replace(/^(\s*)(image|logo):\s*(["']?)([^"'\n]+)\3\s*$/gm, (m, indent, key, quote, value) => {
|
|
193
|
+
const next = swap(value.trim())
|
|
194
|
+
return next === value.trim() ? m : `${indent}${key}: ${quote || '"'}${next}${quote || '"'}`
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
export function maybeInlineAssets(markdown, abs, { inlineImages, inlineMedia }) {
|
|
178
200
|
if (!inlineImages && !inlineMedia) return markdown
|
|
179
201
|
const baseDir = dirname(abs)
|
|
@@ -232,7 +254,17 @@ export function deckLanguages(abs) {
|
|
|
232
254
|
// the deck's other languages along, for `mdeck run`; a built file has one.
|
|
233
255
|
export function slidesPlugin(slidesPath, { languages = false, inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, slides = null, ink = true, results = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
|
|
234
256
|
const abs = resolve(slidesPath)
|
|
235
|
-
|
|
257
|
+
// The deck as shown: other files it includes in place (include.js).
|
|
258
|
+
const readRaw = () => given ?? readFileSync(abs, 'utf-8')
|
|
259
|
+
const expand = (file, source) => given === null ? expandDeck(file, { source }) : { source, files: [], diagnostics: [] }
|
|
260
|
+
const readDeck = () => expand(abs, readRaw()).source
|
|
261
|
+
// The files the deck and its other languages include, from the last load;
|
|
262
|
+
// the dev server may serve their folders, for their pictures.
|
|
263
|
+
let included = [], devServer = null
|
|
264
|
+
const allowIncluded = () => {
|
|
265
|
+
const allow = devServer?.config.server.fs.allow
|
|
266
|
+
if (allow) for (const dir of included.map(file => dirname(file))) if (!allow.includes(dir)) allow.push(dir)
|
|
267
|
+
}
|
|
236
268
|
const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
|
|
237
269
|
const sourceFor = raw => {
|
|
238
270
|
const shared = stripNotes ? stripNotesFrom(raw) : raw
|
|
@@ -286,6 +318,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
286
318
|
name: 'vite-plugin-slides',
|
|
287
319
|
configResolved(config) { serving = config.command === 'serve' },
|
|
288
320
|
configureServer(server) {
|
|
321
|
+
devServer = server
|
|
289
322
|
server.watcher.add([abs, inkPath, ...watchDirs()])
|
|
290
323
|
const refresh = file => {
|
|
291
324
|
if (isWatched(file)) reload(server, ALL_IDS, resolve(file))
|
|
@@ -308,28 +341,35 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
308
341
|
load(id) {
|
|
309
342
|
if (id === RESOLVED_ID) {
|
|
310
343
|
watchDeck(this)
|
|
311
|
-
const raw =
|
|
344
|
+
const raw = readRaw()
|
|
312
345
|
const registry = loadRegistry(abs)
|
|
313
346
|
for (const warning of registry.warnings) this.warn(warning)
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
347
|
+
const manifests = { layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette') }
|
|
348
|
+
// Each file is checked as it is written, so that messages name its lines.
|
|
349
|
+
const expansion = expand(abs, raw)
|
|
350
|
+
const diagnostics = [...validateDeck(parseSlides(raw), manifests), ...expansion.diagnostics]
|
|
351
|
+
for (const file of expansion.files) {
|
|
352
|
+
const text = readFileSync(file, 'utf-8')
|
|
353
|
+
diagnostics.push(...withoutSettings(text, validateDeck(parseSlides(text), manifests)).map(d => ({ ...d, file })))
|
|
354
|
+
}
|
|
317
355
|
const errors = diagnostics.filter(d => d.severity === 'error')
|
|
318
356
|
if (errors.length && !editor) throw new Error(formatDiagnostics(errors, abs))
|
|
319
357
|
if (diagnostics.length) this.warn(formatDiagnostics(diagnostics, abs))
|
|
320
|
-
const source = maybeInlineAssets(sourceFor(
|
|
358
|
+
const source = maybeInlineAssets(sourceFor(expansion.source), abs, { inlineImages, inlineMedia })
|
|
321
359
|
// The other languages are read as they are: `mdeck check` checks each file.
|
|
322
360
|
const others = otherLanguages()
|
|
323
361
|
languageWatch = others.map(({ file }) => file)
|
|
324
362
|
for (const file of languageWatch) this.addWatchFile(file)
|
|
363
|
+
const otherSources = others.map(({ lang, file }) => ({ lang, file, ...expand(file, readFileSync(file, 'utf-8')) }))
|
|
364
|
+
included = [...new Set([...expansion.files, ...otherSources.flatMap(other => other.files)])]
|
|
365
|
+
for (const file of included) this.addWatchFile(file)
|
|
366
|
+
allowIncluded()
|
|
325
367
|
for (const { file } of others) {
|
|
326
|
-
const found = validateDeck(parseSlides(readFileSync(file, 'utf-8')),
|
|
327
|
-
layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette'),
|
|
328
|
-
})
|
|
368
|
+
const found = validateDeck(parseSlides(readFileSync(file, 'utf-8')), manifests)
|
|
329
369
|
if (found.length) this.warn(formatDiagnostics(found, file))
|
|
330
370
|
}
|
|
331
371
|
const translations = others.length && fileLanguage
|
|
332
|
-
? { [fileLanguage]: source, ...Object.fromEntries(
|
|
372
|
+
? { [fileLanguage]: source, ...Object.fromEntries(otherSources.map(({ lang, file, source: text }) => [lang, maybeInlineAssets(sourceFor(text), file, { inlineImages, inlineMedia })])) }
|
|
333
373
|
: {}
|
|
334
374
|
return `export default ${JSON.stringify(source)}\nexport const language = ${JSON.stringify(fileLanguage)}\nexport const languages = ${JSON.stringify(translations)}`
|
|
335
375
|
}
|
|
@@ -341,7 +381,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
341
381
|
// import. The dev server's watcher notices the file once it appears.
|
|
342
382
|
if (existsSync(inkPath)) this.addWatchFile(inkPath)
|
|
343
383
|
let size = {}
|
|
344
|
-
try { const config = parseSlides(
|
|
384
|
+
try { const config = parseSlides(readRaw()).deckConfig ?? {}; size = { width: config.width ?? 1920, height: config.height ?? 1080 } } catch {}
|
|
345
385
|
let data = emptyInk(size)
|
|
346
386
|
if (ink && existsSync(inkPath)) {
|
|
347
387
|
try { data = normalizeInk(JSON.parse(readFileSync(inkPath, 'utf-8')), size) } catch (error) { this.warn(`${inkPath}: ${error.message}; showing no ink`) }
|
|
@@ -389,7 +429,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
389
429
|
for (const record of registry.records) this.addWatchFile(record.file)
|
|
390
430
|
if (!deckLook) return generateExtensionsModule(registry)
|
|
391
431
|
let config = {}
|
|
392
|
-
try { config = parseSlides(
|
|
432
|
+
try { config = parseSlides(readRaw()).deckConfig ?? {} } catch {}
|
|
393
433
|
return generateExtensionsModule(deckLookOnly(registry, config))
|
|
394
434
|
}
|
|
395
435
|
if (id === RESOLVED_COMPONENTS_ID) {
|
|
@@ -409,7 +449,7 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
409
449
|
async transformIndexHtml(html, context) {
|
|
410
450
|
if (!/(^|[\/])index\.html$/.test(context.filename ?? context.path ?? '')) return html
|
|
411
451
|
let config = {}
|
|
412
|
-
try { config = parseSlides(
|
|
452
|
+
try { config = parseSlides(readRaw()).deckConfig ?? {} } catch {}
|
|
413
453
|
const lang = config.lang ?? fileLanguage
|
|
414
454
|
if (typeof lang === 'string' && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) html = html.replace(/<html lang="[^"]*"/, `<html lang="${lang}"`)
|
|
415
455
|
if (embedThemeFonts) {
|
|
@@ -430,10 +470,12 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
430
470
|
// windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
|
|
431
471
|
// The results file is written during the talk and read only by builds.
|
|
432
472
|
if (changed === resultsPath) return []
|
|
433
|
-
|
|
473
|
+
// An included file counts as the deck (saved times are written into it too).
|
|
474
|
+
const deckFile = changed === abs || included.includes(changed)
|
|
475
|
+
const own = (changed === inkPath || deckFile) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
|
|
434
476
|
if (own) {
|
|
435
|
-
invalidate(server,
|
|
436
|
-
return
|
|
477
|
+
invalidate(server, deckFile ? SOURCE_IDS : [RESOLVED_INK_ID])
|
|
478
|
+
return deckFile ? bibUpdate(server) : []
|
|
437
479
|
}
|
|
438
480
|
if (changed === inkPath) {
|
|
439
481
|
reload(server, [RESOLVED_INK_ID], changed)
|
|
@@ -450,6 +492,12 @@ export function slidesPlugin(slidesPath, { languages = false, inlineImages = fal
|
|
|
450
492
|
server.ws.send({ type: 'full-reload' })
|
|
451
493
|
return []
|
|
452
494
|
}
|
|
495
|
+
if (included.includes(changed)) {
|
|
496
|
+
invalidate(server, SOURCE_IDS)
|
|
497
|
+
if (editor) return bibUpdate(server)
|
|
498
|
+
server.ws.send({ type: 'full-reload' })
|
|
499
|
+
return []
|
|
500
|
+
}
|
|
453
501
|
if (changed === abs) {
|
|
454
502
|
if (followComponentFolders(server)) {
|
|
455
503
|
reload(server, ALL_IDS, changed)
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// only written when the deck still has the slides the page saw (`ids`);
|
|
10
10
|
// otherwise the answer is 409. The deck is copied to .mdeck-backups/ before
|
|
11
11
|
// the first write, and the write does not reload the open windows
|
|
12
|
-
// (ownWrites.js).
|
|
12
|
+
// (ownWrites.js). A slide from another file the deck includes (include.js)
|
|
13
|
+
// gets its time in that file, as that file's own slide.
|
|
13
14
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
14
15
|
import { resolve } from 'node:path'
|
|
15
16
|
import { parseSlides } from '../core/parseSlides.js'
|
|
@@ -17,37 +18,52 @@ import { setSlideMeta } from '../core/editDeck.js'
|
|
|
17
18
|
import { parseTime, formatTime } from '../core/timing.js'
|
|
18
19
|
import { isAllowedRequest, send, readJson, backupDeck } from './editorPlugin.js'
|
|
19
20
|
import { markOwnWrite } from './ownWrites.js'
|
|
21
|
+
import { expandDeck } from './include.js'
|
|
20
22
|
|
|
21
23
|
export function createTimingFile(deckPath) {
|
|
22
24
|
const abs = resolve(deckPath)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const backedUp = new Set()
|
|
26
|
+
// The deck as shown, and for each slide the file it is written in.
|
|
27
|
+
const shown = () => {
|
|
28
|
+
const { source, origins } = expandDeck(abs)
|
|
29
|
+
return { deck: parseSlides(source), origins }
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
return {
|
|
27
33
|
path: abs,
|
|
28
|
-
ids: () =>
|
|
34
|
+
ids: () => shown().deck.slides.map(slide => slide.id),
|
|
29
35
|
save(ids, times) {
|
|
30
|
-
|
|
31
|
-
const current =
|
|
36
|
+
const { deck: whole, origins } = shown()
|
|
37
|
+
const current = whole.slides.map(slide => slide.id)
|
|
32
38
|
if (!Array.isArray(ids) || ids.join('\n') !== current.join('\n')) return { status: 409, error: 'The deck changed since the rehearsal began; rehearse again', ids: current }
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
// The new times, by the file the slide is written in and its id there.
|
|
40
|
+
const files = new Map()
|
|
35
41
|
for (const [id, value] of Object.entries(times ?? {})) {
|
|
36
42
|
if (!current.includes(id)) return { status: 400, error: `No slide "${id}" in the deck` }
|
|
37
43
|
const seconds = value == null ? null : parseTime(value)
|
|
38
44
|
if (value != null && seconds == null) return { status: 400, error: `"${value}" is not a time` }
|
|
39
45
|
const time = seconds == null ? undefined : formatTime(seconds)
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
if (whole.slides.find(slide => slide.id === id).meta.time === time) continue
|
|
47
|
+
const { file, id: own } = origins?.[id] ?? { file: abs, id }
|
|
48
|
+
if (!files.has(file)) files.set(file, [])
|
|
49
|
+
files.get(file).push([own, time])
|
|
50
|
+
}
|
|
51
|
+
let changed = 0
|
|
52
|
+
for (const [file, entries] of files) {
|
|
53
|
+
const before = readFileSync(file, 'utf8')
|
|
54
|
+
let source = before, deck = parseSlides(source)
|
|
55
|
+
for (const [id, time] of entries) {
|
|
56
|
+
source = setSlideMeta(deck, id, { time })
|
|
57
|
+
deck = parseSlides(source)
|
|
58
|
+
changed++
|
|
59
|
+
}
|
|
60
|
+
if (source === before) continue
|
|
61
|
+
if (!backedUp.has(file)) { backupDeck(file, before); backedUp.add(file) }
|
|
62
|
+
markOwnWrite(file, source)
|
|
63
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
64
|
+
writeFileSync(temp, source, 'utf8')
|
|
65
|
+
renameSync(temp, file)
|
|
44
66
|
}
|
|
45
|
-
if (source === before) return { status: 200, ok: true, changed: 0 }
|
|
46
|
-
if (!backedUp) { backupDeck(abs, before); backedUp = true }
|
|
47
|
-
markOwnWrite(abs, source)
|
|
48
|
-
const temp = `${abs}.${process.pid}.tmp`
|
|
49
|
-
writeFileSync(temp, source, 'utf8')
|
|
50
|
-
renameSync(temp, abs)
|
|
51
67
|
return { status: 200, ok: true, changed }
|
|
52
68
|
},
|
|
53
69
|
}
|
package/src/cli/args.js
CHANGED
|
@@ -4,8 +4,8 @@ const common = { '--port': value, '--no-open': flag }
|
|
|
4
4
|
const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--no-results': flag }
|
|
5
5
|
const commands = {
|
|
6
6
|
new: { max: 0 },
|
|
7
|
-
run: { ...common, '--network': flag, '--server': optional, max: 1 },
|
|
8
|
-
rehearse: { ...common, '--network': flag, '--server': optional, max: 1 },
|
|
7
|
+
run: { ...common, '--network': flag, '--server': optional, '--no-server': flag, max: 1 },
|
|
8
|
+
rehearse: { ...common, '--network': flag, '--server': optional, '--no-server': flag, max: 1 },
|
|
9
9
|
edit: { ...common, max: 1 },
|
|
10
10
|
design: { ...common, max: 1 },
|
|
11
11
|
themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
|
|
@@ -22,7 +22,7 @@ const commands = {
|
|
|
22
22
|
server: { '--port': value, '--host': optional, max: 0 },
|
|
23
23
|
skill: { '--print': flag, '--install': 'list', '--project': flag, max: 0 },
|
|
24
24
|
migrate: { '--dry-run': flag, max: 1 },
|
|
25
|
-
results: { '--output': value, '-o': '--output', max: 1 },
|
|
25
|
+
results: { '--output': value, '-o': '--output', '--summary': flag, '--json': flag, max: 1 },
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function validateServerOrigin(address) {
|