mdeck 3.4.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +8 -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/glass/extension.toml +59 -0
  6. package/assets/extensions/themes/glass/styles.css +754 -0
  7. package/assets/extensions/themes/plain/extension.toml +58 -0
  8. package/assets/extensions/themes/plain/styles.css +471 -0
  9. package/assets/extensions/themes/work/extension.toml +57 -0
  10. package/assets/extensions/themes/work/styles.css +512 -0
  11. package/docs/reference/authoring.md +25 -8
  12. package/docs/reference/palettes.md +4 -3
  13. package/docs/reference/themes.md +5 -3
  14. package/docs/site/content/appearance.md +24 -10
  15. package/docs/site/content/audience.md +14 -0
  16. package/docs/site/content/theme-repository.md +7 -7
  17. package/docs/site/images/themes/academic-dark.webp +0 -0
  18. package/docs/site/images/themes/academic-light.webp +0 -0
  19. package/docs/site/images/themes/academic.webp +0 -0
  20. package/docs/site/images/themes/glass-dark.webp +0 -0
  21. package/docs/site/images/themes/glass-light.webp +0 -0
  22. package/docs/site/images/themes/glass.webp +0 -0
  23. package/docs/site/images/themes/minimal-dark.webp +0 -0
  24. package/docs/site/images/themes/minimal-light.webp +0 -0
  25. package/docs/site/images/themes/minimal.webp +0 -0
  26. package/docs/site/images/themes/neue-dark.webp +0 -0
  27. package/docs/site/images/themes/neue-light.webp +0 -0
  28. package/docs/site/images/themes/neue.webp +0 -0
  29. package/docs/site/images/themes/plain-dark.webp +0 -0
  30. package/docs/site/images/themes/plain-light.webp +0 -0
  31. package/docs/site/images/themes/plain.webp +0 -0
  32. package/docs/site/images/themes/work-dark.webp +0 -0
  33. package/docs/site/images/themes/work-light.webp +0 -0
  34. package/docs/site/images/themes/work.webp +0 -0
  35. package/examples/showcase/slides.md +2 -2
  36. package/package.json +4 -1
  37. package/skills/write-slides/SKILL.md +2 -2
  38. package/src/build/slidesPlugin.js +16 -15
  39. package/src/components/Poll.jsx +17 -5
  40. package/src/components/inlineText.js +38 -15
  41. package/src/components/poll.css +3 -0
  42. package/src/core/editDeck.js +4 -5
  43. package/src/core/pollList.js +66 -0
  44. package/src/core/tags.js +195 -0
  45. package/src/extensions/discover.js +2 -2
  46. package/src/extensions/moved.js +4 -4
  47. package/src/layouts/layoutApi.jsx +20 -10
  48. package/src/live/roomTag.js +11 -19
  49. package/src/runtime/main.jsx +2 -1
  50. package/src/runtime/markedSetup.js +3 -0
  51. package/assets/extensions/palettes/neon/extension.toml +0 -30
  52. package/assets/extensions/themes/aurora/extension.toml +0 -40
  53. package/assets/extensions/themes/aurora/styles.css +0 -670
  54. package/docs/site/images/themes/aurora-dark.webp +0 -0
  55. package/docs/site/images/themes/aurora-light.webp +0 -0
  56. package/docs/site/images/themes/aurora.webp +0 -0
@@ -5,7 +5,7 @@ A **theme** chooses the fonts and the style of the slides. A **palette** chooses
5
5
  Ask your assistant by name or by description:
6
6
 
7
7
  ```prompt
8
- Use the aurora theme, dark. Or suggest a look that suits a talk for
8
+ Use the glass theme, light. Or suggest a look that suits a talk for
9
9
  a design conference.
10
10
  ```
11
11
 
@@ -26,9 +26,11 @@ If you already have a settings block there, change the existing lines rather tha
26
26
  | Name to use | What it looks like |
27
27
  |---|---|
28
28
  | `neue` | Swiss style: large type, clear lines, red on white |
