mdeck 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/assets/extensions/palettes/cobalt/extension.toml +30 -0
  4. package/assets/extensions/palettes/greyscale/extension.toml +57 -0
  5. package/assets/extensions/themes/academic/extension.toml +13 -0
  6. package/assets/extensions/themes/glass/extension.toml +71 -0
  7. package/assets/extensions/themes/glass/styles.css +754 -0
  8. package/assets/extensions/themes/minimal/extension.toml +10 -0
  9. package/assets/extensions/themes/neue/extension.toml +12 -0
  10. package/assets/extensions/themes/plain/extension.toml +72 -0
  11. package/assets/extensions/themes/plain/styles.css +471 -0
  12. package/assets/extensions/themes/work/extension.toml +69 -0
  13. package/assets/extensions/themes/work/styles.css +512 -0
  14. package/docs/reference/authoring.md +25 -8
  15. package/docs/reference/extensions.md +15 -0
  16. package/docs/reference/palettes.md +4 -3
  17. package/docs/reference/themes.md +10 -3
  18. package/docs/site/content/appearance.md +24 -10
  19. package/docs/site/content/audience.md +14 -0
  20. package/docs/site/content/theme-authoring.md +16 -0
  21. package/docs/site/content/theme-repository.md +8 -8
  22. package/docs/site/images/themes/academic-dark.webp +0 -0
  23. package/docs/site/images/themes/academic-light.webp +0 -0
  24. package/docs/site/images/themes/academic.webp +0 -0
  25. package/docs/site/images/themes/glass-dark.webp +0 -0
  26. package/docs/site/images/themes/glass-light.webp +0 -0
  27. package/docs/site/images/themes/glass.webp +0 -0
  28. package/docs/site/images/themes/minimal-dark.webp +0 -0
  29. package/docs/site/images/themes/minimal-light.webp +0 -0
  30. package/docs/site/images/themes/minimal.webp +0 -0
  31. package/docs/site/images/themes/neue-dark.webp +0 -0
  32. package/docs/site/images/themes/neue-light.webp +0 -0
  33. package/docs/site/images/themes/neue.webp +0 -0
  34. package/docs/site/images/themes/plain-dark.webp +0 -0
  35. package/docs/site/images/themes/plain-light.webp +0 -0
  36. package/docs/site/images/themes/plain.webp +0 -0
  37. package/docs/site/images/themes/work-dark.webp +0 -0
  38. package/docs/site/images/themes/work-light.webp +0 -0
  39. package/docs/site/images/themes/work.webp +0 -0
  40. package/examples/showcase/slides.md +2 -2
  41. package/package.json +4 -1
  42. package/skills/write-slides/SKILL.md +4 -3
  43. package/src/build/slidesPlugin.js +16 -15
  44. package/src/cli/index.js +5 -1
  45. package/src/cli/themes.js +5 -2
  46. package/src/components/Poll.jsx +17 -5
  47. package/src/components/inlineText.js +38 -15
  48. package/src/components/poll.css +3 -0
  49. package/src/core/editDeck.js +4 -5
  50. package/src/core/pollList.js +66 -0
  51. package/src/core/tags.js +195 -0
  52. package/src/editor/extensions.js +8 -0
  53. package/src/extensions/discover.js +2 -2
  54. package/src/extensions/manifest.js +33 -1
  55. package/src/extensions/moved.js +4 -4
  56. package/src/extensions/packages.js +2 -1
  57. package/src/layouts/layoutApi.jsx +20 -10
  58. package/src/live/roomTag.js +11 -19
  59. package/src/runtime/main.jsx +2 -1
  60. package/src/runtime/markedSetup.js +3 -0
  61. package/assets/extensions/palettes/neon/extension.toml +0 -30
  62. package/assets/extensions/themes/aurora/extension.toml +0 -40
  63. package/assets/extensions/themes/aurora/styles.css +0 -670
  64. package/docs/site/images/themes/aurora-dark.webp +0 -0
  65. package/docs/site/images/themes/aurora-light.webp +0 -0
  66. package/docs/site/images/themes/aurora.webp +0 -0
@@ -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
+ }
@@ -68,10 +68,18 @@ export function toRuntimeManifest(model) {
68
68
  if (model.kind === 'theme') return {
69
69
  ...shared, palette: model.palette, ...(model.palettes?.length ? { palettes: model.palettes } : {}), appearance: model.appearance ?? 'light', fonts: model.fonts ?? [], tokens,
70
70
  params: table(model.params ?? [], param => ({ token: param.token, default: tokens[param.token] ?? '', ...(param.title ? { title: param.title } : {}) })),
71
+ ...(model.extra?.guide ? { guide: runtimeGuide(model.extra.guide) } : {}),
71
72
  }
72
73
  return shared
73
74
  }
74
75
 
