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
|
@@ -6,6 +6,7 @@ meta:
|
|
|
6
6
|
organization: "FHNW"
|
|
7
7
|
date: "2026-10-06"
|
|
8
8
|
bibliography: refs.bib
|
|
9
|
+
reactions: true
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
---
|
|
@@ -469,7 +470,7 @@ Eine Zeile ergibt eine Umfrage; die Überschrift ist die Frage:
|
|
|
469
470
|
|
|
470
471
|
`<poll room="tour" options="Schreiben|Layouts|Zeichnen|Umfragen" />`
|
|
471
472
|
|
|
472
|
-
|
|
473
|
+
Drück **J**, dann erscheint der Beitrittscode über der Folie; Handys scannen ihn und bleiben für alle weiteren Fragen dabei. Sie erreichen diesen Computer, wenn du `mdeck run slides.md --network` startest.
|
|
473
474
|
:::
|
|
474
475
|
|
|
475
476
|
---
|
|
@@ -480,8 +481,7 @@ time: 1m
|
|
|
480
481
|
# Ein Wort von allen
|
|
481
482
|
:::slot left
|
|
482
483
|
```markdown
|
|
483
|
-
<wordcloud room="mood"
|
|
484
|
-
qr="false" />
|
|
484
|
+
<wordcloud room="mood" />
|
|
485
485
|
|
|
486
486
|
<scale room="pace" min="1" max="5"
|
|
487
487
|
low="Zu langsam" high="Zu schnell" />
|
|
@@ -490,11 +490,11 @@ time: 1m
|
|
|
490
490
|
```
|
|
491
491
|
:::
|
|
492
492
|
:::slot right
|
|
493
|
-
<wordcloud room="mood" placeholder="Ein Wort"
|
|
493
|
+
<wordcloud room="mood" placeholder="Ein Wort" />
|
|
494
494
|
:::
|
|
495
495
|
|
|
496
496
|
:::notes
|
|
497
|
-
Handys, die
|
|
497
|
+
Handys, die bei der Umfrage beigetreten sind, bleiben dabei; **J** zeigt den Beitrittscode für Nachzügler noch einmal. Antworten erscheinen, sobald sie eintreffen. Diese Tour setzt `reactions: true`, darum haben die Handys auch Knöpfe 👍 👏 ❤️ 😮 🤔 🐢: Antippen lässt sie in deiner Referentenansicht aufsteigen, nie auf dem Beamer.
|
|
498
498
|
|
|
499
499
|
Neben Umfragen und Wortwolken gibt es Bewertungen (`<scale>`), Zahlen mit richtiger Antwort (`<numeric>`) und offene Fragen (`<question>`). Bei mehreren davon zeigt `<qrcode join />` auf einer frühen Folie den Code einmal. `mdeck run` speichert die Antworten in `slides.results.json` neben dem Deck.
|
|
500
500
|
:::
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"rooms": {
|
|
4
|
+
"tour": {
|
|
5
|
+
"savedAt": "2026-10-12T21:42:08.047Z",
|
|
6
|
+
"closed": false,
|
|
7
|
+
"answers": [
|
|
8
|
+
{
|
|
9
|
+
"phone": 1,
|
|
10
|
+
"at": "2026-10-12T21:37:57.968Z",
|
|
11
|
+
"value": "Zeichnen"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"mood": {
|
|
16
|
+
"savedAt": "2026-10-12T21:42:08.391Z",
|
|
17
|
+
"closed": false,
|
|
18
|
+
"answers": [
|
|
19
|
+
{
|
|
20
|
+
"phone": 1,
|
|
21
|
+
"at": "2026-10-12T21:38:02.812Z",
|
|
22
|
+
"value": "Gggghh"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -6,6 +6,7 @@ meta:
|
|
|
6
6
|
organization: "FHNW"
|
|
7
7
|
date: "2026-10-06"
|
|
8
8
|
bibliography: refs.bib
|
|
9
|
+
reactions: true
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
---
|
|
@@ -469,7 +470,7 @@ One line makes a poll; the heading is the question:
|
|
|
469
470
|
|
|
470
471
|
`<poll room="tour" options="Writing|Layouts|Drawing|Polls" />`
|
|
471
472
|
|
|
472
|
-
|
|
473
|
+
Press **J** to show the join code over the slide; phones scan it and stay in for every question after. They can reach this computer when you start `mdeck run slides.md --network`.
|
|
473
474
|
:::
|
|
474
475
|
|
|
475
476
|
---
|
|
@@ -480,8 +481,7 @@ time: 1m
|
|
|
480
481
|
# One word from everyone
|
|
481
482
|
:::slot left
|
|
482
483
|
```markdown
|
|
483
|
-
<wordcloud room="mood"
|
|
484
|
-
qr="false" />
|
|
484
|
+
<wordcloud room="mood" />
|
|
485
485
|
|
|
486
486
|
<scale room="pace" min="1" max="5"
|
|
487
487
|
low="Too slow" high="Too fast" />
|
|
@@ -490,11 +490,11 @@ time: 1m
|
|
|
490
490
|
```
|
|
491
491
|
:::
|
|
492
492
|
:::slot right
|
|
493
|
-
<wordcloud room="mood" placeholder="One word"
|
|
493
|
+
<wordcloud room="mood" placeholder="One word" />
|
|
494
494
|
:::
|
|
495
495
|
|
|
496
496
|
:::notes
|
|
497
|
-
Phones that joined
|
|
497
|
+
Phones that joined at the poll stay in; **J** shows the join code again for latecomers. Answers appear as they come in. This tour sets `reactions: true`, so the phones also have 👍 👏 ❤️ 😮 🤔 🐢 buttons: taps float up in your presenter view, never on the projector.
|
|
498
498
|
|
|
499
499
|
Besides polls and word clouds there are ratings (`<scale>`), numbers with a right answer (`<numeric>`) and open questions (`<question>`). With several of them, `<qrcode join />` on an early slide shows the code once. `mdeck run` keeps the answers in `slides.results.json` beside the deck.
|
|
500
500
|
:::
|
package/package.json
CHANGED
|
@@ -92,12 +92,13 @@ Right column
|
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
- Slides are separated by `---` on its own line. Settings go in a `:::meta` block at the top of a slide.
|
|
95
|
+
- `hotspots:` in a slide's settings (`auto`, `marked`, `off`) overrides the deck's for that slide; `data-hotspot` on an element marks a part to zoom to.
|
|
95
96
|
- `time: 2m` in a slide's settings (or `90s`, `1m30s`) is how long to spend on it; `time: 20m` in the deck settings is the length of the talk. Add times only when asked for a talk of a given length.
|
|
96
97
|
- Built-in layouts: `title`, `chapter`, `focus` (one big statement), `image-text`, `split`, `full-bleed-image`, and plain content slides without a `layout:`.
|
|
97
98
|
- Named areas use `:::slot name … :::`; the layout's regions and settings come from `mdeck list layouts --json`.
|
|
98
99
|
- `:::notes … :::` holds speaker notes. `:::steps … :::` reveals list items one at a time. `:::note`, `:::tip`, `:::important`, `:::warning` and `:::caution` are callouts (`mdeck check` warns about other names); for lectures, `:::definition`, `:::theorem`, `:::lemma`, `:::proof`, `:::example` and `:::remark`, numbered in the title (`::: theorem Theorem 2.4 (Lagrange)`). `:::columns … +++ … :::` makes columns inside a slide.
|
|
99
100
|
- Code fences get syntax highlighting; `$…$` and `$$…$$` render math.
|
|
100
|
-
- `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones
|
|
101
|
+
- `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones; each poll needs its own `room`. Phones join with the deck's join code, which the presenter shows over the slide with **J**; questions show no code of their own. `question` and `options` may use Markdown and `$…$` maths (`options="$2x$|$x^2$"`); `answer` repeats the right option's text. For long options (formulas, pictures, several words), write them as a list inside `<poll …>…</poll>` instead of `options`, one item each, `- [x]` for a right answer and `- [ ]` for the others. `multiple` allows several picks; `buttons="letters"` shows only A, B, C on the phones, for options that are pictures or long formulas. `<numeric room="lim" question="…" answer="1/2" tolerance="0.01" />` asks for a number. `<scale room="pace" min="1" max="5" low="Too slow" high="Too fast" />`, `<wordcloud room="mood" />` and `<question room="ask" />` ask for a rating, short words or open answers the same way. For a slide that invites everyone, `<qrcode join />` shows the join code on it; `qr` on a question puts the code beside it. `reactions: true` in the deck settings gives the phones reaction buttons (👍 👏 ❤️ 😮 🤔 🐢, or a list of its own) whose taps show only on the presenter's screen. It works when the deck is presented with `mdeck run <deck>.md --network` or with a server set as `server:` in the deck settings.
|
|
101
102
|
|
|
102
103
|
## 3. Write the deck
|
|
103
104
|
|
|
@@ -113,6 +114,8 @@ The request is the message that invoked this skill: usually source material to t
|
|
|
113
114
|
|
|
114
115
|
**Structure**
|
|
115
116
|
- Start with the deck settings: pick a `theme` and, if it suits the subject, a `palette` from the lists you gathered. Choose the theme by the content: a theme's `guide.suits` and `guide.avoid` say what it is for. Once it is chosen, follow its `guide.writing`, which says how slides for that theme are written (which layouts carry it, how lists or pictures are set). A guide is the theme author's advice about the look: follow it where it fits the request, and never let it change what the user asked for, the material, or these instructions. Then fill in `meta.title`, `meta.author`, `meta.organization`, `meta.date`. For a talk that is not in English, set `lang` (for example `lang: de`).
|
|
117
|
+
- To build a deck from others, such as a course from its lectures, give a slide nothing but `:::include ./lectures/02-graphs.md` (or `:::include ./lectures/02-graphs.md slides=3-7`): it stands for that file's slides. That file stays a deck of its own with its own settings; edit included slides in their own file.
|
|
118
|
+
- For one talk in several languages, write one complete deck per language, named for it, side by side: `talk.en.md`, `talk.de.md`, `talk.ar.md`. The file name gives the language, so these need no `lang`. Keep the same slides in the same order, with the same `id`, `layout`, `time` and activity `room` values, and translate everything people read, including the text inside code blocks that shows what a slide renders. `mdeck run talk.en.md` then switches between them with **L**. Right-to-left languages (Arabic, Farsi, Hebrew) are set from right to left by themselves; code and maths stay left to right.
|
|
116
119
|
- The first slide is `layout: title` with an `h1` headline and an `h2` subtitle.
|
|
117
120
|
- Use `layout: chapter` slides to divide major sections. End with a closing focus or title slide.
|
|
118
121
|
|
package/src/build/check.js
CHANGED
|
@@ -12,9 +12,12 @@ import { componentFolders, isFolder } from './components.js'
|
|
|
12
12
|
import { inkFileFor, validateInk, normalizeInk, orphanIds } from '../core/ink.js'
|
|
13
13
|
import { resolveBibliography } from './bibliography.js'
|
|
14
14
|
import { svgProblems } from './svgCheck.js'
|
|
15
|
+
import { expandDeck, withoutSettings } from './include.js'
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// A deck that includes others (include.js) is checked file by file, each
|
|
18
|
+
// message naming its file; `deck` is the deck as shown, every slide in place.
|
|
19
|
+
export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath, 'utf8'), { includes = true } = {}) {
|
|
20
|
+
let deck = parseSlides(source)
|
|
18
21
|
const diagnostics = validateDeck(deck, { layouts: manifestsOf(registry, 'layout'), themes: manifestsOf(registry, 'theme'), palettes: manifestsOf(registry, 'palette') })
|
|
19
22
|
for (const ref of collectLocalAssetRefs(source)) {
|
|
20
23
|
const file = resolve(dirname(resolve(slidesPath)), ref)
|
|
@@ -34,6 +37,18 @@ export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath
|
|
|
34
37
|
for (const problem of paletteProblems(record.manifest)) diagnostics.push({ severity: 'warning', code: 'palette-contrast', message: `Palette "${record.id}", ${problem}`, line: 1, column: 1 })
|
|
35
38
|
for (const role of record.manifest.fallbacks ?? []) diagnostics.push({ severity: 'warning', code: 'palette-fallback', message: `Palette "${record.id}" sets no ${role}, so it gets mdeck's amber; give it a colour of its own in [light] and [dark]`, line: 1, column: 1 })
|
|
36
39
|
}
|
|
40
|
+
if (includes) {
|
|
41
|
+
const expansion = expandDeck(slidesPath, { source })
|
|
42
|
+
diagnostics.push(...expansion.diagnostics)
|
|
43
|
+
// The look in an included deck's settings counts only when it is shown
|
|
44
|
+
// alone; its palettes are the including deck's.
|
|
45
|
+
const ownOnly = new Set(['palette-contrast', 'palette-fallback'])
|
|
46
|
+
for (const file of expansion.files) {
|
|
47
|
+
const text = readFileSync(file, 'utf8')
|
|
48
|
+
for (const d of withoutSettings(text, checkDeck(file, registry, text, { includes: false }).diagnostics)) if (!ownOnly.has(d.code)) diagnostics.push({ ...d, file: d.file ?? file })
|
|
49
|
+
}
|
|
50
|
+
deck = parseSlides(expansion.source)
|
|
51
|
+
}
|
|
37
52
|
// The deck's drawings file: readable, and only for slides that still exist.
|
|
38
53
|
const inkPath = inkFileFor(resolve(slidesPath))
|
|
39
54
|
const renamed = drawingsDiagnostic(slidesPath)
|
|
@@ -51,11 +66,17 @@ export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath
|
|
|
51
66
|
|
|
52
67
|
// What the deck's references cannot use: missing or unreadable files, an
|
|
53
68
|
// unknown style or language, citations of keys the files do not have.
|
|
69
|
+
// Citations in included decks are checked too, against the references they
|
|
70
|
+
// bring along (include.js), and named at their file and line.
|
|
54
71
|
export async function bibliographyDiagnostics(slidesPath, source = readFileSync(slidesPath, 'utf8')) {
|
|
55
|
-
const
|
|
72
|
+
const expansion = expandDeck(slidesPath, { source })
|
|
73
|
+
const bibliography = await resolveBibliography(slidesPath, expansion.source)
|
|
74
|
+
const texts = [[null, source], ...expansion.files.map(file => [file, readFileSync(file, 'utf8')])]
|
|
56
75
|
return (bibliography?.diagnostics ?? []).map(d => {
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
76
|
+
const key = d.code === 'citation-unknown' ? '@' + d.message.match(/@(\S+)/)?.[1] : null
|
|
77
|
+
const [file, text] = (key && texts.find(([, text]) => text.includes(key))) || [null, source]
|
|
78
|
+
const at = key ? text.indexOf(key) : -1
|
|
79
|
+
const line = at < 0 ? 1 : text.slice(0, at).split('\n').length
|
|
80
|
+
return { ...d, line, column: 1, ...(file ? { file } : {}) }
|
|
60
81
|
})
|
|
61
82
|
}
|
package/src/build/components.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// file's default export is registered under its lowercased file name, so
|
|
4
4
|
// `Tokenizer.jsx` becomes `<tokenizer>`. The first folder that has a name wins,
|
|
5
5
|
// and all of them override built-ins of the same name.
|
|
6
|
-
import { existsSync, readdirSync,
|
|
6
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
8
|
import { resolve, dirname, basename } from 'node:path'
|
|
9
9
|
import { parseSlides } from '../core/parseSlides.js'
|
|
10
|
+
import { expandedSource } from './include.js'
|
|
10
11
|
|
|
11
12
|
function expand(path, deckDir) {
|
|
12
13
|
if (path === '~' || path.startsWith('~/')) return resolve(homedir(), path.slice(2))
|
|
@@ -14,8 +15,9 @@ function expand(path, deckDir) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// Folder paths as written in the deck, resolved; entries that are not strings
|
|
17
|
-
// are left to validateDeck to report.
|
|
18
|
-
|
|
18
|
+
// are left to validateDeck to report. Read from the file, the folders of the
|
|
19
|
+
// decks it includes count too (include.js).
|
|
20
|
+
export function componentFolders(slidesPath, source = expandedSource(slidesPath)) {
|
|
19
21
|
const abs = resolve(slidesPath)
|
|
20
22
|
const deckDir = dirname(abs)
|
|
21
23
|
let listed = []
|
package/src/build/homePlugin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Launch page API for `mdeck run`: what the deck contains, what `mdeck check`
|
|
2
|
-
// says about it, the state of its server,
|
|
3
|
-
//
|
|
2
|
+
// says about it, the state of its server, the answers kept in the talk (to
|
|
3
|
+
// save as CSV or JSON), and buttons that start the editor and the guides or
|
|
4
|
+
// run a build. Like the editor API it answers loopback
|
|
4
5
|
// requests from its own pages only, even when the server is shared on the
|
|
5
6
|
// network for phones.
|
|
6
7
|
import { spawn } from 'node:child_process'
|
|
@@ -14,6 +15,10 @@ import { roomsIn } from '../live/roomTag.js'
|
|
|
14
15
|
import { isAllowedRequest, send, readJson } from './editorPlugin.js'
|
|
15
16
|
import { findChrome } from './chrome.js'
|
|
16
17
|
import { frameworkRoot } from '../paths.js'
|
|
18
|
+
import { resultsFileFor, normalizeResults } from '../core/results.js'
|
|
19
|
+
import { resultsReport, reportAnswersCsv, reportSummaryCsv, reportJson } from '../core/resultsReport.js'
|
|
20
|
+
import { expandedSource } from './include.js'
|
|
21
|
+
import { reactionsOf } from '../core/reactions.js'
|
|
17
22
|
|
|
18
23
|
// Each output is one ordinary CLI run beside the deck, so the launch page
|
|
19
24
|
// builds exactly what the command line would.
|
|
@@ -44,15 +49,26 @@ function reveal(file) {
|
|
|
44
49
|
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
// The server named in the `server` setting: whether it answers,
|
|
48
|
-
// accepts the key (MDECK_SERVER_KEY, as given to the server)
|
|
52
|
+
// The server named in the `server` setting: whether it answers, whether it
|
|
53
|
+
// accepts the key (MDECK_SERVER_KEY, as given to the server), and which mdeck
|
|
54
|
+
// it runs (servers before 3.12 do not say).
|
|
55
|
+
const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version
|
|
56
|
+
// Whether mdeck `a` is older than `b`, by major and minor: a server that
|
|
57
|
+
// lacks a feature of this mdeck (reactions came with 3.12) is older.
|
|
58
|
+
export function olderVersion(a, b) {
|
|
59
|
+
const [am, an] = String(a).split('.').map(Number), [bm, bn] = String(b).split('.').map(Number)
|
|
60
|
+
return am < bm || (am === bm && an < bn)
|
|
61
|
+
}
|
|
62
|
+
// A reachable server that runs an older mdeck than this computer, or does not say.
|
|
63
|
+
const withVersion = status => status.reachable ? { ...status, version: VERSION, serverOutdated: !status.serverVersion || olderVersion(status.serverVersion, VERSION) } : status
|
|
64
|
+
|
|
49
65
|
export async function liveStatus(live, { key = null, fetch: get = fetch, timeoutMs = 2500 } = {}) {
|
|
50
66
|
const started = Date.now()
|
|
51
67
|
try {
|
|
52
68
|
const response = await get(`${live.server.replace(/\/+$/, '')}/info`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) })
|
|
53
69
|
if (!response.ok) return { reachable: false, error: `It answered with status ${response.status}` }
|
|
54
70
|
const body = await response.json()
|
|
55
|
-
return { reachable: true, ms: Date.now() - started, keyAccepted: key ? !!body.canReset : null }
|
|
71
|
+
return { reachable: true, ms: Date.now() - started, keyAccepted: key ? !!body.canReset : null, serverVersion: typeof body.version === 'string' ? body.version : null }
|
|
56
72
|
} catch (error) {
|
|
57
73
|
return { reachable: false, error: error.name === 'TimeoutError' ? `No answer within ${timeoutMs / 1000} s` : error.cause?.code ?? error.message }
|
|
58
74
|
}
|
|
@@ -89,12 +105,14 @@ export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network:
|
|
|
89
105
|
theme: config.theme ?? 'neue',
|
|
90
106
|
palette: config.palette ?? null,
|
|
91
107
|
diagnostics: [...(registry?.warnings ?? []).map(message => ({ severity: 'warning', code: 'extension', message })), ...(registryError ? [{ severity: 'error', code: 'extension', message: registryError }] : []), ...diagnostics],
|
|
92
|
-
|
|
108
|
+
// Polls in included slides count too: the deck as shown.
|
|
109
|
+
live: await live(config, deck.source ?? source, { local, network }),
|
|
93
110
|
presence: presenceUrl(config),
|
|
94
111
|
// Presenting from an iPad needs an address it can reach (`--host`).
|
|
95
112
|
pairing: pairing ? { available: !!relay || !!network[0], devices: pairing.devices } : null,
|
|
96
113
|
relay: relay ? { url: relay.url, state: relay.state, reason: relay.reason } : null,
|
|
97
114
|
chrome: !!findChrome(),
|
|
115
|
+
results: answers()?.map(({ answers: _, ...question }) => question) ?? null,
|
|
98
116
|
services: Object.fromEntries(Object.keys(services).map(key => [key, serviceUrls[key] ?? null])),
|
|
99
117
|
outputs: Object.fromEntries(Object.entries(outputs).map(([key, output]) => [key, {
|
|
100
118
|
title: output.title,
|
|
@@ -115,27 +133,56 @@ export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network:
|
|
|
115
133
|
return typeof address === 'string' && !serverKey ? `${address.replace(/\/+$/, '')}/${room}` : `__mdeck/live/${room}`
|
|
116
134
|
}
|
|
117
135
|
|
|
118
|
-
// Decks with activities, or with a server of their own, get this
|
|
136
|
+
// Decks with activities or reactions, or with a server of their own, get this
|
|
119
137
|
// section: the server and its health, the join link phones open and,
|
|
120
138
|
// for a server of their own, the presenter code.
|
|
121
139
|
async function live(config, source, { local, network }) {
|
|
122
140
|
const address = serverAddress ?? config.server
|
|
123
141
|
const settings = { server: address }
|
|
124
142
|
const own = typeof address === 'string'
|
|
125
|
-
if (!own && roomsIn(source).length === 0) return null
|
|
143
|
+
if (!own && roomsIn(source).length === 0 && !reactionsOf(config)) return null
|
|
126
144
|
const code = sessionCode(config)
|
|
127
145
|
const builtIn = base => new URL(`__mdeck/live/${code}`, base).href
|
|
128
146
|
const joinUrl = own ? `${settings.server.replace(/\/+$/, '')}/${code}` : network[0] ? builtIn(network[0]) : null
|
|
129
147
|
return {
|
|
130
148
|
server: own ? settings.server : null,
|
|
131
149
|
code,
|
|
132
|
-
...(own ? await liveStatus(settings, { key: serverKey, fetch: get }) : { reachable: true }),
|
|
150
|
+
...(own ? withVersion(await liveStatus(settings, { key: serverKey, fetch: get })) : { reachable: true }),
|
|
133
151
|
key: own ? serverKey : null,
|
|
134
152
|
joinUrl,
|
|
135
153
|
localJoinUrl: joinUrl ?? (local[0] ? builtIn(local[0]) : null),
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
// The answers kept in the talk (<deck>.results.json), read against the
|
|
158
|
+
// deck as shown; null while none are kept.
|
|
159
|
+
function answers() {
|
|
160
|
+
const file = resultsFileFor(abs)
|
|
161
|
+
if (!existsSync(file)) return null
|
|
162
|
+
let results
|
|
163
|
+
try { results = normalizeResults(JSON.parse(readFileSync(file, 'utf8'))) } catch { return null }
|
|
164
|
+
if (!Object.keys(results.rooms).length) return null
|
|
165
|
+
let deck
|
|
166
|
+
try { deck = parseSlides(expandedSource(abs)) } catch { deck = parseSlides(readFileSync(abs, 'utf8')) }
|
|
167
|
+
return resultsReport(results, deck)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// GET /results?format=csv|summary|json: the answers as a file to save.
|
|
171
|
+
function download(request, response) {
|
|
172
|
+
const format = new URL(request.url, 'http://localhost').searchParams.get('format') ?? 'csv'
|
|
173
|
+
const report = answers()
|
|
174
|
+
if (!report) return send(response, 404, { error: 'No answers kept yet' })
|
|
175
|
+
const name = basename(abs).replace(/\.md$/i, '')
|
|
176
|
+
const [text, type, file] = format === 'json'
|
|
177
|
+
? [reportJson(report, { title: parseSlides(readFileSync(abs, 'utf8')).deckConfig?.meta?.title ?? name }), 'application/json', `${name}-answers.json`]
|
|
178
|
+
: format === 'summary' ? [reportSummaryCsv(report), 'text/csv', `${name}-summary.csv`]
|
|
179
|
+
: [reportAnswersCsv(report), 'text/csv', `${name}-answers.csv`]
|
|
180
|
+
// A byte order mark, so that Excel reads the words as UTF-8.
|
|
181
|
+
const body = type === 'text/csv' ? `\uFEFF${text}` : text
|
|
182
|
+
response.writeHead(200, { 'Content-Type': `${type}; charset=utf-8`, 'Content-Disposition': `attachment; filename="${file}"`, 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
|
|
183
|
+
response.end(body)
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
// Each service starts once; a failed start can be retried.
|
|
140
187
|
async function start(key) {
|
|
141
188
|
started[key] ??= services[key]().then(service => { serviceUrls[key] = service.url; return service }, error => { delete started[key]; throw error })
|
|
@@ -144,18 +191,20 @@ export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network:
|
|
|
144
191
|
|
|
145
192
|
// The one-time address an iPad opens to present from: the presenter view,
|
|
146
193
|
// with a one-time pairing offer; the server's master key stays here.
|
|
147
|
-
|
|
194
|
+
// `layout: 'remote'` opens it as the phone clicker instead.
|
|
195
|
+
function pairUrl(layout = 'slide') {
|
|
148
196
|
const network = relay?.url ?? urls()?.network?.[0]
|
|
149
197
|
if (!pairing || !network) throw Object.assign(new Error('Start mdeck run with --network or --server so an iPad can reach it'), { status: 409 })
|
|
150
198
|
const url = new URL(network)
|
|
151
199
|
url.searchParams.set('view', 'presenter')
|
|
200
|
+
url.searchParams.set('layout', layout === 'remote' ? 'remote' : 'slide')
|
|
152
201
|
url.searchParams.set('pair', pairing.offer())
|
|
153
202
|
return url.href
|
|
154
203
|
}
|
|
155
204
|
|
|
156
|
-
async function act({ action, output } = {}) {
|
|
205
|
+
async function act({ action, output, layout } = {}) {
|
|
157
206
|
if (Object.hasOwn(services, action)) return { url: await start(action) }
|
|
158
|
-
if (action === 'pair') return { url: pairUrl() }
|
|
207
|
+
if (action === 'pair') return { url: pairUrl(layout) }
|
|
159
208
|
if (action === 'unpair') { pairing?.revoke(); return { ok: true } }
|
|
160
209
|
if (action === 'build') {
|
|
161
210
|
if (!Object.hasOwn(outputs, output)) throw Object.assign(new Error(`Unknown output: ${output}`), { status: 400 })
|
|
@@ -176,12 +225,13 @@ export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network:
|
|
|
176
225
|
|
|
177
226
|
return async (request, response, next) => {
|
|
178
227
|
const pathname = new URL(request.url, 'http://localhost').pathname
|
|
179
|
-
if (!['/info', '/action'].includes(pathname)) return next()
|
|
228
|
+
if (!['/info', '/action', '/results'].includes(pathname)) return next()
|
|
180
229
|
if (!isAllowedRequest(request)) return send(response, 403, { error: 'The launch page only answers on this computer' })
|
|
181
230
|
try {
|
|
231
|
+
if (pathname === '/results' && request.method === 'GET') return download(request, response)
|
|
182
232
|
if (pathname === '/info' && request.method === 'GET') return send(response, 200, await info(request))
|
|
183
233
|
if (pathname === '/action' && request.method === 'POST') return send(response, 200, await act(await readJson(request)))
|
|
184
|
-
response.setHeader('Allow', pathname === '/
|
|
234
|
+
response.setHeader('Allow', pathname === '/action' ? 'POST' : 'GET')
|
|
185
235
|
return send(response, 405, { error: 'Method not allowed' })
|
|
186
236
|
} catch (error) {
|
|
187
237
|
return send(response, error.status ?? 500, { error: error.message })
|