From c2390e117dbc9f8e730eb7a39d8bb7370dc05bb2 Mon Sep 17 00:00:00 2001
From: Lachlan Kermode
Date: Sat, 19 Sep 2026 21:46:55 +0200
Subject: [PATCH 1/7] Base commit for all packages
---
.github/workflows/check.yml | 237 ++-
bibtex/0.1.0/.gitignore | 3 +
bibtex/0.1.0/Justfile | 55 +
bibtex/0.1.0/demo/rheo/Justfile | 10 +
bibtex/0.1.0/demo/rheo/content/entry.typ | 16 +
bibtex/0.1.0/demo/rheo/content/index.typ | 11 +
bibtex/0.1.0/demo/rheo/content/lib.typ | 30 +
bibtex/0.1.0/demo/rheo/references.bib | 38 +
bibtex/0.1.0/demo/rheo/rheo.toml | 20 +
bibtex/0.1.0/readme.md | 234 ++
bibtex/0.1.0/src/bibtex.css | 111 +
bibtex/0.1.0/src/claim.typ | 13 +
bibtex/0.1.0/src/format.typ | 65 +
bibtex/0.1.0/src/keywords.typ | 31 +
bibtex/0.1.0/src/lib.typ | 196 ++
bibtex/0.1.0/src/parse.typ | 111 +
bibtex/0.1.0/src/view.typ | 84 +
bibtex/0.1.0/test/check.sh | 178 ++
bibtex/0.1.0/test/fields.typ | 11 +
bibtex/0.1.0/test/large.typ | 32 +
bibtex/0.1.0/test/sweep-all.typ | 30 +
bibtex/0.1.0/test/sweep-existing.typ | 36 +
bibtex/0.1.0/test/sweep.typ | 38 +
bibtex/0.1.0/test/units.typ | 149 ++
bibtex/0.1.0/typst.toml | 24 +
cfps/0.1.0/.gitignore | 2 +
cfps/0.1.0/Justfile | 19 +
cfps/0.1.0/readme.md | 252 +++
cfps/0.1.0/src/cfp.typ | 368 ++++
cfps/0.1.0/src/cfps.css | 357 ++++
cfps/0.1.0/src/lib.typ | 14 +
cfps/0.1.0/src/panel.typ | 293 +++
cfps/0.1.0/test/check.sh | 113 +
cfps/0.1.0/test/units.typ | 128 ++
cfps/0.1.0/test/view.typ | 60 +
cfps/0.1.0/typst.toml | 15 +
meetings/0.1.0/.gitignore | 3 +
meetings/0.1.0/Justfile | 26 +
meetings/0.1.0/readme.md | 258 +++
meetings/0.1.0/src/lib.typ | 255 +++
meetings/0.1.0/src/meetings.css | 94 +
meetings/0.1.0/test/check.sh | 63 +
meetings/0.1.0/test/units.typ | 53 +
meetings/0.1.0/test/view.typ | 26 +
meetings/0.1.0/typst.toml | 15 +
pinboard/0.1.0/.gitignore | 5 +
pinboard/0.1.0/Justfile | 20 +
pinboard/0.1.0/demo/rheo/check.sh | 51 +
pinboard/0.1.0/demo/rheo/content/index.typ | 31 +
pinboard/0.1.0/demo/rheo/rheo.toml | 14 +
pinboard/0.1.0/package.json | 14 +
pinboard/0.1.0/pnpm-lock.yaml | 590 ++++++
pinboard/0.1.0/readme.md | 94 +
pinboard/0.1.0/src/board.typ | 60 +
pinboard/0.1.0/src/collapse.js | 64 +
pinboard/0.1.0/src/drag.js | 251 +++
pinboard/0.1.0/src/layout.js | 48 +
pinboard/0.1.0/src/lib.typ | 11 +
pinboard/0.1.0/src/pinboard.css | 126 ++
pinboard/0.1.0/src/pinboard.js | 201 ++
pinboard/0.1.0/src/select.js | 134 ++
pinboard/0.1.0/src/store.js | 84 +
pinboard/0.1.0/test/browser/board.mjs | 404 ++++
pinboard/0.1.0/test/collapse.test.mjs | 88 +
pinboard/0.1.0/test/drag.test.mjs | 85 +
pinboard/0.1.0/test/layout.test.mjs | 83 +
pinboard/0.1.0/test/select.test.mjs | 40 +
pinboard/0.1.0/test/store.test.mjs | 69 +
pinboard/0.1.0/typst.toml | 41 +
pinboard/0.1.0/vite.config.js | 13 +
search/0.1.0/.gitignore | 5 +
search/0.1.0/.marrow.typ | 120 ++
search/0.1.0/Justfile | 28 +
search/0.1.0/demo/rheo/Justfile | 19 +
search/0.1.0/demo/rheo/check.sh | 455 ++++
search/0.1.0/demo/rheo/content/index.typ | 225 ++
search/0.1.0/demo/rheo/content/lib.typ | 43 +
search/0.1.0/demo/rheo/content/refs.bib | 13 +
search/0.1.0/demo/rheo/content/sub/page.typ | 32 +
search/0.1.0/demo/rheo/rheo.toml | 33 +
search/0.1.0/demo/rheo/style.css | 19 +
search/0.1.0/flake.lock | 61 +
search/0.1.0/flake.nix | 22 +
search/0.1.0/package.json | 14 +
search/0.1.0/pnpm-lock.yaml | 580 +++++
search/0.1.0/readme.md | 1877 +++++++++++++++++
search/0.1.0/src/bar.js | 118 ++
search/0.1.0/src/base.typ | 139 ++
search/0.1.0/src/compress.typ | 199 ++
search/0.1.0/src/corpus.typ | 357 ++++
search/0.1.0/src/filter-panel.typ | 383 ++++
search/0.1.0/src/island.js | 56 +
search/0.1.0/src/keywords.js | 70 +
search/0.1.0/src/lib.typ | 49 +
search/0.1.0/src/limit.js | 14 +
search/0.1.0/src/lookup.typ | 152 ++
search/0.1.0/src/marks.js | 75 +
search/0.1.0/src/modal.js | 245 +++
search/0.1.0/src/panel.js | 463 ++++
search/0.1.0/src/panel.typ | 608 ++++++
search/0.1.0/src/preview.js | 125 ++
search/0.1.0/src/rank.typ | 160 ++
search/0.1.0/src/row.js | 126 ++
search/0.1.0/src/score.js | 212 ++
search/0.1.0/src/score.typ | 119 ++
search/0.1.0/src/search.css | 1003 +++++++++
search/0.1.0/src/search.js | 291 +++
search/0.1.0/src/selection.js | 92 +
search/0.1.0/src/tagquery.js | 297 +++
search/0.1.0/src/tagquery.typ | 429 ++++
search/0.1.0/src/text.js | 51 +
search/0.1.0/src/ui.typ | 313 +++
search/0.1.0/src/urlstate.js | 140 ++
search/0.1.0/src/urlsync.js | 64 +
search/0.1.0/test/browser/bar.mjs | 139 ++
search/0.1.0/test/browser/modal.mjs | 160 ++
search/0.1.0/test/extractnote.test.mjs | 157 ++
search/0.1.0/test/filterpanel.test.mjs | 73 +
search/0.1.0/test/global.test.mjs | 76 +
search/0.1.0/test/internal.mjs | 22 +
search/0.1.0/test/island.test.mjs | 136 ++
search/0.1.0/test/matchranges.test.mjs | 78 +
search/0.1.0/test/paneldupes.test.mjs | 87 +
search/0.1.0/test/panelinput.test.mjs | 99 +
search/0.1.0/test/panelmulti.test.mjs | 128 ++
search/0.1.0/test/panelquery.test.mjs | 157 ++
search/0.1.0/test/panelsync.test.mjs | 178 ++
search/0.1.0/test/panelunion.test.mjs | 125 ++
search/0.1.0/test/parity.mjs | 465 ++++
search/0.1.0/test/parity.typ | 440 ++++
search/0.1.0/test/row.test.mjs | 47 +
search/0.1.0/test/search.test.mjs | 87 +
search/0.1.0/test/selection.test.mjs | 158 ++
search/0.1.0/test/urlstate.test.mjs | 144 ++
search/0.1.0/test/urlsync.test.mjs | 124 ++
search/0.1.0/typst.toml | 86 +
search/0.1.0/vite.config.js | 13 +
slipshow/0.1.0/.gitignore | 5 +
slipshow/0.1.0/Justfile | 41 +
slipshow/0.1.0/demo/rheo/Justfile | 18 +
slipshow/0.1.0/demo/rheo/check.sh | 146 ++
.../0.1.0/demo/rheo/content/crossdeck.typ | 18 +
slipshow/0.1.0/demo/rheo/content/deck.typ | 111 +
slipshow/0.1.0/demo/rheo/content/explicit.typ | 32 +
slipshow/0.1.0/demo/rheo/content/index.typ | 60 +
slipshow/0.1.0/demo/rheo/content/lib.typ | 11 +
.../0.1.0/demo/rheo/content/predicate.typ | 33 +
slipshow/0.1.0/demo/rheo/rheo.toml | 35 +
.../0.1.0/examples/_template/content/lib.typ | 10 +
slipshow/0.1.0/examples/_template/rheo.toml | 36 +
slipshow/0.1.0/examples/backgrounds/check.sh | 118 ++
.../backgrounds/content/fullscreen.typ | 78 +
.../examples/backgrounds/content/index.typ | 80 +
.../examples/backgrounds/content/lib.typ | 9 +
.../backgrounds/content/nested/deep.typ | 27 +
.../examples/backgrounds/content/pattern.png | Bin 0 -> 398 bytes
.../examples/backgrounds/content/table.typ | 30 +
.../examples/backgrounds/content/texture.jpg | Bin 0 -> 1712 bytes
slipshow/0.1.0/examples/backgrounds/rheo.toml | 18 +
slipshow/0.1.0/examples/dag/check.sh | 304 +++
.../0.1.0/examples/dag/content/across.typ | 13 +
.../0.1.0/examples/dag/content/corpus.typ | 161 ++
slipshow/0.1.0/examples/dag/content/index.typ | 42 +
slipshow/0.1.0/examples/dag/content/lib.typ | 21 +
.../0.1.0/examples/dag/content/open-only.typ | 42 +
slipshow/0.1.0/examples/dag/content/wide.typ | 40 +
slipshow/0.1.0/examples/dag/rheo.toml | 36 +
.../0.1.0/examples/minimal/content/index.typ | 12 +
.../0.1.0/examples/minimal/content/lib.typ | 9 +
slipshow/0.1.0/examples/minimal/rheo.toml | 12 +
slipshow/0.1.0/examples/mixed/check.sh | 100 +
.../0.1.0/examples/mixed/content/corpus.typ | 93 +
.../0.1.0/examples/mixed/content/index.typ | 19 +
.../0.1.0/examples/mixed/content/inline.typ | 30 +
slipshow/0.1.0/examples/mixed/content/lib.typ | 15 +
.../examples/mixed/content/predicate.typ | 16 +
.../0.1.0/examples/mixed/content/tagged.typ | 15 +
slipshow/0.1.0/examples/mixed/rheo.toml | 12 +
slipshow/0.1.0/examples/ordering/check.sh | 110 +
.../examples/ordering/content/corpus.typ | 126 ++
.../examples/ordering/content/functions.typ | 47 +
.../0.1.0/examples/ordering/content/index.typ | 41 +
.../0.1.0/examples/ordering/content/lib.typ | 9 +
.../examples/ordering/content/reverse.typ | 33 +
.../examples/ordering/content/tag-values.typ | 32 +
slipshow/0.1.0/examples/ordering/rheo.toml | 12 +
slipshow/0.1.0/examples/readme.md | 70 +
slipshow/0.1.0/examples/search-order/check.sh | 125 ++
.../examples/search-order/content/corpus.typ | 138 ++
.../examples/search-order/content/index.typ | 24 +
.../examples/search-order/content/lib.typ | 19 +
.../search-order/content/narrowed.typ | 28 +
.../examples/search-order/content/ranked.typ | 40 +
.../0.1.0/examples/search-order/rheo.toml | 12 +
slipshow/0.1.0/flake.nix | 22 +
slipshow/0.1.0/package.json | 12 +
slipshow/0.1.0/pnpm-lock.yaml | 430 ++++
slipshow/0.1.0/readme.md | 864 ++++++++
slipshow/0.1.0/src/camera.js | 107 +
slipshow/0.1.0/src/edges.js | 198 ++
slipshow/0.1.0/src/lib.typ | 17 +
slipshow/0.1.0/src/marker.typ | 53 +
slipshow/0.1.0/src/select.typ | 425 ++++
slipshow/0.1.0/src/slip.typ | 88 +
slipshow/0.1.0/src/slipshow.css | 336 +++
slipshow/0.1.0/src/slipshow.js | 525 +++++
slipshow/0.1.0/src/slipshow.typ | 542 +++++
slipshow/0.1.0/src/tags.typ | 159 ++
slipshow/0.1.0/test/browser/deck.mjs | 212 ++
slipshow/0.1.0/test/camera.test.mjs | 191 ++
slipshow/0.1.0/test/edges.test.mjs | 35 +
slipshow/0.1.0/test/panic-both.typ | 2 +
slipshow/0.1.0/test/panic-class-fn-type.typ | 5 +
slipshow/0.1.0/test/panic-edges-fn-type.typ | 5 +
slipshow/0.1.0/test/panic-enter.typ | 2 +
slipshow/0.1.0/test/panic-max-width-type.typ | 2 +
slipshow/0.1.0/test/panic-neither.typ | 2 +
slipshow/0.1.0/test/panic-order-mixed.typ | 6 +
slipshow/0.1.0/test/panic-order-type.typ | 5 +
slipshow/0.1.0/test/panic-order.typ | 2 +
slipshow/0.1.0/test/panic-reveal.typ | 2 +
slipshow/0.1.0/test/panic-row-fn-type.typ | 5 +
slipshow/0.1.0/test/panic-row-type.typ | 2 +
slipshow/0.1.0/test/panic-slips-unknown.typ | 2 +
slipshow/0.1.0/test/panic-slips-where.typ | 2 +
slipshow/0.1.0/test/panic-where-type.typ | 2 +
slipshow/0.1.0/test/panics.sh | 28 +
slipshow/0.1.0/test/reveal.test.mjs | 51 +
slipshow/0.1.0/test/units.typ | 400 ++++
slipshow/0.1.0/typst.toml | 47 +
slipshow/0.1.0/vite.config.js | 13 +
timeline/0.1.0/.gitignore | 3 +
timeline/0.1.0/Justfile | 27 +
timeline/0.1.0/readme.md | 922 ++++++++
timeline/0.1.0/src/fragment.typ | 300 +++
timeline/0.1.0/src/ladder.typ | 155 ++
timeline/0.1.0/src/lib.typ | 99 +
timeline/0.1.0/src/read.typ | 125 ++
timeline/0.1.0/src/timeline.css | 321 +++
timeline/0.1.0/src/upcoming.typ | 392 ++++
timeline/0.1.0/src/view.typ | 208 ++
timeline/0.1.0/src/when.typ | 192 ++
timeline/0.1.0/test/check.sh | 283 +++
timeline/0.1.0/test/units.typ | 482 +++++
timeline/0.1.0/test/upcoming.typ | 67 +
timeline/0.1.0/test/view.typ | 104 +
timeline/0.1.0/typst.toml | 44 +
todos/0.1.0/.gitignore | 3 +
todos/0.1.0/.marrow.typ | 62 +
todos/0.1.0/Justfile | 30 +
todos/0.1.0/demo/rheo/check.sh | 299 +++
todos/0.1.0/demo/rheo/content/index.typ | 291 +++
todos/0.1.0/demo/rheo/content/lib.typ | 17 +
todos/0.1.0/demo/rheo/rheo.toml | 15 +
todos/0.1.0/package.json | 14 +
todos/0.1.0/pnpm-lock.yaml | 590 ++++++
todos/0.1.0/readme.md | 1004 +++++++++
todos/0.1.0/src/deck.typ | 183 ++
todos/0.1.0/src/fields.typ | 44 +
todos/0.1.0/src/graph.typ | 436 ++++
todos/0.1.0/src/layout.js | 96 +
todos/0.1.0/src/lib.typ | 49 +
todos/0.1.0/src/search.typ | 232 ++
todos/0.1.0/src/skin.typ | 59 +
todos/0.1.0/src/table.typ | 701 ++++++
todos/0.1.0/src/tags.typ | 287 +++
todos/0.1.0/src/target.typ | 36 +
todos/0.1.0/src/todo-search.js | 362 ++++
todos/0.1.0/src/todo.typ | 282 +++
todos/0.1.0/src/todos.css | 692 ++++++
todos/0.1.0/src/todos.js | 233 ++
todos/0.1.0/src/views.typ | 503 +++++
todos/0.1.0/test/browser/graph.mjs | 112 +
todos/0.1.0/test/layout.test.mjs | 84 +
todos/0.1.0/test/panic-layer-cycle.typ | 4 +
todos/0.1.0/test/panics.sh | 14 +
todos/0.1.0/test/todo-search-sync.test.mjs | 158 ++
todos/0.1.0/test/todo-search.test.mjs | 219 ++
todos/0.1.0/test/units.typ | 511 +++++
todos/0.1.0/typst.toml | 62 +
todos/0.1.0/vite.config.js | 17 +
281 files changed, 38844 insertions(+), 29 deletions(-)
create mode 100644 bibtex/0.1.0/.gitignore
create mode 100644 bibtex/0.1.0/Justfile
create mode 100644 bibtex/0.1.0/demo/rheo/Justfile
create mode 100644 bibtex/0.1.0/demo/rheo/content/entry.typ
create mode 100644 bibtex/0.1.0/demo/rheo/content/index.typ
create mode 100644 bibtex/0.1.0/demo/rheo/content/lib.typ
create mode 100644 bibtex/0.1.0/demo/rheo/references.bib
create mode 100644 bibtex/0.1.0/demo/rheo/rheo.toml
create mode 100644 bibtex/0.1.0/readme.md
create mode 100644 bibtex/0.1.0/src/bibtex.css
create mode 100644 bibtex/0.1.0/src/claim.typ
create mode 100644 bibtex/0.1.0/src/format.typ
create mode 100644 bibtex/0.1.0/src/keywords.typ
create mode 100644 bibtex/0.1.0/src/lib.typ
create mode 100644 bibtex/0.1.0/src/parse.typ
create mode 100644 bibtex/0.1.0/src/view.typ
create mode 100755 bibtex/0.1.0/test/check.sh
create mode 100644 bibtex/0.1.0/test/fields.typ
create mode 100644 bibtex/0.1.0/test/large.typ
create mode 100644 bibtex/0.1.0/test/sweep-all.typ
create mode 100644 bibtex/0.1.0/test/sweep-existing.typ
create mode 100644 bibtex/0.1.0/test/sweep.typ
create mode 100644 bibtex/0.1.0/test/units.typ
create mode 100644 bibtex/0.1.0/typst.toml
create mode 100644 cfps/0.1.0/.gitignore
create mode 100644 cfps/0.1.0/Justfile
create mode 100644 cfps/0.1.0/readme.md
create mode 100644 cfps/0.1.0/src/cfp.typ
create mode 100644 cfps/0.1.0/src/cfps.css
create mode 100644 cfps/0.1.0/src/lib.typ
create mode 100644 cfps/0.1.0/src/panel.typ
create mode 100755 cfps/0.1.0/test/check.sh
create mode 100644 cfps/0.1.0/test/units.typ
create mode 100644 cfps/0.1.0/test/view.typ
create mode 100644 cfps/0.1.0/typst.toml
create mode 100644 meetings/0.1.0/.gitignore
create mode 100644 meetings/0.1.0/Justfile
create mode 100644 meetings/0.1.0/readme.md
create mode 100644 meetings/0.1.0/src/lib.typ
create mode 100644 meetings/0.1.0/src/meetings.css
create mode 100755 meetings/0.1.0/test/check.sh
create mode 100644 meetings/0.1.0/test/units.typ
create mode 100644 meetings/0.1.0/test/view.typ
create mode 100644 meetings/0.1.0/typst.toml
create mode 100644 pinboard/0.1.0/.gitignore
create mode 100644 pinboard/0.1.0/Justfile
create mode 100755 pinboard/0.1.0/demo/rheo/check.sh
create mode 100644 pinboard/0.1.0/demo/rheo/content/index.typ
create mode 100644 pinboard/0.1.0/demo/rheo/rheo.toml
create mode 100644 pinboard/0.1.0/package.json
create mode 100644 pinboard/0.1.0/pnpm-lock.yaml
create mode 100644 pinboard/0.1.0/readme.md
create mode 100644 pinboard/0.1.0/src/board.typ
create mode 100644 pinboard/0.1.0/src/collapse.js
create mode 100644 pinboard/0.1.0/src/drag.js
create mode 100644 pinboard/0.1.0/src/layout.js
create mode 100644 pinboard/0.1.0/src/lib.typ
create mode 100644 pinboard/0.1.0/src/pinboard.css
create mode 100644 pinboard/0.1.0/src/pinboard.js
create mode 100644 pinboard/0.1.0/src/select.js
create mode 100644 pinboard/0.1.0/src/store.js
create mode 100644 pinboard/0.1.0/test/browser/board.mjs
create mode 100644 pinboard/0.1.0/test/collapse.test.mjs
create mode 100644 pinboard/0.1.0/test/drag.test.mjs
create mode 100644 pinboard/0.1.0/test/layout.test.mjs
create mode 100644 pinboard/0.1.0/test/select.test.mjs
create mode 100644 pinboard/0.1.0/test/store.test.mjs
create mode 100644 pinboard/0.1.0/typst.toml
create mode 100644 pinboard/0.1.0/vite.config.js
create mode 100644 search/0.1.0/.gitignore
create mode 100644 search/0.1.0/.marrow.typ
create mode 100644 search/0.1.0/Justfile
create mode 100755 search/0.1.0/demo/rheo/Justfile
create mode 100755 search/0.1.0/demo/rheo/check.sh
create mode 100644 search/0.1.0/demo/rheo/content/index.typ
create mode 100644 search/0.1.0/demo/rheo/content/lib.typ
create mode 100644 search/0.1.0/demo/rheo/content/refs.bib
create mode 100644 search/0.1.0/demo/rheo/content/sub/page.typ
create mode 100644 search/0.1.0/demo/rheo/rheo.toml
create mode 100644 search/0.1.0/demo/rheo/style.css
create mode 100644 search/0.1.0/flake.lock
create mode 100644 search/0.1.0/flake.nix
create mode 100644 search/0.1.0/package.json
create mode 100644 search/0.1.0/pnpm-lock.yaml
create mode 100644 search/0.1.0/readme.md
create mode 100644 search/0.1.0/src/bar.js
create mode 100644 search/0.1.0/src/base.typ
create mode 100644 search/0.1.0/src/compress.typ
create mode 100644 search/0.1.0/src/corpus.typ
create mode 100644 search/0.1.0/src/filter-panel.typ
create mode 100644 search/0.1.0/src/island.js
create mode 100644 search/0.1.0/src/keywords.js
create mode 100644 search/0.1.0/src/lib.typ
create mode 100644 search/0.1.0/src/limit.js
create mode 100644 search/0.1.0/src/lookup.typ
create mode 100644 search/0.1.0/src/marks.js
create mode 100644 search/0.1.0/src/modal.js
create mode 100644 search/0.1.0/src/panel.js
create mode 100644 search/0.1.0/src/panel.typ
create mode 100644 search/0.1.0/src/preview.js
create mode 100644 search/0.1.0/src/rank.typ
create mode 100644 search/0.1.0/src/row.js
create mode 100644 search/0.1.0/src/score.js
create mode 100644 search/0.1.0/src/score.typ
create mode 100644 search/0.1.0/src/search.css
create mode 100644 search/0.1.0/src/search.js
create mode 100644 search/0.1.0/src/selection.js
create mode 100644 search/0.1.0/src/tagquery.js
create mode 100644 search/0.1.0/src/tagquery.typ
create mode 100644 search/0.1.0/src/text.js
create mode 100644 search/0.1.0/src/ui.typ
create mode 100644 search/0.1.0/src/urlstate.js
create mode 100644 search/0.1.0/src/urlsync.js
create mode 100644 search/0.1.0/test/browser/bar.mjs
create mode 100644 search/0.1.0/test/browser/modal.mjs
create mode 100644 search/0.1.0/test/extractnote.test.mjs
create mode 100644 search/0.1.0/test/filterpanel.test.mjs
create mode 100644 search/0.1.0/test/global.test.mjs
create mode 100644 search/0.1.0/test/internal.mjs
create mode 100644 search/0.1.0/test/island.test.mjs
create mode 100644 search/0.1.0/test/matchranges.test.mjs
create mode 100644 search/0.1.0/test/paneldupes.test.mjs
create mode 100644 search/0.1.0/test/panelinput.test.mjs
create mode 100644 search/0.1.0/test/panelmulti.test.mjs
create mode 100644 search/0.1.0/test/panelquery.test.mjs
create mode 100644 search/0.1.0/test/panelsync.test.mjs
create mode 100644 search/0.1.0/test/panelunion.test.mjs
create mode 100644 search/0.1.0/test/parity.mjs
create mode 100644 search/0.1.0/test/parity.typ
create mode 100644 search/0.1.0/test/row.test.mjs
create mode 100644 search/0.1.0/test/search.test.mjs
create mode 100644 search/0.1.0/test/selection.test.mjs
create mode 100644 search/0.1.0/test/urlstate.test.mjs
create mode 100644 search/0.1.0/test/urlsync.test.mjs
create mode 100644 search/0.1.0/typst.toml
create mode 100644 search/0.1.0/vite.config.js
create mode 100644 slipshow/0.1.0/.gitignore
create mode 100644 slipshow/0.1.0/Justfile
create mode 100644 slipshow/0.1.0/demo/rheo/Justfile
create mode 100755 slipshow/0.1.0/demo/rheo/check.sh
create mode 100644 slipshow/0.1.0/demo/rheo/content/crossdeck.typ
create mode 100644 slipshow/0.1.0/demo/rheo/content/deck.typ
create mode 100644 slipshow/0.1.0/demo/rheo/content/explicit.typ
create mode 100644 slipshow/0.1.0/demo/rheo/content/index.typ
create mode 100644 slipshow/0.1.0/demo/rheo/content/lib.typ
create mode 100644 slipshow/0.1.0/demo/rheo/content/predicate.typ
create mode 100644 slipshow/0.1.0/demo/rheo/rheo.toml
create mode 100644 slipshow/0.1.0/examples/_template/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/_template/rheo.toml
create mode 100755 slipshow/0.1.0/examples/backgrounds/check.sh
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/fullscreen.typ
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/index.typ
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/nested/deep.typ
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/pattern.png
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/table.typ
create mode 100644 slipshow/0.1.0/examples/backgrounds/content/texture.jpg
create mode 100644 slipshow/0.1.0/examples/backgrounds/rheo.toml
create mode 100755 slipshow/0.1.0/examples/dag/check.sh
create mode 100644 slipshow/0.1.0/examples/dag/content/across.typ
create mode 100644 slipshow/0.1.0/examples/dag/content/corpus.typ
create mode 100644 slipshow/0.1.0/examples/dag/content/index.typ
create mode 100644 slipshow/0.1.0/examples/dag/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/dag/content/open-only.typ
create mode 100644 slipshow/0.1.0/examples/dag/content/wide.typ
create mode 100644 slipshow/0.1.0/examples/dag/rheo.toml
create mode 100644 slipshow/0.1.0/examples/minimal/content/index.typ
create mode 100644 slipshow/0.1.0/examples/minimal/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/minimal/rheo.toml
create mode 100755 slipshow/0.1.0/examples/mixed/check.sh
create mode 100644 slipshow/0.1.0/examples/mixed/content/corpus.typ
create mode 100644 slipshow/0.1.0/examples/mixed/content/index.typ
create mode 100644 slipshow/0.1.0/examples/mixed/content/inline.typ
create mode 100644 slipshow/0.1.0/examples/mixed/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/mixed/content/predicate.typ
create mode 100644 slipshow/0.1.0/examples/mixed/content/tagged.typ
create mode 100644 slipshow/0.1.0/examples/mixed/rheo.toml
create mode 100755 slipshow/0.1.0/examples/ordering/check.sh
create mode 100644 slipshow/0.1.0/examples/ordering/content/corpus.typ
create mode 100644 slipshow/0.1.0/examples/ordering/content/functions.typ
create mode 100644 slipshow/0.1.0/examples/ordering/content/index.typ
create mode 100644 slipshow/0.1.0/examples/ordering/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/ordering/content/reverse.typ
create mode 100644 slipshow/0.1.0/examples/ordering/content/tag-values.typ
create mode 100644 slipshow/0.1.0/examples/ordering/rheo.toml
create mode 100644 slipshow/0.1.0/examples/readme.md
create mode 100755 slipshow/0.1.0/examples/search-order/check.sh
create mode 100644 slipshow/0.1.0/examples/search-order/content/corpus.typ
create mode 100644 slipshow/0.1.0/examples/search-order/content/index.typ
create mode 100644 slipshow/0.1.0/examples/search-order/content/lib.typ
create mode 100644 slipshow/0.1.0/examples/search-order/content/narrowed.typ
create mode 100644 slipshow/0.1.0/examples/search-order/content/ranked.typ
create mode 100644 slipshow/0.1.0/examples/search-order/rheo.toml
create mode 100644 slipshow/0.1.0/flake.nix
create mode 100644 slipshow/0.1.0/package.json
create mode 100644 slipshow/0.1.0/pnpm-lock.yaml
create mode 100644 slipshow/0.1.0/readme.md
create mode 100644 slipshow/0.1.0/src/camera.js
create mode 100644 slipshow/0.1.0/src/edges.js
create mode 100644 slipshow/0.1.0/src/lib.typ
create mode 100644 slipshow/0.1.0/src/marker.typ
create mode 100644 slipshow/0.1.0/src/select.typ
create mode 100644 slipshow/0.1.0/src/slip.typ
create mode 100644 slipshow/0.1.0/src/slipshow.css
create mode 100644 slipshow/0.1.0/src/slipshow.js
create mode 100644 slipshow/0.1.0/src/slipshow.typ
create mode 100644 slipshow/0.1.0/src/tags.typ
create mode 100644 slipshow/0.1.0/test/browser/deck.mjs
create mode 100644 slipshow/0.1.0/test/camera.test.mjs
create mode 100644 slipshow/0.1.0/test/edges.test.mjs
create mode 100644 slipshow/0.1.0/test/panic-both.typ
create mode 100644 slipshow/0.1.0/test/panic-class-fn-type.typ
create mode 100644 slipshow/0.1.0/test/panic-edges-fn-type.typ
create mode 100644 slipshow/0.1.0/test/panic-enter.typ
create mode 100644 slipshow/0.1.0/test/panic-max-width-type.typ
create mode 100644 slipshow/0.1.0/test/panic-neither.typ
create mode 100644 slipshow/0.1.0/test/panic-order-mixed.typ
create mode 100644 slipshow/0.1.0/test/panic-order-type.typ
create mode 100644 slipshow/0.1.0/test/panic-order.typ
create mode 100644 slipshow/0.1.0/test/panic-reveal.typ
create mode 100644 slipshow/0.1.0/test/panic-row-fn-type.typ
create mode 100644 slipshow/0.1.0/test/panic-row-type.typ
create mode 100644 slipshow/0.1.0/test/panic-slips-unknown.typ
create mode 100644 slipshow/0.1.0/test/panic-slips-where.typ
create mode 100644 slipshow/0.1.0/test/panic-where-type.typ
create mode 100755 slipshow/0.1.0/test/panics.sh
create mode 100644 slipshow/0.1.0/test/reveal.test.mjs
create mode 100644 slipshow/0.1.0/test/units.typ
create mode 100644 slipshow/0.1.0/typst.toml
create mode 100644 slipshow/0.1.0/vite.config.js
create mode 100644 timeline/0.1.0/.gitignore
create mode 100644 timeline/0.1.0/Justfile
create mode 100644 timeline/0.1.0/readme.md
create mode 100644 timeline/0.1.0/src/fragment.typ
create mode 100644 timeline/0.1.0/src/ladder.typ
create mode 100644 timeline/0.1.0/src/lib.typ
create mode 100644 timeline/0.1.0/src/read.typ
create mode 100644 timeline/0.1.0/src/timeline.css
create mode 100644 timeline/0.1.0/src/upcoming.typ
create mode 100644 timeline/0.1.0/src/view.typ
create mode 100644 timeline/0.1.0/src/when.typ
create mode 100755 timeline/0.1.0/test/check.sh
create mode 100644 timeline/0.1.0/test/units.typ
create mode 100644 timeline/0.1.0/test/upcoming.typ
create mode 100644 timeline/0.1.0/test/view.typ
create mode 100644 timeline/0.1.0/typst.toml
create mode 100644 todos/0.1.0/.gitignore
create mode 100644 todos/0.1.0/.marrow.typ
create mode 100644 todos/0.1.0/Justfile
create mode 100755 todos/0.1.0/demo/rheo/check.sh
create mode 100644 todos/0.1.0/demo/rheo/content/index.typ
create mode 100644 todos/0.1.0/demo/rheo/content/lib.typ
create mode 100644 todos/0.1.0/demo/rheo/rheo.toml
create mode 100644 todos/0.1.0/package.json
create mode 100644 todos/0.1.0/pnpm-lock.yaml
create mode 100644 todos/0.1.0/readme.md
create mode 100644 todos/0.1.0/src/deck.typ
create mode 100644 todos/0.1.0/src/fields.typ
create mode 100644 todos/0.1.0/src/graph.typ
create mode 100644 todos/0.1.0/src/layout.js
create mode 100644 todos/0.1.0/src/lib.typ
create mode 100644 todos/0.1.0/src/search.typ
create mode 100644 todos/0.1.0/src/skin.typ
create mode 100644 todos/0.1.0/src/table.typ
create mode 100644 todos/0.1.0/src/tags.typ
create mode 100644 todos/0.1.0/src/target.typ
create mode 100644 todos/0.1.0/src/todo-search.js
create mode 100644 todos/0.1.0/src/todo.typ
create mode 100644 todos/0.1.0/src/todos.css
create mode 100644 todos/0.1.0/src/todos.js
create mode 100644 todos/0.1.0/src/views.typ
create mode 100644 todos/0.1.0/test/browser/graph.mjs
create mode 100644 todos/0.1.0/test/layout.test.mjs
create mode 100644 todos/0.1.0/test/panic-layer-cycle.typ
create mode 100755 todos/0.1.0/test/panics.sh
create mode 100644 todos/0.1.0/test/todo-search-sync.test.mjs
create mode 100644 todos/0.1.0/test/todo-search.test.mjs
create mode 100644 todos/0.1.0/test/units.typ
create mode 100644 todos/0.1.0/typst.toml
create mode 100644 todos/0.1.0/vite.config.js
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index bc1fb74e..60fc08f3 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -14,12 +14,29 @@ jobs:
steps:
- uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ with:
+ version: latest
+
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '22'
+
- uses: extractions/setup-just@v3
- # Pinned to the version this repo is developed against, and the one the
- # manifest declares as its `compiler` floor (0.15.0). Installed from the
- # release tarball rather than a third-party action: one pinned URL is
- # easier to audit than an action's own moving parts.
+ # `pdftotext`, which `slipshow/0.1.0/demo/rheo/check.sh` reads the combined
+ # PDF with to prove no HTML tag leaks into a paged compile. The runner image
+ # carries no poppler, and the script's `2>/dev/null` on that call means an
+ # absent binary would otherwise kill it silently under `set -e`.
+ - name: Install poppler-utils
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends poppler-utils
+
+ # Pinned to the version this repo is developed against, and the one all
+ # four manifests declare as their `compiler` floor (0.15.0). Installed
+ # from the release tarball rather than a third-party action: one pinned URL
+ # is easier to audit than an action's own moving parts.
- name: Install Typst 0.15.1
run: |
set -euo pipefail
@@ -28,14 +45,14 @@ jobs:
tar -xJf typst.tar.xz
echo "$PWD/typst-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
- # Pinned to the OLDEST rheo the demo projects need, NOT to the newest
- # rheo: the point is that the DECLARED floor is the floor actually
- # tested, so a package that reaches for a newer surface fails here
- # instead of on a user's machine. rheo reads `min_version` itself and
- # refuses a package above the running version, so this step and that
- # check have to agree — if they drift, one of them is testing a fiction.
+ # Pinned to the OLDEST rheo the four manifests declare as their floor, NOT
+ # to the newest rheo: the point is that the DECLARED floor is the floor
+ # actually tested, so a package that reaches for a newer surface fails
+ # here instead of on a user's machine. rheo reads `min_version` itself and
+ # refuses a package above the running version, so this step and that check
+ # have to agree — if they drift, one of them is testing a fiction.
#
- # core declares `min_version = "0.6.2"`, and NOT because the Typst
+ # All four declare `min_version = "0.6.2"`, and NOT because the Typst
# surface moved — it has not since 0.6.0. Three requirements stack up.
#
# 0.6.0: `_resolve-dest` (`core/0.1.0/src/urls.typ`) emits a reserved
@@ -52,20 +69,21 @@ jobs:
# from a ref lives at a path keyed by its resolved commit, which no probe
# matches. Every page this family mints from marrow went missing, on a build
# that succeeded and warned about nothing. Since every consumer currently
- # tracks a branch rather than a release, that is the ordinary case here, not
- # an edge one.
+ # tracks the `0.1.0` branch rather than a release, that is the ordinary case
+ # here, not an edge one.
#
# 0.6.3: where a namespace could first resolve from a directory on disk
- # (`path`), which is what `[packages.rookery] path = "../../../.."` in
- # `core/0.1.0/demo/rheo/rheo.toml` asks for. core's `min_version` stays at
- # 0.6.2 because no package surface moved — the gap is entirely in how the
- # DEMO PROJECT locates `@rookery`, so the version installed here is set by
- # the demo's floor rather than the package's own.
+ # (`path`), which is what every `[packages.rookery] path = "../../../.."`
+ # in this repo's demos and examples asks for. No package here declares a
+ # `min_version` above 0.6.2, because no package surface moved — the gap
+ # is entirely in how the DEMO PROJECTS locate `@rookery`, so the version
+ # installed here is now set by the demos' floor rather than the
+ # packages' own.
#
# Pinned URL AND pinned digest, for the same auditability reason the
# Typst step gives: a re-uploaded asset fails this step rather than
# silently changing what CI tested.
- - name: Install rheo 0.6.3 (the floor the demo project needs)
+ - name: Install rheo 0.6.3 (the floor the demo projects need)
run: |
set -euo pipefail
# Digest of the v0.6.3 rheo-x86_64-unknown-linux-gnu.zip asset, published
@@ -98,11 +116,10 @@ jobs:
# `@rookery` IS A CUSTOM NAMESPACE, NOT A TYPST-UNIVERSE ONE, so there is
# no registry to download it from: it resolves only where the package
- # cache already holds it. `core/0.1.0/demo/rheo/native.typ` imports
- # `@rookery/core:0.1.0` by coordinate and is compiled by BARE `typst`,
- # which knows only the cache — so without this the `check-typst` step
- # below dies with
- # `error: package not found (searched for @rookery/core:0.1.0)`.
+ # cache already holds it. `search/0.1.0/src/lib.typ` imports
+ # `@rookery/core:0.1.0` for `ideas` and `note-href`, and `just parity`
+ # compiles that file through `typst eval` — so without this the next step
+ # dies with `error: package not found (searched for @rookery/core:0.1.0)`.
#
# THE WHOLE NAMESPACE, not one package, and this is the same one-line
# setup CLAUDE.md prescribes for a developer's machine. Linking a single
@@ -111,9 +128,10 @@ jobs:
# `ln -sfn TARGET DIR` then writes the link INSIDE it, leaving a
# self-referential `/`.
#
- # The rheo build needs nothing from the cache: `demo/rheo/rheo.toml`
- # declares `[packages.rookery] path = "../../../.."` and reads the
- # package straight out of this checkout.
+ # Only `search` needs it by spec. `core`'s own fixture imports
+ # `/src/lib.typ` and `demo/pure` imports `../../src/lib.typ`, both by
+ # path — which is why those two steps have always passed while this one
+ # could not.
- name: Resolve the @rookery namespace from this checkout
run: |
set -euo pipefail
@@ -121,7 +139,77 @@ jobs:
mkdir -p "$cache"
ln -s "$PWD" "$cache/rookery"
# Fail HERE with something legible rather than inside a typst error later.
- test -f "$cache/rookery/core/0.1.0/typst.toml"
+ test -f "$cache/rookery/search/0.1.0/typst.toml"
+
+ # `search`, `todos`, `slipshow` and `pinboard` each import
+ # `@rheo/rehydrate:0.1.0` from their entrypoint, for the `RheoRehydrate`
+ # global their own scripts re-wire widgets through after a `rheo watch`
+ # morph. `rheo` resolves the `@rheo` namespace itself, so every rheo demo
+ # below would fetch this on its own — but the fixtures and parity
+ # harnesses run under BARE `typst eval`/`typst compile`, which knows only
+ # the package cache, and `just parity` dies there with
+ # `package not found (searched for @rheo/rehydrate:0.1.0)`.
+ #
+ # Pinned URL AND pinned digest, like the rheo step below: read the digest
+ # back with `gh release view rehydrate-0.1.0 --repo
+ # freecomputinglab/rheo-packages --json assets --jq '.assets[0].digest'`
+ # (it comes back "sha256:"-prefixed, which `sha256sum -c` will not
+ # accept). The archive unpacks as `typst.toml`, `src/` and `dist/` with no
+ # wrapping directory, so it extracts straight into the version dir.
+ - name: Install @rheo/rehydrate 0.1.0 into the package cache
+ run: |
+ set -euo pipefail
+ sha256="87ef8e8a673aee4f24d49cddb59275083293d2f2305c746e60de8b557a18aa38"
+ curl -fsSL -o rehydrate.tar.gz \
+ https://github.com/freecomputinglab/rheo-packages/releases/download/rehydrate-0.1.0/rehydrate-0.1.0.tar.gz
+ echo "$sha256 rehydrate.tar.gz" | sha256sum -c -
+ dest="${XDG_CACHE_HOME:-$HOME/.cache}/typst/packages/rheo/rehydrate/0.1.0"
+ mkdir -p "$dest"
+ tar -xzf rehydrate.tar.gz -C "$dest"
+ test -f "$dest/typst.toml"
+
+ # AFTER the namespace-symlink step: @rookery/timeline binds `#dated-idea`
+ # from `@rookery/core:0.1.0` — one line, and enough to need the cache.
+ # Still buildless, like core itself, so no vite step.
+ #
+ # `just test` here runs TWO fixtures: `test/units.typ` asserts values, and
+ # `test/view.typ` + `test/check.sh` assert the markup `#timeline-view`
+ # produces, which a paged compile cannot see.
+ - name: timeline unit and view fixtures
+ run: cd timeline/0.1.0 && just test
+
+ # AFTER the namespace-symlink step, like every step that resolves a
+ # package by coordinate: @rookery/meetings imports BOTH
+ # `@rookery/core:0.1.0` and `@rookery/timeline:0.1.0`. Buildless like
+ # those two, so no vite step.
+ #
+ # `just test` here runs TWO fixtures: `test/units.typ` asserts the values
+ # `#meeting` derives — the `occurred` log entry, the `created` date `on:`
+ # sets, the synthesized title — and `test/view.typ` plus `test/check.sh`
+ # assert the markup, including that the record and the rail come ABOVE the
+ # note's prose, which is document order and so invisible to the value
+ # fixture.
+ - name: meetings unit and view fixtures
+ run: cd meetings/0.1.0 && just test
+
+ # bibtex is buildless like meetings — pure Typst, no vite — and imports
+ # `@rookery/core:0.1.0` for the notes its `#citation` constructor builds,
+ # so it sits after the namespace-resolution step above. Its recipe compiles
+ # two PDF fixtures and four HTML ones, echoing each one's stderr: the sweep
+ # fixtures assert on the WARNINGS a sweep emits, so the output is the test.
+ - name: bibtex unit and sweep fixtures
+ run: cd bibtex/0.1.0 && just test
+
+ # `just test` and `just parity` answer different questions: `just test`
+ # is this package's own node suite (the panels, the island, the URL
+ # state, the selection model, the row builder, the note extractor), and
+ # `just parity` is the only thing keeping the Typst and JavaScript copies
+ # of the ranking rule from drifting — two leaf scorers and the tiering
+ # rule above them. Both are needed. `just build` first: both later
+ # recipes import `src/`, but a build failure is worth catching here too,
+ # since publish-packages.yml only builds on main.
+ - name: search build, unit suite and parity
+ run: cd search/0.1.0 && just build && just test && just parity
# This repo's definition of "lint" is that a package's demo compiles
# (CLAUDE.md, "Build"). `demo/pure` is plain `typst compile`, so it runs
@@ -140,12 +228,103 @@ jobs:
# package-`.marrow.typ` still mints: `check.sh` asserts on the OUTPUT —
# minted note pages, backlinks, depth-relative hrefs, and the generated
# `@layer rookery-tags` rules — none of which `demo/pure` can reach.
+ #
+ # AFTER the namespace-symlink step above, not before: the demo's
+ # `.marrow.typ` needs `@rookery/core:0.1.0` to resolve from the cache.
- name: core rheo demo compiles and asserts, under the declared floor
run: cd core/0.1.0/demo/rheo && just check
# The same content compiled a SECOND way, plain `typst compile`, no
# rheo — one document, one compile pass, no minted pages — and asserted
# by `check-native.sh`, not merely compiled. AFTER the namespace-symlink
- # step, because the content imports `@rookery/core:0.1.0` by coordinate.
+ # step, for the same reason as the rheo build above: the content
+ # imports `@rookery/core:0.1.0` by coordinate.
- name: core rheo demo's rookery compiles and asserts without rheo too
run: cd core/0.1.0/demo/rheo && just check-typst
+
+ # search's own rheo fixture. Its `check.sh` asserts what neither the
+ # `node --test` suite nor the parity harness can see: that rheo copied
+ # `dist/lib.js` and `dist/search.css` into the output, that BOTH are
+ # linked from every page at the right depth-relative prefix
+ # (`rookery/...` at the root, `../rookery/...` one level down), that the
+ # JSON island parses with one row per note, and that every href in it
+ # resolves to a file on disk.
+ #
+ # AFTER the namespace-symlink step, like core's own — and after the
+ # `search build and parity` step above, because `dist/` is gitignored and
+ # rheo cannot resolve the package until vite has written it.
+ - name: search rheo demo compiles and asserts
+ run: cd search/0.1.0/demo/rheo && just check
+
+ # @rookery/todos needs three things in this order, which is why it sits
+ # here rather than beside the unit fixtures above:
+ # - it is a BUILT package (its release ships `dist/lib.js`, gitignored),
+ # so vite has to run before rheo can resolve the release path;
+ # - it hard-imports BOTH `@rookery/core:0.1.0` and
+ # `@rookery/timeline:0.1.0`, so it must follow the namespace symlink;
+ # - `just test-js` covers the graph layout, which is the half of this
+ # package the Typst fixture cannot reach.
+ - name: todos build, unit fixture and graph tests
+ run: cd todos/0.1.0 && just build && just test && just test-js
+
+ # `just check` builds the demo and then asserts on its OUTPUT. The one
+ # assertion it exists for is the `.todo-search-row[hidden]` CSS rule:
+ # without it `#todos-search` sets `hidden` on every non-matching row and
+ # the stylesheet un-hides all of them, so the filter reorders the list and
+ # removes nothing. That compiles clean, passes the unit and JS suites, and
+ # looks correct in the markup — it is only wrong on screen.
+ - name: todos rheo demo compiles and asserts
+ run: cd todos/0.1.0 && just check
+
+ # AFTER the namespace-symlink step, like every rheo demo above: this
+ # package imports `@rookery/core:0.1.0` by coordinate. AFTER its own
+ # `just build`, because `dist/` is gitignored and rheo cannot resolve
+ # the package until vite has written it.
+ - name: slipshow build, unit fixtures and rheo demo
+ run: cd slipshow/0.1.0 && just build && just test && just test-js && just check
+
+ # AFTER the namespace-symlink step, like every rheo demo above: these
+ # example projects import `@rookery/core:0.1.0` by coordinate. AFTER
+ # slipshow's own `just build` above, because `dist/` is gitignored and
+ # rheo cannot resolve the package until vite has written it.
+ - name: slipshow examples compile
+ run: cd slipshow/0.1.0 && just examples
+
+ # The ninth package, on the same footing as the four above: `just check`
+ # builds `dist/` and then the demo whose built HTML `test/browser/board.mjs`
+ # asserts against, so the browser step below has a fixture to serve.
+ - name: pinboard build, unit suite and rheo demo
+ run: cd pinboard/0.1.0 && just build && just test-js && just check
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-1.63.0-${{ runner.os }}
+
+ # Real-engine tests across WebKit (the Safari engine) and Chromium. LAST
+ # in the job, because every suite asserts against a package's BUILT demo
+ # and the demos are built by the steps above.
+ #
+ # GECKO RUNS LOCALLY, NOT HERE, which `ROOKERY_BROWSER_ENGINES` is for —
+ # see the harness's own comment on it for the evidence. Its synthetic
+ # pointer input is unreliable on a runner: `pinboard-board`'s drag moved
+ # 55px of 60 on one run and 0 on the next, with both other engines green
+ # each time. Only the engine list narrows; every suite and every
+ # assertion still runs.
+ #
+ # So only two browsers are installed. `--with-deps` is what pulls the
+ # system libraries each one needs.
+ #
+ # No nix here, unlike the local path: the root devShell hands the harness
+ # `PLAYWRIGHT_CORE` and `PLAYWRIGHT_BROWSERS_PATH` from nixpkgs, and
+ # `loadPlaywright` falls back to a plain node_modules resolution when
+ # neither is set. Installing nix on the runner to reproduce that would cost
+ # minutes a run to buy nothing.
+ - name: Browser suites across WebKit and Chromium
+ env:
+ ROOKERY_BROWSER_ENGINES: webkit,chromium
+ run: |
+ npm install --no-save playwright-core@1.63.0
+ npx --yes playwright@1.63.0 install --with-deps webkit chromium
+ just browser
diff --git a/bibtex/0.1.0/.gitignore b/bibtex/0.1.0/.gitignore
new file mode 100644
index 00000000..c640daab
--- /dev/null
+++ b/bibtex/0.1.0/.gitignore
@@ -0,0 +1,3 @@
+.direnv/
+build
+*.pdf
diff --git a/bibtex/0.1.0/Justfile b/bibtex/0.1.0/Justfile
new file mode 100644
index 00000000..f7e0bd15
--- /dev/null
+++ b/bibtex/0.1.0/Justfile
@@ -0,0 +1,55 @@
+default:
+ @echo "rookery-bibtex: pure Typst package, entrypoint is src/lib.typ directly — nothing to build"
+
+# Unit fixture for the parser, the title/key derivations and the field view,
+# plus the rendered `all()` sweep fixture. No runner for the unit half:
+# `assert.eq` inside `test/units.typ` fails the compile with a line number,
+# and a passing compile is the green light — the same shape `@rookery/core`
+# and `@rookery/timeline` use. The rendered half (`test/sweep.typ`) compiles
+# to HTML and `test/check.sh` greps what came out.
+#
+# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS
+# package. `--features html` for parity with this repo's other Justfiles.
+# `--format pdf` with a `/dev/null` output for `units.typ` because typst
+# cannot infer a format from that path, and nothing there is rendered — only
+# asserted.
+#
+# `sweep*.typ`'s stderr is captured and grepped for `did not converge`: a
+# sweep that calls the CLAIMING `citation` from inside its own loop reads its
+# own write in the same pass, and Typst's state resolution oscillates and
+# gives up with exactly that warning while still exiting 0 — a silently
+# incomplete bibliography on a build that only warns. `all()` is built to
+# avoid it (see `src/claim.typ`), and `keywords: "existing"` reads the tag
+# registry mid-sweep for the same reason (see `src/lib.typ`) — this is what
+# would catch a regression in either: `typst compile` alone would not, since
+# the warning does not fail the build.
+#
+# `sweep-all.typ`/`sweep-existing.typ` are separate documents, not two calls
+# in `sweep.typ`, because a second `all()` call is a compile error — each
+# needs its own `bibtex(..)` and its own vertebra.
+#
+# `test/large.typ` parses a field past Typst's 10,000-iteration `while`
+# ceiling and a 400-entry corpus, so a parser that regresses to stepping
+# character by character fails the compile rather than passing unnoticed.
+test:
+ typst compile --features html --root . --format pdf test/units.typ /dev/null
+ @echo "units OK"
+ typst compile --features html --root . --format pdf test/large.typ /dev/null
+ @echo "large OK"
+ # `mkdir` first: unlike `rheo`, `typst compile` does not create its output
+ # directory and fails with "No such file or directory" on a fresh checkout,
+ # since `test/build/` is gitignored and never committed.
+ mkdir -p test/build
+ typst compile --features html --format html --root . test/sweep.typ test/build/sweep.html 2>test/build/sweep.stderr
+ cat test/build/sweep.stderr >&2
+ ! grep -q "did not converge" test/build/sweep.stderr
+ typst compile --features html --format html --root . test/sweep-existing.typ test/build/sweep-existing.html 2>test/build/sweep-existing.stderr
+ cat test/build/sweep-existing.stderr >&2
+ ! grep -q "did not converge" test/build/sweep-existing.stderr
+ typst compile --features html --format html --root . test/sweep-all.typ test/build/sweep-all.html 2>test/build/sweep-all.stderr
+ cat test/build/sweep-all.stderr >&2
+ ! grep -q "did not converge" test/build/sweep-all.stderr
+ typst compile --features html --format html --root . test/fields.typ test/build/fields.html 2>test/build/fields.stderr
+ cat test/build/fields.stderr >&2
+ ! grep -q "did not converge" test/build/fields.stderr
+ ./test/check.sh
diff --git a/bibtex/0.1.0/demo/rheo/Justfile b/bibtex/0.1.0/demo/rheo/Justfile
new file mode 100644
index 00000000..3c795ce5
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/Justfile
@@ -0,0 +1,10 @@
+# rheo is NOT in this repo's devShell — locally it is the sibling `rheo/`
+# crate, same as `@rookery/core`'s own demo (see that Justfile's header).
+build:
+ rheo compile .
+
+watch:
+ rheo watch . --html --open
+
+clean:
+ rm -rf build
diff --git a/bibtex/0.1.0/demo/rheo/content/entry.typ b/bibtex/0.1.0/demo/rheo/content/entry.typ
new file mode 100644
index 00000000..bf20d475
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/content/entry.typ
@@ -0,0 +1,16 @@
+// The hand-written half of the bibliography: this note's body is authored,
+// not swept — `index.typ`'s `all()` mints every OTHER entry with the empty
+// body a swept note always gets. A hand-written `#citation` for a key always
+// wins over `all()`, no matter where the two calls sit relative to each
+// other.
+#import "lib.typ": demo, refs
+
+#show: demo
+
+= A cited entry
+
+#(refs.citation)("okafor2019")[
+ Cited directly, because its accounting of responsiveness as a budget spent
+ reframes what the rest of this bibliography treats as measured only after
+ the fact.
+]
diff --git a/bibtex/0.1.0/demo/rheo/content/index.typ b/bibtex/0.1.0/demo/rheo/content/index.typ
new file mode 100644
index 00000000..08882b6e
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/content/index.typ
@@ -0,0 +1,11 @@
+// The rest of the bibliography, swept in one call: `all()` mints a note for
+// every entry `entry.typ`'s hand-written `#citation` has not already
+// claimed — see `@rookery/bibtex`'s readme, "all() — minting the rest of the
+// bibliography".
+#import "lib.typ": demo, refs
+
+#show: demo
+
+= References
+
+#(refs.all)()
diff --git a/bibtex/0.1.0/demo/rheo/content/lib.typ b/bibtex/0.1.0/demo/rheo/content/lib.typ
new file mode 100644
index 00000000..802f9ce9
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/content/lib.typ
@@ -0,0 +1,30 @@
+// The one place the demo is configured, applied by both vertebrae — same
+// reason `@rookery/core`'s own demo does this (see its `content/lib.typ`):
+// `#show: rookery` is per-FILE, so a project that wants one configuration
+// wraps it once here and every vertebra applies the wrapper.
+#import "@rookery/core:0.1.0": rookery
+#import "@rookery/bibtex:0.1.0": bibtex
+
+#let refs = bibtex(read("../references.bib"))
+
+// Appended after every minted page's OWN body — `entry.typ`'s hand-written
+// citation as much as every entry `index.typ`'s `all()` sweeps — so a reader
+// lands on the bibliographic record regardless of which path minted the
+// page. A NAMED TOP-LEVEL FUNCTION, deliberately: `idea-page-template` is
+// read back from document-wide state, so an inline closure built inside
+// `demo` below would be a different value per vertebra and whichever file
+// happened to compile last would win.
+// `id` arrives prefixed (`idea:`, the default `prefix:` `#show: rookery`
+// publishes) — the bare BibTeX key, which is what `refs.fields` looks up, is
+// everything after the first `:`.
+#let citation-page(id: none, note: (:), doc) = {
+ doc
+ if id != none {
+ (refs.fields)(id.split(":").last())
+ }
+}
+
+#let demo(doc) = {
+ show: rookery.with(idea-page-template: citation-page)
+ doc
+}
diff --git a/bibtex/0.1.0/demo/rheo/references.bib b/bibtex/0.1.0/demo/rheo/references.bib
new file mode 100644
index 00000000..32993cf3
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/references.bib
@@ -0,0 +1,38 @@
+@book{harrow2014,
+ title = {Assembling the Archive: Notes Toward a Practice},
+ shorttitle = {Assembling the Archive},
+ author = {Harrow, Elena},
+ year = {2014},
+ publisher = {Tidewater Press},
+}
+
+@article{okafor2019,
+ title = {Latency Budgets for Interactive Systems},
+ author = {Okafor, Chidi},
+ journal = {Journal of Systems Research},
+ volume = {12},
+ pages = {201--227},
+ year = {2019},
+ doi = {10.5555/jsr.2019.0012},
+ abstract = {Argues that a system's perceived responsiveness is a budget spent, not a property measured after the fact, and proposes a way to account for it at design time.},
+}
+
+@inproceedings{singh2021,
+ title = {Incremental Computation for Editable Documents},
+ author = {Singh, Priya and Novak, Tomas and Reyes, Marisol},
+ booktitle = {Proceedings of the Workshop on Live Systems},
+ year = {2021},
+}
+
+@book{fenwick2017,
+ title = {Field Notes on Distributed Consensus},
+ editor = {Fenwick, Dorothy},
+ year = {2017},
+ publisher = {Causeway Editions},
+}
+
+@misc{fielding2022,
+ title = {Notes on a Small Protocol},
+ author = {Fielding, Sam},
+ year = {2022},
+}
diff --git a/bibtex/0.1.0/demo/rheo/rheo.toml b/bibtex/0.1.0/demo/rheo/rheo.toml
new file mode 100644
index 00000000..5631f2f2
--- /dev/null
+++ b/bibtex/0.1.0/demo/rheo/rheo.toml
@@ -0,0 +1,20 @@
+# @rookery/bibtex's in-repo demo: a small rookery whose notes come from
+# `references.bib` rather than hand-authored prose — `content/index.typ`
+# sweeps the whole bibliography with `all()`, `content/entry.typ` claims one
+# entry by hand with `#citation` to show the override `all()` respects.
+version = "0.6.2"
+content_dir = "content"
+formats = ["html"]
+
+# `lib.typ` is a library, not a page: it holds the shared `#show: rookery`
+# wrapper both vertebrae apply. Without this it would compile to its own
+# `lib.html`.
+[spine]
+exclude = ["lib.typ"]
+
+# The Typst cache's `rookery` namespace is a per-machine symlink that can
+# point at a different checkout of this repo, so this reads `@rookery/*` out
+# of this tree instead. See `slipshow/0.1.0/demo/rheo/rheo.toml` for the full
+# argument.
+[packages.rookery]
+path = "../../../.."
diff --git a/bibtex/0.1.0/readme.md b/bibtex/0.1.0/readme.md
new file mode 100644
index 00000000..2cc0aa70
--- /dev/null
+++ b/bibtex/0.1.0/readme.md
@@ -0,0 +1,234 @@
+# @rookery/bibtex
+
+A BibTeX reader and a `#citation` note constructor for
+[`@rookery/core`](../../core/0.1.0) notes — parse a `.bib` file once, then mint
+one note per reference, titled and keyed from the entry itself.
+
+```typst
+#import "@rookery/core:0.1.0": idea
+#import "@rookery/bibtex:0.1.0": bibtex
+
+#let refs = bibtex(read("refs.bib"))
+
+#refs.citation("badiou2002")[
+ #refs.fields("badiou2002")
+]
+```
+
+`badiou2002` is the BibTeX key. `#refs.citation(..)` mints a note titled
+`Badiou, *Ethics* (2002)` — derived from the entry, not typed by hand — tagged
+`citation` by default; `#refs.fields(..)` renders every field the entry
+carries as an HTML definition list, for a body that just wants the record
+laid out.
+
+## `bibtex(src, mint:, tag:, keywords:, show-fields:, only:)`
+
+`src` is a `.bib` file's contents, or an array of them — several exports read
+as one bibliography, joined with a newline between members so a file ending
+mid-token cannot fuse into the next file's first token:
+
+```typst
+#let refs = bibtex((read("primary.bib"), read("secondary.bib")))
+```
+
+`only:` restricts parsing to a handful of keys — a reference manager export
+with thousands of entries costs only what a project actually cites:
+
+```typst
+#let refs = bibtex(read("references.bib"), only: ("badiou2002", "smith2020"))
+```
+
+`auto` (the default) parses the whole file, exactly as `bibtex` behaved
+before this parameter existed. A key `only` names that isn't in `src` is
+dropped silently rather than raised as an error here — `entry(key)` (and
+`citation`/`fields` through it) is where a missing key becomes an error,
+at the point something actually asks for it.
+
+The return value is a dictionary of five functions, all closed over the
+parsed bibliography:
+
+| | |
+| --- | --- |
+| `bib` | the parsed dictionary itself, `key -> (field: value, ..)`, every value a plain string |
+| `entry(key)` | that entry, asserting the key exists rather than handing back `none` |
+| `fields(key, show-fields: auto)` | that entry's fields, as the `
` `fields-block` builds; `show-fields` falls back to the factory's own `show-fields:` when omitted |
+| `citation(key, title: auto, tags: none, display-tags: true, ..)` | a note titled from the entry (`title:` overrides it) and tagged `tag:` (`"citation"` by default) alongside whatever `tags:` you pass |
+| `all()` | mints a note for every entry not already claimed by a hand-written `citation` call |
+
+`citation`'s `key` accepts the form you actually write: `@badiou2002` (a
+Typst `ref`, caught by Typst's own reference checking if the key is wrong), a
+bare label, or a string computed at build time.
+
+A dictionary field holding a function cannot be called with `#refs.citation(..)`
+under Typst 0.15.1 — `cannot directly call dictionary keys as functions` — so
+call through parenthesized field access instead: `#(refs.citation)(..)`,
+`#(refs.all)()`.
+
+## `all()` — minting the rest of the bibliography
+
+A bibliography is a list of things worth a note. `citation(..)` mints one
+where you've written it by hand; `all()` mints the REST — every key `bib`
+carries that no `citation` call has claimed, in the bibliography's own
+alphabetical key order:
+
+```typst
+#let refs = bibtex(read("refs.bib"))
+
+#refs.citation()[The one you want to say something about.]
+#(refs.all)()
+```
+
+`etal2002` keeps its hand-written body; every other entry mints with an empty
+body, titled from the entry the same way `citation` derives its own title.
+**Call it once, from one vertebra** — a second call is a compile error
+(`all() mints the whole bibliography and must be called once, from one
+vertebra`), because it mints the whole bibliography and a second pass would
+either double-register every key or silently do nothing, neither of which is
+useful. A hand-written `citation` for a key always wins: `all()` never mints
+over one, no matter where in the document the two calls sit relative to each
+other.
+
+`all()` sweeps the entries the factory knows — which is `bib`, not the whole
+`.bib` file, when `only:` narrowed it. This is how `only:` turns a large
+library into a small number of notes: sweep a four-key `bib` with `all()`
+and four notes mint, not fourteen hundred.
+
+`mint:` defaults to `@rookery/core`'s own `idea`, which is what you want on
+plain rookery. **A project on `@rookery/timeline` or `@rookery/todos` should
+pass THAT package's own constructor instead** — the version decorated with
+its date or todo arguments — because a citation minted through core's
+undecorated one would not carry them:
+
+```typst
+#import "@rookery/timeline:0.1.0": idea
+#let refs = bibtex(read("refs.bib"), mint: idea)
+```
+
+`parse-bib`, `bib-chunks`, `parse-entry`, `bib-title`, `cite-key`,
+`fields-block` and `keyword-tags` are re-exported from the entrypoint too,
+for a consumer that wants the parts directly rather than only through the
+factory.
+
+## `keywords:` — a Zotero export's keywords as rookery tags
+
+A Better BibTeX export carries `keywords = {..}` — the Zotero tags on the
+record. By default that field renders as an ordinary row in the citation
+block and nothing else; `keywords:` also turns it into real rookery tags, so
+a citation is reachable through the same tag views as every other note:
+
+```typst
+#let refs = bibtex(read("refs.bib"), keywords: "all")
+```
+
+Three values, `none` (the default — no consuming project changes behaviour
+on upgrade):
+
+| | |
+| --- | --- |
+| `none` | (default) the `keywords` field is not turned into tags at all |
+| `"all"` | every keyword becomes a tag, whether or not the rookery already has it |
+| `"existing"` | only a keyword that already matches a tag SOMEWHERE ELSE in the rookery becomes one; the rest are ignored |
+
+A keyword is slugified before it is compared or minted — trimmed, lowercased,
+every run of non-alphanumeric characters collapsed to one hyphen, leading and
+trailing hyphens stripped — so `Digital Humanities` becomes the tag
+`digital-humanities`, and `"existing"` matches against that slug (the tags
+already in a rookery are themselves slugs). A keyword that slugifies to the
+empty string is dropped. `keywords` may hold several, split on both `,` and
+`;` since Better BibTeX emits either depending on export settings.
+
+Keyword tags merge with whatever `tags:` a `citation`/`all()` call already
+carries, and with the package's own `tag` (`"citation"` by default) — a
+caller's explicit tag always wins on a key collision. The `keywords` row
+itself stays in the citation block regardless: it is bibliographic data, and
+the tags are an addition to it, not a replacement.
+
+`"existing"` reads the rookery's own tag registry, which is why it can only
+run where `#context` is available — `all()` already runs inside one;
+`citation` opens one of its own for this mode specifically, rather than for
+every mode.
+
+## `show-fields:` — hiding fields from the citation block
+
+`fields-block` (and `fields(..)` through it) renders every field an entry
+carries. Some of that is noise on a published page — `urldate` is when the
+record was last touched in Zotero, and a `doi` is redundant beside a `url` —
+so `show-fields` says which fields to leave out:
+
+```typst
+#let refs = bibtex(read("refs.bib"), show-fields: ("urldate": false, "doi": false))
+```
+
+or on a single call, without touching the factory default:
+
+```typst
+#refs.fields("badiou2002", show-fields: ("urldate": false))
+```
+
+It's a dictionary mapping a field name to a boolean, and its shape is what
+makes it pleasant to write:
+
+| | |
+| --- | --- |
+| omitted entirely | (default) every field is shown — `show-fields: (:)` and no argument at all behave identically, so upgrading to this doesn't change any existing project |
+| a partial dictionary | hides only what it names `false`; a field it doesn't mention is shown — the dictionary is a list of exceptions, not a whitelist, so you name the two fields you don't want rather than the twenty you do |
+| `true` | shown, same as absent — worth accepting so a project can flip a field back on and keep the line as a record of the decision, instead of deleting it |
+
+`"entry-type"` is a valid key here too, even though it names no real BibTeX
+field — it's the parser's own synthesized key behind the `Type` row, and
+`("entry-type": false)` hides that row like any other.
+
+Only the *values* are validated (each must be a boolean); the keys are not
+checked against any known field list, because the block deliberately renders
+any field a `.bib` carries, including ones this package has never heard of —
+so a misspelt key (`"urldata"` for `"urldate"`) is legitimate syntax that
+silently hides nothing.
+
+Hiding every field an entry carries makes `fields-block` emit nothing at all —
+no label, no empty `
`.
+
+`show-fields:` pairs well with `keywords:` above: tag from `keywords` while
+hiding the `keywords` row itself, so the citation block shows the tags rather
+than the raw comma-separated field they came from:
+
+```typst
+#let refs = bibtex(read("refs.bib"), keywords: "all", show-fields: ("keywords": false))
+```
+
+## What the parser does not handle
+
+A hand-rolled scanner over `@type{key, field = {..} | "..." | bare}`, with
+nested braces and `{{Protected Words}}` unwrapped to the words themselves —
+BibTeX uses an interior brace to protect capitalization from a citation
+style, not to say anything about the text. It does not:
+
+- expand `@string` macros;
+- resolve `#` string concatenation;
+- translate LaTeX escapes (`\&`, `{\'e}`, …) — they come through as literal
+ text.
+
+Export from your reference manager with macros expanded and Unicode rather
+than LaTeX escapes (Zotero and most others do this by default) and the parser
+sees exactly what you'd expect.
+
+## Requirements
+
+No build step and no JavaScript: `typst.toml`'s `entrypoint` points straight
+at `src/`, so an edit takes effect immediately. `citation(..)` calls into
+`@rookery/core` 0.1.0 for `idea`; nothing else here imports it.
+
+## Development
+
+```sh
+cd bibtex/0.1.0
+just test
+rheo compile demo/rheo
+```
+
+`demo/rheo` is a small rookery whose notes come straight from a `.bib` file:
+`demo/rheo/references.bib` carries five entries — a book with a `shorttitle`,
+an article with a `doi`, `journal`, `volume`, `pages` and an `abstract`, an
+entry with three authors, and one with an `editor` and no `author` — and
+`demo/rheo/content/index.typ` sweeps all but one of them with `all()`, while
+`demo/rheo/content/entry.typ` claims the remaining one by hand with
+`#citation` to show the override `all()` respects.
diff --git a/bibtex/0.1.0/src/bibtex.css b/bibtex/0.1.0/src/bibtex.css
new file mode 100644
index 00000000..7ee138d4
--- /dev/null
+++ b/bibtex/0.1.0/src/bibtex.css
@@ -0,0 +1,111 @@
+/* rookery-bibtex — the citation block `#fields-block` draws: a label, then one
+ row per bibliographic field.
+
+ Thin on purpose, like the rest of this family: enough that an entry reads as a
+ table out of the box, and nothing that presumes a page design. No fonts, no page
+ colours, and no absolute font size — every size here is a factor of whatever the
+ page already sets, so the block takes the surrounding type.
+
+ THE LAYER, and it is not optional. rheo links a PACKAGE's stylesheet AFTER the
+ project's own, so on equal specificity this file would win every tie and a project
+ could not fix it by writing its rule "later" — there is no later. Wrapping
+ everything in a cascade layer inverts that: any UNLAYERED rule in the project's CSS
+ beats any layered rule here, whatever its specificity or position. A plain
+ `.citation-fields dt { color: red }` in a site's stylesheet just works. That is the
+ guarantee, and it is why nothing in this file sits outside the layer.
+
+ THE PROPERTIES. Every colour and size is `var(--x, )`, the default being
+ the literal in the var() call. Set one on `.citation-fields` (or anywhere it
+ inherits from) and the block is themed without overriding a rule at all:
+
+ --citation-fg a field's value
+ --citation-muted the label above the block, and a field's name
+ --citation-line the rules between fields
+ --citation-gap space between a field's text and the rule under it
+ --citation-gutter width of the name column
+
+ THE GUTTER MATCHES @rookery/timeline'S RAIL, 7.5em, and the match is the point: a
+ note page that draws both puts this block under that one, and two adjacent tables
+ whose columns start in different places read as two conventions rather than one
+ page. A project moving one should move the other — hence the default here reads
+ `--timeline-gutter` first, so setting that single property lines both up. */
+@layer bibtex {
+ /* A LABEL, NOT A HEADING, and `#fields-block` emits a `
` for it precisely so
+ it claims no place in the page's outline above the note's own headings. Styled as
+ one, so it does not read as one — the same treatment @rookery/timeline gives
+ `.upcoming-title`. */
+ .citation-fields-head {
+ margin: 1.2rem 0 0;
+ color: var(--citation-muted, gray);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ font-size: 0.85em;
+ }
+
+ /* TWO COLUMNS: the field's name in the gutter, its value to the right. A grid on
+ the `
` itself, with each `
`/`
` auto-placed as its own item — so a
+ value that wraps to three lines pushes the next row down instead of drifting out
+ of column.
+
+ A GRID RATHER THAN A FLEX LINE, for the reason measured across this family: a
+ flex item's basis is only a HYPOTHETICAL size, so a long field name would push
+ its value and no two rows would agree on where the value starts.
+
+ `align-items: baseline` sits a name on the first line of its value, which is what
+ keeps a one-word name level with the opening line of an abstract. */
+ .citation-fields {
+ display: grid;
+ grid-template-columns: var(--citation-gutter, var(--timeline-gutter, 7.5em)) 1fr;
+ column-gap: 0.9rem;
+ margin: 0.6rem 0 0;
+ border-top: 1px solid var(--citation-line, var(--timeline-line, currentColor));
+ }
+
+ /* THE RULES BETWEEN FIELDS, one per row, drawn on BOTH cells of the row so the two
+ segments abut into a single line across the block. Horizontal only: there is no
+ rule down the gutter, because the field names are a label column rather than a
+ second column of data.
+ ONE ROW'S TWO CELLS MUST END AT THE SAME HEIGHT for those segments to meet, and
+ that is what forbids `align-items: baseline` here. Baseline alignment sizes each
+ cell to its own content, so a one-word name beside a paragraph-long abstract
+ draws its rule at the top of the row while the abstract draws it at the bottom —
+ one row, two broken lines at different heights. The default `stretch` gives both
+ cells the row's full height instead, which is also why the gap between rows is
+ `padding` on the cells rather than `row-gap` on the grid: a gap would fall
+ BELOW each rule, leaving the line crammed against the text above it.
+ A name still sits level with the first line of its value, since both cells now
+ start at the row's top edge. */
+ .citation-fields dt,
+ .citation-fields dd {
+ padding: var(--citation-gap, 0.4rem) 0;
+ border-bottom: 1px solid var(--citation-line, var(--timeline-line, currentColor));
+ }
+
+ /* THE FIELD NAME, muted and uppercased — a label, matching the stage names on
+ @rookery/timeline's rail so the two blocks read as one page. */
+ .citation-fields dt {
+ color: var(--citation-muted, gray);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ font-size: 0.85em;
+ }
+
+ /* THE VALUE. `margin: 0` is load-bearing rather than tidy: a browser's default
+ `
` carries `margin-inline-start: 40px`, which in a grid cell indents every
+ value away from its own column. */
+ .citation-fields dd {
+ margin: 0;
+ color: var(--citation-fg, inherit);
+ font-size: 0.9em;
+ }
+
+ /* NUMBERS AN EVEN WIDTH, so a year, a volume and a page range line up down the
+ column rather than shifting with each digit — the same reason
+ @rookery/timeline sets it on a date. `overflow-wrap` is for the two fields that
+ can carry an unbroken string longer than the column: a DOI and a URL, which
+ would otherwise widen the grid and push the page sideways. */
+ .citation-fields dd {
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+ }
+}
diff --git a/bibtex/0.1.0/src/claim.typ b/bibtex/0.1.0/src/claim.typ
new file mode 100644
index 00000000..0d74562a
--- /dev/null
+++ b/bibtex/0.1.0/src/claim.typ
@@ -0,0 +1,13 @@
+// The two states behind `bibtex(..)`'s split between minting and claiming.
+//
+// `_claimed` cannot be a read of core's own note registry: `ideas()` reads
+// `_registry.final()`, and `all()` (lib.typ) FEEDS that registry by minting
+// notes into it — asking the registry "is this key taken" from inside the
+// sweep that writes it is circular, and Typst's own state resolution refuses
+// to converge on a loop like that. A separate state breaks the cycle: `all()`
+// only ever READS `_claimed`, and `citation` (lib.typ) is the only writer.
+//
+// `_swept` guards `all()` against a second call — see `all()`'s own comment
+// for why it must be read with `.get()`, never `.final()`.
+#let _claimed = state("rookery-bibtex-claimed", (:))
+#let _swept = state("rookery-bibtex-swept", 0)
diff --git a/bibtex/0.1.0/src/format.typ b/bibtex/0.1.0/src/format.typ
new file mode 100644
index 00000000..0cae3c6c
--- /dev/null
+++ b/bibtex/0.1.0/src/format.typ
@@ -0,0 +1,65 @@
+// Deriving a citation note's title from a parsed BibTeX entry, and reading
+// back the key a `#citation` call was written with.
+
+// The BibTeX key a `#citation` was written with. `@key` is the form to write —
+// Typst parses it as a `ref`, so a key that is not in the bibliography is caught
+// by Typst's own checking as well as by the missing-key assert once the
+// bibliography is consulted — with a bare label or a string taken too, for a
+// call that computes its key.
+#let cite-key(r) = {
+ if type(r) == str { return r }
+ if type(r) == label { return str(r) }
+ if type(r) == content and r.func() == ref { return str(r.target) }
+ panic("citation: expected `@key`, a label or a string, got " + repr(r))
+}
+
+// Surnames, in the order the entry lists them. BibTeX writes an author list as
+// `Last, First and Last, First`, but a Zotero export can also emit `First Last`
+// where the record has no split name — hence the two branches. The unsplit one
+// takes the LAST WORD, so `Ursula Le Guin` reads as `Guin`: a particle is only
+// knowable from the comma form, which is what a record with a split name gives.
+#let _surnames(names) = {
+ if names == none { return () }
+ names
+ .split(" and ")
+ .map(a => {
+ let a = a.trim()
+ if a.contains(",") { a.split(",").first().trim() } else { a.split(" ").last() }
+ })
+ .filter(s => s != "")
+}
+
+// Two names read as a pair, three or more as the first plus `et al.` — the same cut
+// a reader's eye makes, and the note's title is a shelf label rather than a
+// reference.
+#let _byline(names) = {
+ if names.len() == 0 {
+ none
+ } else if names.len() == 1 {
+ names.first()
+ } else if names.len() == 2 {
+ names.join(" and ")
+ } else {
+ names.first() + " et al."
+ }
+}
+
+// What a citation note is CALLED: `Badiou, Ethics (2002)`.
+//
+// `shorttitle` FIRST, which is why Better BibTeX's own field is worth having: the
+// full title of the Badiou is `Ethics: An Essay on the Understanding of Evil`, and
+// a rookery row, a browser tab and an `@idea:` link all want the two syllables the
+// book is known by. The full title is the fallback, not the other way round.
+//
+// `editor` stands in for a missing `author` — an edited collection is cited by the
+// people who made it — and every part is optional: an entry with nothing but a
+// title yields the title, and one with no title at all yields `none`, which is
+// rookery's own "this note has no authored title" and leaves it to derive a label
+// from the body.
+#let bib-title(entry) = {
+ let work = entry.at("shorttitle", default: entry.at("title", default: none))
+ if work == none { return none }
+ let by = _byline(_surnames(entry.at("author", default: entry.at("editor", default: none))))
+ let year = entry.at("year", default: none)
+ [#if by != none [#by, ]#emph(work)#if year != none [ (#year)]]
+}
diff --git a/bibtex/0.1.0/src/keywords.typ b/bibtex/0.1.0/src/keywords.typ
new file mode 100644
index 00000000..b4123a0b
--- /dev/null
+++ b/bibtex/0.1.0/src/keywords.typ
@@ -0,0 +1,31 @@
+// A BibTeX `keywords` field, split into rookery tag slugs.
+//
+// BibTeX has no repeated fields, so `keywords = {ethics, ontology, badiou}`
+// parses to one string (`parse-bib`, `parse.typ`); Better BibTeX exports use
+// either `,` or `;` as the separator depending on export settings, so both
+// are accepted.
+
+#let _NON-ALNUM = regex("[^a-z0-9]+")
+#let _EDGE-HYPHENS = regex("^-+|-+$")
+#let _KEYWORD-SEP = regex("[,;]")
+
+// Trimmed, lowercased, every run of non-alphanumeric characters collapsed to
+// one hyphen, leading/trailing hyphens stripped. A tag with a space silently
+// breaks its CSS class downstream — `idea-tag-`'s class attribute is
+// built by joining tag names with a space, so an untrimmed `digital
+// humanities` becomes TWO classes, `idea-tag-digital` and a stray global
+// `humanities` — so every keyword goes through this before it becomes a tag.
+#let _slugify(s) = {
+ let s = lower(s.trim())
+ let s = s.replace(_NON-ALNUM, "-")
+ s.replace(_EDGE-HYPHENS, "")
+}
+
+// The raw `keywords` field value (a string, or `none` for an entry that
+// carries no such field) as an array of slugs, empty parts dropped — a
+// keyword that slugifies to the empty string (punctuation only) contributes
+// nothing.
+#let keyword-tags(raw) = {
+ if raw == none { return () }
+ raw.split(_KEYWORD-SEP).map(_slugify).filter(s => s != "")
+}
diff --git a/bibtex/0.1.0/src/lib.typ b/bibtex/0.1.0/src/lib.typ
new file mode 100644
index 00000000..d86cf18d
--- /dev/null
+++ b/bibtex/0.1.0/src/lib.typ
@@ -0,0 +1,196 @@
+// @rookery/bibtex — a BibTeX reader and a `#citation` note constructor for
+// @rookery/core notes.
+//
+// `bibtex(src, mint:, tag:, keywords:, show-fields:, only:)` parses one
+// or more `.bib` sources once and closes over the result, returning:
+//
+// bib: the parsed dictionary, `key -> (field: value, ..)`
+// entry: key -> that entry, asserting the key exists
+// fields: (key, show-fields: auto) -> its fields, as the HTML `
`
+// `fields-block` builds; `show-fields` falls back to the
+// factory's own when omitted
+// citation: (key, title: auto, tags: none, display-tags: true, ..) -> a note,
+// titled from the entry unless `title:` overrides it
+// all: () -> mints every entry NOT already claimed by a hand-written
+// `citation` call, once per document (see `claim.typ`)
+//
+// `src` may be a single string or an array of strings — several `.bib` exports
+// read as one bibliography, joined with a newline between members so a file
+// ending mid-token cannot fuse into the next file's first token.
+//
+// `parse-bib`, `bib-chunks`, `parse-entry`, `bib-title`, `cite-key`,
+// `fields-block` and `keyword-tags` are re-exported so a consumer can reach
+// the parts directly rather than only through the factory.
+
+// `_norm-tags` IS ONE OF CORE'S PRIVATE NAMES, imported deliberately: merging keyword
+// tags into the caller's own needs both sides to be dictionaries, and `tags:` accepts a
+// string, an array or a dictionary. Core has no public equivalent, and a local copy of
+// its normalisation would drift silently the day core accepts a fifth shape — where
+// this import breaks loudly, at compile time, if the name ever moves.
+#import "@rookery/core:0.1.0": idea as _core-idea, tag-data, _merge-base-tags, _norm-tags
+#import "parse.typ": *
+#import "format.typ": *
+#import "view.typ": *
+#import "claim.typ": *
+#import "keywords.typ": *
+
+#let _KEYWORDS-MODES = (none, "all", "existing")
+
+// `mint:` defaults to core's own `idea`, which covers a project on plain
+// rookery. IT STAYS A PARAMETER because a project on `@rookery/timeline` or
+// `@rookery/todos` mints its notes through THAT package's own constructor —
+// the one decorated with its date or todo arguments — and a citation minted
+// through core's undecorated version would not take them.
+//
+// `keywords:` turns an entry's `keywords` field into rookery tags, on top of
+// whatever `tags:` a `citation`/`all()` call already carries:
+//
+// none (default) no tags from keywords — unchanged behaviour
+// "all" every keyword becomes a tag, whatever the rookery already has
+// "existing" only a keyword that already matches a tag somewhere in the
+// rookery becomes one; the rest are ignored
+//
+// `"existing"` reads `tag-data()` to learn what tags exist, which needs
+// `#context`. That is safe here specifically because the sweep only ever adds
+// tags that ALREADY exist — the known-tag set is a fixed point under its own
+// writes — so reading it mid-sweep still converges. See `note` below for how
+// that read reaches both minting paths.
+//
+// `only:` parses just the named keys out of `src`, so a large library costs
+// what it's USED rather than what it contains. `auto` (the default) parses
+// the whole file — not `none`, which would read as "parse nothing". A key
+// `only` names that `src` doesn't carry is dropped silently; nothing here
+// errors on it, because `entry(key)` already asserts on a missing key at the
+// point something asks for it, which is a more useful place to fail than
+// factory construction.
+#let bibtex(
+ src,
+ mint: _core-idea,
+ tag: "citation",
+ keywords: none,
+ show-fields: (:),
+ only: auto,
+) = {
+ assert(
+ keywords in _KEYWORDS-MODES,
+ message: "@rookery/bibtex: `keywords` must be none, \"all\" or \"existing\" — got "
+ + repr(keywords),
+ )
+ assert(
+ only == auto or type(only) == array,
+ message: "@rookery/bibtex: `only` must be auto or an array of keys — got " + repr(only),
+ )
+ // Captured under its own name because `fields:` below takes a per-call
+ // parameter of the same name — inside that closure, `show-fields` is the
+ // per-call one, and this is the only way back to the factory's.
+ let _show-fields = show-fields
+ let src = if type(src) == array { src.join("\n") } else { src }
+ // `only:` filters the source before parsing, not the parsed result after
+ // — see the header comment above for why, and for the missing-key
+ // contract.
+ let bib = if only == auto { parse-bib(src) } else {
+ let chunks = bib-chunks(src)
+ let kept = only.filter(k => k in chunks).map(k => chunks.at(k))
+ if kept.len() == 0 { (:) } else { parse-bib(kept.join("\n")) }
+ }
+ let entry = key => {
+ let e = bib.at(key, default: none)
+ assert(e != none, message: "no `" + key + "` in the bibliography")
+ e
+ }
+ // One entry's keyword slugs, filtered against the rookery's known tags in
+ // "existing" mode. Needs `#context` only for that mode — `all()` already
+ // calls `note` from inside one; `citation` wraps its own call below, only
+ // when `keywords` is "existing", rather than becoming a context function
+ // for every mode.
+ let kw-tags-for(key, known: none) = {
+ if keywords == none { return (:) }
+ let slugs = keyword-tags(entry(key).at("keywords", default: none))
+ let kept = if keywords == "existing" {
+ let known = if known != none {
+ known
+ } else {
+ tag-data().values().map(t => t.keys()).flatten().dedup()
+ }
+ slugs.filter(s => s in known)
+ } else {
+ slugs
+ }
+ kept.fold((:), (d, t) => { d.insert(t, none); d })
+ }
+ // The mint, with no claim. `all()` below calls this directly, for every key
+ // the sweep reaches — `citation`'s claim would make the sweep both write and
+ // read `_claimed` in the same pass, and Typst's state resolution does not
+ // converge on that (see `claim.typ`).
+ //
+ // Keyword tags merge UNDER the caller's own `tags:` — dictionary `+` lets
+ // the right side win a key collision, so an explicit tag always wins over
+ // one derived from `keywords`. The package's own `tag` then merges under
+ // BOTH of those, through `_merge-base-tags`, so neither the keyword tags nor
+ // a caller's own `tags:` can displace it.
+ let note = (key, title: auto, tags: none, display-tags: true, known: none, ..args) => mint(
+ key,
+ title: if title == auto { bib-title(entry(key)) } else { title },
+ tags: _merge-base-tags(tag, kw-tags-for(key, known: known) + _norm-tags(tags)),
+ display-tags: display-tags,
+ ..args,
+ )
+ (
+ bib: bib,
+ entry: entry,
+ fields: (key, show-fields: auto) => fields-block(
+ entry(key),
+ show-fields: if show-fields == auto { _show-fields } else { show-fields },
+ ),
+ // The authoring form: claims its key — so `all()` skips it — then mints.
+ // The claim is idempotent, so writing `citation` for one key twice still
+ // reads as one claimed key, not a collision.
+ citation: (key, title: auto, tags: none, display-tags: true, ..args) => {
+ let key = cite-key(key)
+ _claimed.update(c => { c.insert(key, none); c })
+ if keywords == "existing" {
+ context note(key, title: title, tags: tags, display-tags: display-tags, ..args)
+ } else {
+ note(key, title: title, tags: tags, display-tags: display-tags, ..args)
+ }
+ },
+ // Mints a note for every bibliography key not already claimed by a
+ // hand-written `citation`, in the bibliography's own alphabetical key
+ // order. Must be called ONCE, from one vertebra, inside a `#show: rookery`
+ // document — a second call panics rather than re-minting.
+ //
+ // `.get()` on `_swept`, NOT `.final()`: `.final()` would see the increment
+ // this very call makes and panic on the FIRST call too. `.get()` sees only
+ // what ran before this point, so the guard fires on a genuine second call
+ // and nothing else.
+ all: () => context {
+ if _swept.get() > 0 {
+ panic(
+ "@rookery/bibtex: all() mints the whole bibliography and must be "
+ + "called once, from one vertebra",
+ )
+ }
+ _swept.update(n => n + 1)
+ // Computed once for the whole sweep, not once per key: "existing" mode
+ // only ever adds a tag that already exists elsewhere, so the known-tag
+ // set is a fixed point under its own writes and one read up front
+ // answers every key in the loop below.
+ let known = if keywords == "existing" {
+ tag-data().values().map(t => t.keys()).flatten().dedup()
+ } else {
+ none
+ }
+ for key in bib.keys().sorted() {
+ if key not in _claimed.final() {
+ // `[]`, an empty body, NOT omitted: core's `#idea` reads a single
+ // positional argument as the note's BODY (idea.typ, the
+ // `pos.len() == 1` branch), so `note(key)` alone would make the key
+ // itself the body — unnamed, landing on the sequence counter as
+ // `ideas/1.html` rather than under its own key. The empty body
+ // keeps `key` in the name slot.
+ note(key, [], known: known)
+ }
+ }
+ },
+ )
+}
diff --git a/bibtex/0.1.0/src/parse.typ b/bibtex/0.1.0/src/parse.typ
new file mode 100644
index 00000000..8db3483c
--- /dev/null
+++ b/bibtex/0.1.0/src/parse.typ
@@ -0,0 +1,111 @@
+// A hand-rolled BibTeX scanner: `@type{key, field = {..} | "..." | bare}`,
+// nested braces, and `{{Protected Words}}` unwrapped. It does not expand
+// `@string` macros, `#` concatenation, or LaTeX escapes — every value comes
+// back as the literal text between its delimiters, squashed to single spaces.
+
+// Newlines and runs of spaces flattened to one space: a `title = {..}` wrapped
+// across three lines is one line of prose, and the indentation is the file's, not
+// the title's.
+#let _squash(s) = s.trim().split(regex("\\s+")).join(" ")
+
+#let _BRACES = regex("[{}]")
+#let _HEAD = regex("^@([A-Za-z]+)\\s*\\{\\s*([^,\\s]+)\\s*,")
+#let _FIELD = regex("^[\\s,]*([A-Za-z][A-Za-z0-9_\\-]*)\\s*=\\s*")
+
+// One field value, from `s` sitting on its first character. Returns `(value, next)`
+// — Typst has no out-parameters, so every scanner here hands the cursor back rather
+// than mutating one.
+//
+// A braced value is found by jumping between brace positions rather than walking
+// its characters one at a time: `s.matches(_BRACES)` is a single native pass over
+// the whole value, and depth-counting then loops over BRACES — usually two or
+// four — instead of over every character the value contains. This is what lets a
+// 16,000-character `abstract` parse at all: a per-character `while` loop is capped
+// at 10,000 iterations by Typst itself.
+//
+// BRACES ARE DROPPED, ALL OF THEM, not just the outer pair. In BibTeX an interior
+// brace protects capitalization from the style rather than saying anything about
+// the text, so `{{An}} Essay` is the words `An Essay`.
+#let _value(s) = {
+ if s.starts-with("{") {
+ let depth = 0
+ let end = none
+ for m in s.matches(_BRACES) {
+ if m.text == "{" { depth += 1 } else {
+ depth -= 1
+ if depth == 0 { end = m.start; break }
+ }
+ }
+ if end == none { return (s.slice(1).replace("{", "").replace("}", ""), s.len()) }
+ (s.slice(1, end).replace("{", "").replace("}", ""), end + 1)
+ } else if s.starts-with("\"") {
+ let q = s.slice(1).position("\"")
+ if q == none { return (s.slice(1), s.len()) }
+ (s.slice(1, q + 1), q + 2)
+ } else {
+ // A bare value — `year = 2002`, `month = jan` — ends at the field separator.
+ let e = s.position(regex("[,}]"))
+ if e == none { return (s, s.len()) }
+ (s.slice(0, e), e)
+ }
+}
+
+// One `@type{key, ..}` chunk, as `bib-chunks` below hands it out, parsed into
+// `(key, fields)` — `field: value`, field names lowercased, values squashed. `none`
+// if `chunk` doesn't even start with a recognizable entry head.
+//
+// `"entry-type"` sits under a key a real BibTeX field name can never carry — a
+// field name cannot contain a hyphen — so it can't collide with a field the entry
+// actually has.
+#let parse-entry(chunk) = {
+ let m = chunk.match(_HEAD)
+ if m == none { return none }
+ let fields = ("entry-type": lower(m.captures.at(0)))
+ let rest = chunk.slice(m.end)
+ while true {
+ let fm = rest.match(_FIELD)
+ if fm == none { break }
+ let after = rest.slice(fm.end)
+ let (value, next) = _value(after)
+ fields.insert(lower(fm.captures.at(0)), _squash(value))
+ rest = after.slice(next)
+ }
+ (m.captures.at(1).trim(), fields)
+}
+
+// `key -> that entry's own source text`, one native `str.split` over the whole
+// file rather than a per-character scan — this is where nearly all of the
+// speedup over a character-at-a-time reader comes from, since every entry then
+// gets its own small chunk to parse instead of sharing one array with an element
+// per character in the file.
+//
+// Splitting on `"\n@"` costs nothing measurable (it's native Rust) at the price of
+// one known gap: a braced value containing a line that itself starts with `@`
+// would be cut in the wrong place. BibTeX exports don't wrap values that way (a
+// wrapped value is indented), so this is an acceptable trade.
+#let bib-chunks(src) = {
+ let out = (:)
+ // The leading `"\n"` makes the file's OWN first entry break on the same `\n@`
+ // as every other one, so it isn't handed to `parse-entry` with a doubled `@`.
+ for chunk in ("\n" + src).split("\n@") {
+ let c = chunk.position(",")
+ if c == none { continue }
+ let head = chunk.slice(0, c)
+ let b = head.position("{")
+ if b == none { continue }
+ out.insert(head.slice(b + 1).trim(), "@" + chunk)
+ }
+ out
+}
+
+// `key -> (field: value)`, field names lowercased. Splits the file into entries
+// first (`bib-chunks`) and parses each one on its own (`parse-entry`) rather than
+// scanning the whole file through one shared array of its characters.
+#let parse-bib(src) = {
+ let out = (:)
+ for (key, chunk) in bib-chunks(src) {
+ let e = parse-entry(chunk)
+ if e != none { out.insert(e.at(0), e.at(1)) }
+ }
+ out
+}
diff --git a/bibtex/0.1.0/src/view.typ b/bibtex/0.1.0/src/view.typ
new file mode 100644
index 00000000..b9750417
--- /dev/null
+++ b/bibtex/0.1.0/src/view.typ
@@ -0,0 +1,84 @@
+// Rendering a parsed BibTeX entry's fields as an HTML definition list.
+
+// The order a reader wants: who, what, where it appeared, then the numbers,
+// then the handles. Any field NOT listed here still renders — it is appended
+// after these, sorted, so an unusual BibTeX field is shown rather than lost.
+#let _ORDER = (
+ "entry-type", "author", "editor", "translator", "title", "shorttitle",
+ "booktitle", "journal", "series", "publisher", "address", "edition",
+ "volume", "number", "pages", "year", "month", "doi", "url", "urldate",
+ "isbn", "issn", "keywords", "note", "abstract",
+)
+
+// Field names whose capitalized form reads badly.
+#let _TERMS = (
+ "entry-type": "Type",
+ "shorttitle": "Short title",
+ "booktitle": "Book title",
+ "urldate": "Accessed",
+ "doi": "DOI",
+ "url": "URL",
+ "isbn": "ISBN",
+ "issn": "ISSN",
+)
+
+// `_ORDER` filtered to the fields `entry` carries, with anything `show-fields`
+// names `false` removed. A key `show-fields` does not mention stays — the
+// dictionary is a list of exceptions, not a whitelist — and `true` is the same
+// as absent, so a project can flip a field back on without deleting the line.
+#let _visible-order(entry, show-fields) = {
+ let order = _ORDER.filter(k => k in entry) + entry.keys().filter(k => k not in _ORDER).sorted()
+ order.filter(k => show-fields.at(k, default: true))
+}
+
+// Every field an entry carries, as a labelled HTML definition list — none skipped,
+// so a BibTeX field this module has never heard of still surfaces rather than being
+// silently dropped. `show-fields` hides some of them: a dictionary mapping a field
+// name to `false` removes it from the list. `"entry-type"` is a valid key here too,
+// though it names no real BibTeX field — it is the parser's own synthesized key
+// behind the `Type` row, hideable the same as any other.
+//
+// THE LABEL IS PART OF THE BLOCK, so one call gets a page the whole footer and the
+// stylesheet can size the label against the rows beneath it. A `
` rather than a
+// heading: the block sits under a note's own prose and must claim no place in the
+// page's outline above it. `label: none` omits it, for a page that heads the block
+// itself.
+//
+// A `show-fields` that hides every field the entry carries returns nothing at
+// all — not the label, not an empty `
` — the same reasoning `@rookery/core`
+// applies to an empty references block: a heading over an empty table is worse
+// than no block.
+#let fields-block(entry, label: "Citation", show-fields: (:)) = {
+ assert(
+ type(show-fields) == dictionary,
+ message: "@rookery/bibtex: `show-fields` must be a dictionary, got " + repr(show-fields),
+ )
+ for (k, v) in show-fields {
+ assert(
+ type(v) == bool,
+ message: "@rookery/bibtex: `show-fields` value for \"" + k + "\" must be a boolean, got "
+ + repr(v),
+ )
+ }
+ let order = _visible-order(entry, show-fields)
+ if order.len() == 0 { return }
+ let term = k => _TERMS.at(k, default: upper(k.first()) + k.slice(1))
+ let value = (k, v) => {
+ if k == "doi" {
+ link("https://doi.org/" + v, v)
+ } else if k == "url" {
+ link(v, v)
+ } else {
+ v
+ }
+ }
+ if label != none {
+ html.elem("div", attrs: (class: "citation-fields-head"), label)
+ }
+ html.elem("dl", attrs: (class: "citation-fields"), {
+ for k in order {
+ html.elem("dt", term(k))
+ html.elem("dd", value(k, entry.at(k)))
+ }
+ })
+}
diff --git a/bibtex/0.1.0/test/check.sh b/bibtex/0.1.0/test/check.sh
new file mode 100755
index 00000000..389c49ee
--- /dev/null
+++ b/bibtex/0.1.0/test/check.sh
@@ -0,0 +1,178 @@
+#!/usr/bin/env bash
+# Asserts on `test/sweep.typ`'s rendered OUTPUT, not merely that it compiled.
+# `units.typ` covers `bibtex(..)`'s pure logic; this covers what `all()`
+# actually registers — one note per bibliography key not already claimed by a
+# hand-written `#citation`, each keyed by its BibTeX key rather than by the
+# unnamed-note counter.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+S=test/build/sweep.html
+fail=0
+note() { echo "FAIL: $*"; fail=1; }
+
+[ -f "$S" ] || { echo "FAIL: no $S — run 'just test' first"; exit 1; }
+
+python3 - "$S" <<'PY' || fail=1
+import re, sys
+h = open(sys.argv[1]).read()
+
+def txt(s):
+ return " ".join(re.sub(r"<[^>]+>", " ", s).split())
+
+# Each row is a `
` holding one ``
+# and one `` — separate elements rather than a single
+# text-joined string, so the empty-body row cannot be mangled by HTML's own
+# whitespace collapsing. One row per note `ideas()` found registered — a
+# faithful count of the registry, not of what the fixture merely asked to mint.
+rows = re.findall(r'
(.*?)
', h, re.S)
+if len(rows) != 2:
+ print(f"FAIL: expected 2 registered notes, found {len(rows)}: {rows}"); sys.exit(1)
+
+def cell(row, cls):
+ m = re.search(rf'(.*?)', row, re.S)
+ return txt(m.group(1)) if m else None
+
+pairs = [(cell(r, "sweep-id"), cell(r, "sweep-body")) for r in rows]
+ids = sorted(p[0] for p in pairs)
+if ids != ["idea:badiou2002", "idea:smith2020"]:
+ print(f"FAIL: registered ids are {ids}, wanted idea:badiou2002 and idea:smith2020")
+ sys.exit(1)
+
+# NEITHER named `1`: the whole defect this design exists to avoid is `all()`
+# reading a single-argument mint as an unnamed note, landing it on the
+# sequence counter as `idea:1` instead of under its own key.
+if any(p[0] == "idea:1" for p in pairs):
+ print(f"FAIL: a note minted as the unnamed counter's `idea:1`: {rows}"); sys.exit(1)
+
+by_id = dict(pairs)
+if by_id["idea:badiou2002"] != "A hand-written body.":
+ print(f"FAIL: the hand-written citation's body is {by_id['idea:badiou2002']!r}, "
+ f"wanted 'A hand-written body.'")
+ sys.exit(1)
+if by_id["idea:smith2020"] != "":
+ print(f"FAIL: the swept note's body is {by_id['idea:smith2020']!r}, wanted empty")
+ sys.exit(1)
+
+print(f" all(): 2 notes registered — {rows}")
+PY
+
+FL=test/build/fields.html
+[ -f "$FL" ] || { echo "FAIL: no $FL — run 'just test' first"; exit 1; }
+
+# `fields.typ` hides `doi` and `urldate` via the factory's own `show-fields:` —
+# their `
` rows must be entirely absent, and a field the entry carries but
+# the dictionary doesn't name (`author`) must still be there.
+python3 - "$FL" <<'PY' || fail=1
+import re, sys
+h = open(sys.argv[1]).read()
+
+if "
DOI
" in h:
+ print("FAIL: show-fields hid \"doi\" but
DOI
is still rendered")
+ sys.exit(1)
+if "
Accessed
" in h:
+ print("FAIL: show-fields hid \"urldate\" but
Accessed
is still rendered")
+ sys.exit(1)
+if "
Author
" not in h:
+ print("FAIL: show-fields did not name \"author\" but its
Author
is missing")
+ sys.exit(1)
+print(" show-fields: doi and urldate absent, author present")
+PY
+
+EX=test/build/sweep-existing.html
+AL=test/build/sweep-all.html
+[ -f "$EX" ] || { echo "FAIL: no $EX — run 'just test' first"; exit 1; }
+[ -f "$AL" ] || { echo "FAIL: no $AL — run 'just test' first"; exit 1; }
+
+# `keywords: "existing"` keeps a keyword only where it already matches a tag
+# elsewhere in the rookery (`liminal`, seeded by the hand-written `seed`
+# note); `keywords: "all"` keeps every keyword regardless. Both fixtures mint
+# the same three bibliography entries, so the two `kw-row` scans below are
+# directly comparable — the only thing that differs is which keywords
+# survive.
+python3 - "$EX" "$AL" <<'PY' || fail=1
+import re, sys
+
+def txt(s):
+ return " ".join(re.sub(r"<[^>]+>", " ", s).split())
+
+def rows(path):
+ h = open(path).read()
+ out = {}
+ for row in re.findall(r'
(.*?)
', h, re.S):
+ m_id = re.search(r'(.*?)', row, re.S)
+ m_tags = re.search(r'(.*?)', row, re.S)
+ out[txt(m_id.group(1))] = txt(m_tags.group(1)) if m_tags else ""
+ return out
+
+existing = rows(sys.argv[1])
+all_mode = rows(sys.argv[2])
+
+want_existing = {
+ "idea:aaa": "citation,liminal",
+ "idea:bbb": "citation",
+ "idea:ccc": "citation",
+ "idea:seed": "liminal",
+}
+want_all = {
+ "idea:aaa": "brandnew,citation,liminal",
+ "idea:bbb": "brandnew,citation",
+ "idea:ccc": "citation,digital-humanities",
+ "idea:seed": "liminal",
+}
+
+ok = True
+if existing != want_existing:
+ print(f"FAIL: keywords=\"existing\" tags are {existing}, wanted {want_existing}")
+ ok = False
+if all_mode != want_all:
+ print(f"FAIL: keywords=\"all\" tags are {all_mode}, wanted {want_all}")
+ ok = False
+
+def fmt(d):
+ ids = ("idea:aaa", "idea:bbb", "idea:ccc", "idea:seed")
+ return " | ".join(f"{k.split(':')[1]}={d[k].replace(',', '+')}" for k in ids)
+
+print(f' existing: {fmt(existing)}')
+print(f' all: {fmt(all_mode)}')
+
+if not ok: sys.exit(1)
+PY
+
+# Every `class="..."` a note carries is `idea`, `idea-box`, `idea-tag` (the
+# permalink pill's own shape hook) or `idea-tag-`, where `` is
+# what `keyword-tags` produces (lowercase, hyphen-separated). A raw
+# multi-word keyword that skipped slugifying would split into a bogus
+# `idea-tag-` token PLUS a stray bare word carrying no `idea-tag-`
+# prefix at all — a broken two-class attribute, invisible in a passing
+# compile. Scanned across every rendered fixture, not just the keyword ones,
+# so this also guards `sweep.html` and any future one.
+python3 - test/build/*.html <<'PY' || fail=1
+import re, sys
+
+allowed = re.compile(r'^(idea|idea-box|idea-tag|idea-tag-[a-z0-9]+(-[a-z0-9]+)*)$')
+bad = []
+for path in sys.argv[1:]:
+ h = open(path).read()
+ for m in re.finditer(r'class="([^"]*)"', h):
+ tokens = m.group(1).split()
+ # Only an attribute that is ALREADY one of a note's own class lists —
+ # `class="idea idea-tag-.."`, `class="idea-box idea-tag-.."`,
+ # `class="idea-tag idea-tag-.."` (a permalink pill) — is in scope; an
+ # unrelated attribute (this fixture's own `kw-row` spans, say) never
+ # carries an `idea`/`idea-tag`/`idea-tag-` token at all.
+ if not any(t in ("idea", "idea-box", "idea-tag") or t.startswith("idea-tag-") for t in tokens):
+ continue
+ for token in tokens:
+ if not allowed.match(token):
+ bad.append((path, m.group(1), token))
+
+if bad:
+ for path, cls, token in bad:
+ print(f'FAIL: {path}: stray class token {token!r} in class="{cls}" — '
+ f'a keyword with a space that was not slugified splits an '
+ f'`idea-tag-` class exactly like this')
+ sys.exit(1)
+print(" no stray idea-tag- class tokens")
+PY
+
+if [ "$fail" -eq 0 ]; then echo "sweep OK"; else echo "sweep FAILED"; exit 1; fi
diff --git a/bibtex/0.1.0/test/fields.typ b/bibtex/0.1.0/test/fields.typ
new file mode 100644
index 00000000..3fecc77d
--- /dev/null
+++ b/bibtex/0.1.0/test/fields.typ
@@ -0,0 +1,11 @@
+// The RENDERED half of the `show-fields:` fixture — asserts on the actual
+// markup a hidden field disappears from, not merely on the filter behind it
+// (`units.typ` covers that in isolation). The factory hides `doi` and
+// `urldate`; `refs.fields(..)` takes no override, so what renders here is the
+// factory default reaching `fields-block` through `lib.typ`'s threading.
+#import "/src/lib.typ": bibtex
+
+#let BIB = "@article{hidden2020,\n title = {A Paper},\n author = {Jane Smith},\n year = {2020},\n doi = {10.5555/x},\n urldate = {2024-01-01},\n}\n"
+#let refs = bibtex(BIB, show-fields: ("doi": false, "urldate": false))
+
+#(refs.fields)("hidden2020")
diff --git a/bibtex/0.1.0/test/large.typ b/bibtex/0.1.0/test/large.typ
new file mode 100644
index 00000000..a4dac9b8
--- /dev/null
+++ b/bibtex/0.1.0/test/large.typ
@@ -0,0 +1,32 @@
+// Large-corpus fixture for @rookery/bibtex. Run with `just test` from
+// `bibtex/0.1.0`. Same shape as `test/units.typ`: no runner, a failing
+// `assert` fails the compile with a line number.
+
+#import "/src/lib.typ": *
+
+// A field longer than Typst's 10,000-iteration `while` ceiling. A parser
+// that steps character by character cannot read this at all.
+#let LONG = "lorem " * 2000
+#let ONE = "@book{k,\n title = {A Book},\n abstract = {" + LONG + "},\n}\n"
+#assert.eq(parse-bib(ONE).at("k").title, "A Book")
+#assert.eq(parse-bib(ONE).at("k").abstract, LONG.trim())
+#assert.eq(parse-bib(ONE).at("k").abstract.len(), 11999)
+
+// A corpus, so the cost of the file is exercised rather than the cost of
+// one entry: 400 entries, each with an abstract of its own.
+//
+// The outer parens are load-bearing: a `#let` binding's right-hand side
+// stops parsing at the end of its first line, so a method chain that wraps
+// across lines (`.map(..)` / `.join(..)` each on their own line) silently
+// binds `MANY` to the un-joined array unless the whole expression is inside
+// one `(..)` that keeps the parser in code mode across the newlines.
+#let MANY = (
+ range(400)
+ .map(i => "@article{k" + str(i) + ",\n title = {Paper " + str(i) + "},\n"
+ + " abstract = {" + ("filler " * 200) + "},\n}\n")
+ .join("\n")
+)
+#let parsed = parse-bib(MANY)
+#assert.eq(parsed.len(), 400)
+#assert.eq(parsed.at("k399").title, "Paper 399")
+#assert.eq(parsed.keys().filter(k => "abstract" in parsed.at(k)).len(), 400)
diff --git a/bibtex/0.1.0/test/sweep-all.typ b/bibtex/0.1.0/test/sweep-all.typ
new file mode 100644
index 00000000..b5db3fa6
--- /dev/null
+++ b/bibtex/0.1.0/test/sweep-all.typ
@@ -0,0 +1,30 @@
+// The RENDERED half of the `keywords: "all"` fixture — the same entries as
+// `sweep-existing.typ`, so the two fixtures are directly comparable, but
+// every keyword becomes a tag whether or not the rookery already has it.
+// `aaa` picks up both `liminal` and `brandnew`, `bbb` picks up `brandnew`,
+// and `ccc`'s `Digital Humanities` becomes the single tag
+// `digital-humanities` — proving the slug, not the raw space-carrying
+// keyword, is what lands in the class list (see `check.sh`'s scan for a
+// stray class token).
+#import "@rookery/core:0.1.0": rookery, idea, ideas
+#import "/src/lib.typ": bibtex
+
+#let BIB = "@book{aaa,\n title = {A},\n keywords = {liminal, brandnew},\n}\n\n@book{bbb,\n title = {B},\n keywords = {brandnew},\n}\n\n@book{ccc,\n title = {C},\n keywords = {Digital Humanities},\n}\n"
+#let refs = bibtex(BIB, keywords: "all")
+
+#show: rookery
+
+#idea("seed", tags: "liminal")[A hand-written note.]
+// `aaa` claimed by hand, exercising `citation`'s plain (non-context) path —
+// `"all"` mode needs no registry read, so it never wraps this call.
+#(refs.citation)("aaa")[]
+#(refs.all)()
+
+#context {
+ for i in ideas() {
+ html.elem("div", attrs: (class: "kw-row"), {
+ html.elem("span", attrs: (class: "kw-id"), i.id)
+ html.elem("span", attrs: (class: "kw-tags"), i.tags.sorted().join(","))
+ })
+ }
+}
diff --git a/bibtex/0.1.0/test/sweep-existing.typ b/bibtex/0.1.0/test/sweep-existing.typ
new file mode 100644
index 00000000..93764025
--- /dev/null
+++ b/bibtex/0.1.0/test/sweep-existing.typ
@@ -0,0 +1,36 @@
+// The RENDERED half of the `keywords: "existing"` fixture. `seed` is a
+// hand-written note carrying the tag `liminal`, written before the sweep
+// runs; `aaa`'s keywords are `liminal, brandnew` (the existing tag plus an
+// unused one) and `bbb`'s is `brandnew` alone. `ccc`'s keywords, `Digital
+// Humanities`, slugify to `digital-humanities` — a tag nothing else in this
+// rookery carries, so it too is dropped, but it is here to prove the
+// slug — not the raw, space-carrying keyword — is what gets tested against
+// the known-tag set.
+//
+// `units.typ` covers `keyword-tags` and the slug itself; this covers what
+// `"existing"` mode actually keeps once mixed with a real registry read.
+#import "@rookery/core:0.1.0": rookery, idea, ideas
+#import "/src/lib.typ": bibtex
+
+#let BIB = "@book{aaa,\n title = {A},\n keywords = {liminal, brandnew},\n}\n\n@book{bbb,\n title = {B},\n keywords = {brandnew},\n}\n\n@book{ccc,\n title = {C},\n keywords = {Digital Humanities},\n}\n"
+#let refs = bibtex(BIB, keywords: "existing")
+
+#show: rookery
+
+#idea("seed", tags: "liminal")[A hand-written note.]
+// `aaa` claimed by hand — exercising `citation`'s own context-wrapped path
+// for `"existing"` mode, not only `all()`'s, which already runs inside one.
+#(refs.citation)("aaa")[]
+#(refs.all)()
+
+// One `
` per registered note, id and sorted tag list in
+// their own spans — `check.sh` greps these to confirm which keywords
+// survived the existing-tag filter.
+#context {
+ for i in ideas() {
+ html.elem("div", attrs: (class: "kw-row"), {
+ html.elem("span", attrs: (class: "kw-id"), i.id)
+ html.elem("span", attrs: (class: "kw-tags"), i.tags.sorted().join(","))
+ })
+ }
+}
diff --git a/bibtex/0.1.0/test/sweep.typ b/bibtex/0.1.0/test/sweep.typ
new file mode 100644
index 00000000..e285f56f
--- /dev/null
+++ b/bibtex/0.1.0/test/sweep.typ
@@ -0,0 +1,38 @@
+// The RENDERED half of the `all()` fixture — the sweep that mints a note for
+// every bibliography key a hand-written `#citation` has not already claimed.
+// `units.typ` covers `bibtex(..)`'s shape; this covers what `all()` actually
+// registers, which needs `#show: rookery` and a real note registry to read
+// back — a page without it renders no note chrome at all.
+//
+// Two entries: `badiou2002` is claimed by hand, with an authored body;
+// `smith2020` is left for `all()`, which mints it with the empty body a swept
+// note always gets (see `lib.typ`'s comment on why that body is load-bearing).
+#import "@rookery/core:0.1.0": rookery, ideas
+#import "/src/lib.typ": bibtex
+
+#let BIB = "@book{badiou2002,\n title = {Ethics},\n}\n\n@article{smith2020,\n title = {A Paper},\n}\n"
+#let refs = bibtex(BIB)
+
+#show: rookery
+
+// Parenthesized field access, not `#refs.citation(..)`: Typst 0.15.1 refuses
+// to call a dictionary VALUE with method syntax (`cannot directly call
+// dictionary keys as functions`) — `bibtex(..)` returns a plain dictionary of
+// functions, not an object with methods, so every call here needs the
+// `(refs.field)(..)` form.
+#(refs.citation)("badiou2002")[A hand-written body.]
+#(refs.all)()
+
+// One `
` per registered note, id and body in their own
+// spans (rather than joined with a text separator, which HTML's whitespace
+// collapsing would mangle for the empty-body row) — for `check.sh` to grep.
+// `ideas()` reads the registry itself, so this is a faithful count of what
+// actually registered, not merely of what this file asked to mint.
+#context {
+ for i in ideas() {
+ html.elem("div", attrs: (class: "sweep-row"), {
+ html.elem("span", attrs: (class: "sweep-id"), i.id)
+ html.elem("span", attrs: (class: "sweep-body"), i.body)
+ })
+ }
+}
diff --git a/bibtex/0.1.0/test/units.typ b/bibtex/0.1.0/test/units.typ
new file mode 100644
index 00000000..661f6cbd
--- /dev/null
+++ b/bibtex/0.1.0/test/units.typ
@@ -0,0 +1,149 @@
+// Unit fixture for @rookery/bibtex. Run with `just test` from
+// `bibtex/0.1.0`. There is no runner and no JS: an `assert` that fails
+// fails the compile with a line number, and a passing compile is the green
+// light — the same shape `@rookery/core` and `@rookery/timeline` use.
+
+#import "/src/lib.typ": *
+
+// ---- parse-bib — a two-entry fixture yields both keys ----------------------
+#let TWO = "@book{badiou2002,\n title = {Ethics},\n}\n\n@article{smith2020,\n title = {A Paper},\n}\n"
+#assert.eq(parse-bib(TWO).keys().sorted(), ("badiou2002", "smith2020"))
+
+// The entry type is recorded under its own key, which a real BibTeX field name
+// can never carry — a field name cannot contain a hyphen — so it can't collide
+// with a field the entry actually has.
+#assert.eq(parse-bib(TWO).at("badiou2002").entry-type, "book")
+
+// Interior braces are dropped entirely, not just the outer pair: in BibTeX an
+// interior brace protects capitalization from the citation style rather than
+// saying anything about the text.
+#assert.eq(parse-bib("@misc{k, title = {{An} Essay},}").at("k").title, "An Essay")
+
+// A value split across source lines squashes to single spaces — the
+// indentation belongs to the file, not to the title.
+#assert.eq(
+ parse-bib("@misc{k, title = {A\n Multi-Line\n Title},}").at("k").title,
+ "A Multi-Line Title",
+)
+
+// A quoted value and a bare value both parse.
+#assert.eq(parse-bib("@misc{k, note = \"quoted\",}").at("k").note, "quoted")
+#assert.eq(parse-bib("@misc{k, year = 2002,}").at("k").year, "2002")
+
+// ---- cite-key — a string, a label, or a ref, all read back the key --------
+#assert.eq(cite-key("badiou2002"), "badiou2002")
+#assert.eq(cite-key(), "badiou2002")
+#assert.eq(cite-key(ref()), "badiou2002")
+
+// ---- bib-title — the note title an entry derives ---------------------------
+//
+// `bib-title` returns CONTENT, not a string, so comparing it needs the exact
+// same markup shape it builds rather than a hand-assembled equivalent —
+// Typst's content equality is structural, and `[Badiou, ]` + `emph(..)` does
+// not equal one sequence built as `[#by, ]#emph(..)`. `_expect` mirrors
+// `bib-title`'s own construction so the comparison is meaningful rather than
+// weakened to a type check.
+#let _expect(by, work, year) = [#if by != none [#by, ]#emph(work)#if year != none [ (#year)]]
+
+// `shorttitle` wins over `title` when both are present.
+#assert.eq(
+ bib-title((shorttitle: "Ethics", title: "Ethics: An Essay", author: "Alain Badiou", year: "2002")),
+ _expect("Badiou", "Ethics", "2002"),
+)
+
+// Three or more authors cut to the first surname plus `et al.`.
+#assert.eq(
+ bib-title((title: "A Paper", author: "Jane Smith and John Doe and Alex Lee")),
+ _expect("Smith et al.", "A Paper", none),
+)
+
+// Neither `author` nor `editor`: the title stands with no byline at all.
+#assert.eq(bib-title((title: "No Byline Here")), _expect(none, "No Byline Here", none))
+
+// No title at all: `none`, rookery's own "this note has no authored title".
+#assert.eq(bib-title((author: "Someone")), none)
+
+// ---- bibtex(..) — the factory's own shape -----------------------------------
+//
+// `all` is a field on the returned dictionary, and a function — the sweep a
+// project calls once. What it MINTS is document-level behaviour and needs the
+// rendered fixture in `test/sweep.typ`; this only checks the shape.
+#assert.eq(type(bibtex(TWO).all), function)
+
+// `only:` keeps just the named keys, parsing the source down to them rather
+// than filtering the parsed result.
+#assert.eq(bibtex(TWO, only: ("smith2020",)).bib.keys(), ("smith2020",))
+#assert.eq(bibtex(TWO, only: ()).bib.len(), 0)
+
+// A key `only` names that the fixture doesn't carry is dropped silently, not
+// an error — `entry(key)` is where a missing key raises, not here.
+#assert.eq(bibtex(TWO, only: ("nosuchkey",)).bib.len(), 0)
+
+// `auto`, the default, parses the whole file exactly as `bibtex` behaved
+// before `only:` existed.
+#assert.eq(bibtex(TWO).bib.keys().sorted(), ("badiou2002", "smith2020"))
+
+// `all()` mints in `bib.keys().sorted()` order, alphabetical rather than
+// insertion order — a fixture whose keys are already alphabetical (like
+// `TWO` above) cannot tell the two apart, hence a fixture entered in reverse.
+#let UNSORTED = "@book{zeta, title = {Z},}\n\n@book{alpha, title = {A},}\n"
+#assert.eq(bibtex(UNSORTED).bib.keys().sorted(), ("alpha", "zeta"))
+
+// A bad `keywords:` value is rejected, naming the three accepted ones —
+// exercised by reading `bibtex(..)`'s own assert message rather than by a
+// negative test case: Typst has no way to catch a panic, so a fixture cannot
+// assert one without aborting the whole compile.
+
+// ---- _visible-order — fields-block's show-fields filter --------------------
+//
+// `fields-block` returns content, so the filter that decides which fields
+// appear is tested directly rather than through rendered markup — `check.sh`
+// covers the markup itself, on a fixture that actually renders.
+#let _ENTRY = (entry-type: "book", title: "Ethics", author: "Alain Badiou", doi: "10.1/x")
+
+// Omitted entirely: every field shows, in `_ORDER`'s sequence.
+#assert.eq(_visible-order(_ENTRY, (:)), ("entry-type", "author", "title", "doi"))
+
+// A partial dictionary hides only what it names `false`; a field it doesn't
+// mention stays.
+#assert.eq(_visible-order(_ENTRY, ("doi": false)), ("entry-type", "author", "title"))
+
+// `true` is the same as absent: shown.
+#assert.eq(
+ _visible-order(_ENTRY, ("doi": true)),
+ ("entry-type", "author", "title", "doi"),
+)
+
+// An unknown key changes nothing — `show-fields` is validated on its values,
+// not its keys, so a misspelt one silently hides nothing.
+#assert.eq(
+ _visible-order(_ENTRY, ("isbn": false)),
+ ("entry-type", "author", "title", "doi"),
+)
+
+// Hiding every field the entry carries yields an empty order — `fields-block`
+// returns early on this rather than emitting a label over nothing.
+#assert.eq(
+ _visible-order(_ENTRY, ("entry-type": false, "title": false, "author": false, "doi": false)),
+ (),
+)
+
+// ---- keyword-tags — a BibTeX `keywords` field as rookery tag slugs --------
+//
+// Better BibTeX emits either separator depending on export settings, so both
+// are accepted; each part is trimmed, lowercased, and every run of
+// non-alphanumeric characters collapses to one hyphen with the ends
+// stripped.
+#assert.eq(keyword-tags("ethics, ontology, badiou"), ("ethics", "ontology", "badiou"))
+#assert.eq(keyword-tags("ethics; ontology; badiou"), ("ethics", "ontology", "badiou"))
+
+// A keyword with a space would otherwise break its CSS class — slugifying
+// collapses it to one hyphen. Mixed case folds too.
+#assert.eq(keyword-tags("Digital Humanities"), ("digital-humanities",))
+#assert.eq(keyword-tags("ETHICS, Ontology"), ("ethics", "ontology"))
+
+// Punctuation-only keyword slugifies to the empty string and is dropped.
+#assert.eq(keyword-tags("ethics, !!!, badiou"), ("ethics", "badiou"))
+
+// No `keywords` field at all: an empty array, not an error.
+#assert.eq(keyword-tags(none), ())
diff --git a/bibtex/0.1.0/typst.toml b/bibtex/0.1.0/typst.toml
new file mode 100644
index 00000000..64872923
--- /dev/null
+++ b/bibtex/0.1.0/typst.toml
@@ -0,0 +1,24 @@
+[package]
+name = "bibtex"
+version = "0.1.0"
+compiler = "0.15.0"
+entrypoint = "src/lib.typ"
+authors = ["The Free Computing Lab "]
+license = "MIT"
+description = "A BibTeX reader and a #citation note constructor for @rookery/core notes"
+repository = "https://github.com/freecomputinglab/rookery"
+
+# The floor @rookery/core declares, matched rather than guessed: this package mints
+# notes through core's `idea` and is only ever built inside a project already
+# on a rheo that can resolve the `@rookery` namespace and a ref-fetched package by
+# its resolved commit — both of which need 0.6.2.
+#
+# `min_version` MUST BE DECLARED BEFORE THE SUBTABLE BELOW IT: a format-specific
+# subtable placed above it would capture that key into itself instead.
+[tool.rheo]
+min_version = "0.6.2"
+
+# The stylesheet for the citation block `#fields-block` draws — a label and one row
+# per bibliographic field, sized against whatever type the page already sets.
+[tool.rheo.html]
+css_stylesheet = "src/bibtex.css"
diff --git a/cfps/0.1.0/.gitignore b/cfps/0.1.0/.gitignore
new file mode 100644
index 00000000..bc2f3e53
--- /dev/null
+++ b/cfps/0.1.0/.gitignore
@@ -0,0 +1,2 @@
+dist/
+test/build/
diff --git a/cfps/0.1.0/Justfile b/cfps/0.1.0/Justfile
new file mode 100644
index 00000000..d4d52863
--- /dev/null
+++ b/cfps/0.1.0/Justfile
@@ -0,0 +1,19 @@
+default:
+ @echo "@rookery/cfps: pure Typst package, entrypoint is src/lib.typ directly — nothing to build"
+
+# Two fixtures. `test/units.typ` asserts every VALUE the package's constructors
+# derive, and `test/view.typ` plus `test/check.sh` assert the MARKUP and the
+# order of a cfp's own blocks — the same split @rookery/meetings' Justfile uses.
+#
+# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS
+# package. `--features html` for parity with this repo's other Justfiles.
+#
+# `mkdir` first: `typst compile` does not create its output directory and
+# fails with "No such file or directory" on a fresh checkout, since
+# `test/build/` is gitignored and never committed.
+test:
+ mkdir -p test/build
+ typst compile --features html --root . --format html test/units.typ test/build/units.html
+ @echo "units OK"
+ typst compile --features html --root . --format html test/view.typ test/build/view.html
+ ./test/check.sh
diff --git a/cfps/0.1.0/readme.md b/cfps/0.1.0/readme.md
new file mode 100644
index 00000000..bf6550cb
--- /dev/null
+++ b/cfps/0.1.0/readme.md
@@ -0,0 +1,252 @@
+# @rookery/cfps
+
+A venue and its calls for [`@rookery/core`](../../core/0.1.0): a durable place
+things are heard from, and one round of it — a deadline, a portal, and what
+happened when it was answered.
+
+```typst
+#import "@rookery/core:0.1.0": rookery
+#import "@rookery/cfps:0.1.0": cfps
+#show: rookery
+
+#let TODAY = datetime(year: 2026, month: 6, day: 1)
+#let (venue, cfp, cfp-state, panel) = cfps(kinds: (
+ postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))),
+))
+
+#venue("acme", title: [Acme University])[A programme that runs every year.]
+
+#cfp(
+ "acme-postdoc-26",
+ venue: ,
+ kind: "postdoc",
+ deadline: datetime(year: 2026, month: 1, day: 1),
+ today: TODAY,
+)[A round that lapsed with nothing sent.]
+
+#cfp(
+ "acme-postdoc-25",
+ venue: ,
+ kind: "postdoc",
+ deadline: datetime(year: 2025, month: 1, day: 1),
+ timeline: (submitted: datetime(year: 2024, month: 12, day: 1), offered: datetime(year: 2025, month: 2, day: 1)),
+ today: TODAY,
+)[A round that was answered, and settled.]
+
+#panel(state: "settled", today: TODAY) // -> lists only "acme-postdoc-25"
+```
+
+`#venue`'s `title:` defaults to `auto`, which titles the venue by its own
+id — an authored `title:`, as in the example above, overrides it.
+
+## A call and its answer are one note
+
+A CFP is one round of a venue — a call for papers or applications, together
+with whatever came back. The two are never two different attempts at two
+different things; they are one attempt looked at before and after, so
+keeping them as two separate notes would mean two places a deadline or a
+decision could disagree, and a backlink graph where "what became of this" is
+an edge to walk rather than a fact already on the note. `#cfp` folds the two
+into one note instead, and its own log — the ordinary dated history
+`@rookery/timeline` already gives every note — is where the whole story,
+wire to verdict, actually lives.
+
+A VENUE is the other half, and it is deliberately thin: a durable place a call
+recurs from — a programme, a conference series, a journal — carrying no dates
+and closing nothing. Two rounds of one programme are two `#cfp`s sharing one
+`#venue`.
+
+## `cfps(kinds:)` — the factory
+
+`kind`/`ladder` are not this package's vocabulary, so `cfps(..)` is a
+factory: bind it to a caller's own once, and it hands back the four names
+that vocabulary makes possible.
+
+```typst
+#let (venue, cfp, cfp-state, panel) = cfps(kinds: (
+ job: (
+ sort: "job",
+ ladder: (transit: ("submitted", "interview"), terminal: ("offered", "rejected", "dropped")),
+ ),
+ journal: (
+ sort: "journal",
+ ladder: (transit: ("submitted", "review-*"), terminal: ("accepted", "desk-rejected")),
+ ),
+))
+```
+
+`kinds:` is a dictionary of kind name to `(sort:, ladder:)`, where `ladder:`
+is an ordinary `@rookery/timeline` ladder — `transit:` and `terminal:` arrays
+of stage names, a trailing `-*` naming a FAMILY of stages (`review-*` matches
+`review-1`, `review-2`, ..., see that package's own `ladder.typ`). A bad
+`kinds:` fails at this call, not on whichever `#cfp` happens to name a bad
+kind first, and `#cfp`'s own `kind:` argument is checked against it the same
+way:
+
+```typst
+#cfp("x", kind: "grant", ..) // grant is not job or journal
+```
+
+```
+@rookery/cfps: #cfp's `kind` must be one of ("job", "journal") — got "grant".
+```
+
+A worked round-trip, minting both halves and narrowing `panel:` to what has
+actually settled:
+
+```typst
+#let (venue, cfp, cfp-state, panel) = cfps(kinds: (
+ postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))),
+))
+
+#let TODAY = datetime(year: 2026, month: 6, day: 1)
+#venue("eth", title: [ETH Zürich])[A school.]
+
+#cfp("eth-postdoc-26", venue: , kind: "postdoc", deadline: datetime(year: 2026, month: 3, day: 1), today: TODAY)[
+ Sent, nothing back yet.
+]
+#cfp(
+ "eth-postdoc-25",
+ venue: ,
+ kind: "postdoc",
+ deadline: datetime(year: 2025, month: 3, day: 1),
+ timeline: (submitted: datetime(year: 2025, month: 1, day: 1), offered: datetime(year: 2025, month: 4, day: 1)),
+ today: TODAY,
+)[Answered, and settled.]
+
+#panel(state: "settled", today: TODAY)
+```
+
+The panel above lists exactly `eth-postdoc-25` — the still-open `26` round
+is filtered out by `state: "settled"`.
+
+## Why `kind`/`ladder` are caller-supplied
+
+No package can know that `offered` ends a job application while `accepted`
+ends a conference submission and is the middle of a journal's review. That
+vocabulary is the caller's, project by project, the same reason
+[`@rookery/timeline`'s own `ladder.typ`](../../timeline/0.1.0/src/ladder.typ)
+takes a ladder as a parameter rather than owning one — see that file's header
+for the argument in full; this package just inherits it, one level up.
+
+## `#cfp` IS a `#todo`
+
+`#cfp` is built on [`@rookery/todos`](../../todos/0.1.0)' own `todo(..)`, not
+a bare tagged note, and that buys a reader two things for free. A cfp shows
+up in `#todo-table` with no separate wiring — an open call is
+work outstanding, the same as any other todo — and it closes CORRECTLY the
+moment it is answered or its deadline lapses: `#cfp` computes the real close
+date itself (the earliest real answer, or the deadline if it lapsed
+unanswered) and passes it as `todo(..)`'s `done:`, a real dated log entry
+rather than a flag. `is-closed`, `#todo-table`, a consumer's own worklists —
+everything reading that log agrees, because there is only the one log to
+read. `priority:` is `@rookery/todos`' own as well, unchanged: a project
+already running that package's worklists gets its cfps sorted into them
+automatically, at whatever priority they were given.
+
+## Integrating `#window`
+
+A site that wraps `@rookery/todos`' `#window` with its own hiding logic —
+transcluding a cfp but hiding it when, say, the real answer was a rejection —
+has one thing to get right: that wrapper must pass `closed: true` through on
+whichever branch decides to SHOW the note. `@rookery/todos`' own `#window`
+independently hides any closed todo unless told otherwise, and every `#cfp`
+now closes on either a real answer or a lapsed deadline — so a site's own
+hiding decision will otherwise be silently overridden by that second, unrelated
+check the moment the deadline passes. This is not a bug in either package; it
+is what composing two independent "should this be visible" rules does, and
+it is worth knowing before wrapping `#window` around a `#cfp` for the first
+time rather than rediscovering it on a live site.
+
+## `panel:` — the rounds table
+
+`panel(state:, tags:, countdown:, today:)` draws one row per call — `when |
+title | school | verdict` — in date order, bound to the same `kinds`/ladder
+`cfp` was:
+
+```typst
+#panel(state: ("open", "in-flight"), countdown: true, today: TODAY)
+```
+
+`state:` narrows by the same four states `cfp-state` derives (below);
+`tags:` narrows further by whatever grouping the caller's own site uses (a
+cycle, a kind not already covered by `state:`); `countdown:` washes the date
+cell by how many days remain, the same three-week band `@rookery/todos`'
+`#todo-table` reads. On a paged target the same rows draw as a plain list —
+there is no grid to align there.
+
+### Every `today:` is explicit
+
+Every function here that needs a reference date takes it as `today:`. There is
+no fallback to the document's own date — a call that omits it panics, naming
+the problem.
+
+## The four states, and why they are net of the reserved stages
+
+`cfp-state(tags, ladder:, today:)` returns one of `"watching"` (nothing
+announced at all), `"open"` (a wire is out, unanswered, not yet lapsed),
+`"in-flight"` (a real answer is in and it is a transit rung), or `"settled"`
+(a real answer is in and it is a terminal rung). All four are read off the
+note's log with `deadline`/`scheduled`/`closed` stripped first
+(`real-stage-of` does the stripping; `cfp-state` is the four-state
+reading on top of it) — because a raw, unfiltered log answers a different
+question than the one a caller is actually asking, in two ways that bit the
+site this package was ported from:
+
+- **A lapsed, unanswered call is `"open"`, not `"in-flight"`.** Read raw, an
+ overdue `deadline` is the LATEST reached entry, and nothing in it looks
+ like a transit rung — so a naive reading calls it in flight when nothing
+ was ever sent.
+- **A real answer dated before the deadline still counts.** A call dropped
+ early — its `timeline:` reaching a terminal stage before the deadline
+ itself arrives — reads raw as still "deadline", the later of the two dates,
+ masking the actual answer.
+
+## What it owns
+
+- `venue`/`venue-`, flat tags marking a note as a venue, and one per
+ call kind it hosts.
+- `venue-call` (a venue's own standing submissions page) and `venue-school`
+ (an array of host-institution names, for a joint programme).
+- `cfp`, the flat marker `panel:` filters rows on — stamped directly in
+ `#cfp`'s own tags rather than through an `idea(..)` call, since
+ `#cfp` mints through `@rookery/todos`' `todo(..)`, which already claims the
+ `todo` tag for itself.
+- `cfp-venue` (the venue's name, valued, optional), `cfp-id` (the call's own
+ name, so a tag-only filter can identify its row), `submission-apply` (this
+ round's own portal, distinct from a venue's standing `venue-call`),
+ `submission-work` (the matched application's own path or URL), and
+ `submission-estimated` (a flat marker for a body carried over from a past
+ cycle's call rather than read off this round's own).
+
+## Requirements
+
+- Typst 0.15.0 or later (`typst.toml`'s `compiler` floor).
+- rheo 0.6.2 or later (`min_version`).
+- [`@rookery/core:0.1.0`](../../core/0.1.0),
+ [`@rookery/timeline:0.1.0`](../../timeline/0.1.0) and
+ [`@rookery/todos:0.1.0`](../../todos/0.1.0). All three are hard imports —
+ `#cfp` is a skin on `@rookery/todos`' own `todo(..)`, which is itself a
+ skin on `@rookery/timeline`'s dated notes.
+
+## Development
+
+```sh
+cd cfps/0.1.0
+just test
+```
+
+Two fixtures, no build step: `test/units.typ` asserts every value the
+package's constructors derive — the four-state ladder logic, the tags `#cfp`
+stamps, that closing is a real log entry rather than a flag — and
+`test/view.typ` plus `test/check.sh` assert the rendered markup: that the rail
+draws from `deadline:` alone as readily as from a full `timeline:`, the
+opportunity table's position relative to it, the venue backlink, and
+`panel:`'s row classes. `typst.toml`'s `entrypoint` points straight at
+`src/lib.typ`, so `src/` is what ships and an edit takes effect immediately.
+
+## Future work
+
+No `demo/` project ships with this package yet — a worked `rheo compile`
+target, the way `@rookery/todos`' demo exercises all six of its views, would
+be a reasonable thing to add for the next round of work on it.
diff --git a/cfps/0.1.0/src/cfp.typ b/cfps/0.1.0/src/cfp.typ
new file mode 100644
index 00000000..a58308bc
--- /dev/null
+++ b/cfps/0.1.0/src/cfp.typ
@@ -0,0 +1,368 @@
+// @rookery/cfps — a venue and its calls: a durable place things are heard from,
+// and one round of it, with a deadline, a portal, and what happened when it was
+// answered.
+//
+// A VENUE is what recurs — a programme, a conference series, a journal. A CFP is
+// one round of it: a call for papers/applications, folded together with its own
+// answer, so one note carries both what was sent and what came back. Two rounds
+// of one programme are two cfps sharing one venue.
+//
+// #let (venue, cfp, cfp-state) = cfps(kinds: (
+// postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))),
+// ))
+// #venue("acme", title: [Acme University])[..]
+// #cfp("acme-postdoc-26", venue: , kind: "postdoc", deadline: d)[..]
+//
+// `kind`/`ladder` ARE CALLER CONFIGURATION, not a vocabulary this package owns.
+// `accepted` ends a conference submission and is the middle of a journal's; a
+// package cannot know that, the same reason @rookery/timeline's own
+// `is-settled`/`rung`/`next-stage` take a ladder as a parameter rather than a
+// constant. So `cfps(kinds:)` is a FACTORY, bound to a caller's vocabulary once,
+// the same shape @rookery/meetings' `meetings(..)` and @rookery/bibtex's
+// `bibtex(..)` already take.
+//
+// `#cfp` IS A SKIN ON @rookery/todos' `#todo`, not a hand-rolled closing flag.
+// It closes through `done:` — a real date folded into the shared
+// @rookery/timeline log — so `is-closed`/the flat `todo-closed` marker/
+// `priority`'s worklist behaviour all come free and correct: `todo(..)` derives
+// its flat marker FROM the same log entry `done:` writes, so the two can never
+// disagree.
+
+#import "@rookery/core:0.1.0": idea, _merge-base-tags, _norm
+#import "@rookery/todos:0.1.0": todo
+#import "@rookery/timeline:0.1.0": (
+ CLOSED-STAGE, DEADLINE-STAGE, SCHEDULED-STAGE, assert-ladder, timeline-tags, is-settled, normalize-tags, stage-matches,
+ stage-of, timeline-of, timeline-view,
+)
+
+// ---- Tag keys ---------------------------------------------------------------
+//
+// Ported from the reference site's own `_lib/template.typ`, minus `CITATION-KEY`
+// — that one belongs to a site's own bibliography, not to a venue/cfp pair.
+
+#let VENUE-KEY = "venue"
+#let VENUE-CALL-KEY = "venue-call"
+// Host institution(s), as idea NAMES — an array, for a joint programme.
+#let SCHOOL-KEY = "venue-school"
+#let CFP-KEY = "cfp"
+// The venue a call comes from, as a venue idea NAME. Optional: a call can be
+// recorded before anything is written about the place it came from.
+#let CFP-VENUE-KEY = "cfp-venue"
+// A call's own name, carried as a valued tag purely so a tag-only filter can
+// identify the row it is looking at.
+#let CFP-ID-KEY = "cfp-id"
+// THIS ROUND's portal, distinct from the venue's `call:` — a venue's `call:` is
+// the standing submissions page; `apply:` is the instance, reissued every cycle.
+#let APPLY-KEY = "submission-apply"
+#let WORK-KEY = "submission-work"
+// Marks a cfp's body as carried over from a past cycle's call rather than read
+// off this round's own.
+#let ESTIMATED-KEY = "submission-estimated"
+
+// ---- The two-column metadata table -------------------------------------------
+//
+// Both `venue`/`cfp` open their body with this — a venue's `call:`, a cfp's
+// `work:` — styled the same as @rookery/meetings' own `.meeting-fields`.
+// `none`-valued rows drop out; an empty table draws nothing.
+#let _opportunity-table(pairs) = {
+ let pairs = pairs.filter(p => p.at(1) != none)
+ if pairs.len() == 0 { return [] }
+ html.elem("dl", attrs: (class: "opportunity-meta"), {
+ for (term, value, how) in pairs {
+ html.elem("dt", term)
+ html.elem("dd", if how == "url" {
+ link(value, value)
+ } else if how == "path" {
+ raw(value)
+ } else {
+ value.replace("-", " ")
+ })
+ }
+ })
+}
+
+// The reference date is always an explicit `today:` argument — Typst has no wall
+// clock to fall back to.
+#let _resolve-today(today) = {
+ assert(
+ type(today) == datetime,
+ message: "@rookery/cfps: #cfp needs a reference date — pass one explicitly, "
+ + "e.g. `today: datetime(year: 2026, month: 8, day: 25)`. Typst has no wall "
+ + "clock, so there is nothing to fall back to. Got " + repr(today),
+ )
+ today
+}
+
+// ---- `kinds:` validation ------------------------------------------------------
+//
+// Eager, at factory-construction time — a bad `kinds:` fails as soon as it is
+// given, not on whichever `#cfp` call happens to hit the bad kind first. What
+// makes a `kind` valid is this package's own contract (`sort:` and `ladder:`
+// present, `sort:` a string); what makes a `ladder:` valid is
+// `@rookery/timeline`'s, so that half is delegated to `assert-ladder`.
+#let _assert-kinds(kinds) = {
+ assert(
+ type(kinds) == dictionary,
+ message: "@rookery/cfps: `kinds:` must be a dictionary of kind name -> "
+ + "(sort: , ladder: (transit: (..), terminal: (..))) — got "
+ + repr(kinds),
+ )
+ for (name, spec) in kinds.pairs() {
+ assert(
+ type(spec) == dictionary and "sort" in spec and "ladder" in spec,
+ message: "@rookery/cfps: kind " + repr(name) + " must be a dictionary with "
+ + "`sort:` (a string) and `ladder:` (a @rookery/timeline ladder) — got "
+ + repr(spec),
+ )
+ assert(
+ type(spec.sort) == str,
+ message: "@rookery/cfps: kind " + repr(name) + "'s `sort:` must be a string — got " + repr(spec.sort),
+ )
+ assert-ladder(spec.ladder)
+ }
+}
+
+// Every stage any configured kind's ladder names, so `#cfp` can reject a typo in
+// `timeline:` at the call site rather than rendering a row that cannot be
+// placed. The shape of the reference's `STAGES`/`SETTLED-STAGES`, folded over
+// `kinds.values()` instead of a hardcoded `LADDERS`.
+#let _stages(kinds) = {
+ let all = ()
+ for (_, spec) in kinds.pairs() { all += spec.ladder.transit + spec.ladder.terminal }
+ all.dedup()
+}
+
+// ---- `venue` ------------------------------------------------------------------
+//
+// A venue is what durably exists: a programme, a conference series, a journal.
+// It carries no dates and is not a todo — the one constructor here `#cfp`'s
+// closing mechanism does not touch.
+#let venue(name, title: auto, call: none, school: none, tags: none, display-tags: true, ..args) = {
+ // `auto` titles a venue by its own id, the same resolution `#cfp` makes
+ // for the venue it names. `#idea` takes content or `none` and has no
+ // `auto`, so this cannot be left for it to sort out.
+ let title = if title != auto { title } else { raw(_norm(name)) }
+ let own = (:)
+ if call != none { own.insert(VENUE-CALL-KEY, call) }
+ if school != none {
+ own.insert(SCHOOL-KEY, if type(school) == array { school } else { (school,) })
+ }
+ let pos = args.pos()
+ let body = if pos.len() == 0 { [] } else { pos.at(0) }
+ let full = {
+ _opportunity-table((("Call", call, "url"),))
+ body
+ }
+ idea(
+ name,
+ title: title,
+ // Merges VENUE-KEY under the built tags, so a caller naming its own
+ // `tags:` cannot displace the package's key.
+ tags: _merge-base-tags(VENUE-KEY, own + normalize-tags(tags)),
+ display-tags: display-tags,
+ ..args.named(),
+ full,
+ )
+}
+
+// ---- Reading what actually happened, net of `deadline`/`scheduled` ----------
+//
+// A cfp's own combined log also carries `deadline`/`scheduled` — the two
+// reserved stage names @rookery/timeline folds into every dated note's log
+// alongside whatever a call's own answer adds. Read raw, a lapsed deadline with
+// nothing sent yet is the LATEST reached entry, so anything asking "what stage
+// is this at" off the raw log risks reading an overdue-but-unanswered call as
+// answered, or reading a real terminal stage dated BEFORE its own deadline (a
+// call dropped early) as still "deadline". So the reserved names are stripped
+// first, and only what is left — an actual application stage — answers either
+// question.
+//
+// `CLOSED-STAGE` IS IN THE EXCLUSION TUPLE FROM THE START, unlike the reference
+// this was ported from: this package's `#cfp` closes through the log itself
+// (see `cfp` below), so its own `closed` entry would otherwise read back as the
+// note's current stage, and every settled cfp would report itself "closed"
+// instead of whatever it actually settled at.
+#let _real-tags(tags) = {
+ let real = (:)
+ for e in timeline-of(tags) {
+ if e.stage not in (DEADLINE-STAGE, SCHEDULED-STAGE, CLOSED-STAGE) { real.insert(e.stage, e.timestamp) }
+ }
+ timeline-tags(timeline: real)
+}
+
+// The stage ACTUALLY reached, net of `deadline`/`scheduled`/`closed`. `none`
+// where nothing real has happened yet, whether because the log is empty or
+// because only its reserved entries have been reached.
+#let real-stage-of(tags, today: none) = stage-of(_real-tags(tags), today: today)
+
+// Four states, all derived from the (stripped) timeline:
+//
+// watching the raw log is empty; nothing announced yet
+// open every real entry is still in the future (or there are none)
+// in-flight the latest real entry that has happened is a transit stage
+// settled that entry is a terminal one
+#let cfp-state(tags, ladder: none, today: none) = {
+ if timeline-of(tags).len() == 0 { return "watching" }
+ let real-tags = _real-tags(tags)
+ if timeline-of(real-tags).len() == 0 { return "open" }
+ let s = stage-of(real-tags, today: today)
+ if s == none { return "open" }
+ if ladder != none and is-settled(real-tags, ladder: ladder, today: today) { return "settled" }
+ "in-flight"
+}
+
+// ---- `cfps(kinds:)` — the factory ---------------------------------------------
+//
+// `kinds`: kind name -> (sort: , ladder: (transit: (..), terminal: (..))).
+// Validated eagerly here, once, rather than on whichever `#cfp` call happens to
+// hit a bad kind first.
+#let cfps(kinds: (:)) = {
+ _assert-kinds(kinds)
+ let stages = _stages(kinds)
+
+ // ---- `cfp` — one call, and what became of it -------------------------------
+ //
+ // Built on @rookery/todos' `todo(..)` rather than on bare `idea(..)`: that
+ // package already forwards `deadline:`/`scheduled:`/`timeline:` to the shared
+ // log exactly as this needs, and adds `done:` — a real closing date, folded
+ // into the log as a `CLOSED-STAGE` entry — and `priority:`, which this
+ // constructor exposes directly rather than banning.
+ //
+ // `deps:`/`metadata:`/`active:`/`status:`/`type:` are deliberately NOT
+ // parameters here: `todo(..)` accepts all of them, but nothing about a cfp
+ // asks for @rookery/todos' dependency graph or its own todo-kind vocabulary.
+ // Left at `todo(..)`'s own defaults.
+ let cfp(
+ name,
+ venue: none,
+ title: auto,
+ kind: none,
+ deadline: none,
+ scheduled: none,
+ apply: none,
+ priority: none,
+ timeline: none,
+ work: none,
+ estimated: false,
+ today: none,
+ tags: none,
+ display-tags: true,
+ ..args,
+ ) = {
+ assert(
+ kind != none,
+ message: "@rookery/cfps: #cfp(" + repr(name) + ") needs a `kind:`, one of "
+ + repr(kinds.keys()) + ". Without one the call has no sort, so no ladder can "
+ + "say which of its answer's stages are final — they would read as in flight "
+ + "forever.",
+ )
+ assert(
+ kind in kinds,
+ message: "@rookery/cfps: #cfp's `kind` must be one of " + repr(kinds.keys())
+ + " — got " + repr(kind) + ".",
+ )
+
+ let log-stages = if timeline == none { (:) } else { timeline }
+ for (stage, _) in log-stages.pairs() {
+ assert(
+ stages.any(p => stage-matches(p, stage)),
+ message: "@rookery/cfps: #cfp(" + repr(name) + ")'s `timeline:` names the stage "
+ + repr(stage) + ", which no configured kind's ladder carries. Add the rung to "
+ + "the right kind's ladder rather than inventing one here: a stage no ladder "
+ + "names renders but cannot be placed.",
+ )
+ }
+
+ // `CFP-KEY` is stamped here rather than arriving from an
+ // `idea(CFP-KEY)` call: this `cfp` mints through @rookery/todos'
+ // `todo(..)`, which tags the note `todo`, so the bare "this is a cfp"
+ // marker every consumer filters on has to be part of `own` itself.
+ let own = ((VENUE-KEY + "-" + kind): none, (CFP-KEY): none)
+ let venue = if venue == none { none } else { _norm(venue) }
+
+ // THE TITLE IS COMPOSED from the venue's id, the way the reference's own
+ // `#cfp` does — minus the `cycle:` half, which is not this package's
+ // business (a caller builds its own cycle-aware wrapper on top of this).
+ let title = if title != auto { title } else if venue == none { none } else { raw(venue) }
+
+ own.insert(CFP-ID-KEY, name)
+ if venue != none { own.insert(CFP-VENUE-KEY, venue) }
+ if apply != none { own.insert(APPLY-KEY, apply) }
+ if work != none { own.insert(WORK-KEY, work) }
+ if estimated { own.insert(ESTIMATED-KEY, none) }
+
+ let ladder = kinds.at(kind).ladder
+ let pos = args.pos()
+ let body = if pos.len() == 0 { [] } else { pos.at(0) }
+
+ // `today:` resolves HERE, inside the one `context` block this note needs —
+ // for `target()`, exactly as the reference's own rail does — rather than a
+ // second one. `close-on` is computed in the same block, since it also needs
+ // a "now" to test `lapsed` against, and the whole note is minted from inside
+ // it so that `done:` carries the resolved value rather than an unresolved
+ // fallback.
+ context {
+ let resolved-today = _resolve-today(today)
+ let t = timeline-tags(deadline: deadline, scheduled: scheduled, timeline: timeline)
+
+ // THE CLOSE DATE, not a bool. The earliest REAL (non-reserved) answer if
+ // one exists — the day applying stopped being outstanding work — else the
+ // deadline itself if that has lapsed with nothing sent, else `none` (still
+ // open work).
+ let lapsed = deadline != none and resolved-today > deadline
+ let real-dates = log-stages
+ .pairs()
+ .map(p => {
+ let v = p.at(1)
+ if type(v) == datetime { v } else { v.at("timestamp", default: none) }
+ })
+ .filter(d => d != none)
+ let close-on = if real-dates.len() > 0 {
+ real-dates.sorted().first()
+ } else if lapsed {
+ deadline
+ } else { none }
+
+ // THE RAIL — the WHOLE combined log, `deadline`/`scheduled` included, in
+ // the order it actually happened, rather than treating the wire as a fact
+ // stated elsewhere. HTML only: `html.elem` means nothing on a paged
+ // target, where `timeline-view` renders its own list.
+ let rail = {
+ if target() == "html" and timeline-of(t).len() > 0 {
+ html.elem("p", attrs: (class: "idea-timeline-head"), "Timeline")
+ }
+ timeline-view((:), t, today: resolved-today, ladder: ladder)
+ }
+
+ // ORDER: the table, then the rail, then the prose, then the venue
+ // backlink — the fixed facts first, the story of what happened next,
+ // then the commentary on both.
+ let full = {
+ _opportunity-table((("Work", work, "path"),))
+ rail
+ parbreak()
+ body
+ if venue != none {
+ parbreak()
+ ref(label("idea:" + venue))
+ }
+ }
+
+ todo(
+ name,
+ title: title,
+ deadline: deadline,
+ scheduled: scheduled,
+ timeline: timeline,
+ done: close-on,
+ priority: priority,
+ tags: own + normalize-tags(tags),
+ display-tags: display-tags,
+ ..args.named(),
+ full,
+ )
+ }
+ }
+
+ (venue: venue, cfp: cfp, cfp-state: cfp-state)
+}
diff --git a/cfps/0.1.0/src/cfps.css b/cfps/0.1.0/src/cfps.css
new file mode 100644
index 00000000..fa6a6768
--- /dev/null
+++ b/cfps/0.1.0/src/cfps.css
@@ -0,0 +1,357 @@
+/* @rookery/cfps — the rounds table `#panel` draws, and the two-column metadata
+ block `#venue`/`#cfp` open with (`_opportunity-table` in `cfp.typ`).
+
+ Structural only, like `@rookery/timeline`'s and `@rookery/todos`' own
+ stylesheets: every colour and every site-shaped size is a custom property
+ with a generic fallback, never a literal borrowed from a particular site's
+ palette — a consuming project sets `--cfps-*` once and this whole panel
+ follows.
+
+ THE LAYER, for the same reason `@rookery/timeline`'s `timeline.css` states
+ it: rheo links a package's stylesheet after the project's own in package
+ RESOLUTION order, not necessarily last, so this file cannot assume it wins a
+ specificity tie. Wrapping it in `@layer cfps` inverts that — any unlayered
+ rule in a project's own CSS beats anything here regardless of specificity.
+
+ THE PROPERTIES:
+
+ --cfps-gutter width of the date column, shared with `.opportunity-meta`
+ --cfps-edge hairline borders (row dividers, the metadata block, the
+ countdown tooltip)
+ --cfps-muted a soft/watch date, the school column, the match line
+ --cfps-fg emphasised text — a firm date, a title link, banded ink
+ --cfps-link a title/school link
+ --cfps-mark the open group's first-row tidemark
+ --cfps-focus the countdown tooltip's focus outline
+ --cfps-band-* the seven countdown wash colours (`overdue` solid, the
+ other six washes), falling back to the family-wide
+ `--rookery-heat-urgent/soon/later` ramp `@rookery/todos`'
+ own bands already read, then to a literal
+ --cfps-rung-* the five priority-only washes, same fallback chain
+
+ `.round-badge`'s own ink and radius fall back to `@rookery/core`'s published
+ `--idea-tag-*` properties instead of a `--cfps-*` one of their own — a badge
+ here is a rookery tag chip, the same object a pill on a note's hat is, so it
+ takes that object's colour rather than inventing a second one. */
+@layer cfps {
+ .round-list {
+ list-style: none;
+ margin: 1rem 0;
+ padding: 0;
+ }
+
+ /* Four tracks — date, title, school, verdict — align down the page: a column
+ of dates that does not line up is a column read one row at a time. */
+ .round-row {
+ display: grid;
+ grid-template-columns: var(--cfps-gutter, 7.5em) 1fr auto auto;
+ gap: 0 0.9rem;
+ align-items: baseline;
+ padding: 0.45rem 0;
+ border-bottom: 1px solid var(--cfps-edge, GrayText);
+ }
+
+ .round-row:last-child {
+ border-bottom: none;
+ }
+
+ /* A row carried over from a past cycle's call rather than read off this
+ round's own dims as a whole, rather than any single column of it — the
+ uncertainty is about the entry, not about one field. */
+ .round-row.idea-tag-submission-estimated {
+ opacity: 0.6;
+ }
+
+ .round-when {
+ font-family: ui-monospace, monospace;
+ font-size: 0.8em;
+ font-variant-numeric: tabular-nums;
+ color: var(--cfps-fg, inherit);
+ white-space: nowrap;
+ }
+
+ /* A watch-date, not a deadline — it shares the date column because it
+ answers the same question ("when does this need me"), so it reads as the
+ softer of the two rather than a date of equal standing. */
+ .round-when.soft {
+ color: var(--cfps-muted, GrayText);
+ font-style: italic;
+ }
+
+ .round-title a {
+ color: var(--cfps-fg, inherit);
+ }
+
+ .round-title a:hover {
+ color: var(--cfps-link, LinkText);
+ }
+
+ /* Between the title and the verdict: left-aligned prose-adjacent text, since
+ a school is part of naming the row rather than a badge on it. */
+ .round-school {
+ font-size: 0.8em;
+ color: var(--cfps-muted, GrayText);
+ white-space: nowrap;
+ }
+
+ .round-school a {
+ color: var(--cfps-muted, GrayText);
+ }
+
+ .round-school a:hover {
+ color: var(--cfps-link, LinkText);
+ }
+
+ /* ONE CHIP, EVERYWHERE: a badge here is a rookery tag chip (`idea-tag` +
+ `idea-tag-`), the same two classes a pill on a note's hat wears —
+ see `@rookery/core`'s own generated `idea-tag-*` rules for the colour a
+ themed name publishes onto them. Outline only: a solid chip in the tag's
+ own hue would put the label on a background of the same colour, unreadable
+ by construction. */
+ .round-badge {
+ font-size: 0.8em;
+ line-height: 1;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ padding: 0.1em 0.5em;
+ border-radius: var(--idea-tag-radius, 999px);
+ white-space: nowrap;
+ color: var(--idea-tag-color, var(--idea-id-color, GrayText));
+ background-color: transparent;
+ border: 1px solid var(--idea-tag-line, var(--cfps-edge, GrayText));
+ }
+
+ /* A transit stage is the qualifier, a terminal one the answer — so a stage's
+ chip loses its outline and reads as a word beside the verdict rather than
+ as a second verdict. */
+ .round-badge-stage {
+ border-color: transparent;
+ padding-left: 0.1em;
+ padding-right: 0.1em;
+ }
+
+ /* THE TIDEMARK lands on the date of the first open row. Open rounds sort
+ oldest first, so that row is either the next thing due or the one already
+ overdue — the one thing on the page worth marking. `:not([data-countdown])`
+ keeps a banded date and a marked one off the same cell: a banded date
+ already carries a stronger signal than "first" does. `Mark`/the system
+ highlight colour, since this is exactly what `` means. */
+ .round-list-open .round-row:first-child .round-when:not([data-countdown]) {
+ color: var(--cfps-fg, inherit);
+ background-color: var(--cfps-mark, Mark);
+ border-radius: 3px;
+ padding: 0 0.3em;
+ margin-left: -0.3em;
+ }
+
+ /* The verdict cell: how far a call got, then what came back. Two spans
+ rather than one string, so a stage can stand alone — reached but not yet
+ answered is a real state this record has to be able to say. */
+ .round-verdict {
+ display: flex;
+ gap: 0.35rem;
+ align-items: baseline;
+ }
+
+ /* The matched application's own path, under the title on its own line — a
+ filename, not prose, so it does not compete with the title for the grid's
+ date-aligned column. */
+ .round-match {
+ grid-column: 2 / -1;
+ font-size: 0.8em;
+ color: var(--cfps-muted, GrayText);
+ }
+
+ /* THE COUNTDOWN IS THE DATE CELL'S OWN BACKGROUND, not a fifth span: the row
+ is a four-track grid with no track to auto-place a fifth thing into, and a
+ dropped span silently doubled the row's height. The colour is the reading
+ at a glance; the phrase below is a tooltip for whoever wants the number. */
+ .round-when[data-countdown] {
+ position: relative;
+ color: var(--cfps-fg, inherit);
+ border-radius: 3px;
+ padding: 0 0.3em;
+ margin-left: -0.3em;
+ cursor: help;
+ }
+
+ /* THREE WEEKS, seven steps, two per hue past the overdue one — a deadline is
+ felt in weeks, so this week reads red, next week orange, the one after
+ yellow, each darkening toward its front. `--cfps-band-` is the
+ per-band override; under it, the family-wide `--rookery-heat-*` ramp
+ `@rookery/todos` already reads, so a site setting that ramp once themes
+ every heat chip in the family, this one included. */
+ .round-when-overdue {
+ background-color: var(--cfps-band-overdue, var(--rookery-heat-urgent, #b3261e));
+ color: var(--cfps-band-overdue-fg, #fff);
+ }
+
+ .round-when-imminent {
+ background-color: var(
+ --cfps-band-imminent,
+ color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 40%, transparent)
+ );
+ }
+
+ .round-when-urgent {
+ background-color: var(
+ --cfps-band-urgent,
+ color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 26%, transparent)
+ );
+ }
+
+ .round-when-soon {
+ background-color: var(
+ --cfps-band-soon,
+ color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 32%, transparent)
+ );
+ }
+
+ .round-when-near {
+ background-color: var(
+ --cfps-band-near,
+ color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 18%, transparent)
+ );
+ }
+
+ .round-when-approaching {
+ background-color: var(
+ --cfps-band-approaching,
+ color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 28%, transparent)
+ );
+ }
+
+ .round-when-distant {
+ background-color: var(
+ --cfps-band-distant,
+ color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 15%, transparent)
+ );
+ }
+
+ /* THE PRIORITY RAMP, only where the countdown has nothing to say — a call
+ three weeks or more out never earns a band above, so a high-priority one
+ sitting far out would otherwise read exactly like a low-priority one
+ beside it. Five rungs, the two coolest falling back to a neutral wash
+ rather than a fourth hue, since nothing above claims one. */
+ .round-when-rung-0 {
+ background-color: var(
+ --cfps-rung-0,
+ color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 24%, transparent)
+ );
+ }
+
+ .round-when-rung-1 {
+ background-color: var(
+ --cfps-rung-1,
+ color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 20%, transparent)
+ );
+ }
+
+ .round-when-rung-2 {
+ background-color: var(
+ --cfps-rung-2,
+ color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 18%, transparent)
+ );
+ }
+
+ .round-when-rung-3 {
+ background-color: var(--cfps-rung-3, color-mix(in oklab, currentColor 12%, transparent));
+ }
+
+ .round-when-rung-4 {
+ background-color: var(--cfps-rung-4, color-mix(in oklab, currentColor 6%, transparent));
+ }
+
+ /* A banded date is not soft-grey: sitting on a wash it has to hold its own
+ ink against it, so the colour comes back. The italic stays — that half of
+ the distinction still reads. */
+ .round-when.soft[data-countdown] {
+ color: var(--cfps-fg, inherit);
+ }
+
+ /* THE PHRASE, on demand — drawn rather than handed to `title:`, since a
+ native tooltip cannot be styled, opens on a delay, and would appear beside
+ this one rather than instead of it. `:focus` (not `:focus-visible`) is
+ what makes it reachable on a touch screen, where a tap is the only hover
+ there is. Above the cell, so it never covers the row beneath. */
+ .round-when[data-countdown]:hover::after,
+ .round-when[data-countdown]:focus::after {
+ content: attr(data-countdown);
+ position: absolute;
+ bottom: calc(100% + 0.35rem);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ padding: 0.15em 0.5em;
+ border: 1px solid var(--cfps-edge, GrayText);
+ border-radius: 3px;
+ background-color: var(--cfps-tooltip-bg, Canvas);
+ color: var(--cfps-fg, CanvasText);
+ font-size: 0.8em;
+ font-style: normal;
+ line-height: 1.4;
+ white-space: nowrap;
+ pointer-events: none;
+ }
+
+ .round-when[data-countdown]:focus {
+ outline: 1px solid var(--cfps-focus, Highlight);
+ outline-offset: 1px;
+ }
+
+ .round-empty {
+ color: var(--cfps-muted, GrayText);
+ font-style: italic;
+ }
+
+ /* `#cfp`'s own two-column metadata table (`_opportunity-table` in
+ `cfp.typ`) — this class is not published by any rookery package, `#cfp`
+ being the one thing minting it, so the grid itself lives here. Shares
+ `--cfps-gutter` with `.round-row` above, so a reader who has just read a
+ rounds table sees the same column line up on the note it links to. */
+ .opportunity-meta {
+ display: grid;
+ grid-template-columns: var(--cfps-gutter, 7.5em) 1fr;
+ column-gap: 0.9rem;
+ margin: 0 0 1.2rem;
+ border-top: 1px solid var(--cfps-edge, GrayText);
+ }
+
+ .opportunity-meta dt,
+ .opportunity-meta dd {
+ padding: 0.4rem 0;
+ border-bottom: 1px solid var(--cfps-edge, GrayText);
+ }
+
+ .opportunity-meta dt {
+ color: var(--cfps-muted, GrayText);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ font-size: 0.85em;
+ }
+
+ /* `margin: 0` is load-bearing, not tidy: a browser's default `
` carries
+ `margin-inline-start: 40px`, which in a grid cell indents the value away
+ from its own column. */
+ .opportunity-meta dd {
+ margin: 0;
+ }
+
+ /* The date column stops being a column below 768px: a fixed gutter beside a
+ title leaves the title wrapping at two words. */
+ @media (max-width: 768px) {
+ .round-row {
+ grid-template-columns: 1fr auto;
+ }
+
+ .round-when,
+ .round-school,
+ .round-match {
+ grid-column: 1 / -1;
+ }
+
+ .round-when {
+ justify-self: start;
+ }
+ }
+}
diff --git a/cfps/0.1.0/src/lib.typ b/cfps/0.1.0/src/lib.typ
new file mode 100644
index 00000000..6b2acf92
--- /dev/null
+++ b/cfps/0.1.0/src/lib.typ
@@ -0,0 +1,14 @@
+#import "cfp.typ": *
+#import "cfp.typ" as _cfp
+#import "panel.typ": *
+
+// `cfps(kinds:)` STILL RESOLVES TO ONE FACTORY, not two. `cfp.typ`'s own
+// `cfps(kinds:)` cannot build `panel:` itself: doing so needs `panel.typ`,
+// which imports `cfp.typ` for its constants and `real-stage-of`/`cfp-state` —
+// a package cycle either way round. So this module SHADOWS the star-imported
+// `cfps` with one that calls straight through to `cfp.typ`'s own (reached here
+// via the aliased import, since the shadow below cannot see itself) and merges
+// in `panel:` — the same "a later top-level `#let` shadows a star-imported
+// name" device `@rookery/timeline`'s own `lib.typ` uses to decorate
+// `idea`.
+#let cfps(kinds: (:)) = (.._cfp.cfps(kinds: kinds), panel: _make-panel(kinds))
diff --git a/cfps/0.1.0/src/panel.typ b/cfps/0.1.0/src/panel.typ
new file mode 100644
index 00000000..c071af08
--- /dev/null
+++ b/cfps/0.1.0/src/panel.typ
@@ -0,0 +1,293 @@
+// @rookery/cfps — the rounds table: one row per call, `when | title | school |
+// verdict`, in date order.
+//
+// Ported from the reference site's own `#cfps` panel, minus the vocabulary that
+// belongs to that SITE rather than to a venue/cfp pair: no `sort:` (the site's own
+// job/conference/journal split), no `cycle:` (a caller's own tagging convention,
+// reached instead through `tags:` below), and no `HIDDEN-STAGES` filtering (that
+// belongs to `@rookery/todos`' `#window`, a different package).
+//
+// `CFP-KEY` IS THE ROW FILTER — the bare marker `#cfp` stamps on every note it
+// mints (`cfp.typ`'s `own`). It cannot come from an `idea(CFP-KEY)` call, since
+// a cfp mints through @rookery/todos' `todo(..)`, so the two halves of this
+// package meet on that one key.
+
+#import "cfp.typ": *
+#import "@rookery/core:0.1.0": ideas
+#import "@rookery/timeline:0.1.0": deadline-of, scheduled-of
+#import "@rookery/todos:0.1.0": priority-of, priority-rung
+
+// The reference date is always an explicit `today:` argument (a private copy of
+// `cfp.typ`'s own `_resolve-today` — Typst has no wall clock, so there is nothing
+// to fall back to).
+#let _resolve-today(today) = {
+ assert(
+ type(today) == datetime,
+ message: "@rookery/cfps: #panel needs a reference date — pass one explicitly, "
+ + "e.g. `today: datetime(year: 2026, month: 8, day: 25)`. Got " + repr(today),
+ )
+ today
+}
+
+// `tags:` narrows a panel to whatever grouping the CALLER's site wants (a cycle, a
+// kind not already filtered by `state:`, anything else its own tagging convention
+// names) — `none`, one tag name, or an array of them, the same three forms
+// `#ideas`' own `tags:` accepts.
+#let _as-tag-array(tags) = {
+ if tags == none {
+ ()
+ } else if type(tags) == str {
+ (tags,)
+ } else if type(tags) == array {
+ tags
+ } else {
+ panic("@rookery/cfps: panel's `tags:` must be none, a string, or an array of tag names — got " + repr(tags))
+ }
+}
+
+// THE NEXT THING DUE on a note's own log, whatever rung it sits on — the date a
+// round row shows once something has answered its call. A call's own deadline is
+// the useful date right up until a submission exists; after that it is what the
+// attempt itself still waits on (a revision, a resubmission, a booked interview),
+// which is why this asks the whole log for its next unpassed entry rather than
+// naming one stage. `none` where nothing is still ahead — a settled attempt has
+// nothing outstanding, which is the honest reading.
+#let _next-open-date(tags, today: none) = {
+ if today == none { return none }
+ let ahead = timeline-of(tags).filter(e => e.timestamp > today)
+ if ahead.len() == 0 { none } else { ahead.first().timestamp }
+}
+
+#let _fmt-date(d) = d.display("[day padding:none] [month repr:short] [year]")
+
+// A venue's title/href and its schools' labels, by venue NAME — one pass over
+// `ideas(values: true)`, cached in a dictionary, rather than a lookup per row.
+// The join this package owns is cfp -> venue -> schools; kind/sort/dates stay on
+// the cfp's own row, exactly as the ontology already splits them.
+#let _venue-index(all) = {
+ let by-name = all.map(r => (r.name, r)).to-dict()
+ let school-of(name) = {
+ let s = by-name.at(name, default: none)
+ if s == none { none } else { (label: s.label, href: s.href) }
+ }
+ name => {
+ let v = by-name.at(name, default: none)
+ if v == none {
+ (title: none, href: none, schools: ())
+ } else {
+ (
+ title: v.label,
+ href: v.href,
+ schools: v.tags-dict.at(SCHOOL-KEY, default: ()).map(school-of).filter(x => x != none),
+ )
+ }
+ }
+}
+
+// The `panel:` method `cfps(kinds:)` returns — bound to the SAME `kinds`/ladder
+// configuration the factory's `cfp` resolves against, so a row's ladder never
+// disagrees with the note it was minted with.
+#let _make-panel(kinds) = {
+ let panel(tags: none, state: "open", countdown: false, empty: [Nothing here.], today: none) = context {
+ let resolved-today = _resolve-today(today)
+ let want-state = if type(state) == array { state } else { (state,) }
+ let want = (CFP-KEY,) + _as-tag-array(tags)
+
+ let all = ideas(values: true)
+ let venue-of = _venue-index(all)
+
+ let words(s) = s.replace("-", " ")
+
+ let rows = ideas(tagged: want, match: "all", values: true)
+ .map(r => {
+ let t = r.tags-dict
+ let kind = kinds.keys().find(k => (VENUE-KEY + "-" + k) in t)
+ let ladder = if kind == none { none } else { kinds.at(kind).ladder }
+ let dl = deadline-of(t)
+ let sch = scheduled-of(t)
+ // WHAT HAS ACTUALLY BEEN ANSWERED, net of the reserved stage names — see
+ // `real-stage-of`'s own comment in `cfp.typ` for why the raw log cannot
+ // answer this.
+ let rs = real-stage-of(t, today: resolved-today)
+ let answered = rs != none
+ let nxt = _next-open-date(t, today: resolved-today)
+ let st = cfp-state(t, ladder: ladder, today: resolved-today)
+ let venue-name = t.at(CFP-VENUE-KEY, default: none)
+ let v = if venue-name == none { (title: none, href: none, schools: ()) } else { venue-of(venue-name) }
+ (
+ label: r.label,
+ target: r.href,
+ schools: v.schools,
+ kind: kind,
+ stage: rs,
+ // The call's own priority — how much it matters, unchanged by what has
+ // been sent.
+ priority: priority-of(t),
+ // Unanswered: the call's own wire. Answered: only what is still
+ // outstanding.
+ when: if answered { nxt } else if dl != none { dl } else { sch },
+ firm: if answered { nxt != none } else { dl != none },
+ state: st,
+ settled-stage: if st == "settled" { rs } else { none },
+ transit-stage: if st == "in-flight" { rs } else { none },
+ tags-dict: t,
+ work: t.at(WORK-KEY, default: none),
+ )
+ })
+ .filter(r => want-state.contains(r.state))
+
+ // A plain string sort in date order, undated rows falling to the end.
+ let dated = rows.filter(r => r.when != none)
+ let undated = rows.filter(r => r.when == none)
+ let rows = dated.sorted(key: r => r.when) + undated
+
+ // The rung ramp is relative to what's actually on THIS page: the distinct
+ // non-zero priorities among these rows, largest (most important) first.
+ let scale = rows.map(r => r.priority).filter(p => p > 0).dedup().sorted().rev()
+
+ // PAGED: no anchor to click and no grid to align, so the rows become a plain
+ // Typst list.
+ if target() != "html" {
+ if rows.len() == 0 { return text(gray, emph(empty)) }
+ return list(
+ ..rows.map(r => {
+ let d = r.when
+ if d != none { [#_fmt-date(d)#if not r.firm { [ (expected)] } — ] }
+ r.label
+ if r.schools.len() > 0 { [ #text(gray, "[" + r.schools.map(s => s.label).join(", ") + "]")] }
+ let parts = (r.kind, r.stage).filter(s => s != none).map(words)
+ if parts.len() > 0 { [ #text(gray, "(" + parts.join(", ") + ")")] }
+ if r.work != none { [ #text(gray, "→ " + r.work)] }
+ }),
+ )
+ }
+
+ if rows.len() == 0 {
+ return html.elem("p", attrs: (class: "round-empty"), empty)
+ }
+
+ // The state rides on the list because one rule needs it: the first row's date
+ // is lit only in the open group, where the top row is either next or already
+ // overdue.
+ let list-cls = (("round-list",) + want-state.map(s => "round-list-" + s)).join(" ")
+ html.elem(
+ "ul",
+ attrs: (class: list-cls),
+ rows
+ .map(r => html.elem(
+ "li",
+ attrs: (
+ // `submission-estimated` is what dims a row, read straight off the
+ // call's own tags. The kind rides on the CHIP rather than the row,
+ // because a themed tag's colour is a custom property and a custom
+ // property inherits — with the kind's class on the row, a rejected
+ // chip would come out tenure-track orange.
+ class: (("round-row",) + r.tags-dict.keys().map(k => "idea-tag-" + k)).join(" "),
+ ),
+ {
+ let d = r.when
+ // THE COUNTDOWN IS THE DATE CELL, not a fifth thing beside it — see
+ // `cfps.css`'s own comment on `.round-when[data-countdown]` for why.
+ // Three weeks, seven steps, two per hue past the overdue one.
+ let band = if not countdown or d == none { none } else {
+ let days = (d - resolved-today).days()
+ if days < 0 { "overdue" } else if days <= 3 { "imminent" } else if days <= 7 { "urgent" } else if (
+ days <= 11
+ ) { "soon" } else if days <= 14 { "near" } else if days <= 18 { "approaching" } else if days <= 21 {
+ "distant"
+ } else { none }
+ }
+ let phrase = if band == none { none } else {
+ let days = (d - resolved-today).days()
+ if days == -1 { "yesterday" } else if days < 0 { str(-days) + " days ago" } else if days == 0 {
+ "today"
+ } else if days == 1 { "tomorrow" } else { "in " + str(days) + " days" }
+ }
+ // PRIORITY, only where the countdown has nothing to say — a call
+ // three weeks or more out never earns a band above, so this closes
+ // that gap on the same date cell rather than a second one.
+ let rung = priority-rung(r.priority, scale, rungs: 5)
+ let pri-band = if band != none or d == none or rung == none { none } else {
+ "rung-" + str(rung)
+ }
+ let phrase = if phrase != none { phrase } else if pri-band == none { none } else {
+ "priority " + str(r.priority)
+ }
+ let when-cls = (
+ ("round-when",)
+ + (if r.firm { () } else { ("soft",) })
+ + (
+ if band != none { ("round-when-" + band,) } else if pri-band != none {
+ ("round-when-" + pri-band,)
+ } else { () }
+ )
+ ).join(" ")
+ let when-attrs = if phrase == none { (:) } else {
+ (
+ "data-countdown": phrase,
+ "aria-label": _fmt-date(d) + ", " + phrase,
+ "tabindex": "0",
+ )
+ }
+ html.elem(
+ "span",
+ attrs: (class: when-cls) + when-attrs,
+ if d == none { [—] } else {
+ html.elem("time", attrs: (datetime: d.display("[year]-[month]-[day]")), _fmt-date(d))
+ },
+ )
+ html.elem("span", attrs: (class: "round-title"), {
+ if r.target == none { r.label } else {
+ html.elem("a", attrs: (href: r.target), r.label)
+ }
+ })
+ // Its own column: a school is a fact a reader scans down for, not a
+ // clause folded into the title.
+ html.elem(
+ "span",
+ attrs: (class: "round-school"),
+ r
+ .schools
+ .map(s => if s.href == none { s.label } else { html.elem("a", attrs: (href: s.href), s.label) })
+ .join([ \/ ]),
+ )
+ // The verdict cell: a stage and an outcome are one vocabulary split
+ // by the ladder — the transit rung is the qualifier, the terminal one
+ // the answer. Each badge is a rookery tag chip (`idea-tag` +
+ // `idea-tag-`), so a themed name colours itself the same way
+ // any other pill on a note's hat does.
+ if r.kind != none or r.stage != none {
+ html.elem("span", attrs: (class: "round-verdict"), {
+ if r.kind != none {
+ html.elem(
+ "span",
+ attrs: (class: "round-badge idea-tag idea-tag-" + VENUE-KEY + "-" + r.kind),
+ r.kind,
+ )
+ }
+ if r.transit-stage != none {
+ html.elem(
+ "span",
+ attrs: (class: "round-badge round-badge-stage idea-tag idea-tag-" + r.transit-stage),
+ words(r.transit-stage),
+ )
+ }
+ if r.settled-stage != none {
+ html.elem(
+ "span",
+ attrs: (class: "round-badge round-badge-outcome idea-tag idea-tag-" + r.settled-stage),
+ words(r.settled-stage),
+ )
+ }
+ })
+ }
+ if r.work != none {
+ html.elem("span", attrs: (class: "round-match"), raw(r.work))
+ }
+ },
+ ))
+ .join(),
+ )
+ }
+ panel
+}
diff --git a/cfps/0.1.0/test/check.sh b/cfps/0.1.0/test/check.sh
new file mode 100755
index 00000000..b87f30c4
--- /dev/null
+++ b/cfps/0.1.0/test/check.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# Asserts on the rendered fixture's OUTPUT, not merely that it compiled.
+# `units.typ` covers every value; this covers the markup — that a rail draws
+# from `deadline:` alone, that the opportunity table sits between the title and
+# the rail, that a venue backlink is present only where `venue:` was given, and
+# that `panel:`'s rows carry the right classes for settled/open/watching — plus
+# one thing neither fixture can check: that a bad `kind:` fails LOUDLY, which
+# needs its own `typst compile` this script expects to fail.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+H=test/build/view.html
+[ -f "$H" ] || { echo "FAIL: no $H — run 'just test' first"; exit 1; }
+
+python3 - "$H" <<'PY'
+import re, sys
+h = open(sys.argv[1]).read()
+fail = 0
+def note(msg):
+ global fail
+ print("FAIL:", msg); fail = 1
+
+# One card per note, sliced on the note's own anchor id.
+def card(name):
+ i = h.find('id="idea:%s"' % name)
+ if i < 0:
+ note("no card for %s" % name); return ""
+ j = h.find('', i)
+ return h[i:j]
+
+deadline_only = card("acme-deadline-only")
+settled = card("acme-settled")
+watching = card("no-venue-watching")
+
+# 1. THE RAIL DRAWS FROM `deadline:` ALONE — no `timeline:` was given here, and
+# the rail is the whole point of a call whose only fact so far is its wire.
+if '' not in deadline_only:
+ note("deadline-only: no rail drawn from deadline: alone")
+if "idea-timeline-head" not in deadline_only:
+ note("deadline-only: no 'Timeline' head above the rail")
+
+# 2. ORDER: opportunity table, then the rail, then the prose, then the venue
+# backlink — `acme-settled` is the one fixture carrying `work:`, so the
+# table actually renders and its position can be checked.
+o_dl = settled.find('class="opportunity-meta"')
+o_rail = settled.find('class="idea-timeline-head"')
+o_body = settled.find("Answered and settled")
+o_ref = settled.find('class="idea-ref"')
+if not (0 < o_dl < o_rail < o_body < o_ref):
+ note("settled: blocks out of order (table %d, rail head %d, body %d, ref %d)"
+ % (o_dl, o_rail, o_body, o_ref))
+if "
Work
" not in settled:
+ note("settled: opportunity table missing its Work row")
+
+# 3. A VENUE BACKLINK appears only where `venue:` was given.
+if "Acme University" not in deadline_only or "Acme University" not in settled:
+ note("a cfp naming a venue drew no backlink to it")
+if "Acme University" in watching or "idea-ref" in watching:
+ note("a cfp naming no venue drew a backlink anyway")
+
+# 4. `panel:`'s rows carry the right classes for settled / open / watching.
+def round_list(state):
+ m = re.search(r'
(.*?)
' % state, h, re.S)
+ return m.group(1) if m else None
+
+settled_row = round_list("settled")
+if settled_row is None:
+ note("no round-list-settled panel rendered")
+elif "round-badge-outcome" not in settled_row or "idea-tag-offered" not in settled_row:
+ note("settled row: no terminal-stage outcome badge")
+
+open_row = round_list("open")
+if open_row is None:
+ note("no round-list-open panel rendered")
+elif "round-when soft" in open_row or "