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.
- package/CHANGELOG.md +27 -0
- package/assets/base.css +23 -2
- package/assets/extensions/themes/academic/styles.css +33 -5
- package/assets/extensions/themes/aurora/styles.css +32 -6
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/docs/reference/authoring.md +89 -6
- package/docs/site/content/audience.md +41 -4
- package/docs/site/content/commands.md +30 -4
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/more-content.md +22 -0
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/troubleshooting.md +1 -1
- package/docs/site/content/views.md +3 -1
- package/examples/citations/refs.bib +30 -0
- package/examples/citations/refs.yml +21 -0
- package/examples/citations/slides.md +43 -0
- package/examples/citations/slides.pdf +0 -0
- package/package.json +5 -1
- package/skills/write-slides/SKILL.md +7 -3
- package/src/build/bibliography.js +307 -0
- package/src/build/check.js +12 -0
- package/src/build/renderCheck.js +98 -0
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +70 -7
- package/src/build/themeCheck.js +8 -5
- package/src/cli/args.js +22 -5
- package/src/cli/index.js +54 -12
- package/src/components/Bibliography.jsx +23 -0
- package/src/components/Numeric.jsx +59 -0
- package/src/components/Poll.jsx +59 -33
- package/src/components/QrCodeTag.jsx +11 -2
- package/src/components/Question.jsx +10 -6
- package/src/components/Scale.jsx +13 -8
- package/src/components/VideoPlayer.jsx +16 -0
- package/src/components/WordCloud.jsx +10 -6
- package/src/components/activity.jsx +90 -9
- package/src/components/citation.css +28 -0
- package/src/components/inlineText.js +40 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +27 -7
- package/src/core/citations.js +108 -0
- package/src/core/editDeck.js +43 -0
- package/src/core/icons.js +2 -0
- package/src/core/ink.js +16 -0
- package/src/core/labels.js +48 -0
- package/src/core/parseSlides.js +1 -1
- package/src/core/results.js +64 -0
- package/src/core/urls.js +2 -2
- package/src/core/validateDeck.js +7 -1
- package/src/editor/sampleDeck.js +34 -8
- package/src/layouts/layoutApi.jsx +14 -3
- package/src/live/answer/answer.css +20 -0
- package/src/live/answer/answer.js +149 -24
- package/src/live/client.js +75 -7
- package/src/live/roomTag.js +6 -2
- package/src/live/rooms.js +6 -1
- package/src/runtime/ReaderView.jsx +2 -1
- package/src/runtime/citations.js +70 -0
- package/src/runtime/deck-stage.js +5 -2
- package/src/runtime/editorBridge.js +7 -1
- package/src/runtime/follow.js +89 -0
- package/src/runtime/ink/bus.js +7 -6
- package/src/runtime/ink/room.js +8 -0
- package/src/runtime/ink/select.js +4 -2
- package/src/runtime/main.jsx +22 -8
- 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.
|
|
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,
|
|
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.
|
|
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.
|
|
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]} & ${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
|
+
}
|
package/src/build/check.js
CHANGED
|
@@ -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
|
+
}
|