29
- | `aurora` | Northern lights: curtains of light, night-sky chapters and glass |
29
+ | `plain` | Black on white and lots of room, the heading beside the content |
30
30
  | `minimal` | Nothing but the content: system fonts, calm type, no decoration; works offline |
31
31
  | `academic` | A lecture: compact slides, theorem and definition boxes, tables like in a paper |
32
+ | `work` | An everyday company template: a coloured band, your logo, numbered sections, a footer |
33
+ | `glass` | Liquid glass: soft light behind frosted panes; it starts dark |
32
34
 
33
35
  Each theme comes with colours that suit it, so you do not have to choose any. More themes are in the theme repository (see “Themes that moved to the repository” below).
34
36
 
@@ -42,11 +44,11 @@ Each theme in its own colours, on the title slide of the tour example. Click a p
42
44
 
43
45
  Default colours: `swiss`.
44
46
 
45
- ### Aurora
47
+ ### Plain
46
48
 
47
- [![The tour's title slide in the Aurora theme](images/themes/aurora.webp)](images/themes/aurora.webp)
49
+ [![The tour's title slide in the Plain theme](images/themes/plain.webp)](images/themes/plain.webp)
48
50
 
49
- Default colours: `neon`.
51
+ Default colours: `greyscale`. In the dark it is the same, inverted.
50
52
 
51
53
  ### Academic
52
54
 
@@ -60,6 +62,18 @@ Default colours: `nordic`.
60
62
 
61
63
  Default colours: `paper`. It uses the fonts already on the computer, so it needs no internet connection and looks plain on purpose.
62
64
 
65
+ ### Work
66
+
67
+ [![The tour's title slide in the Work theme](images/themes/work.webp)](images/themes/work.webp)
68
+
69
+ Default colours: `cobalt`. Set `meta.logo` and it shows top left; any palette makes it your company's colours.
70
+
71
+ ### Glass
72
+
73
+ [![The tour's title slide in the Glass theme](images/themes/glass.webp)](images/themes/glass.webp)
74
+
75
+ Default colours: `nordic`, dark.
76
+
63
77
  ## Choose colours
64
78
 
65
79
  To use other colours, set `palette` to one of these:
@@ -67,8 +81,9 @@ To use other colours, set `palette` to one of these:
67
81
  | Name to use | Colours |
68
82
  |---|---|
69
83
  | `swiss` | White, black and signal red (neue's own) |
70
- | `nordic` | Ice grey, deep navy and a cool steel blue (academic's own) |
71
- | `neon` | Violet and magenta, loud (aurora's own) |
84
+ | `nordic` | Ice grey, deep navy and a cool steel blue (academic's and glass's own) |
85
+ | `cobalt` | Strong ultramarine on white with vivid orange (work's own) |
86
+ | `greyscale` | Black, white and greys only, good for printing (plain's own) |
72
87
  | `lagoon` | Teal on warm stone |
73
88
  | `paper` | Off-white and near-black with one orange accent (minimal's own) |
74
89
 
@@ -97,8 +112,6 @@ Some slides use the other version on purpose: neue's statement slides are dark i
97
112
 
98
113
  The first chapter slide of the tour in each palette a theme offers. Choose a theme above the pictures; click a sheet to see it larger. A theme that offers only its own colours is not shown here.
99
114
 
100
- Some themes turn their chapter slides around on purpose: aurora's are a night sky in a light talk, and light in a dark one.
101
-
102
115
  ### Light
103
116
 
104
117
  <!-- theme-sheets light -->
@@ -127,7 +140,8 @@ Earlier versions of mdeck came with more themes and palettes. They are now in th
127
140
 
128
141
  | Theme | What it looks like (its palette) |
129
142
  |---|---|
130
- | `duet` | Two accent colours that take turns, slab headings (`cobalt`) |
143
+ | `aurora` | Northern lights: curtains of light, night-sky chapters (`neon`) |
144
+ | `duet` | Two accent colours that take turns, slab headings (`cobalt`, which comes with mdeck) |
131
145
  | `editorial` | A magazine spread with serif headlines and pull quotes (`terra`) |
132
146
  | `fhnw` | The FHNW visual identity, with its own `brand` palette only |
133
147
  | `terminal` | Headings typed at a prompt, a status bar; it starts dark (`phosphor`) |
@@ -44,6 +44,20 @@ The question and the options may contain formatting and formulas, written as on
44
44
 
45
45
  Both the slide and the phones show the formulas. A `|` inside a formula stays in it, so `$|x|$` is one option; to put a `|` in plain text, write `\|`.
46
46
 
47
+ When the options get long, write them as a list inside the tag instead, one option per line, and tick the right one with `[x]`:
48
+
49
+ ```markdown
50
+ <poll room="derivative" question="What is $\frac{d}{dx}\, x^2$?">
51
+
52
+ - [x] $2x$
53
+ - [ ] $x^2$
54
+ - [ ] $\frac{x^3}{3}$
55
+
56
+ </poll>
57
+ ```
58
+
59
+ Nothing needs escaping there, and the right answer is marked where it stands.
60
+
47
61
  Options can also be pictures. Show only letters on the phones then:
48
62
 
49
63
  ```markdown
@@ -1,6 +1,6 @@
1
1
  # Install, remove or share a theme
2
2
 
3
- mdeck comes with four themes, `neue`, `academic`, `aurora` and `minimal`, and five palettes, `swiss`, `nordic`, `neon`, `paper` and `lagoon`. They work right away, also without an internet connection. More themes and palettes are in the **theme repository**; try every one on a sample deck, light and dark, at <https://gh.tschieber.de/mdeck-themes/>.
3
+ mdeck comes with six themes, `neue`, `plain`, `academic`, `minimal`, `work` and `glass`, and six palettes, `swiss`, `nordic`, `cobalt`, `greyscale`, `paper` and `lagoon`. They work right away, also without an internet connection. More themes and palettes are in the **theme repository**; try every one on a sample deck, light and dark, at <https://gh.tschieber.de/mdeck-themes/>.
4
4
 
5
5
  ## Find and install one
6
6
 
@@ -15,16 +15,16 @@ mdeck palettes search
15
15
  Install a theme:
16
16
 
17
17
  ```sh
18
- mdeck themes install duet
18
+ mdeck themes install terminal
19
19
  ```
20
20
 
21
- A theme comes with its default palette, so this installs the palette `cobalt` too. A palette installs on its own:
21
+ A theme comes with its default palette, so this installs the palette `phosphor` too. A palette installs on its own:
22
22
 
23
23
  ```sh
24
24
  mdeck palettes install solarized
25
25
  ```
26
26
 
27
- They are installed for every deck on this computer, in a folder in your home folder (`~/.mdeck/extensions`). Then choose one at the top of a slide file (`theme: duet`, `palette: solarized`) or in the editor's **Deck** tab.
27
+ They are installed for every deck on this computer, in a folder in your home folder (`~/.mdeck/extensions`). Then choose one at the top of a slide file (`theme: terminal`, `palette: solarized`) or in the editor's **Deck** tab.
28
28
 
29
29
  The design page has the same: open it with `mdeck design`, choose **Show what you can install**, and press **Install** beside a theme or palette.
30
30
 
@@ -44,15 +44,15 @@ The slide folder then carries the theme and its palette, so it works on a comput
44
44
  mdeck themes list # what comes with mdeck, and what is installed
45
45
  mdeck themes update # newer versions of the installed themes
46
46
  mdeck palettes update
47
- mdeck themes remove duet
48
- mdeck palettes remove cobalt
47
+ mdeck themes remove terminal
48
+ mdeck palettes remove phosphor
49
49
  ```
50
50
 
51
51
  Add a slide file, or `--local`, to work beside that deck. If you changed an installed file by hand, `update` and `remove` stop and tell you which one, so your change is not lost; add `--force` to go ahead anyway.
52
52
 
53
53
  A palette that an installed theme uses by default stays until that theme is removed. A palette made for one theme only (like FHNW's `brand`) comes and goes with that theme.
54
54
 
55
- The themes and palettes that come with mdeck can be removed too: `mdeck themes remove aurora` hides it from every deck and from the lists, and `mdeck themes install aurora` brings it back, without the internet. The last theme always stays. A deck without a `theme:` setting uses `neue`; if you removed it, `mdeck check` tells you.
55
+ The themes and palettes that come with mdeck can be removed too: `mdeck themes remove glass` hides it from every deck and from the lists, and `mdeck themes install glass` brings it back, without the internet. The last theme always stays. A deck without a `theme:` setting uses `neue`; if you removed it, `mdeck check` tells you.
56
56
 
57
57
  ## What may be in one
58
58
 
Binary file
@@ -1,5 +1,5 @@
1
1
  ---
2
- theme: aurora
2
+ theme: glass
3
3
  meta:
4
4
  title: "A tour of mdeck"
5
5
  author: "Tilman Schieber"
@@ -380,7 +380,7 @@ layout: split
380
380
  :::slot left
381
381
  ```markdown
382
382
  ---
383
- theme: aurora
383
+ theme: work
384
384
  palette: lagoon
385
385
  ---
386
386
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdeck",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "description": "Write slides as a plain text file, present them in the browser, share them as one HTML file or PDF.",
6
6
  "license": "MIT",
@@ -75,5 +75,8 @@
75
75
  "vite": "^8.3.1",
76
76
  "vite-plugin-singlefile": "^2.3.3",
77
77
  "ws": "^8.22.0"
78
+ },
79
+ "devDependencies": {
80
+ "parse5": "^8.0.1"
78
81
  }
79
82
  }
@@ -34,7 +34,7 @@ If `mdeck` is not installed, rely on the quick reference below.
34
34
  ```markdown
35
35
  ---
36
36
  theme: neue # theme id from `mdeck list themes`
37
- palette: nordic # optional: swiss, nordic, neon, lagoon, paper, or one installed from the theme repository (`mdeck themes search`)
37
+ palette: nordic # optional: swiss, nordic, cobalt, greyscale, lagoon, paper, or one installed from the theme repository (`mdeck themes search`)
38
38
  appearance: light # optional: light or dark
39
39
  meta:
40
40
  title: "Talk title"
@@ -94,7 +94,7 @@ Right column
94
94
  - Named areas use `:::slot name … :::`; the layout's regions and settings come from `mdeck list layouts --json`.
95
95
  - `:::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.
96
96
  - Code fences get syntax highlighting; `$…$` and `$$…$$` render math.
97
- - `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones through a QR code on the slide; each poll needs its own `room`. `question` and `options` may use Markdown and `$…$` maths (`options="$2x$|$x^2$"`); `answer` repeats the right option's text. `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. With several of them, put `<qrcode join />` (the deck's join code) on an early slide to show the QR code once, and add `qr="false"` to the questions. It works when the deck is presented with `mdeck run <deck>.md --network` or with a server set as `server:` in the deck settings.
97
+ - `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones through a QR code on the slide; each poll needs its own `room`. `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. With several of them, put `<qrcode join />` (the deck's join code) on an early slide to show the QR code once, and add `qr="false"` to the questions. It works when the deck is presented with `mdeck run <deck>.md --network` or with a server set as `server:` in the deck settings.
98
98
 
99
99
  ## 3. Write the deck
100
100
 
@@ -13,6 +13,7 @@ import { isOwnWrite } from './ownWrites.js'
13
13
  import { fileURLToPath } from 'node:url'
14
14
  import { assertDrawings } from './drawings.js'
15
15
  import { marked } from 'marked'
16
+ import { scanTags, replaceTagsIn, withAttribute } from '../core/tags.js'
16
17
  import { resolveBibliography, sampleBibliography } from './bibliography.js'
17
18
 
18
19
  const VIRTUAL_ID = 'virtual:slides'
@@ -97,21 +98,19 @@ function inlineMarkdownImages(markdown, baseDir) {
97
98
  })
98
99
  }
99
100
 
100
- function inlineHtmlImgSources(markdown, baseDir) {
101
- return markdown.replace(/(<img\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
102
- const dataUrl = toDataUrl(src, baseDir)
103
- if (!dataUrl) return m
104
- return `${pre}${quote}${dataUrl}${endQuote}`
101
+ // The `src` of the named tags, as data: URLs where the file is local.
102
+ function inlineTagSources(markdown, baseDir, names) {
103
+ return replaceTagsIn(markdown, tag => {
104
+ if (tag.closing || !names.has(tag.name) || !tag.attrs.src) return
105
+ const dataUrl = toDataUrl(tag.attrs.src, baseDir)
106
+ return dataUrl ? withAttribute(tag, 'src', dataUrl) : undefined
105
107
  })
106
108
  }
107
109
 
108
- function inlineHtmlMediaSources(markdown, baseDir) {
109
- return markdown.replace(/(<(?:video|audio|source|videoplayer)\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
110
- const dataUrl = toDataUrl(src, baseDir)
111
- if (!dataUrl) return m
112
- return `${pre}${quote}${dataUrl}${endQuote}`
113
- })
114
- }
110
+ const PICTURE_TAGS = new Set(['img'])
111
+ const MEDIA_TAGS = new Set(['video', 'audio', 'source', 'videoplayer'])
112
+ const inlineHtmlImgSources = (markdown, baseDir) => inlineTagSources(markdown, baseDir, PICTURE_TAGS)
113
+ const inlineHtmlMediaSources = (markdown, baseDir) => inlineTagSources(markdown, baseDir, MEDIA_TAGS)
115
114
 
116
115
  function inlineYamlImageFields(markdown, baseDir) {
117
116
  return markdown.replace(/^(\s*)(image|logo):\s*(["']?)([^"'\n]+)\3\s*$/gm, (m, indent, key, quote, value) => {
@@ -141,9 +140,11 @@ export function collectLocalAssetRefs(markdown) {
141
140
  marked.walkTokens(marked.lexer(body), token => {
142
141
  if (token.type === 'image') add(token.href)
143
142
  if (token.type === 'html') {
144
- for (const match of token.text.matchAll(/<(?:img|video|audio|source|videoplayer)\b[^>]*\bsrc=(["'])([^"']+)\1/gi)) add(match[2])
145
- // Markdown pictures in a tag's attributes, such as a poll's options.
146
- for (const match of token.text.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) add(match[1])
143
+ for (const tag of scanTags(token.text)) {
144
+ if (PICTURE_TAGS.has(tag.name) || MEDIA_TAGS.has(tag.name)) add(tag.attrs.src)
145
+ // Markdown pictures in a tag's attributes, such as a poll's options.
146
+ for (const value of Object.values(tag.attrs)) for (const match of value.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) add(match[1])
147
+ }
147
148
  }
148
149
  })
149
150
  }
@@ -3,6 +3,7 @@ import { useRoom, latestByDevice } from '../live/client.js'
3
3
  import { t } from '../core/labels.js'
4
4
  import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr, isSet } from './activity.jsx'
5
5
  import { choicesOf, slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
6
+ import { pollChoices } from '../core/pollList.js'
6
7
  import './poll.css'
7
8
 
8
9
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
@@ -32,6 +33,18 @@ import './poll.css'
32
33
  // The question and the options are Markdown with $…$ maths, as on a slide:
33
34
  // options="$x^2$|$2x$|$\frac{x^3}{3}$". A bar inside a formula stays in it
34
35
  // ($|x|$); \| is a bar in the text. Votes and `answer` use the source text.
36
+ //
37
+ // Options may instead be a list inside the tag, one item each, `[x]` for a
38
+ // right one (src/core/pollList.js). They arrive here as `data-choices`.
39
+
40
+ // The options and the right ones, from the list or from `options` and `answer`.
41
+ function choicesFrom({ options, answer, 'data-choices': listed }) {
42
+ const fromList = listed != null && pollChoices(listed)
43
+ const choices = fromList ? fromList.map(choice => choice.text) : choicesOf(options)
44
+ const marked = fromList ? fromList.filter(choice => choice.right).map(choice => choice.text) : []
45
+ const correct = marked.length ? marked : choicesOf(answer).filter(choice => choices.includes(choice))
46
+ return { choices, correct }
47
+ }
35
48
 
36
49
  const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
37
50
  /** What `buttons` puts before each option: letters, numbers, or nothing. */
@@ -46,11 +59,10 @@ export const keysFor = (buttons, count) => {
46
59
  // from before `multiple` sends one option; it counts as a list of one.
47
60
  const picksOf = (value, multiple) => multiple ? (Array.isArray(value) ? value : [value]) : (Array.isArray(value) ? [] : [value])
48
61
 
49
- export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '', multiple, buttons }) {
50
- const choices = choicesOf(options)
62
+ export default function Poll({ room = 'poll', question = '', qr, results = '', multiple, buttons, ...attributes }) {
63
+ const { choices, correct } = choicesFrom(attributes)
51
64
  const several = isSet(multiple)
52
65
  const keys = keysFor(buttons, choices.length)
53
- const correct = choicesOf(answer).filter(choice => choices.includes(choice))
54
66
  const live = useRoom(room)
55
67
  const controls = useControls(room, live, { results: results !== 'hidden', solution: correct.length ? correct : null })
56
68
  const votes = latestByDevice(controls.counted(live.messages))
@@ -82,8 +94,8 @@ export default function Poll({ room = 'poll', question = '', options = '', qr, a
82
94
  // What the phones show while this poll is on screen. `optionsHtml` are the
83
95
  // options drawn, with maths as MathML; with `buttons`, phones show `keys`
84
96
  // instead. An answer page from before them shows `question` and `options`.
85
- Poll.phone = ({ question, options, multiple, buttons }, { slideTitle = '' } = {}) => {
86
- const choices = choicesOf(options)
97
+ Poll.phone = ({ question, multiple, buttons, ...attributes }, { slideTitle = '' } = {}) => {
98
+ const { choices } = choicesFrom(attributes)
87
99
  const keys = keysFor(buttons, choices.length)
88
100
  return {
89
101
  type: 'choice', ...phoneQuestion(question, slideTitle), options: choices,
@@ -18,23 +18,46 @@ export const phoneQuestion = (question, slideTitle) => question ? { question, qu
18
18
  // with a space, and ending before a space, punctuation, a bar or the end.
19
19
  const MATH = /^(\${1,2})(?!\$|\s)(?:\\.|[^\\\n$])*?\1(?=[\s?!.,:|]|$)/
20
20
 
21
+ // Reads options as Markdown, with formulas as one piece each, to find the
22
+ // bars between them.
23
+ const splitter = new Marked().use({ extensions: [{
24
+ name: 'math',
25
+ level: 'inline',
26
+ start: src => src.indexOf('$') < 0 ? undefined : src.indexOf('$'),
27
+ tokenizer: src => { const math = MATH.exec(src); if (math) return { type: 'math', raw: math[0] } },
28
+ }] })
29
+
30
+ // Text, emphasis and strikethrough are split at their bars; code, formulas,
31
+ // links, pictures and tags are whole, and `\|` is a bar in the text.
32
+ const SPLIT = new Set(['text', 'em', 'strong', 'del'])
33
+ function splitTokens(tokens, parts) {
34
+ for (const token of tokens) {
35
+ if (token.type === 'escape' && token.text === '|') parts[parts.length - 1] += '|'
36
+ else if (SPLIT.has(token.type) && token.tokens?.length) {
37
+ const inner = token.tokens.map(child => child.raw).join('')
38
+ const at = token.raw.indexOf(inner)
39
+ if (at < 0) { splitText(token.raw, parts); continue }
40
+ parts[parts.length - 1] += token.raw.slice(0, at)
41
+ splitTokens(token.tokens, parts)
42
+ parts[parts.length - 1] += token.raw.slice(at + inner.length)
43
+ } else if (SPLIT.has(token.type)) splitText(token.raw, parts)
44
+ else parts[parts.length - 1] += token.raw
45
+ }
46
+ }
47
+ function splitText(text, parts) {
48
+ const [first, ...rest] = text.split('|')
49
+ parts[parts.length - 1] += first
50
+ parts.push(...rest)
51
+ }
52
+
21
53
  /**
22
- * "A|B|C" as ["A", "B", "C"]. A bar in a formula belongs to it ($|x|$,
23
- * $\{x \mid x > 0\}$), and \| is a bar in the text. "$5|$10" is two
24
- * options: neither $ starts a formula.
54
+ * "A|B|C" as ["A", "B", "C"], each as written. A bar in a formula belongs to
55
+ * it ($|x|$, $\{x \mid x > 0\}$), as does one in `code`, a [link](…) or a
56
+ * picture; \| is a bar in the text. "$5|$10" is two options: neither $
57
+ * starts a formula. Emphasis does not hold a bar: "2*3|4*5" is two options.
25
58
  */
26
59
  export function choicesOf(options) {
27
- const text = String(options ?? '')
28
- const parts = []
29
- let part = ''
30
- for (let i = 0; i < text.length; i++) {
31
- const math = text[i] === '$' && text.slice(i).match(MATH)
32
- if (math) { part += math[0]; i += math[0].length - 1 }
33
- else if (text[i] === '\\' && text[i + 1] === '|') { part += '|'; i++ }
34
- else if (text[i] === '\\' && i + 1 < text.length) { part += text[i] + text[i + 1]; i++ }
35
- else if (text[i] === '|') { parts.push(part); part = '' }
36
- else part += text[i]
37
- }
38
- parts.push(part)
60
+ const parts = ['']
61
+ splitTokens(splitter.Lexer.lexInline(String(options ?? ''), splitter.defaults), parts)
39
62
  return parts.map(choice => choice.trim()).filter(Boolean)
40
63
  }
@@ -1,4 +1,7 @@
1
1
  /* ── Poll on the slide ─────────────────────────────────────────────────── */
2
+ /* What follows an activity (or a join code) on the slide has room above it,
3
+ as after a list: the component's wrapper has no margin of its own. */
4
+ :is(.slide-body, [data-region]) div:has(> .poll, > .poll-join-slide) + * { margin-top: var(--gap-item, 32px); }
2
5
  .poll { display: flex; gap: 72px; align-items: center; width: 100%; font-size: var(--fs-body, 36px); color: var(--ink, #111); }
3
6
  .poll-main { flex: 1; min-width: 0; }
4
7
  .poll .poll-question { margin: 0 0 32px; font-weight: 600; }
@@ -4,7 +4,7 @@
4
4
  // identical; callers reparse the result.
5
5
  import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
6
6
  import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
7
- import { maskCode } from '../live/roomTag.js'
7
+ import { openTagsIn } from './tags.js'
8
8
 
9
9
  const REGION_RE = /^[a-z][a-z0-9-]*$/
10
10
 
@@ -233,12 +233,11 @@ export function stripNotes(source) {
233
233
  // The live activities, and the code to join them (<qrcode join />): a slide
234
234
  // with one of them is left out of shared builds. <qrcode follow /> and other
235
235
  // QR codes stay.
236
- const ACTIVITY_RE = /<(poll|question|wordcloud|scale|numeric)\b/i
237
- const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
238
- const joins = text => { const m = JOIN_RE.exec(text); return !!m && !/^(false|no|off|0)$/i.test(m[1] ?? m[2] ?? m[3] ?? '') }
236
+ const ACTIVITIES = new Set(['poll', 'question', 'wordcloud', 'scale', 'numeric'])
237
+ const joins = tag => tag.name === 'qrcode' && 'join' in tag.attrs && !/^\{?(false|no|off|0)\}?$/i.test(tag.attrs.join.trim())
239
238
  export function hasActivity(slide) {
240
239
  return [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
241
- .some(text => { const masked = maskCode(text); return ACTIVITY_RE.test(masked) || joins(masked) })
240
+ .some(text => openTagsIn(text).some(tag => ACTIVITIES.has(tag.name) || joins(tag)))
242
241
  }
243
242
 
244
243
  // Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
@@ -0,0 +1,66 @@
1
+ import { Marked } from 'marked'
2
+ import { tagsIn, closingTagOf } from './tags.js'
3
+
4
+ // A poll's options as a Markdown list inside the tag, for options that are
5
+ // more than a word: pictures, long formulas, text with bars in it.
6
+ //
7
+ // <poll room="deriv" question="What is $\frac{d}{dx} x^2$?">
8
+ // - [x] $2x$
9
+ // - [ ] $x^2$
10
+ // </poll>
11
+ //
12
+ // Each item is one option, as written; `[x]` marks a right answer. Before a
13
+ // slide is drawn, and before the phones read the tag, such a poll becomes
14
+ // `<poll … data-choices="[…]">` with the options as JSON, so Poll needs no
15
+ // Markdown of its own to find them. A poll whose content is not a list
16
+ // stays as it is.
17
+
18
+ const lexer = new Marked()
19
+
20
+ const attributeText = text => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
21
+
22
+ /**
23
+ * The options of a list, [{ text, right }], or null when `markdown` is not
24
+ * a list. A change of bullet (`-` to `*`) starts a new list in Markdown;
25
+ * here it goes on with the same options.
26
+ */
27
+ export function listChoices(markdown) {
28
+ const blocks = lexer.lexer(markdown).filter(token => token.type !== 'space')
29
+ if (!blocks.length || blocks.some(block => block.type !== 'list')) return null
30
+ const choices = blocks.flatMap(block => block.items)
31
+ .map(item => ({ text: item.text.replace(/\s*\n\s*/g, ' ').trim(), right: !!(item.task && item.checked) }))
32
+ // An empty `- [ ]` is no task to marked, but no option either.
33
+ .filter(choice => choice.text && !/^\[[ xX]\]$/.test(choice.text))
34
+ return choices.length ? choices : null
35
+ }
36
+
37
+ /** `markdown` with every poll that holds a list given its options as `data-choices`. */
38
+ export function expandPollLists(markdown) {
39
+ const source = String(markdown ?? '')
40
+ if (!/<poll\b/i.test(source)) return source
41
+ const text = source.replace(/\r\n?/g, '\n')
42
+ const tags = tagsIn(text)
43
+ let out = ''
44
+ let last = 0
45
+ tags.forEach((open, at) => {
46
+ if (open.name !== 'poll' || open.closing || open.start == null || open.start < last) return
47
+ const close = closingTagOf(tags, at)
48
+ if (close?.start == null) return
49
+ const choices = listChoices(text.slice(open.end, close.start))
50
+ if (!choices) return
51
+ // The closing tag on a line of its own keeps the tag one block of HTML,
52
+ // as `<poll … />` on its line is, rather than a paragraph.
53
+ out += text.slice(last, open.start) + `${open.raw.slice(0, -1).trimEnd()} data-choices="${attributeText(JSON.stringify(choices))}">\n</poll>`
54
+ last = close.end
55
+ })
56
+ return last ? out + text.slice(last) : source
57
+ }
58
+
59
+ /** The options of a poll from its attributes: the list's, else `options`. */
60
+ export function pollChoices(listed) {
61
+ try {
62
+ const choices = JSON.parse(listed)
63
+ if (Array.isArray(choices)) return choices.filter(choice => typeof choice?.text === 'string').map(choice => ({ text: choice.text, right: choice.right === true }))
64
+ } catch {}
65
+ return null
66
+ }