76
+ // A theme's guide as validateManifest normalizes it (the forms do not edit it;
77
+ // it is kept with the other settings they do not know).
78
+ function runtimeGuide(guide) {
79
+ const phrases = list => list?.map(phrase => String(phrase).trim())
80
+ return clean({ suits: phrases(guide.suits), avoid: phrases(guide.avoid), writing: guide.writing?.trim().replace(/[ \t]+\n/g, '\n') })
81
+ }
82
+
75
83
  // Starting points for new extensions. `from` is a registry manifest to copy;
76
84
  // a theme is always a copy, a palette may start from readable plain colours.
77
85
  export function starterFiles(kind, id, title, from = null, fromFiles = {}) {
@@ -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
- // aurora`): they stay in mdeck's folder but are not offered. Kept as
25
- // "theme:aurora" and "palette:neon".
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 = {}
@@ -20,7 +20,7 @@ const SHARED_KEYS = ['schema', 'kind', 'id', 'title', 'description']
20
20
  const PACKAGE_KEYS = ['version', 'author', 'license', 'homepage', 'mdeck']
21
21
  const KEYS = {
22
22
  layout: [...SHARED_KEYS, 'frame', 'files', 'regions', 'properties'],
23
- theme: [...SHARED_KEYS, ...PACKAGE_KEYS, 'palette', 'palettes', 'appearance', 'fonts', 'files', 'tokens', 'params'],
23
+ theme: [...SHARED_KEYS, ...PACKAGE_KEYS, 'palette', 'palettes', 'appearance', 'fonts', 'files', 'tokens', 'params', 'guide'],
24
24
  palette: [...SHARED_KEYS, ...PACKAGE_KEYS, 'theme', 'light', 'dark'],
25
25
  }
26
26
  export const VERSION_RE = /^\d+\.\d+\.\d+$/
@@ -203,12 +203,44 @@ function validateTheme(raw, shared, fail) {
203
203
  for (const key of ['title', 'description']) if (param[key] != null && typeof param[key] !== 'string') fail('must be text', `${path}.${key}`)
204
204
  params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}) }
205
205
  }
206
+ const guide = validateGuide(raw.guide, fail)
206
207
  return {
207
208
  ...shared, palette: raw.palette, ...(raw.palettes ? { palettes: [...raw.palettes] } : {}),
208
209
  appearance: raw.appearance ?? 'light', fonts: raw.fonts ?? [], tokens, params,
210
+ ...(guide ? { guide } : {}),
209
211
  }
210
212
  }
211
213
 
214
+ // What a theme is for and how to write for it, from its author: the content
215
+ // it suits, the content to avoid, and a few sentences on writing slides for
216
+ // it. People see it in listings; assistants that write slides read it as the
217
+ // author's advice about the theme, never as instructions. It is short plain
218
+ // text, so it stays advice: phrases of one line, a paragraph or two at most.
219
+ export const GUIDE_LIMITS = { items: 8, phrase: 60, writing: 1200 }
220
+ function validateGuide(table, fail) {
221
+ if (table == null) return null
222
+ if (!isPlainObject(table)) fail('guide must be a table with suits, avoid and writing', 'guide')
223
+ for (const key of Object.keys(table)) if (!['suits', 'avoid', 'writing'].includes(key)) fail('Unknown guide setting. Allowed: suits, avoid, writing', `guide.${key}`)
224
+ const guide = {}
225
+ for (const key of ['suits', 'avoid']) {
226
+ const list = table[key]
227
+ if (list == null) continue
228
+ if (!Array.isArray(list) || !list.length || list.length > GUIDE_LIMITS.items) fail(`must be a list of 1 to ${GUIDE_LIMITS.items} short phrases`, `guide.${key}`)
229
+ guide[key] = list.map((phrase, index) => {
230
+ if (typeof phrase !== 'string' || !phrase.trim() || /\n/.test(phrase.trim()) || phrase.trim().length > GUIDE_LIMITS.phrase) fail(`must be a phrase of one line, at most ${GUIDE_LIMITS.phrase} characters`, `guide.${key}[${index}]`)
231
+ return phrase.trim()
232
+ })
233
+ }
234
+ if (table.writing != null) {
235
+ if (typeof table.writing !== 'string' || !table.writing.trim()) fail('must be text', 'guide.writing')
236
+ const writing = table.writing.trim().replace(/[ \t]+\n/g, '\n')
237
+ if (writing.length > GUIDE_LIMITS.writing) fail(`must be at most ${GUIDE_LIMITS.writing} characters (it has ${writing.length})`, 'guide.writing')
238
+ guide.writing = writing
239
+ }
240
+ if (!Object.keys(guide).length) fail('guide must say what the theme suits, what to avoid, or how to write for it', 'guide')
241
+ return guide
242
+ }
243
+
212
244
  // A palette is a family of colours in two variants: [light] and [dark], each
