From 8ac44c05f9d6d1913ccbf2254861c6238baf77aa Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 22 Sep 2026 15:23:08 +0200 Subject: [PATCH 01/69] Cuts core 0.1.1 from 0.1.0 0.1.0 is released, so its directory is immutable and stays in the tree; 0.1.1 is where core is authored from here. Every version this repo writes out by hand follows the cut, including the paths `.github/workflows/check.yml` tests and the `lib.typ` reference in CLAUDE.md, which `check-versions` cannot see. --- .github/workflows/check.yml | 24 +- CLAUDE.md | 2 +- core/0.1.1/.gitignore | 3 + core/0.1.1/.marrow.typ | 755 +++++++ core/0.1.1/Justfile | 26 + core/0.1.1/demo/pure/Justfile | 118 ++ core/0.1.1/demo/pure/card-gap.typ | 37 + core/0.1.1/demo/pure/display-parity.typ | 27 + core/0.1.1/demo/pure/excluded.typ | 70 + core/0.1.1/demo/pure/folded-height.typ | 36 + core/0.1.1/demo/pure/idea-key-prefix.typ | 12 + core/0.1.1/demo/pure/idea-key.typ | 18 + core/0.1.1/demo/pure/naming.typ | 44 + core/0.1.1/demo/pure/root-prefix.typ | 30 + core/0.1.1/demo/pure/root.typ | 33 + core/0.1.1/demo/pure/theme.typ | 28 + core/0.1.1/demo/rheo/Justfile | 45 + core/0.1.1/demo/rheo/check-native.sh | 68 + core/0.1.1/demo/rheo/check.sh | 840 ++++++++ .../demo/rheo/content/ideated-doctitle.typ | 11 + core/0.1.1/demo/rheo/content/ideated-id.typ | 27 + .../0.1.1/demo/rheo/content/ideated-named.typ | 27 + core/0.1.1/demo/rheo/content/ideated.typ | 49 + core/0.1.1/demo/rheo/content/index.typ | 131 ++ core/0.1.1/demo/rheo/content/lib.typ | 83 + core/0.1.1/demo/rheo/content/refs.bib | 13 + core/0.1.1/demo/rheo/content/refs.typ | 52 + core/0.1.1/demo/rheo/content/relations.typ | 54 + .../demo/rheo/content/same-title-pair.typ | 29 + .../demo/rheo/content/sub/deeper/page.typ | 141 ++ core/0.1.1/demo/rheo/content/sub/page.typ | 39 + core/0.1.1/demo/rheo/content/tags.typ | 153 ++ core/0.1.1/demo/rheo/native.typ | 27 + core/0.1.1/demo/rheo/rheo.toml | 30 + core/0.1.1/src/base.typ | 106 + core/0.1.1/src/bib.typ | 178 ++ core/0.1.1/src/core.css | 1836 +++++++++++++++++ core/0.1.1/src/data.typ | 420 ++++ core/0.1.1/src/hyperlink.typ | 190 ++ core/0.1.1/src/idea.typ | 677 ++++++ core/0.1.1/src/ideate.typ | 636 ++++++ core/0.1.1/src/lib.typ | 62 + core/0.1.1/src/links.typ | 126 ++ core/0.1.1/src/outline.typ | 668 ++++++ core/0.1.1/src/permalink.typ | 226 ++ core/0.1.1/src/pure.typ | 1193 +++++++++++ core/0.1.1/src/row.typ | 207 ++ core/0.1.1/src/state.typ | 520 +++++ core/0.1.1/src/template.typ | 722 +++++++ core/0.1.1/src/theme.typ | 170 ++ core/0.1.1/src/transclusion.typ | 543 +++++ core/0.1.1/src/urls.typ | 75 + core/0.1.1/src/validate.typ | 135 ++ core/0.1.1/src/window.typ | 516 +++++ core/0.1.1/test/inputs.typ | 40 + core/0.1.1/test/units.typ | 892 ++++++++ core/0.1.1/typst.toml | 35 + 57 files changed, 13242 insertions(+), 13 deletions(-) create mode 100644 core/0.1.1/.gitignore create mode 100644 core/0.1.1/.marrow.typ create mode 100644 core/0.1.1/Justfile create mode 100644 core/0.1.1/demo/pure/Justfile create mode 100644 core/0.1.1/demo/pure/card-gap.typ create mode 100644 core/0.1.1/demo/pure/display-parity.typ create mode 100644 core/0.1.1/demo/pure/excluded.typ create mode 100644 core/0.1.1/demo/pure/folded-height.typ create mode 100644 core/0.1.1/demo/pure/idea-key-prefix.typ create mode 100644 core/0.1.1/demo/pure/idea-key.typ create mode 100644 core/0.1.1/demo/pure/naming.typ create mode 100644 core/0.1.1/demo/pure/root-prefix.typ create mode 100644 core/0.1.1/demo/pure/root.typ create mode 100644 core/0.1.1/demo/pure/theme.typ create mode 100755 core/0.1.1/demo/rheo/Justfile create mode 100755 core/0.1.1/demo/rheo/check-native.sh create mode 100755 core/0.1.1/demo/rheo/check.sh create mode 100644 core/0.1.1/demo/rheo/content/ideated-doctitle.typ create mode 100644 core/0.1.1/demo/rheo/content/ideated-id.typ create mode 100644 core/0.1.1/demo/rheo/content/ideated-named.typ create mode 100644 core/0.1.1/demo/rheo/content/ideated.typ create mode 100644 core/0.1.1/demo/rheo/content/index.typ create mode 100644 core/0.1.1/demo/rheo/content/lib.typ create mode 100644 core/0.1.1/demo/rheo/content/refs.bib create mode 100644 core/0.1.1/demo/rheo/content/refs.typ create mode 100644 core/0.1.1/demo/rheo/content/relations.typ create mode 100644 core/0.1.1/demo/rheo/content/same-title-pair.typ create mode 100644 core/0.1.1/demo/rheo/content/sub/deeper/page.typ create mode 100644 core/0.1.1/demo/rheo/content/sub/page.typ create mode 100644 core/0.1.1/demo/rheo/content/tags.typ create mode 100644 core/0.1.1/demo/rheo/native.typ create mode 100644 core/0.1.1/demo/rheo/rheo.toml create mode 100644 core/0.1.1/src/base.typ create mode 100644 core/0.1.1/src/bib.typ create mode 100644 core/0.1.1/src/core.css create mode 100644 core/0.1.1/src/data.typ create mode 100644 core/0.1.1/src/hyperlink.typ create mode 100644 core/0.1.1/src/idea.typ create mode 100644 core/0.1.1/src/ideate.typ create mode 100644 core/0.1.1/src/lib.typ create mode 100644 core/0.1.1/src/links.typ create mode 100644 core/0.1.1/src/outline.typ create mode 100644 core/0.1.1/src/permalink.typ create mode 100644 core/0.1.1/src/pure.typ create mode 100644 core/0.1.1/src/row.typ create mode 100644 core/0.1.1/src/state.typ create mode 100644 core/0.1.1/src/template.typ create mode 100644 core/0.1.1/src/theme.typ create mode 100644 core/0.1.1/src/transclusion.typ create mode 100644 core/0.1.1/src/urls.typ create mode 100644 core/0.1.1/src/validate.typ create mode 100644 core/0.1.1/src/window.typ create mode 100644 core/0.1.1/test/inputs.typ create mode 100644 core/0.1.1/test/units.typ create mode 100644 core/0.1.1/typst.toml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bc1fb74..5c8b0cc 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -38,9 +38,9 @@ jobs: # core declares `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 + # 0.6.0: `_resolve-dest` (`core/0.1.1/src/urls.typ`) emits a reserved # `rheo-page:` link dest that only 0.6.0 and later rewrite into a - # depth-correct href; `core/0.1.0/demo/rheo/check.sh` catches the literal an + # depth-correct href; `core/0.1.1/demo/rheo/check.sh` catches the literal an # older rheo ships instead. # # 0.6.1: where rheo learned to resolve a namespace it does not ship, via the @@ -57,7 +57,7 @@ jobs: # # 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 + # `core/0.1.1/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. @@ -94,15 +94,15 @@ jobs: run: just check-versions - name: core unit fixture - run: cd core/0.1.0 && just test + run: cd core/0.1.1 && just test # `@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`, + # cache already holds it. `core/0.1.1/demo/rheo/native.typ` imports + # `@rookery/core:0.1.1` 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)`. + # `error: package not found (searched for @rookery/core:0.1.1)`. # # 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 @@ -121,7 +121,7 @@ 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/core/0.1.1/typst.toml" # 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 @@ -134,18 +134,18 @@ jobs: # `prefix:`/`theme:`, which is document-wide state and cannot share a # document with content that hardcodes the default. - name: core pure demo compiles and checks, HTML and PDF - run: cd core/0.1.0/demo/pure && just build + run: cd core/0.1.1/demo/pure && just build # The rheo half of core's coverage, and the only thing that proves # 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. - name: core rheo demo compiles and asserts, under the declared floor - run: cd core/0.1.0/demo/rheo && just check + run: cd core/0.1.1/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, because the content imports `@rookery/core:0.1.1` 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 + run: cd core/0.1.1/demo/rheo && just check-typst diff --git a/CLAUDE.md b/CLAUDE.md index 858bfdb..a0350a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,7 @@ rheo present at all. on this machine) — readable from package scope without any `ctx:` parameter. - `@rookery/core` uses this pattern throughout and takes NO `ctx` parameter at - all: see `core/0.1.0/src/lib.typ`'s `_rheo-ctx()`/`_target()` helpers. + all: see `core/0.1.1/src/lib.typ`'s `_rheo-ctx()`/`_target()` helpers. DO NOT assert or panic when rheo is absent under this pattern — that's Pattern A's job for packages that genuinely can't function without rheo. diff --git a/core/0.1.1/.gitignore b/core/0.1.1/.gitignore new file mode 100644 index 0000000..c640daa --- /dev/null +++ b/core/0.1.1/.gitignore @@ -0,0 +1,3 @@ +.direnv/ +build +*.pdf diff --git a/core/0.1.1/.marrow.typ b/core/0.1.1/.marrow.typ new file mode 100644 index 0000000..9cc279b --- /dev/null +++ b/core/0.1.1/.marrow.typ @@ -0,0 +1,755 @@ +// Mints one output page per registered note, at ideas/.html, so a note +// gets a real URL instead of only an in-page fragment anchor on whatever page +// it happens to be written in. Reachable purely by importing this package — +// no rheo.toml entry, no project file needed — because rheo inlines a +// package's `.marrow.typ` verbatim at the bundle root (see typst_manifest.rs +// in rheo core). Skipped automatically for the combined PDF target, which +// rejects `document()`/`asset()` outright. +// +// Reach the package's own code by package spec, never a relative import: this +// text is spliced into rheo's synthesized bundle root, so a relative path +// would resolve against the PROJECT root, not this file's own directory. +// +// The `:` stripped off each id to get a slug comes from `_pfx` (the +// document-wide prefix state), never a literal — a project running +// `#show: rookery.with(prefix: "note")` must mint at the same paths lib.typ +// links to. The `:` stripping for the minting path is `_note-page`'s +// job, not this file's: it returns the slug, the file and the handle +// together, reading that one state on this file's behalf. +// +// Deliberately does NOT re-declare the note's `:` Typst label on the +// minted page. Two elements sharing one label break every #link/#window/ +// #hyperlink resolution to it as soon as either is referenced (labels only +// error on ambiguous lookup, not on declaration — see the epic's "Verified +// facts"). The label stays owned by the anchor #idea creates at the note's +// original call site; #link/#window/#hyperlink keep resolving there. The +// permalink on the minted page is a plain same-page HTML fragment +// (`href="#" + id` against this page's own heading `id` attribute), not a +// second declaration of the Typst label. +// +// Minted with `rheo-document`, not a bare `document()`, per rheo core's own +// guidance (crates/core/src/typ/rheo.typ): a bare `document()` skips +// `rheo-page-init`, so the page never publishes a handle of its own and +// inherits whatever `state("rheo-handle")` the spine left behind. Every +// depth-relative href computed ON this page then rides on that inherited +// value — rheo's cross-vertebra link rule, and lib.typ's `_note-href`. +// MEASURED on this package's own demo: the bare form happens to emit the same +// `../` prefix, because the inherited handle is one level deep just as +// `ideas/` is. That is a coincidence of this spine's shape, not a +// guarantee. Passing the handle makes the depth this page's own property. It +// mirrors the path — `ideas/.html` <-> `ideas:` — with both halves +// coming from lib.typ's `_note-page`, so minting and linking cannot drift. +// The permalink comes from lib.typ's `_permalink-tab` — the same top rule a +// note wears inside a card — with the href forced to this page's own fragment, +// because a minted page must not link to itself. Building the by hand here +// instead is how it came to miss the configurable hover property that every +// other permalink carries. +// CONTEXT FOOTER. A minted page shows the note stripped of everything around +// it, which is the point — but a reader who lands on one has no way back to +// the argument it was written inside. The footer names that page and links to +// the note's own anchor within it, not merely to the top of it. +// +// The link is `#link(label(id))`, i.e. Typst's own cross-document label +// resolution, which exports as `.html#loc-N` — the anchor `#idea` +// declared at the call site. Two rules could have intercepted it and neither +// does: rheo's `show link:` rewrite only touches links whose target is a +// `rheo-handle` figure (this one is a `rheo-idea-anchor`), and it is exactly +// that rewrite which DROPS the fragment and lands on the top of a page. So +// `#link()` would have been the wrong tool here despite being the +// obvious one — it goes to the page, not to the note in it. +// BACKLINKS. The inverse of every note's recorded outbound links (see +// `_outbound` in lib.typ): the notes that point AT this one, in registry order. +// Built once for the whole run rather than per page — inverting the map costs +// one pass over the registry, doing it inside the loop would cost one per note. +// +// Only notes appear here. A link written in a page's ordinary prose, outside +// any `#idea`, cannot: nothing records it, because the registry is the only +// thing this package can see and it holds notes, not pages. That is also why +// the list can be rendered as `#window`s at all — every entry is by construction +// a thing there is a note to show. +// PAGE TEMPLATE. A minted page is a `#document` spliced in HERE, at the bundle +// root, so it is outside every vertebra and inherits none of the project's own +// `#show:` chrome — no site header, no nav. `#show: rookery.with( +// idea-page-template: ...)` is how a project hands one over; this file applies +// applied. `none` (the default) mints the bare page this always produced. +// Every name here is INTERNAL to `@rookery/core` and load-bearing for this +// file. lib.typ carries the matching "CONSUMED BY .marrow.typ" banner; renaming +// or re-signing any of them means changing both files in the same commit. +// SYNDICATION BEACON. Opt-in via `#show: rookery.with(syndicate: true)` +// (default off — a package must not emit into another package's label +// namespace unasked). When on, each minted note page also carries a +// `#metadata((..)) ` beacon shaped to the contract +// `@rheo/feeds`'s `items()` reads (see that package's readme and the +// `` label — `feeds:item` is `items()`'s own default +// `label-name`). Emitted whether or not `@rheo/feeds` is installed at +// all: this file never imports it, and an unread `#metadata` beacon is +// inert — `#metadata` renders nothing on any target, so a project with no +// feeds in its dependency tree pays nothing for a beacon nobody queries. +// This is the SECONDARY syndication path — a project's own `.marrow.typ` (or +// any package) sourcing `ideas(tags:, match:)` straight into feeds's +// `items()` is the primary one; this exists for what that route cannot +// reach, e.g. a hand-authored page syndicating itself. +#import "@rookery/core:0.1.1": _registry, _note-page, _pfx, _dir, _c, _index-page, ideas, _head, _permalink, _permalink-tab, _themed, _tags-color-rules, _handle-title, _page-links, _page-href, _body-at, _footnoted, _refs-block, _own-cited-keys, _window-depth, _idea-page-template, _syndicate, _display-context, _display-backlinks, _display-title, _page-titles, _plain, _visible-tags, _tags-attr, window, hyperlink, _ref-text, _rec-label, _assert-unique-names, _dup-warning-content, _tag-pred + +#context { + // Two notes sharing a name, checked once here at bundle root rather than + // per note or per page — see `validate.typ`'s own banner for why a + // resolved id alone cannot tell a collision from a replay. Panics on a + // real collision; otherwise hands back a warning per id whose one + // repeated payload turns out to have two or more genuine call sites. + // + // KEPT AS DATA, not rendered here: bundle-root content has nowhere safe to + // go (a bare paragraph here hard-errors, "text is not allowed at the + // top-level in bundle export") — the mint loop below renders each + // warning on the affected note's OWN page instead, once it exists. + let dup-warnings = (:) + for w in _assert-unique-names() { dup-warnings.insert(w.id, w) } + let registry = _registry.final() + let tpl = _idea-page-template.final() + let syndicate = _syndicate.final() + let display-context = _display-context.final() + let display-backlinks = _display-backlinks.final() + let display-title = _display-title.final() + // Resolved HERE, in the one context that already resolves every other + // document-wide key, and handed to `_handle-title` as a plain string — that + // function is package scope with no context of its own (see its banner). + let page-titles = _page-titles.final() + + // THE PER-TAG THEME, which every page below has to carry for itself. + // + // `theme: (tags-color: ..)` is delivered as generated `.idea-tag-` rules + // (`_tags-color-rules`, src/theme.typ), and `rookery()` emits them once per + // VERTEBRA — which cannot reach a page THIS file mints, because a minted page + // is a separate `#document` that never calls `rookery()` again (the same fact + // recorded at src/template.typ's emission). Without this the pages where tag + // colour matters MOST would be the only ones without it: every minted note + // page renders its tags as pills unconditionally (see `tags:` below), and + // `ideas/index.html` puts `idea-tag-` on every row and carries the site's + // search modal, whose chips wear the same classes. + // + // Read ONCE, outside both loops, like `minted-depth` and the backlink maps: + // `_tags-color-rules()` reads one document-wide `.final()` theme, so every + // minted page gets the identical block. `none` — no themed tag — must produce + // no `` rule, and custom +// properties inherit from `:root` down the whole DOM, so `#search-bar` and +// `#search-modal` resolve it with no inline style and no private copy of the +// table — neither element has a `.idea-*` ancestor to inherit from. + +// Lowercase, with `-`/`_` read as a space. Applied to the HAYSTACK AND THE QUERY, +// which is what makes an id findable by how a person types it: the note +// `flat-ids` matches "flat ids", and the literal "flat-ids" still matches, both +// sides collapsing to the same thing. Folding only the haystack would break the +// second case, the query's `-` finding no `-` left to match. +// +// Deliberately NOT accent-folding: "cafe" does not match "Café", and fixing that +// means Unicode normalisation the JavaScript port would have to reproduce +// exactly. The readme records it as a known limitation. +#let _fold(s) = lower(s).replace("-", " ").replace("_", " ") + +// A row's own tag NAMES. `ideas()` gives `tags` as an array of names and +// `values: true` adds `tags-dict`, whose keys are those same names, so reading +// either means a caller may pass rows from either call. A row with neither +// field is untagged, and therefore matches no tag expression at all. +#let _row-tags(r) = { + let d = r.at("tags-dict", default: none) + if d != none { return d.keys() } + let t = r.at("tags", default: ()) + if type(t) == dictionary { t.keys() } else { t } +} + +// The default haystack a text input filters on: the row's label, name and body, +// joined. Searching the BODY is what finds a note by a phrase inside it rather +// than by its title. +#let _row-haystack(r) = ( + r.at("label", default: ""), + r.at("name", default: ""), + r.at("body", default: ""), +).filter(s => s != "" and s != none).join(" ") + +// The short half of the haystack — the row's own name, with no body. The panel +// subsequence-matches THIS (see `panel.js`), because a subsequence of a +// paragraph is not a match; the body is matched by substring instead. +#let _row-name(r) = ( + r.at("label", default: ""), + r.at("name", default: ""), +).filter(s => s != "" and s != none).join(" ") + +// A `datetime` as the zero-padded `[year][month][day]` string every date sort +// here compares, or `none` for an undated row. The padding is what makes a +// plain string sort a date sort, with no `datetime` comparison anywhere. +#let _date-stamp(d) = if d == none { none } else { d.display("[year][month][day]") } + +// `tags`, `match` and `body-search` are checked identically by several public +// functions, so the check and its message live here once. `where` is the caller's +// own name as it appears in the message, possessive included — `#search-ideas'` +// and `#search-index's` are both correct for their nouns. +// +// A deliberate copy of `@rookery/core`'s three rather than an import: these +// messages say `@rookery/search:` and belong to this package. `limit` and `class` +// are checked in `_search-ui-common` instead, being the UI surfaces' pair; +// `#search-ideas`' `limit` is a different check again, accepting 0 where the UI's +// does not. +#let _assert-tags(v, where) = assert( + v == none + or type(v) == str + or (type(v) == array and v.all(t => type(t) == str)), + message: "@rookery/search: " + where + " `tags` must be none, a " + + "string, or an array of strings — got " + repr(v), +) + +#let _assert-match(v, where) = assert( + v == "any" or v == "all", + message: "@rookery/search: " + where + " `match` must be \"any\" or " + + "\"all\" — got " + repr(v), +) + +#let _assert-bool(v, name, where) = assert( + type(v) == bool, + message: "@rookery/search: " + where + " `" + name + "` must be a " + + "boolean — got " + repr(v), +) + +// The message names the trade rather than only the two words, because the wrong +// value here is a performance cliff rather than a typo: a site that meant +// `"asset"` and wrote something else gets the island in every page. +#let _assert-mode(v, where) = assert( + v == "asset" or v == "inline", + message: "@rookery/search: " + where + " `mode` must be \"asset\" (one " + + "fetched index, needs http(s)://) or \"inline\" (the index in every " + + "page, works from file://) — got " + repr(v), +) diff --git a/search/0.1.0/src/compress.typ b/search/0.1.0/src/compress.typ new file mode 100644 index 0000000..b8693b1 --- /dev/null +++ b/search/0.1.0/src/compress.typ @@ -0,0 +1,199 @@ +// The corpus pass: a note's plain-text body tokenized, and every note compressed +// to its most distinctive terms. +// +// What `#search-index` puts in a row's `body`. That field is MATCH-ONLY — the +// modal's preview pane fetches the note's own minted page rather than excerpting +// the island — so its bytes go on the terms that DISTINGUISH a note rather than +// on whatever the note opens with. On a 56-note site, `body-terms: 48` and +// `df-ceiling: 40` cost about 24 KB of island for whole-note coverage, with 38 of +// the 56 notes reaching the term cap: the budget binds rather than idling. +// +// NO WEIGHTS ARE SHIPPED. Position is the weight and `body-score` reads rank. In +// notes this short almost every term has tf=1, so a weight collapses to idf, and +// idf is a property of the term rather than of the note — a digit-per-term weight +// string cost 11% of the payload to distinguish the top two or three terms. +// +// Build time only, and it stays that way: the browser matches against the RESULT, +// so `body-score`/`bodyScore` are the only pair that needs porting. + +#import "base.typ": * + +// The stopword FLOOR, not the filter: the df ceiling below catches nearly +// everything on this list anyway, worth 0.4% of the island's bytes. It earns its +// place on a SMALL rookery, where too few notes exist for df to carry any signal. +// +// Function words only, and nothing shorter than three clusters — `_tokenize`'s +// length floor has already dropped `a`, `is`, `of`, `to`, `it` and the rest +// before this dictionary is consulted. That is why `im` and `id` are absent while +// `ive`/`wasnt`/`dont` are here: a contraction arrives apostrophe-stripped, no +// apostrophe surviving tokenization. +// +// TYPOS ARE NOT FILTERED: `somethign` is in the note, and a reader who typed the +// same typo should find it. +// +// A DICTIONARY, not an array, because the question asked of every token is a key +// test — `t in _stopwords` — where array membership is a scan of a hundred +// strings per token. +#let _stopwords = { + // Parenthesised, and it has to be: in Typst code mode a line break ends the + // statement, so a continuation line opening with `+` reads as a unary plus on a + // fresh expression and fails with `cannot apply unary '+' to string`. + let ws = ( + "the and but not for with was are were been being have has had " + + "this that these those they them their there then than from into " + + "over under out off about above below between through before after " + + "again all any both each few more most other some such only " + + "same too very can will just should now our you your she her " + + "his him its who which what when where why how while because " + + "until against among around also would could might must may here " + + "does did nor yet whether either neither every another something " + + "anything nothing everything though although however therefore " + + "ive wasnt dont cant didnt isnt thats theres youre theyre ill" + ) + let d = (:) + for w in ws.split(" ") { + if w != "" { d.insert(w, true) } + } + d +} + +// One note's plain-text body to candidate terms, in FIRST-APPEARANCE order with +// duplicates KEPT — `_compress-corpus` counts them for tf. +// +// Lowercased, split on every non-alphanumeric cluster EXCEPT `.` and `-`, which +// are kept INSIDE a token: `0.5.1`, `rheo-context`, `eco-marxist` and +// `marrow.typ` are exactly what a reader searches for, and splitting them yields +// `5`, `1`, `rheo`, `context` — none of which is the thing wanted. A LEADING `.` is kept too, so `.marrow.typ` survives +// verbatim; the dotted form then answers both queries, a dotless one still being +// a substring of it, where the stripped form answers only the dotless query. A +// TRAILING `.`/`-` is stripped, that one being sentence punctuation rather than +// part of the term (`code.` -> `code`, `well-` -> `well`). The cost of one rule +// doing both is that `word.Next` with no space after the stop reads as a single +// term; a plain-text body puts a space there. +// +// `_` IS A SPLITTER, not a token character. `.` and `-` are the two exceptions +// and the set is closed — every addition is another character a reader has to +// type exactly to match what the build kept. +// +// Dropped: tokens under 3 clusters, bare digit runs, and the stopword floor. A +// bare digit run is `^[0-9]+$` and nothing looser, so `0.5.1` and an id like +// `26w30` are NOT bare numbers and survive — they are among the most distinctive +// terms a note has. +// +// NO STEMMING, no accent folding, no language detection: documented non-goals. +// Each would be a rule the READER now has to reproduce in the search box, since +// the browser matches raw substrings against whatever the build kept. +// +// The token pattern and the bare-digit test, built ONCE rather than per body +// and per token: this pass runs over every note's body, and on the inline +// index path over every note's body per output page. +// +// Doubled backslashes: Typst rejects `\.` / `\p` as unknown STRING escapes, so +// the regex the engine sees is `\.?[\p{L}\p{N}][\p{L}\p{N}.\-]*`. +#let _TOKEN-RE = regex("\\.?[\\p{L}\\p{N}][\\p{L}\\p{N}.\\-]*") +#let _DIGITS-RE = regex("^[0-9]+$") + +#let _tokenize(body) = { + let out = () + for m in lower(body).matches(_TOKEN-RE) { + let t = m.text + // `str.len()` and `str.slice` are BYTE offsets, which is safe here and only + // here: the two characters being stripped are ASCII, so `len() - 1` is always + // a character boundary. The length FLOOR below counts clusters instead, + // because that one is about how much of a word a reader sees. + while t.len() > 0 and (t.ends-with(".") or t.ends-with("-")) { + t = t.slice(0, t.len() - 1) + } + if t.clusters().len() < 3 { continue } + if t.match(_DIGITS-RE) != none { continue } + if t in _stopwords { continue } + out.push(t) + } + out +} + +// The corpus-wide pass: `bodies` in, one space-joined term string per body out, +// SAME ORDER, so the caller zips the result back onto its rows positionally. +// +// THE SIGNATURE TAKES BODIES, NEVER ROWS, and that is a build-cost decision. +// `#search-index` runs on every output page, so this pass is called once per +// page, and Typst memoises a pure call keyed on its ARGUMENTS: thirty calls with +// an identical argument cost the same as one, while thirty differing in one +// argument cost about 21x. So the pass is free per build for as long as every +// argument is page-invariant. +// +// An `ideas()` row is NOT page-invariant: `href` is depth-relative, so a nested +// vertebra's rows differ from a top-level page's, so handing this the whole rows +// array makes every page a cache miss. `bodies` is the projection of the only +// field this needs. DO NOT widen it back to rows: nothing fails, no test goes +// red, and the build time is silently multiplied by the page count. +#let _compress-corpus(bodies, body-terms: 48, df-ceiling: 40) = { + let n = bodies.len() + let toks = bodies.map(_tokenize) + + // Document frequency: how many NOTES hold the term, counted once per note, not + // once per occurrence — a `seen` set per note is what makes it a document + // frequency rather than a corpus term count. + let df = (:) + for ts in toks { + let seen = (:) + for t in ts { + if t in seen { continue } + seen.insert(t, true) + df.insert(t, df.at(t, default: 0) + 1) + } + } + + toks.map(ts => { + // tf per term, and `order` in first-appearance order — the tie-break below + // rides on that order, so it is built here rather than recovered later. + let tf = (:) + let order = () + for t in ts { + if t in tf { + tf.insert(t, tf.at(t) + 1) + } else { + tf.insert(t, 1) + order.push(t) + } + } + + // THE DF CEILING is the part that earns its keep. At 40% it cuts the ordinary + // function words AND the corpus-specific noise no word list could know about + // — `week`, on a site of weeknotes. It buys QUALITY rather than bytes: the + // island is much the same size from df<=20% to df<=100%, because the top-K cut + // already binds. + // + // The percentage is compared by CROSS-MULTIPLICATION, so no float and no + // rounding decides whether a term is in or out. + // + // A df OF 1 IS NEVER DROPPED. A term in exactly one note is by definition not + // shared with the corpus, and the ceiling exists to remove what is shared. + // Without the guard a small rookery indexes NOTHING: at n=2 every term is in + // 50% or 100% of the notes and 50 > 40. It rescues only the corpus too small + // for df to mean anything, the case the stopword floor is also there for. + let kept = order.filter(t => { + let d = df.at(t) + d <= 1 or d * 100 <= n * df-ceiling + }) + + // Integer tf-idf: round(100 * tf * log2(n / df)). The 100 keeps the ordering + // a float would carry without a float ever reaching the sort key, and the + // number itself is never shipped — it only orders. + // + // SORTED BY WEIGHT ALONE, tie-broken by FIRST APPEARANCE: `order` is in + // first-appearance order and Typst's `.sorted` is stable, so equal weights + // keep the order the note wrote them in and the island is byte-stable between + // builds. The key is a plain integer rather than a `(weight, index)` array + // because an array key is not reliably comparable here. + let ranked = kept.sorted(key: t => ( + -1 * int(calc.round(100 * tf.at(t) * calc.log(n / df.at(t), base: 2))) + )) + + let top = ranked.slice(0, calc.min(body-terms, ranked.len())) + // `array.join()` on an EMPTY array returns `none` in Typst, not `""`, and an + // empty result is a real case: a genuinely empty note compresses to no terms + // at all. + if top.len() == 0 { "" } else { top.join(" ") } + }) +} diff --git a/search/0.1.0/src/corpus.typ b/search/0.1.0/src/corpus.typ new file mode 100644 index 0000000..dbdbdb1 --- /dev/null +++ b/search/0.1.0/src/corpus.typ @@ -0,0 +1,357 @@ +// What `#search-index` puts on a page — a pointer at the build's one fetched +// index, or the index itself inline — and the build-once cache the inline form +// leans on. + +#import "base.typ": * +#import "compress.typ": * +#import "lookup.typ": * + +// The inline path's corpus pass cannot lean on Typst's memo: rheo emits many +// documents from one build and a per-page memo does not carry across them, so +// `.marrow.typ` next to this file runs ONCE at the bundle root, compresses the +// whole rookery there, and publishes the result into this state for the +// per-page path to read back. +// +// Keyed by note id rather than by position, since `#search-index` selects and +// orders its own rows, and by `body-terms`/`df-ceiling` too, since those knobs +// change the terms themselves — an index built with different knobs never +// reads another's. +// +// Empty without rheo: `.final()` gives `(:)`, every lookup misses, and +// `#search-index` compresses inline instead. The same happens under rheo for +// a tag-filtered index — see the miss path at its call site. +// +// `mode: "asset"`, the default, reads none of this: the marrow writes the +// finished index to a file and a page carries only a pointer at it. +#let _corpus-cache = state("rookery-search-corpus", (:)) + +// The whole island row set, built once at the bundle root, for the inline path +// to read back instead of deriving it per page: `_corpus-cache` above hoists +// only the compressed body terms, so without this a page still calls +// `search-ideas("")` to walk the registry, flatten every body and rank the +// result, once per page. This holds the finished rows in `_rank`'s empty-query +// order, keyed by the same compression knobs; every field but the href is +// page-invariant, so a page's own job is to prefix `page` with its own depth. +// +// Reading the rows back this way still does not rescue the inline path — see +// `#search-index`'s own documentation for the numbers behind that and for why +// `mode: "asset"` is the default instead. +// +// Empty without rheo, like `_corpus-cache`: no bundle root, no marrow, every +// lookup misses, and `#search-index` derives its rows directly. +#let _rows-cache = state("rookery-search-rows", (:)) + +// The cache key for one set of compression knobs. A string rather than a dict +// so it can be a dictionary key at all. +#let _corpus-key(body-terms, df-ceiling) = "t" + str(body-terms) + "/d" + str(df-ceiling) + +// Above this much duplicated JSON, `mode: "inline"` gets a complaint. 8 MB is +// chosen to sit well clear of any site that inlines deliberately — a 40-note +// rookery's island is around 20 KB, so even a hundred pages of it is 2 MB — +// while catching the shape that actually goes wrong: an island that grew with +// the corpus, times a page count that grew with it too. The site that prompted +// this reached 43 MB. +#let _ISLAND-BUDGET = 8 * 1000 * 1000 + +// Complain, once per page, when this page's island times the number of pages +// carrying one comes to more than [`_ISLAND-BUDGET`]. +// +// A HIDDEN `
`, not a panic: an inline index is a CHOICE, and a `file://` +// project has no alternative, so a build that made it must not fail. The +// mechanism is `@rookery/todos`' `#todos-validate`, which reports the same way +// for the same reason. +// +// PER PAGE, because per page is where this function can see anything at all — +// `.marrow.typ` knows the corpus but not what any page did with it. That is +// also proportionate: a project on the inline path is already paying the island +// in every page, so ~250 bytes of explanation beside it is not the problem. +// +// THE OTHER HALF OF THIS IS RHEO'S, and it needs no package: `rheo compile` +// prints one summary line per build carrying the page count and the total +// output bytes, so `364 page(s), 43.1 MB` says the same thing for every package +// and every project. What this adds is the diagnosis — which package, which +// switch — for the one case where a package knows it. +#let _island-budget-report(island-bytes, note-count) = { + let c = _rheo-ctx() + if c == none { return } + // Every vertebra can carry a bar, and every minted note page carries whatever + // the template puts on it, so the page count is both. An overestimate is + // impossible to avoid here and harmless: this is an order-of-magnitude test. + let pages = c.at("spine-flat", default: ()).len() + note-count + let total = island-bytes * pages + if total <= _ISLAND-BUDGET { return } + html.elem( + "div", + attrs: (class: "rookery-search-budget-report", hidden: "hidden"), + "@rookery/search: the inline index is " + + str(calc.round(island-bytes / 1000)) + + " kB x " + + str(pages) + + " pages = " + + str(calc.round(total / 1000000, digits: 1)) + + " MB of duplicated JSON. Drop `mode: \"inline\"` to fetch one " + + "`rookery/search/index.json` instead (the default), or lower " + + "`body-terms`. Inline is only required for a `file://` build.", + ) +} + +// +// #search-index() // usually not called directly +// #search-index(mode: "inline") // carry the JSON in the page itself +// #search-index(elem-id: "notes-index") // a second, differently-keyed index +// #search-index(body-terms: 24) // a tighter term budget per note +// #search-index(df-ceiling: 20) // a harsher cut of shared terms +// #search-index(body-search: false) // no body text in the island at all +// #search-index(tags: "phd") // only the notes tagged phd +// +// TWO MODES, AND THE DEFAULT IS `"asset"`. Under `"asset"` the build writes ONE +// `rookery/search/index.json` from `.marrow.typ` and each page carries only an +// empty ``, +// one row per note: `(id, name, text, tags, body, created, href)`, where `text` +// is the plain-text title ("" when untitled), `tags` is the note's own tag +// array (THE KEY IS ABSENT when it has none), `body` is that note's compressed +// term string ("" when it compresses to nothing), `created` is that note's +// resolved date as the zero-padded `"[year][month][day]"` stamp `_date-stamp` +// builds (THE KEY IS ABSENT when the note is undated — never `""` or `null`), +// and `href` is the depth-relative path to the note's minted page, computed +// against the page this call sits on, so an island in a site's shared chrome +// comes out right on a nested vertebra too. +// +// The field is `text`, not `title`, on purpose: same name, same meaning, same +// type as `search-ideas` returns. `title` there is CONTENT, which JSON cannot +// carry, and one name meaning two types across two surfaces is how a consumer +// gets it wrong. +// +// `body-terms` AND `df-ceiling` CONTROL THE COMPRESSION: a row's `body` is +// `_compress-corpus`' output for that note — its `body-terms` most distinctive +// terms, space-joined in weight order, with every term appearing in more than +// `df-ceiling` percent of the SELECTED notes dropped first. The reasoning behind +// both defaults is at `_compress-corpus`. +// +// A BUDGET IS NOT OPTIONAL, because the island is inline in EVERY page rather +// than fetched once: a 40-note rookery's sources run to about 31 KB, so an +// uncapped index would cost 20-25 KB of JSON per page. The budget is a term count +// rather than a character prefix so that the bytes go on a note's distinctive +// terms instead of on whatever it opens with — a prefix cap left about a third of +// a real corpus unfindable in the bar. +// +// `df-ceiling` IS COMPUTED OVER THE SELECTED NOTES, so `tags:` below moves it: a +// term common across a whole rookery can be distinctive within one tag's notes, +// and each island's ceiling is measured for the corpus it actually carries. +// +// A NOTE CAN COMPRESS TO NOTHING, and its `body` is then `""` — a genuinely empty +// note. It is unfindable by body, as an empty note always is, and its keyword row +// in the modal is empty. +// +// `body-search: false` OMITS THE `body` FIELD ALTOGETHER — a row is then +// `(id, name, text, href)`, and the island shrinks to roughly the sum of the +// corpus's ids and titles. It is the same switch `#search-ideas` takes and +// means the same thing on both sides of the language boundary: the browser +// searches ids and titles only, and no JavaScript enforces that: `search` in +// `src/score.js` reads `row.body ?? ""` and `bodyScore("", q)` is `null` for +// every non-empty query, so a row with no body cannot produce a body-tier hit. +// Leaving the field out is the whole implementation. +// +// Two consequences. A note findable ONLY by a word in its body becomes +// unfindable, which is the point. And the modal's preview pane loses the keyword +// row drawn from this field, so on `file://`, where the rich preview cannot be +// fetched, it shows "No preview"; over http the fetched page is unaffected. +// +// EITHER WAY THE TYPST SIDE STAYS EXHAUSTIVE: `#search-ideas` scores full bodies, +// so a term this island drops — to `body-search: false`, to the `df-ceiling`, or +// to the `body-terms` cut — is still findable there. +// +// THE SEPARATE FETCHED FILE IS `mode: "asset"`, described at the top. Typst's +// bundle target has `asset(path, data)` and rheo routes it to the output +// directory, so `.marrow.typ` writes the index once and this function only +// points at it. `"inline"` survives because a `file://` page has no fetch. +// +// `search-bar` emits this itself, so most projects never call it. Call it +// directly when building a custom UI, or when several bars share one index — +// see `search-bar`'s `index:` parameter. +// +// The rows are `search-ideas("")` — the empty query matching everything — with +// the fields JSON cannot carry dropped and unmintable notes filtered out. No +// `body-search:` is forwarded to that call and none is wanted: an empty query +// returns `none` from `body-score` for every note, so the body tier is empty +// whatever the switch says, and every row arrives through the name tier. +// +// `tags:`/`match:` ARE forwarded there, and they scope the island: a note the +// selection excludes is not in the JSON, so the browser cannot find it. That is +// how a bar over just the notes tagged `phd` is built — see `#search-bar`. +// +// EACH ROW CARRIES ITS NOTE'S `tags`, because the browser has something to decide +// with them: a reader types `tags:(a|b)&c` into the bar and the script evaluates +// that expression per row. The author's `tags:` parameter below settles the +// CORPUS in Typst; this field is what the reader's own filter reads. +// +// It costs about 18 B a note, which is why there is no `tag-search: false` +// switch: `body-search: false` earns one by removing the largest field in the +// row, and this has no such case to answer. +// +// THE KEY IS OMITTED for an untagged note rather than written as `()`, exactly as +// `body-search: false` omits `body`: an absent key means "none", where `()` would +// cost a key per row to say the same thing. The port reads `row.tags ?? []`. +#let search-index( + elem-id: "rookery-search-index", + mode: "asset", + body-terms: 48, + df-ceiling: 40, + body-search: true, + tags: none, + match: "any", +) = context { + if _target() != "html" { return } + _assert-mode(mode, "#search-index's") + let cache-key = _corpus-key(body-terms, df-ceiling) + // THE POINTER, and nothing else on the page. `data-rookery-search-src` is + // what `src/island.js` tests to decide between fetching and parsing, and + // `data-rookery-search-base` is this page's own depth prefix, which the + // shared file cannot carry for it. A ` +
f
+ `); + const box = extractNote(doc, "https://example.org/x.html"); + assert.equal(box.querySelectorAll("script").length, 0); +}); + +test("extractNote: resolves relative href/src against the fetched page's URL", () => { + const doc = parse(` +

