mdeck 3.3.0 → 3.5.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 +25 -0
- package/README.md +1 -1
- package/assets/base.css +23 -2
- package/assets/extensions/palettes/cobalt/extension.toml +30 -0
- package/assets/extensions/palettes/greyscale/extension.toml +57 -0
- package/assets/extensions/themes/academic/styles.css +18 -5
- package/assets/extensions/themes/glass/extension.toml +59 -0
- package/assets/extensions/themes/glass/styles.css +754 -0
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/assets/extensions/themes/plain/extension.toml +58 -0
- package/assets/extensions/themes/plain/styles.css +471 -0
- package/assets/extensions/themes/work/extension.toml +57 -0
- package/assets/extensions/themes/work/styles.css +512 -0
- package/docs/reference/authoring.md +50 -10
- package/docs/reference/palettes.md +4 -3
- package/docs/reference/themes.md +5 -3
- package/docs/site/content/appearance.md +24 -10
- package/docs/site/content/audience.md +49 -4
- package/docs/site/content/commands.md +2 -0
- package/docs/site/content/theme-repository.md +7 -7
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/glass-dark.webp +0 -0
- package/docs/site/images/themes/glass-light.webp +0 -0
- package/docs/site/images/themes/glass.webp +0 -0
- package/docs/site/images/themes/minimal-dark.webp +0 -0
- package/docs/site/images/themes/minimal-light.webp +0 -0
- package/docs/site/images/themes/minimal.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/plain-dark.webp +0 -0
- package/docs/site/images/themes/plain-light.webp +0 -0
- package/docs/site/images/themes/plain.webp +0 -0
- package/docs/site/images/themes/work-dark.webp +0 -0
- package/docs/site/images/themes/work-light.webp +0 -0
- package/docs/site/images/themes/work.webp +0 -0
- package/examples/showcase/slides.md +2 -2
- package/package.json +4 -1
- package/skills/write-slides/SKILL.md +2 -2
- package/src/build/bibliography.js +29 -11
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +42 -18
- package/src/build/themeCheck.js +2 -1
- package/src/cli/args.js +2 -1
- package/src/cli/index.js +17 -4
- package/src/components/Numeric.jsx +59 -0
- package/src/components/Poll.jsx +72 -57
- package/src/components/Question.jsx +10 -6
- package/src/components/Scale.jsx +13 -8
- package/src/components/WordCloud.jsx +10 -6
- package/src/components/activity.jsx +88 -7
- package/src/components/inlineText.js +63 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +26 -13
- package/src/core/editDeck.js +5 -6
- package/src/core/icons.js +2 -0
- package/src/core/labels.js +32 -0
- package/src/core/pollList.js +66 -0
- package/src/core/results.js +64 -0
- package/src/core/tags.js +195 -0
- package/src/editor/sampleDeck.js +34 -8
- package/src/extensions/discover.js +2 -2
- package/src/extensions/moved.js +4 -4
- package/src/layouts/layoutApi.jsx +24 -13
- package/src/live/answer/answer.css +19 -0
- package/src/live/answer/answer.js +147 -24
- package/src/live/client.js +51 -6
- package/src/live/roomTag.js +11 -15
- package/src/live/rooms.js +6 -1
- package/src/runtime/citations.js +15 -5
- package/src/runtime/ink/bus.js +7 -13
- package/src/runtime/main.jsx +5 -2
- package/src/runtime/markedSetup.js +3 -0
- package/src/runtime/registry.jsx +2 -0
- package/assets/extensions/palettes/neon/extension.toml +0 -30
- package/assets/extensions/themes/aurora/extension.toml +0 -40
- package/assets/extensions/themes/aurora/styles.css +0 -644
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
package/src/core/tags.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
import markedKatex from 'marked-katex-extension'
|
|
3
|
+
|
|
4
|
+
// The HTML tags in a deck's Markdown, found the way the slide finds them:
|
|
5
|
+
// marked decides what is HTML (not code, not a tag a blank line cut in two),
|
|
6
|
+
// and the tags in that HTML are read the way a browser reads them, so a `>`
|
|
7
|
+
// in a quoted value (question="Is $x > 0$?") does not end its tag. Every
|
|
8
|
+
// part of mdeck that looks for tags in a deck's text goes through here.
|
|
9
|
+
|
|
10
|
+
const lexer = new Marked().use(markedKatex({ throwOnError: false, output: 'html' }))
|
|
11
|
+
|
|
12
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
|
|
13
|
+
const decode = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, dec, hex, name) =>
|
|
14
|
+
dec ? String.fromCodePoint(Number(dec)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : ENTITIES[name.toLowerCase()] ?? entity)
|
|
15
|
+
|
|
16
|
+
const SPACE = /[\t\n\f\r ]/
|
|
17
|
+
// Elements whose content is text, not tags.
|
|
18
|
+
const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title'])
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The tags in a piece of HTML, in order: { name, attrs, spans, raw, index,
|
|
22
|
+
* end, closing, selfClosing }, `spans` giving where each value is in `raw`. Attribute values are decoded and a repeated
|
|
23
|
+
* attribute keeps its first value, as in the browser. Comments are skipped,
|
|
24
|
+
* and a `<` that starts no tag is text.
|
|
25
|
+
*/
|
|
26
|
+
export function scanTags(html) {
|
|
27
|
+
const tags = []
|
|
28
|
+
let i = 0
|
|
29
|
+
while ((i = html.indexOf('<', i)) >= 0) {
|
|
30
|
+
if (html.startsWith('<!--', i)) {
|
|
31
|
+
const close = html.indexOf('-->', i + 4)
|
|
32
|
+
i = close < 0 ? html.length : close + 3
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const tag = readTag(html, i)
|
|
36
|
+
if (!tag) { i++; continue }
|
|
37
|
+
tags.push(tag)
|
|
38
|
+
i = tag.end
|
|
39
|
+
if (!tag.closing && !tag.selfClosing && RAW_TEXT.has(tag.name)) {
|
|
40
|
+
const close = html.toLowerCase().indexOf(`</${tag.name}`, i)
|
|
41
|
+
i = close < 0 ? html.length : close
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return tags
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readTag(html, start) {
|
|
48
|
+
let i = start + 1
|
|
49
|
+
const closing = html[i] === '/'
|
|
50
|
+
if (closing) i++
|
|
51
|
+
const name = /^[a-z][^\t\n\f\r />]*/i.exec(html.slice(i))?.[0]
|
|
52
|
+
if (!name) return null
|
|
53
|
+
i += name.length
|
|
54
|
+
const attrs = {}
|
|
55
|
+
// Where each attribute's value is in `raw`, as [from, to].
|
|
56
|
+
const spans = {}
|
|
57
|
+
let selfClosing = false
|
|
58
|
+
while (i < html.length) {
|
|
59
|
+
while (SPACE.test(html[i] ?? '')) i++
|
|
60
|
+
if (html[i] === '>') return { name: name.toLowerCase(), attrs, spans, raw: html.slice(start, i + 1), index: start, end: i + 1, closing, selfClosing }
|
|
61
|
+
if (html[i] === '/') { selfClosing = html[i + 1] === '>'; i++; continue }
|
|
62
|
+
if (i >= html.length) break
|
|
63
|
+
selfClosing = false
|
|
64
|
+
let attr = html[i++]
|
|
65
|
+
while (i < html.length && !SPACE.test(html[i]) && !'/>='.includes(html[i])) attr += html[i++]
|
|
66
|
+
while (SPACE.test(html[i] ?? '')) i++
|
|
67
|
+
let value = ''
|
|
68
|
+
let span = [i - start, i - start]
|
|
69
|
+
if (html[i] === '=') {
|
|
70
|
+
i++
|
|
71
|
+
while (SPACE.test(html[i] ?? '')) i++
|
|
72
|
+
const quote = html[i]
|
|
73
|
+
if (quote === '"' || quote === "'") {
|
|
74
|
+
const close = html.indexOf(quote, i + 1)
|
|
75
|
+
if (close < 0) return null
|
|
76
|
+
value = html.slice(i + 1, close)
|
|
77
|
+
span = [i + 1 - start, close - start]
|
|
78
|
+
i = close + 1
|
|
79
|
+
} else {
|
|
80
|
+
const from = i
|
|
81
|
+
while (i < html.length && !SPACE.test(html[i]) && html[i] !== '>') i++
|
|
82
|
+
value = html.slice(from, i)
|
|
83
|
+
span = [from - start, i - start]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
attr = attr.toLowerCase()
|
|
87
|
+
if (!(attr in attrs)) { attrs[attr] = decode(value); spans[attr] = span }
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The children of a token, whatever kind it is.
|
|
93
|
+
const childrenOf = token => [
|
|
94
|
+
...token.tokens ?? [],
|
|
95
|
+
...token.items ?? [],
|
|
96
|
+
...(token.header ?? []).flatMap(cell => cell.tokens ?? []),
|
|
97
|
+
...(token.rows ?? []).flat().flatMap(cell => cell.tokens ?? []),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The HTML in a piece of Markdown, as marked reads it: [{ text, start }],
|
|
102
|
+
* where `start` is the HTML's offset in the source, or null where it cannot
|
|
103
|
+
* be told (such as a tag spread over several lines of a quotation).
|
|
104
|
+
*/
|
|
105
|
+
export function htmlIn(markdown = '') {
|
|
106
|
+
const source = String(markdown ?? '').replace(/\r\n?/g, '\n')
|
|
107
|
+
const found = []
|
|
108
|
+
// A child's raw text is found inside its parent's, after its older sibling.
|
|
109
|
+
const walk = (tokens, parent, parentStart) => {
|
|
110
|
+
let cursor = 0
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
const at = parentStart == null || token.raw == null ? -1 : parent.indexOf(token.raw, cursor)
|
|
113
|
+
const start = at < 0 ? null : parentStart + at
|
|
114
|
+
if (at >= 0) cursor = at + token.raw.length
|
|
115
|
+
if (token.type === 'html') found.push({ text: token.raw ?? token.text, start })
|
|
116
|
+
const children = childrenOf(token)
|
|
117
|
+
if (children.length) walk(children, token.raw ?? '', start)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
walk(lexer.lexer(source), source, 0)
|
|
121
|
+
return found
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The tags in a piece of Markdown, opening and closing, in order, each with
|
|
126
|
+
* `start` and `end` in the source (null where it cannot be told). Tags in
|
|
127
|
+
* code are not tags.
|
|
128
|
+
*/
|
|
129
|
+
export function tagsIn(markdown = '') {
|
|
130
|
+
return htmlIn(markdown).flatMap(({ text, start }) => scanTags(text).map(tag => ({
|
|
131
|
+
...tag,
|
|
132
|
+
start: start == null ? null : start + tag.index,
|
|
133
|
+
end: start == null ? null : start + tag.end,
|
|
134
|
+
})))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The closing tag of `tags[at]` in a list from tagsIn or scanTags, past any
|
|
139
|
+
* of the same name inside it; null when it has none.
|
|
140
|
+
*/
|
|
141
|
+
export function closingTagOf(tags, at) {
|
|
142
|
+
const open = tags[at]
|
|
143
|
+
if (open.closing || open.selfClosing) return null
|
|
144
|
+
let depth = 0
|
|
145
|
+
for (const tag of tags.slice(at + 1)) {
|
|
146
|
+
if (tag.name !== open.name || tag.selfClosing) continue
|
|
147
|
+
if (!tag.closing) depth++
|
|
148
|
+
else if (depth) depth--
|
|
149
|
+
else return tag
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Self-closing tags of the named elements given a closing tag. A browser
|
|
156
|
+
* takes `<poll />` for `<poll>`, so what follows would end up inside it.
|
|
157
|
+
*/
|
|
158
|
+
export function closeTags(html, names) {
|
|
159
|
+
let out = ''
|
|
160
|
+
let last = 0
|
|
161
|
+
for (const tag of scanTags(html)) {
|
|
162
|
+
if (!tag.selfClosing || !names.has(tag.name)) continue
|
|
163
|
+
out += html.slice(last, tag.index) + tag.raw.replace(/\s*\/>$/, '>') + `</${tag.name}>`
|
|
164
|
+
last = tag.end
|
|
165
|
+
}
|
|
166
|
+
return out + html.slice(last)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The opening tags (and self-closing ones) in a piece of Markdown. */
|
|
170
|
+
export const openTagsIn = markdown => tagsIn(markdown).filter(tag => !tag.closing)
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Calls `fn(tag)` for every tag in a piece of Markdown and puts what it
|
|
174
|
+
* returns in its place (the tag stays when it returns undefined). Line ends
|
|
175
|
+
* come back as \n.
|
|
176
|
+
*/
|
|
177
|
+
export function replaceTagsIn(markdown, fn) {
|
|
178
|
+
const source = String(markdown ?? '').replace(/\r\n?/g, '\n')
|
|
179
|
+
let out = ''
|
|
180
|
+
let last = 0
|
|
181
|
+
for (const tag of tagsIn(source)) {
|
|
182
|
+
if (tag.start == null || tag.start < last) continue
|
|
183
|
+
const replacement = fn(tag)
|
|
184
|
+
if (replacement == null) continue
|
|
185
|
+
out += source.slice(last, tag.start) + replacement
|
|
186
|
+
last = tag.end
|
|
187
|
+
}
|
|
188
|
+
return out + source.slice(last)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A tag's text with one attribute's value replaced (as written, not encoded). */
|
|
192
|
+
export function withAttribute(tag, name, value) {
|
|
193
|
+
const [from, to] = tag.spans[name]
|
|
194
|
+
return tag.raw.slice(0, from) + value + tag.raw.slice(to)
|
|
195
|
+
}
|
package/src/editor/sampleDeck.js
CHANGED
|
@@ -6,6 +6,26 @@
|
|
|
6
6
|
// over hills under a sun, for every layout that shows a picture.
|
|
7
7
|
export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
|
|
8
8
|
|
|
9
|
+
// The references the sample deck cites. Builds find them in a file beside the
|
|
10
|
+
// deck; the dev and edit servers read them from here where there is none.
|
|
11
|
+
export const SAMPLE_REFERENCES_FILE = 'sample-references.bib'
|
|
12
|
+
export const SAMPLE_REFERENCES = `@article{doe2024,
|
|
13
|
+
author = {Doe, Jane and Roe, Richard},
|
|
14
|
+
title = {Measuring how slides are read},
|
|
15
|
+
journal = {Journal of Sample Studies},
|
|
16
|
+
year = {2024},
|
|
17
|
+
volume = {12},
|
|
18
|
+
pages = {1--10}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@book{roe2023,
|
|
22
|
+
author = {Roe, Richard},
|
|
23
|
+
title = {Quotations and Their Sources},
|
|
24
|
+
publisher = {Example Press},
|
|
25
|
+
year = {2023}
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
|
|
9
29
|
// `picture` is where the slides find the picture: a file beside the deck, or
|
|
10
30
|
// a data URL where there is no such file.
|
|
11
31
|
export function sampleDeck(picture = './picture.svg') {
|
|
@@ -15,6 +35,7 @@ meta:
|
|
|
15
35
|
author: "A. Author"
|
|
16
36
|
organization: "Institute"
|
|
17
37
|
date: "2026-10-06"
|
|
38
|
+
bibliography: ${SAMPLE_REFERENCES_FILE}
|
|
18
39
|
---
|
|
19
40
|
|
|
20
41
|
---
|
|
@@ -51,10 +72,10 @@ alt: "Shapes"
|
|
|
51
72
|
---
|
|
52
73
|
# Lists, emphasis and code
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
1. A step with *emphasis* and **strong** words
|
|
76
|
+
2. A step with \`inline code\` and a [link](https://example.com)
|
|
77
|
+
- A nested point
|
|
78
|
+
- Another nested point
|
|
58
79
|
|
|
59
80
|
\`\`\`js
|
|
60
81
|
const total = values.reduce((sum, value) => sum + value, 0)
|
|
@@ -69,7 +90,7 @@ const total = values.reduce((sum, value) => sum + value, 0)
|
|
|
69
90
|
| Treatment | 5.1 | 0.6 |
|
|
70
91
|
| Follow-up | 5.0 | 0.7 |
|
|
71
92
|
|
|
72
|
-
The difference is $\\Delta = 0.9
|
|
93
|
+
The difference is $\\Delta = 0.9$ [@doe2024, p. 4], with a footnote.[^1]
|
|
73
94
|
|
|
74
95
|
[^1]: A source for the claim.
|
|
75
96
|
|
|
@@ -115,9 +136,9 @@ $$
|
|
|
115
136
|
|
|
116
137
|
+++
|
|
117
138
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
> A quotation set apart from the text.
|
|
140
|
+
|
|
141
|
+
As @roe2023 notes, a quote needs its source.
|
|
121
142
|
:::
|
|
122
143
|
|
|
123
144
|
---
|
|
@@ -158,6 +179,11 @@ alt: "Shapes"
|
|
|
158
179
|
overlay: true
|
|
159
180
|
---
|
|
160
181
|
# A picture that fills the slide.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
# References
|
|
185
|
+
|
|
186
|
+
<bibliography />
|
|
161
187
|
`.replaceAll('./picture.svg', picture)
|
|
162
188
|
}
|
|
163
189
|
|
|
@@ -21,8 +21,8 @@ export function userExtensionsDir() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// The built-in themes and palettes this user removed (`mdeck themes remove
|
|
24
|
-
//
|
|
25
|
-
// "theme:
|
|
24
|
+
// glass`): they stay in mdeck's folder but are not offered. Kept as
|
|
25
|
+
// "theme:glass" and "palette:nordic".
|
|
26
26
|
const removedFile = () => resolve(mdeckHome(), 'removed.json')
|
|
27
27
|
export function removedBuiltIns() {
|
|
28
28
|
let saved = {}
|
package/src/extensions/moved.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Themes and palettes that came with mdeck
|
|
2
|
-
//
|
|
1
|
+
// Themes and palettes that once came with mdeck and now live in the theme
|
|
2
|
+
// repository. A deck that names one is told how to install it instead
|
|
3
3
|
// of being told the name is unknown.
|
|
4
4
|
export const MOVED = {
|
|
5
|
-
themes: ['duet', 'editorial', 'fhnw', 'terminal', 'sketch'],
|
|
6
|
-
palettes: ['
|
|
5
|
+
themes: ['duet', 'editorial', 'fhnw', 'terminal', 'sketch', 'aurora'],
|
|
6
|
+
palettes: ['terra', 'brand', 'phosphor', 'pastel', 'forest', 'neon'],
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// Palettes that were removed because another one looks almost the same, by
|
|
@@ -3,9 +3,11 @@ import { useEffect, useRef } from 'preact/hooks'
|
|
|
3
3
|
import { render } from 'preact'
|
|
4
4
|
import { marked, Parser } from 'marked'
|
|
5
5
|
import { registry } from '../runtime/registry'
|
|
6
|
+
import { scanTags, closeTags } from '../core/tags.js'
|
|
7
|
+
import { expandPollLists } from '../core/pollList.js'
|
|
6
8
|
import { InkLayer } from '../runtime/ink/InkLayer.jsx'
|
|
7
9
|
import themedSvgs from 'virtual:deck-svgs'
|
|
8
|
-
import { renderCitations, citationsHtml as referencesOf } from '../runtime/citations.js'
|
|
10
|
+
import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
|
|
9
11
|
|
|
10
12
|
// ─── Content extraction ────────────────────────────────────────────────────
|
|
11
13
|
|
|
@@ -39,7 +41,9 @@ function preprocessFootnotes(markdown) {
|
|
|
39
41
|
export { t } from '../core/labels.js'
|
|
40
42
|
|
|
41
43
|
export function extractContent(markdown, { headingLevels = [], paragraph = false } = {}) {
|
|
42
|
-
|
|
44
|
+
// marked's preprocess hook (src/runtime/markedSetup.js) does not run for
|
|
45
|
+
// marked.lexer, so the lists of polls become their options here.
|
|
46
|
+
const processed = expandPollLists(markdown)
|
|
43
47
|
const footnotesHtml = ''
|
|
44
48
|
const tokens = marked.lexer(processed)
|
|
45
49
|
const headings = {}
|
|
@@ -74,17 +78,19 @@ function themedDataSvg(src) {
|
|
|
74
78
|
return /var\(--/.test(markup) && /^\s*<svg\b/.test(markup) ? markup : null
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
|
|
78
|
-
|
|
79
81
|
// Pictures in slide text keep the drawing's own size, like an <img>.
|
|
80
82
|
export function inlineThemedPictures(html) {
|
|
81
83
|
if (!html || !html.includes('<img')) return html
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
let out = ''
|
|
85
|
+
let last = 0
|
|
86
|
+
for (const tag of scanTags(html)) {
|
|
87
|
+
const markup = tag.name === 'img' && !tag.closing && themedSvg(tag.attrs.src)
|
|
88
|
+
if (!markup) continue
|
|
89
|
+
const alt = (tag.attrs.alt ?? '').replace(/&/g, '&').replace(/"/g, '"')
|
|
90
|
+
out += html.slice(last, tag.index) + `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
|
|
91
|
+
last = tag.end
|
|
92
|
+
}
|
|
93
|
+
return out + html.slice(last)
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
// A picture from the slide's settings; `fit: cover` fills its box like a
|
|
@@ -100,6 +106,10 @@ export function Picture({ src, alt, fit, position, class: className, ...attribut
|
|
|
100
106
|
|
|
101
107
|
// ─── Hydration for inline components ──────────────────────────────────────
|
|
102
108
|
|
|
109
|
+
// Components may be written `<poll … />`, which a browser takes for an open
|
|
110
|
+
// tag, putting what follows inside the component, where it is lost.
|
|
111
|
+
const COMPONENT_TAGS = new Set(Object.keys(registry))
|
|
112
|
+
|
|
103
113
|
export function HtmlContent({ html, class: className, ...attributes }) {
|
|
104
114
|
const ref = useRef()
|
|
105
115
|
|
|
@@ -119,7 +129,7 @@ export function HtmlContent({ html, class: className, ...attributes }) {
|
|
|
119
129
|
return () => mounted.forEach(wrapper => render(null, wrapper))
|
|
120
130
|
}, [html])
|
|
121
131
|
|
|
122
|
-
return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: inlineThemedPictures(html) }} />
|
|
132
|
+
return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: closeTags(inlineThemedPictures(html), COMPONENT_TAGS) }} />
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
// ─── Header / footer / footnotes rails ────────────────────────────────────
|
|
@@ -188,11 +198,12 @@ export function prepareSlide(slide) {
|
|
|
188
198
|
.sort((a, b) => position(a[1]) - position(b[1]))
|
|
189
199
|
let separator = '\u0000mdeck-region\u0000'
|
|
190
200
|
while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
|
|
191
|
-
const
|
|
201
|
+
const bib = bibliographyOf(slide.deckConfig)
|
|
202
|
+
const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
|
|
192
203
|
const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
|
|
193
204
|
const pieces = processed.split(separator)
|
|
194
205
|
const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
|
|
195
|
-
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids)
|
|
206
|
+
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
|
|
196
207
|
return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
|
|
197
208
|
}
|
|
198
209
|
|
|
@@ -33,3 +33,22 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
|
|
|
33
33
|
.answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
|
|
34
34
|
footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
|
|
35
35
|
.answer-follow { display: block; margin: 0 20px 24px; padding: 12px 16px; text-align: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; }
|
|
36
|
+
/* Maths in a question or option (MathML, see answer.js). */
|
|
37
|
+
.answer-rich math { font-size: 1.1em; }
|
|
38
|
+
/* buttons="letters|numbers": big keys; the options are on the slide. */
|
|
39
|
+
.answer-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 12px; }
|
|
40
|
+
.answer .answer-keys button { padding: 22px 0; text-align: center; font-size: 28px; }
|
|
41
|
+
/* `multiple`: a box that is ticked when picked. */
|
|
42
|
+
.answer-check { display: inline-block; width: 1em; height: 1em; margin-right: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
|
|
43
|
+
.answer button.is-picked .answer-check { opacity: 1; background: currentColor; box-shadow: inset 0 0 0 2px var(--accent); }
|
|
44
|
+
/* Closed: no more answers. */
|
|
45
|
+
.answer button:disabled, .answer textarea:disabled, .answer input:disabled { opacity: .45; cursor: default; }
|
|
46
|
+
.answer button.is-picked:disabled { opacity: .75; }
|
|
47
|
+
/* Once the right answer shows. */
|
|
48
|
+
.answer-verdict { margin: 4px 0 0; padding: 14px 16px; border-radius: 12px; background: color-mix(in srgb, var(--ink) 6%, transparent); font-weight: 600; }
|
|
49
|
+
.answer-verdict.is-right { background: color-mix(in srgb, #16a34a 16%, transparent); color: color-mix(in srgb, #16a34a 75%, var(--ink)); }
|
|
50
|
+
.answer-verdict.is-wrong { background: color-mix(in srgb, #dc2626 12%, transparent); }
|
|
51
|
+
/* <numeric>: a number field and Send. */
|
|
52
|
+
.answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
|
|
53
|
+
.answer-number input { width: 100%; min-width: 0; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; font-size: 22px; font-variant-numeric: tabular-nums; }
|
|
54
|
+
.answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|