213
245
  // with every colour role. Themes use one variant for the slides and the
214
246
  // other for inverted slides. `theme` makes a palette private to that theme.
@@ -1,9 +1,9 @@
1
- // Themes and palettes that came with mdeck before 3.0 and now live in the
2
- // theme repository. A deck that names one is told how to install it instead
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: ['cobalt', 'terra', 'brand', 'phosphor', 'pastel', 'forest'],
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
@@ -195,7 +195,8 @@ function entryOf(kind, record) {
195
195
  kind, id: record.id, title: record.title, description: record.description ?? '',
196
196
  version: record.package?.version ?? '0.0.0', ...(record.package?.author ? { author: record.package.author } : {}), ...(record.package?.license ? { license: record.package.license } : {}),
197
197
  ...(record.package?.homepage ? { homepage: record.package.homepage } : {}), ...(record.package?.mdeck ? { mdeck: record.package.mdeck } : {}),
198
- ...(kind === 'theme' ? { palette: record.manifest.palette, ...(record.manifest.palettes?.length ? { palettes: record.manifest.palettes } : {}), appearance: record.manifest.appearance ?? 'light' } : {}),
198
+ ...(kind === 'theme' ? { palette: record.manifest.palette, ...(record.manifest.palettes?.length ? { palettes: record.manifest.palettes } : {}), appearance: record.manifest.appearance ?? 'light',
199
+ ...(record.manifest.guide ? { guide: record.manifest.guide } : {}) } : {}),
199
200
  ...(kind === 'palette' && record.manifest.theme ? { theme: record.manifest.theme } : {}),
200
201
  }
201
202
  }
@@ -3,6 +3,8 @@ 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
10
  import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
