mdeck 3.2.0 → 3.4.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 (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +33 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +89 -6
  8. package/docs/site/content/audience.md +41 -4
  9. package/docs/site/content/commands.md +30 -4
  10. package/docs/site/content/drawing.md +1 -1
  11. package/docs/site/content/more-content.md +22 -0
  12. package/docs/site/content/sharing.md +1 -0
  13. package/docs/site/content/troubleshooting.md +1 -1
  14. package/docs/site/content/views.md +3 -1
  15. package/examples/citations/refs.bib +30 -0
  16. package/examples/citations/refs.yml +21 -0
  17. package/examples/citations/slides.md +43 -0
  18. package/examples/citations/slides.pdf +0 -0
  19. package/package.json +5 -1
  20. package/skills/write-slides/SKILL.md +7 -3
  21. package/src/build/bibliography.js +307 -0
  22. package/src/build/check.js +12 -0
  23. package/src/build/renderCheck.js +98 -0
  24. package/src/build/resultsPlugin.js +77 -0
  25. package/src/build/slidesPlugin.js +70 -7
  26. package/src/build/themeCheck.js +8 -5
  27. package/src/cli/args.js +22 -5
  28. package/src/cli/index.js +54 -12
  29. package/src/components/Bibliography.jsx +23 -0
  30. package/src/components/Numeric.jsx +59 -0
  31. package/src/components/Poll.jsx +59 -33
  32. package/src/components/QrCodeTag.jsx +11 -2
  33. package/src/components/Question.jsx +10 -6
  34. package/src/components/Scale.jsx +13 -8
  35. package/src/components/VideoPlayer.jsx +16 -0
  36. package/src/components/WordCloud.jsx +10 -6
  37. package/src/components/activity.jsx +90 -9
  38. package/src/components/citation.css +28 -0
  39. package/src/components/inlineText.js +40 -0
  40. package/src/components/numbers.js +39 -0
  41. package/src/components/poll.css +27 -7
  42. package/src/core/citations.js +108 -0
  43. package/src/core/editDeck.js +43 -0
  44. package/src/core/icons.js +2 -0
  45. package/src/core/ink.js +16 -0
  46. package/src/core/labels.js +48 -0
  47. package/src/core/parseSlides.js +1 -1
  48. package/src/core/results.js +64 -0
  49. package/src/core/urls.js +2 -2
  50. package/src/core/validateDeck.js +7 -1
  51. package/src/editor/sampleDeck.js +34 -8
  52. package/src/layouts/layoutApi.jsx +14 -3
  53. package/src/live/answer/answer.css +20 -0
  54. package/src/live/answer/answer.js +149 -24
  55. package/src/live/client.js +75 -7
  56. package/src/live/roomTag.js +6 -2
  57. package/src/live/rooms.js +6 -1
  58. package/src/runtime/ReaderView.jsx +2 -1
  59. package/src/runtime/citations.js +70 -0
  60. package/src/runtime/deck-stage.js +5 -2
  61. package/src/runtime/editorBridge.js +7 -1
  62. package/src/runtime/follow.js +89 -0
  63. package/src/runtime/ink/bus.js +7 -6
  64. package/src/runtime/ink/room.js +8 -0
  65. package/src/runtime/ink/select.js +4 -2
  66. package/src/runtime/main.jsx +22 -8
  67. package/src/runtime/registry.jsx +4 -0
@@ -16,7 +16,7 @@ Press **D** in standalone slides or in the presenter view, or use the pen button
16
16
 
17
17
  **Zoom**: two fingers zoom into the slide and move around it, one finger moves a zoomed slide, and Ctrl + scroll (a trackpad pinch) zooms on a laptop. Only the slide grows; the bars and the toolbar keep their size. The audience window shows the same part of the slide. **1:1** at the top left shows the whole slide again, as does the next slide.
18
18
 
19
- On a touch screen such as an iPad, drawing is on as soon as the presenter view opens, and in the deck as soon as the pencil touches it, with the toolbar folded into its button in the bottom right corner (↘ folds it again). Until then, a tap on the right third of the screen goes forward, on the left third back, and a swipe does the same. Drawing starts with the laser pointer; choose the pen or the highlighter to write. The pen and the highlighter each have colours and three sizes, and each keeps its own; one pen colour is **Accent**, the accent colour of the theme and palette: a drawing in it changes with them. The other pen colours follow light and dark: on dark slides red, blue and green are lighter, and the text colour is white instead of black. The toolbar also has undo and redo (also Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z), **Clear this slide**, and **×** to stop drawing; **D** and **Escape** do the same. While you draw, the pencil and the mouse draw; fingers keep working: a swipe to the left goes forward, to the right back, a tap on a stroke selects it, and buttons and polls on the slide still respond. Turn slides with the arrow keys or the presenter view's arrows, as always. Holding the pen still for half a second at the end of a stroke makes it a straight line.
19
+ On a touch screen such as an iPad, drawing is on as soon as the presenter view opens, and in the deck as soon as the pencil touches it, with the toolbar folded into its button in the bottom right corner (↘ folds it again). Until then, a tap on the right third of the screen goes forward, on the left third back, and a swipe does the same. Drawing starts with the laser pointer; choose the pen or the highlighter to write. The pen and the highlighter each have colours and three sizes, and each keeps its own; one pen colour is **Accent**, the accent colour of the theme and palette: a drawing in it changes with them. The other pen colours follow light and dark: on dark slides red, blue and green are lighter, and the text colour is white instead of black. The toolbar also has undo and redo (also Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z), **Clear this slide**, and **×** to stop drawing; **D** and **Escape** do the same. While you draw, the pencil and the mouse draw; fingers keep working: a swipe to the left goes forward, to the right back, a tap on a stroke selects it, and buttons and polls on the slide still respond. Turn slides with the arrow keys or the presenter view's arrows, as always. Holding the pen still for half a second at the end of a stroke makes it a straight line; near a multiple of 15° (horizontal, vertical, 45° …) it snaps to that angle.
20
20
 
21
21
  **I** hides and shows what was drawn before, for example when a slide should look clean again. The toolbar has the same switch.
22
22
 
@@ -81,6 +81,28 @@ This finding comes from our neighborhood survey.[^survey]
81
81
 
82
82
  The name inside the brackets connects the sentence to its source. Give each source a different name.
83
83
 
84
+ If you keep your sources in Zotero or another reference manager, export them as a `.bib` file next to the deck and cite them by their keys. The slide shows a short citation and lists the full reference at its bottom; a slide with `<bibliography />` lists them all:
85
+
86
+ ```markdown
87
+ ---
88
+ bibliography: refs.bib
89
+ ---
90
+ # Results
91
+
92
+ Gardens cool the street [@smith2020, p. 12].
93
+
94
+ ---
95
+ # Sources
96
+
97
+ <bibliography />
98
+ ```
99
+
100
+ - `[@smith2020, p. 12]` adds a page, `[see @smith2020; @doe2019]` cites two works at once, and `@smith2020` in a sentence reads "Smith et al. (2020)".
101
+ - `csl: vancouver` numbers the works instead; `harvard1` is built in too, and any style from the [Zotero style repository](https://www.zotero.org/styles) works as a `.csl` file next to the deck. Words such as "p." follow the deck's `lang`.
102
+ - Besides `.bib`, a CSL-JSON file (`.json`) or a Typst Hayagriva file (`.yml`) works, and `bibliography:` takes a list of several.
103
+ - A long list goes over two slides with `<bibliography part="1/2" />` and `<bibliography part="2/2" />`.
104
+ - A key that is not in the file shows as `@key?`, and `mdeck check` names it.
105
+
84
106
  ## Add a QR code
85
107
 
86
108
  A QR code lets people open a link on their phones. Replace the address below with your own:
@@ -20,6 +20,7 @@ Send `my-talk.html` by email. It opens in a **reader view**: an outline of all s
20
20
 
21
21
  - Drawings from `my-talk.drawings.json` are part of every build and PDF; `--no-drawings` leaves them out. See [Draw on your slides](drawing.html).
22
22
  - Your speaker notes are removed. Add `--notes` to keep them in the file, for example when you also present from the same file. Readers only see notes if you put `reader:` with `notes: true` in the settings at the top of your slide file.
23
+ - Slides with a poll, question, word cloud or scale stay as a record of the talk: the question and its options, marked as answered live. Add `--no-polls` to leave them out of the file and its PDF, together with slides showing the join code (`<qrcode join />`).
23
24
  - The PDF is made while sending, using Chrome or Chromium on your computer. If neither is found, the button offers the browser's own "Save as PDF" dialog instead. Set `MDECK_CHROME` to the browser's path if it is installed somewhere unusual. `--no-pdf` skips it.
24
25
  - `-o` chooses the name: `mdeck send my-talk.md -o handout.html`.
25
26
  - The file keeps your theme and colours; readers can only switch between light and dark. To keep that fixed too, put `reader:` with `themes: false` in the settings at the top of your slide file.
@@ -61,7 +61,7 @@ For new slides, a settings block should begin with `:::meta` and end with `:::`.
61
61
 
62
62
  ## Text is missing or does not fit
63
63
 
64
- A slide has a fixed amount of space. Split a long explanation over two slides, shorten a paragraph, or try a simpler layout. The current checker does not automatically detect visual overflow; tell your assistant which slides overflow.
64
+ A slide has a fixed amount of space. Split a long explanation over two slides, shorten a paragraph, or try a simpler layout. `mdeck check my-talk.md --render` lists the slides whose content does not fit, with the line each starts on.
65
65
 
66
66
  If you see an “unknown region” error, check that the layout supports the named area you used. For example, `left` and `right` work with `split`, but not with `title`.
67
67
 
@@ -11,12 +11,14 @@ Every build and every preview contains all views. The address picks one with `?v
11
11
  | `?view=deck` | Standalone slides with drawing controls | Slides opened from the reader view or a built file |
12
12
  | `?view=presenter` | Current and next slide, notes and timer, and a button that opens the audience window | You, while presenting |
13
13
  | `?view=audience` | Slides without notes, with navigation synced to the presenter view | The projector |
14
- | `?view=reader` | The reader view: an outline, **Slides** and **Read** modes, a **Look** menu and a PDF | People you send the talk to |
14
+ | `?view=reader` | The reader view: an outline, **Slides** and **Read** modes, light or dark, and a PDF | People you send the talk to |
15
+ | `?view=follow` | The slide you are showing and your drawing, live; people can page back and return with **Back to live**, but never see a slide before you show it | People in the room, on their phone or laptop |
15
16
 
16
17
  Ways to switch between them:
17
18
 
18
19
  - The **Overview** link in the corner of the deck opens the reader view. Its outline is the overview of all slides.
19
20
  - **Present** in the reader view opens standalone slides at the current slide.
21
+ - People in the room open the follow view with `<qrcode follow />` on a slide, with the **Follow the slides** link on the phones' answer page, or with the **Follow** address `mdeck run --network` prints. It works where their devices reach both the slides and your stage room: on the same network with `mdeck run --network`, or anywhere for a deck hosted with a `server` setting.
20
22
  - The presenter view's audience-window button opens the audience view in a new window. Navigation in either window updates the other, in the same browser or on a paired iPad: see [Draw on your slides](drawing.html#present-from-an-ipad).
21
23
  - The launch page of `mdeck run`, at the preview's plain address, offers Presenter, Audience and Reader. Its presenter and audience buttons belong together.
22
24
  - Fullscreen is a control inside a view (**F** or its fullscreen button). For a single screen, use the presenter view's slide-only layout.
@@ -0,0 +1,30 @@
1
+ @article{sweller1988,
2
+ author = {Sweller, John},
3
+ title = {Cognitive Load During Problem Solving: Effects on Learning},
4
+ journal = {Cognitive Science},
5
+ year = {1988},
6
+ volume = {12},
7
+ number = {2},
8
+ pages = {257--285},
9
+ doi = {10.1207/s15516709cog1202_4}
10
+ }
11
+
12
+ @article{roediger2006,
13
+ author = {Roediger, Henry L. and Karpicke, Jeffrey D.},
14
+ title = {Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention},
15
+ journal = {Psychological Science},
16
+ year = {2006},
17
+ volume = {17},
18
+ number = {3},
19
+ pages = {249--255},
20
+ doi = {10.1111/j.1467-9280.2006.01693.x}
21
+ }
22
+
23
+ @book{mayer2009,
24
+ author = {Mayer, Richard E.},
25
+ title = {Multimedia Learning},
26
+ edition = {2},
27
+ publisher = {Cambridge University Press},
28
+ address = {Cambridge},
29
+ year = {2009}
30
+ }
@@ -0,0 +1,21 @@
1
+ # The same deck can draw on a Typst (Hayagriva) file as well.
2
+ freeman2014:
3
+ type: Article
4
+ title: Active learning increases student performance in science, engineering, and mathematics
5
+ author:
6
+ - Freeman, Scott
7
+ - Eddy, Sarah L.
8
+ - McDonough, Miles
9
+ - Smith, Michelle K.
10
+ - Okoroafor, Nnadozie
11
+ - Jordt, Hannah
12
+ - Wenderoth, Mary Pat
13
+ date: 2014
14
+ page-range: 8410-8415
15
+ serial-number:
16
+ doi: 10.1073/pnas.1319030111
17
+ parent:
18
+ type: Periodical
19
+ title: Proceedings of the National Academy of Sciences
20
+ volume: 111
21
+ issue: 23
@@ -0,0 +1,43 @@
1
+ ---
2
+ theme: academic
3
+ meta:
4
+ title: "What helps people learn"
5
+ author: "Tilman Schieber"
6
+ organization: "FHNW"
7
+ bibliography: [refs.bib, refs.yml]
8
+ csl: apa
9
+ ---
10
+
11
+ ---
12
+ :::meta
13
+ layout: title
14
+ :::
15
+ # What helps people learn
16
+ ## Citations from a reference file
17
+
18
+ :::notes
19
+ The references come from `refs.bib` (BibTeX, as Zotero exports it) and `refs.yml` (Hayagriva, Typst's format). Try `csl: vancouver` or `lang: de` in the deck settings.
20
+ :::
21
+
22
+ ---
23
+ # Less on the slide
24
+
25
+ Working memory holds only a few things at once, so a slide that makes people search wastes it [@sweller1988].
26
+
27
+ Words spoken alongside a picture beat the same words printed on it [@mayer2009, chap. 11].
28
+
29
+ ---
30
+ # Ask, don't tell
31
+
32
+ As @roediger2006 showed, recalling something fixes it better than reading it again.
33
+
34
+ Lectures with active learning cut failure rates by about a third [@freeman2014; see also @roediger2006].
35
+
36
+ :::notes
37
+ Freeman et al. pooled 225 studies [-@freeman2014].
38
+ :::
39
+
40
+ ---
41
+ # References
42
+
43
+ <bibliography />
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdeck",
3
- "version": "3.2.0",
3
+ "version": "3.4.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",
@@ -57,7 +57,11 @@
57
57
  "theme-images": "node tools/theme-images.mjs"
58
58
  },
59
59
  "dependencies": {
60
+ "@citation-js/core": "^0.9.0",
61
+ "@citation-js/plugin-bibtex": "^0.9.0",
62
+ "@citation-js/plugin-csl": "^0.9.0",
60
63
  "@preact/preset-vite": "^2.10.6",
64
+ "citeproc": "^2.4.63",
61
65
  "d3-cloud": "^1.2.9",
62
66
  "js-yaml": "^4.3.2",
63
67
  "katex": "^0.18.9",
@@ -41,6 +41,8 @@ meta:
41
41
  author: "Name"
42
42
  organization: "FHNW"
43
43
  date: "2026-09-16"
44
+ bibliography: refs.bib # optional: .bib, CSL-JSON or Hayagriva; cite [@key, p. 3] or @key, list with <bibliography />
45
+ csl: apa # optional: apa, vancouver, harvard1 or a .csl file
44
46
  ---
45
47
 
46
48
  ---
@@ -92,7 +94,7 @@ Right column
92
94
  - Named areas use `:::slot name … :::`; the layout's regions and settings come from `mdeck list layouts --json`.
93
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.
94
96
  - Code fences get syntax highlighting; `$…$` and `$$…$$` render math.
95
- - `<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`. `<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. `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.
96
98
 
97
99
  ## 3. Write the deck
98
100
 
@@ -128,10 +130,12 @@ The request is the message that invoked this skill: usually source material to t
128
130
  Save the deck as a `.md` file named after the topic (for example `enzyme-kinetics.md`), then run:
129
131
 
130
132
  ```sh
131
- mdeck check <deck>.md
133
+ mdeck check <deck>.md --render
132
134
  ```
133
135
 
134
- Fix anything it reports. When you worked from source material, list what you left out or moved to notes, and anything you added that is not in the material. Finish by telling the user the two commands they will want next: `mdeck run <deck>.md` for the launch page (preview, presenting, editor, builds and checks), and `mdeck send <deck>.md` for a file to send around (a reader view with a PDF; notes are removed unless `--notes` is added).
136
+ Fix anything it reports. `--render` builds the deck and shows every slide in Chrome with all steps revealed; it names each slide whose content does not fit, whose text is cut off at the edge or hard to read, with the line the slide starts on. Shorten or split those slides, or pick another layout, and check again. If Chrome is missing, run the check without `--render` and tell the user.
137
+
138
+ To see slides yourself, if you can read pictures, run `mdeck snapshot <deck>.md --slide 3,5-7`: it saves PNG files to `.mdeck-snapshots/` beside the deck and prints their paths (`--dark` shows the dark appearance). Look at slides you were unsure about, such as diagrams, pictures and columns. When you worked from source material, list what you left out or moved to notes, and anything you added that is not in the material. Finish by telling the user the two commands they will want next: `mdeck run <deck>.md` for the launch page (preview, presenting, editor, builds and checks), and `mdeck send <deck>.md` for a file to send around (a reader view with a PDF; notes are removed unless `--notes` is added).
135
139
 
136
140
  ## 5. A new look: themes and palettes
137
141
 
@@ -0,0 +1,307 @@
1
+ import { readFileSync, statSync, existsSync } from 'fs'
2
+ import { resolve, dirname, extname, basename } from 'path'
3
+ import yaml from 'js-yaml'
4
+ import { parseSlides } from '../core/parseSlides.js'
5
+ import { scanCitations, citedKeys } from '../core/citations.js'
6
+ import { sampleDeck, SAMPLE_REFERENCES, SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
7
+
8
+ // The deck's references, formatted here in Node with citeproc so that none of
9
+ // it travels in the deck: the page receives finished HTML for every citation
10
+ // as written, the full entry of every cited work, and the reference list.
11
+ //
12
+ // Reference files: BibTeX/BibLaTeX (.bib), CSL-JSON (.json), and YAML (.yml,
13
+ // .yaml) holding either Hayagriva (Typst's format, a map of entries) or
14
+ // CSL-YAML (a list of entries with ids).
15
+
16
+ export const bibliographyFiles = (deckPath, deckConfig = {}) =>
17
+ [deckConfig.bibliography ?? []].flat().filter(file => typeof file === 'string' && file).map(file => resolve(dirname(resolve(deckPath)), file))
18
+
19
+ export const styleFile = (deckPath, deckConfig = {}) =>
20
+ typeof deckConfig.csl === 'string' && /\.csl$/i.test(deckConfig.csl) ? resolve(dirname(resolve(deckPath)), deckConfig.csl) : null
21
+
22
+ // ─── Reading reference files ──────────────────────────────────────────────
23
+
24
+ const parsed = new Map()
25
+
26
+ async function readItems(file) {
27
+ const { mtimeMs } = statSync(file)
28
+ const cached = parsed.get(file)
29
+ if (cached?.mtimeMs === mtimeMs) return cached.items
30
+ const items = await parseReferences(readFileSync(file, 'utf-8'), extname(file))
31
+ parsed.set(file, { mtimeMs, items })
32
+ return items
33
+ }
34
+
35
+ // Reference file text as CSL-JSON items; `ext` names the format.
36
+ export async function parseReferences(text, ext) {
37
+ ext = ext.toLowerCase()
38
+ let items
39
+ if (ext === '.json') items = JSON.parse(text)
40
+ else if (ext === '.yml' || ext === '.yaml') {
41
+ // The core schema keeps dates as written (2003-06-21), not as Date objects.
42
+ const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA })
43
+ items = Array.isArray(doc) ? doc : hayagrivaToCsl(doc ?? {})
44
+ } else if (ext === '.bib' || ext === '.bibtex') {
45
+ const { Cite } = await import('@citation-js/core')
46
+ await import('@citation-js/plugin-bibtex')
47
+ items = new Cite(text, { forceType: '@biblatex/text' }).data
48
+ } else throw new Error(`unknown reference format "${ext}" (use .bib, .json or .yml)`)
49
+ if (!Array.isArray(items)) throw new Error('expected a list of references')
50
+ return items.filter(item => item && item.id != null).map(item => ({ ...item, id: String(item.id) }))
51
+ }
52
+
53
+ // `sample`: the sample deck's references stand in for a missing file of
54
+ // theirs (the dev and edit servers show the sample deck without a folder).
55
+ export async function loadBibliography(files, { sample = false } = {}) {
56
+ const items = []
57
+ const seen = new Set()
58
+ const diagnostics = []
59
+ for (const file of files) {
60
+ let found
61
+ try {
62
+ found = sample && basename(file) === SAMPLE_REFERENCES_FILE && !existsSync(file) ? await parseReferences(SAMPLE_REFERENCES, '.bib') : await readItems(file)
63
+ } catch (error) {
64
+ diagnostics.push({ severity: 'warning', code: 'bibliography-file', message: error.code === 'ENOENT' ? `Bibliography ${file} not found` : `Bibliography ${file}: ${error.message}` })
65
+ continue
66
+ }
67
+ for (const item of found) {
68
+ if (seen.has(item.id)) { diagnostics.push({ severity: 'warning', code: 'bibliography-duplicate', message: `Reference "${item.id}" appears twice; using the first (${file})` }); continue }
69
+ seen.add(item.id)
70
+ items.push(item)
71
+ }
72
+ }
73
+ return { items, diagnostics }
74
+ }
75
+
76
+ // ─── Hayagriva → CSL-JSON ─────────────────────────────────────────────────
77
+
78
+ const text = value => value == null ? undefined : typeof value === 'object' && !Array.isArray(value) ? text(value.value) : String(value)
79
+
80
+ function person(value) {
81
+ if (value && typeof value === 'object') {
82
+ return { family: value.name, given: value['given-name'], 'non-dropping-particle': value.prefix, suffix: value.suffix }
83
+ }
84
+ const parts = String(value).split(',').map(part => part.trim())
85
+ if (parts.length === 1) return { literal: parts[0] }
86
+ const [family, given, suffix] = parts
87
+ const particle = family.match(/^((?:[a-z]+\s+)+)(.+)$/)
88
+ return particle ? { 'non-dropping-particle': particle[1].trim(), family: particle[2], given, suffix } : { family, given, suffix }
89
+ }
90
+ const people = value => value == null ? undefined : [value].flat().map(person)
91
+
92
+ function date(value) {
93
+ if (value == null) return undefined
94
+ const parts = String(value).match(/^(-?\d{1,4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?/)
95
+ return parts ? { 'date-parts': [parts.slice(1).filter(Boolean).map(Number)] } : { literal: String(value) }
96
+ }
97
+
98
+ const kind = value => String(value ?? 'misc').toLowerCase()
99
+
100
+ function cslType(type, parentType) {
101
+ if (type === 'article') return { periodical: 'article-journal', newspaper: 'article-newspaper', proceedings: 'paper-conference', conference: 'paper-conference', blog: 'post-weblog', web: 'webpage', book: 'chapter', anthology: 'chapter' }[parentType] ?? 'article-journal'
102
+ return {
103
+ book: 'book', anthology: 'book', proceedings: 'book', chapter: 'chapter', anthos: 'chapter', thesis: 'thesis', report: 'report',
104
+ web: 'webpage', blog: 'post-weblog', thread: 'post', video: 'motion_picture', audio: 'song', repository: 'software',
105
+ patent: 'patent', case: 'legal_case', legislation: 'legislation', manuscript: 'manuscript', newspaper: 'article-newspaper',
106
+ periodical: 'periodical', reference: 'entry-encyclopedia', entry: 'entry', artwork: 'graphic', performance: 'performance',
107
+ exhibition: 'event', conference: 'event', original: 'document', misc: 'document',
108
+ }[type] ?? 'document'
109
+ }
110
+
111
+ function publisher(value) {
112
+ if (value == null) return {}
113
+ if (typeof value === 'object') return { publisher: text(value.name), 'publisher-place': text(value.location) }
114
+ return { publisher: String(value) }
115
+ }
116
+
117
+ const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value != null && value !== '' && !(Array.isArray(value) && !value.length)))
118
+
119
+ export function hayagrivaToCsl(doc) {
120
+ return Object.entries(doc).map(([id, entry]) => {
121
+ entry ??= {}
122
+ const parent = [entry.parent ?? []].flat()[0] ?? {}
123
+ const serial = { ...(typeof parent['serial-number'] === 'object' ? parent['serial-number'] : {}), ...(typeof entry['serial-number'] === 'object' ? entry['serial-number'] : {}) }
124
+ const url = entry.url ?? parent.url
125
+ return clean({
126
+ id,
127
+ type: cslType(kind(entry.type), parent.type ? kind(parent.type) : undefined),
128
+ title: text(entry.title),
129
+ 'title-short': typeof entry.title === 'object' ? text(entry.title.short) : undefined,
130
+ author: people(entry.author),
131
+ editor: people(entry.editor ?? parent.editor),
132
+ translator: people(entry.translator),
133
+ issued: date(entry.date ?? parent.date),
134
+ 'container-title': text(parent.title),
135
+ 'collection-title': text([parent.parent ?? []].flat()[0]?.title),
136
+ volume: text(entry.volume ?? parent.volume),
137
+ issue: text(entry.issue ?? parent.issue),
138
+ page: text(entry['page-range'] ?? parent['page-range']),
139
+ edition: text(entry.edition ?? parent.edition),
140
+ ...publisher(entry.publisher ?? parent.publisher),
141
+ 'publisher-place': text(entry.location ?? parent.location) ?? publisher(entry.publisher ?? parent.publisher)['publisher-place'],
142
+ genre: text(entry.genre),
143
+ 'event-title': kind(parent.type) === 'conference' ? text(parent.title) : undefined,
144
+ DOI: text(serial.doi),
145
+ ISBN: text(serial.isbn),
146
+ ISSN: text(serial.issn),
147
+ PMID: text(serial.pmid),
148
+ number: typeof entry['serial-number'] === 'string' ? entry['serial-number'] : undefined,
149
+ URL: text(url),
150
+ accessed: typeof url === 'object' ? date(url.date) : undefined,
151
+ language: text(entry.language),
152
+ note: text(entry.note),
153
+ abstract: text(entry.abstract),
154
+ ...(entry.organization && !entry.publisher ? { publisher: text(entry.organization) } : {}),
155
+ })
156
+ })
157
+ }
158
+
159
+ // ─── Formatting ───────────────────────────────────────────────────────────
160
+
161
+ // The texts a deck shows, in the order the audience sees them: each slide's
162
+ // regions, then its notes. The runtime renders citations in the same texts.
163
+ export function deckTexts(source) {
164
+ const { slides } = parseSlides(source)
165
+ const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
166
+ return slides.flatMap(slide => [
167
+ ...Object.values(slide.regions ?? { body: { content: slide.content } }).sort((a, b) => position(a) - position(b)).map(region => region.content ?? ''),
168
+ slide.meta?.notes ?? '',
169
+ ])
170
+ }
171
+
172
+ function pickLocale(lang, available) {
173
+ if (!lang) return 'en-US'
174
+ const wanted = String(lang).replace('_', '-')
175
+ const exact = available.find(id => id.toLowerCase() === wanted.toLowerCase())
176
+ if (exact) return exact
177
+ const language = wanted.split('-')[0].toLowerCase()
178
+ return available.find(id => id.toLowerCase().startsWith(language + '-')) ?? null
179
+ }
180
+
181
+ // A name for a narrative citation when the style has no author-only form
182
+ // (numeric styles): Smith, Smith and Doe, Smith et al.
183
+ function namesOf(item) {
184
+ const names = (item.author ?? item.editor ?? []).map(name => name.family ?? name.literal).filter(Boolean)
185
+ if (!names.length) return item['title-short'] ?? item.title ?? item.id
186
+ if (names.length === 1) return names[0]
187
+ if (names.length === 2) return `${names[0]} &amp; ${names[1]}`
188
+ return `${names[0]} et al.`
189
+ }
190
+
191
+ const toCiteItem = item => clean({ id: item.id, locator: item.locator, label: item.locator ? item.label : undefined, prefix: item.prefix, suffix: item.suffix, 'suppress-author': item.suppressAuthor || undefined })
192
+
193
+ // `{ ids, clusters, singles, entries, order, diagnostics }`: `clusters` maps
194
+ // each citation as written to its HTML; `singles` and `entries` map keys to a
195
+ // one-work citation and to the full reference; `order` is the reference list.
196
+ export async function formatBibliography({ items, texts, csl, lang, nocite = [], deckDir = '.' }) {
197
+ const diagnostics = []
198
+ const byId = new Map(items.map(item => [item.id, item]))
199
+ const result = { ids: [...byId.keys()], clusters: {}, singles: {}, entries: {}, order: [], diagnostics }
200
+
201
+ const clusters = texts.flatMap(text => scanCitations(text, { isKey: id => byId.has(id) }))
202
+ const unknown = new Set(clusters.flatMap(cluster => cluster.items.map(item => item.id)).filter(id => !byId.has(id)))
203
+ for (const id of unknown) diagnostics.push({ severity: 'warning', code: 'citation-unknown', message: `Citation @${id} is not in the bibliography` })
204
+
205
+ const wanted = [nocite].flat().map(id => String(id).replace(/^@/, ''))
206
+ const uncited = wanted.includes('*') ? [...byId.keys()] : wanted.filter(id => {
207
+ if (byId.has(id)) return true
208
+ diagnostics.push({ severity: 'warning', code: 'citation-unknown', message: `nocite: ${id} is not in the bibliography` })
209
+ return false
210
+ })
211
+ if (!clusters.length && !uncited.length) return result
212
+
213
+ const { plugins } = await import('@citation-js/core')
214
+ await import('@citation-js/plugin-csl')
215
+ const { default: CSL } = await import('citeproc')
216
+ const config = plugins.config.get('@csl')
217
+
218
+ let style = config.styles.get('apa')
219
+ const styleName = csl ?? 'apa'
220
+ if (/\.csl$/i.test(styleName)) {
221
+ try { style = readFileSync(resolve(deckDir, styleName), 'utf-8') } catch (error) {
222
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName}: ${error.code === 'ENOENT' ? 'file not found' : error.message}; using apa` })
223
+ }
224
+ } else if (config.styles.has(styleName)) style = config.styles.get(styleName)
225
+ else diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Unknown citation style "${styleName}" (built in: ${config.styles.list().join(', ')}, or a .csl file); using apa` })
226
+
227
+ let locale = pickLocale(lang, config.locales.list())
228
+ if (!locale) {
229
+ diagnostics.push({ severity: 'warning', code: 'citation-locale', message: `No citation locale for "${lang}" (available: ${config.locales.list().join(', ')}); using en-US` })
230
+ locale = 'en-US'
231
+ }
232
+
233
+ let engine
234
+ try {
235
+ engine = new CSL.Engine({
236
+ retrieveItem: id => byId.get(id),
237
+ retrieveLocale: id => config.locales.get(id) ?? config.locales.get('en-US'),
238
+ }, style, locale, true)
239
+ } catch (error) {
240
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName}: ${error.message}` })
241
+ return result
242
+ }
243
+ engine.setOutputFormat('html')
244
+ engine.opt.development_extensions.wrap_url_and_doi = true
245
+
246
+ // In order, so numbering and disambiguation (2020a, 2020b) come out right.
247
+ const html = new Map()
248
+ const done = []
249
+ clusters.forEach((cluster, index) => {
250
+ // A mixed citation formats its known works; the runtime marks the others.
251
+ const known = cluster.items.filter(item => byId.has(item.id))
252
+ if (!known.length) return
253
+ const citationItems = known.map(item => toCiteItem(cluster.narrative ? { ...item, suppressAuthor: true } : item))
254
+ const citation = { citationID: `c${index}`, citationItems, properties: { noteIndex: 0 } }
255
+ const [, updates] = engine.processCitationCluster(citation, done, [])
256
+ done.push([citation.citationID, 0])
257
+ for (const [, text, id] of updates) html.set(id, text)
258
+ })
259
+ clusters.forEach((cluster, index) => {
260
+ const text = html.get(`c${index}`)
261
+ if (text == null || Object.hasOwn(result.clusters, cluster.raw)) return
262
+ if (!cluster.narrative) { result.clusters[cluster.raw] = text; return }
263
+ const { id } = cluster.items[0]
264
+ let author = engine.makeCitationCluster([{ id, 'author-only': true }])
265
+ if (!author || /NO_PRINTED_FORM/.test(author)) author = namesOf(byId.get(id))
266
+ result.clusters[cluster.raw] = `${author} ${text}`
267
+ })
268
+
269
+ const cited = citedKeys(clusters).filter(id => byId.has(id))
270
+ for (const id of cited) result.singles[id] = engine.makeCitationCluster([{ id }])
271
+ if (uncited.length) engine.updateUncitedItems(uncited)
272
+ const bibliography = engine.makeBibliography()
273
+ if (!bibliography) {
274
+ diagnostics.push({ severity: 'warning', code: 'citation-style', message: `Citation style ${styleName} has no reference list` })
275
+ return result
276
+ }
277
+ const [meta, entries] = bibliography
278
+ meta.entry_ids.forEach(([id], i) => {
279
+ result.entries[id] = entries[i].trim()
280
+ result.order.push(id)
281
+ })
282
+ return result
283
+ }
284
+
285
+ // Everything the deck's `bibliography`, `csl`, `nocite` and `lang` ask for,
286
+ // or null when the deck has no bibliography.
287
+ export async function resolveBibliography(deckPath, source, { sample = false } = {}) {
288
+ let deckConfig
289
+ try { deckConfig = parseSlides(source).deckConfig ?? {} } catch { return null }
290
+ const files = bibliographyFiles(deckPath, deckConfig)
291
+ if (!files.length) return null
292
+ const loaded = await loadBibliography(files, { sample })
293
+ const formatted = await formatBibliography({
294
+ items: loaded.items, texts: deckTexts(source), csl: deckConfig.csl, lang: deckConfig.lang,
295
+ nocite: deckConfig.nocite ?? [], deckDir: dirname(resolve(deckPath)),
296
+ })
297
+ return { ...formatted, diagnostics: [...loaded.diagnostics, ...formatted.diagnostics], files, style: styleFile(deckPath, deckConfig) }
298
+ }
299
+
300
+ // The sample deck's citations, formatted once: the design page previews it
301
+ // in the editor of any deck, whose own references are another matter.
302
+ let sampleFormatted = null
303
+ export function sampleBibliography() {
304
+ sampleFormatted ??= parseReferences(SAMPLE_REFERENCES, '.bib')
305
+ .then(items => formatBibliography({ items, texts: deckTexts(sampleDeck()) }))
306
+ return sampleFormatted
307
+ }
@@ -10,6 +10,7 @@ import { paletteProblems } from '../extensions/contrast.js'
10
10
  import { collectLocalAssetRefs } from './slidesPlugin.js'
11
11
  import { componentFolders, isFolder } from './components.js'
12
12
  import { inkFileFor, validateInk, normalizeInk, orphanIds } from '../core/ink.js'
13
+ import { resolveBibliography } from './bibliography.js'
13
14
 
14
15
  export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath, 'utf8')) {
15
16
  const deck = parseSlides(source)
@@ -40,3 +41,14 @@ export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath
40
41
  }
41
42
  return { deck, diagnostics }
42
43
  }
44
+
45
+ // What the deck's references cannot use: missing or unreadable files, an
46
+ // unknown style or language, citations of keys the files do not have.
47
+ export async function bibliographyDiagnostics(slidesPath, source = readFileSync(slidesPath, 'utf8')) {
48
+ const bibliography = await resolveBibliography(slidesPath, source)
49
+ return (bibliography?.diagnostics ?? []).map(d => {
50
+ const at = d.code === 'citation-unknown' ? source.indexOf('@' + d.message.match(/@(\S+)/)?.[1]) : -1
51
+ const line = at < 0 ? 1 : source.slice(0, at).split('\n').length
52
+ return { ...d, line, column: 1 }
53
+ })
54
+ }