Title

+

css

+
f
+ `); + const box = extractNote(doc, "https://example.org/site/ideas/etal.html"); + const a = box.querySelector("a"); + const img = box.querySelector("img"); + assert.equal(a.getAttribute("href"), "https://example.org/site/style.css"); + assert.equal(img.getAttribute("src"), "https://example.org/site/ideas/img/a.png"); +}); + +test("extractNote: leaves a fragment-only link exactly as written", () => { + const doc = parse(` +

Title

+

footnote

+
f
+ `); + const box = extractNote(doc, "https://example.org/site/ideas/etal.html"); + assert.equal(box.querySelector("a").getAttribute("href"), "#loc-3"); +}); + +test("extractNote: leaves an empty href untouched (does not throw resolving against it)", () => { + const doc = parse(` +

Title

+

empty

+
f
+ `); + const box = extractNote(doc, "https://example.org/site/ideas/etal.html"); + assert.equal(box.querySelector("a").getAttribute("href"), ""); +}); diff --git a/search/0.1.0/test/filterpanel.test.mjs b/search/0.1.0/test/filterpanel.test.mjs new file mode 100644 index 0000000..ea7b89f --- /dev/null +++ b/search/0.1.0/test/filterpanel.test.mjs @@ -0,0 +1,73 @@ +// `passesTags(row, pressed, mode)` — the predicate behind `#filter-panel`'s pills. +// +// TWO COMPOSITIONS, and the default is "any". A row survives if it carries ANY pressed +// tag, so a second pill WIDENS; `"all"` keeps only a row carrying every pressed tag, so +// it narrows. The default is "any" because the tags a pill row is built from are usually +// mutually exclusive in practice — one epic per todo — and intersecting two of those +// returns nothing at all. +// +// Everything else about the two panel kinds — the input, the count, the reordering — is +// one shared `wirePanel`, which is why this is the only new predicate to pin. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { passesTags } from "./internal.mjs"; + +// A row as `wirePanel` builds it: the tag names off `data-panel-tags`, as a Set. +const row = (...tags) => ({ tags: new Set(tags) }); + +test("no pills pressed passes every row, in both modes", () => { + const pressed = new Set(); + for (const mode of ["any", "all"]) { + assert.equal(passesTags(row(), pressed, mode), true); + assert.equal(passesTags(row("ready"), pressed, mode), true); + } +}); + +test("one pill keeps only its carriers, in both modes", () => { + const pressed = new Set(["ready"]); + for (const mode of ["any", "all"]) { + assert.equal(passesTags(row("ready"), pressed, mode), true); + assert.equal(passesTags(row("ready", "epic-jobs"), pressed, mode), true); + assert.equal(passesTags(row("blocked"), pressed, mode), false); + assert.equal(passesTags(row(), pressed, mode), false); + } +}); + +// THE DEFAULT. Two pills UNION: pressing a second one widens the result, which is what +// makes a row of mutually exclusive tags (one epic per todo) usable at all. +test("two pills UNION by default — carrying either is enough", () => { + const pressed = new Set(["ready", "epic-jobs"]); + assert.equal(passesTags(row("ready", "epic-jobs"), pressed, "any"), true); + assert.equal(passesTags(row("ready"), pressed, "any"), true); + assert.equal(passesTags(row("epic-jobs"), pressed, "any"), true); + assert.equal(passesTags(row("blocked"), pressed, "any"), false); +}); + +// AN UNRECOGNISED MODE FALLS BACK TO THE DEFAULT rather than throwing: the value comes +// off a DOM attribute, and a page carrying a typo should still filter. +test("anything that is not \"all\" composes as \"any\"", () => { + const pressed = new Set(["ready", "epic-jobs"]); + for (const mode of ["any", undefined, "", "ALL", "every"]) { + assert.equal(passesTags(row("ready"), pressed, mode), true); + } +}); + +test("`all` INTERSECTS — carrying one of the two is not enough", () => { + const pressed = new Set(["ready", "epic-jobs"]); + assert.equal(passesTags(row("ready", "epic-jobs"), pressed, "all"), true); + assert.equal(passesTags(row("ready"), pressed, "all"), false); + assert.equal(passesTags(row("epic-jobs"), pressed, "all"), false); +}); + +// THE PREFIX CASE, and the reason the Typst side space-pads `data-panel-tags` at both +// ends: an implementation testing the raw attribute with `includes("epic")` would +// match ` epic-jobs `. Splitting on spaces into a Set makes a half-match impossible, +// and this test is what keeps it that way if the parsing is ever "optimised" back to a +// substring test. +test("a tag that is another's prefix does not half-match", () => { + for (const mode of ["any", "all"]) { + assert.equal(passesTags(row("epic-jobs"), new Set(["epic"]), mode), false); + assert.equal(passesTags(row("epic"), new Set(["epic-jobs"]), mode), false); + assert.equal(passesTags(row("epic", "epic-jobs"), new Set(["epic"]), mode), true); + } +}); diff --git a/search/0.1.0/test/global.test.mjs b/search/0.1.0/test/global.test.mjs new file mode 100644 index 0000000..24d6c1a --- /dev/null +++ b/search/0.1.0/test/global.test.mjs @@ -0,0 +1,76 @@ +// THE `RookerySearch` GLOBAL, and the one asymmetry it exists to end. +// +// WHAT IT PINS. `vite.config.js` builds `dist/lib.js` as an IIFE named +// `RookerySearch`, so a project consuming a RELEASE has always had the whole +// surface on `globalThis`. A project consuming `src/*.js` through a repo-backed +// namespace got ES modules and no global at all, so the same site code — and +// `@rookery/todos`' `#todos-search`, which feature-detects this object rather +// than importing across the package boundary — worked or did not depending on +// which coordinate the project happened to use. `src/search.js` now publishes it +// itself, and vite's own assignment still wins where both run (`??=`). +// +// AND THE OTHER HALF: under BARE NODE nothing is published. The parity harness +// imports this module, and a global appearing there would make the two modes +// indistinguishable from a test's point of view — the guard is `typeof document`, +// so this is the case that proves the guard is the guard. +// +// TWO EVALUATIONS OF ONE MODULE, which is why both imports are dynamic and the +// second carries a query string: ES module instances are cached per specifier, so +// `?dom=1` is what buys a second evaluation with a document in place. The order +// is load-bearing — bare node first, then a DOM. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; + +await import("../src/search.js"); +const bare = globalThis.RookerySearch; + +globalThis.document = parseHTML("").document; +// DELIBERATELY NO `globalThis.window`, and that absence is load-bearing. +// `search.js` registers its rheo rehydrate hook at module-evaluation time, and +// reading `window` to do it would throw on this import while working on every +// real page — so it reads `globalThis`, and this import is what holds it to +// that. Supplying a `window` here to keep the import quiet would only hide the +// next regression of the same kind. +await import("../src/search.js?dom=1"); +const published = globalThis.RookerySearch; + +test("bare node gets no global", () => { + assert.equal(bare, undefined); +}); + +test("a document gets the whole surface", () => { + assert.equal(typeof published, "object"); + // The three `#todos-search` feature-detects, named first because that widget's + // availability test is `splitQuery && evalTagQuery && fold` and a partial + // surface must degrade as an absent one does. + for (const name of ["splitQuery", "evalTagQuery", "fold"]) { + assert.equal(typeof published[name], "function", `${name} is missing`); + } + // The rest of the documented surface, so a rename here fails here rather than + // on a consuming site. + for (const name of [ + "clusters", + "parseTagQuery", + "positiveAtoms", + "score", + "bodyScore", + "search", + "readIndex", + "initPanels", + "wirePanel", + "resetKeys", + "init", + ]) { + assert.equal(typeof published[name], "function", `${name} is missing`); + } +}); + +test("the global's functions are the module's own", async () => { + // Not a re-implementation and not a stale copy: the same rule, reachable two + // ways. `fold` is the cheap witness — it is what a consumer must apply to a + // row's tags before `evalTagQuery` will agree with the search bar. + const { fold } = await import("../src/text.js"); + assert.equal(published.fold("In-Progress"), fold("In-Progress")); + assert.equal(published.fold("In-Progress"), "in progress"); +}); diff --git a/search/0.1.0/test/internal.mjs b/search/0.1.0/test/internal.mjs new file mode 100644 index 0000000..8c3ee7e --- /dev/null +++ b/search/0.1.0/test/internal.mjs @@ -0,0 +1,22 @@ +// Bridges the module-private helpers this suite unit-tests — `matchRanges`, +// `selection`, `extractNote`. +// +// It used to read `src/search.js` as TEXT, append a throwaway +// `export {...}` naming those three top-level `const`s, write that to a temp +// file and import it. That trick existed because the whole package was one flat +// module with no way to address a private binding from outside it. +// +// Since the split there is a better answer: each of the three lives in a module +// of its own and is exported there for exactly this reason. This file is now a +// plain re-export, and the temp-file dance is gone — along with its one real +// hazard, which the split would have tripped anyway: the temp copy landed in +// `os.tmpdir()`, where a relative `import "./text.js"` cannot resolve. +// +// They are deliberately NOT re-exported from `src/search.js`: the +// package's public surface is what that entrypoint exports, and a test's need to +// reach inside is not a reason to widen it. +export { matchRanges } from "../src/marks.js"; +export { selection } from "../src/selection.js"; +export { extractNote } from "../src/preview.js"; +export { renderRow } from "../src/row.js"; +export { passesTags } from "../src/panel.js"; diff --git a/search/0.1.0/test/island.test.mjs b/search/0.1.0/test/island.test.mjs new file mode 100644 index 0000000..dcbd775 --- /dev/null +++ b/search/0.1.0/test/island.test.mjs @@ -0,0 +1,136 @@ +// `loadIndex(elemId)` — the mode-agnostic index reader. It has to answer two +// shapes of page from the same element id: an inline island carrying the JSON +// (`mode: "inline"`), and a pointer at the build's one fetched file +// (`mode: "asset"`). The mode is read off `data-rookery-search-src`, never +// guessed, so these tests fix that attribute's meaning as the contract. +// +// THE REBASING IS THE PART WORTH PINNING. A fetched index is shared by every +// page, so it carries site-root hrefs; the inline island's are measured from +// the page it sits in. `loadIndex` joins the fetched rows onto the page's own +// `data-rookery-search-base` prefix, and everything downstream reads `row.href` +// without knowing which mode produced it. A wrong prefix here is a site of +// broken links that no Typst test can see. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { readIndex, loadIndex } from "../src/island.js"; + +const ROWS = [ + { id: "idea:etal", name: "etal", text: "Et al", href: "ideas/etal.html" }, + { id: "idea:flat", name: "flat", text: "Flat", href: "ideas/flat.html" }, +]; + +// One page per test, installed as the global `document` the way a real page is. +const page = (body) => { + const { document } = parseHTML(`${body}`); + globalThis.document = document; +}; + +// `fetch` is global in node 18+, so a stub has to be installed and removed +// rather than injected. `calls` is what proves the fetch happened at all. +const stubFetch = (impl) => { + const calls = []; + globalThis.fetch = async (url) => { + calls.push(url); + return impl(url); + }; + return calls; +}; + +const ok = (value) => ({ ok: true, json: async () => value }); + +test("loadIndex: no element for the id is null, not a throw", async () => { + page(`
`); + assert.equal(await loadIndex("rookery-search-index"), null); +}); + +test("loadIndex: an inline island is parsed in place, with no fetch", async () => { + page( + ``, + ); + const calls = stubFetch(() => ok([])); + assert.deepEqual(await loadIndex("rookery-search-index"), ROWS); + assert.equal(calls.length, 0); +}); + +test("loadIndex: unparseable inline JSON is null, not a throw", async () => { + page(``); + assert.equal(await loadIndex("rookery-search-index"), null); +}); + +test("loadIndex: a pointer fetches its src and rebases every href onto the page's base", async () => { + page( + ``, + ); + const calls = stubFetch(() => ok(ROWS)); + const rows = await loadIndex("rookery-search-index"); + assert.deepEqual(calls, ["../rookery/search/index.json"]); + assert.deepEqual( + rows.map((r) => r.href), + ["../ideas/etal.html", "../ideas/flat.html"], + ); + // Every other field survives the rebasing untouched. + assert.equal(rows[0].id, "idea:etal"); + assert.equal(rows[0].text, "Et al"); +}); + +test("loadIndex: an empty base leaves hrefs exactly as fetched (a root page)", async () => { + page( + ``, + ); + stubFetch(() => ok(ROWS)); + assert.deepEqual(await loadIndex("rookery-search-index"), ROWS); +}); + +test("loadIndex: a missing base is read as the root, not as undefined", async () => { + page( + ``, + ); + stubFetch(() => ok(ROWS)); + const rows = await loadIndex("rookery-search-index"); + assert.deepEqual( + rows.map((r) => r.href), + ["ideas/etal.html", "ideas/flat.html"], + ); +}); + +test("loadIndex: a failed fetch is null — the bar goes inert, the page does not throw", async () => { + page( + ``, + ); + stubFetch(() => ({ ok: false, json: async () => ({}) })); + assert.equal(await loadIndex("rookery-search-index"), null); +}); + +test("loadIndex: a rejected fetch (offline, file://) is null", async () => { + page( + ``, + ); + globalThis.fetch = async () => { + throw new TypeError("Failed to fetch"); + }; + assert.equal(await loadIndex("rookery-search-index"), null); +}); + +test("loadIndex: a payload that is not an array is null", async () => { + page( + ``, + ); + stubFetch(() => ok({ rows: ROWS })); + assert.equal(await loadIndex("rookery-search-index"), null); +}); + +test("readIndex stays synchronous and inline-only, being a published surface", () => { + page( + ``, + ); + assert.deepEqual(readIndex("rookery-search-index"), ROWS); +}); diff --git a/search/0.1.0/test/matchranges.test.mjs b/search/0.1.0/test/matchranges.test.mjs new file mode 100644 index 0000000..be798c8 --- /dev/null +++ b/search/0.1.0/test/matchranges.test.mjs @@ -0,0 +1,78 @@ +// `matchRanges` — every occurrence of every term in `text`, found against the +// FOLDED copy and merged where they overlap (or touch), but returned as +// offsets that slice the ORIGINAL `text`. That only works because `fold` is +// length-preserving (lowercasing an ASCII letter, or turning `-`/`_` into a +// space, never changes the character count) — see the boundary test below. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { matchRanges } from "./internal.mjs"; + +test("matchRanges: no terms is empty", () => { + assert.deepEqual(matchRanges("hello world", []), []); +}); + +test("matchRanges: an empty term is skipped, not a wildcard match", () => { + assert.deepEqual(matchRanges("hello world", [""]), []); +}); + +test("matchRanges: no occurrence is empty", () => { + assert.deepEqual(matchRanges("hello world", ["zzz"]), []); +}); + +test("matchRanges: single term, single occurrence", () => { + assert.deepEqual(matchRanges("hello world", ["world"]), [{ start: 6, end: 11 }]); +}); + +test("matchRanges: case-insensitive, matches the folded copy", () => { + assert.deepEqual(matchRanges("Hello WORLD", ["world"]), [{ start: 6, end: 11 }]); +}); + +test("matchRanges: two terms, non-overlapping, both kept separate", () => { + // fold() turns each "_" into a space, one-for-one, so "ab__cd" (6 chars) + // folds to "ab cd" (still 6) — offsets found in the fold land correctly + // on the original. + assert.deepEqual(matchRanges("ab__cd", ["ab", "cd"]), [ + { start: 0, end: 2 }, + { start: 4, end: 6 }, + ]); +}); + +test("matchRanges: overlapping ranges from two different terms are merged", () => { + // "abc" at 0-3, "cde" at 2-5 — they overlap at index 2, so one range 0-5. + assert.deepEqual(matchRanges("abcdef", ["abc", "cde"]), [{ start: 0, end: 5 }]); +}); + +test("matchRanges: touching (not overlapping) ranges still merge — boundary is <=, not <", () => { + // "ab" at 0-2, "bc" at 1-3: overlapping by one character either way, but + // this also exercises the exact boundary the merge condition checks + // (`r.start <= last.end`), not just a comfortably-overlapping case. + assert.deepEqual(matchRanges("abc", ["ab", "bc"]), [{ start: 0, end: 3 }]); +}); + +test("matchRanges: repeated adjacent occurrences of one term collapse into one range", () => { + // "ab" occurs at 0, 2, 4 in "ababab". Each new occurrence starts exactly + // where the previous one ended (r.start === last.end), which the merge + // condition treats as touching and folds together. + assert.deepEqual(matchRanges("ababab", ["ab"]), [{ start: 0, end: 6 }]); +}); + +test("matchRanges: ranges are sorted by start regardless of term order", () => { + // "world" (second term) occurs before "hello" (first term) in the text — + // confirms the final sort, not just accumulation order. + assert.deepEqual(matchRanges("world hello", ["hello", "world"]), [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + ]); +}); + +test("matchRanges: length-preserving fold — offsets from the folded copy slice the ORIGINAL text correctly", () => { + // fold("Rheo-Context") -> "rheo context": the hyphen becomes a space and + // every letter lowercases, one-for-one, so both strings are 12 characters + // long and "context" is found at index 5 in the fold. Slicing the + // ORIGINAL "Rheo-Context" at [5, 12) must land exactly on "Context" — + // proving the offset transfers across the fold rather than drifting. + const text = "Rheo-Context"; + const ranges = matchRanges(text, ["context"]); + assert.deepEqual(ranges, [{ start: 5, end: 12 }]); + assert.equal(text.slice(ranges[0].start, ranges[0].end), "Context"); +}); diff --git a/search/0.1.0/test/paneldupes.test.mjs b/search/0.1.0/test/paneldupes.test.mjs new file mode 100644 index 0000000..b3f7f01 --- /dev/null +++ b/search/0.1.0/test/paneldupes.test.mjs @@ -0,0 +1,87 @@ +// `wirePanel`'s PILL MIRRORING, over a real DOM (linkedom): a facet value may be drawn +// TWICE on one panel — once in the pill block, once again inside its own row, which is +// what `@rookery/todos` wants for a row's own tag badge — and `aria-pressed` is the +// state, so both copies must always agree. +// +// WHAT IT PINS. Pressing either copy has to set BOTH buttons' `aria-pressed`, an in-row +// press has to filter the list exactly as a block press does (it is a real pill, not +// decoration), and pressing one copy then the other has to RELEASE the filter — the +// underlying value is toggled once, never added twice because two buttons answered one +// click each. The last test pins that a pill with only one copy on the page still +// behaves exactly as it always did. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +// `state` carries "ready" TWICE: once in the pill block, once inside row `a`'s own +// list item, standing in for a row drawing its own facet value as a badge. +const PANEL = ` +
+ +
+ + + + +
+

3 rows

+
    +
  • a + +
  • +
  • b
  • +
  • c
  • +
`; + +const wire = () => { + const { document } = parseHTML(PANEL); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + return document; +}; + +const blockPill = (document, value) => + document.querySelector(`.panel-pills .panel-pill[data-panel-value="${value}"]`); +const rowPill = (document, value) => + document.querySelector(`.panel-row .panel-pill[data-panel-value="${value}"]`); +const press = (el, document) => el.dispatchEvent(new document.defaultView.Event("click")); +const shownIds = (document) => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.dataset.panelText) + .sort(); + +test("pressing the block copy mirrors aria-pressed onto the row copy", () => { + const document = wire(); + press(blockPill(document, "ready"), document); + assert.equal(blockPill(document, "ready").getAttribute("aria-pressed"), "true"); + assert.equal(rowPill(document, "ready").getAttribute("aria-pressed"), "true"); +}); + +test("pressing the row copy mirrors onto the block copy and actually filters the list", () => { + const document = wire(); + press(rowPill(document, "ready"), document); + assert.equal(blockPill(document, "ready").getAttribute("aria-pressed"), "true"); + assert.equal(rowPill(document, "ready").getAttribute("aria-pressed"), "true"); + // An in-row pill is a working filter, not decoration: only the "ready" rows remain. + assert.deepEqual(shownIds(document), ["alpha", "gamma"]); +}); + +test("pressing one copy and then the other RELEASES the filter, not toggling it twice", () => { + const document = wire(); + press(blockPill(document, "ready"), document); + press(rowPill(document, "ready"), document); + assert.equal(blockPill(document, "ready").getAttribute("aria-pressed"), "false"); + assert.equal(rowPill(document, "ready").getAttribute("aria-pressed"), "false"); + assert.deepEqual(shownIds(document), ["alpha", "beta", "gamma"]); +}); + +test("a pill with only one copy on the panel behaves exactly as before", () => { + const document = wire(); + const blocked = blockPill(document, "blocked"); + press(blocked, document); + assert.equal(blocked.getAttribute("aria-pressed"), "true"); + press(blocked, document); + assert.equal(blocked.getAttribute("aria-pressed"), "false"); +}); diff --git a/search/0.1.0/test/panelinput.test.mjs b/search/0.1.0/test/panelinput.test.mjs new file mode 100644 index 0000000..3e7f97a --- /dev/null +++ b/search/0.1.0/test/panelinput.test.mjs @@ -0,0 +1,99 @@ +// `wirePanel`'s TEXT INPUT, over a real DOM (linkedom), because the bug this pins was +// invisible to every other kind of test here. +// +// WHAT IT WAS. `apply()` read `const s = ok ? score(row.text, q) : -1` and hid a row on +// `s < 0`. But `score` returns `null` for no match — its own header says so — and +// `null < 0` is FALSE in JavaScript. So a non-matching row was kept: the input reordered +// the list and filtered nothing, in BOTH panel kinds, from the day `#panel` shipped. +// +// WHY NOTHING CAUGHT IT. The unit suite pins `score` (which was right), the parity +// harness compares the JS scorer against the Typst one (also right), and the demo's +// `check.sh` greps the BUILT MARKUP, where the input has not been typed into. The +// defect lived in the one line joining a correct scorer to a correct list, and only a +// DOM test that types can see it. +// +// A SECOND DEFECT the same DOM shape pins: `resolve` scored the whole +// `data-panel-text` haystack (label + name + body) by SUBSEQUENCE, which is the +// right rule for a short title but matches almost any query against a body long +// enough — a subsequence of thousands of characters is not a rare event. The +// last test below fixes a fixture row long enough to show the difference: a +// query that is a subsequence of the body's characters but not a substring of +// any of its words must NOT match. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +const PANEL = `
+

3 rows

    +
  • a
  • +
  • b
  • +
  • c
  • +
`; + +const wire = () => { + const { document } = parseHTML(PANEL); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + const input = document.querySelector(".panel-input"); + return { + document, + type: (v) => { + input.value = v; + input.dispatchEvent(new document.defaultView.Event("input")); + }, + shown: () => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.textContent), + count: () => document.querySelector(".panel-count").textContent, + }; +}; + +test("an empty query shows every row, in the build-time order", () => { + const p = wire(); + assert.deepEqual(p.shown(), ["a", "b", "c"]); + assert.equal(p.count(), "3 rows"); +}); + +test("a query HIDES the rows it does not match", () => { + const p = wire(); + p.type("abstract"); + assert.deepEqual(p.shown(), ["a"]); + assert.equal(p.count(), "1 of 3"); +}); + +test("a query matching nothing hides everything and says so", () => { + const p = wire(); + p.type("zzz"); + assert.deepEqual(p.shown(), []); + assert.equal(p.count(), "nothing matches"); +}); + +test("clearing the query restores every row", () => { + const p = wire(); + p.type("abstract"); + p.type(""); + assert.deepEqual(p.shown(), ["a", "b", "c"]); + assert.equal(p.count(), "3 rows"); +}); + +test("a long body is matched by substring, not subsequence", () => { + const { document } = parseHTML(`
+

2 rows

    +
  • a
  • +
  • b
  • +
`); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + const input = document.querySelector(".panel-input"); + const shown = () => [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden).map((r) => r.textContent); + // `lprs` is a subsequence of row a's body and a substring of no word in it. + input.value = "lprs"; + input.dispatchEvent(new document.defaultView.Event("input")); + assert.deepEqual(shown(), []); + input.value = "rivers"; + input.dispatchEvent(new document.defaultView.Event("input")); + assert.deepEqual(shown(), ["a"]); +}); diff --git a/search/0.1.0/test/panelmulti.test.mjs b/search/0.1.0/test/panelmulti.test.mjs new file mode 100644 index 0000000..a026924 --- /dev/null +++ b/search/0.1.0/test/panelmulti.test.mjs @@ -0,0 +1,128 @@ +// `wirePanel`'s MULTI-VALUED FACETS (`data-panel-multi`), over a real DOM (linkedom), +// because the whole of this feature is a predicate reading an attribute — and the two +// halves that have to agree about it are in different languages. +// +// WHAT IT PINS. A scalar facet tests `wanted.has(row.values[field])`; a multi-valued +// one has to INTERSECT, and a row's attribute holds `" a b "` rather than a value. Get +// either half wrong and the pills match NOTHING while the page still renders and the +// build still passes — which is the failure mode every guard in `panel.typ` is written +// against, and the one a markup grep cannot see because the pills are correct markup. +// +// The composition rules are the point of the last two tests: within a group the values +// OR (pressing a second tag widens), and across groups they AND (a tag and a state +// narrow together). That is what `#filter-panel`'s single undifferentiated pill row +// could not express and what `multi:` exists to keep. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +// Three rows over two groups: `tag` is multi-valued, `state` is not. `b` carries both +// tags, which is the only row an intersection test and an equality test disagree about. +const PANEL = ` +
+ +
+ + + + + + + + +
+

3 rows

+
    +
  • a
  • +
  • b
  • +
  • c
  • +
`; + +const wire = () => { + const { document } = parseHTML(PANEL); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + return { + press: (facet, value) => + document + .querySelector(`.panel-pill[data-panel-facet="${facet}"][data-panel-value="${value}"]`) + .dispatchEvent(new document.defaultView.Event("click")), + shown: () => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.textContent) + .sort(), + count: () => document.querySelector(".panel-count").textContent, + }; +}; + +test("no pill pressed shows every row, the untagged one included", () => { + const p = wire(); + assert.deepEqual(p.shown(), ["a", "b", "c"]); + assert.equal(p.count(), "3 rows"); +}); + +test("a tag pill keeps every row CARRYING that tag, not one equal to it", () => { + const p = wire(); + p.press("tag", "phd"); + // `b`'s attribute is " frontend phd ": an equality test against "phd" finds nothing, + // which is exactly the bug this file exists to catch. + assert.deepEqual(p.shown(), ["b"]); + assert.equal(p.count(), "1 of 3"); +}); + +test("a row carrying two tags is matched by either", () => { + const p = wire(); + p.press("tag", "frontend"); + assert.deepEqual(p.shown(), ["a", "b"]); +}); + +test("two tag pills OR — a second press WIDENS the list", () => { + const p = wire(); + p.press("tag", "phd"); + assert.deepEqual(p.shown(), ["b"]); + p.press("tag", "frontend"); + assert.deepEqual(p.shown(), ["a", "b"]); +}); + +test("pressing a tag again releases it", () => { + const p = wire(); + p.press("tag", "phd"); + p.press("tag", "phd"); + assert.deepEqual(p.shown(), ["a", "b", "c"]); + assert.equal(p.count(), "3 rows"); +}); + +test("a tag group ANDs with a scalar group — the two narrow together", () => { + const p = wire(); + p.press("tag", "frontend"); + p.press("state", "ready"); + // `b` carries `frontend` but is blocked; `c` is ready but untagged. + assert.deepEqual(p.shown(), ["a"]); +}); + +test("a tag pill and a state that no row combines matches nothing", () => { + const p = wire(); + p.press("tag", "phd"); + p.press("state", "ready"); + assert.deepEqual(p.shown(), []); + assert.equal(p.count(), "nothing matches"); +}); + +test("with no data-panel-multi a tag attribute is compared as a whole value", () => { + // THE DEFAULT IS UNCHANGED, which is what keeps every panel written before this + // feature reading exactly as it did: absent the declaration the field is a scalar, + // so `" frontend phd "` is one value and matches no pill. Pinned so the tokenizing + // cannot leak into the scalar path. + const { document } = parseHTML(PANEL.replace(' data-panel-multi="tag"', "")); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + document + .querySelector('.panel-pill[data-panel-facet="tag"][data-panel-value="phd"]') + .dispatchEvent(new document.defaultView.Event("click")); + assert.deepEqual( + [...document.querySelectorAll(".panel-row")].filter((r) => !r.hidden).map((r) => r.textContent), + [], + ); +}); diff --git a/search/0.1.0/test/panelquery.test.mjs b/search/0.1.0/test/panelquery.test.mjs new file mode 100644 index 0000000..7bc0382 --- /dev/null +++ b/search/0.1.0/test/panelquery.test.mjs @@ -0,0 +1,157 @@ +// The `tags:` QUERY LANGUAGE IN A PANEL INPUT, over a real DOM (linkedom). +// +// WHAT IT PINS. `wirePanel` used to score the raw input against `data-panel-text` and +// nothing else, so typing `tags:todo` into a panel fuzzy-matched the literal string +// "tags:todo" against note titles and found nothing — while the same string in the +// search bar worked, because `search()` splits the query. Both readmes advertise +// `tags:todo&!tags:todo-closed`; only one of the two inputs honoured it. +// +// EVERY CASE HERE IS A DOM CASE on purpose. The parser and the evaluator are pinned by +// `just parity` and by the Typst fixtures; what is unpinned is the JOIN — reading the +// right attribute, folding it, splitting once, and ANDing the result with the pills. A +// mistake in any of those is correct-looking JavaScript that filters nothing or +// everything. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +// Four rows. `data-panel-all-tags` is the QUERY channel; `data-tag` is a multi-valued +// FACET, so the AND between a typed expression and a pressed pill can be exercised. +// `d` deliberately carries NO query channel at all — an older page's markup, which must +// fail a tag expression rather than pass it. +const PANEL = ` +
+ +
+ + + +
+

4 rows

+
    +
  • a
  • +
  • b
  • +
  • c
  • +
  • d
  • +
`; + +const wire = (html = PANEL) => { + const { document } = parseHTML(html); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + const input = document.querySelector(".panel-input"); + return { + type: (v) => { + input.value = v; + input.dispatchEvent(new document.defaultView.Event("input")); + }, + press: (facet, value) => + document + .querySelector(`.panel-pill[data-panel-facet="${facet}"][data-panel-value="${value}"]`) + .dispatchEvent(new document.defaultView.Event("click")), + shown: () => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.textContent) + .sort(), + count: () => document.querySelector(".panel-count").textContent, + }; +}; + +test("an empty query shows every row", () => { + const p = wire(); + assert.deepEqual(p.shown(), ["a", "b", "c", "d"]); + assert.equal(p.count(), "4 rows"); +}); + +test("`tags:todo` keeps the rows carrying that tag", () => { + const p = wire(); + p.type("tags:todo"); + // `c` carries `note`, `d` carries nothing at all. NOTE the prefix rule: an atom + // matches by prefix, so `todo` also matches `todo-closed` — which is why `b` is here + // and why the next case needs the negation to exclude it. + assert.deepEqual(p.shown(), ["a", "b"]); +}); + +test("`tags:todo&!tags:todo-closed` is the string both readmes advertise", () => { + const p = wire(); + // `!` negates exactly the clause after it, so a second tag clause under a + // negation needs its OWN `tags:` now that the field no longer opens a + // whole sub-expression on its own. + p.type("tags:todo&!tags:todo-closed"); + assert.deepEqual(p.shown(), ["a"]); + assert.equal(p.count(), "1 of 4"); +}); + +test("the expression filters and the residual text ranks", () => { + const p = wire(); + // `a` and `b` both carry `todo`; only `a`'s haystack holds "window". + p.type("tags:todo window"); + assert.deepEqual(p.shown(), ["a"]); +}); + +test("a half-typed expression behaves as its valid prefix", () => { + const p = wire(); + // A live input types every prefix of a valid query on the way to it, so + // `parseTagQuery` repairs rather than throwing and a dangling `&` is dropped. + p.type("tags:todo&"); + assert.deepEqual(p.shown(), ["a", "b"]); + // An unclosed group repairs the same way, discarding the dangling `(` — the + // second `tags:` is its own clause either side of it, which is what a + // reader typing `tags:(tags:todo|` one keystroke at a time is on the way to. + p.type("tags:(tags:todo"); + assert.deepEqual(p.shown(), ["a", "b"]); +}); + +test("a tag expression ANDs with a pressed pill", () => { + const p = wire(); + p.press("tag", "frontend"); + assert.deepEqual(p.shown(), ["a"]); + // `b` satisfies the expression but not the pill; the pill stays pressed and keeps + // filtering, which is the composition the panel commits to. + p.type("tags:todo-closed"); + assert.deepEqual(p.shown(), []); + assert.equal(p.count(), "nothing matches"); +}); + +test("a query with no `tags:` prefix is an ordinary fuzzy filter", () => { + const p = wire(); + p.type("window"); + assert.deepEqual(p.shown(), ["a", "c", "d"]); +}); + +test("a `tags:` clause is recognised anywhere in the tree, not only in leading position", () => { + const p = wire(); + // The implicit `&` ANDs the bare word with the field clause after it: `a`'s + // haystack matches "window" AND it carries `todo`, so it alone survives — + // `b` matches the tag but not the text, `c` matches the text but not the + // tag (it carries `note`, not `todo`), and `d` carries no tags at all. + p.type("window tags:todo"); + assert.deepEqual(p.shown(), ["a"]); +}); + +test("a row with no query channel fails every tag expression", () => { + const p = wire(); + p.type("tags:note"); + // `d` has no `data-panel-all-tags`. A row whose tags are unknown must not pass a + // filter it was never tested against. + assert.deepEqual(p.shown(), ["c"]); +}); + +test("the query channel is read, not the pill channel", () => { + // `b`'s pill channel (`data-tag`) is empty while its query channel holds + // `todo todo-closed`. Reading the wrong attribute would hide it here. + const p = wire(); + p.type("tags:todo-closed"); + assert.deepEqual(p.shown(), ["b"]); +}); + +test("matching folds case and hyphens, as the search bar does", () => { + const p = wire(); + p.type("tags:TODO-CLOSED"); + assert.deepEqual(p.shown(), ["b"]); + // `_fold` maps `-` and `_` to a space on both sides, so these are the same atom. + p.type("tags:todo_closed"); + assert.deepEqual(p.shown(), ["b"]); +}); diff --git a/search/0.1.0/test/panelsync.test.mjs b/search/0.1.0/test/panelsync.test.mjs new file mode 100644 index 0000000..6a1e34c --- /dev/null +++ b/search/0.1.0/test/panelsync.test.mjs @@ -0,0 +1,178 @@ +// `wirePanel`'s URL SYNC (`sync:`/`data-panel-sync`), over a real DOM (linkedom) +// with `history` and `location` stubbed by hand — linkedom supplies neither, and +// `commit`'s whole job is writing through them. +// +// THE STUB'S `replaceState` UPDATES ITS OWN `location.search`, the way a real +// browser's does, so a widget's second write in one test sees its first rather +// than a query string frozen at wire time. +// +// EVERY TEST CLAIMS ITS OWN KEY. `claimKey` (`urlstate.js`) is a process-wide +// singleton with no reset, and `node --test` runs every test in this file in one +// process, so reusing a key across tests would make every test after the first +// look like a second widget claiming an already-synced namespace. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +let keySeq = 0; +const nextKey = (base) => `${base}${keySeq++}`; + +// Three rows over one facet group, `state`, so both the text box and a pill can +// be exercised on the same fixture. `key` of `null` omits `data-panel-sync` +// entirely, for the panel that never opts in. +const facetPanel = (key) => ` +
+ +
+ + + + +
+

3 rows

+
    +
  • a
  • +
  • b
  • +
  • c
  • +
`; + +// `#filter-panel`'s shape: `data-panel-mode="tags"`, pills carrying +// `data-panel-tag` with no group wrapper, rows carrying `data-panel-tags`. +const tagPanel = (key) => ` +
+ +
+ + +
+

2 rows

+
    +
  • a
  • +
  • b
  • +
`; + +let captured; + +const wire = (html, search = "") => { + captured = null; + globalThis.location = { pathname: "/index.html", search, hash: "" }; + globalThis.history = { + replaceState: (_state, _title, url) => { + captured = url; + const q = url.indexOf("?"); + const h = url.indexOf("#"); + const end = h === -1 ? url.length : h; + globalThis.location.search = q === -1 ? "" : url.slice(q, end); + }, + }; + const { document } = parseHTML(html); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + const input = document.querySelector(".panel-input"); + return { + document, + input, + type: (v) => { + input.value = v; + input.dispatchEvent(new document.defaultView.Event("input")); + }, + escape: () => { + const ev = new document.defaultView.Event("keydown"); + ev.key = "Escape"; + input.dispatchEvent(ev); + }, + press: (facet, value) => + document + .querySelector(`.panel-pill[data-panel-facet="${facet}"][data-panel-value="${value}"]`) + .dispatchEvent(new document.defaultView.Event("click")), + pressTag: (tag) => + document + .querySelector(`.panel-pill[data-panel-tag="${tag}"]`) + .dispatchEvent(new document.defaultView.Event("click")), + shown: () => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.textContent) + .sort(), + count: () => document.querySelector(".panel-count").textContent, + }; +}; + +test("rehydrates the filter box from `.q` and filters on first paint", () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key), `${key}.q=abstract`); + assert.equal(p.input.value, "abstract"); + assert.deepEqual(p.shown(), ["a"]); + assert.equal(p.count(), "1 of 3"); +}); + +test("rehydrates a pressed pill from `.`, filtering with no click dispatched", () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key), `${key}.state=ready`); + const pill = p.document.querySelector('.panel-pill[data-panel-facet="state"][data-panel-value="ready"]'); + assert.equal(pill.getAttribute("aria-pressed"), "true"); + assert.deepEqual(p.shown(), ["a", "c"]); + assert.equal(p.count(), "2 of 3"); +}); + +test("a URL value naming no pill on the page is ignored, not applied", () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key), `${key}.state=nonexistent`); + assert.deepEqual(p.shown(), ["a", "b", "c"]); + for (const pill of p.document.querySelectorAll(".panel-pill")) { + assert.equal(pill.getAttribute("aria-pressed"), "false"); + } +}); + +test("pressing a pill persists it to `.`, and pressing again removes it", () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key)); + p.press("state", "ready"); + assert.match(captured, new RegExp(`${key}\\.state=ready`)); + p.press("state", "ready"); + assert.doesNotMatch(captured, new RegExp(`${key}\\.state`)); +}); + +test("typing persists `.q` after the debounce; Escape clears it immediately", async () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key)); + p.type("reference"); + // Nothing yet — the write is debounced, not synchronous with the keystroke. + assert.equal(captured, null); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.match(captured, new RegExp(`${key}\\.q=reference`)); + p.escape(); + assert.doesNotMatch(captured, new RegExp(`${key}\\.q`)); +}); + +test("persisting merges into the existing query string rather than replacing it", () => { + const key = nextKey("todos"); + const p = wire(facetPanel(key), "tab=todos&other.q=x"); + p.press("state", "ready"); + assert.match(captured, /tab=todos/); + assert.match(captured, /other\.q=x/); + assert.match(captured, new RegExp(`${key}\\.state=ready`)); +}); + +test("a panel with no `sync:` never touches the address bar", () => { + const p = wire(facetPanel(null)); + p.type("abstract"); + assert.equal(captured, null); + p.press("state", "ready"); + assert.equal(captured, null); +}); + +test("tag mode persists a pressed pill to `.t` and rehydrates it back", () => { + const writeKey = nextKey("ideas"); + const w = wire(tagPanel(writeKey)); + w.pressTag("urgent"); + assert.match(captured, new RegExp(`${writeKey}\\.t=urgent`)); + + // A fresh widget under its own key, standing in for the next page load: the + // written state is read back the same way `readSync` was just proven to write it. + const readKey = nextKey("ideas"); + const r = wire(tagPanel(readKey), `${readKey}.t=urgent`); + assert.equal(r.document.querySelector('.panel-pill[data-panel-tag="urgent"]').getAttribute("aria-pressed"), "true"); + assert.deepEqual(r.shown(), ["a"]); +}); diff --git a/search/0.1.0/test/panelunion.test.mjs b/search/0.1.0/test/panelunion.test.mjs new file mode 100644 index 0000000..5ea8db0 --- /dev/null +++ b/search/0.1.0/test/panelunion.test.mjs @@ -0,0 +1,125 @@ +// `wirePanel`'s UNION GROUPS (`data-panel-union`), over a real DOM (linkedom). +// +// WHAT IT PINS, and why the bug it is written against was invisible. `#panel` ANDs +// across groups, which is right when each group asks a different question. @rookery/todos +// splits ONE question — what is this todo about — into `epic` and `tag`, and a todo +// carrying `epic-rheo` is deliberately given no `rheo` pill in the tag group. So pressing +// `rheo` and `birds` asked for a row that is BOTH, which no row is: two pills that each +// worked alone showed "nothing matches" together, on correct markup, with the build +// passing. Only a predicate test catches that. +// +// The last two tests are the boundary: an ordinary group still ANDs against a union +// group, and a union group with nothing pressed constrains nothing at all. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wirePanel } from "../src/panel.js"; + +// The waterline shape, minimised: `epic` is scalar, `tag` is multi-valued, `state` is the +// group on the other line. No row has both `epic="rheo"` and `birds` among its tags — +// that is the point, and it is not a gap in the fixture. +const PANEL = ` +
+ +
+
+ + + + + + + + +
+
+ + + + +
+
+

4 rows

+
    +
  • a
  • +
  • b
  • +
  • c
  • +
  • d
  • +
`; + +const wire = (html = PANEL) => { + const { document } = parseHTML(html); + globalThis.document = document; + wirePanel(document.querySelector(".panel"), 0); + return { + press: (facet, value) => + document + .querySelector(`.panel-pill[data-panel-facet="${facet}"][data-panel-value="${value}"]`) + .dispatchEvent(new document.defaultView.Event("click")), + shown: () => + [...document.querySelectorAll(".panel-row")] + .filter((r) => !r.hidden) + .map((r) => r.textContent) + .sort(), + count: () => document.querySelector(".panel-count").textContent, + }; +}; + +test("no pill pressed shows every row", () => { + const p = wire(); + assert.deepEqual(p.shown(), ["a", "b", "c", "d"]); + assert.equal(p.count(), "4 rows"); +}); + +test("one union group pressed behaves exactly as it did — this changes nothing alone", () => { + const p = wire(); + p.press("epic", "rheo"); + assert.deepEqual(p.shown(), ["a"]); + p.press("epic", "rheo"); + p.press("tag", "birds"); + assert.deepEqual(p.shown(), ["b", "c"]); +}); + +test("two union groups OR — the bug, stated", () => { + const p = wire(); + p.press("epic", "rheo"); + p.press("tag", "birds"); + // ANDed this is empty: no row is under `rheo` AND tagged `birds`. That is what the + // panel used to show, on two pills that each worked alone. + assert.deepEqual(p.shown(), ["a", "b", "c"]); + assert.equal(p.count(), "3 of 4"); +}); + +test("values still OR within each union group", () => { + const p = wire(); + p.press("epic", "rheo"); + p.press("epic", "admin"); + p.press("tag", "homelab"); + assert.deepEqual(p.shown(), ["a", "c", "d"]); +}); + +test("an ordinary group still ANDs against the union ones", () => { + const p = wire(); + p.press("epic", "rheo"); + p.press("tag", "birds"); + p.press("state", "ready"); + // `b` is under neither pressed subject alone — it is blocked, so the state group drops + // it — and `a`/`c` are each a subject hit that is also ready. + assert.deepEqual(p.shown(), ["a", "c"]); +}); + +test("a union group with nothing pressed constrains nothing", () => { + const p = wire(); + p.press("state", "blocked"); + // Both union groups are empty, so the union clause must not be asked at all — the + // failure mode being a declaration that hides every row until a subject is pressed. + assert.deepEqual(p.shown(), ["b"]); +}); + +test("without data-panel-union the groups AND, exactly as before", () => { + const p = wire(PANEL.replace(' data-panel-union="epic tag"', "")); + p.press("epic", "rheo"); + p.press("tag", "birds"); + assert.deepEqual(p.shown(), []); + assert.equal(p.count(), "nothing matches"); +}); diff --git a/search/0.1.0/test/parity.mjs b/search/0.1.0/test/parity.mjs new file mode 100644 index 0000000..d75a21d --- /dev/null +++ b/search/0.1.0/test/parity.mjs @@ -0,0 +1,465 @@ +// Pins the Typst and JavaScript copies of the ranking rule to each other, at +// both layers: `fuzzy-score`/`body-score` against `score`/`bodyScore`, and the +// tiering rule above them — `_rank` against `search` — where a drift is silent +// (the static Typst listing and the live bar simply disagree about ordering). +// Run from the package root: `just parity`. +// +// Tests the SOURCE module, not `dist/lib.js`: vite bundles and minifies, it does +// not change semantics, and testing the source means the fixture runs without a +// build. The module's auto-init is guarded on `typeof document`, so importing it +// under node wires nothing. +import { execFileSync } from "node:child_process"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { score, bodyScore, search, splitQuery, evalTagQuery, evalClauses, fold } from "../src/search.js"; + +// `file` defaults to the hand-written fixture; the generated suite below +// points it at its own throwaway `.typ` instead, so both fixtures share one +// `typst eval` shape. +const evalMetadata = (label, file = "test/parity.typ") => + JSON.parse( + execFileSync("typst", [ + "eval", "--features", "html", "--root", ".", "--format", "json", + `query(<${label}>).first().value`, "--in", file, + ], { encoding: "utf8" }), + ); + +let bad = 0; + +const rows = evalMetadata("parity"); +for (const row of rows) { + const js = score(row.hay, row.query); + if (js !== row.score) { + bad++; + console.error(`MISMATCH hay=${JSON.stringify(row.hay)} query=${JSON.stringify(row.query)} typst=${row.score} js=${js}`); + } +} +if (bad > 0) { + console.error(`${bad}/${rows.length} cases disagree — fuzzy-score and search.js have drifted`); + process.exit(1); +} +console.log(`parity OK across ${rows.length} cases`); + +let bodyBad = 0; +const bodyRows = evalMetadata("body-parity"); +for (const row of bodyRows) { + const js = bodyScore(row.body, row.query); + if (js !== row.score) { + bodyBad++; + console.error(`MISMATCH body=${JSON.stringify(row.body)} query=${JSON.stringify(row.query)} typst=${row.score} js=${js}`); + } +} +if (bodyBad > 0) { + console.error(`${bodyBad}/${bodyRows.length} cases disagree — body-score and bodyScore have drifted`); + process.exit(1); +} +console.log(`body parity OK across ${bodyRows.length} cases`); + +// The layer above the scorers. Compares the id SEQUENCE, not a set: which tier a +// row lands in, how tiers order against each other, how ties break and where +// `limit` cuts are all order, and order is the thing that drifts. +let tierBad = 0; +const tierRows = evalMetadata("tier-rows"); +const tierCases = evalMetadata("tier-parity"); +for (const c of tierCases) { + const js = search(tierRows, c.query, c.limit ?? null); + const jsIds = js.map((h) => h.id); + const jsScores = js.map((h) => h.score); + const jsKinds = js.map((h) => h.kind); + const at = [...c.ids, ...jsIds].findIndex( + (_, i) => c.ids[i] !== jsIds[i] || c.scores[i] !== jsScores[i] || c.kinds[i] !== jsKinds[i], + ); + const same = + c.ids.length === jsIds.length && + c.ids.every((id, i) => id === jsIds[i] && c.scores[i] === jsScores[i] && c.kinds[i] === jsKinds[i]); + if (!same) { + tierBad++; + console.error( + `MISMATCH query=${JSON.stringify(c.query)} limit=${c.limit} first differs at index ${at}\n` + + ` typst: ${JSON.stringify(c.ids.map((id, i) => [id, c.scores[i], c.kinds[i]]))}\n` + + ` js: ${JSON.stringify(jsIds.map((id, i) => [id, jsScores[i], jsKinds[i]]))}`, + ); + } +} +if (tierBad > 0) { + console.error(`${tierBad}/${tierCases.length} cases disagree — _rank and search have drifted`); + process.exit(1); +} +console.log(`tier parity OK across ${tierCases.length} cases`); + +// The query parser and its tag-only evaluator — the one rule here whose +// output is not a number, so it is diffed AS DATA: the RPN flattened to a +// string exactly as `_rpn-str` flattens it in `test/parity.typ`, and one +// boolean per fixed tag set. Both, not just the verdict: a parser that +// agreed only on the final booleans could still have drifted on precedence. +let tagBad = 0; +const tagRows = evalMetadata("tag-parity"); +// CHARACTER FOR CHARACTER `tag-sets` in `test/parity.typ`, AND IN THE SAME +// ORDER — `evals` is compared positionally, so a reordering here reads as a +// parser drift. Change one list, change the other. +const TAG_SETS = [["note"], ["note", "draft"], ["draft"], ["a", "c"], ["b", "c"], []]; +// `_rpn-str`'s twin: an atom's value in quotes, its field bare and prefixed +// with a colon when non-empty, an operator bare — joined by spaces. +const rpnStr = (rpn) => + rpn.map((t) => (t.t === "atom" ? `${t.f ? t.f + ":" : ""}"${t.v}"` : t.v)).join(" "); +for (const row of tagRows) { + const js = splitQuery(row.query); + const jsRpn = rpnStr(js.rpn); + // `fold` each tag, as the fixture does and as every real caller must: + // `evalTagQuery`'s atoms were folded at push time and it compares folded + // against folded. + const jsEvals = TAG_SETS.map((s) => evalTagQuery(js.rpn, s.map(fold))); + if (jsRpn !== row.rpn || JSON.stringify(jsEvals) !== JSON.stringify(row.evals)) { + tagBad++; + console.error(`MISMATCH query=${JSON.stringify(row.query)} + typst rpn=${JSON.stringify(row.rpn)} evals=${JSON.stringify(row.evals)} + js rpn=${JSON.stringify(jsRpn)} evals=${JSON.stringify(jsEvals)}`); + } +} +if (tagBad > 0) { + console.error(`${tagBad}/${tagRows.length} cases disagree — parse-tag-query and parseTagQuery have drifted`); + process.exit(1); +} +console.log(`tag parity OK across ${tagRows.length} cases`); + +// `eval-clauses`'s cases — CHARACTER FOR CHARACTER `clause-cases` in +// `test/parity.typ`, AND IN THE SAME ORDER, each an RPN plus a resolve table +// keyed by atom value returning `{matched, score}`. +let clauseBad = 0; +const clauseRows = evalMetadata("clause-parity"); +const clauseCases = [ + // `a & b`, both matching. + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "&" }], + resolve: { a: { matched: true, score: 3 }, b: { matched: true, score: 5 } } }, + // `a | b`, only the right side matching. + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "|" }], + resolve: { a: { matched: false, score: 9 }, b: { matched: true, score: 4 } } }, + // `a | b`, both matching. + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "|" }], + resolve: { a: { matched: true, score: 2 }, b: { matched: true, score: 7 } } }, + // `!a` over a matching `a`. + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "op", v: "!" }], + resolve: { a: { matched: true, score: 6 } } }, + // a gating clause ANDed with a scoring clause. + { rpn: [{ t: "atom", f: "field", v: "val" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "&" }], + resolve: { val: { matched: true, score: 0 }, b: { matched: true, score: 8 } } }, + // a dangling operator. + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "op", v: "&" }], + resolve: { a: { matched: true, score: 4 } } }, + // an empty RPN. + { rpn: [], resolve: {} }, + // `a & b`, a name-tier hit and a body-tier hit: tier promotes to "name". + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "&" }], + resolve: { a: { matched: true, score: 3, tier: "name" }, b: { matched: true, score: 5, tier: "body" } } }, + // `a | b`, only the body-tier side matching: tier is "body", not "none". + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "|" }], + resolve: { a: { matched: false, score: 9, tier: "name" }, b: { matched: true, score: 4, tier: "body" } } }, + // `!a` over a name-tier hit: negation carries tier "none". + { rpn: [{ t: "atom", f: "", v: "a" }, { t: "op", v: "!" }], + resolve: { a: { matched: true, score: 6, tier: "name" } } }, + // a gate (tier "none") ANDed with a body-tier hit: the gate must not pull + // the combined tier down to "none". + { rpn: [{ t: "atom", f: "field", v: "val" }, { t: "atom", f: "", v: "b" }, { t: "op", v: "&" }], + resolve: { val: { matched: true, score: 0, tier: "none" }, b: { matched: true, score: 8, tier: "body" } } }, +]; +clauseCases.forEach((c, i) => { + const row = clauseRows[i]; + const jsRpn = rpnStr(c.rpn); + const js = evalClauses(c.rpn, (field, value) => c.resolve[value]); + if (jsRpn !== row.rpn || JSON.stringify(js) !== JSON.stringify(row.result)) { + clauseBad++; + console.error(`MISMATCH rpn=${JSON.stringify(jsRpn)} + typst rpn=${JSON.stringify(row.rpn)} result=${JSON.stringify(row.result)} + js rpn=${JSON.stringify(jsRpn)} result=${JSON.stringify(js)}`); + } +}); +if (clauseBad > 0) { + console.error(`${clauseBad}/${clauseCases.length} cases disagree — eval-clauses and evalClauses have drifted`); + process.exit(1); +} +console.log(`clause parity OK across ${clauseCases.length} cases`); + +// ---- Generated fuzz suite -------------------------------------------------- +// +// Everything above is a regression table: every case was added chasing a +// defect already found. The two scorers are implemented in two languages with +// two different string models — Typst's `.clusters()` (extended grapheme +// clusters) against a JS `[...str]` spread (UTF-16 code points) — and that gap +// is exactly the kind of thing a hand-written table does not think to probe. +// MEASURED (typst 0.15.1): a base character plus a combining mark +// (`"e" + "́"`) is ONE Typst cluster and TWO JS code points; a ZWJ emoji +// sequence (family: 👨‍👩‍👧‍👦) is ONE cluster and SEVEN code points; a +// variation-selector emoji (❤️) is ONE cluster and TWO code points. Every +// place `fuzzy-score`/`score` count "one character" — the match loop, the +// length-difference bonus, the near-start bonus — reads a different unit on +// each side for a haystack or query containing one of these, so this is +// where real drift is most likely to be sitting undetected. +// +// FIXED SEED, so a failure is reproducible without saving the case first — +// the whole reason for the paste-ready MISMATCH line below. +const SEED = 0xc0ffee; +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rand = mulberry32(SEED); +const randInt = (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1)); // inclusive +const pick = (arr) => arr[randInt(0, arr.length - 1)]; + +// The alphabet. ASCII words (the common case, weighted heaviest below), +// `-`/`_` joins (what `_fold` exists to collapse), combining-mark sequences +// and ZWJ/variation-selector emoji (the cluster-vs-codepoint gap above), and +// CJK words (single codepoint, single cluster, single grapheme on both sides +// — the control group that should never disagree). +const ASCII_WORDS = [ + "window", "windows", "flat", "ids", "tags", "depth", "budget", "index", + "note", "draft", "alpha", "beta", "rheo", "context", "html", "typst", + "script", "render", "glass", "spine", "width", "widow", "wind", "wander", + "under", "over", "cafe", "search", "query", "score", +]; +const COMBINING_BASE = ["e", "a", "o", "u", "i", "n", "c", "y"]; +// Acute, grave, breve, diaeresis, cedilla, tilde — MEASURED above, each pairs +// with its base into exactly one Typst cluster. +const COMBINING_MARKS = ["́", "̀", "̆", "̈", "̧", "̃"]; +const PRECOMPOSED = ["café", "naïve", "niño", "façade", "résumé", "jalapeño"]; +const EMOJI_VS16 = ["❤️", "☀️", "✈️"]; // ❤️ ☀️ ✈️ +const EMOJI_ZWJ = [ + "\u{1f468}‍\u{1f469}‍\u{1f467}‍\u{1f466}", // family: man, woman, girl, boy + "\u{1f469}‍\u{1f4bb}", // woman technologist + "\u{1f3f3}️‍\u{1f308}", // rainbow flag +]; +const CJK_WORDS = [ + "窗户", "深度", "索引", "笔记", "视窗", "预算", "中文", "检索", "日本語", "ウィンドウ", "カフェ", "ノート", +]; + +const mixedCase = (w) => + [...w].map((c) => (rand() < 0.5 ? c.toUpperCase() : c.toLowerCase())).join(""); + +// One "term" — no raw space inside, since both scorers split on a literal +// space. Weighted toward plain ASCII (the bulk of a real hay/body) with the +// exotic kinds appearing often enough to matter. +function randomToken() { + const kind = pick([ + "ascii", "ascii", "ascii", "ascii", "hyphen", "hyphen", + "combining", "precomposed", "emoji-vs16", "emoji-zwj", "cjk", + ]); + switch (kind) { + case "ascii": { + const w = pick(ASCII_WORDS); + return rand() < 0.3 ? mixedCase(w) : w; + } + case "hyphen": + return `${pick(ASCII_WORDS)}${pick(["-", "_"])}${pick(ASCII_WORDS)}`; + case "combining": { + let s = ""; + for (let i = 0, n = randInt(1, 3); i < n; i++) s += pick(COMBINING_BASE) + pick(COMBINING_MARKS); + return s; + } + case "precomposed": + return pick(PRECOMPOSED); + case "emoji-vs16": + return pick(EMOJI_VS16); + case "emoji-zwj": + return pick(EMOJI_ZWJ); + case "cjk": + return pick(CJK_WORDS); + } +} + +const randomHay = () => { + const n = randInt(2, 6); + const parts = Array.from({ length: n }, randomToken); + return parts.join(pick([" ", " ", " ", "-", "_", ""])); +}; + +// Query strategies over a hay, sliced at CODE-POINT boundaries (`[...hay]`) — +// the same unit `score()` iterates in. Slicing there rather than at cluster +// boundaries is deliberate: it is exactly what lets a substring/subsequence +// query land in the MIDDLE of a combining-mark or ZWJ sequence, which is the +// case that actually exercises the cluster-vs-codepoint gap. +const codepoints = (s) => [...s]; + +function substringQuery(hay) { + const cps = codepoints(hay); + if (cps.length === 0) return ""; + const start = randInt(0, cps.length - 1); + const len = randInt(1, Math.min(6, cps.length - start)); + return cps.slice(start, start + len).join(""); +} + +function subsequenceQuery(hay) { + const cps = codepoints(hay); + if (cps.length === 0) return ""; + const k = randInt(1, Math.min(5, cps.length)); + const idxs = new Set(); + while (idxs.size < k) idxs.add(randInt(0, cps.length - 1)); + return [...idxs].sort((a, b) => a - b).map((i) => cps[i]).join(""); +} + +// A substring/subsequence, then perturbed so it is unlikely to still be a +// clean match — the "near-miss" the bead asks for, exercising the `none`/ +// `null` path and the boundary around it. +function nearMissQuery(hay) { + const cps = codepoints(rand() < 0.5 ? substringQuery(hay) : subsequenceQuery(hay)); + if (cps.length === 0) return pick(ASCII_WORDS); + switch (pick(["drop", "insert", "swapcase", "reverse"])) { + case "drop": + cps.splice(randInt(0, cps.length - 1), 1); + break; + case "insert": + cps.splice(randInt(0, cps.length), 0, pick(ASCII_WORDS)[0]); + break; + case "swapcase": { + const i = randInt(0, cps.length - 1); + cps[i] = cps[i] === cps[i].toUpperCase() ? cps[i].toLowerCase() : cps[i].toUpperCase(); + break; + } + case "reverse": + cps.reverse(); + break; + } + return cps.join(""); +} + +function randomFuzzyCase() { + const hay = randomHay(); + if (rand() < 0.05) return { hay, query: "" }; // the empty-query edge, scores 0 on both sides + const query = pick([substringQuery, subsequenceQuery, nearMissQuery])(hay); + return { hay, query }; +} + +// `body-score`'s body is a TERM LIST, not prose — see its comment in +// `src/lib.typ` — so a generated body is a space-joined run of the same +// token pool, and a query term is drawn from an EXACT body term, a PREFIX of +// one (substring matching), or a term absent from the body (the AND-miss +// path, `none` on both sides). +const randomBody = () => Array.from({ length: randInt(3, 10) }, randomToken).join(" "); + +function randomBodyQuery(body) { + const terms = body.split(" ").filter((t) => t !== ""); + const k = randInt(1, Math.min(3, terms.length || 1)); + const qterms = []; + for (let i = 0; i < k; i++) { + if (terms.length === 0) { qterms.push(pick(ASCII_WORDS)); continue; } + switch (pick(["exact", "prefix", "miss"])) { + case "exact": + qterms.push(pick(terms)); + break; + case "prefix": { + const cps = codepoints(pick(terms)); + qterms.push(cps.slice(0, randInt(1, cps.length)).join("")); + break; + } + case "miss": + qterms.push(pick(ASCII_WORDS) + "zzqx"); // vanishingly unlikely to be a substring of any term + break; + } + } + return qterms.join(" "); +} + +// Typst string-literal escaping — backslash, quote, and any control +// character (none of the alphabet above should ever produce one, but a +// mutation strategy like `insert`/`reverse` is otherwise unconstrained). +// Everything else, including every non-ASCII character in the alphabet +// above, is written straight into the UTF-8 `.typ` source with no escaping. +function typstStr(s) { + let out = ""; + for (const ch of s) { + if (ch === "\\") out += "\\\\"; + else if (ch === '"') out += '\\"'; + else if (ch.codePointAt(0) < 0x20) out += "\\u{" + ch.codePointAt(0).toString(16) + "}"; + else out += ch; + } + return `"${out}"`; +} +const typstTuple = (a, b) => `(${typstStr(a)}, ${typstStr(b)})`; + +const N_FUZZY = 250; +const N_BODY = 150; +const fuzzyCases = Array.from({ length: N_FUZZY }, randomFuzzyCase); +const bodyCases = Array.from({ length: N_BODY }, () => { + const body = randomBody(); + return { body, query: randomBodyQuery(body) }; +}); + +// Written next to `parity.typ`, imports the same `/src/lib.typ`, evaluated +// exactly as `evalMetadata` already evaluates the hand-written fixture, then +// deleted — this file is a byproduct of one run, not a fixture to keep. +// A pid+timestamp name, not a fixed one, because another agent running +// `just parity` concurrently in this same checkout must not collide with it. +const genFile = `test/.generated-parity-${process.pid}-${Date.now()}.typ`; +const genSrc = `// AUTO-GENERATED by test/parity.mjs, seed 0x${SEED.toString(16)} — do not +// edit, do not commit. Deleted by the run that wrote it; see its comment. +#import "/src/lib.typ": body-score, fuzzy-score +#let fuzzy-cases = ( + ${fuzzyCases.map((c) => typstTuple(c.hay, c.query)).join(",\n ")}, +) +#metadata(fuzzy-cases.map(c => ( + hay: c.at(0), + query: c.at(1), + score: fuzzy-score(c.at(0), c.at(1)), +))) +#let body-cases = ( + ${bodyCases.map((c) => typstTuple(c.body, c.query)).join(",\n ")}, +) +#metadata(body-cases.map(c => ( + body: c.at(0), + query: c.at(1), + score: body-score(c.at(0), c.at(1)), +))) +`; + +// `exitCode` rather than an in-place `process.exit()`: `process.exit()` skips +// pending `finally` blocks entirely (MEASURED — it left the temp file on +// disk on a mismatch, the exact leak the `finally` below exists to prevent), +// so a bad exit is deferred until after cleanup has actually run. +let exitCode = 0; +writeFileSync(genFile, genSrc, "utf8"); +try { + let genFuzzyBad = 0; + const genFuzzyRows = evalMetadata("generated-parity", genFile); + for (const row of genFuzzyRows) { + const js = score(row.hay, row.query); + if (js !== row.score) { + genFuzzyBad++; + console.error( + `GENERATED MISMATCH hay=${JSON.stringify(row.hay)} query=${JSON.stringify(row.query)} typst=${row.score} js=${js}\n` + + ` paste into parity.typ's \`cases\`: ${typstTuple(row.hay, row.query)},`, + ); + } + } + if (genFuzzyBad > 0) { + console.error(`${genFuzzyBad}/${genFuzzyRows.length} generated fuzzy-score cases disagree — see above for paste-ready regression cases`); + exitCode = 1; + } else { + console.log(`generated fuzzy parity OK across ${genFuzzyRows.length} cases (seed 0x${SEED.toString(16)})`); + } + + let genBodyBad = 0; + const genBodyRows = evalMetadata("generated-body-parity", genFile); + for (const row of genBodyRows) { + const js = bodyScore(row.body, row.query); + if (js !== row.score) { + genBodyBad++; + console.error( + `GENERATED MISMATCH body=${JSON.stringify(row.body)} query=${JSON.stringify(row.query)} typst=${row.score} js=${js}\n` + + ` paste into parity.typ's \`body-cases\`: ${typstTuple(row.body, row.query)},`, + ); + } + } + if (genBodyBad > 0) { + console.error(`${genBodyBad}/${genBodyRows.length} generated body-score cases disagree — see above for paste-ready regression cases`); + exitCode = 1; + } else { + console.log(`generated body parity OK across ${genBodyRows.length} cases (seed 0x${SEED.toString(16)})`); + } +} finally { + unlinkSync(genFile); +} +if (exitCode !== 0) process.exit(exitCode); diff --git a/search/0.1.0/test/parity.typ b/search/0.1.0/test/parity.typ new file mode 100644 index 0000000..2871205 --- /dev/null +++ b/search/0.1.0/test/parity.typ @@ -0,0 +1,440 @@ +// The parity fixture. `test/parity.mjs` reads this via `typst eval` and diffs +// every score against `score` in `src/search.js`. Not shipped: the +// release archive tars `dist/`, which vite builds from `src/` alone. +#import "/src/lib.typ": ( + _fold, _rank, body-score, eval-clauses, eval-tag-query, fuzzy-score, + parse-tag-query, split-query, +) +#let cases = ( + ("flat-ids", "flat"), ("flat-ids", "flat ids"), ("flat-ids", "flat-ids"), + ("windows", "window"), ("window-depth", "window"), ("windows", "windows"), + ("Flat ids, and why", "why"), ("Flat ids, and why", "flt"), + ("tags", "zzz"), ("tags", ""), ("Windows", "wnd"), + ("the window depth budget, and why an index does not want it", "window"), + ("W i n d o w s", "window"), ("ETAL", "etal"), ("etal", "ETAL"), + ("a_b_c", "a b"), ("Café", "cafe"), + // CLUSTERS WIDER THAN ONE CODE POINT, named rather than left to the generated + // suite. The port counted UTF-16 code points where `fuzzy-score` counts + // extended grapheme clusters, and since `hc.len()` and `first` are global terms + // in the score, one such sequence anywhere in a hay moved every query against + // it. These five are the failures that found it, kept as a named regression: + // the generator can only catch them again at a seed that happens to draw them. + // `\u{306}` written as an escape, NOT as a precomposed `ĕ`: the precomposed form + // is one code point AND one cluster, so it agrees either way and pins nothing. + // MEASURED — with the spread restored, four of these five fail and the + // precomposed spelling of this one passed. + ("👩‍💻-queRY", "💻eY"), ("résumé-❤️", "rsu❤"), ("❤️ window", "w"), + ("e\u{306} beta", "e\u{306} be"), ("检索c̃", "索c̃"), +) +#metadata(cases.map(c => ( + hay: c.at(0), + query: c.at(1), + score: fuzzy-score(c.at(0), c.at(1)), +))) + +// A second fixture for `body-score` — the AND, rank-scored matcher over note +// bodies. Its own labelled array, `` above kept untouched, because +// `body-score` is a different rule with a different signature (`none` on ANY +// missing term, not a fuzzy subsequence). +// +// THE BODIES ARE TERM LISTS, NOT PROSE, and that is the fixture following the +// rule rather than a convenience. What `#search-index` ships is +// `_compress-corpus`' output — a note's most distinctive terms, space-joined in +// weight order — and RANK over that list is the whole score now, so prose here +// would pin a shape the browser never sees. (`#search-ideas` does still score +// full prose through the same function, a word simply being a term there; no case +// below needs to be prose to hold that down.) +// +// NO CASE COUNTS CLUSTERS, because the rule no longer does — a rank is a term +// index, which Typst and JavaScript count identically. The non-ASCII case is +// therefore about accent folding NOT happening, and nothing else. +#let body-cases = ( + // A compressed island row, as `_compress-corpus` really emits them (this one + // is the spike's own sample output). Multi-term AND, a hyphenated and a dotted + // term surviving whole, the exact-match +3 twice, and one bucket step: + // `rheo-context` at rank 0 scores 10 + 3, `html` at rank 6 scores + // 10 - int(6 / 4) = 9, + 3. So 25. + ( + "rheo-context typst changes 0.5.1 removing types html released 0.6.0", + "rheo-context html", + ), + // Multi-term query where one term is present and one is not — must score + // `none`, not a partial score. + ( + "rheo-context typst changes 0.5.1 removing types html released 0.6.0", + "rheo-context zzz", + ), + // TWO PREFIX MATCHES AND NO EXACT ONE: `chang` in `changes` at rank 2, `0.5` + // in `0.5.1` at rank 3, both in bucket 0, neither equal to a kept term. 10 + 10 + // = 20, where the same pair matched exactly would be 26 — that difference is + // the bonus under test. Also pins that a query may address PART of a dotted + // term. + ( + "rheo-context typst changes 0.5.1 removing types html released 0.6.0", + "chang 0.5", + ), + // THE RANK FLOOR, `max(1, 10 - int(rank / 4))`, and that the exact-match +3 is + // added AFTER it: 42 terms in SEVENS below (so ranks 0-6, 7-13, 14-20, 21-27, + // 28-34, 35-41 line up with the six string chunks), the query's second term + // last at rank 41, where 10 - int(41 / 4) is 0 and the floor lifts it to 1. + // `marx` at rank 0 scores 10 + 3, `endpaper` at rank 41 scores 1 + 3, so 17. A + // note is capped at `body-terms` (48), so a rank in the forties is a real + // position in a real island row and not a synthetic extreme. + ( + "marx kohei saito eco-marxist anthropocene deutscher torino " + + "fame prize degrowth capital metabolic rift grundrisse " + + "lecture seminar translation japanese ecology socialism abundance " + + "scarcity commons enclosure rentier austerity municipal utopia " + + "archive pamphlet footnote marginalia hardback paperback remainder " + + "warehouse catalogue imprint colophon errata frontispiece endpaper", + "marx endpaper", + ), + // NON-ASCII, and a query missing only on the accent: `cafe` is not a substring + // of `café`, so the AND fails and the score is `none`. No accent folding, by + // design and documented as a limitation in the readme — the tokenizer keeps the + // accent, so the reader must type it. + ("café leche madrid cortado azúcar tostada", "cafe tostada"), + // An empty query is the name rule's business, not this one — `none`. Load- + // bearing for `#search-index`, which builds its rows from `search-ideas("")` + // and relies on every note scoring `none` here so the body tier stays empty. + ("kernel module driver", ""), +) +#metadata(body-cases.map(c => ( + body: c.at(0), + query: c.at(1), + score: body-score(c.at(0), c.at(1)), +))) + +// A third fixture, for the layer ABOVE the two scorers: which tier a row lands +// in, how the tiers order against each other, how ties break, and where `limit` +// cuts. That rule is implemented twice — `_rank` here, `search` in +// `src/search.js` — and diffing only the leaf scorers left it unchecked. +// +// ORDER IS THE THING UNDER TEST, so the runner compares the id SEQUENCE, not a +// set. Rows are kept in ID ORDER because that is what `ideas()` hands `_rank` +// (see its comment): Typst leans on a stable sort for ties where JavaScript +// breaks them by id, and the two agree only for id-ordered input. +// +// Each row below exists for a boundary named in the comment beside it. `body` is +// ABSENT from one row on purpose — that missing key is the whole implementation +// of `body-search: false`, read as `""` on both sides. +#let tier-rows = ( + // Matches on TITLE only: "aaa" has no w-i-n-d-o-w subsequence. + (id: "idea:aaa", name: "aaa", text: "Window handling", label: "Window handling", body: "prose about nothing in particular"), + // Matches on BODY only. It carries `text: ""` — no authored title — and a + // `label` whose words do NOT include the query, which is what keeps this case + // constructible at all now that the name tier scores `label`: rookery derives a + // titleless note's label from its body's FIRST 60 characters, so a body-only + // match is one whose match falls later than that. The old fixture relied on + // `text: ""` skipping the + // title score entirely rather than scoring an empty haystack. + ( + id: "idea:bbb", + name: "bbb", + text: "", + // NO LETTER `w` ANYWHERE IN THE LABEL, which is what makes this a body-only + // row for both queries the tier cases use ("win" and "wnd"): a subsequence + // match needs its first letter, so neither can touch the name tier here. + label: "opening clause, mentions the query only later on", + body: "opening clause, mentions the query only later on, then win window windows", + ), + // NO `body` KEY AT ALL. Must never reach the body tier, and must not error. + // `label` falls back to the NAME, which is what rookery does for a titleless + // note with no body either. + (id: "idea:ccc", name: "ccc", text: "", label: "ccc"), + // Two rows tying on score. For a REAL query ("wnd" below) the tie still + // breaks by id: ddd before eee. DATED, so the empty-residual case ("", none + // below) breaks the same tie by date instead — newest first, eee (Mar 2026) + // before ddd (Jan 2026) — and sorts both ahead of every undated row. + (id: "idea:ddd", name: "wnd", text: "Wnd", label: "Wnd", created: datetime(year: 2026, month: 1, day: 5)), + (id: "idea:eee", name: "wnd", text: "Wnd", label: "Wnd", created: datetime(year: 2026, month: 3, day: 1)), + // A WEAK name match: the query's letters appear scattered through a long + // haystack, so its score lands BELOW a strong body-tier score. It must still + // sort above every body row — that is the tiering rule, not a score contest. + (id: "idea:scatter", name: "wqqiqqnqqqqqqqqqqqqqqqqqqqqqqqq", text: "", label: "wqqiqqnqqqqqqqqqqqqqqqqqqqqqqqq"), + // TAG-CARRYING ROWS, placed HERE and not appended: `idea:tg*` sorts between + // `idea:scatter` and `idea:window-depth`, and this array MUST stay in id order + // (see the comment above it) or the empty-query case diverges — MEASURED, an + // appended pair failed exactly that case and nothing else. + // + // For the `tags:` predicate. Deliberately free of the letter + // `w`, so they cannot subsequence-match "window"/"win"/"wnd" and perturb the + // nine cases that predate them. Every OTHER row here has no `tags` key at + // all, which is the shape `#search-index` emits for an untagged note and the + // one both sides must read as "no tags" rather than erroring. + (id: "idea:tg1", name: "tg1", text: "Alpha", label: "Alpha", body: "alpha prose", tags: ("phd", "draft")), + (id: "idea:tg2", name: "tg2", text: "Beta", label: "Beta", body: "beta prose", tags: ("phd",)), + // Two strong name matches that the length term separates (35 vs 40 for + // "window") — the pair the scorer's own comment cites. + (id: "idea:window-depth", name: "window-depth", text: "Controlling window depth", label: "Controlling window depth"), + (id: "idea:windows", name: "windows", text: "Windows", label: "Windows"), + // MIXED TIERS ON ONE ROW, for the "window depth" case below: "window" is a + // name-tier match on the label, "depth" is not — the letters `d`, `e`, `p`, + // `t`, `h` do not all occur in order in "window" — so it only matches via + // `body-score`. The reduction must promote the whole row to the name tier + // rather than blend the two scores or drop to the body tier. + (id: "idea:zmix", name: "window", text: "", label: "window", body: "assorted prose about depth analysis and more"), + // BODY-ONLY ACROSS BOTH CLAUSES of an AND ("gamma delta" below): neither + // word occurs in the name/label, so this pins the reduction staying + // `"body"` when every matched text clause is a body-tier match, not just + // the first one checked. + (id: "idea:zzbody", name: "zzbody", text: "", label: "zzbody", body: "notes about gamma rays and delta variants"), +) +// Emitted for the runner too, so the JavaScript side ranks THE SAME rows rather +// than a hand-copied second corpus that could drift from this one. +// +// `created` IS RESTAMPED ON THE WAY OUT, and that is not a divergence from +// "the same rows" — it is the SAME conversion `#search-index`'s row-builder +// makes when it ships an `ideas()` row (raw `datetime`) to the browser as JSON +// (a `"[year][month][day]"` string): `typst eval --format json` has no native +// encoding for `datetime` and falls back to its debug repr +// (`"datetime(year: ..)"`, MEASURED), which is not a lexicographically-ordered +// stamp. `_rank(tier-rows, ..)` below still sees the RAW `datetime` on `ddd`/ +// `eee`, exactly as `ideas()` would hand it — only the metadata dump for the +// JS runner is restamped, matching what a real island row actually carries. +#metadata(tier-rows.map(e => { + // TWO CONVERSIONS, both of them what `#search-index`'s row-builder does when it + // ships an `ideas()` row to the browser as JSON. `_rank` below still sees the + // RAW row, exactly as `ideas()` would hand it; only the metadata dump for the JS + // runner is restamped, so both halves see what they see in production. + // + // `text` <- `label`: the island's `text` field means WHAT TO CALL THE NOTE, and + // `label` is rookery's answer — the authored title flattened, else the body's + // first 60 characters, else the name, never empty. Without this the JS scored an + // empty title for every titleless row while Typst scored its label, and the two + // disagreed on exactly the rows this fixture exists to pin down. + let e = (..e, text: e.at("label", default: e.at("text", default: ""))) + let u = e.at("created", default: none) + if u == none { e } else { (..e, created: u.display("[year][month][day]")) } +})) + +#let tier-cases = ( + ("window", none), // full tiering, name rows above the body row + ("window", 2), // `limit` cutting INSIDE the name tier + ("window", 3), // `limit` landing exactly ON the tier boundary: the one body + // row is dropped, because the cut applies to the CONCATENATION and not per tier + ("window", 0), // a zero limit is empty, not unlimited + ("win", none), // body-tier score ABOVE a weak name-tier score + ("wnd", none), // a tie, broken by id + // Empty query: every row scores 0 in the name tier. Dated rows (eee, ddd) + // now sort newest-first ahead of the undated rows, which keep their old id + // order — the new default/browse-listing rule, not the old flat id order. + ("", none), + ("zzz", none), // no match anywhere + // TWO SCORING CLAUSES, ANDed by the implicit space: "window" and "depth" + // each score independently against name/label (or body), and the row must + // match BOTH — not one `fuzzy-score`/`bodyScore` call over the two-word + // string, which is what this query was before a bare word became a clause + // of its own. Also pins the REDUCTION across tiers: `idea:zmix` matches + // "window" on its label (name tier) and "depth" only via `body-score` + // (body tier), and must be promoted to the name tier rather than blended + // or dropped to the body tier. + ("window depth", none), + // BOTH CLAUSES BODY-TIER ONLY: `idea:zzbody` matches neither "gamma" nor + // "delta" on its label, so both fall to `body-score` — the reduction stays + // `"body"` rather than reading only the first clause it resolves. + ("gamma delta", none), + // THE `tags:` GATE, which nothing above reaches. It is one line in each + // language and it runs ahead of every scorer, so an untested copy would drift + // silently: MEASURED, deleting it from the JavaScript side leaves all ten + // cases above passing. + ("tags:phd", none), // gate only, no text clause: survivors at score 0, id order + // NEGATION OF A FIELD CLAUSE, spelled `!tags:draft` now that `tags:` no + // longer opens a whole sub-expression on its own — `!` negates exactly the + // clause after it, so tg1 (tagged draft) drops and tg2 (not) stays. + ("tags:phd&!tags:draft", none), + ("tags:phd alpha", none), // gate THEN rank the text clause over the survivors + ("tags:nope", none), // matches no note: empty, not unfiltered + // A GATE OR'D WITH A TEXT CLAUSE: every draft-tagged row, plus every row + // whose name/label matches "window" — tg1 (draft) and window-depth/windows + // (window) all survive, and a row satisfying only the text side scores by + // that side alone (`|`'s score is the max over the matched sides, and a + // gate always scores `0`). + ("tags:draft | window", none), + // A GATE AND A TEXT CLAUSE, one word apart: tg1 is tagged draft, but its + // name/label ("tg1"/"Alpha") does not match "window", so it still drops — + // the implicit `&` requires both sides, exactly as `tags:phd alpha` above + // does for a different pair. + ("tags:draft window", none), + // NEGATION AHEAD OF A GATE, then ANDed with a text clause: every row NOT + // tagged draft whose name/label matches "window" — tg1 drops for the tag, + // tg2 drops for the text, window-depth and windows survive. + ("!tags:draft window", none), + // A BARE `tags:` — a field with nothing after its colon — is still NO + // FILTER AT ALL, tagged or not: see `eval-tag-query`'s comment for why an + // empty value must not read as membership in the empty string. + ("tags:", none), +) +#metadata(tier-cases.map(c => { + let hits = _rank(tier-rows, c.at(0), limit: c.at(1)) + ( + query: c.at(0), + limit: c.at(1), + ids: hits.map(h => h.id), + scores: hits.map(h => h.score), + kinds: hits.map(h => h.kind), + ) +})) + +// A fourth fixture, for the query parser and its tag-only evaluator — the one +// rule here whose output is not a number. It is diffed AS DATA: a flattened RPN +// string and a boolean per fixed tag set, both of which a JavaScript twin can +// produce character for character. That is the whole reason the parser is +// shunting-yard and emits a token array (see `parse-tag-query`), rather than a +// recursive descent whose only comparable output would be its final verdict. +// +// THE FIRST 19 CASES ARE THE EXACT SET THE SPIKE VERIFIED — do not thin them +// out. They are the only place precedence, the frozen escape set, folding, and +// every repair path (`unclosed-open`, `unmatched-close`, a dangling operator, a +// trailing `\`) are pinned. A case that looks redundant is holding one of those +// down. A SPACE no longer ends the expression — it is an implicit `&` — so a +// case originally written to pin where parsing stopped now pins the tree it +// builds instead, which `evals` still exercises the same way. +// +// Note that `\\` in a Typst string literal is ONE backslash in the query, which +// is what a reader would actually type: `"tags:a\\&b"` is the query `tags:a\&b`. +#let tag-cases = ( + "tags:(a|b)&c", "tags:a|b&c", "tags:!draft", "tags:!(draft|todo)¬e", + "tags:draft window depth", "tags:draft window depth ", "tags:a\\&b", + "tags:a\\|b|c", "tags:\\(paren\\)", "tags:a\\ b", "tags:(a|", "tags:a&", + "tags:)a", "tags:", "tags:a\\", "TAGS:note", "window depth", + "tags:note&&draft", "tags:((note))", + // STACKED `!`, added after the spike, and the ONLY thing in this table that + // exercises `!` being RIGHT-associative. MEASURED: against the 19 cases above, + // deleting the right-associativity entry from the JavaScript port's operator + // table changed no case at all and `just parity` still passed — a rule the + // comment above claimed to pin and did not. `!!draft` is the shortest input + // that needs it: read left-associatively, the second `!` pops the first off the + // stack before it has an operand. + "tags:!!draft", "tags:!!!draft¬e", + // CLUSTERS IN A TAG QUERY, and these pin an EQUIVALENCE rather than a past bug. + // `parse-tag-query` walks `.clusters()`; the JavaScript port walked code points + // until bead rheo-packages-j6e, which is a real drift in `fuzzy-score` (see the + // five cases in `cases` above) — but MEASURED, it was unobservable here, and all + // four of these agreed under both implementations. + // + // WHY, because it is the thing that could stop being true: the only path that + // cares how wide a unit is is the escape branch, which consumes exactly ONE, and + // every other branch merely appends the unit to `atom` — where concatenating a + // cluster's code points rebuilds the same string. So `a\❤️b` is one atom either + // way: a spread escapes only U+2764 and then appends U+FE0F as ordinary text, + // which is where it would have landed regardless. It takes an operator character + // INSIDE a cluster to break that, and the frozen escape set is all ASCII while no + // grapheme cluster continues with ASCII. Widen the escape set and this is where + // it shows up. + "tags:résumé", "tags:❤️|c̃", "tags:👩‍💻¬e", "tags:a\\❤️b", + // FIELD:VALUE CLAUSES — an atom splits on its FIRST unescaped `:`, over the + // WHOLE query now that there is no outer `tags:` to strip first. So + // `tags:tags:draft` splits at the very first colon — field `tags`, value + // `tags:draft` (the colon inside the value does not split a second time) — + // and `status:done` is its own field clause, `window` a bare text atom. + // `a\:b` pins that an escaped `:` never splits. `a:b:c` pins that only the + // first `:` splits (field `a`, value `b:c`). `:draft` pins that a leading + // `:` with nothing before it is text, not an empty field name. `tags:` on + // its own — the bare string — pins field `tags`, empty value, a valid + // prefix rather than a repair. + "tags:tags:draft", "status:done", "window", "a\\:b", + "a:b:c", ":draft", "tags:", + // KEYWORD SPELLINGS OF `&`/`|`/`!`, plus a LEADING `-` for `!` on a clause. + // `a AND b`/`a and b` pin the case-insensitive whole-word test producing + // the same tree as `a & b`; `a OR b` and `NOT a` pin the other two. `android` + // pins that a keyword promotes only a COMPLETE atom, never a substring. + // `\AND` pins that an escaped keyword is the literal atom "and". `-tags:draft` + // and `window -depth` pin a leading `-` opening a clause as `!`, and + // `in-progress` pins that a `-` INSIDE an already-started atom stays part + // of the word. + "a AND b", "a and b", "a OR b", "NOT a", "android", "\\AND", + "-tags:draft", "window -depth", "in-progress", +) +// One fixed ladder of tag sets, evaluated for EVERY case, so the runner compares +// a whole boolean row rather than a single verdict — the last set is the untagged +// note, which is where a negation has to keep working. +#let tag-sets = (("note",), ("note", "draft"), ("draft",), ("a", "c"), ("b", "c"), ()) +// An atom renders as a quoted value, with its field prefixed bare and a colon +// where non-empty — `"draft"` for a bare word, `tags:"draft"` for a field +// clause — so a case with no field renders exactly as it did before atoms +// carried one. +#let _rpn-str(rpn) = { + let s = rpn.map(t => if t.at(0) == "atom" { + let field = t.at(1) + let prefix = if field == "" { "" } else { field + ":" } + prefix + "\"" + t.at(2) + "\"" + } else { t.at(1) }).join(" ") + // `array.join()` on an EMPTY array returns `none`, not `""` (MEASURED, and + // documented at `#search-index` in `src/lib.typ`) — reached only by the + // literal empty query, since every other case below builds at least one + // atom now that a bare word after a space is a clause of its own rather + // than dropped residual text. + if s == none { "" } else { s } +} +// `eval-tag-query` wants FOLDED tags (its atoms are folded at push time), so the +// fixture folds each set with `_fold` exactly as a real caller must — the one +// step a port could skip and still look right on ASCII single-word tags. +// +// `evals` still runs EVERY case through `eval-tag-query`'s tag-only resolver, +// bare-word atoms included — a bare word now parses as a field-less atom +// exactly as it always did, so pinning it against a tag set is the same test +// it always was, whatever `eval-clauses` in `rank.typ`/`score.js` separately +// does with it as a text clause. +#metadata(tag-cases.map(c => { + let r = split-query(c) + ( + query: c, + rpn: _rpn-str(r.rpn), + evals: tag-sets.map(ts => eval-tag-query(r.rpn, ts.map(_fold))), + ) +})) + +// `eval-clauses` cases: an RPN plus a table of clause resolutions keyed by +// atom value, diffed on the resulting `(matched, score)`. Each case supplies +// its own `resolve` table rather than reusing `tag-sets` above, because a +// clause's score is part of what is under test here and a tag filter has +// none. +#let clause-cases = ( + // `a & b`, both matching: score is the SUM. + (rpn: (("atom", "", "a"), ("atom", "", "b"), ("op", "&")), + resolve: (a: (matched: true, score: 3), b: (matched: true, score: 5))), + // `a | b`, only the right side matching: score is `b`'s alone, not a max + // against a's unmatched (and irrelevant) score. + (rpn: (("atom", "", "a"), ("atom", "", "b"), ("op", "|")), + resolve: (a: (matched: false, score: 9), b: (matched: true, score: 4))), + // `a | b`, both matching: score is the MAX, not the sum. + (rpn: (("atom", "", "a"), ("atom", "", "b"), ("op", "|")), + resolve: (a: (matched: true, score: 2), b: (matched: true, score: 7))), + // `!a` over a matching `a`: negation flips `matched` and always scores `0`. + (rpn: (("atom", "", "a"), ("op", "!")), + resolve: (a: (matched: true, score: 6),)), + // a gating clause (`field:val`, score `0`) ANDed with a scoring clause: the + // gate contributes nothing but still must match for the whole to match. + (rpn: (("atom", "field", "val"), ("atom", "", "b"), ("op", "&")), + resolve: (val: (matched: true, score: 0), b: (matched: true, score: 8))), + // a dangling operator (`a &` with no right operand): the arity guard skips + // the `&`, leaving `a`'s own resolution on top of the stack. + (rpn: (("atom", "", "a"), ("op", "&")), + resolve: (a: (matched: true, score: 4),)), + // an empty RPN: no filter, `(matched: true, score: 0)`. + (rpn: (), resolve: (:)), + // `a & b`, `a` a name-tier hit and `b` a body-tier hit: the combined tier + // promotes to `"name"`, the reduction `_rank`/`search` rely on. + (rpn: (("atom", "", "a"), ("atom", "", "b"), ("op", "&")), + resolve: (a: (matched: true, score: 3, tier: "name"), b: (matched: true, score: 5, tier: "body"))), + // `a | b`, only the body-tier side matching: the combined tier is `"body"`, + // not `"none"` — an unmatched side's tier is as irrelevant as its score. + (rpn: (("atom", "", "a"), ("atom", "", "b"), ("op", "|")), + resolve: (a: (matched: false, score: 9, tier: "name"), b: (matched: true, score: 4, tier: "body"))), + // `!a` over a name-tier hit: negation carries tier `"none"`, matching that + // it never contributes a score either. + (rpn: (("atom", "", "a"), ("op", "!")), + resolve: (a: (matched: true, score: 6, tier: "name"),)), + // a gate (tier `"none"`) ANDed with a body-tier hit: the gate must not + // pull the combined tier down to `"none"`. + (rpn: (("atom", "field", "val"), ("atom", "", "b"), ("op", "&")), + resolve: (val: (matched: true, score: 0, tier: "none"), b: (matched: true, score: 8, tier: "body"))), +) +#metadata(clause-cases.map(c => { + let resolve(field, value) = c.resolve.at(value) + ( + rpn: _rpn-str(c.rpn), + result: eval-clauses(c.rpn, resolve), + ) +})) diff --git a/search/0.1.0/test/row.test.mjs b/search/0.1.0/test/row.test.mjs new file mode 100644 index 0000000..6fc2450 --- /dev/null +++ b/search/0.1.0/test/row.test.mjs @@ -0,0 +1,47 @@ +// `renderRow(hit, terms)` — one result row: a title span, a break opportunity, +// and the `[idea:]` id span. +// +// The `` between the two spans is what these tests exist for. Without it +// the browser sees the title's last word and the whole nowrap id as ONE +// unbreakable run, and drops that final word onto a second line in every row at +// every pane width — see bead rheo-packages-row-wbr-eas7 for the measurements. +// A DOM shim cannot reproduce line breaking, so what is pinned here is the +// STRUCTURE that makes the break possible. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { renderRow } from "./internal.mjs"; + +const { document } = parseHTML(""); +globalThis.document = document; + +const hit = { id: "idea:amanda-holmes", name: "amanda-holmes", text: "Amanda Holmes and Adrian Johnston", href: "ideas/amanda-holmes.html" }; + +test("a row separates title and id with a break opportunity", () => { + const row = renderRow(hit, []); + const kids = [...row.childNodes].filter((n) => n.nodeType === 1); + assert.deepEqual( + kids.map((n) => n.nodeName.toLowerCase()), + ["span", "wbr", "span"], + ); + assert.equal(kids[0].className, "rookery-search-title"); + assert.equal(kids[2].className, "rookery-search-id"); +}); + +test("the wbr adds no text, so the row reads as title then id", () => { + const row = renderRow(hit, []); + // No word space introduced: a space text node would fix the break too, but + // widens the gap the id's own `margin-left` already supplies. + assert.equal(row.textContent, "Amanda Holmes and Adrian Johnston[idea:amanda-holmes]"); +}); + +// There is deliberately NO `|| hit.name` fallback in `renderRow`: `text` is +// rookery's derived label and is never empty, so a fallback would be dead code +// that also hid a real bug if the island ever shipped `""`. This pins the +// absence — restoring the fallback turns the row back into `amanda-holmes[...` +// and fails here. +test("an empty title is rendered as empty, not filled in from the name", () => { + const row = renderRow({ ...hit, text: "" }, []); + assert.equal(row.textContent, "[idea:amanda-holmes]"); +}); diff --git a/search/0.1.0/test/search.test.mjs b/search/0.1.0/test/search.test.mjs new file mode 100644 index 0000000..4347daf --- /dev/null +++ b/search/0.1.0/test/search.test.mjs @@ -0,0 +1,87 @@ +// `search` — the JS-side ranker over a `#search-index` row list. Cross- +// language agreement with Typst's `_rank` is `just parity`'s job (the +// `tier-parity` fixture); this file unit-tests the function standalone: tier +// boundaries, tie-breaks, the `tags:` predicate, `limit`, and the +// `row.body`/`row.text`/`row.tags` "field simply absent" fallbacks that stand +// in for an older island or a `body-search: false` build. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { search } from "../src/search.js"; + +test("search: a tier-0 (name/text) hit outranks a tier-1 (body) hit regardless of raw score", () => { + const rows = [ + // No "alpha" substring/subsequence in "Beta widget" — falls through to + // body, where "alpha" is the body's very first term (best possible rank). + { id: "idea:bbb", name: "idea:bbb", text: "Beta widget", body: "alpha context depth" }, + // "Alpha" is right there in the title — a much lower fuzzy-score number + // than the body hit above, but tier 0 must still win. + { id: "idea:aaa", name: "idea:aaa", text: "Alpha window", body: "" }, + ]; + const out = search(rows, "alpha", null); + assert.deepEqual(out.map((h) => h.id), ["idea:aaa", "idea:bbb"]); + assert.equal(out[0].kind, "name"); + assert.equal(out[1].kind, "body"); +}); + +test("search: ties within a tier break on id, ascending, not insertion order", () => { + const rows = [ + { id: "idea:zzz", name: "idea:zzz", text: "window", body: "" }, + { id: "idea:aaa", name: "idea:aaa", text: "window", body: "" }, + ]; + const out = search(rows, "window", null); + assert.deepEqual(out.map((h) => h.id), ["idea:aaa", "idea:zzz"]); +}); + +test("search: a row with no body (body-search: false) never reaches tier 1", () => { + const rows = [{ id: "idea:ccc", name: "idea:ccc", text: "Gamma" }]; // no `body` key at all + assert.deepEqual(search(rows, "alpha", null), []); +}); + +test("search: an empty body (real, not omitted) also never reaches tier 1", () => { + const rows = [{ id: "idea:ccc", name: "idea:ccc", text: "Gamma", body: "" }]; + assert.deepEqual(search(rows, "alpha", null), []); +}); + +test("search: bare `tags:x` with no residual text — every survivor lands at score 0, ordered by id", () => { + const rows = [ + { id: "idea:ddd", name: "idea:ddd", text: "Delta", tags: ["draft"] }, + { id: "idea:eee", name: "idea:eee", text: "Echo", tags: ["draft"] }, + { id: "idea:fff", name: "idea:fff", text: "Foxtrot" }, // no `tags` key — an untagged/older row + ]; + const out = search(rows, "tags:draft", null); + assert.deepEqual(out.map((h) => h.id), ["idea:ddd", "idea:eee"]); + assert.deepEqual(out.map((h) => h.score), [0, 0]); + assert.deepEqual(out.map((h) => h.kind), ["name", "name"]); +}); + +test("search: `tags:` filters BEFORE the residual text ranks", () => { + const rows = [ + { id: "idea:ddd", name: "idea:ddd", text: "window depth", tags: ["draft"] }, + { id: "idea:eee", name: "idea:eee", text: "window budget" }, // matches text, wrong/missing tag + ]; + const out = search(rows, "tags:draft window", null); + assert.deepEqual(out.map((h) => h.id), ["idea:ddd"]); +}); + +test("search: limit slices the sorted output; null limit returns everything", () => { + const rows = [ + { id: "idea:aaa", name: "idea:aaa", text: "window", body: "" }, + { id: "idea:bbb", name: "idea:bbb", text: "window", body: "" }, + { id: "idea:ccc", name: "idea:ccc", text: "window", body: "" }, + ]; + assert.equal(search(rows, "window", 2).length, 2); + assert.equal(search(rows, "window", null).length, 3); + assert.equal(search(rows, "window").length, 3); // limit omitted entirely +}); + +test("search: within tier 1, higher bodyScore ranks first", () => { + const rows = [ + // "window" is a late term (low rank -> low points) and only a prefix hit. + { id: "idea:low", name: "idea:low", text: "Zzz", body: "alpha beta gamma delta epsilon window" }, + // "window" is the exact first term -> best possible bodyScore. + { id: "idea:high", name: "idea:high", text: "Zzz", body: "window alpha beta" }, + ]; + const out = search(rows, "window", null); + assert.deepEqual(out.map((h) => h.id), ["idea:high", "idea:low"]); + assert.ok(out[0].score > out[1].score); +}); diff --git a/search/0.1.0/test/selection.test.mjs b/search/0.1.0/test/selection.test.mjs new file mode 100644 index 0000000..e0cb298 --- /dev/null +++ b/search/0.1.0/test/selection.test.mjs @@ -0,0 +1,158 @@ +// `selection(list, input, onSelect)` — the active-option factory shared by +// `wire`'s dropdown and `wireModal`'s modal. Per the module's own comment +// above it: -1 means no active option, movement clamps and never wraps, +// `aria-activedescendant` lives on the INPUT, and row ids come from the +// list's own id. A stale-`aria-activedescendant` bug already hid in this +// exact function (found by manual browser probing, not by review) — the +// "DOM replaced under it" test below targets that class of bug directly. +// +// Real DOM-like nodes (linkedom), not hand-rolled objects: `selection` calls +// `list.querySelectorAll`, `el.setAttribute`/`removeAttribute`, and +// `el.scrollIntoView` — worth exercising against the real thing. linkedom +// has no `scrollIntoView` (no layout engine to scroll), so it's stubbed to a +// no-op per element, same as a browser call this suite doesn't need to +// assert on. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { selection } from "./internal.mjs"; + +const { document } = parseHTML(""); + +const makeRow = () => { + const row = document.createElement("li"); + row.className = "rookery-search-row"; + row.scrollIntoView = () => {}; // not implemented by linkedom + return row; +}; + +const makeList = (n, { id } = {}) => { + const list = document.createElement("ul"); + if (id !== undefined) list.id = id; + for (let i = 0; i < n; i++) list.append(makeRow()); + document.body.append(list); + return list; +}; + +const makeInput = () => { + const input = document.createElement("input"); + document.body.append(input); + return input; +}; + +test("selection: assigns the list an id when it has none, and leaves an existing id alone", () => { + const listA = makeList(2); + selection(listA, makeInput()); + assert.match(listA.id, /^rookery-search-list-\d+$/); + + const listB = makeList(2); + selection(listB, makeInput()); + assert.notEqual(listA.id, listB.id); // monotonic across instances + + const listC = makeList(2, { id: "my-list" }); + selection(listC, makeInput()); + assert.equal(listC.id, "my-list"); // not overwritten +}); + +test("selection: row ids are derived from the list's own id, for every row", () => { + const list = makeList(3, { id: "my-list" }); + const input = makeInput(); + const sel = selection(list, input); + sel.select(1); + const rows = [...list.querySelectorAll(".rookery-search-row")]; + assert.deepEqual(rows.map((r) => r.id), ["my-list-opt-0", "my-list-opt-1", "my-list-opt-2"]); + assert.equal(input.getAttribute("aria-activedescendant"), "my-list-opt-1"); +}); + +test("selection: exactly one row is marked selected", () => { + const list = makeList(3, { id: "my-list" }); + const sel = selection(list, makeInput()); + sel.select(1); + const rows = [...list.querySelectorAll(".rookery-search-row")]; + assert.deepEqual(rows.map((r) => r.getAttribute("aria-selected")), ["false", "true", "false"]); + assert.equal(rows[1].getAttribute("data-rookery-search-selected"), "true"); + assert.equal(rows[0].hasAttribute("data-rookery-search-selected"), false); + assert.equal(rows[2].hasAttribute("data-rookery-search-selected"), false); +}); + +test("selection: an empty list selects nothing — clear(), not a throw", () => { + const list = makeList(0, { id: "my-list" }); + const input = makeInput(); + const sel = selection(list, input); + sel.select(0); + assert.equal(sel.index(), -1); + assert.equal(sel.current(), null); + assert.equal(input.hasAttribute("aria-activedescendant"), false); +}); + +test("selection: clamps, never wraps, at both ends", () => { + const list = makeList(3, { id: "my-list" }); + const sel = selection(list, makeInput()); + + assert.equal(sel.index(), -1); + sel.move(-1); // "up" from nothing activates the list at row 0, not the end + assert.equal(sel.index(), 0); + sel.move(-1); // already at 0 — stays, does not wrap to row 2 + assert.equal(sel.index(), 0); + + sel.move(1); + sel.move(1); + assert.equal(sel.index(), 2); // last row + sel.move(1); // already at the end — stays, does not wrap to row 0 + assert.equal(sel.index(), 2); + + sel.select(-100); + assert.equal(sel.index(), 0); + sel.select(100); + assert.equal(sel.index(), 2); +}); + +test("selection: clear() removes aria-activedescendant entirely (not sets it empty)", () => { + const list = makeList(2, { id: "my-list" }); + const input = makeInput(); + const sel = selection(list, input); + sel.select(1); + assert.equal(input.getAttribute("aria-activedescendant"), "my-list-opt-1"); + sel.clear(); + assert.equal(sel.index(), -1); + assert.equal(input.hasAttribute("aria-activedescendant"), false); + for (const row of list.querySelectorAll(".rookery-search-row")) { + assert.equal(row.getAttribute("aria-selected"), "false"); + assert.equal(row.hasAttribute("data-rookery-search-selected"), false); + } +}); + +test("selection: onSelect fires on a real selection, not on the no-rows clear() path", () => { + const list = makeList(0, { id: "my-list" }); + const input = makeInput(); + let calls = 0; + const sel = selection(list, input, () => calls++); + + sel.select(0); + assert.equal(calls, 0); // no rows: hits clear(), never reaches onSelect + + list.append(makeRow()); // a row shows up (e.g. a fresh render) + sel.select(0); + assert.equal(calls, 1); +}); + +test("selection: a DOM swap under it never leaves aria-activedescendant pointing at a removed row", () => { + // The regression class the bead's own comment flags: re-rendering the list + // (a new query producing fewer/different rows) must not leave the input + // naming a row id from the old DOM. + const list = makeList(3, { id: "my-list" }); + const input = makeInput(); + const sel = selection(list, input); + sel.select(2); + assert.equal(input.getAttribute("aria-activedescendant"), "my-list-opt-2"); + + // Simulate a re-render: old rows gone, one new row in their place. + sel.clear(); + list.replaceChildren(); + list.append(makeRow()); + sel.select(0); + + assert.equal(input.getAttribute("aria-activedescendant"), "my-list-opt-0"); + const [freshRow] = list.querySelectorAll(".rookery-search-row"); + assert.equal(freshRow.getAttribute("aria-selected"), "true"); +}); diff --git a/search/0.1.0/test/urlstate.test.mjs b/search/0.1.0/test/urlstate.test.mjs new file mode 100644 index 0000000..6e375c0 --- /dev/null +++ b/search/0.1.0/test/urlstate.test.mjs @@ -0,0 +1,144 @@ +// `urlstate.js` — the query-string primitives, exercised as pure string-in +// string-out functions (no `history`; `URLSearchParams` is a node global). The +// comma-and-space case pins the reason every value is written as its own +// repeated param rather than comma-joined: a facet value legally containing a +// comma would corrupt a joined param with no escaping rule to recover it. +// +// `claimKey` IS THE ONE EXCEPTION and needs a DOM, because a claim is refused +// on whether the element holding it is still in the document — which is what +// lets a widget re-claim its own key after a rheo morph. Those cases build a +// throwaway document with linkedom rather than making the whole file DOM-bound. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { readSync, writeSync, readParam, writeParam, commit, claimKey, resetKeys, debounce } from "../src/urlstate.js"; + +test("readSync: q and repeated values for the given key", () => { + const { q, values } = readSync("todos", "todos.q=rheo&todos.state=ready&todos.state=blocked"); + assert.equal(q, "rheo"); + assert.deepEqual(values.get("state"), new Set(["ready", "blocked"])); +}); + +test("readSync: a leading '?' behaves identically", () => { + const { q, values } = readSync("todos", "?todos.q=rheo&todos.state=ready&todos.state=blocked"); + assert.equal(q, "rheo"); + assert.deepEqual(values.get("state"), new Set(["ready", "blocked"])); +}); + +test("readSync: another widget's params are invisible", () => { + const { q, values } = readSync("todos", "ideas.t=cfp"); + assert.equal(q, ""); + assert.equal(values.size, 0); +}); + +test("writeSync/readSync round-trip", () => { + const search = "todos.q=rheo&todos.state=ready&todos.state=blocked"; + const state = readSync("todos", search); + const rewritten = writeSync("todos", state, search); + assert.deepEqual(readSync("todos", rewritten), state); +}); + +test("writeSync: clearing a widget removes its params and keeps everyone else's", () => { + const out = writeSync("todos", { q: "", values: new Map() }, "todos.q=x&tab=todos"); + assert.equal(out, "tab=todos"); +}); + +test("writeSync: a facet value containing a comma and a space survives the round trip unchanged", () => { + const state = { q: "", values: new Map([["tag", new Set(["a, b"])]]) }; + const search = writeSync("todos", state, ""); + assert.deepEqual(readSync("todos", search).values.get("tag"), new Set(["a, b"])); +}); + +test("readParam/writeParam: the scalar case", () => { + const search = writeParam("tab", "todos", ""); + assert.equal(readParam("tab", search), "todos"); +}); + +test("readParam: absent param is null", () => { + assert.equal(readParam("tab", "todos.q=x"), null); +}); + +test("writeParam: null deletes the param and leaves the rest", () => { + assert.equal(writeParam("tab", null, "tab=todos&todos.q=x"), "todos.q=x"); +}); + +test("writeParam: undefined and empty string also delete", () => { + assert.equal(writeParam("tab", undefined, "tab=todos"), ""); + assert.equal(writeParam("tab", "", "tab=todos"), ""); +}); + +test("claimKey: first claim wins, every repeat is refused", () => { + assert.equal(claimKey("a"), true); + assert.equal(claimKey("a"), false); + assert.equal(claimKey("b"), true); +}); + +test("claimKey: a repeat with no resetKeys() in between still warns and is refused", (t) => { + const warn = t.mock.method(console, "warn", () => {}); + assert.equal(claimKey("dup"), true); + assert.equal(claimKey("dup"), false); + assert.equal(warn.mock.calls.length, 1); +}); + +test("resetKeys: a key claimed, then reset, can be claimed again", () => { + assert.equal(claimKey("reclaim"), true); + assert.equal(claimKey("reclaim"), false); + resetKeys(); + assert.equal(claimKey("reclaim"), true); +}); + +// THE THREE OWNER CASES, and between them they are why the rehydrate path needs +// no `resetKeys()` and therefore no agreement about which package's hook runs +// first. A widget re-wiring itself after a rheo morph is the first case; a +// widget whose element the morph replaced rather than matched is the second; +// two widgets genuinely sharing one key on a live page is the third, and it +// must still be caught. +test("claimKey: the same owner re-claims its own key", () => { + const { document } = parseHTML("
"); + const owner = document.getElementById("a"); + assert.equal(claimKey("own", owner), true); + assert.equal(claimKey("own", owner), true); +}); + +test("claimKey: a detached owner's claim is taken over by its replacement", () => { + const { document } = parseHTML( + "
", + ); + const gone = document.getElementById("old"); + const fresh = document.getElementById("new"); + assert.equal(claimKey("handover", gone), true); + // What a morph does when it cannot match an element: the old node leaves the + // document, so the claim it is holding is dead rather than merely held. + gone.remove(); + assert.equal(claimKey("handover", fresh), true); +}); + +test("claimKey: two owners both in the document still collide", (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const { document } = parseHTML( + "
", + ); + assert.equal(claimKey("shared", document.getElementById("one")), true); + assert.equal(claimKey("shared", document.getElementById("two")), false); + assert.equal(warn.mock.calls.length, 1); +}); + +test("commit: does not throw with no history present", () => { + assert.doesNotThrow(() => commit("a=1")); +}); + +test("debounce: runs fn only once after the trailing call, with its arguments", () => { + let calls = []; + const fn = (...args) => calls.push(args); + const debounced = debounce(fn, 10); + debounced(1); + debounced(2); + debounced(3); + assert.equal(calls.length, 0); + return new Promise((resolve) => { + setTimeout(() => { + assert.deepEqual(calls, [[3]]); + resolve(); + }, 30); + }); +}); diff --git a/search/0.1.0/test/urlsync.test.mjs b/search/0.1.0/test/urlsync.test.mjs new file mode 100644 index 0000000..8d9a51e --- /dev/null +++ b/search/0.1.0/test/urlsync.test.mjs @@ -0,0 +1,124 @@ +// `urlsync.js` — a radio group's selection synced to one query-string param — +// exercised over a real DOM (linkedom), because `wireRadioGroup` reads and +// writes `location`/`history` directly and neither exists on linkedom's own +// window. Both are stubbed by hand, the same move `panelinput.test.mjs` +// makes for `document`. +// +// Each test claims its own key: `claimKey` remembers every key claimed for +// the life of the module, which is the whole file's run, so reusing a key +// across tests would make the second wiring a refusal rather than a fresh +// claim. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHTML } from "linkedom"; +import { wireRadioGroup } from "../src/urlsync.js"; + +let captured = null; + +const reset = (search = "") => { + captured = null; + globalThis.location = { pathname: "/index.html", search, hash: "" }; + globalThis.history = { + replaceState: (_state, _title, url) => { + captured = url; + }, + }; +}; + +const TABS = `
+ + + +
`; + +const wireTabs = (key) => { + const { document } = parseHTML(TABS); + globalThis.document = document; + const container = document.querySelector(".tabs"); + const result = wireRadioGroup(container, key); + const radios = [...document.querySelectorAll(".tab-radio")]; + return { + result, + checked: () => radios.map((r) => r.checked), + change: (i) => radios[i].dispatchEvent(new document.defaultView.Event("change")), + }; +}; + +test("a matching param overrides the markup's checked default", () => { + reset("tab-restore=todos"); + const { checked } = wireTabs("tab-restore"); + assert.deepEqual(checked(), [false, true, false]); +}); + +test("a param naming no radio leaves the markup's own default standing", () => { + reset("tab-stale=nope"); + const { document } = parseHTML(TABS); + globalThis.document = document; + const container = document.querySelector(".tabs"); + const radios = [...document.querySelectorAll(".tab-radio")]; + const before = radios.map((r) => r.checked); + wireRadioGroup(container, "tab-stale"); + // linkedom does not reflect the `checked` CONTENT attribute onto the + // `.checked` IDL property on its own, so the only environment-agnostic way + // to prove nothing was touched is to show wiring left every radio exactly + // as it found it. + assert.deepEqual(radios.map((r) => r.checked), before); +}); + +test("wiring alone writes nothing to the URL", () => { + reset(); + wireTabs("tab-noop"); + assert.equal(captured, null); +}); + +test("a change on a radio writes its value under the given key", () => { + reset(); + const { change } = wireTabs("tab-change"); + change(2); + assert.match(captured, /tab-change=ideas/); +}); + +test("a change merges with an existing param instead of replacing the query string", () => { + reset("todos.q=x"); + const { change } = wireTabs("tab-merge"); + change(1); + assert.match(captured, /todos\.q=x/); + assert.match(captured, /tab-merge=todos/); +}); + +test("a group whose radios carry no `value` attribute syncs nothing", () => { + reset(); + const { document } = parseHTML(`
+ + +
`); + globalThis.document = document; + const container = document.querySelector(".tabs"); + const result = wireRadioGroup(container, "tab-novalue"); + assert.equal(result, null); + const radios = [...document.querySelectorAll(".tab-radio")]; + radios[1].dispatchEvent(new document.defaultView.Event("change")); + assert.equal(captured, null); +}); + +test("a second container claiming an already-claimed key is refused and stays unwired", () => { + reset(); + const { document } = parseHTML(` +
+ + +
+
+ + +
+`); + globalThis.document = document; + const first = document.querySelector(".one"); + const second = document.querySelector(".two"); + assert.notEqual(wireRadioGroup(first, "tab-dup"), null); + assert.equal(wireRadioGroup(second, "tab-dup"), null); + const secondRadios = [...second.querySelectorAll(".tab-radio")]; + secondRadios[1].dispatchEvent(new document.defaultView.Event("change")); + assert.equal(captured, null); +}); diff --git a/search/0.1.0/typst.toml b/search/0.1.0/typst.toml new file mode 100644 index 0000000..6072191 --- /dev/null +++ b/search/0.1.0/typst.toml @@ -0,0 +1,86 @@ +[package] +name = "search" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "Fuzzy search over @rookery/core notes — Typst primitive, JSON index, and an embeddable search bar" +repository = "https://github.com/freecomputinglab/rookery" + +# THE RHEO FLOOR, not a rookery version. It was `0.5.2` and that was wrong rather +# than merely stale: this package hard-imports `@rookery/core` (`src/lib.typ`, +# `src/lookup.typ`), whose own manifest declares the same floor — so a project on +# rheo 0.5.2 satisfied what this said, then failed on the rookery it pulled in. +# The other three packages in the family already said 0.6.0; this was the odd one +# out. Its own readme states the dependency in prose too. +# +# 0.6.1 raises it, and not because the Typst surface moved: it is where rheo +# learned to resolve a namespace it does not ship, via the `[packages.]` +# table a project needs to say where `@rookery` comes from at all. Without it +# these packages cannot be consumed under their own namespace, and +# `[tool.rheo.source.html]` below is a 0.6.1 manifest key an older rheo reads +# straight past. The floor turns "package not found" into a message naming the +# version. +[tool.rheo] +# +# 0.6.2 raises it again, and this one is the sharpest reason yet: 0.6.1 located +# packages by probing Typst's directory layout in the two places that read a +# package's `.marrow.typ`, and a package fetched from a ref lives at a path keyed +# by its resolved commit, which no probe matches. Every page THIS FAMILY MINTS +# FROM MARROW therefore went missing — on a build that succeeded and warned about +# nothing. Declaring 0.6.2 is what turns that silence into an error naming the +# version. +min_version = "0.6.2" + +[tool.rheo.html] +js_scripts = "dist/lib.js" +css_stylesheet = "src/search.css" +# THIS PACKAGE REBUILDS ITS OWN STATE AFTER A MORPH, so it does not need the +# reload rheo's dev server would otherwise fall back to. `search.js`'s `init()` +# wires every bar, modal and panel off the DOM as it stands at the moment it +# runs, and rheo's `watch` build can now patch a content edit straight into the +# live page (Idiomorph) instead of reloading it — a pass nothing re-runs +# script for, and after which a widget wired against the OLD DOM is reading +# markup that has moved under it. Declaring `js_rehydrate = true` is this +# package saying it has its own fix: `search.js` pushes a callback onto +# `window.__rheoRehydrate`, a queue rheo's live client drains after every +# morph, which resets the URL-sync claim registry and re-runs `init()` from +# scratch against the patched markup. OMITTING THE FLAG BREAKS NOTHING — an +# older rheo, or a project that never opts in, reads straight past a key it +# does not know and falls back to the full-page reload it already does today. +js_rehydrate = true + +[tool.rheo.source.html] +# THE SAME DECLARATION AS ABOVE, REPEATED RATHER THAN INHERITED: rheo merges +# `[tool.rheo.source.*]` OVER `[tool.rheo.html]` for a project built against +# `src/` rather than the `dist/lib.js` release, and a key stated only in the +# release table is not something that merge carries across to the source one. +# Skip it here and a source build would silently lose the fix above and fall +# back to a full reload, while the very same package built as a release kept +# rehydrating — the one asymmetry between the two build shapes that must never +# show up as a behaviour difference. +js_rehydrate = true +# Every file `src/search.js` reaches via a relative ES import, listed +# dependency-first: rheo's asset copy acts on exactly this list, not on a +# scan of import statements, so a file left out here never lands in the +# output even though the browser's own module graph would have found it. +js_scripts = [ + "src/urlstate.js", + "src/urlsync.js", + "src/text.js", + "src/marks.js", + "src/tagquery.js", + "src/score.js", + "src/row.js", + "src/preview.js", + "src/selection.js", + "src/island.js", + "src/limit.js", + "src/keywords.js", + "src/bar.js", + "src/modal.js", + "src/panel.js", + "src/search.js", +] +js_module = true diff --git a/search/0.1.0/vite.config.js b/search/0.1.0/vite.config.js new file mode 100644 index 0000000..eca3837 --- /dev/null +++ b/search/0.1.0/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { + entry: "src/search.js", + formats: ["iife"], + name: "RookerySearch", + fileName: () => "lib.js", + }, + outDir: "dist", + }, +}); diff --git a/slipshow/0.1.0/.gitignore b/slipshow/0.1.0/.gitignore new file mode 100644 index 0000000..282c648 --- /dev/null +++ b/slipshow/0.1.0/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +.direnv/ +build +*.pdf diff --git a/slipshow/0.1.0/Justfile b/slipshow/0.1.0/Justfile new file mode 100644 index 0000000..41b3a8a --- /dev/null +++ b/slipshow/0.1.0/Justfile @@ -0,0 +1,41 @@ +build: + pnpm install + pnpm run build + +# `--features html` is mandatory whatever the output format (`std.target` is +# gated by the feature and core reads it at import time); `--root .` so the +# fixture's `#import "/src/lib.typ"` resolves against this package; +# `--format pdf` into `/dev/null` because typst cannot infer a format from +# that path and nothing is rendered, only asserted. +test: + typst compile --features html --root . --format pdf test/units.typ /dev/null + ./test/panics.sh + @echo "units OK" + +# Node's own `--test` glob, not a bare directory: `node --test test/` errors +# on this machine's Node (v24) — it tries to `require` "test" as a module +# rather than scan the directory. +test-js: + node --test test/*.test.mjs + +# Asserts on the OUTPUT. The one thing it exists for is the DOM +# `src/slipshow.typ` renders: `test/units.typ` proves the tag mapping and +# `resolve-slips` return the right VALUES, but neither it nor `test-js` can +# see the markup a real rheo build produces. +check: build + rheo compile demo/rheo + ./demo/rheo/check.sh + +# Every example compiles. One recipe rather than a Justfile per example: the +# repo root's `build` walks `find . -mindepth 2 -name Justfile` and runs the +# default recipe in each directory it finds, so a Justfile down here would +# make a package build shell out to rheo five times. +examples: build + #!/usr/bin/env bash + set -euo pipefail + for d in examples/*/; do + case "$(basename "$d")" in _*) continue;; esac + echo "==> $d" + rheo compile "$d" + if [ -x "$d/check.sh" ]; then "$d/check.sh"; fi + done diff --git a/slipshow/0.1.0/demo/rheo/Justfile b/slipshow/0.1.0/demo/rheo/Justfile new file mode 100644 index 0000000..974fc85 --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/Justfile @@ -0,0 +1,18 @@ +# rheo is NOT in this repo's devShell — locally it is the sibling `rheo/` crate, +# or whatever `rheo` is on PATH. CI installs a pinned release and runs +# `just check` below, so this recipe is both the local check and the CI one. +# +# `just build` IN THE PACKAGE ROOT FIRST. This package is a BUILT one: its +# `typst.toml` points at `dist/`, which is gitignored, so a fresh checkout has +# no package for rheo to resolve until vite has run. +build: + rheo compile . + +watch: + rheo watch . + +# Asserts on the OUTPUT, not merely that the build succeeded — the DOM +# `src/slipshow.typ` renders, and the PDF branch it renders nothing at all +# for, only exist after a real rheo build. +check: build + ./check.sh diff --git a/slipshow/0.1.0/demo/rheo/check.sh b/slipshow/0.1.0/demo/rheo/check.sh new file mode 100755 index 0000000..a4fd28f --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/check.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Asserts on this fixture's OUTPUT, not merely that the build succeeded — the +# DOM `src/slipshow.typ` renders only exists after a real rheo build, and +# nothing in `test/units.typ` (a paged, no-render compile) can see it. +# +# Run through `just check`, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +P=build/pdf +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +# 1. Every page the spine mints, at the HTML depth; the PDF format lays every +# vertebra into ONE combined document rather than one file per page +# (`SpineLayout::SingleCombined`, rheo core's `reticulate/spine.rs`), named +# from this fixture's own directory rather than per page. +for f in index explicit predicate deck; do + [ -f "$H/$f.html" ] || note "no page at $H/$f.html" +done +pdf="$P/rheo.pdf" +[ -f "$pdf" ] || note "no combined PDF at $pdf" + +# 2. Exactly one deck wrapper per page, and one
element at all" +for row in "Aligns the slip" "Centers the slip" "Brings the slip fully into view"; do + grep -q "$row" "$H/deck.html" || note "deck.html: table row missing: $row" +done + +# 5. The background and non-default enter reach the markup as inline +# attributes on the SECTION, never as a class. +grep -qi 'style="background: #eef4ea"' "$H/deck.html" || + note "deck.html: the background slip did not get an inline style" +grep -q 'data-enter="focus"' "$H/deck.html" || + note "deck.html: the enter:focus slip did not carry data-enter" + +# 6. The PDF branch is transparent (`src/slipshow.typ`: "no camera, no deck, +# nothing to click"): `deck.typ`'s slips show up as plain text, in +# reading order, with no HTML tag ever entering a PDF-targeted compile at +# all. Checked against `deck.typ`'s own slides only, not the other three +# pages sharing this PDF: `index.typ`'s and `predicate.typ`'s tag-queried +# decks legitimately print each transcluded note a second time, bracketed +# "[idea:]" by `#window`'s own paged rendering — a real feature of +# the tag-query route, not the artefact this check is guarding against. +# +# `pdftotext` (poppler-utils) is the one binary this script needs beyond a +# POSIX toolbox, and its own stderr is discarded below — so an absent one is +# named here rather than exiting 127 with nothing printed. +command -v pdftotext >/dev/null || + { echo "demo/rheo: pdftotext (poppler-utils) is not installed"; exit 1; } +txt=$(pdftotext "$pdf" - 2>/dev/null) +[ -n "$(echo "$txt" | tr -d '[:space:]')" ] || note "rheo.pdf: pdftotext produced no text at all" +# A NARROW pattern, deliberately: several slides' own prose describes the +# HTML this package renders (`
`, `style="background: ..."`) as +# plain text, which a broader match on "`, which is what a reader opening this page should see. +#import "lib.typ": demo +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: demo + += Notes on rookery, as a deck + +#slipshow(slips: ( + slip("opening", title: [An endlessly scrolling presentation], fullscreen: true)[ + This deck is a dozen `@rookery/core` ideas, laid out by + `@rookery/slipshow` as one continuously scrollable page. Every slide + below is a note first and a slide second. + ], + + slip("why-a-slide-is-a-note", title: [Why a slide is a note])[ + A rookery already stores atomic, interlinked ideas. `#slip` adds nothing + to that model but a handful of presentation options — `fullscreen`, + `background`, `enter`, `order`, `class`, `max-width` — so a slip can be + windowed, searched, and linked exactly like any other idea, and a deck is + just one more way of looking at notes that already exist. + ], + + slip("two-primitives", title: [Two primitives])[ + `#idea` registers a note. `#slip` is an `#idea` variant that also tags + the note `slip` and folds its presentation options into the same tag + dictionary, because `tags` is the only field on a rookery record + extensible enough to carry them through to a tag-queried deck. + ], + + slip("the-camera", title: [The camera, not a transition], enter: "focus")[ + This slip overrides the deck's default `scroll` entry with `focus`, + which centers it on both axes and zooms it to fill the viewport. There + is no `transition:` or `duration:` anywhere in this package — the camera + is the only motion a slip has. + ], + + slip("camera-actions", title: [Eight actions])[ + #table( + columns: 2, + stroke: none, + [*Action*], [*What it moves*], + [`scroll`], [Brings the slip fully into view when it fits; otherwise scrolls to its top.], + [`up`], [Aligns the slip's top edge to the viewport's top edge.], + [`down`], [Aligns the slip's bottom edge to the viewport's bottom edge.], + [`center`], [Centers the slip vertically.], + [`focus`], [Centers the slip on both axes and zooms it to fill the viewport.], + [`left`], [Aligns the slip's left edge to the viewport's left edge.], + [`right`], [Aligns the slip's right edge to the viewport's right edge.], + [`center-x`], [Centers the slip horizontally.], + ) + ], + + slip("a-background-of-its-own", title: [Backgrounds are inline style], background: rgb("#eef4ea"))[ + A `color` tag value becomes `background: ` on this slip's own + `
`. Any other value is assumed to be an image path or URL and + becomes a `background-image` instead — a Typst gradient has no CSS + serialization this package can produce, so `background:` only ever + carries a colour or an image. + ], + + slip("options-are-tags", title: [Presentation options are tags])[ + `fullscreen` and `enter` are FLAT tags — the key alone encodes the + value, like `slip-fullscreen` or `slip-enter-focus` — so either is a pill + a filter can press. `background`, `order`, `class`, `row` and + `max-width` are VALUED tags: present-filterable, but the value itself is + for `#slipshow` alone to read. + ], + + slip("two-routes", title: [Two ways to build a deck])[ + A deck can query the registry by tag, or take an explicit ordered array + of already-rendered ideas. The two read their options back from + different places — a tag-queried deck from `ideas(values: true)`, an + explicit-array deck from the rendered content itself — and either can + work while the other is broken. This deck uses the array; the array's + sibling fixture uses the tag query. + ], + + slip("transparent-on-paper", title: [Transparent on a paged target])[ + On a PDF build there is no camera and no deck wrapper: the ideas render + in their resolved order exactly as if they had been written straight + into the document, with no `div.slipshow` and no `section.slip` + anywhere in the output. + ], + + slip("a-grid-vanishes-in-html", title: [A grid vanishes in HTML])[ + The table two slides back is a `table`, not a `grid`: Typst's HTML + export silently drops a `grid` element, which would leave this slide + correct in the PDF build and empty in the browser. A borderless `table` + is the layout choice here for exactly that reason, not for its rules. + ], + + slip("takeaways", title: [What to remember])[ + - A slip is a note with presentation options. + - The camera moves; nothing transitions or times out. + - A deck can be built by query or by array — pick whichever the content + already looks like. + ], + + slip("closing", title: [End of the deck], fullscreen: true)[ + That is the whole surface: `#slip`, `#slipshow`, and eight camera + actions between them. + ], +)) diff --git a/slipshow/0.1.0/demo/rheo/content/explicit.typ b/slipshow/0.1.0/demo/rheo/content/explicit.typ new file mode 100644 index 0000000..0a26a5b --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/content/explicit.typ @@ -0,0 +1,32 @@ +// The EXPLICIT-ARRAY route: `#slipshow(slips: ..)` reads its options back out +// of already-rendered content via `src/marker.typ`'s `slip-meta`, rather than +// querying the registry — the code path `index.typ` does not exercise at +// all. The ideas are written INLINE in the call, so nothing here is ever +// placed a second time: unlike a tag-queried deck, this page shows each slip +// exactly once. +#import "lib.typ": demo +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: demo + += Deck built from an explicit array + +// `reveal: false` is this page's second job: it is the ONE deck in this +// fixture that opts out of the progressive reveal, so `check.sh` has a +// `data-reveal="all"` root to assert against alongside the three that take +// the default. A three-slip deck on a page about how the array route reads +// its options back is also the case where showing everything at once costs a +// reader least. +#slipshow(reveal: false, slips: ( + slip("intro", title: [Written in the order it runs], fullscreen: true)[ + An explicit array is already ordered by construction, so `#slipshow` + refuses an `order:` argument here rather than silently ignoring it. + ], + slip(background: rgb("#e4edf5"))[ + An UNNAMED slip: `resolve-slips` still has to hand it back as a `"content"` + entry, and `select.typ`'s renderer falls back to its position (`slip-1`) + for the `id` a named row would otherwise supply. + ], + slip("centered", enter: "center")[ + Overrides the deck's default `scroll` entry for this one slip alone. + ], +)) diff --git a/slipshow/0.1.0/demo/rheo/content/index.typ b/slipshow/0.1.0/demo/rheo/content/index.typ new file mode 100644 index 0000000..dea166e --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/content/index.typ @@ -0,0 +1,60 @@ +// The TAG-QUERY route: `#slipshow(tags: ..)` reads its slip list out of the +// registry via `ideas(values: true)` (`select.typ`'s `_slip-rows-from-query`), +// rather than from an explicit array — the code path `explicit.typ` does not +// exercise at all. +#import "lib.typ": demo +#import "@rookery/core:0.1.0": idea +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: demo + += Deck built from a tag query + +The five notes below are ordinary `@rookery/core` ideas, authored right here +on this page. `#slipshow` at the bottom re-resolves them by tag and renders +them again, in `slip-order`, inside its own deck. + +#slip("opener", title: [Selected by tag], fullscreen: true, tags: ("demo-index",))[ + A fullscreen slip. `#slipshow` picks this note up because it carries the + `slip` tag, not because it is written near the call below — the two have no + positional relationship at all. +] + +#slip("with-background", title: [A background of its own], background: rgb("#f4ede1"), tags: ("demo-index",))[ + `background:` becomes an inline `style="background: ..."` on this slip's + `
`, computed from the colour's own hex form. +] + +#slip("with-enter", title: [A different camera action], enter: "focus", tags: ("demo-index",))[ + This slip overrides the deck's default `scroll` entry with `focus`, so its + `
` carries its own `data-enter` attribute rather than relying on + the one set on `div.slipshow`. +] + +#slip("with-order", title: [Pinned ahead of the rest], order: 3, tags: ("demo-index",))[ + An explicit `slip-order` sorts before every slip that has none, regardless + of the position either was written in. +] + +#idea("plain-note", title: [No slip options at all], tags: ("slip": none, "demo-index": none))[ + A bare `#idea`, tagged `slip` by hand rather than through `#slip` — it + carries none of `#slip`'s presentation keys. The deck still has to resolve + and render it under its own defaults. +] + +// `ideas()` reads the whole project's registry, not just this page +// (`@rookery/core`'s `data.typ`; `state.typ`'s own comment on `_registry`: +// "a note may be excluded in one file and linked from another, and `.final()` +// is what makes every reader agree"). A bare `tags: "slip"` here would also +// pull in the slips authored on `explicit.typ`, `predicate.typ` and +// `deck.typ`. `demo-index` scopes the query to the five notes above, the same +// way `@rookery/search`'s own demo scopes its fixture tags away from the rest +// of its page. +// +// Every slip above also renders once already, at its own authored position — +// that copy is an ordinary note, exactly like a bare `#idea`. The deck below +// is a SECOND view of the same five notes, transcluded through `#window` +// (`select.typ`'s `kind: "row"` route): a tag-queried deck is always an +// additional view onto content that already lives somewhere, never its only +// home. `explicit.typ` is the route for a deck that should have no such +// twin. +#slipshow(tags: ("slip", "demo-index"), match: "all") diff --git a/slipshow/0.1.0/demo/rheo/content/lib.typ b/slipshow/0.1.0/demo/rheo/content/lib.typ new file mode 100644 index 0000000..9b0254f --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/content/lib.typ @@ -0,0 +1,11 @@ +// Shared @rookery/core configuration, applied by every page below. `#show: +// rookery` is per-FILE — an import cannot install it for another file — so a +// project applying one configuration wraps it once here and every vertebra +// applies the wrapper. Excluded from the spine (see rheo.toml) because it +// holds no page content of its own. +#import "@rookery/core:0.1.0": rookery + +#let demo(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/demo/rheo/content/predicate.typ b/slipshow/0.1.0/demo/rheo/content/predicate.typ new file mode 100644 index 0000000..e57de9a --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/content/predicate.typ @@ -0,0 +1,33 @@ +// `tags:` also accepts a PREDICATE — a function from a tag dictionary to a +// bool (`select.typ`) — as the extension point for a full boolean grammar +// with no dependency on `@rookery/search`. A project wanting the `a&b` +// grammar builds the predicate from that package's `parse-tag-query`/ +// `eval-tag-query` and hands the result straight to `tags:`; this file proves +// the extension point works with `@rookery/search` installed nowhere at all — +// the predicate below is a plain inline Typst closure. +#import "lib.typ": demo +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: demo + += Deck built from a tag predicate + +#slip("a", title: [Fullscreen, selected], fullscreen: true, tags: ("demo-predicate",))[ + Fullscreen and tagged for this page, so the predicate below keeps it. +] + +#slip("b", title: [Not fullscreen, dropped], tags: ("demo-predicate",))[ + Tagged for this page but not fullscreen, so the predicate below drops it — + the note still exists in the registry, it simply is not in this deck. +] + +#slip("c", title: [Fullscreen, selected], fullscreen: true, tags: ("demo-predicate",))[ + A second fullscreen slip, also kept. +] + +// `demo-predicate` scopes the predicate to this page's own three notes: the +// registry is project-wide (see `index.typ`'s own comment on `ideas()`), and +// `a`/`c`'s bare `slip-fullscreen` tag would otherwise also match the +// fullscreen bookends on `deck.typ`. +#let is-selected-fullscreen = t => "demo-predicate" in t and "slip-fullscreen" in t + +#slipshow(tags: is-selected-fullscreen) diff --git a/slipshow/0.1.0/demo/rheo/rheo.toml b/slipshow/0.1.0/demo/rheo/rheo.toml new file mode 100644 index 0000000..2848162 --- /dev/null +++ b/slipshow/0.1.0/demo/rheo/rheo.toml @@ -0,0 +1,35 @@ +# @rookery/slipshow's only in-repo rheo fixture. This repo's own definition of +# lint (CLAUDE.md, "Build") is "the package builds and its demo compiles", +# and without this the package shipped src/, dist/ and test/ with no demo/ +# ever exercising the DOM `src/slipshow.typ` actually renders. +# +# The corpus is a REAL rookery, not one page: `index.typ` builds its deck from +# a TAG QUERY, `explicit.typ` from an EXPLICIT ARRAY, `predicate.typ` from a +# PREDICATE `tags:` function, and `deck.typ` is the realistic dozen-slip +# presentation this package exists for. Four pages because the two +# definition routes (`select.typ`) take different code paths through +# `resolve-slips`, and either can break while the other passes. +# +# `formats` carries `pdf` deliberately: `#slipshow` renders transparently — +# no camera, no deck wrapper, the ideas in their resolved order — on a paged +# target, and only a PDF build proves that branch runs at all. +version = "0.6.2" +content_dir = "content" +formats = ["html", "pdf"] + +[spine] +# `lib.typ` is a library, not a page: it holds the `demo` wrapper every +# vertebra applies. Without this it would compile to its own `lib.html`. +exclude = ["lib.typ"] + +# The Typst package cache's `rookery` namespace +# (`~/.cache/typst/packages/rookery`) is a per-machine convenience symlink, +# and on a machine running several checkouts side by side it can point +# anywhere — including a DIFFERENT checkout of this repository than the one +# this fixture lives in. This override reads `@rookery/*` straight out of +# THIS repository's own tree instead, so the fixture always exercises the +# code sitting next to it rather than whatever the cache happens to resolve +# to. `path` is anchored to this file's own directory, four levels up from +# `//`. +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/_template/content/lib.typ b/slipshow/0.1.0/examples/_template/content/lib.typ new file mode 100644 index 0000000..a571762 --- /dev/null +++ b/slipshow/0.1.0/examples/_template/content/lib.typ @@ -0,0 +1,10 @@ +// Shared `@rookery/core` configuration for this example. `#show: rookery` is +// per-FILE — an import cannot install it for another file — so every page +// imports `template` from here and applies it once. Excluded from the spine +// (see `rheo.toml`) because it holds no page content of its own. +#import "@rookery/core:0.1.0": rookery + +#let template(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/examples/_template/rheo.toml b/slipshow/0.1.0/examples/_template/rheo.toml new file mode 100644 index 0000000..359d6c5 --- /dev/null +++ b/slipshow/0.1.0/examples/_template/rheo.toml @@ -0,0 +1,36 @@ +# The shape every example under `examples/` copies. `version` matches the +# `min_version` this package's `typst.toml` declares and the rheo version +# `.github/workflows/check.yml` installs — do not raise it here; the point of +# the floor is that the declared floor is the one tested. +# +# An example adds `formats = ["html", "pdf"]` only where it asserts something +# about paged output (a `#slipshow` compiled outside HTML renders +# transparently — see `src/slipshow.typ`) — a PDF build doubles the compile +# for no gain in the rest. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +# `lib.typ` holds the show rule every page applies, not a page of its own — +# without this it would compile to its own `lib.html`. +exclude = ["lib.typ"] + +# The Typst package cache's `rookery` namespace is a per-machine symlink, and +# on a machine checking out this repository more than once (several jj +# workspaces, a second clone) it can point at a DIFFERENT checkout than the +# one this example lives in — silently building against whatever code sits +# there instead of the code next to it. This override reads `@rookery/*` +# straight out of THIS repository's tree instead, the same fix +# `demo/rheo/rheo.toml` applies to this package's own fixture and for the +# same reason. `path` is anchored to this file's own directory, four levels +# up from `//`. +# +# An example is also meant to be COPIED OUT of this repo, where a relative +# path four levels up no longer resolves to anything. A copy taken elsewhere +# needs this block replaced with however that project already resolves +# `@rookery` — a `releases`/`repo` source (see this repo's own CLAUDE.md, +# "Local development against a live rheo project") — or dropped entirely if +# the package cache already has it. +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/backgrounds/check.sh b/slipshow/0.1.0/examples/backgrounds/check.sh new file mode 100755 index 0000000..5813155 --- /dev/null +++ b/slipshow/0.1.0/examples/backgrounds/check.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Asserts on this example's OUTPUT, not merely that the build succeeded — a +# dropped CSS declaration, a mis-converted gradient angle, or an image +# referenced the wrong way all compile clean and none of them fails a build. +# +# Run through `just examples` at the package root, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +P=build/pdf +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +idx="$H/index.html" +fs="$H/fullscreen.html" +deep="$H/nested/deep.html" +tbl="$H/table.html" +for f in "$idx" "$fs" "$deep" "$tbl"; do + [ -f "$f" ] || note "no page at $f" +done + +# 1. Every gradient kind reached the markup, and the default linear gradient +# carries the +90 correction (`_gradient-css`, `src/slipshow.typ`) rather +# than Typst's own raw 0deg. +for kind in "linear-gradient(" "radial-gradient(" "conic-gradient("; do + grep -qF "$kind" "$idx" || note "index.html: missing $kind" +done +grep -q 'linear-gradient(90deg' "$idx" || + note "index.html: the default linear gradient did not carry the +90 correction (want 90deg)" +grep -q 'linear-gradient(0deg' "$idx" && + note "index.html: the default linear gradient emitted Typst's raw 0deg, uncorrected" + +# 2. The eight-digit alpha hex reaches the markup unmodified — no special +# handling anywhere in this package, so a regression here is a regression +# in `.to-hex()` itself, not in this package's own code. +grep -q '#ff000080' "$idx" || note "index.html: missing the alpha colour's #ff000080" + +# 3. fullscreen.html: exactly two fullscreen sections, each carrying either +# an image layer (`div.slip-bg`) or an inline gradient declaration. +full=$(grep -o 'class="slip slip-fullscreen' "$fs" | wc -l) +[ "$full" -eq 2 ] || note "fullscreen.html: expected 2 fullscreen slips, found $full" +python3 - "$fs" <<'PY' || fail=1 +import re, sys +h = open(sys.argv[1]).read() +secs = re.findall(r'
]*>.*?(?=
/dev/null 2>&1; then + note "found a literal url( in the built HTML — an image background must be a data: URI" +fi + +# 5. THE DEPTH ASSERTION: nested/deep.html embeds the byte-identical PNG +# payload as fullscreen.html's own root-level image slip. A +# `url(pattern.png)` would have resolved against a different directory at +# each depth and broken one of the two; the data: URI does not care. +python3 - "$fs" "$deep" <<'PY' || fail=1 +import re, sys +fs_html = open(sys.argv[1]).read() +deep_html = open(sys.argv[2]).read() +m1 = re.search(r'data:image/png;base64,[A-Za-z0-9+/=]+', fs_html) +m2 = re.search(r'data:image/png;base64,[A-Za-z0-9+/=]+', deep_html) +if not m1: + print("FAIL: fullscreen.html carries no PNG data: URI"); sys.exit(1) +if not m2: + print("FAIL: nested/deep.html carries no PNG data: URI"); sys.exit(1) +if m1.group(0) != m2.group(0): + print("FAIL: fullscreen.html and nested/deep.html embed different PNG payloads for the same source image") + sys.exit(1) +print(" depth: nested/deep.html embeds a byte-identical PNG payload to the root page") +PY + +# 6. The PDF build exists and is transparent (`src/slipshow.typ`: "no +# camera, no deck, nothing to click"): no HTML/slipshow wrapper artefact +# ever enters a PDF-targeted compile, the note bodies are all present, and +# the table's rows print in order — the one assertion that catches the +# grid-in-HTML trap this package cannot express as code (see table.typ). +pdf=$(find "$P" -name '*.pdf' | head -1) +[ -n "$pdf" ] && [ -f "$pdf" ] || note "no PDF built under $P" +if [ -n "${pdf:-}" ] && [ -f "$pdf" ]; then + txt=$(pdftotext "$pdf" - 2>/dev/null) + [ -n "$(echo "$txt" | tr -d '[:space:]')" ] || note "pdftotext produced no text at all" + # `[idea:]` is the tag-query route's own second reference (`#window`'s + # paged rendering, see `demo/rheo/check.sh`'s identical comment) — a real + # feature of this route, not the leak this line guards against. + echo "$txt" | grep -qE '
5(yNW<^#4k{pE~R74u&}) z{iT0i9Isfv)#j=c$3EzoWtHj2WdSXOcC}{O4|xJOtu4OPtYS idZz6OGLfg@FXL67j9q(fomT<|8iS{+pUXO@geCxmm6=HZ literal 0 HcmV?d00001 diff --git a/slipshow/0.1.0/examples/backgrounds/content/table.typ b/slipshow/0.1.0/examples/backgrounds/content/table.typ new file mode 100644 index 0000000..33045de --- /dev/null +++ b/slipshow/0.1.0/examples/backgrounds/content/table.typ @@ -0,0 +1,30 @@ +// A borderless `table` over a gradient background. Typst's HTML export +// silently drops `grid` — a slip built with one compiles clean, renders +// correctly in the PDF branch, and is empty in the browser — which is a +// constraint no assertion in this package's own code can express. `table` +// is the safe choice for tabular layout inside a slip, and this project +// builds both formats specifically so the trap is reachable here rather +// than only documented. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: template + += A table over a gradient + +#slip( + "table-over-gradient", + title: [Three camera actions, over a gradient], + background: gradient.linear(rgb("#0f2027"), rgb("#2c5364"), angle: 90deg), + tags: ("bg-table",), +)[ + #table( + columns: 2, + stroke: none, + [*Action*], [*What it moves*], + [`scroll`], [Brings the slip fully into view when it fits.], + [`focus`], [Centers the slip on both axes and zooms to fill the viewport.], + [`left`], [Aligns the slip's left edge to the viewport's left edge.], + ) +] + +#slipshow(tags: ("slip", "bg-table"), match: "all") diff --git a/slipshow/0.1.0/examples/backgrounds/content/texture.jpg b/slipshow/0.1.0/examples/backgrounds/content/texture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d200022956385e7e90331ef051f9e6fcf32fd00c GIT binary patch literal 1712 zcmb7^2~3ko6vw}|AEgIJKMq~NA`os7VF6Jtq1-692T(yd3KpTrA;?wamP16aAh!ge zzzPDQU=zcO035+{`%_31 z0OJSI+%pXv0E2@Feh>yCzTbes`2Yk6@ylu;Q8K@38as#(9D~HPOgzf8O`YPnA(F6f z>Hd)q!N(5=d9{=vE*Cx&x7?3&-fhsqgAWOb%4!$~i4Yn*#bh||00C}2ID~+JqrjRi zs0j~ra}E@=%lvG} z3R6%-QtAMuZCtW7C*zFtz~bu~WsO%ved&p-*Zq#2hKt2vjFL>>f$~m~%o*Rs)wI_- z&vlpaL*nwxA?v3EVLq#D3wD^wdKj~(+3&r~fH}T>u$#t54J%T`^6R0h%II;r1u+Y; zhHgZ(ikyg*T+YjB<)SuMK*@|*A8!fbD5yzei5Rm&dW}(#-TIW?!hSN%B*D379`R*$ zpwWP(*4`zArk9lObhgO*{#Mr47xmro{jKNWjT-5R5;6HSGljzozKN2*LrYeElY}gp zgM}TR?ykFt>xtGsNK#Wlo(xH>pxzA6Q{@1|J*~y}gDc#zUA@T}du0X}NyaX{t%d&k z1q?eBl*p5|m8FqZ7s^RJsW$8oj8LiKGy8;c_eY-0-l-8K<2U!tG@PGW&JvTG9x;}K zqjbQF(wjR1E{w=K{9+t{;?&moF#p71?TosIRH=e63r5n&5m>v{3M;f~kycWda@1f^ z%hLby14bWPaJ=iK8jd`bf5Oo#^WkOVr;rpYx6$A2#oQ8W{ZUxVtIFkQdONXSdG{@5&Dbld8Hyw zSNr{7-q(zV0oEc03cWDQGya%oe1*g%fAdP&?n8|fIQnVau1xo8n2qfsvASz?ZnY&p zRj`}b(llM!-Q805S<%L7D7@2<8Sy9-E73#l7ygK>18Xj' + r'|
` alternative, so depth + # starts at 1 inside the deck and a row's own depth is what closes it. + out, depth, rowdepth = [], 0, None + for m in re.finditer(TAGS, html): + s = m.group(0) + if s.startswith('
': + if depth == rowdepth: + rowdepth = None + depth -= 1 + elif rowdepth is not None: + out[-1][2].append(m.group(2)) + else: + out.append(("bare", None, [m.group(2)])) + return out + +def rows(html): + return [(r, ids) for kind, r, ids in blocks(html) if kind == "row"] + +def seq(html): + # The same sequence with the `slip-idea:` prefix and the row number + # dropped — the row NUMBERS are group ids `dfs-of` mints and nothing + # reads them as an index, so pinning them would assert an implementation + # detail rather than a layout. + return [ + (kind, [i.removeprefix("slip-idea:") for i in ids]) + for kind, _, ids in blocks(html) + ] + +# 1. THE DEPTH-FIRST SEQUENCE, block by block. `kickoff` stacks on its own, +# the four todos it releases share ONE row, and each of those four is then +# followed down its own branch before the next dependency-free todo starts +# — `merge-results` and `ship-final` are `audit-logs`' branch, not a layer. +# The four remaining dependency-free todos come last, in priority-then-name +# order (`note-onboarding` 2, `renew-lease` 2, `sync-calendar` 3, +# `retire-legacy` 4), each stacking on its own. +INDEX_SEQ = [ + ("bare", ["kickoff"]), + ("row", ["audit-logs", "collect-data", "review-budget", "draft-notes"]), + ("bare", ["merge-results"]), + ("bare", ["ship-final"]), + ("bare", ["compile-summary"]), + ("bare", ["sign-off"]), + ("bare", ["publish-report"]), + ("bare", ["note-onboarding"]), + ("bare", ["renew-lease"]), + ("bare", ["sync-calendar"]), + ("bare", ["retire-legacy"]), +] +check("index.html block sequence", seq(deck("index")), INDEX_SEQ) + +# 2. The one row's ORDER: priority then name, unprioritised last. +# `audit-logs`/`collect-data` share priority 1 (name breaks the tie), +# `review-budget` is priority 3, and `draft-notes` carries no priority at +# all — the corpus's one deliberately unprioritised sibling +# (`content/corpus.typ`) — so it must still come last, not first. Pinned +# separately from the sequence above because it is the tie-break rule +# `dfs-of` shares with `layers()`, not a fact about the walk. +idx = rows(deck("index")) +if len(idx) != 1: + print(f"FAIL: index.html: expected 1 div.slip-row, found {len(idx)}"); bad = 1 +else: + check( + "index.html row id sequence (priority, then name, unprioritised last)", + idx[0][1], + ["slip-idea:audit-logs", "slip-idea:collect-data", + "slip-idea:review-budget", "slip-idea:draft-notes"], + ) + +# 3. `across.html` is the same graph under `direction: "across"`, and the one +# difference is the top: the five todos with nothing blocking them span a +# single row instead of stacking, and everything they release is laid out +# exactly as it is on `index.html`. +ACROSS_SEQ = [ + ("row", ["kickoff", "note-onboarding", "renew-lease", "sync-calendar", + "retire-legacy"]), + ("row", ["audit-logs", "collect-data", "review-budget", "draft-notes"]), + ("bare", ["merge-results"]), + ("bare", ["ship-final"]), + ("bare", ["compile-summary"]), + ("bare", ["sign-off"]), + ("bare", ["publish-report"]), +] +check("across.html block sequence", seq(deck("across")), ACROSS_SEQ) + +# 4. Every section sharing a row with another carries a `max-width` in its +# inline `style` — a multi-slip row with no cap is four full-width slips +# scrolled one at a time, not the layout this example is proving. +for page in ("index", "across"): + html = deck(page) + for row, ids in rows(html): + if len(ids) <= 1: + continue + for id in ids: + sec = re.search( + r'
]*>', html, + ) + if sec is None or "max-width" not in sec.group(0): + note(f"{page}.html: {id} (row {row}) carries no max-width") + +# 5. `open-only.html` drops exactly the one closed todo (`retire-legacy`, +# `content/corpus.typ`) and nothing else. `graph-slice` narrows the graph; +# it does not disturb the walk over what is left, so the sequence is +# `index.html`'s with that one bare block removed. +check("open-only.html block sequence", + seq(deck("open-only")), + [b for b in INDEX_SEQ if b != ("bare", ["retire-legacy"])]) + +# 6. `wide.html` is the deliberately overdone case: one row, eight slips, +# each capped at 20em so the row overflows and scrolls horizontally +# within itself rather than the page growing wider. +wide = rows(deck("wide")) +if len(wide) != 1: + print(f"FAIL: wide.html: expected 1 div.slip-row, found {len(wide)}"); bad = 1 +else: + check("wide.html row 0 section count", len(wide[0][1]), 8) + +# 7. The status rail: `todo-slip-keys`'s `class:` function (`todos/0.1.0/ +# src/deck.typ`) mints `todo-slip-ready`/`-blocked`/`-closed` straight +# onto each slide's own `
` — the classes `todos.css`'s +# `.todo-slip-ready`/`-blocked`/`-closed` rules key on. +def section_classes(html): + return { + id: cls.split() + for cls, id in re.findall(r'
` OR ITS PATHS, deliberately: the +# connector layer is built by `src/edges.js` at runtime and this file +# reads static build output, where no script has run, so a path count +# could never pass. The declaration is the half that comes from the graph +# and the half a build can see; the drawing is covered by +# `test/edges.test.mjs` (its geometry) and by a browser (its appearance). +def section_edges(html): + # `data-slip-edges` per section id. A section WITHOUT the attribute is + # absent from the dict entirely rather than mapped to `[]` — the two are + # different claims and three of the checks below turn on the difference. + out = {} + for tag in re.findall(r'
]*>', html): + id_m = re.search(r' id="([^"]+)"', tag) + e_m = re.search(r' data-slip-edges="([^"]*)"', tag) + if id_m and e_m: + out[id_m.group(1)] = e_m.group(1).split() + return out + +idx_edges = section_edges(deck("index")) +oo_edges = section_edges(deck("open-only")) + +# A slide `kickoff` releases depends on it alone; one further down the branch +# on two todos, and the attribute keeps them in `deps` order rather than +# sorting them. +check("index.html audit-logs edges", idx_edges.get("slip-idea:audit-logs"), + ["slip-idea:kickoff"]) +check("index.html merge-results edges (in deps order)", + idx_edges.get("slip-idea:merge-results"), + ["slip-idea:audit-logs", "slip-idea:collect-data"]) + +# `kickoff` depends on nothing, so nothing feeds its rail and it carries no +# attribute at all — not an empty one. +if "slip-idea:kickoff" in idx_edges: + print(f"FAIL: index.html: kickoff carries data-slip-edges=" + f"{idx_edges['slip-idea:kickoff']} despite depending on nothing") + bad = 1 + +# THE DECK-SIDE TWIN OF CHECK 8. `note-onboarding`'s one dep is dangling, so +# it is not a blocker and draws no curve either — the pair pins that rule from +# both directions, the class and the edge. +if "slip-idea:note-onboarding" in idx_edges: + print("FAIL: index.html: note-onboarding carries data-slip-edges despite " + "its one dep being dangling") + bad = 1 + +# EVERY ID NAMED RESOLVES TO A SLIDE ON THE SAME PAGE. This is what would +# catch the drop-names-outside-the-deck rule regressing into an attribute full +# of ids pointing nowhere — edges no curve could ever be drawn for. +for page, edges in (("index", idx_edges), ("open-only", oo_edges)): + present = set(section_classes(deck(page))) + for source, targets in edges.items(): + for t in targets: + if t not in present: + print(f"FAIL: {page}.html: {source}'s data-slip-edges names {t}, " + f"which is no section on this page") + bad = 1 + +# `open-only.html` reaches `#slipshow` by the `slips:` route +# (`content/open-only.typ`), and the same edges land there: `edges:` composes +# with an explicit array exactly as `row:` and `class:` already do. The +# corpus's one closed todo blocks nothing, so dropping it takes no edge with +# it and the answer is the same as on `index.html`. +check("open-only.html merge-results edges", + oo_edges.get("slip-idea:merge-results"), + ["slip-idea:audit-logs", "slip-idea:collect-data"]) + +if not bad: + print(" index: depth-first sequence, one sibling row in priority/name order") + print(" across: the dependency-free todos span one row, the rest unchanged") + print(" index/across: every multi-slip row is max-width'd") + print(" index: status rail matches the graph, including the dangling-dep case") + print(" index: connector edges are the open deps, and every id names a slide") + print(" open-only: one fewer section, same walk, same rail") + print(" wide: one row, eight sections") +sys.exit(bad) +PY + +if [ "$fail" -eq 0 ]; then echo "examples/dag OK"; else echo "examples/dag FAILED"; exit 1; fi diff --git a/slipshow/0.1.0/examples/dag/content/across.typ b/slipshow/0.1.0/examples/dag/content/across.typ new file mode 100644 index 0000000..eaf96b8 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/across.typ @@ -0,0 +1,13 @@ +// The same corpus as `content/index.typ`, with the one option that changes +// the layout: `direction: "across"` puts every dependency-free todo — the +// five with nothing blocking them — into a single row spanning the screen, +// where the default stacks them one under the next. Everything they release +// is laid out identically on both pages, because the sibling group is what +// decides a row and `direction:` does not touch it: the four todos `kickoff` +// releases share a row here exactly as they do there. +#import "lib.typ": template, todo-slipshow +#show: template + += Organizing the retreat, across + +#todo-slipshow(tags: "todo", direction: "across") diff --git a/slipshow/0.1.0/examples/dag/content/corpus.typ b/slipshow/0.1.0/examples/dag/content/corpus.typ new file mode 100644 index 0000000..b9f7541 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/corpus.typ @@ -0,0 +1,161 @@ +// Fourteen todos, each with a PINNED name, telling one story — organizing a +// retreat — so the graph below is shaped like a real project's rather than a +// synthetic chain. It is four levels deep, and this file's shape is what +// every page reading it renders: +// +// level 0 (5) `kickoff` and four unrelated standalone todos beside it — +// one CLOSED (`retire-legacy`), one with a DANGLING dep +// (`note-onboarding`, which names a note that does not +// exist — see `@rookery/todos`' `graph.typ` line 62 for why +// that is not an error), and two ordinary open todos. None of +// the five is blocked, so these are the walk's roots and the +// todos `direction: "across"` puts in one row. +// level 1 (4) `audit-logs`, `collect-data`, `review-budget`, and +// `draft-notes` all depend on `kickoff` and on nothing from +// each other — the WIDE sibling group this whole example +// exists to prove, so several unrelated todos releasing off +// one parent sit BESIDE each other rather than in a list. +// Priorities 3, 3, 1 and none (unprioritised) so the sibling +// ordering — priority descending, then name, unprioritised +// last — is visible rather than accidental. +// level 2 (3) each depends on two level-1 todos. +// level 3 (2) each depends on two level-2 todos. +// +// NO TODO CARRIES A ROW TAG: the row a todo joins is derived from the graph +// above, not authored — `content/index.typ` is one `#todo-slipshow` call and +// nothing else. `slip-max-width` stays a tag here, since which todos are +// worth capping is a presentation choice this file makes, not something the +// graph derives. +#import "lib.typ": template, todo +#show: template + += The corpus + +Fourteen todos for one retreat, authored once here. `content/index.typ`, +`content/open-only.typ` and `content/wide.typ` all read this same graph — +nothing below is rendered specially. + +#todo( + "kickoff", title: [Kick off the retreat], priority: 4, + tags: (slip-max-width: 18em), +)[ + Pick a date, book the venue, and tell everyone it is happening. +] + +#todo( + "retire-legacy", title: [Retire the legacy signup form], priority: 0, + done: datetime(year: 2026, month: 1, day: 10), + tags: (slip-max-width: 18em), +)[ + The old paper signup sheet is finally gone — everyone signs up online now. +] + +#todo( + "note-onboarding", title: [Draft onboarding notes], priority: 2, + deps: ("legacy-import",), + tags: (slip-max-width: 18em), +)[ + Meant to carry over the attendee list the old signup form kept, but that + form (`legacy-import`) was never itself a todo in this rookery — a typo or + a note pinned on a page outside the spine, either way this dep resolves to + nothing and stays a todo the graph cannot place. +] + +#todo( + "renew-lease", title: [Renew the venue lease], priority: 2, + tags: (slip-max-width: 18em), +)[ + The venue's lease on the retreat hall expires the week before the retreat + does, so this has to close before travel is booked. +] + +#todo( + "sync-calendar", title: [Sync the shared calendar], priority: 1, + tags: (slip-max-width: 18em), +)[ + Everyone's calendar invite should show the same dates, room, and dial-in. +] + +#todo( + "audit-logs", title: [Audit the registration logs], priority: 3, + deps: ("kickoff",), + tags: (slip-max-width: 24em), +)[ + Check the registration system for duplicate or bounced signups before + anyone downstream builds a headcount on top of it. +] + +#todo( + "collect-data", title: [Collect attendee dietary data], priority: 3, + deps: ("kickoff",), + tags: (slip-max-width: 24em), +)[ + A short form asking every attendee about allergies and preferences, closed + a week before catering needs numbers. +] + +#todo( + "review-budget", title: [Review the catering budget], priority: 1, + deps: ("kickoff",), + tags: (slip-max-width: 24em), +)[ + Last year's per-head catering cost, checked against this year's quote + before anyone commits to a headcount. +] + +#todo( + "draft-notes", title: [Draft the welcome notes], + deps: ("kickoff",), + tags: (slip-max-width: 24em), +)[ + A page handed out at check-in: schedule, wifi password, where the bathrooms + are. Nobody has claimed it yet, so it carries no priority at all — and it + is exactly the todo that has to sort LAST among its four siblings, not + first, for that reason. +] + +#todo( + "compile-summary", title: [Compile the attendee summary], priority: 3, + deps: ("collect-data", "draft-notes"), + tags: (slip-max-width: 24em), +)[ + One page combining the dietary form's results with whatever the welcome + notes already promise attendees, so catering and the door desk read the + same numbers. +] + +#todo( + "merge-results", title: [Merge the audit and dietary results], priority: 2, + deps: ("audit-logs", "collect-data"), + tags: (slip-max-width: 24em), +)[ + One clean roster: the audited registration list joined against who + actually answered the dietary form. +] + +#todo( + "publish-report", title: [Publish the budget report], priority: 2, + deps: ("review-budget", "draft-notes"), + tags: (slip-max-width: 24em), +)[ + The reviewed catering budget, written up alongside the welcome notes' + headcount assumptions, for whoever signs the final cheque. +] + +#todo( + "ship-final", title: [Ship the retreat pack], priority: 4, + deps: ("merge-results", "publish-report"), + tags: (slip-max-width: 45%), +)[ + The roster and the budget report, bundled into one pack and sent to the + venue and the caterer. +] + +#todo( + "sign-off", title: [Sign off the retreat plan], priority: 2, + deps: ("compile-summary", "publish-report"), + tags: (slip-max-width: 45%), +)[ + A last read of the attendee summary against the budget report before + travel gets booked against either one. +] diff --git a/slipshow/0.1.0/examples/dag/content/index.typ b/slipshow/0.1.0/examples/dag/content/index.typ new file mode 100644 index 0000000..0ec1fb7 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/index.typ @@ -0,0 +1,42 @@ +// `@rookery/todos`' `dfs-of()` walks a dependency graph depth-first: one +// branch is followed to its end before the next starts, and the todos a +// single parent releases are emitted together as one group. That is what a +// deck wants — a todo sits next to the work it unblocks rather than a layer +// away from it, so the connector curve between them is short. This page is +// that walk over the fourteen todos in `content/corpus.typ`: `kickoff` and +// the four unrelated todos stack down the screen, and the four things +// `kickoff` releases span one row beside each other. +// +// `content/across.typ` is the same graph with `direction: "across"`, the one +// option that changes any of this. +// +// THE WHOLE PAGE IS ONE `#todo-slipshow` CALL. The row each slide joins, the +// sibling order (priority descending, then name, unprioritised last) and the status rail +// (ready/blocked/closed) all come straight from `todo-slip-keys` inside +// `@rookery/todos`' `deck.typ` — this page hands it nothing but the tag query +// selecting every todo, and has no `row:`/`order:`/`class:` of its own to +// keep in sync with the graph. +// +// TWO WRAPPER-BASED ROUTES WERE TRIED FIRST AND BOTH FAILED, worth recording +// so nobody re-discovers it the hard way. Wrapping every todo inline — +// `slip(name, row: group.at(name))[#window(name)]` — collides: the todo's +// own name is already registered under `idea:`, and a second +// registration under that id with different tags panics as a duplicate +// (`core/0.1.0/src/idea.typ`'s `_registry.update`). Dropping to an auto id +// avoids that particular collision but panics differently: deciding which +// todos to wrap means reading `todo-graph()`'s `.final()` registry, which is +// not settled until every note on the page — wrapper ideas included — has +// been placed once, so Typst reruns layout to converge. Each rerun +// re-registers the auto-id wrapper from the counter's start and lands on +// the SAME id with DIFFERENT content across runs, which panics as a +// duplicate too. `row:` exists because neither wrapper shape works: it +// hands a computed row straight to a todo's own existing registration +// instead of creating a second one. (`content/wide.typ` still wraps its +// todos — its row is a page-local override, not a graph fact, so a second, +// pinned-name registration is the right shape there.) +#import "lib.typ": template, todo-slipshow +#show: template + += Organizing the retreat, down the screen + +#todo-slipshow(tags: "todo") diff --git a/slipshow/0.1.0/examples/dag/content/lib.typ b/slipshow/0.1.0/examples/dag/content/lib.typ new file mode 100644 index 0000000..838ec87 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/lib.typ @@ -0,0 +1,21 @@ +// Shared configuration for this example, and the one file that names all +// three packages it composes: `@rookery/core` for the show rule every page +// applies, `@rookery/todos` for the dependency graph `content/corpus.typ` +// builds and `content/index.typ`/`content/open-only.typ`/`content/wide.typ` +// read back, and `@rookery/slipshow` for the horizontal deck itself. +// slipshow does not import todos, and todos does not import slipshow — this +// project is what composes them, which is the whole point of the example. +// +// `invisible-tags` hides `slip-max-width` from every tag pill: it is this +// page's own presentation instruction, not something a reader looking at a +// todo card needs to see, and rookery renders any tag it does not know how +// to interpret otherwise. There is no `slip-row` tag to hide alongside it — +// every row here comes from `#slipshow`'s `row:`, not from a tag on `#todo`. +#import "@rookery/core:0.1.0": rookery, window +#import "@rookery/todos:0.1.0": todo, todo-graph, graph-slice, layer-of, layers, priority-of, todo-slip-keys, todo-slipshow +#import "@rookery/slipshow:0.1.0": slip, slipshow + +#let template(doc) = { + show: rookery.with(invisible-tags: ("slip-max-width",)) + doc +} diff --git a/slipshow/0.1.0/examples/dag/content/open-only.typ b/slipshow/0.1.0/examples/dag/content/open-only.typ new file mode 100644 index 0000000..f703e80 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/open-only.typ @@ -0,0 +1,42 @@ +// The same deck as `content/index.typ`, over `graph-slice(graph, closed: +// false)` instead of a bare tag query — proof that the walk is DERIVED from +// the graph rather than hardcoded on this page. `retire-legacy` is the +// corpus's one closed todo (`content/corpus.typ`), one of the five with +// nothing blocking it, so removing it takes one block out of the sequence and +// leaves every other slide exactly where the unsliced deck puts it. +// +// `graph-slice` hands back rows and edges, not names, and `#slipshow`'s +// `slips:` wants an ordered array of names (or content) — `order:`/ +// `reverse:` are refused alongside `slips:` because an explicit array is +// already in the order it was written (`src/select.typ`), so the sort this +// page needs happens here, over the sliced rows, before naming them. +// `row:`, `class:` and `edges:` have no such restriction — none of them +// selects or reorders anything — so all three compose with `slips:` the same +// way they compose with a tag query on `content/index.typ`. +// +// `row:`/`order:`/`class:`/`edges:` THEMSELVES ARE THE PACKAGE'S, not +// hand-rolled on this page: `todo-slip-keys(graph)` (`@rookery/todos`' +// `deck.typ`) returns the same four key functions `#todo-slipshow` feeds +// `#slipshow` on `content/index.typ`, computed once here into `keys` and +// reused for the sort, the row grouping, the status rail and the connector +// curves — `dfs-of` runs on the FULL graph inside it, closed todos included, +// so a slice's sequence agrees with the unsliced deck's. The slice takes no +// edge with it either: the one todo it drops is closed, and a closed todo +// blocks nothing. +#import "lib.typ": template, todo-graph, graph-slice, todo-slip-keys, slipshow +#show: template + += The same DAG, open todos only + +#context { + let graph = todo-graph() + let sliced = graph-slice(graph, closed: false) + let keys = todo-slip-keys(graph) + let ordered = sliced.rows.sorted(key: keys.order) + slipshow( + slips: ordered.map(r => r.name), + row: keys.row, + class: keys.class, + edges: keys.edges, + ) +} diff --git a/slipshow/0.1.0/examples/dag/content/wide.typ b/slipshow/0.1.0/examples/dag/content/wide.typ new file mode 100644 index 0000000..1bd2b05 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/content/wide.typ @@ -0,0 +1,40 @@ +// The sideways-scroll case, deliberately overdone: eight todos forced into +// ONE row, each capped at `max-width: 20em`, so on any screen narrower than +// eight of those side by side the row overflows and scrolls horizontally +// within itself while the rest of the page stays put. This is the only page +// in this example that reaches the controller's row-scroll branch, because +// it is the only one that puts more into a row than a screen can hold. +// +// `content/corpus.typ`'s own `slip-row` tags split these eight across two +// different layers (0 and 1), which is correct for `content/index.typ` but +// wrong for THIS page — an artificial row like this one is a per-page +// presentation choice, not a fact about the graph, so it has to override +// what the note itself carries rather than read it. That is what `#slip`'s +// wrapper is for: `#slip("wide-" + name, row: 0, max-width: 20em)[#window(name)]` +// registers a SECOND, pinned note per todo, under a name derived from but +// distinct from the todo's own — so its own `row`/`max-width` win for this +// page while the wrapped `#window` still shows the real todo underneath +// unchanged. +// +// PINNED, NOT AUTO-ID, and this was tried the other way first and MEASURED +// to break: an auto id is read back from `counter("rheo-ideas-seq")` at the +// position each wrapper is finally placed, but this page decides WHICH +// eight todos to wrap by reading `todo-graph()`'s `.final()` registry — +// itself unresolved until every note on the page (wrapper ideas included) +// has been placed. Typst re-runs layout to converge that circularity, and +// each rerun re-registers the auto-id wrappers from the counter's start, +// landing on the SAME id (`idea:0`) with DIFFERENT content across two runs — +// `@rookery/core` panics on exactly that mismatch. A pinned id sidesteps it: +// the name is a pure function of `name` alone, so every rerun re-emits +// byte-identical content under the same id, which `_registry.update` +// (`core/0.1.0/src/idea.typ`) treats as a re-emission rather than a +// collision. +#import "lib.typ": template, todo-graph, layers, slip, slipshow, window +#show: template + += Eight todos, deliberately crammed into one row + +#context { + let names = layers(todo-graph()).flatten().map(r => r.name).slice(0, 8) + slipshow(slips: names.map(name => slip("wide-" + name, row: 0, max-width: 20em)[#window(name)])) +} diff --git a/slipshow/0.1.0/examples/dag/rheo.toml b/slipshow/0.1.0/examples/dag/rheo.toml new file mode 100644 index 0000000..359d6c5 --- /dev/null +++ b/slipshow/0.1.0/examples/dag/rheo.toml @@ -0,0 +1,36 @@ +# The shape every example under `examples/` copies. `version` matches the +# `min_version` this package's `typst.toml` declares and the rheo version +# `.github/workflows/check.yml` installs — do not raise it here; the point of +# the floor is that the declared floor is the one tested. +# +# An example adds `formats = ["html", "pdf"]` only where it asserts something +# about paged output (a `#slipshow` compiled outside HTML renders +# transparently — see `src/slipshow.typ`) — a PDF build doubles the compile +# for no gain in the rest. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +# `lib.typ` holds the show rule every page applies, not a page of its own — +# without this it would compile to its own `lib.html`. +exclude = ["lib.typ"] + +# The Typst package cache's `rookery` namespace is a per-machine symlink, and +# on a machine checking out this repository more than once (several jj +# workspaces, a second clone) it can point at a DIFFERENT checkout than the +# one this example lives in — silently building against whatever code sits +# there instead of the code next to it. This override reads `@rookery/*` +# straight out of THIS repository's tree instead, the same fix +# `demo/rheo/rheo.toml` applies to this package's own fixture and for the +# same reason. `path` is anchored to this file's own directory, four levels +# up from `//`. +# +# An example is also meant to be COPIED OUT of this repo, where a relative +# path four levels up no longer resolves to anything. A copy taken elsewhere +# needs this block replaced with however that project already resolves +# `@rookery` — a `releases`/`repo` source (see this repo's own CLAUDE.md, +# "Local development against a live rheo project") — or dropped entirely if +# the package cache already has it. +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/minimal/content/index.typ b/slipshow/0.1.0/examples/minimal/content/index.typ new file mode 100644 index 0000000..259f746 --- /dev/null +++ b/slipshow/0.1.0/examples/minimal/content/index.typ @@ -0,0 +1,12 @@ +// The smallest deck this package can render: three named slips, queried by +// their `slip` tag, with no options at all — the shape every other example +// under `examples/` builds on top of. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: template + +#slip("opening")[Welcome. This deck has three slips and no options.] +#slip("middle")[Each `#slip` is a note the deck below queries by tag.] +#slip("closing")[The end.] + +#slipshow(tags: "slip") diff --git a/slipshow/0.1.0/examples/minimal/content/lib.typ b/slipshow/0.1.0/examples/minimal/content/lib.typ new file mode 100644 index 0000000..661e176 --- /dev/null +++ b/slipshow/0.1.0/examples/minimal/content/lib.typ @@ -0,0 +1,9 @@ +// Shared `@rookery/core` configuration for this example. See +// `examples/_template/content/lib.typ` for why this file exists and why it +// is excluded from the spine. +#import "@rookery/core:0.1.0": rookery + +#let template(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/examples/minimal/rheo.toml b/slipshow/0.1.0/examples/minimal/rheo.toml new file mode 100644 index 0000000..d0e312c --- /dev/null +++ b/slipshow/0.1.0/examples/minimal/rheo.toml @@ -0,0 +1,12 @@ +# The smallest possible slipshow deck — copy `examples/_template/` for the +# comments explaining `version`, `[spine]`, and the `[packages.rookery]` +# override below. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +exclude = ["lib.typ"] + +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/mixed/check.sh b/slipshow/0.1.0/examples/mixed/check.sh new file mode 100755 index 0000000..22dc3c7 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/check.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Asserts on this example's OUTPUT: that all three routes into a mixed +# #idea/#slip deck actually produce a mixed deck, not merely that the build +# succeeds — a deck silently missing every plain idea compiles just as clean +# as one that has them. +# +# Run through `just examples` at the package root, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +idx="$H/index.html" +tagged="$H/tagged.html" +inline="$H/inline.html" +pred="$H/predicate.html" +for f in "$idx" "$tagged" "$inline" "$pred"; do + [ -f "$f" ] || note "no page at $f" +done + +# 1. index.html: the explicit `slips:` array names four notes, two #slip and +# two plain #idea — assert the id SEQUENCE, since the array route's whole +# promise is the order, not just which four notes showed up. +python3 - "$idx" <<'PY' || fail=1 +import re, sys +h = open(sys.argv[1]).read() +ids = re.findall(r'
the way `fullscreen: true`'s `slip-fullscreen` class used +# to be the only proxy for it. Counting `data-rookery-tags` for the exact +# tag "slip" (not merely containing it, so "slip-order"/"slip-background" +# don't fool a substring match) gives the exact count of #slip-authored +# sections — five, out of corpus.typ's ten "talk"-tagged notes — and five +# of ten is still strictly fewer than the total, which is what proves this +# "talk"-tagged deck holds more sections than a "tags: slip" deck would. +python3 - "$tagged" <<'PY' || fail=1 +import re, sys +h = open(sys.argv[1]).read() +tag_lists = re.findall(r'data-rookery-tags="([^"]*)"', h) +total = len(tag_lists) +slip = sum(1 for tags in tag_lists if "slip" in tags.split()) +if slip != 5: + print(f"FAIL: tagged.html: expected exactly 5 sections carrying the 'slip' tag via data-rookery-tags, found {slip}") + sys.exit(1) +if not (slip < total): + print(f"FAIL: tagged.html: slip-tagged sections ({slip}) is not fewer than the total ({total})") + sys.exit(1) +print(f" tagged: {slip} of {total} sections carry the slip tag through data-rookery-tags") +PY + +# 4. inline.html: three sections from ideas written inline in `slips:` rather +# than named, and the one authored `fullscreen: true` still carries +# `slip-fullscreen` — proving an option survives being read back off +# rendered content (`marker.typ`'s `slip-tags-of`) as well as off a +# registry row. +n=$(grep -o '
([^<]*)', h) +if len(titles) == 0: + print("FAIL: predicate.html selected no notes at all") + sys.exit(1) +bad = [t for t in titles if not t.startswith("Method")] +if bad: + print(f"FAIL: predicate.html selected a note not labelled 'Method...': {bad}") + sys.exit(1) +print(f" predicate: {len(titles)} notes, all labelled 'Method...'") +PY + +if [ "$fail" -eq 0 ]; then echo "examples/mixed OK"; else echo "examples/mixed FAILED"; exit 1; fi diff --git a/slipshow/0.1.0/examples/mixed/content/corpus.typ b/slipshow/0.1.0/examples/mixed/content/corpus.typ new file mode 100644 index 0000000..d4ef6d2 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/corpus.typ @@ -0,0 +1,93 @@ +// The ten notes every other page in this example reaches into, five written +// as `#idea` and five as `#slip`, all sharing one `talk` tag. Every name is +// PINNED, never an auto id: `index.typ`'s `slips:` array and +// `predicate.typ`'s `where:` both address these notes by name/label, and +// core's unnamed-note ids shift whenever an earlier note is inserted (see +// `#idea`'s own header, `@rookery/core`'s `idea.typ`), which would silently +// repoint either. +// +// The `slip`-authored half carries a deliberate spread of options — one +// `fullscreen`, one `background`, one non-default `enter`, one explicit +// `order` — so `index.typ`, `tagged.typ`, and `inline.typ` each have +// something besides the tag to show. The `idea`-authored half carries no +// `slip-*` option at all, which is the whole point of this example: a plain +// note is not a lesser slide, it is a slide that took the deck's defaults. +#import "lib.typ": template +#import "@rookery/core:0.1.0": idea +#import "@rookery/slipshow:0.1.0": slip +#show: template + += The corpus + +Ten notes, read by name or by tag from every other page in this project — +never rendered as a deck here. This page just proves they exist and reads +like an ordinary rookery in the process. + +#slip("opening", title: [Welcomes the room], fullscreen: true, tags: "talk")[ + This talk is itself a mixed deck: some slides are plain notes, some are + `#slip`s with staging of their own. Nobody in the room needs to know which + is which — that is the whole point. +] + +#idea("intro-question", title: [The question this talk answers], tags: "talk")[ + Can a presentation slide be an ordinary note in disguise? Yes — and this + deck is the proof, walking through the three ways to reach one. +] + +#slip( + "background-note", + title: [Where this project came from], + background: rgb("#eaf2ff"), + tags: "talk", +)[ + A rookery starts as a folder of atomic ideas nobody presents. + `@rookery/slipshow` turns the same ideas into a deck without asking the + author to duplicate a single sentence of them. +] + +#idea("plain-context", title: [Context a plain idea is happy to carry], tags: "talk")[ + This slide carries no `slip-*` options at all — no fullscreen, no + background, no custom entrance. It simply takes whatever the deck around it + already decided. +] + +#slip( + "method-overview", + title: [Method: how the study was run], + enter: "focus", + tags: "talk", +)[ + Ten notes, five written as `#idea`, five as `#slip`, all sharing one tag. + The camera focuses in on this slide rather than scrolling to it — the + deck's one non-default entrance. +] + +#idea("method-detail", title: [Method: the specific measurements taken], tags: "talk")[ + What actually gets checked: section counts, id order, and whether options + survive being rendered as raw content instead of looked up by name. +] + +#slip( + "order-example", + title: [Pinned mid-deck by an explicit order], + order: 5, + tags: "talk", +)[ + A `slip-order` tag of 5 puts this note here regardless of when it was + written — useful whenever presentation order and authoring order diverge. +] + +#idea("discussion", title: [What the results might mean], tags: "talk")[ + If a plain note can sit in a deck unmodified, a rookery never has to choose + between being a knowledge base and being a talk. +] + +#slip("results", title: [The headline result], tags: "talk")[ + Three routes into a mixed deck, one honest caveat about `tags: "slip"`, and + not a single sentence duplicated between the notes and the slides. +] + +#idea("closing", title: [Thanks and questions], tags: "talk")[ + That's the deck. Ask about the `where:` route if you want the one nobody + guesses first. +] diff --git a/slipshow/0.1.0/examples/mixed/content/index.typ b/slipshow/0.1.0/examples/mixed/content/index.typ new file mode 100644 index 0000000..1c43076 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/index.typ @@ -0,0 +1,19 @@ +// The explicit-array route: a deck built by naming notes, not by querying +// them. See `content/corpus.typ` for the ten notes it draws from. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += A deck built by naming notes + +An explicit `slips:` array selects by construction — every element is already +a decision about what belongs, so a plain `#idea` needs no tag to be included +here, only its name. The array is also the presentation order: `order:` +has nothing left to do once the sequence is written out by hand, and +`#slipshow` refuses it alongside `slips:` for exactly that reason. + +Two of the four notes below are `#slip`s, two are plain `#idea`s, mixed by +name in one sequence — the array does not care which kind a name resolves +to. + +#slipshow(slips: ("opening", "background-note", "plain-context", "closing")) diff --git a/slipshow/0.1.0/examples/mixed/content/inline.typ b/slipshow/0.1.0/examples/mixed/content/inline.typ new file mode 100644 index 0000000..f30a6e9 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/inline.typ @@ -0,0 +1,30 @@ +// The other explicit-array shape: ideas written inline in the `slips:` call +// rather than named. This is a genuinely different code path from +// `index.typ`'s — a name in `slips:` resolves through the registry +// (`select.typ`'s `_slip-lookup`), while inline content here has its options +// read back off the RENDERED markup by `marker.typ`'s `slip-tags-of`. One can +// break while the other passes, which is why this page exists alongside +// `index.typ` rather than instead of it. +// +// These three notes still take PINNED names (`intro`, `aside`, `outro`) — +// see `corpus.typ`'s header on why an auto id is never safe — even though +// nothing here looks them up by name. They also carry no `talk` tag: they +// join the same project-wide registry `tagged.typ` queries, and tagging them +// `talk` would silently grow that page's deck by three. +#import "lib.typ": template +#import "@rookery/core:0.1.0": idea +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: template + += A deck built from ideas written in place + +The same explicit-array route as `index.typ`, but the array holds +already-rendered content instead of names. `#slip`'s options still have to +survive being read back out of that rendered markup rather than off a +registry row — the fullscreen slip below proves they do. + +#slipshow(slips: ( + slip("intro", fullscreen: true)[Written right where the deck calls for it, not filed away by name.], + idea("aside")[A plain `#idea`, inline, taking the deck's defaults exactly as it would from the registry.], + slip("outro")[The options above came from the rendered markup, not a lookup.], +)) diff --git a/slipshow/0.1.0/examples/mixed/content/lib.typ b/slipshow/0.1.0/examples/mixed/content/lib.typ new file mode 100644 index 0000000..4ffa15c --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/lib.typ @@ -0,0 +1,15 @@ +// Shared `@rookery/core` configuration for this example, imported alongside +// `@rookery/slipshow` even though `slipshow` itself is never called from this +// file: rheo's package asset detection scans a project's own files for +// `@rookery/*` imports, not the packages those files import in turn, so an +// import HERE — in the one file every page of this project applies — is what +// guarantees both core's idea-box stylesheet and slipshow's deck stylesheet +// reach every page, not only whichever one that page's own file happens to +// name for its own functions. +#import "@rookery/core:0.1.0": rookery +#import "@rookery/slipshow:0.1.0" + +#let template(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/examples/mixed/content/predicate.typ b/slipshow/0.1.0/examples/mixed/content/predicate.typ new file mode 100644 index 0000000..c4c0505 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/predicate.typ @@ -0,0 +1,16 @@ +// The `where:` route: a predicate over the whole registry row, needing no +// shared tag at all. Selects two notes out of `corpus.typ`'s ten — one +// `#slip`, one plain `#idea` — by a plain string test on their label. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += A deck built by predicate + +`where:` reaches `r.label`, not `r.title`: `label` is a `str`, always, while +`title` is content and `.starts-with()` has no method on it (`ideas()` +documents `label` as the field for exactly this, `@rookery/core`'s +`data.typ` line 327). Reaching for `title` here is the mistake this page +exists to avoid making. + +#slipshow(where: r => r.label.starts-with("Method")) diff --git a/slipshow/0.1.0/examples/mixed/content/tagged.typ b/slipshow/0.1.0/examples/mixed/content/tagged.typ new file mode 100644 index 0000000..dfe4886 --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/content/tagged.typ @@ -0,0 +1,15 @@ +// The tag route over a tag BOTH kinds carry. See `content/corpus.typ` for +// the ten notes it draws from — this page's `#slipshow` sees every one of +// them, because `ideas()` is project-wide. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += A deck built by querying a tag + +`#slipshow(tags: "slip")` would select only the five `#slip`-authored notes +below, because a plain `#idea` carries no `slip` tag at all. Querying on a +tag the author put on both kinds instead — `talk`, here — is how a mixed deck +is selected without naming a single note. + +#slipshow(tags: "talk") diff --git a/slipshow/0.1.0/examples/mixed/rheo.toml b/slipshow/0.1.0/examples/mixed/rheo.toml new file mode 100644 index 0000000..598e2ac --- /dev/null +++ b/slipshow/0.1.0/examples/mixed/rheo.toml @@ -0,0 +1,12 @@ +# A mixed `#idea`/`#slip` deck — see `examples/_template/rheo.toml` for the +# comments on `version`, `[spine]`, and the `[packages.rookery]` override +# below; this file only sets the values, not the reasons. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +exclude = ["lib.typ"] + +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/ordering/check.sh b/slipshow/0.1.0/examples/ordering/check.sh new file mode 100755 index 0000000..65f4c0e --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/check.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Asserts on the SEQUENCE of `id` attributes each deck renders, not on +# counts: a count cannot tell a correct order from a reversed one, which is +# the entire subject of this example. Modelled on `search/0.1.0/demo/rheo/ +# check.sh` — greps and a python3 heredoc for anything a grep cannot express, +# run through `just examples`, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +for f in corpus index functions reverse tag-values; do + [ -f "$H/$f.html" ] || note "no page at $H/$f.html" +done + +python3 - "$H" <<'PY' || fail=1 +import re, sys + +H = sys.argv[1] + +def decks(page): + # One list of bare note names per `div.slipshow` on the page, in + # document order — `id="slip-idea:"` is `_slip-attrs`'s own + # scheme (`src/slipshow.typ`), so this reads the DOM the same way a + # browser would rather than re-deriving the order out-of-band. + h = open(f"{H}/{page}.html").read() + segs = [s for s in re.split(r'(?=
r.label` sequence differs from +# the plain id-sorted sequence — proof the key function actually ran. +fns = decks("functions") +if len(fns) != 3: + print(f"FAIL: functions.html: expected 3 div.slipshow, found {len(fns)}"); bad = 1 +else: + label_order = ["kilo", "juliett", "india", "hotel", "golf", "foxtrot", + "echo", "delta", "charlie", "bravo", "alpha", "lima"] + check("functions.html deck 1 (order: r => r.label)", fns[0], label_order) + if fns[0] == ID_ORDER: + print("FAIL: functions.html deck 1 matches id order — the label key function did not run") + bad = 1 + check("functions.html deck 2 (order: r => r.body.len())", fns[1], + ["lima", "kilo", "delta", "hotel", "juliett", "golf", "echo", + "foxtrot", "india", "bravo", "charlie", "alpha"]) + check("functions.html deck 3 (order: r => r.page)", fns[2], ID_ORDER) + +# 4. `content/reverse.typ`'s reverse-chronological deck is the exact reverse +# of `index.html`'s `"created"` deck's KEYED portion (the ten dated +# notes), and its undated notes are still last, not first. +rev = decks("reverse") +if len(rev) != 2: + print(f"FAIL: reverse.html: expected 2 div.slipshow, found {len(rev)}"); bad = 1 +else: + created_keyed = ["bravo", "lima", "foxtrot", "india", "juliett", "alpha", + "charlie", "echo", "hotel", "delta"] + want_reverse_created = list(reversed(created_keyed)) + ["golf", "kilo"] + check("reverse.html deck 1 (order: \"created\", reverse: true)", rev[0], want_reverse_created) + if rev[0][-2:] != ["golf", "kilo"]: + print(f"FAIL: reverse.html deck 1: undated notes are not last: {rev[0]}") + bad = 1 + check("reverse.html deck 2 (order: r => r.label, reverse: true)", rev[1], + list(reversed(label_order))) + +# 5. `content/tag-values.typ`'s filtered deck holds only the `weight`-bearing +# notes, ascending; the unfiltered deck holds all twelve, with the +# keyless nine after the three that have a `weight`. +tv = decks("tag-values") +if len(tv) != 2: + print(f"FAIL: tag-values.html: expected 2 div.slipshow, found {len(tv)}"); bad = 1 +else: + check("tag-values.html deck 1 (where: has weight)", tv[0], ["hotel", "echo", "india"]) + check("tag-values.html deck 2 (no where:, keyless last)", tv[1], + ["hotel", "echo", "india", "alpha", "bravo", "charlie", "delta", + "foxtrot", "golf", "juliett", "kilo", "lima"]) + +if not bad: + print(" every deck's id sequence matches its order form") +sys.exit(bad) +PY + +if [ "$fail" -eq 0 ]; then echo "examples/ordering OK"; else echo "examples/ordering FAILED"; exit 1; fi diff --git a/slipshow/0.1.0/examples/ordering/content/corpus.typ b/slipshow/0.1.0/examples/ordering/content/corpus.typ new file mode 100644 index 0000000..f3a4863 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/corpus.typ @@ -0,0 +1,126 @@ +// The shared corpus every other page in this example queries. Twelve notes, +// each with a PINNED name — never an auto id, which steps a package-wide +// counter that shifts the moment a note is inserted earlier in the file, and +// would silently repoint `index.typ`'s explicit `order:` array. +// +// This is the only page that authors `#slip` notes at all, so a bare +// `tags: "slip"` on any other page resolves to exactly these twelve and +// nothing else — no `demo-*` scoping tag needed the way `demo/rheo/content/ +// index.typ` needs one, because nothing outside this file ever carries the +// `slip` tag. +// +// The corpus is deliberately awkward, because the four order forms compared +// across this example are judged on exactly the notes below: +// +// - `golf` and `kilo` carry no `created:` — undated notes sort LAST under +// `order: "created"`, never first. +// - `alpha` and `charlie` share one `created:` date — `order: "created"` +// breaks the tie by `id`, so `alpha` (the earlier id) leads `charlie`. +// - `bravo` and `juliett` carry no `slip-order` tag — they sort last, in +// id order, under the default `order: "slip-order"`. +// - `echo`, `hotel`, and `india` carry a `weight` tag with a numeric +// value, for `content/tag-values.typ`'s ordering by tag VALUE. +// - `kilo`'s title starts with a numeral and `lima`'s starts with a +// lower-case letter, so `content/functions.typ`'s `order: r => r.label` +// is visibly a plain string sort (digit, then upper-case, then +// lower-case) rather than anything alphabetically clever. +// +// Every note's name, title, `created:`, `slip-order`, and any `weight` are +// chosen so the id order, the `r => r.label` order, the `"created"` order, +// and the default `"slip-order"` order all disagree — see this example's +// `content/index.typ` and `content/functions.typ` for the four decks that +// prove it. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slip +#show: template + += The corpus + +Twelve notes, authored once here. Every other page in this example queries +them by tag and orders them a different way — nothing below is rendered +specially; it is an ordinary rookery, exactly like any other. + +#slip( + "alpha", title: [Zoo maintenance log], + created: datetime(year: 2024, month: 3, day: 1), order: 7, +)[ + The rookery keeper counts eggs every morning before opening the aviary gates for visitors. +] + +#slip( + "bravo", title: [Yellow submarine notes], + created: datetime(year: 2024, month: 1, day: 1), +)[ + A submarine hums beneath the waves while the crew hums along in return. +] + +#slip( + "charlie", title: [Xylophone practice log], + created: datetime(year: 2024, month: 3, day: 1), order: 2, +)[ + The xylophone rang out eight clear notes across the empty rehearsal hall. +] + +#slip( + "delta", title: [Waffle recipe notes], + created: datetime(year: 2024, month: 5, day: 1), order: 9, +)[ + Waffles cool on the rack while syrup warms on the stove. +] + +#slip( + "echo", title: [Valley echo report], + created: datetime(year: 2024, month: 3, day: 15), order: 4, + tags: (weight: 3), +)[ + The canyon answered every shout with a fainter echo of itself. +] + +#slip( + "foxtrot", title: [Underfoot dance steps], + created: datetime(year: 2024, month: 1, day: 10), order: 1, +)[ + The dance step turns twice before gliding forward across the floor. +] + +#slip( + "golf", title: [Tundra survey notes], order: 6, +)[ + The tundra stretched flat and pale beneath a thin winter sun. +] + +#slip( + "hotel", title: [Sundial repair log], + created: datetime(year: 2024, month: 4, day: 1), order: 10, + tags: (weight: 1), +)[ + The sundial cast a long shadow just past noon on the lawn. +] + +#slip( + "india", title: [Rooftop garden notes], + created: datetime(year: 2024, month: 1, day: 20), order: 5, + tags: (weight: 5), +)[ + Tomatoes and basil crowd the narrow beds above a busy street corner. +] + +#slip( + "juliett", title: [Quilt pattern diary], + created: datetime(year: 2024, month: 2, day: 10), +)[ + The quilt pattern repeats in blue diamonds stitched by hand. +] + +#slip( + "kilo", title: [3 Steps to Espresso], order: 3, +)[ + Three hot shots of espresso, ground fine, pulled fast. +] + +#slip( + "lima", title: [arctic fox sighting log], + created: datetime(year: 2024, month: 1, day: 5), order: 8, +)[ + A fox crossed the frozen field just after dawn broke. +] diff --git a/slipshow/0.1.0/examples/ordering/content/functions.typ b/slipshow/0.1.0/examples/ordering/content/functions.typ new file mode 100644 index 0000000..e1cc937 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/functions.typ @@ -0,0 +1,47 @@ +// The fourth order form, and the page this example exists for: a KEY +// FUNCTION, called once per row over the whole registry row `content/ +// corpus.typ` describes (`id`, `name`, `title`, `text`, `label`, `tags`, +// `tags-dict`, `body`, `href`, `page`, `created` — `@rookery/core`'s +// `data.typ`). See `content/index.typ` for the three forms that need no +// function at all. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += Ordering by a key function + +== Alphabetical by `label` + +`label` and not `title`: `label` is a `str`, always — the authored title +flattened to plain text, or the note's own name when there is no title — and +a key function must return a comparable value. `title` is CONTENT, and +`array.sorted` panics on it; that panic is `_sort-rows`'s own message +(`src/select.typ`), not a generic Typst error, precisely because using +`title` here is the mistake a reader will make. + +`kilo`'s title starts with a numeral and `lima`'s starts with a lower-case +letter, so the sequence below is a plain string sort — digit, then every +upper-case initial, then lower-case — and visibly not an order any reader +chose by hand. Compare it to the corpus's id order (`content/corpus.typ`) to +see that the key function actually ran rather than being silently ignored. + +#slipshow(tags: "slip", order: r => r.label) + +== Shortest note first + +A key computed from the note rather than read off it: `r.body` is the note's +body flattened to plain text (`@rookery/core`'s `_body-plain`), so `.len()` +is its character count. + +#slipshow(tags: "slip", order: r => r.body.len()) + +== Grouped by minted page + +`r.page` is where THIS note's own minted page lives (`ideas/.html`), +metadata no other field on the row carries. Every note in this corpus is +authored in `content/corpus.typ` and mints into the same `ideas/` directory, +so grouping by `r.page` here reproduces id order — the interesting case is a +project whose notes are pinned across several `note-dir:` sections, where +this groups them by section instead. + +#slipshow(tags: "slip", order: r => r.page) diff --git a/slipshow/0.1.0/examples/ordering/content/index.typ b/slipshow/0.1.0/examples/ordering/content/index.typ new file mode 100644 index 0000000..5f77e60 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/index.typ @@ -0,0 +1,41 @@ +// The three order forms that need no key function at all, one deck each, on +// one page — see `content/corpus.typ` for the shared corpus every deck below +// queries, and `content/functions.typ` for the fourth form, a key function. +// +// THREE DECKS ON ONE PAGE IS DELIBERATE. Nothing in `#slipshow` enforces one +// deck per page — `src/slipshow.typ`'s own header says the controller uses +// the first `div.slipshow` it finds and ignores the rest. This page is a +// comparison to READ, not one to navigate: only the first deck below +// responds to the arrow keys. That is not a bug in the other two, it is +// what three decks on one page always does. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += Three ways to order a deck with no key function + +== An explicit array of names + +Rows named in the array come first, in exactly that sequence; rows the array +does not name are appended afterwards, in id order. Only three of the +corpus's twelve names appear below, so the appended tail is visible rather +than theoretical. + +#slipshow(tags: "slip", order: ("lima", "alpha", "kilo")) + +== Ascending by `created` + +Rows sort by their `created:` date, oldest first. `golf` and `kilo` carry no +`created:` at all, so both land LAST, in id order, rather than first or +anywhere in the middle. `alpha` and `charlie` share one date, so the tie +breaks by id: `alpha` leads `charlie`. + +#slipshow(tags: "slip", order: "created") + +== The default: ascending by `slip-order` + +`#slipshow`'s own default when no `order:` is given at all. `bravo` and +`juliett` carry no `slip-order` tag, so both land LAST, in id order, exactly +as an undated note does under `order: "created"` above. + +#slipshow(tags: "slip") diff --git a/slipshow/0.1.0/examples/ordering/content/lib.typ b/slipshow/0.1.0/examples/ordering/content/lib.typ new file mode 100644 index 0000000..661e176 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/lib.typ @@ -0,0 +1,9 @@ +// Shared `@rookery/core` configuration for this example. See +// `examples/_template/content/lib.typ` for why this file exists and why it +// is excluded from the spine. +#import "@rookery/core:0.1.0": rookery + +#let template(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/examples/ordering/content/reverse.typ b/slipshow/0.1.0/examples/ordering/content/reverse.typ new file mode 100644 index 0000000..81fc9f8 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/reverse.typ @@ -0,0 +1,33 @@ +// `reverse:`, on the two orders where descending is what an author actually +// wants. It composes with either the built-in `"created"` form +// (`content/index.typ`) or a key function (`content/functions.typ`) — not +// with `slips:`, an explicit array, which `resolve-slips` refuses alongside +// `reverse:` because an explicit array is already in the order it was +// written. +// +// `reverse:` reverses the KEYED rows only (`src/select.typ`'s `_sort-pairs`). +// An undated note is not "first" in a reverse-chronological deck — it is +// still the note with no date, and it is still LAST. A reader who has not +// seen this will assume the opposite; the deck below is what proves it. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += Descending orders + +== Reverse-chronological + +The single most-wanted presentation order: newest first. `golf` and `kilo` +carry no `created:`, so they are still LAST here, exactly where they sit in +`content/index.typ`'s ascending `"created"` deck — `reverse:` never moves an +unkeyed row. + +#slipshow(tags: "slip", order: "created", reverse: true) + +== Reverse-alphabetical by `label` + +The exact reverse of `content/functions.typ`'s `order: r => r.label` deck: +every row has a `label` (it is never `none`), so every row is keyed and +`reverse:` flips the whole sequence. + +#slipshow(tags: "slip", order: r => r.label, reverse: true) diff --git a/slipshow/0.1.0/examples/ordering/content/tag-values.typ b/slipshow/0.1.0/examples/ordering/content/tag-values.typ new file mode 100644 index 0000000..bf1c05d --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/content/tag-values.typ @@ -0,0 +1,32 @@ +// Ordering by a note's own tag VALUE — what `tags-dict` (the full tag +// dictionary, values included) is for, as opposed to `tags`, the flat array +// of tag NAMES `where:`/`order:` see on every other page in this example. +// Only `echo`, `hotel`, and `india` carry a `weight` tag +// (`content/corpus.typ`); the other nine do not. +// +// `where:` and `order:` composing on the same call is the point of the first +// deck below: `where:` runs first and narrows the corpus to rows that HAVE +// the key, so `order:`'s `.at("weight")` never has to handle a missing one. +#import "lib.typ": template +#import "@rookery/slipshow:0.1.0": slipshow +#show: template + += Ordering by a tag's value + +== Filtered to the notes that carry `weight` + +#slipshow( + tags: "slip", + where: r => "weight" in r.tags-dict, + order: r => r.tags-dict.at("weight"), +) + +== Unfiltered: a missing `weight` sorts last + +Drop the `where:` and the same key function still works: a row with no +`weight` looks up a key that is not there, `.at("weight", default: none)` +returns `none`, and `none` sorts LAST — the same rule an undated note follows +under `order: "created"` (`content/index.typ`). The nine keyless notes below +are not filtered out, they are simply after the three that have a `weight`. + +#slipshow(tags: "slip", order: r => r.tags-dict.at("weight", default: none)) diff --git a/slipshow/0.1.0/examples/ordering/rheo.toml b/slipshow/0.1.0/examples/ordering/rheo.toml new file mode 100644 index 0000000..4b4bee9 --- /dev/null +++ b/slipshow/0.1.0/examples/ordering/rheo.toml @@ -0,0 +1,12 @@ +# Every `order:` form `#slipshow` accepts, compared side by side. See +# `examples/_template/rheo.toml` for what each line below means and why the +# `[packages.rookery]` override exists — copied verbatim, not reinterpreted. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +exclude = ["lib.typ"] + +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/examples/readme.md b/slipshow/0.1.0/examples/readme.md new file mode 100644 index 0000000..8c4d911 --- /dev/null +++ b/slipshow/0.1.0/examples/readme.md @@ -0,0 +1,70 @@ +# `@rookery/slipshow` examples + +Five rheo projects, one per capability: a deck composed through +`@rookery/search`, a mixed `#idea`/`#slip` deck, backgrounds and fullscreen, +metadata-driven ordering, and a `@rookery/todos` dependency DAG walked +depth-first, in both deck directions. Each is both an EXAMPLE — copy one to +start a presentation of your own — and a TEST — CI compiles every one of +them. + +## Shape + +Every example is a complete rheo project: + +``` +/ + rheo.toml + content/ + lib.typ # shared imports + the one `#show: rookery.with(..)` every page applies + ... # the pages themselves +``` + +`lib.typ` is a library, not a page — `[spine] exclude = ["lib.typ"]` in +`rheo.toml` keeps it out of the built site. A page imports it and applies the +show rule once: + +```typ +#import "lib.typ": template +#show: template +``` + +`examples/_template/` carries this shape with no content beyond the show +rule wrapper; it starts with `_` so `just examples` (below) skips it — a +template proves nothing by compiling, it exists to be copied. + +## Building + +One example, by hand: + +```sh +cd examples/ && rheo compile . +``` + +then open `build/html/index.html` in a browser. All five, from the package +root: + +```sh +just examples +``` + +`dag` is the one example that imports `@rookery/todos`, and unlike this +package `todos` is a BUILT package — its `dist/lib.js` has to exist before +rheo can resolve it. `just examples` above builds `slipshow` itself but not +its sibling, so on a checkout where `todos` has never been built, compile it +first: `cd ../../todos/0.1.0 && just build`. + +## Self-contained + +No example imports from another, and there is no shared `examples/lib.typ` +above them: each is written as if it were the only directory in the +checkout, so it can be copied out of this repo and run unchanged (modulo +however your project already resolves the `@rookery` namespace — see +`examples/_template/rheo.toml` for the override this repo's own copies need +and why). + +## The one shared gotcha + +Typst silently drops `grid` in HTML export. A table inside a slip must be a +borderless `table`, never a `grid`: a `grid` compiles clean, renders +correctly in a PDF build, and is EMPTY in the browser. Reach for `table` any +time an example needs tabular layout inside a slip. diff --git a/slipshow/0.1.0/examples/search-order/check.sh b/slipshow/0.1.0/examples/search-order/check.sh new file mode 100755 index 0000000..d561dd4 --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/check.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Asserts on the SEQUENCE of `id` attributes each deck renders — a count +# cannot tell a ranked order from an id-sorted one, and an empty deck looks +# identical to a build that ignored its predicate, which is why every check +# below also demands the deck be non-empty. Modelled on `examples/ordering/ +# check.sh` and `search/0.1.0/demo/rheo/check.sh` — greps and a python3 +# heredoc, run through `just examples`, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +for f in corpus index ranked narrowed; do + [ -f "$H/$f.html" ] || note "no page at $H/$f.html" +done + +python3 - "$H" <<'PY' || fail=1 +import re, sys + +H = sys.argv[1] + +def decks(page): + # One list of bare note ids per `div.slipshow` on the page, in document + # order — `id="slip-idea:"` is `_slip-attrs`'s own scheme + # (`slipshow.typ`), so this reads the DOM the same way a browser would + # rather than re-deriving the order out-of-band. + h = open(f"{H}/{page}.html").read() + segs = [s for s in re.split(r'(?=
r.name))", rk[0], RANK_ORDER) + if rk[0] and rk[0][0] != RANK_ORDER[0]: + print(f"FAIL: ranked.html's first slip is {rk[0][0]!r}, not the top hit {RANK_ORDER[0]!r}") + bad = 1 + +# 3. Exactly one slip in the ranked deck carries `slip-fullscreen`: `calib-xi` +# (`content/corpus.typ`) is both the top hit and the one note authored +# with `fullscreen: true`. This is the assertion the example exists for — +# passing `window(name)` instead of the bare name would compile and render +# fine while silently emitting zero `slip-fullscreen` sections here. +h_ranked = open(f"{H}/ranked.html").read() +n_fullscreen = len(re.findall(r'class="slip slip-fullscreen"', h_ranked)) +if n_fullscreen != 1: + print(f"FAIL: ranked.html has {n_fullscreen} slip-fullscreen section(s), want 1") + bad = 1 + +# 4. `narrowed.html`'s `where:` keeps only notes with a `created` date in 2026 +# or later, ascending. `calib-gamma` and `method-theta` carry no `created` +# at all (`content/corpus.typ`) and both are excluded, alongside every +# dated-but-pre-2026 note. +nw = decks("narrowed") +if len(nw) != 1: + print(f"FAIL: narrowed.html: expected 1 div.slipshow, found {len(nw)}"); bad = 1 +else: + check("narrowed.html (where: created.year() >= 2026, order: created)", nw[0], [ + "idea:calib-alpha", "idea:calib-beta", "idea:calib-epsilon", + "idea:method-eta", "idea:result-iota", "idea:draft-nu", "idea:calib-xi", + ]) + for undated in ("idea:calib-gamma", "idea:method-theta"): + if undated in nw[0]: + print(f"FAIL: narrowed.html includes the undated note {undated}") + bad = 1 + +# 5. No page's deck is empty. An empty deck is the silent failure mode of +# every predicate on this page — a query that selects nothing renders a +# bare `
` and looks identical to a build that never +# ran the predicate at all. +for page, ds in (("index", idx), ("ranked", rk), ("narrowed", nw)): + for i, d in enumerate(ds): + if len(d) == 0: + print(f"FAIL: {page}.html deck {i} is empty"); bad = 1 + +if not bad: + print(" index: method&!draft selects 5; ranked: rank order differs from id order," + " top hit's fullscreen survived; narrowed: created >= 2026 excludes both" + " undated notes") +sys.exit(bad) +PY + +if [ "$fail" -eq 0 ]; then echo "examples/search-order OK"; else echo "examples/search-order FAILED"; exit 1; fi diff --git a/slipshow/0.1.0/examples/search-order/content/corpus.typ b/slipshow/0.1.0/examples/search-order/content/corpus.typ new file mode 100644 index 0000000..00133a8 --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/content/corpus.typ @@ -0,0 +1,138 @@ +// The corpus every other page in this example queries: fifteen notes, each +// with a PINNED name — never an auto id, which steps a package-wide counter +// that shifts the moment a note is inserted earlier in the file, and would +// silently repoint `ranked.typ`'s names (carried out of a ranking, not +// written by hand) as well as `index.typ`'s tag query. +// +// Tags overlap on purpose: `method`, `result`, `draft`, and `archive` sit on +// several notes each, so `index.typ`'s `method&!draft` selects a real subset +// rather than everything or nothing. Thirteen notes carry a `created:` date; +// `calib-gamma` and `method-theta` carry none, so `narrowed.typ`'s +// `where: r => r.created != none` has something to exclude besides old +// dates. +// +// Most titles open with the word "calibration" — what `ranked.typ`'s +// `search-ideas("calibration")` ranks on — so that page's top hits differ +// from a plain id sort instead of coinciding with it by accident. +#import "lib.typ": template, slip +#show: template + += The corpus + +Fifteen notes, authored once here. Every other page in this example queries +them by tag, by field, or by search rank — nothing below is rendered +specially. + +#slip( + "calib-alpha", title: [Calibration protocol overview], + tags: ("method", "result"), created: datetime(year: 2026, month: 1, day: 10), +)[ + The calibration protocol synchronizes every sensor against the reference + standard before a run begins. +] + +#slip( + "calib-beta", title: [Detector calibration walkthrough], + tags: ("method", "result"), created: datetime(year: 2026, month: 2, day: 15), +)[ + This walkthrough covers detector calibration end to end, from warm-up to + the final drift check. +] + +#slip( + "calib-gamma", title: [Calibration draft checklist], + tags: ("method", "draft"), +)[ + A checklist draft for calibration, incomplete until the reference source + arrives. +] + +#slip( + "calib-delta", title: [Early calibration notes], + tags: ("method", "draft"), created: datetime(year: 2025, month: 11, day: 20), +)[ + An early pass at calibration notes, written before the procedure had a + name. +] + +#slip( + "calib-epsilon", title: [Sensor calibration results], + tags: ("result",), created: datetime(year: 2026, month: 3, day: 5), +)[ + Sensor calibration results from the second batch show the drift within + tolerance. +] + +#slip( + "calib-zeta", title: [Archived calibration report], + tags: ("result", "archive"), created: datetime(year: 2022, month: 4, day: 1), +)[ + An archived calibration report from a run that predates the current + procedure. +] + +#slip( + "calib-xi", title: [Calibration plan], fullscreen: true, + tags: ("method", "result"), created: datetime(year: 2026, month: 7, day: 1), +)[ + The calibration plan for the coming quarter, presented full screen for the + review meeting. +] + +#slip( + "calib-omicron", title: [Calibration report draft], + tags: ("method", "draft"), created: datetime(year: 2024, month: 9, day: 1), +)[ + A draft report on calibration, held back pending a second reviewer. +] + +#slip( + "method-eta", title: [Method writeup: sampling procedure], + tags: ("method",), created: datetime(year: 2026, month: 4, day: 12), +)[ + The sampling procedure draws three replicates per site and logs the time + of each draw. +] + +#slip( + "method-theta", title: [Method scratchpad], + tags: ("method", "draft"), +)[ + Loose notes on method, not yet organized into a procedure. +] + +#slip( + "result-iota", title: [Result summary for trial two], + tags: ("result",), created: datetime(year: 2026, month: 5, day: 1), +)[ + The second trial's results confirm the pattern seen in the first. +] + +#slip( + "result-kappa", title: [Result appendix tables], + tags: ("result", "archive"), created: datetime(year: 2021, month: 8, day: 9), +)[ + Appendix tables summarizing every result from the quarter's trials. +] + +#slip( + "archive-lambda", title: [Archive of legacy method notes], + tags: ("method", "archive"), created: datetime(year: 2020, month: 1, day: 1), +)[ + Legacy method notes kept for reference, including an early calibration + attempt that was later abandoned. +] + +#slip( + "archive-mu", title: [Archive index], + tags: ("archive",), created: datetime(year: 2019, month: 6, day: 1), +)[ + An index of everything moved to the archive so far. +] + +#slip( + "draft-nu", title: [Draft thoughts on calibration], + tags: ("draft",), created: datetime(year: 2026, month: 6, day: 18), +)[ + Loose thoughts on calibration, written before the plan was finalized. +] diff --git a/slipshow/0.1.0/examples/search-order/content/index.typ b/slipshow/0.1.0/examples/search-order/content/index.typ new file mode 100644 index 0000000..e7f8b4c --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/content/index.typ @@ -0,0 +1,24 @@ +// The boolean-query deck: `method&!draft` selects every note tagged `method` +// that is NOT also tagged `draft` — the calibration write-ups that have left +// draft status, whatever their `result`/`archive` tags besides. See +// `content/corpus.typ` for the tags each note carries. +// +// The grammar (`&`, `|`, `!`, `()`) lives entirely in `@rookery/search`; +// `@rookery/slipshow` depends on nothing but `@rookery/core` and knows only +// that `tags:` accepts a predicate over a tag dictionary. `t.keys()` is the +// one line of adaptation a caller supplies: `eval-tag-query` wants a flat +// array of (already-folded) tag names, and `#slipshow`'s `tags:` predicate +// hands the whole dictionary instead — that seam is why this example exists. +#import "lib.typ": template, slipshow, parse-tag-query, eval-tag-query +#show: template + += Composed by a boolean tag query + +The query below, `method&!draft`, selects every note tagged `method` that is +not also tagged `draft` — whatever `result`/`archive` tags it carries besides. +The boolean grammar (`&`, `|`, `!`, `()`) belongs entirely to `@rookery/search`; +`@rookery/slipshow` depends on nothing but `@rookery/core` and only knows that +`tags:` accepts a predicate function over a note's tags — the predicate is the +seam that lets the two compose with no package-level edge between them. + +#slipshow(tags: t => eval-tag-query(parse-tag-query("method&!draft").rpn, t.keys())) diff --git a/slipshow/0.1.0/examples/search-order/content/lib.typ b/slipshow/0.1.0/examples/search-order/content/lib.typ new file mode 100644 index 0000000..fefd17a --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/content/lib.typ @@ -0,0 +1,19 @@ +// Shared imports and template for this example. `#show: rookery` is per-FILE, +// so every page imports this module and applies the show rule once. Excluded +// from the spine (see `rheo.toml`) because it holds no page content of its +// own. +// +// This is the ONE place all three packages this example depends on are named +// — `@rookery/slipshow` itself depends on none of them but core, and the +// import of `@rookery/search` below belongs to this PROJECT's content, not to +// the slipshow package. All three coordinates are written out in full: a +// Typst import spec has to be a literal, and the root `just check-versions` +// checks each one against its manifest. +#import "@rookery/core:0.1.0": rookery +#import "@rookery/search:0.1.0": search-ideas, parse-tag-query, eval-tag-query +#import "@rookery/slipshow:0.1.0": slip, slipshow + +#let template(doc) = { + show: rookery + doc +} diff --git a/slipshow/0.1.0/examples/search-order/content/narrowed.typ b/slipshow/0.1.0/examples/search-order/content/narrowed.typ new file mode 100644 index 0000000..87c4b52 --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/content/narrowed.typ @@ -0,0 +1,28 @@ +// The `where:` path: a predicate over the WHOLE registry row, for a +// narrowing `tags:` cannot express — here, `created`. Composed with a plain +// tag query (`tags: "slip"`, every note in this corpus) and `order: +// "created"`, ascending. +// +// `calib-gamma` and `method-theta` carry no `created:` at all +// (`content/corpus.typ`); `where:` drops them, not just sorts them last — an +// undated note is not a weak match for "created 2026 or later", it is a note +// this predicate cannot evaluate as true. Every other pre-2026 note is +// excluded on the same line for the ordinary reason: its date fails the +// test. +#import "lib.typ": template, slipshow +#show: template + += Composed by a row query, ordered by date + +Every note in the corpus (`tags: "slip"`), narrowed by `where:` to those +`created` in 2026 or later, ascending. `where:` sees the whole registry row, +not just a note's tags, which is what makes a query over `created` possible +at all — `tags:`'s predicate never sees that field. `calib-gamma` and +`method-theta` carry no `created:` date and are excluded outright, alongside +every dated-but-earlier note. + +#slipshow( + tags: "slip", + where: r => r.created != none and r.created.year() >= 2026, + order: "created", +) diff --git a/slipshow/0.1.0/examples/search-order/content/ranked.typ b/slipshow/0.1.0/examples/search-order/content/ranked.typ new file mode 100644 index 0000000..2aff880 --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/content/ranked.typ @@ -0,0 +1,40 @@ +// THE RANKED DECK — the page this example exists for. `search-ideas` scores +// every note against a text query and returns registry rows in RANK order; +// handing those rows' `name`s to `slips:` is the only way to turn a ranking +// into a deck at all, because a ranking is an ORDER and `slips:` is the one +// `#slipshow` parameter that accepts one directly. +// +// `search-ideas` calls `@rookery/core`'s `ideas()` WITHOUT `values: true`, so +// its rows carry `tags` as a flat array and no `tags-dict` — one more reason +// the NAME is what crosses to `slips:` rather than anything from the row +// itself: slipshow re-reads each note's presentation options off the +// registry by name, not off whatever shape a ranking call happens to return. +// +// Passing `window(r.name)` instead of `r.name` compiles and renders, but +// silently drops every `#slip` option (fullscreen, background, enter, ...): +// `#window` wraps its whole body in a `context { .. }` block +// (`core/0.1.0/src/window.typ` line 148), and a Typst context block's content +// is opaque until evaluated, so the marker walk that reads those options +// finds nothing inside it. `select.typ`'s own header records the same +// warning. Passing the bare name instead defers rendering to `#slipshow` +// itself, by which point the options are read off the registry row directly. +// +// `order:` is refused alongside `slips:` (`resolve-slips` in `select.typ`) — +// the ranking IS the order, so it is never passed here. +#import "lib.typ": template, search-ideas, slipshow +#show: template + += Composed and ordered by search rank + +Eight notes, ranked by `search-ideas("calibration")` rather than sorted by id +or date — a title that opens with "calibration" outranks one that merely +mentions it partway through, so the deck below is not the corpus's usual id +order. One of the eight, the top hit, carries `fullscreen: true`: proof that a +presentation option survives the trip from a ranking back into a deck. + +#context { + let hits = search-ideas("calibration", limit: 8) + // `hits.map(r => r.name)`, never `hits.map(r => window(r.name))` — see the + // header above for why the latter silently drops every `#slip` option. + slipshow(slips: hits.map(r => r.name)) +} diff --git a/slipshow/0.1.0/examples/search-order/rheo.toml b/slipshow/0.1.0/examples/search-order/rheo.toml new file mode 100644 index 0000000..b726ee3 --- /dev/null +++ b/slipshow/0.1.0/examples/search-order/rheo.toml @@ -0,0 +1,12 @@ +# A deck composed and ordered by `@rookery/search`'s query language. See +# `examples/_template/rheo.toml` for what each line below means and why the +# `[packages.rookery]` override exists — copied verbatim, not reinterpreted. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +[spine] +exclude = ["lib.typ"] + +[packages.rookery] +path = "../../../.." diff --git a/slipshow/0.1.0/flake.nix b/slipshow/0.1.0/flake.nix new file mode 100644 index 0000000..96a5c79 --- /dev/null +++ b/slipshow/0.1.0/flake.nix @@ -0,0 +1,22 @@ +{ + description = "rookery-search - fuzzy search over @rookery/core notes"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + nodejs + pnpm + ]; + }; + }); +} diff --git a/slipshow/0.1.0/package.json b/slipshow/0.1.0/package.json new file mode 100644 index 0000000..d73225b --- /dev/null +++ b/slipshow/0.1.0/package.json @@ -0,0 +1,12 @@ +{ + "name": "rookery-slipshow", + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "vite build" + }, + "packageManager": "pnpm@10.21.0", + "devDependencies": { + "vite": "^8.0.5" + } +} diff --git a/slipshow/0.1.0/pnpm-lock.yaml b/slipshow/0.1.0/pnpm-lock.yaml new file mode 100644 index 0000000..813fa71 --- /dev/null +++ b/slipshow/0.1.0/pnpm-lock.yaml @@ -0,0 +1,430 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vite: + specifier: ^8.0.5 + version: 8.2.2 + +packages: + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@oxc-project/types@0.148.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.18: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + vite@8.2.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/slipshow/0.1.0/readme.md b/slipshow/0.1.0/readme.md new file mode 100644 index 0000000..911273f --- /dev/null +++ b/slipshow/0.1.0/readme.md @@ -0,0 +1,864 @@ +# @rookery/slipshow + +An endlessly scrolling presentation over `@rookery/core` ideas, in the spirit +of [slipshow](https://github.com/panglesd/slipshow). Each slip is a note: +`#slip` is an `#idea` variant, so a slide is rendered with core's own card +styling — the left rule and the tab that carries its permalink — and is +searchable, windowable and linkable exactly like any other idea in the +rookery. This package implements the slip MODEL — one continuously scrolling +document with a camera that moves between fully-rendered slides — with its +own camera engine, written from scratch. **It does not embed slipshow's own +JavaScript**, which has no maintained distribution meant to be dropped into +someone else's page. + +**Camera-only, and that is worth saying plainly to a reader who knows +slipshow already.** A slip is fully rendered the moment it is shown; only the +viewport moves within it. There is no `pause`, no incremental build of a +slip's own content, and no step directives of any kind — the camera is the +only motion this package has. + +**A deck does reveal itself one slip at a time, though, and that is the +default.** A slipshow opens EMPTY: no slip is on the page until the reader +presses for one, and each advance brings exactly one more into the document, +so the page grows as the presentation runs. This is a whole-slip reveal and +not slipshow's own `pause` — the boundary is always "everything up to the +current slip", never a point inside one — and going back takes slips away +again rather than leaving a trail. `#slipshow(reveal: false)` renders the +whole deck up front instead; see "`reveal:`" below. + +**A slipshow is a FLAT ordered list of ideas.** An idea written literally +inside another's body — a nested `#idea`/`#slip` — is ordinary content: it +renders as part of the slip that contains it, never as a slip of its own. +`#slipshow` does not look inside a slip to find more slides. + +```typst +#import "@rookery/core:0.1.0": rookery +#import "@rookery/slipshow:0.1.0": slip, slipshow +#show: rookery + +#slipshow(slips: ( + slip("intro", title: [Welcome], fullscreen: true)[The opening slip.], + slip("closing")[The last one.], +)) +``` + +## Import both packages, in your own files + +**A project using slipshow must import `@rookery/core` AND +`@rookery/slipshow` in its own `.typ` files.** This is the same requirement +`@rookery/search`'s readme states about itself, and for the identical reason: +rheo's package asset auto-detection only scans a project's own files for +package imports, not the packages those files' packages import in turn. +Importing only `@rookery/slipshow` and reaching core through it does not +register core with the build. + +The cost is not cosmetic here, either. Without a direct import of core, +core's own `.marrow.typ` never runs and its stylesheet (`src/core.css`) is +never injected — and that stylesheet is what draws the card identity this +package's whole pitch rests on: the left rule, the tab, the permalink. A +slipshow built this way still scrolls and still camera-moves correctly (that +part is this package's own manifest, injected off the direct +`@rookery/slipshow` import), but every slip renders as unstyled text with no +border and no tab. + +In practice this is free — a project with `#slip`/`#idea` calls in it already +imports core to write them. It is stated here because the failure is silent: +the build succeeds either way. + +## `#slip` — a slide is a note + +```typst +#slip( + fullscreen: false, + background: none, + enter: none, + order: none, + class: none, + row: none, + max-width: none, + tags: none, + exclude-tags: (), + display-frame: false, + display-id: false, + ..args, +) +``` + +`#slip` is `#idea` plus a handful of presentation options, folded into the +note's own tag dictionary (`src/tags.typ`'s `slip-tags`) — the only field on +a rookery record extensible enough to carry them through to a tag-queried +deck, which never sees the call site itself, only the registry row. + +| option | type | default | what it does | +| --- | --- | --- | --- | +| `fullscreen` | `bool` | `false` | tags the note `slip-fullscreen`; the stylesheet stretches the slip to at least the viewport height and centers its content in it | +| `background` | `color`, `gradient`, or `image(..)` content | `none` | this slip's own background — see "Backgrounds" below | +| `enter` | one of the eight camera actions (see "Keyboard and mouse controls") | `none` (deck default) | overrides `#slipshow`'s deck-wide `enter:` for this one slip | +| `order` | `int` | `none` | this slip's `slip-order` — the default sort key for a tag-queried deck | +| `class` | `str` | `none` | appended to the rendered `
`'s class list | +| `row` | `int` | `none` | groups CONSECUTIVE slips sharing the same value into one `div.slip-row[data-row=..]` wrapper — overridden per-deck by `#slipshow`'s own `row:` key function, below, for a row that is computed rather than authored | +| `max-width` | `length`, `ratio`, or a raw-CSS `str` | `none` | a `max-width` declaration on the slip's own `
` — never `width`, so a narrower slip stays narrow rather than being stretched to fill the cap | +| `tags` | any of core's four tag forms | `none` | the caller's own tags, merged in LAST — a caller naming one of `#slip`'s own keys wins outright | +| `exclude-tags` | array of tag names | `()` | forwarded through to the underlying `#idea` — see below | +| `display-frame` | `bool` | `false` | `@rookery/core`'s own switch with its default INVERTED — no card left rule, no indent | +| `display-id` | `bool` | `false` | core's again, inverted — no `[idea:]` permalink, and so no hat | +| `..args` | — | — | every other `#idea` argument (`title`, `level`, `created`, `display-date`, `display-tags`, …), forwarded untouched | + +`fullscreen`, `enter`, `order`, `class` and `row` are each type-checked at the +`#slip()` call site and panic naming the bad value. `background` is the one +exception: it is left untouched here — `#slip`/`slip-tags` have no business +interpreting a colour, a gradient or an image — and is checked instead where +it is actually rendered, inside `#slipshow`. A `#slip` call with a bad +background type therefore builds without complaint; the panic fires when the +deck renders that slip. + +### A slip is bare by default, wherever it renders + +`display-frame` and `display-id` are `@rookery/core` arguments, and `#slip` only +changes their defaults — the same inversion `#slipshow` makes for a queried slip +(see "A slip wears no card chrome" below), and it has to be made in both places +because the two routes render at different times. `#slipshow` renders a QUERIED +slip itself and can pass whatever it likes; an explicit-array slip was already +rendered at its own `#slip(..)` call site, long before any deck saw it, so no +deck-level setting can reach inside it. Setting the default on `#slip` is what +makes the array route agree with the query route. + +**A `#slip` is therefore bare where it is AUTHORED too**, not only inside a deck. +That is intended rather than a side effect: a `#slip` written on a page usually +renders twice — once inline where it sits, once inside the deck that queries it +back — and the two copies should look the same. Pass `display-frame: true` / +`display-id: true` to get core's ordinary card back for one slip. + +`display-label` is deliberately absent: it is a `#window` argument, and a card +already prints the authored title alone, so there is no derived label for a +`#slip` to suppress. + +`foldable` and `reserve-title` are absent for the same reason, and it is the +same reason twice: both are `#window` arguments, and an authored `#slip` renders +as a CARD, which has no disclosure and no summary row at all — so there is +nothing to make unfoldable and no reserved title line to drop. They apply to the +queried route, where `#slipshow` renders each slip through `#window`, and that +is where the deck sets them. + +All three of `#idea`'s id forms work identically on `#slip`: a bare body with +an auto-generated id (`#slip[..]`), a string name (`#slip("intro")[..]`), or +a label (`#slip()[..]`). + +**A plain `#idea` is usable in a slipshow too**, and simply takes the deck's +defaults: no fullscreen, no background, the deck's own `enter:`, and it sorts +last under the default `slip-order` since it carries no `slip-order` tag at +all. `#slip` exists only for a note that wants presentation options of its +own — see `demo/rheo/content/index.typ`'s `plain-note` for a worked example +of a bare `#idea` tagged `slip` by hand and rendered inside a deck alongside +notes built with `#slip`. + +### `exclude-tags` — pass the same list to `#slip` as to `#idea` + +A project excluding tags from a build (`@rookery/core`'s readme, "Excluding +notes from a build") has to bind BOTH constructors to the same +`exclude-tags:` list: `#slip` calls the `idea` it binds from core's package +scope, so binding a project's own `idea` alone does not reach `#slip` — a +project's `#slip` would go on hatching notes the project meant to exclude. +`#slip` names `exclude-tags:` in its own signature for exactly this reason, +and needs the same list `idea` itself gets: + +```typst +#import "@rookery/core:0.1.0": idea as _idea +#import "@rookery/slipshow:0.1.0": slip as _slip + +#let EX = ("protected",) +#let idea = _idea.with(exclude-tags: EX) +#let slip = _slip.with(exclude-tags: EX) +``` + +## `#slipshow` — the deck container + +```typst +#slipshow( + slips: none, + tags: none, + where: none, + match: "any", + order: "slip-order", + reverse: false, + row: none, + enter: "scroll", + reveal: true, + display-frame: false, + display-id: false, + display-label: false, + foldable: false, + reserve-title: false, + backlink: false, +) +``` + +`#slipshow` resolves its slip list from exactly ONE of two routes and renders +the result. Giving neither route, or both, panics naming which: + +- **`slips:`** — an explicit, already-ordered array. +- **`tags:` and/or `where:`** — a query over the registry, sorted by `order:`. + +`enter:` is the deck-wide default camera action, one of the eight named in +"Keyboard and mouse controls" below (`"scroll"` by default); a bad value +panics naming the valid set. + +`reveal:` is the progressive reveal, `true` by default — see its own section +below. + +`display-frame:`, `display-id:`, `display-label:`, `foldable:` and `reserve-title:` are +`@rookery/core`'s own chrome switches with their defaults inverted — see "A slip +wears no card chrome" below. + +`foldable: false` and `reserve-title: false` are the two that make a slip read +as a slide rather than as a reference to a note: + +- **`foldable: false`** — a slide is not a disclosure. There is no + `
`/``, so nothing can fold a slide shut under a stray click + and the summary row stops offering a pointer. The `[idea:]` permalink + inside it is still a link and still navigates. Pass `foldable: true` for a + deck you want to collapse. +- **`reserve-title: false`** — no blank line above a titleless slide. Because + this deck also sets `display-label: false`, a note with no AUTHORED title has a + genuinely empty summary, and the line core reserves for a title there is dead + space above the body. **A slide whose note DOES have a title still shows it, + with core's ordinary spacing** — the reservation only ever applied to a + summary with no title at all. Either way the slide is not foldable; the two + switches are independent. + +The hover tint is deliberately LEFT ALONE, at core's `display-background: true`. It +is how a slide answers a pointer, and it is independent of `display-frame`, which +this deck does turn off — so there is no `display-background` argument here, since +its only value would be core's default. + +`backlink:` is core's own too, also inverted: a deck does not count as a link to +the notes it shows — see "A deck is not a reference" below. + +### A slip wears no card chrome + +```typst +#slipshow(tags: "slip") // bare slips, the default +#slipshow(tags: "slip", display-frame: true) // put the card's rule back +``` + +A queried slip is transcluded through `#window`, so it arrives as a full +`@rookery/core` card unless told otherwise. Inside a presentation all of that +card's chrome is noise, so `#slipshow` passes three of core's own arguments with +their defaults **inverted**: + +| argument | here | in core | what `false` drops | +| --- | --- | --- | --- | +| `display-frame` | `false` | `true` | the card's left rule and its indent | +| `display-id` | `false` | `true` | the `[idea:]` permalink, and with it the whole hat | +| `display-label` | `false` | `true` | the title derived from a note's first line; an AUTHORED title still shows | + +The reasoning, one line each: a slip's `
` is already the visual unit, so +a card frame inside it reads as a frame around a frame; the `[idea:47]` chip above +every slide is machinery a reader of a deck has no use for; and an untitled note's +derived label sits directly above the very first line it was derived from, so the +slide prints that line twice. + +**These are core's knobs, not this package's.** Nothing here draws or hides any of +it — `@rookery/core`'s readme documents what each one does and its own stylesheet +does the hiding. `#slipshow` only chooses different defaults and forwards them. + +**One cost, inherited from core and worth repeating:** the permalink is the ONLY +way to discover an AUTO-GENERATED id. A deck of unnamed notes rendered with +`display-id: false` therefore has no ids a reader can copy into a `#window` or a +`#slip-` fragment. Give the slips explicit names if they should be linkable. + +### A deck is not a reference + +```typst +#slipshow(tags: "slip") // announces nothing, the default +#slipshow(tags: "slip", backlink: true) // the deck page counts as a link +``` + +A queried slip is transcluded through `#window`, and a `#window` normally +announces the note it shows: you wrote it in a note's prose, so that note links +to this one. A deck is not that. It is a VIEW of notes that already live +somewhere else — `demo/rheo/content/index.typ` puts it as "always an additional +view onto content that already lives somewhere, never its only home" — and +nobody wrote a link at all. Left announcing, a page of twenty queried notes puts +itself in twenty notes' Backlinks. + +So `backlink:` is `false` here where core's own default is `true`. A deck whose +whole point IS to point at those notes — an index page, a reading list — passes +`true`. + +**Nothing about this is visible in the markup**, which is worth saying because it +makes the behaviour hard to check by eye. The flag travels inside the announce +marker `#window` emits, and that marker is emitted whatever its value: a third +reader needs it to know a nested window will claim the enclosing note's +citations. `@rookery/core`'s readme has the full account under "`backlink:` — a +view is not a reference". + +**The array route is unaffected**, and not because it opts out: an entry that is +already-rendered `#slip(..)` content never went through `#window`, so it never +announced anything in the first place. An entry given by NAME does go through +`#window` and follows the deck. + +### `reveal:` — the deck opens empty + +```typst +#slipshow(tags: "slip") // opens empty, one slip per advance +#slipshow(tags: "slip", reveal: false) // the whole deck rendered up front +``` + +By default a slipshow shows **no slips at all** when the page loads, and +reveals them one at a time as the reader advances: the first press puts up the +first slip, the second press the second, and so on. The boundary is always the +CURRENT slip — `←` and `Home` hide what they move back past rather than leaving +it on the page, and `End` reveals the whole deck at once because that is where +the current slip now is. Opening on a `#slip-` fragment reveals everything +up to and including the slip it names, so a permalink into the middle of a deck +still lands on a page with the run-up to it in place. + +**A hidden slip is `display: none` and takes up no room**, so a page carrying a +deck is exactly as long as the slips currently on it. That is the point rather +than an implementation detail: the alternative — hiding a slip in place — +leaves a twenty-slip deck opening as one screen of content and nineteen screens +of blank page, and every camera target the engine computes is measured against +a document that is mostly nothing. + +Three consequences worth knowing before turning it on for a deck, or off: + +- **Click-to-advance cannot START a revealing deck.** The click handler is + bound to `div.slipshow`, which has no area at all while the deck is empty, so + the first move has to come from the keyboard (`→`, `↓`, `Space`, …). Click + works normally from the first slip onwards. Listening on the document instead + would let a click on a page's own heading or margin scroll a reader into a + deck they had not asked to enter, which is the same thing the load-time + camera rule below refuses to do. +- **The hiding is done by the controller, not by the markup.** `#slipshow` + renders the identical DOM either way and only sets `data-reveal` on the deck + root; `src/slipshow.js` adds a `slipshow-revealing` class at startup and a + `slip-revealed` class per revealed slip, and `src/slipshow.css` hides on + those. So a reader whose JavaScript never ran — a blocked script, an EPUB + reader that executes none — gets the whole deck rendered rather than a blank + page with no key that would fill it. +- **A PDF prints every slip**, `reveal:` or not: the paged branch has no camera + to have arrived at one, exactly as it has no rows and no backgrounds. + +`reveal:` is a `bool`; anything else panics naming it. + +### The explicit array: `slips:` + +An array whose elements may be already-rendered content, note NAMES (a +string or a label), or a mix of both: + +```typst +#slipshow(slips: ( + slip("intro", title: [Written in the order it runs], fullscreen: true)[ + An explicit array is already ordered by construction. + ], + "a-note-authored-elsewhere", // resolved against the registry by name + slip("centered", enter: "center")[Overrides the deck default for one slip.], +)) +``` + +A `str`/label element is looked up against the registry; a name matching +nothing PANICS rather than being silently skipped, because a named slip is an +assertion about what the presentation contains — a dropped slide should stop +the build, not slip through it. Anything else in the array is read as +content directly, exactly as it always has been. + +**The three chrome arguments reach the tag-query route only.** An entry in a +`slips:` array that is already-rendered `#slip(..)` content was rendered before +`#slipshow` ever saw it, so a deck-level setting cannot reach inside it; such a +slip carries whatever its own `#slip(..)` call asked for — which is bare by +default too, since `#slip` inverts the same two defaults at its own call site +(see "A slip is bare by default, wherever it renders" above). An entry given by +NAME goes through `#window` like any queried slip and does follow the deck. + +**`order:` and `reverse:` are refused if given a non-default value alongside +`slips:`** — an explicit array is already in the order it was written, so +there is nothing left for either to do. Drop them, or switch to `tags:`/ +`where:` if the deck should be reordered. + +**Passing `window(name)` inside a `slips:` array silently drops every +`#slip` option.** This is worth stating outright because nothing about it +errors: `#window` wraps its whole body in a `context { .. }` block, and a +Typst context block's content is opaque until it is evaluated — the code +that reads a slip's options back out of rendered content finds nothing +inside the unevaluated context and falls back to no options at all. The deck +still compiles and renders; `fullscreen`, `background`, `enter` and every +other `#slip` key are just gone. Pass the bare NAME instead +(`#slipshow(slips: (n1, n2))`, not `#slipshow(slips: (window(n1), +window(n2)))`) — a name defers rendering, and therefore defers context +evaluation, to the point where `#slipshow` reads the note's options straight +off its registry row rather than sniffing them out of content. + +### Querying the registry: `tags:`, `where:`, `match:` + +```typst +// a plain tag, or an array with `match:` +#slipshow(tags: "slip") +#slipshow(tags: ("slip", "demo"), match: "all") + +// a predicate over the tag dictionary +#slipshow(tags: t => "slip" in t and "slip-fullscreen" in t) + +// a predicate over the WHOLE row — fields `tags:` cannot see +#slipshow(where: r => r.page == "methods") + +// both compose: `tags:` narrows first, `where:` narrows the survivors +#slipshow(tags: "slip", where: r => r.created != none) +``` + +`tags:` is `none`, a plain tag name or array (forwarded straight to core's +`#ideas(tagged:, match:)`, along with `match:`, `"any"` by default), OR a +**predicate function** taking a note's tag dictionary and returning a bool — +see "The `a&b` query language" below for what that predicate route is for. +`match:` is ignored when `tags:` is a function: a predicate walks +`ideas(values: true)` itself and has no use for it. + +`where:` is a predicate over the WHOLE registry row — `id`, `name`, `title`, +`text`, `label`, `tags` (the flat name array), `tags-dict` (the full +dictionary), `body`, `href`, `page`, `created` — for a selection `tags:` +cannot express at all: by date, by page, by title text, by body content. +`tags:` and `where:` may be given together; `tags:` runs first as core's own +cheap filter, and `where:` narrows whatever survives it. + +### `order:` — four forms, all sorted through the same tie-break + +- an **array of note names/ids** — position in that array is the sort key; a + row named nowhere in it sorts last, in id order; +- **`"created"`** — ascending by the row's own `created` date; +- **`"slip-order"`** (the default) — ascending by the note's own `slip-order` + tag (set through `#slip(order: n)`); +- a **key function** — `row => `, called once per row over the + WHOLE row (the same shape `where:` sees), returning an `int`, `float`, + `str` or `datetime`. Every row's key must be the same type — Typst cannot + compare values of different types — and `row.title` (content) is rejected + with a hint to use `row.label` or `row.text` instead. + +Every form treats "no key" the same way — an unmatched array position, no +`created`, no `slip-order` tag, a key function returning `none` — as LAST, in +id order, whatever `reverse:` says: `reverse: true` reverses only the KEYED +rows, so an undated note in a reverse-chronological deck stays the note with +no date rather than jumping to the front. `reverse:` is a `bool`, `false` by +default. + +### `row:` — a key function for a COMPUTED row + +```typst +#context { + let layer = layer-of(todo-graph()) // @rookery/todos + slipshow(tags: "todo", row: r => layer.at(r.name, default: none), order: ..) +} +``` + +Same shape as `order:`'s key-function form — a function called once per row, +over the WHOLE row — but for GROUPING instead of sorting: its return value +overrides that note's own `slip-row` tag for the purpose of `div.slip-row` +wrapping, without touching the note's tags or the deck's order at all. +`none`, or `row:` left at its default, falls back to the note's own +`slip-row` tag (set through `#slip(row: n)`); anything but an `int` or `none` +panics naming `row`. + +This is the extension point for a deck whose rows are DERIVED rather than +authored — a dependency-graph layer, a date bucket, any group-by over a +field `#slip`'s own `row:` argument cannot see, because that argument only +ever runs at the call site, never over a note queried back out of the +registry. `row:` composes with either route (`slips:` as well as `tags:`/ +`where:`): it groups whatever the route resolved, it does not reorder it, so +a caller still sorts (`order:`) so a row's members sit adjacent. + +### `class:` — a key function for a COMPUTED class + +```typst +#context { + let keys = todo-slip-keys(todo-graph()) // @rookery/todos + slipshow(tags: "todo", class: keys.class, row: keys.row, order: keys.order) +} +``` + +`class:`'s shape is `row:`'s, redirected: a function called once per row, +over the WHOLE row, but for a slip's CLASS LIST instead of its grouping. It +returns a `str` or `none`; anything else panics naming `class`. A +SPACE-SEPARATED string is several classes, since the result is appended to +the rendered `
`'s class list and joined into that one `class` +attribute verbatim (`_slip-attrs`, `src/slipshow.typ`). + +The two share their override rule too, worth restating precisely: `class:` +beats a note's own `slip-class` tag by KEY PRESENCE, not value, exactly as +`row:` beats `slip-row` one section above. A `class:` function that computes +`none` for a note means "no class" — not "fall back to whatever tag it +carries" — because the key lands on every entry the function actually ran +on, and `#slipshow`'s `_entry-class` (`slipshow.typ`) tells "ran and said +none" apart from "never ran" by that presence alone. Leave `class:` at its +default and a note's own `slip-class` tag renders untouched. + +`class:` composes with EITHER route, `slips:` included, unlike `order:`/ +`reverse:` — which `slips:` refuses outright (see "The explicit array" +above) — because it neither selects nor reorders anything an explicit +array would otherwise conflict over. + +This exists for a class that cannot be a tag because it is derived from +something outside the note itself. `@rookery/todos`' `ready` and `blocked` +are exactly that, and `class:` is the only route by which they reach a +slide's `
` at all — see that package's readme, "Decks: +#todo-slipshow". + +### `edges:` — a key function for the slides a slide points at + +```typst +#context { + let keys = todo-slip-keys(todo-graph()) // @rookery/todos + slipshow(tags: "todo", edges: keys.edges, class: keys.class, row: keys.row, order: keys.order) +} +``` + +The third of the computed key functions, and the only one whose result is a +LIST: a function called once per queried slide, over the WHOLE registry row, +returning an array of NOTE NAMES — each a `str` or a `label` — or `none`. +Anything else panics naming `edges`. A label arrives normalized to its string +form, so `("a", )` and `("a", "b")` are the same answer. + +**A name the deck does not show is DROPPED, not an error.** A deck is +frequently a slice of a corpus — `examples/dag/content/open-only.typ` is +exactly that — so a slide pointing at a note this deck happens not to show is +ordinary rather than a mistake, and so is a name that resolves to nothing at +all. This is deliberately the opposite of `slips:`'s own unknown-name panic +("The explicit array", above): there a name is an assertion about what the +deck CONTAINS, here it is a fact about a graph the deck is only a view of. + +What reaches the DOM is `data-slip-edges` on the slide's own `
`: a +space-separated list of the ELEMENT IDS of the slides pointed at, already +restricted to slides this deck shows. It is absent entirely when nothing +survives the drop — an empty attribute would claim the slide points at +nothing in particular, which is a different thing from no edges having been +computed for it. There is no `slip-edges` tag to fall back to and there could +not be: which slides a slide depends on is a fact about a graph only the +deck's caller knows. + +`edges:` composes with EITHER route, `slips:` included, for the same reason +`row:` and `class:` do — it neither selects nor reorders anything. What is +drawn from the attribute is "The connector curves" under "Customising the +CSS" below. + +## The `a&b` query language + +`tags:` accepting a predicate function is the extension point for a full +boolean grammar over tags, with no dependency on `@rookery/search` at all — +the grammar lives there, this package only needs the function shape: + +```typst +#import "@rookery/search:0.1.0": parse-tag-query, eval-tag-query +#slipshow(tags: t => eval-tag-query(parse-tag-query("a&b").rpn, t.keys())) +``` + +`t.keys()`, and not `t`: a `tags:` predicate receives the note's tag +DICTIONARY, while `eval-tag-query` walks an array of tag NAMES (`tags.any(..)` +in `@rookery/search`'s `tagquery.typ`), and the keys are that array. Handing +the dictionary straight over fails the compile with `type dictionary has no +method 'any'` — loudly, at least, rather than quietly selecting the wrong +notes. `examples/search-order/` is the worked version. + +`@rookery/slipshow` does not import `@rookery/search`, and does not need to. +Without it installed, a project still has explicit orderings (`slips:` with +an array, or `order:`'s array/function forms) and core's own `tags:`/ +`match: "any"|"all"` — a predicate is one more way in, not the only one. + +## The tag surface + +Every `#slip` option lives in the note's own tag dictionary +(`src/tags.typ`), which is what makes it filterable and stylable with no +special-casing beyond tags a project already knows how to work with. + +**Flat keys** — present or absent, value `none`, render as a pill wherever +`display-tags: true` is set, and each emits an `.idea-tag-` CSS class on +whatever renders the slip. That is BOTH routes, not only the inline one: +`_render-slip` (`src/slipshow.typ`) renders an already-rendered `slips:` +array entry as its own inline `#idea`/`#slip` card, and renders every entry +resolved BY NAME — a `slips:` array of strings/labels, or the whole +`tags:`/`where:` query route — through `#window`. A `#window`'s own wrapper +wears the same `.idea-tag-` classes and the matching +`data-rookery-tags` its card does, so a deck built from a tag query — the +common case, and the one every example here uses — carries this class on +every slip exactly as an explicit inline deck does: + +| key | set by | +| --- | --- | +| `slip` | every `#slip` call, and `#idea(tags: ("slip": none))` by hand — the key a deck's `tags:`/`where:` query filters on | +| `slip-fullscreen` | `#slip(fullscreen: true)` | +| `slip-enter-` | `#slip(enter: )`, one of the eight camera actions | + +**Valued keys** — present-filterable by key, but carry no pill (a valued tag +never gets one, the same rule core states for a tag like `depends-on`): + +| key | set by | +| --- | --- | +| `slip-background` | `#slip(background: ..)` | +| `slip-order` | `#slip(order: ..)` | +| `slip-class` | `#slip(class: ..)` | +| `slip-row` | `#slip(row: ..)` | +| `slip-max-width` | `#slip(max-width: ..)` | + +Flat keys are filterable by core's own `#ideas(tagged:)`/`#window(tagged:)` and +by `@rookery/search`'s `tags:draft`-style query language exactly like any +other tag — `tags:slip-fullscreen` finds every fullscreen slip in a rookery +without this package's help. A project reading these off a tag dictionary +directly (say, walking `ideas(values: true)` itself to style or list slips) +can use this package's own accessors rather than re-deriving the key names: +`is-slip(tags)`, `is-fullscreen(tags)`, `enter-of(tags)`, `background-of(tags)`, +`order-of(tags)`, `class-of(tags)`, `row-of(tags)`, `max-width-of(tags)` — +each takes the tag DICTIONARY (`row.tags-dict`), not a note name, the same +convention `src/tags.typ` uses throughout. + +## Keyboard and mouse controls + +Reachable from the keyboard and from a click, both bound in +`src/slipshow.js`: + +| input | what it does | +| --- | --- | +| `→` / `↓` / `Page Down` / `Space` | advance to the next slip | +| `←` / `↑` / `Page Up` | go back to the previous slip | +| `Home` | jump to the first slip | +| `End` | jump to the last slip | +| `Esc` | **unfocus** — return the camera to wherever it was before the last `focus` action moved it (a stack, so repeated focuses undo in the reverse order); a no-op if nothing was ever focused | +| click anywhere inside the deck | advance to the next slip | + +On a revealing deck (the default) every one of these also brings the deck up +to wherever it lands: forwards reveals, backwards hides again, and `End` +reveals the lot. Only the click has a starting condition — an empty deck has +nothing to click, so the first move is a keypress. See "`reveal:`" above. + +Keyboard shortcuts are ignored outright while a modifier (`Ctrl`/`Alt`/ +`Meta`/`Shift`) is held, and while focus sits in an ``, a `