@@ -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
- const processed = markdown
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
- return html.replace(/<img\b[^>]*>/gi, tag => {
83
- const markup = themedSvg(attribute(tag, 'src'))
84
- if (!markup) return tag
85
- const alt = attribute(tag, 'alt') ?? ''
86
- return `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
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, '&amp;').replace(/"/g, '&quot;')
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 ────────────────────────────────────
@@ -1,21 +1,11 @@
1
1
  // Finds the component tag a phone should see: the one whose `room` attribute
2
2
  // matches, with its content if it has any.
3
- const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3
+ import { tagsIn, closingTagOf } from '../core/tags.js'
4
4
 
5
- // Code is not an activity: fenced blocks and `inline code` are blanked out
6
- // (keeping every other character where it was) before searching.
7
- const blank = text => text.replace(/[^\n]/g, ' ')
8
- export function maskCode(source = '') {
9
- return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
10
- }
11
-
12
- // Inside a tag: anything but its end, with quoted values whole, so a `>` in
13
- // an attribute (question="Is $x > 0$?") does not end the tag.
14
- const INSIDE = `(?:[^>"']|"[^"]*"|'[^']*')`
15
-
16
- // Every activity in a source, in order: [{ tag, room }].
5
+ // Every activity in a source, in order: [{ tag, room }]. Tags in code are
6
+ // not activities (src/core/tags.js).
17
7
  export function roomsIn(source = '') {
18
- return [...maskCode(source).matchAll(new RegExp(`<([a-z][a-z0-9-]*)\\b${INSIDE}*?\\broom\\s*=\\s*(["'])([^"']+)\\2`, 'gi'))].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
8
+ return tagsIn(source).filter(tag => !tag.closing && tag.attrs.room).map(tag => ({ tag: tag.name, room: tag.attrs.room }))
19
9
  }
20
10
 
21
11
  // The rooms on one slide, from its body and its named regions (the body is
@@ -34,9 +24,11 @@ export function slideTitleFor(deck, room) {
34
24
  }
35
25
 
36
26
  export function findRoomTag(source, room) {
37
- const open = new RegExp(`<([a-z][a-z0-9-]*)\\b${INSIDE}*?\\broom\\s*=\\s*(["'])${escape(room)}\\2${INSIDE}*>`, 'i').exec(maskCode(source))
38
- if (!open) return null
39
- if (open[0].endsWith('/>')) return open[0]
40
- const close = source.indexOf(`</${open[1]}>`, open.index + open[0].length)
41
- return close < 0 ? open[0] : source.slice(open.index, close + open[1].length + 3)
27
+ const text = String(source ?? '').replace(/\r\n?/g, '\n')
28
+ const tags = tagsIn(text)
29
+ const at = tags.findIndex(tag => !tag.closing && tag.attrs.room === room)
30
+ if (at < 0) return null
31
+ const open = tags[at]
32
+ const close = open.start == null ? null : closingTagOf(tags, at)
33
+ return close?.end == null ? open.raw : text.slice(open.start, close.end)
42
34
  }
@@ -26,6 +26,7 @@ import { strokePath } from '../core/ink.js'
26
26
  import { inkFileName } from 'virtual:deck-ink'
27
27
  import savedResults from 'virtual:deck-results'
28
28
  import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
29
+ import { expandPollLists } from '../core/pollList.js'
29
30
  import './reader.css'
30
31
  import { SlideRenderer, manifests } from '../layouts/renderSlide'
31
32
  import { setCalloutLabels } from './markedSetup'
@@ -81,7 +82,7 @@ function activityOn(deck, slide) {
81
82
  const room = roomsOnSlide(slide)[0]
82
83
  if (!room) return { room: null, activity: null }
83
84
  const tag = findRoomTag(slide.content ?? '', room) ?? Object.values(slide.regions ?? {}).map(region => findRoomTag(region.content ?? '', room)).find(Boolean)
84
- const element = tag && new DOMParser().parseFromString(tag, 'text/html').body.firstElementChild
85
+ const element = tag && new DOMParser().parseFromString(expandPollLists(tag), 'text/html').body.firstElementChild
85
86
  const component = element && registry[element.localName]
86
87
  if (typeof component?.phone !== 'function') return { room, activity: null }
87
88
  const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
@@ -5,6 +5,7 @@ import '../components/callout.css'
5
5
  import '../components/columns.css'
6
6
  import { scanDirectives, sourceLines, fenceState } from '../core/source.js'
7
7
  import { CALLOUT_LABELS, labelSetFor } from '../core/labels.js'
8
+ import { expandPollLists } from '../core/pollList.js'
8
9
 
9
10
  function readDirective(src, name) {
10
11
  if (!/^:::\s*[\w-]+/.test(src)) return null
@@ -36,6 +37,8 @@ function escapeHtml(text) {
36
37
  }
37
38
 
38
39
  marked.use(markedKatex({ throwOnError: false, output: 'html' }))
40
+ // A poll's options written as a list inside the tag (src/core/pollList.js).
41
+ marked.use({ hooks: { preprocess: expandPollLists } })
39
42
 
40
43
  // marked.use() prepends via unshift, so last-registered = highest priority.
41
44
  // Register callout first (lowest priority) so columns and steps match before it.
@@ -1,30 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "neon"
4
- title = "Neon"
5
- description = "Violet and magenta; loud, for workshops."
6
- version = "1.0.0"
7
- author = "Tilman Schieber"
8
- license = "MIT"
9
-
10
- [light]
11
- "--bg" = "#ffffff"
12
- "--surface" = "#f4f0fb"
13
- "--ink" = "#14072b"
14
- "--ink-soft" = "#3a2d55"
15
- "--muted" = "#6a6084"
16
- "--rule" = "#e4ddf3"
17
- "--accent" = "#9b00d9"
18
- "--accent-2" = "#d6106f"
19
- "--on-accent" = "#ffffff"
20
-
21
- [dark]
22
- "--bg" = "#0d0d0d"
23
- "--surface" = "#1a1a1a"
24
- "--ink" = "#f0f0f0"
25
- "--ink-soft" = "#cccccc"
26
- "--muted" = "#8a8a8a"
27
- "--rule" = "#2a2a2a"
28
- "--accent" = "#cc17ff"
29
- "--accent-2" = "#ff1791"
30
- "--on-accent" = "#0d0d0d"
@@ -1,40 +0,0 @@
1
- schema = 1
2
- kind = "theme"
3
- id = "aurora"
4
- title = "Aurora"
5
- description = "Northern lights: curtains of light in the accent colours, night-sky chapter and statement slides, frosted glass."
6
- version = "1.0.0"
7
- author = "Tilman Schieber"
8
- license = "MIT"
9
- # The palette this theme uses unless the deck sets one; colours come only from palettes.
10
- palette = "neon"
11
- fonts = [
12
- "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700;800&display=swap",
13
- ]
14
-
15
- [files]
16
- styles = "styles.css"
17
-
18
- # Type, spacing and fonts used by styles.css; colours come from the palette.
19
- # Deck settings (params) override these.
20
- [tokens]
21
-
22
- # Type scale (px) — projection-appropriate at 1920×1080
23
- "--fs-display" = "168px"
24
- "--fs-title" = "112px"
25
- "--fs-focus" = "100px"
26
- "--fs-h" = "76px"
27
- "--fs-sub" = "44px"
28
- "--fs-body" = "34px"
29
- "--fs-small" = "26px"
30
- "--fs-eyebrow" = "20px"
31
-
32
- # Spacing
33
- "--pad-x" = "120px"
34
- "--pad-y" = "72px"
35
- "--gap-title" = "48px"
36
- "--gap-item" = "24px"
37
-
38
- # Type families
39
- "--font-display" = "\"Plus Jakarta Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif"
40
- "--font-body" = "\"Plus Jakarta Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif"