diff --git a/.birds/ideas/rk-keep-a-replayed-nested-note-from-taking-d4fc76cb.md b/.birds/ideas/rk-keep-a-replayed-nested-note-from-taking-d4fc76cb.md deleted file mode 100644 index 219d922a..00000000 --- a/.birds/ideas/rk-keep-a-replayed-nested-note-from-taking-d4fc76cb.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -id: rk-keep-a-replayed-nested-note-from-taking-d4fc76cb -short-id: d4f -title: Keep a replayed nested note from taking a second ordinal -priority: 5 -labels: -- fix-scope-ordinal-replay -- parked -deps: [] -closed: false ---- -Touches: core/0.1.0/src/state.typ, core/0.1.0/src/idea.typ, core/0.1.0/demo/rheo/content/nested-replay.typ, core/0.1.0/demo/rheo/check.sh - -A titleless note nested inside another note takes its number from a container -ordinal that advances every time the parent's body is rendered. Transclude the -parent and the child takes a second number, so one note has two ids depending -on where it was reached. - -## The defect, measured - -`/home/lox/code/waterline/rookery/clusters/digitaltheory/pragma/meetings.typ` -lines 376 to 419 are one `#meeting` call, and its body contains **exactly one** -nested `#idea[` (line 398). Confirm before starting: - -``` -awk 'NR>=376 && NR<=419' /home/lox/code/waterline/rookery/clusters/digitaltheory/pragma/meetings.typ | grep -c '#idea' -``` - -printed `1`. Yet building that site shows, in the non-convergence trace for -`state("rookery-idea-scope")`, a final run containing both of: - -``` -(key: "meeting-with-on-4-9-26", n: 2), -(key: "meeting-with-on-4-9-26-2", n: 0), -``` - -The first is the meeting's own pushed container, reporting that **two** -titleless notes have been minted inside it. The second is a container pushed -for a note whose id is `meeting-with-on-4-9-26-2` — the same single nested -note, minted again, taking ordinal 2 instead of the 1 it already had. - -A nested note's id is `-`, so an ordinal that advances on replay -is an id that changes with it. This is not the title-slug numbering: that is a -separate mechanism, and `state("rookery-idea-slug-count")` never appears in a -non-convergence warning on this site at all. - -## Why it happens - -Anchors, run at filing from `/home/lox/code/_fcl/rookery/core/0.1.0`: - -``` -rg -n 'let _scope-peek|let _scope-record|let _scope =' src/state.typ -``` - -printed `src/state.typ:395` (`_scope`), `:415` (`_scope-peek`), `:451` -(`_scope-record`). And: - -``` -rg -n '_scope-record\(|_scope-peek\(|_scope.update' src/idea.typ -``` - -printed five sites: `:167-168` (the excluded-note path, which still consumes an -ordinal to keep sibling numbering stable), `:276` (the container peek in the -main mint), `:310` (the record), `:331` (this note's OWN container pushed for -its body's nested notes) and `:634` (the matching pop). - -`_scope-record` advances the ordinal unconditionally. When `#window` replays a -parent note's body, the nested `#idea` mint block runs again, records again, -and the container it is counting against goes from 1 to 2. - -## The fix, and the model to copy - -This exact problem was already solved for the title-slug numbering in this same -file. Anchors: - -``` -rg -n 'let _slug-count =|let _slug-peek|let _slug-record' src/state.typ -rg -n 'let occupant' src/idea.typ -``` - -printed `src/state.typ:494, :517, :535` and `src/idea.typ:289`. There, -`_slug-count` maps a slug to an ARRAY OF OCCUPANTS rather than a count, an -occupant being the note's own `(title, body, tags, level, display)` tuple built -from call-site values; `_slug-peek` returns an occupant's existing position if -it is already listed, and `_slug-record` appends only when it is absent. A note -rendered twice therefore gets the same number back. - -Apply the same shape to the container ordinal: a note that has already taken an -ordinal within a container must get that ordinal back, not the next one. The -occupant tuple is already computed at `idea.typ:289`, before the id exists, so -it is available at the peek and record sites without new plumbing. - -Two constraints on the implementation: - -1. **Keep the updater a pure function of its own argument.** `_scope-record` - already follows this, and it is load-bearing: an updater closing over a - separately-read value was measured to cost one extra compile attempt per - note sharing the container. -2. **Do not change the top-level (vertebra) accumulator's behaviour** beyond - making it idempotent the same way. Its keying by handle, its `top: true` - marker and its per-vertebra reset are correct and were expensive to get - right. - -## Non-goals - -- **Do not touch the slug numbering** (`_slug-count`, `_slug-peek`, - `_slug-record`). It is already idempotent and already stable on this site. -- **Do not touch `bib.typ`.** Its footnote counters are a separate defect with - its own bird. -- **Do not touch `urls.typ`, `outline.typ`, `permalink.typ` or `.marrow.typ`.** -- **Do not reach for `state("rheo-handle")` or any rheo state** in the - identity. This package must keep working under a plain `typst compile`, which - `demo/pure/` exercises. -- **Do not edit anything in `/home/lox/code/waterline`.** You may READ it and - BUILD it as a diagnostic (VERIFY 4), nothing more. - -## Uncertainty, and what is known about reproducing it - -A previous investigation could NOT reproduce this at small scale: a solo -`#meeting`, a `#meeting` transcluded cross-vertebra, the same content value -placed twice, two argument-identical calls, and a verbatim copy of the entire -real `pragma/meetings.typ` all converged cleanly with a single occupant. The -defect has so far only been observed on the full site. - -So do not assume a two-file fixture will reproduce it. Write the fixture -anyway (VERIFY 2) — a nested titleless note inside a parent that is -transcluded is the minimal shape of the bug, and it is the regression test -whether or not it fails today. If it does NOT fail before your change, say so -explicitly in your report and rely on the site measurement in VERIFY 4 for -evidence that the fix does something. - -If you find the ordinal cannot be made idempotent without an identity that is -itself unstable, STOP and report with your evidence rather than landing a -guess. - -## VERIFY - -1. From `core/0.1.0/`, `just test` passes. -2. Add `core/0.1.0/demo/rheo/content/nested-replay.typ`: a titled parent note - containing one titleless nested note with a distinctive grep marker, and - transclude that parent into a `#window` on a different vertebra (the demo - already does this elsewhere — see `demo/rheo/content/sub/page.typ` for the - shape). Assert in `check.sh` that the nested note mints exactly one page, - that its name ends `-1`, and that no page ending `-2` exists for it. -3. From `core/0.1.0/demo/rheo/`, `just check` passes and prints `demo/rheo OK`. -4. Build the site as a diagnostic: copy - `/home/lox/code/waterline/rookery/rheo.toml` to a scratch config INSIDE that - repository (e.g. `rookery/.checkscope.toml`), replace the `repo =`/`branch =` - pair in `[packages.rookery]` with `path = ""`, run - `rheo compile rookery --config rookery/.checkscope.toml --html --build-dir --input today=2026-09-20`, - and DELETE the scratch config afterwards. In that build's - `state("rookery-idea-scope")` trace, no key `meeting-with-on-4-9-26-2` may - appear. Report the trace either way; the document may still fail to converge - for the footnote reason, which is acceptable. -5. From `core/0.1.0/demo/pure/`, `just build` prints `demo/pure OK`. -6. From the repository root, `just check-versions` still prints its OK line. \ No newline at end of file diff --git a/.birds/ideas/rk-rename-display-id-in-slipshow-e397c7b6.md b/.birds/ideas/rk-rename-display-id-in-slipshow-e397c7b6.md index 07a3febe..f0cff015 100644 --- a/.birds/ideas/rk-rename-display-id-in-slipshow-e397c7b6.md +++ b/.birds/ideas/rk-rename-display-id-in-slipshow-e397c7b6.md @@ -6,7 +6,7 @@ priority: 2 labels: - fix-idea-name-terminology deps: [] -closed: false +closed: true --- `@rookery/core` has renamed the `display-id:` argument to `display-name:`, as part of standardising on one word — **name** — for the thing that names a note. diff --git a/.birds/ideas/rk-rename-idea-id-color-in-consumers-7a2d21ea.md b/.birds/ideas/rk-rename-idea-id-color-in-consumers-7a2d21ea.md index 74d51825..dcdaef82 100644 --- a/.birds/ideas/rk-rename-idea-id-color-in-consumers-7a2d21ea.md +++ b/.birds/ideas/rk-rename-idea-id-color-in-consumers-7a2d21ea.md @@ -6,7 +6,7 @@ priority: 2 labels: - fix-idea-name-terminology deps: [] -closed: false +closed: true --- `@rookery/core` has renamed the CSS custom property `--idea-id-color` to `--idea-name-color`, as part of standardising on one word — **name** — for the diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bc1fb74e..60fc08f3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,12 +14,29 @@ jobs: steps: - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + with: + version: latest + + - uses: actions/setup-node@v5 + with: + node-version: '22' + - uses: extractions/setup-just@v3 - # Pinned to the version this repo is developed against, and the one the - # manifest declares as its `compiler` floor (0.15.0). Installed from the - # release tarball rather than a third-party action: one pinned URL is - # easier to audit than an action's own moving parts. + # `pdftotext`, which `slipshow/0.1.0/demo/rheo/check.sh` reads the combined + # PDF with to prove no HTML tag leaks into a paged compile. The runner image + # carries no poppler, and the script's `2>/dev/null` on that call means an + # absent binary would otherwise kill it silently under `set -e`. + - name: Install poppler-utils + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends poppler-utils + + # Pinned to the version this repo is developed against, and the one all + # four manifests declare as their `compiler` floor (0.15.0). Installed + # from the release tarball rather than a third-party action: one pinned URL + # is easier to audit than an action's own moving parts. - name: Install Typst 0.15.1 run: | set -euo pipefail @@ -28,14 +45,14 @@ jobs: tar -xJf typst.tar.xz echo "$PWD/typst-x86_64-unknown-linux-musl" >> "$GITHUB_PATH" - # Pinned to the OLDEST rheo the demo projects need, NOT to the newest - # rheo: the point is that the DECLARED floor is the floor actually - # tested, so a package that reaches for a newer surface fails here - # instead of on a user's machine. rheo reads `min_version` itself and - # refuses a package above the running version, so this step and that - # check have to agree — if they drift, one of them is testing a fiction. + # Pinned to the OLDEST rheo the four manifests declare as their floor, NOT + # to the newest rheo: the point is that the DECLARED floor is the floor + # actually tested, so a package that reaches for a newer surface fails + # here instead of on a user's machine. rheo reads `min_version` itself and + # refuses a package above the running version, so this step and that check + # have to agree — if they drift, one of them is testing a fiction. # - # core declares `min_version = "0.6.2"`, and NOT because the Typst + # All four declare `min_version = "0.6.2"`, and NOT because the Typst # surface moved — it has not since 0.6.0. Three requirements stack up. # # 0.6.0: `_resolve-dest` (`core/0.1.0/src/urls.typ`) emits a reserved @@ -52,20 +69,21 @@ jobs: # from a ref lives at a path keyed by its resolved commit, which no probe # matches. Every page this family mints from marrow went missing, on a build # that succeeded and warned about nothing. Since every consumer currently - # tracks a branch rather than a release, that is the ordinary case here, not - # an edge one. + # tracks the `0.1.0` branch rather than a release, that is the ordinary case + # here, not an edge one. # # 0.6.3: where a namespace could first resolve from a directory on disk - # (`path`), which is what `[packages.rookery] path = "../../../.."` in - # `core/0.1.0/demo/rheo/rheo.toml` asks for. core's `min_version` stays at - # 0.6.2 because no package surface moved — the gap is entirely in how the - # DEMO PROJECT locates `@rookery`, so the version installed here is set by - # the demo's floor rather than the package's own. + # (`path`), which is what every `[packages.rookery] path = "../../../.."` + # in this repo's demos and examples asks for. No package here declares a + # `min_version` above 0.6.2, because no package surface moved — the gap + # is entirely in how the DEMO PROJECTS locate `@rookery`, so the version + # installed here is now set by the demos' floor rather than the + # packages' own. # # Pinned URL AND pinned digest, for the same auditability reason the # Typst step gives: a re-uploaded asset fails this step rather than # silently changing what CI tested. - - name: Install rheo 0.6.3 (the floor the demo project needs) + - name: Install rheo 0.6.3 (the floor the demo projects need) run: | set -euo pipefail # Digest of the v0.6.3 rheo-x86_64-unknown-linux-gnu.zip asset, published @@ -98,11 +116,10 @@ jobs: # `@rookery` IS A CUSTOM NAMESPACE, NOT A TYPST-UNIVERSE ONE, so there is # no registry to download it from: it resolves only where the package - # cache already holds it. `core/0.1.0/demo/rheo/native.typ` imports - # `@rookery/core:0.1.0` by coordinate and is compiled by BARE `typst`, - # which knows only the cache — so without this the `check-typst` step - # below dies with - # `error: package not found (searched for @rookery/core:0.1.0)`. + # cache already holds it. `search/0.1.0/src/lib.typ` imports + # `@rookery/core:0.1.0` for `ideas` and `note-href`, and `just parity` + # compiles that file through `typst eval` — so without this the next step + # dies with `error: package not found (searched for @rookery/core:0.1.0)`. # # THE WHOLE NAMESPACE, not one package, and this is the same one-line # setup CLAUDE.md prescribes for a developer's machine. Linking a single @@ -111,9 +128,10 @@ jobs: # `ln -sfn TARGET DIR` then writes the link INSIDE it, leaving a # self-referential `/`. # - # The rheo build needs nothing from the cache: `demo/rheo/rheo.toml` - # declares `[packages.rookery] path = "../../../.."` and reads the - # package straight out of this checkout. + # Only `search` needs it by spec. `core`'s own fixture imports + # `/src/lib.typ` and `demo/pure` imports `../../src/lib.typ`, both by + # path — which is why those two steps have always passed while this one + # could not. - name: Resolve the @rookery namespace from this checkout run: | set -euo pipefail @@ -121,7 +139,77 @@ jobs: mkdir -p "$cache" ln -s "$PWD" "$cache/rookery" # Fail HERE with something legible rather than inside a typst error later. - test -f "$cache/rookery/core/0.1.0/typst.toml" + test -f "$cache/rookery/search/0.1.0/typst.toml" + + # `search`, `todos`, `slipshow` and `pinboard` each import + # `@rheo/rehydrate:0.1.0` from their entrypoint, for the `RheoRehydrate` + # global their own scripts re-wire widgets through after a `rheo watch` + # morph. `rheo` resolves the `@rheo` namespace itself, so every rheo demo + # below would fetch this on its own — but the fixtures and parity + # harnesses run under BARE `typst eval`/`typst compile`, which knows only + # the package cache, and `just parity` dies there with + # `package not found (searched for @rheo/rehydrate:0.1.0)`. + # + # Pinned URL AND pinned digest, like the rheo step below: read the digest + # back with `gh release view rehydrate-0.1.0 --repo + # freecomputinglab/rheo-packages --json assets --jq '.assets[0].digest'` + # (it comes back "sha256:"-prefixed, which `sha256sum -c` will not + # accept). The archive unpacks as `typst.toml`, `src/` and `dist/` with no + # wrapping directory, so it extracts straight into the version dir. + - name: Install @rheo/rehydrate 0.1.0 into the package cache + run: | + set -euo pipefail + sha256="87ef8e8a673aee4f24d49cddb59275083293d2f2305c746e60de8b557a18aa38" + curl -fsSL -o rehydrate.tar.gz \ + https://github.com/freecomputinglab/rheo-packages/releases/download/rehydrate-0.1.0/rehydrate-0.1.0.tar.gz + echo "$sha256 rehydrate.tar.gz" | sha256sum -c - + dest="${XDG_CACHE_HOME:-$HOME/.cache}/typst/packages/rheo/rehydrate/0.1.0" + mkdir -p "$dest" + tar -xzf rehydrate.tar.gz -C "$dest" + test -f "$dest/typst.toml" + + # AFTER the namespace-symlink step: @rookery/timeline binds `#dated-idea` + # from `@rookery/core:0.1.0` — one line, and enough to need the cache. + # Still buildless, like core itself, so no vite step. + # + # `just test` here runs TWO fixtures: `test/units.typ` asserts values, and + # `test/view.typ` + `test/check.sh` assert the markup `#timeline-view` + # produces, which a paged compile cannot see. + - name: timeline unit and view fixtures + run: cd timeline/0.1.0 && just test + + # AFTER the namespace-symlink step, like every step that resolves a + # package by coordinate: @rookery/meetings imports BOTH + # `@rookery/core:0.1.0` and `@rookery/timeline:0.1.0`. Buildless like + # those two, so no vite step. + # + # `just test` here runs TWO fixtures: `test/units.typ` asserts the values + # `#meeting` derives — the `occurred` log entry, the `created` date `on:` + # sets, the synthesized title — and `test/view.typ` plus `test/check.sh` + # assert the markup, including that the record and the rail come ABOVE the + # note's prose, which is document order and so invisible to the value + # fixture. + - name: meetings unit and view fixtures + run: cd meetings/0.1.0 && just test + + # bibtex is buildless like meetings — pure Typst, no vite — and imports + # `@rookery/core:0.1.0` for the notes its `#citation` constructor builds, + # so it sits after the namespace-resolution step above. Its recipe compiles + # two PDF fixtures and four HTML ones, echoing each one's stderr: the sweep + # fixtures assert on the WARNINGS a sweep emits, so the output is the test. + - name: bibtex unit and sweep fixtures + run: cd bibtex/0.1.0 && just test + + # `just test` and `just parity` answer different questions: `just test` + # is this package's own node suite (the panels, the island, the URL + # state, the selection model, the row builder, the note extractor), and + # `just parity` is the only thing keeping the Typst and JavaScript copies + # of the ranking rule from drifting — two leaf scorers and the tiering + # rule above them. Both are needed. `just build` first: both later + # recipes import `src/`, but a build failure is worth catching here too, + # since publish-packages.yml only builds on main. + - name: search build, unit suite and parity + run: cd search/0.1.0 && just build && just test && just parity # This repo's definition of "lint" is that a package's demo compiles # (CLAUDE.md, "Build"). `demo/pure` is plain `typst compile`, so it runs @@ -140,12 +228,103 @@ jobs: # package-`.marrow.typ` still mints: `check.sh` asserts on the OUTPUT — # minted note pages, backlinks, depth-relative hrefs, and the generated # `@layer rookery-tags` rules — none of which `demo/pure` can reach. + # + # AFTER the namespace-symlink step above, not before: the demo's + # `.marrow.typ` needs `@rookery/core:0.1.0` to resolve from the cache. - name: core rheo demo compiles and asserts, under the declared floor run: cd core/0.1.0/demo/rheo && just check # The same content compiled a SECOND way, plain `typst compile`, no # rheo — one document, one compile pass, no minted pages — and asserted # by `check-native.sh`, not merely compiled. AFTER the namespace-symlink - # step, because the content imports `@rookery/core:0.1.0` by coordinate. + # step, for the same reason as the rheo build above: the content + # imports `@rookery/core:0.1.0` by coordinate. - name: core rheo demo's rookery compiles and asserts without rheo too run: cd core/0.1.0/demo/rheo && just check-typst + + # search's own rheo fixture. Its `check.sh` asserts what neither the + # `node --test` suite nor the parity harness can see: that rheo copied + # `dist/lib.js` and `dist/search.css` into the output, that BOTH are + # linked from every page at the right depth-relative prefix + # (`rookery/...` at the root, `../rookery/...` one level down), that the + # JSON island parses with one row per note, and that every href in it + # resolves to a file on disk. + # + # AFTER the namespace-symlink step, like core's own — and after the + # `search build and parity` step above, because `dist/` is gitignored and + # rheo cannot resolve the package until vite has written it. + - name: search rheo demo compiles and asserts + run: cd search/0.1.0/demo/rheo && just check + + # @rookery/todos needs three things in this order, which is why it sits + # here rather than beside the unit fixtures above: + # - it is a BUILT package (its release ships `dist/lib.js`, gitignored), + # so vite has to run before rheo can resolve the release path; + # - it hard-imports BOTH `@rookery/core:0.1.0` and + # `@rookery/timeline:0.1.0`, so it must follow the namespace symlink; + # - `just test-js` covers the graph layout, which is the half of this + # package the Typst fixture cannot reach. + - name: todos build, unit fixture and graph tests + run: cd todos/0.1.0 && just build && just test && just test-js + + # `just check` builds the demo and then asserts on its OUTPUT. The one + # assertion it exists for is the `.todo-search-row[hidden]` CSS rule: + # without it `#todos-search` sets `hidden` on every non-matching row and + # the stylesheet un-hides all of them, so the filter reorders the list and + # removes nothing. That compiles clean, passes the unit and JS suites, and + # looks correct in the markup — it is only wrong on screen. + - name: todos rheo demo compiles and asserts + run: cd todos/0.1.0 && just check + + # AFTER the namespace-symlink step, like every rheo demo above: this + # package imports `@rookery/core:0.1.0` by coordinate. AFTER its own + # `just build`, because `dist/` is gitignored and rheo cannot resolve + # the package until vite has written it. + - name: slipshow build, unit fixtures and rheo demo + run: cd slipshow/0.1.0 && just build && just test && just test-js && just check + + # AFTER the namespace-symlink step, like every rheo demo above: these + # example projects import `@rookery/core:0.1.0` by coordinate. AFTER + # slipshow's own `just build` above, because `dist/` is gitignored and + # rheo cannot resolve the package until vite has written it. + - name: slipshow examples compile + run: cd slipshow/0.1.0 && just examples + + # The ninth package, on the same footing as the four above: `just check` + # builds `dist/` and then the demo whose built HTML `test/browser/board.mjs` + # asserts against, so the browser step below has a fixture to serve. + - name: pinboard build, unit suite and rheo demo + run: cd pinboard/0.1.0 && just build && just test-js && just check + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-1.63.0-${{ runner.os }} + + # Real-engine tests across WebKit (the Safari engine) and Chromium. LAST + # in the job, because every suite asserts against a package's BUILT demo + # and the demos are built by the steps above. + # + # GECKO RUNS LOCALLY, NOT HERE, which `ROOKERY_BROWSER_ENGINES` is for — + # see the harness's own comment on it for the evidence. Its synthetic + # pointer input is unreliable on a runner: `pinboard-board`'s drag moved + # 55px of 60 on one run and 0 on the next, with both other engines green + # each time. Only the engine list narrows; every suite and every + # assertion still runs. + # + # So only two browsers are installed. `--with-deps` is what pulls the + # system libraries each one needs. + # + # No nix here, unlike the local path: the root devShell hands the harness + # `PLAYWRIGHT_CORE` and `PLAYWRIGHT_BROWSERS_PATH` from nixpkgs, and + # `loadPlaywright` falls back to a plain node_modules resolution when + # neither is set. Installing nix on the runner to reproduce that would cost + # minutes a run to buy nothing. + - name: Browser suites across WebKit and Chromium + env: + ROOKERY_BROWSER_ENGINES: webkit,chromium + run: | + npm install --no-save playwright-core@1.63.0 + npx --yes playwright@1.63.0 install --with-deps webkit chromium + just browser diff --git a/bibtex/0.1.0/.gitignore b/bibtex/0.1.0/.gitignore new file mode 100644 index 00000000..c640daab --- /dev/null +++ b/bibtex/0.1.0/.gitignore @@ -0,0 +1,3 @@ +.direnv/ +build +*.pdf diff --git a/bibtex/0.1.0/Justfile b/bibtex/0.1.0/Justfile new file mode 100644 index 00000000..f7e0bd15 --- /dev/null +++ b/bibtex/0.1.0/Justfile @@ -0,0 +1,55 @@ +default: + @echo "rookery-bibtex: pure Typst package, entrypoint is src/lib.typ directly — nothing to build" + +# Unit fixture for the parser, the title/key derivations and the field view, +# plus the rendered `all()` sweep fixture. No runner for the unit half: +# `assert.eq` inside `test/units.typ` fails the compile with a line number, +# and a passing compile is the green light — the same shape `@rookery/core` +# and `@rookery/timeline` use. The rendered half (`test/sweep.typ`) compiles +# to HTML and `test/check.sh` greps what came out. +# +# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS +# package. `--features html` for parity with this repo's other Justfiles. +# `--format pdf` with a `/dev/null` output for `units.typ` because typst +# cannot infer a format from that path, and nothing there is rendered — only +# asserted. +# +# `sweep*.typ`'s stderr is captured and grepped for `did not converge`: a +# sweep that calls the CLAIMING `citation` from inside its own loop reads its +# own write in the same pass, and Typst's state resolution oscillates and +# gives up with exactly that warning while still exiting 0 — a silently +# incomplete bibliography on a build that only warns. `all()` is built to +# avoid it (see `src/claim.typ`), and `keywords: "existing"` reads the tag +# registry mid-sweep for the same reason (see `src/lib.typ`) — this is what +# would catch a regression in either: `typst compile` alone would not, since +# the warning does not fail the build. +# +# `sweep-all.typ`/`sweep-existing.typ` are separate documents, not two calls +# in `sweep.typ`, because a second `all()` call is a compile error — each +# needs its own `bibtex(..)` and its own vertebra. +# +# `test/large.typ` parses a field past Typst's 10,000-iteration `while` +# ceiling and a 400-entry corpus, so a parser that regresses to stepping +# character by character fails the compile rather than passing unnoticed. +test: + typst compile --features html --root . --format pdf test/units.typ /dev/null + @echo "units OK" + typst compile --features html --root . --format pdf test/large.typ /dev/null + @echo "large OK" + # `mkdir` first: unlike `rheo`, `typst compile` does not create its output + # directory and fails with "No such file or directory" on a fresh checkout, + # since `test/build/` is gitignored and never committed. + mkdir -p test/build + typst compile --features html --format html --root . test/sweep.typ test/build/sweep.html 2>test/build/sweep.stderr + cat test/build/sweep.stderr >&2 + ! grep -q "did not converge" test/build/sweep.stderr + typst compile --features html --format html --root . test/sweep-existing.typ test/build/sweep-existing.html 2>test/build/sweep-existing.stderr + cat test/build/sweep-existing.stderr >&2 + ! grep -q "did not converge" test/build/sweep-existing.stderr + typst compile --features html --format html --root . test/sweep-all.typ test/build/sweep-all.html 2>test/build/sweep-all.stderr + cat test/build/sweep-all.stderr >&2 + ! grep -q "did not converge" test/build/sweep-all.stderr + typst compile --features html --format html --root . test/fields.typ test/build/fields.html 2>test/build/fields.stderr + cat test/build/fields.stderr >&2 + ! grep -q "did not converge" test/build/fields.stderr + ./test/check.sh diff --git a/bibtex/0.1.0/demo/rheo/Justfile b/bibtex/0.1.0/demo/rheo/Justfile new file mode 100644 index 00000000..3c795ce5 --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/Justfile @@ -0,0 +1,10 @@ +# rheo is NOT in this repo's devShell — locally it is the sibling `rheo/` +# crate, same as `@rookery/core`'s own demo (see that Justfile's header). +build: + rheo compile . + +watch: + rheo watch . --html --open + +clean: + rm -rf build diff --git a/bibtex/0.1.0/demo/rheo/content/entry.typ b/bibtex/0.1.0/demo/rheo/content/entry.typ new file mode 100644 index 00000000..bf20d475 --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/content/entry.typ @@ -0,0 +1,16 @@ +// The hand-written half of the bibliography: this note's body is authored, +// not swept — `index.typ`'s `all()` mints every OTHER entry with the empty +// body a swept note always gets. A hand-written `#citation` for a key always +// wins over `all()`, no matter where the two calls sit relative to each +// other. +#import "lib.typ": demo, refs + +#show: demo + += A cited entry + +#(refs.citation)("okafor2019")[ + Cited directly, because its accounting of responsiveness as a budget spent + reframes what the rest of this bibliography treats as measured only after + the fact. +] diff --git a/bibtex/0.1.0/demo/rheo/content/index.typ b/bibtex/0.1.0/demo/rheo/content/index.typ new file mode 100644 index 00000000..08882b6e --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/content/index.typ @@ -0,0 +1,11 @@ +// The rest of the bibliography, swept in one call: `all()` mints a note for +// every entry `entry.typ`'s hand-written `#citation` has not already +// claimed — see `@rookery/bibtex`'s readme, "all() — minting the rest of the +// bibliography". +#import "lib.typ": demo, refs + +#show: demo + += References + +#(refs.all)() diff --git a/bibtex/0.1.0/demo/rheo/content/lib.typ b/bibtex/0.1.0/demo/rheo/content/lib.typ new file mode 100644 index 00000000..802f9ce9 --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/content/lib.typ @@ -0,0 +1,30 @@ +// The one place the demo is configured, applied by both vertebrae — same +// reason `@rookery/core`'s own demo does this (see its `content/lib.typ`): +// `#show: rookery` is per-FILE, so a project that wants one configuration +// wraps it once here and every vertebra applies the wrapper. +#import "@rookery/core:0.1.0": rookery +#import "@rookery/bibtex:0.1.0": bibtex + +#let refs = bibtex(read("../references.bib")) + +// Appended after every minted page's OWN body — `entry.typ`'s hand-written +// citation as much as every entry `index.typ`'s `all()` sweeps — so a reader +// lands on the bibliographic record regardless of which path minted the +// page. A NAMED TOP-LEVEL FUNCTION, deliberately: `idea-page-template` is +// read back from document-wide state, so an inline closure built inside +// `demo` below would be a different value per vertebra and whichever file +// happened to compile last would win. +// `id` arrives prefixed (`idea:`, the default `prefix:` `#show: rookery` +// publishes) — the bare BibTeX key, which is what `refs.fields` looks up, is +// everything after the first `:`. +#let citation-page(id: none, note: (:), doc) = { + doc + if id != none { + (refs.fields)(id.split(":").last()) + } +} + +#let demo(doc) = { + show: rookery.with(idea-page-template: citation-page) + doc +} diff --git a/bibtex/0.1.0/demo/rheo/references.bib b/bibtex/0.1.0/demo/rheo/references.bib new file mode 100644 index 00000000..32993cf3 --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/references.bib @@ -0,0 +1,38 @@ +@book{harrow2014, + title = {Assembling the Archive: Notes Toward a Practice}, + shorttitle = {Assembling the Archive}, + author = {Harrow, Elena}, + year = {2014}, + publisher = {Tidewater Press}, +} + +@article{okafor2019, + title = {Latency Budgets for Interactive Systems}, + author = {Okafor, Chidi}, + journal = {Journal of Systems Research}, + volume = {12}, + pages = {201--227}, + year = {2019}, + doi = {10.5555/jsr.2019.0012}, + abstract = {Argues that a system's perceived responsiveness is a budget spent, not a property measured after the fact, and proposes a way to account for it at design time.}, +} + +@inproceedings{singh2021, + title = {Incremental Computation for Editable Documents}, + author = {Singh, Priya and Novak, Tomas and Reyes, Marisol}, + booktitle = {Proceedings of the Workshop on Live Systems}, + year = {2021}, +} + +@book{fenwick2017, + title = {Field Notes on Distributed Consensus}, + editor = {Fenwick, Dorothy}, + year = {2017}, + publisher = {Causeway Editions}, +} + +@misc{fielding2022, + title = {Notes on a Small Protocol}, + author = {Fielding, Sam}, + year = {2022}, +} diff --git a/bibtex/0.1.0/demo/rheo/rheo.toml b/bibtex/0.1.0/demo/rheo/rheo.toml new file mode 100644 index 00000000..5631f2f2 --- /dev/null +++ b/bibtex/0.1.0/demo/rheo/rheo.toml @@ -0,0 +1,20 @@ +# @rookery/bibtex's in-repo demo: a small rookery whose notes come from +# `references.bib` rather than hand-authored prose — `content/index.typ` +# sweeps the whole bibliography with `all()`, `content/entry.typ` claims one +# entry by hand with `#citation` to show the override `all()` respects. +version = "0.6.2" +content_dir = "content" +formats = ["html"] + +# `lib.typ` is a library, not a page: it holds the shared `#show: rookery` +# wrapper both vertebrae apply. Without this it would compile to its own +# `lib.html`. +[spine] +exclude = ["lib.typ"] + +# The Typst cache's `rookery` namespace is a per-machine symlink that can +# point at a different checkout of this repo, so this reads `@rookery/*` out +# of this tree instead. See `slipshow/0.1.0/demo/rheo/rheo.toml` for the full +# argument. +[packages.rookery] +path = "../../../.." diff --git a/bibtex/0.1.0/readme.md b/bibtex/0.1.0/readme.md new file mode 100644 index 00000000..2cc0aa70 --- /dev/null +++ b/bibtex/0.1.0/readme.md @@ -0,0 +1,234 @@ +# @rookery/bibtex + +A BibTeX reader and a `#citation` note constructor for +[`@rookery/core`](../../core/0.1.0) notes — parse a `.bib` file once, then mint +one note per reference, titled and keyed from the entry itself. + +```typst +#import "@rookery/core:0.1.0": idea +#import "@rookery/bibtex:0.1.0": bibtex + +#let refs = bibtex(read("refs.bib")) + +#refs.citation("badiou2002")[ + #refs.fields("badiou2002") +] +``` + +`badiou2002` is the BibTeX key. `#refs.citation(..)` mints a note titled +`Badiou, *Ethics* (2002)` — derived from the entry, not typed by hand — tagged +`citation` by default; `#refs.fields(..)` renders every field the entry +carries as an HTML definition list, for a body that just wants the record +laid out. + +## `bibtex(src, mint:, tag:, keywords:, show-fields:, only:)` + +`src` is a `.bib` file's contents, or an array of them — several exports read +as one bibliography, joined with a newline between members so a file ending +mid-token cannot fuse into the next file's first token: + +```typst +#let refs = bibtex((read("primary.bib"), read("secondary.bib"))) +``` + +`only:` restricts parsing to a handful of keys — a reference manager export +with thousands of entries costs only what a project actually cites: + +```typst +#let refs = bibtex(read("references.bib"), only: ("badiou2002", "smith2020")) +``` + +`auto` (the default) parses the whole file, exactly as `bibtex` behaved +before this parameter existed. A key `only` names that isn't in `src` is +dropped silently rather than raised as an error here — `entry(key)` (and +`citation`/`fields` through it) is where a missing key becomes an error, +at the point something actually asks for it. + +The return value is a dictionary of five functions, all closed over the +parsed bibliography: + +| | | +| --- | --- | +| `bib` | the parsed dictionary itself, `key -> (field: value, ..)`, every value a plain string | +| `entry(key)` | that entry, asserting the key exists rather than handing back `none` | +| `fields(key, show-fields: auto)` | that entry's fields, as the `
` `fields-block` builds; `show-fields` falls back to the factory's own `show-fields:` when omitted | +| `citation(key, title: auto, tags: none, display-tags: true, ..)` | a note titled from the entry (`title:` overrides it) and tagged `tag:` (`"citation"` by default) alongside whatever `tags:` you pass | +| `all()` | mints a note for every entry not already claimed by a hand-written `citation` call | + +`citation`'s `key` accepts the form you actually write: `@badiou2002` (a +Typst `ref`, caught by Typst's own reference checking if the key is wrong), a +bare label, or a string computed at build time. + +A dictionary field holding a function cannot be called with `#refs.citation(..)` +under Typst 0.15.1 — `cannot directly call dictionary keys as functions` — so +call through parenthesized field access instead: `#(refs.citation)(..)`, +`#(refs.all)()`. + +## `all()` — minting the rest of the bibliography + +A bibliography is a list of things worth a note. `citation(..)` mints one +where you've written it by hand; `all()` mints the REST — every key `bib` +carries that no `citation` call has claimed, in the bibliography's own +alphabetical key order: + +```typst +#let refs = bibtex(read("refs.bib")) + +#refs.citation()[The one you want to say something about.] +#(refs.all)() +``` + +`etal2002` keeps its hand-written body; every other entry mints with an empty +body, titled from the entry the same way `citation` derives its own title. +**Call it once, from one vertebra** — a second call is a compile error +(`all() mints the whole bibliography and must be called once, from one +vertebra`), because it mints the whole bibliography and a second pass would +either double-register every key or silently do nothing, neither of which is +useful. A hand-written `citation` for a key always wins: `all()` never mints +over one, no matter where in the document the two calls sit relative to each +other. + +`all()` sweeps the entries the factory knows — which is `bib`, not the whole +`.bib` file, when `only:` narrowed it. This is how `only:` turns a large +library into a small number of notes: sweep a four-key `bib` with `all()` +and four notes mint, not fourteen hundred. + +`mint:` defaults to `@rookery/core`'s own `idea`, which is what you want on +plain rookery. **A project on `@rookery/timeline` or `@rookery/todos` should +pass THAT package's own constructor instead** — the version decorated with +its date or todo arguments — because a citation minted through core's +undecorated one would not carry them: + +```typst +#import "@rookery/timeline:0.1.0": idea +#let refs = bibtex(read("refs.bib"), mint: idea) +``` + +`parse-bib`, `bib-chunks`, `parse-entry`, `bib-title`, `cite-key`, +`fields-block` and `keyword-tags` are re-exported from the entrypoint too, +for a consumer that wants the parts directly rather than only through the +factory. + +## `keywords:` — a Zotero export's keywords as rookery tags + +A Better BibTeX export carries `keywords = {..}` — the Zotero tags on the +record. By default that field renders as an ordinary row in the citation +block and nothing else; `keywords:` also turns it into real rookery tags, so +a citation is reachable through the same tag views as every other note: + +```typst +#let refs = bibtex(read("refs.bib"), keywords: "all") +``` + +Three values, `none` (the default — no consuming project changes behaviour +on upgrade): + +| | | +| --- | --- | +| `none` | (default) the `keywords` field is not turned into tags at all | +| `"all"` | every keyword becomes a tag, whether or not the rookery already has it | +| `"existing"` | only a keyword that already matches a tag SOMEWHERE ELSE in the rookery becomes one; the rest are ignored | + +A keyword is slugified before it is compared or minted — trimmed, lowercased, +every run of non-alphanumeric characters collapsed to one hyphen, leading and +trailing hyphens stripped — so `Digital Humanities` becomes the tag +`digital-humanities`, and `"existing"` matches against that slug (the tags +already in a rookery are themselves slugs). A keyword that slugifies to the +empty string is dropped. `keywords` may hold several, split on both `,` and +`;` since Better BibTeX emits either depending on export settings. + +Keyword tags merge with whatever `tags:` a `citation`/`all()` call already +carries, and with the package's own `tag` (`"citation"` by default) — a +caller's explicit tag always wins on a key collision. The `keywords` row +itself stays in the citation block regardless: it is bibliographic data, and +the tags are an addition to it, not a replacement. + +`"existing"` reads the rookery's own tag registry, which is why it can only +run where `#context` is available — `all()` already runs inside one; +`citation` opens one of its own for this mode specifically, rather than for +every mode. + +## `show-fields:` — hiding fields from the citation block + +`fields-block` (and `fields(..)` through it) renders every field an entry +carries. Some of that is noise on a published page — `urldate` is when the +record was last touched in Zotero, and a `doi` is redundant beside a `url` — +so `show-fields` says which fields to leave out: + +```typst +#let refs = bibtex(read("refs.bib"), show-fields: ("urldate": false, "doi": false)) +``` + +or on a single call, without touching the factory default: + +```typst +#refs.fields("badiou2002", show-fields: ("urldate": false)) +``` + +It's a dictionary mapping a field name to a boolean, and its shape is what +makes it pleasant to write: + +| | | +| --- | --- | +| omitted entirely | (default) every field is shown — `show-fields: (:)` and no argument at all behave identically, so upgrading to this doesn't change any existing project | +| a partial dictionary | hides only what it names `false`; a field it doesn't mention is shown — the dictionary is a list of exceptions, not a whitelist, so you name the two fields you don't want rather than the twenty you do | +| `true` | shown, same as absent — worth accepting so a project can flip a field back on and keep the line as a record of the decision, instead of deleting it | + +`"entry-type"` is a valid key here too, even though it names no real BibTeX +field — it's the parser's own synthesized key behind the `Type` row, and +`("entry-type": false)` hides that row like any other. + +Only the *values* are validated (each must be a boolean); the keys are not +checked against any known field list, because the block deliberately renders +any field a `.bib` carries, including ones this package has never heard of — +so a misspelt key (`"urldata"` for `"urldate"`) is legitimate syntax that +silently hides nothing. + +Hiding every field an entry carries makes `fields-block` emit nothing at all — +no label, no empty `
`. + +`show-fields:` pairs well with `keywords:` above: tag from `keywords` while +hiding the `keywords` row itself, so the citation block shows the tags rather +than the raw comma-separated field they came from: + +```typst +#let refs = bibtex(read("refs.bib"), keywords: "all", show-fields: ("keywords": false)) +``` + +## What the parser does not handle + +A hand-rolled scanner over `@type{key, field = {..} | "..." | bare}`, with +nested braces and `{{Protected Words}}` unwrapped to the words themselves — +BibTeX uses an interior brace to protect capitalization from a citation +style, not to say anything about the text. It does not: + +- expand `@string` macros; +- resolve `#` string concatenation; +- translate LaTeX escapes (`\&`, `{\'e}`, …) — they come through as literal + text. + +Export from your reference manager with macros expanded and Unicode rather +than LaTeX escapes (Zotero and most others do this by default) and the parser +sees exactly what you'd expect. + +## Requirements + +No build step and no JavaScript: `typst.toml`'s `entrypoint` points straight +at `src/`, so an edit takes effect immediately. `citation(..)` calls into +`@rookery/core` 0.1.0 for `idea`; nothing else here imports it. + +## Development + +```sh +cd bibtex/0.1.0 +just test +rheo compile demo/rheo +``` + +`demo/rheo` is a small rookery whose notes come straight from a `.bib` file: +`demo/rheo/references.bib` carries five entries — a book with a `shorttitle`, +an article with a `doi`, `journal`, `volume`, `pages` and an `abstract`, an +entry with three authors, and one with an `editor` and no `author` — and +`demo/rheo/content/index.typ` sweeps all but one of them with `all()`, while +`demo/rheo/content/entry.typ` claims the remaining one by hand with +`#citation` to show the override `all()` respects. diff --git a/bibtex/0.1.0/src/bibtex.css b/bibtex/0.1.0/src/bibtex.css new file mode 100644 index 00000000..7ee138d4 --- /dev/null +++ b/bibtex/0.1.0/src/bibtex.css @@ -0,0 +1,111 @@ +/* rookery-bibtex — the citation block `#fields-block` draws: a label, then one + row per bibliographic field. + + Thin on purpose, like the rest of this family: enough that an entry reads as a + table out of the box, and nothing that presumes a page design. No fonts, no page + colours, and no absolute font size — every size here is a factor of whatever the + page already sets, so the block takes the surrounding type. + + THE LAYER, and it is not optional. rheo links a PACKAGE's stylesheet AFTER the + project's own, so on equal specificity this file would win every tie and a project + could not fix it by writing its rule "later" — there is no later. Wrapping + everything in a cascade layer inverts that: any UNLAYERED rule in the project's CSS + beats any layered rule here, whatever its specificity or position. A plain + `.citation-fields dt { color: red }` in a site's stylesheet just works. That is the + guarantee, and it is why nothing in this file sits outside the layer. + + THE PROPERTIES. Every colour and size is `var(--x, )`, the default being + the literal in the var() call. Set one on `.citation-fields` (or anywhere it + inherits from) and the block is themed without overriding a rule at all: + + --citation-fg a field's value + --citation-muted the label above the block, and a field's name + --citation-line the rules between fields + --citation-gap space between a field's text and the rule under it + --citation-gutter width of the name column + + THE GUTTER MATCHES @rookery/timeline'S RAIL, 7.5em, and the match is the point: a + note page that draws both puts this block under that one, and two adjacent tables + whose columns start in different places read as two conventions rather than one + page. A project moving one should move the other — hence the default here reads + `--timeline-gutter` first, so setting that single property lines both up. */ +@layer bibtex { + /* A LABEL, NOT A HEADING, and `#fields-block` emits a `
` for it precisely so + it claims no place in the page's outline above the note's own headings. Styled as + one, so it does not read as one — the same treatment @rookery/timeline gives + `.upcoming-title`. */ + .citation-fields-head { + margin: 1.2rem 0 0; + color: var(--citation-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* TWO COLUMNS: the field's name in the gutter, its value to the right. A grid on + the `
` itself, with each `
`/`
` auto-placed as its own item — so a + value that wraps to three lines pushes the next row down instead of drifting out + of column. + + A GRID RATHER THAN A FLEX LINE, for the reason measured across this family: a + flex item's basis is only a HYPOTHETICAL size, so a long field name would push + its value and no two rows would agree on where the value starts. + + `align-items: baseline` sits a name on the first line of its value, which is what + keeps a one-word name level with the opening line of an abstract. */ + .citation-fields { + display: grid; + grid-template-columns: var(--citation-gutter, var(--timeline-gutter, 7.5em)) 1fr; + column-gap: 0.9rem; + margin: 0.6rem 0 0; + border-top: 1px solid var(--citation-line, var(--timeline-line, currentColor)); + } + + /* THE RULES BETWEEN FIELDS, one per row, drawn on BOTH cells of the row so the two + segments abut into a single line across the block. Horizontal only: there is no + rule down the gutter, because the field names are a label column rather than a + second column of data. + ONE ROW'S TWO CELLS MUST END AT THE SAME HEIGHT for those segments to meet, and + that is what forbids `align-items: baseline` here. Baseline alignment sizes each + cell to its own content, so a one-word name beside a paragraph-long abstract + draws its rule at the top of the row while the abstract draws it at the bottom — + one row, two broken lines at different heights. The default `stretch` gives both + cells the row's full height instead, which is also why the gap between rows is + `padding` on the cells rather than `row-gap` on the grid: a gap would fall + BELOW each rule, leaving the line crammed against the text above it. + A name still sits level with the first line of its value, since both cells now + start at the row's top edge. */ + .citation-fields dt, + .citation-fields dd { + padding: var(--citation-gap, 0.4rem) 0; + border-bottom: 1px solid var(--citation-line, var(--timeline-line, currentColor)); + } + + /* THE FIELD NAME, muted and uppercased — a label, matching the stage names on + @rookery/timeline's rail so the two blocks read as one page. */ + .citation-fields dt { + color: var(--citation-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* THE VALUE. `margin: 0` is load-bearing rather than tidy: a browser's default + `
` carries `margin-inline-start: 40px`, which in a grid cell indents every + value away from its own column. */ + .citation-fields dd { + margin: 0; + color: var(--citation-fg, inherit); + font-size: 0.9em; + } + + /* NUMBERS AN EVEN WIDTH, so a year, a volume and a page range line up down the + column rather than shifting with each digit — the same reason + @rookery/timeline sets it on a date. `overflow-wrap` is for the two fields that + can carry an unbroken string longer than the column: a DOI and a URL, which + would otherwise widen the grid and push the page sideways. */ + .citation-fields dd { + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + } +} diff --git a/bibtex/0.1.0/src/claim.typ b/bibtex/0.1.0/src/claim.typ new file mode 100644 index 00000000..0d74562a --- /dev/null +++ b/bibtex/0.1.0/src/claim.typ @@ -0,0 +1,13 @@ +// The two states behind `bibtex(..)`'s split between minting and claiming. +// +// `_claimed` cannot be a read of core's own note registry: `ideas()` reads +// `_registry.final()`, and `all()` (lib.typ) FEEDS that registry by minting +// notes into it — asking the registry "is this key taken" from inside the +// sweep that writes it is circular, and Typst's own state resolution refuses +// to converge on a loop like that. A separate state breaks the cycle: `all()` +// only ever READS `_claimed`, and `citation` (lib.typ) is the only writer. +// +// `_swept` guards `all()` against a second call — see `all()`'s own comment +// for why it must be read with `.get()`, never `.final()`. +#let _claimed = state("rookery-bibtex-claimed", (:)) +#let _swept = state("rookery-bibtex-swept", 0) diff --git a/bibtex/0.1.0/src/format.typ b/bibtex/0.1.0/src/format.typ new file mode 100644 index 00000000..0cae3c6c --- /dev/null +++ b/bibtex/0.1.0/src/format.typ @@ -0,0 +1,65 @@ +// Deriving a citation note's title from a parsed BibTeX entry, and reading +// back the key a `#citation` call was written with. + +// The BibTeX key a `#citation` was written with. `@key` is the form to write — +// Typst parses it as a `ref`, so a key that is not in the bibliography is caught +// by Typst's own checking as well as by the missing-key assert once the +// bibliography is consulted — with a bare label or a string taken too, for a +// call that computes its key. +#let cite-key(r) = { + if type(r) == str { return r } + if type(r) == label { return str(r) } + if type(r) == content and r.func() == ref { return str(r.target) } + panic("citation: expected `@key`, a label or a string, got " + repr(r)) +} + +// Surnames, in the order the entry lists them. BibTeX writes an author list as +// `Last, First and Last, First`, but a Zotero export can also emit `First Last` +// where the record has no split name — hence the two branches. The unsplit one +// takes the LAST WORD, so `Ursula Le Guin` reads as `Guin`: a particle is only +// knowable from the comma form, which is what a record with a split name gives. +#let _surnames(names) = { + if names == none { return () } + names + .split(" and ") + .map(a => { + let a = a.trim() + if a.contains(",") { a.split(",").first().trim() } else { a.split(" ").last() } + }) + .filter(s => s != "") +} + +// Two names read as a pair, three or more as the first plus `et al.` — the same cut +// a reader's eye makes, and the note's title is a shelf label rather than a +// reference. +#let _byline(names) = { + if names.len() == 0 { + none + } else if names.len() == 1 { + names.first() + } else if names.len() == 2 { + names.join(" and ") + } else { + names.first() + " et al." + } +} + +// What a citation note is CALLED: `Badiou, Ethics (2002)`. +// +// `shorttitle` FIRST, which is why Better BibTeX's own field is worth having: the +// full title of the Badiou is `Ethics: An Essay on the Understanding of Evil`, and +// a rookery row, a browser tab and an `@idea:` link all want the two syllables the +// book is known by. The full title is the fallback, not the other way round. +// +// `editor` stands in for a missing `author` — an edited collection is cited by the +// people who made it — and every part is optional: an entry with nothing but a +// title yields the title, and one with no title at all yields `none`, which is +// rookery's own "this note has no authored title" and leaves it to derive a label +// from the body. +#let bib-title(entry) = { + let work = entry.at("shorttitle", default: entry.at("title", default: none)) + if work == none { return none } + let by = _byline(_surnames(entry.at("author", default: entry.at("editor", default: none)))) + let year = entry.at("year", default: none) + [#if by != none [#by, ]#emph(work)#if year != none [ (#year)]] +} diff --git a/bibtex/0.1.0/src/keywords.typ b/bibtex/0.1.0/src/keywords.typ new file mode 100644 index 00000000..b4123a0b --- /dev/null +++ b/bibtex/0.1.0/src/keywords.typ @@ -0,0 +1,31 @@ +// A BibTeX `keywords` field, split into rookery tag slugs. +// +// BibTeX has no repeated fields, so `keywords = {ethics, ontology, badiou}` +// parses to one string (`parse-bib`, `parse.typ`); Better BibTeX exports use +// either `,` or `;` as the separator depending on export settings, so both +// are accepted. + +#let _NON-ALNUM = regex("[^a-z0-9]+") +#let _EDGE-HYPHENS = regex("^-+|-+$") +#let _KEYWORD-SEP = regex("[,;]") + +// Trimmed, lowercased, every run of non-alphanumeric characters collapsed to +// one hyphen, leading/trailing hyphens stripped. A tag with a space silently +// breaks its CSS class downstream — `idea-tag-`'s class attribute is +// built by joining tag names with a space, so an untrimmed `digital +// humanities` becomes TWO classes, `idea-tag-digital` and a stray global +// `humanities` — so every keyword goes through this before it becomes a tag. +#let _slugify(s) = { + let s = lower(s.trim()) + let s = s.replace(_NON-ALNUM, "-") + s.replace(_EDGE-HYPHENS, "") +} + +// The raw `keywords` field value (a string, or `none` for an entry that +// carries no such field) as an array of slugs, empty parts dropped — a +// keyword that slugifies to the empty string (punctuation only) contributes +// nothing. +#let keyword-tags(raw) = { + if raw == none { return () } + raw.split(_KEYWORD-SEP).map(_slugify).filter(s => s != "") +} diff --git a/bibtex/0.1.0/src/lib.typ b/bibtex/0.1.0/src/lib.typ new file mode 100644 index 00000000..d86cf18d --- /dev/null +++ b/bibtex/0.1.0/src/lib.typ @@ -0,0 +1,196 @@ +// @rookery/bibtex — a BibTeX reader and a `#citation` note constructor for +// @rookery/core notes. +// +// `bibtex(src, mint:, tag:, keywords:, show-fields:, only:)` parses one +// or more `.bib` sources once and closes over the result, returning: +// +// bib: the parsed dictionary, `key -> (field: value, ..)` +// entry: key -> that entry, asserting the key exists +// fields: (key, show-fields: auto) -> its fields, as the HTML `
` +// `fields-block` builds; `show-fields` falls back to the +// factory's own when omitted +// citation: (key, title: auto, tags: none, display-tags: true, ..) -> a note, +// titled from the entry unless `title:` overrides it +// all: () -> mints every entry NOT already claimed by a hand-written +// `citation` call, once per document (see `claim.typ`) +// +// `src` may be a single string or an array of strings — several `.bib` exports +// read as one bibliography, joined with a newline between members so a file +// ending mid-token cannot fuse into the next file's first token. +// +// `parse-bib`, `bib-chunks`, `parse-entry`, `bib-title`, `cite-key`, +// `fields-block` and `keyword-tags` are re-exported so a consumer can reach +// the parts directly rather than only through the factory. + +// `_norm-tags` IS ONE OF CORE'S PRIVATE NAMES, imported deliberately: merging keyword +// tags into the caller's own needs both sides to be dictionaries, and `tags:` accepts a +// string, an array or a dictionary. Core has no public equivalent, and a local copy of +// its normalisation would drift silently the day core accepts a fifth shape — where +// this import breaks loudly, at compile time, if the name ever moves. +#import "@rookery/core:0.1.0": idea as _core-idea, tag-data, _merge-base-tags, _norm-tags +#import "parse.typ": * +#import "format.typ": * +#import "view.typ": * +#import "claim.typ": * +#import "keywords.typ": * + +#let _KEYWORDS-MODES = (none, "all", "existing") + +// `mint:` defaults to core's own `idea`, which covers a project on plain +// rookery. IT STAYS A PARAMETER because a project on `@rookery/timeline` or +// `@rookery/todos` mints its notes through THAT package's own constructor — +// the one decorated with its date or todo arguments — and a citation minted +// through core's undecorated version would not take them. +// +// `keywords:` turns an entry's `keywords` field into rookery tags, on top of +// whatever `tags:` a `citation`/`all()` call already carries: +// +// none (default) no tags from keywords — unchanged behaviour +// "all" every keyword becomes a tag, whatever the rookery already has +// "existing" only a keyword that already matches a tag somewhere in the +// rookery becomes one; the rest are ignored +// +// `"existing"` reads `tag-data()` to learn what tags exist, which needs +// `#context`. That is safe here specifically because the sweep only ever adds +// tags that ALREADY exist — the known-tag set is a fixed point under its own +// writes — so reading it mid-sweep still converges. See `note` below for how +// that read reaches both minting paths. +// +// `only:` parses just the named keys out of `src`, so a large library costs +// what it's USED rather than what it contains. `auto` (the default) parses +// the whole file — not `none`, which would read as "parse nothing". A key +// `only` names that `src` doesn't carry is dropped silently; nothing here +// errors on it, because `entry(key)` already asserts on a missing key at the +// point something asks for it, which is a more useful place to fail than +// factory construction. +#let bibtex( + src, + mint: _core-idea, + tag: "citation", + keywords: none, + show-fields: (:), + only: auto, +) = { + assert( + keywords in _KEYWORDS-MODES, + message: "@rookery/bibtex: `keywords` must be none, \"all\" or \"existing\" — got " + + repr(keywords), + ) + assert( + only == auto or type(only) == array, + message: "@rookery/bibtex: `only` must be auto or an array of keys — got " + repr(only), + ) + // Captured under its own name because `fields:` below takes a per-call + // parameter of the same name — inside that closure, `show-fields` is the + // per-call one, and this is the only way back to the factory's. + let _show-fields = show-fields + let src = if type(src) == array { src.join("\n") } else { src } + // `only:` filters the source before parsing, not the parsed result after + // — see the header comment above for why, and for the missing-key + // contract. + let bib = if only == auto { parse-bib(src) } else { + let chunks = bib-chunks(src) + let kept = only.filter(k => k in chunks).map(k => chunks.at(k)) + if kept.len() == 0 { (:) } else { parse-bib(kept.join("\n")) } + } + let entry = key => { + let e = bib.at(key, default: none) + assert(e != none, message: "no `" + key + "` in the bibliography") + e + } + // One entry's keyword slugs, filtered against the rookery's known tags in + // "existing" mode. Needs `#context` only for that mode — `all()` already + // calls `note` from inside one; `citation` wraps its own call below, only + // when `keywords` is "existing", rather than becoming a context function + // for every mode. + let kw-tags-for(key, known: none) = { + if keywords == none { return (:) } + let slugs = keyword-tags(entry(key).at("keywords", default: none)) + let kept = if keywords == "existing" { + let known = if known != none { + known + } else { + tag-data().values().map(t => t.keys()).flatten().dedup() + } + slugs.filter(s => s in known) + } else { + slugs + } + kept.fold((:), (d, t) => { d.insert(t, none); d }) + } + // The mint, with no claim. `all()` below calls this directly, for every key + // the sweep reaches — `citation`'s claim would make the sweep both write and + // read `_claimed` in the same pass, and Typst's state resolution does not + // converge on that (see `claim.typ`). + // + // Keyword tags merge UNDER the caller's own `tags:` — dictionary `+` lets + // the right side win a key collision, so an explicit tag always wins over + // one derived from `keywords`. The package's own `tag` then merges under + // BOTH of those, through `_merge-base-tags`, so neither the keyword tags nor + // a caller's own `tags:` can displace it. + let note = (key, title: auto, tags: none, display-tags: true, known: none, ..args) => mint( + key, + title: if title == auto { bib-title(entry(key)) } else { title }, + tags: _merge-base-tags(tag, kw-tags-for(key, known: known) + _norm-tags(tags)), + display-tags: display-tags, + ..args, + ) + ( + bib: bib, + entry: entry, + fields: (key, show-fields: auto) => fields-block( + entry(key), + show-fields: if show-fields == auto { _show-fields } else { show-fields }, + ), + // The authoring form: claims its key — so `all()` skips it — then mints. + // The claim is idempotent, so writing `citation` for one key twice still + // reads as one claimed key, not a collision. + citation: (key, title: auto, tags: none, display-tags: true, ..args) => { + let key = cite-key(key) + _claimed.update(c => { c.insert(key, none); c }) + if keywords == "existing" { + context note(key, title: title, tags: tags, display-tags: display-tags, ..args) + } else { + note(key, title: title, tags: tags, display-tags: display-tags, ..args) + } + }, + // Mints a note for every bibliography key not already claimed by a + // hand-written `citation`, in the bibliography's own alphabetical key + // order. Must be called ONCE, from one vertebra, inside a `#show: rookery` + // document — a second call panics rather than re-minting. + // + // `.get()` on `_swept`, NOT `.final()`: `.final()` would see the increment + // this very call makes and panic on the FIRST call too. `.get()` sees only + // what ran before this point, so the guard fires on a genuine second call + // and nothing else. + all: () => context { + if _swept.get() > 0 { + panic( + "@rookery/bibtex: all() mints the whole bibliography and must be " + + "called once, from one vertebra", + ) + } + _swept.update(n => n + 1) + // Computed once for the whole sweep, not once per key: "existing" mode + // only ever adds a tag that already exists elsewhere, so the known-tag + // set is a fixed point under its own writes and one read up front + // answers every key in the loop below. + let known = if keywords == "existing" { + tag-data().values().map(t => t.keys()).flatten().dedup() + } else { + none + } + for key in bib.keys().sorted() { + if key not in _claimed.final() { + // `[]`, an empty body, NOT omitted: core's `#idea` reads a single + // positional argument as the note's BODY (idea.typ, the + // `pos.len() == 1` branch), so `note(key)` alone would make the key + // itself the body — unnamed, landing on the sequence counter as + // `ideas/1.html` rather than under its own key. The empty body + // keeps `key` in the name slot. + note(key, [], known: known) + } + } + }, + ) +} diff --git a/bibtex/0.1.0/src/parse.typ b/bibtex/0.1.0/src/parse.typ new file mode 100644 index 00000000..8db3483c --- /dev/null +++ b/bibtex/0.1.0/src/parse.typ @@ -0,0 +1,111 @@ +// A hand-rolled BibTeX scanner: `@type{key, field = {..} | "..." | bare}`, +// nested braces, and `{{Protected Words}}` unwrapped. It does not expand +// `@string` macros, `#` concatenation, or LaTeX escapes — every value comes +// back as the literal text between its delimiters, squashed to single spaces. + +// Newlines and runs of spaces flattened to one space: a `title = {..}` wrapped +// across three lines is one line of prose, and the indentation is the file's, not +// the title's. +#let _squash(s) = s.trim().split(regex("\\s+")).join(" ") + +#let _BRACES = regex("[{}]") +#let _HEAD = regex("^@([A-Za-z]+)\\s*\\{\\s*([^,\\s]+)\\s*,") +#let _FIELD = regex("^[\\s,]*([A-Za-z][A-Za-z0-9_\\-]*)\\s*=\\s*") + +// One field value, from `s` sitting on its first character. Returns `(value, next)` +// — Typst has no out-parameters, so every scanner here hands the cursor back rather +// than mutating one. +// +// A braced value is found by jumping between brace positions rather than walking +// its characters one at a time: `s.matches(_BRACES)` is a single native pass over +// the whole value, and depth-counting then loops over BRACES — usually two or +// four — instead of over every character the value contains. This is what lets a +// 16,000-character `abstract` parse at all: a per-character `while` loop is capped +// at 10,000 iterations by Typst itself. +// +// BRACES ARE DROPPED, ALL OF THEM, not just the outer pair. In BibTeX an interior +// brace protects capitalization from the style rather than saying anything about +// the text, so `{{An}} Essay` is the words `An Essay`. +#let _value(s) = { + if s.starts-with("{") { + let depth = 0 + let end = none + for m in s.matches(_BRACES) { + if m.text == "{" { depth += 1 } else { + depth -= 1 + if depth == 0 { end = m.start; break } + } + } + if end == none { return (s.slice(1).replace("{", "").replace("}", ""), s.len()) } + (s.slice(1, end).replace("{", "").replace("}", ""), end + 1) + } else if s.starts-with("\"") { + let q = s.slice(1).position("\"") + if q == none { return (s.slice(1), s.len()) } + (s.slice(1, q + 1), q + 2) + } else { + // A bare value — `year = 2002`, `month = jan` — ends at the field separator. + let e = s.position(regex("[,}]")) + if e == none { return (s, s.len()) } + (s.slice(0, e), e) + } +} + +// One `@type{key, ..}` chunk, as `bib-chunks` below hands it out, parsed into +// `(key, fields)` — `field: value`, field names lowercased, values squashed. `none` +// if `chunk` doesn't even start with a recognizable entry head. +// +// `"entry-type"` sits under a key a real BibTeX field name can never carry — a +// field name cannot contain a hyphen — so it can't collide with a field the entry +// actually has. +#let parse-entry(chunk) = { + let m = chunk.match(_HEAD) + if m == none { return none } + let fields = ("entry-type": lower(m.captures.at(0))) + let rest = chunk.slice(m.end) + while true { + let fm = rest.match(_FIELD) + if fm == none { break } + let after = rest.slice(fm.end) + let (value, next) = _value(after) + fields.insert(lower(fm.captures.at(0)), _squash(value)) + rest = after.slice(next) + } + (m.captures.at(1).trim(), fields) +} + +// `key -> that entry's own source text`, one native `str.split` over the whole +// file rather than a per-character scan — this is where nearly all of the +// speedup over a character-at-a-time reader comes from, since every entry then +// gets its own small chunk to parse instead of sharing one array with an element +// per character in the file. +// +// Splitting on `"\n@"` costs nothing measurable (it's native Rust) at the price of +// one known gap: a braced value containing a line that itself starts with `@` +// would be cut in the wrong place. BibTeX exports don't wrap values that way (a +// wrapped value is indented), so this is an acceptable trade. +#let bib-chunks(src) = { + let out = (:) + // The leading `"\n"` makes the file's OWN first entry break on the same `\n@` + // as every other one, so it isn't handed to `parse-entry` with a doubled `@`. + for chunk in ("\n" + src).split("\n@") { + let c = chunk.position(",") + if c == none { continue } + let head = chunk.slice(0, c) + let b = head.position("{") + if b == none { continue } + out.insert(head.slice(b + 1).trim(), "@" + chunk) + } + out +} + +// `key -> (field: value)`, field names lowercased. Splits the file into entries +// first (`bib-chunks`) and parses each one on its own (`parse-entry`) rather than +// scanning the whole file through one shared array of its characters. +#let parse-bib(src) = { + let out = (:) + for (key, chunk) in bib-chunks(src) { + let e = parse-entry(chunk) + if e != none { out.insert(e.at(0), e.at(1)) } + } + out +} diff --git a/bibtex/0.1.0/src/view.typ b/bibtex/0.1.0/src/view.typ new file mode 100644 index 00000000..b9750417 --- /dev/null +++ b/bibtex/0.1.0/src/view.typ @@ -0,0 +1,84 @@ +// Rendering a parsed BibTeX entry's fields as an HTML definition list. + +// The order a reader wants: who, what, where it appeared, then the numbers, +// then the handles. Any field NOT listed here still renders — it is appended +// after these, sorted, so an unusual BibTeX field is shown rather than lost. +#let _ORDER = ( + "entry-type", "author", "editor", "translator", "title", "shorttitle", + "booktitle", "journal", "series", "publisher", "address", "edition", + "volume", "number", "pages", "year", "month", "doi", "url", "urldate", + "isbn", "issn", "keywords", "note", "abstract", +) + +// Field names whose capitalized form reads badly. +#let _TERMS = ( + "entry-type": "Type", + "shorttitle": "Short title", + "booktitle": "Book title", + "urldate": "Accessed", + "doi": "DOI", + "url": "URL", + "isbn": "ISBN", + "issn": "ISSN", +) + +// `_ORDER` filtered to the fields `entry` carries, with anything `show-fields` +// names `false` removed. A key `show-fields` does not mention stays — the +// dictionary is a list of exceptions, not a whitelist — and `true` is the same +// as absent, so a project can flip a field back on without deleting the line. +#let _visible-order(entry, show-fields) = { + let order = _ORDER.filter(k => k in entry) + entry.keys().filter(k => k not in _ORDER).sorted() + order.filter(k => show-fields.at(k, default: true)) +} + +// Every field an entry carries, as a labelled HTML definition list — none skipped, +// so a BibTeX field this module has never heard of still surfaces rather than being +// silently dropped. `show-fields` hides some of them: a dictionary mapping a field +// name to `false` removes it from the list. `"entry-type"` is a valid key here too, +// though it names no real BibTeX field — it is the parser's own synthesized key +// behind the `Type` row, hideable the same as any other. +// +// THE LABEL IS PART OF THE BLOCK, so one call gets a page the whole footer and the +// stylesheet can size the label against the rows beneath it. A `
` rather than a +// heading: the block sits under a note's own prose and must claim no place in the +// page's outline above it. `label: none` omits it, for a page that heads the block +// itself. +// +// A `show-fields` that hides every field the entry carries returns nothing at +// all — not the label, not an empty `
` — the same reasoning `@rookery/core` +// applies to an empty references block: a heading over an empty table is worse +// than no block. +#let fields-block(entry, label: "Citation", show-fields: (:)) = { + assert( + type(show-fields) == dictionary, + message: "@rookery/bibtex: `show-fields` must be a dictionary, got " + repr(show-fields), + ) + for (k, v) in show-fields { + assert( + type(v) == bool, + message: "@rookery/bibtex: `show-fields` value for \"" + k + "\" must be a boolean, got " + + repr(v), + ) + } + let order = _visible-order(entry, show-fields) + if order.len() == 0 { return } + let term = k => _TERMS.at(k, default: upper(k.first()) + k.slice(1)) + let value = (k, v) => { + if k == "doi" { + link("https://doi.org/" + v, v) + } else if k == "url" { + link(v, v) + } else { + v + } + } + if label != none { + html.elem("div", attrs: (class: "citation-fields-head"), label) + } + html.elem("dl", attrs: (class: "citation-fields"), { + for k in order { + html.elem("dt", term(k)) + html.elem("dd", value(k, entry.at(k))) + } + }) +} diff --git a/bibtex/0.1.0/test/check.sh b/bibtex/0.1.0/test/check.sh new file mode 100755 index 00000000..389c49ee --- /dev/null +++ b/bibtex/0.1.0/test/check.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Asserts on `test/sweep.typ`'s rendered OUTPUT, not merely that it compiled. +# `units.typ` covers `bibtex(..)`'s pure logic; this covers what `all()` +# actually registers — one note per bibliography key not already claimed by a +# hand-written `#citation`, each keyed by its BibTeX key rather than by the +# unnamed-note counter. +set -euo pipefail +cd "$(dirname "$0")/.." +S=test/build/sweep.html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +[ -f "$S" ] || { echo "FAIL: no $S — run 'just test' first"; exit 1; } + +python3 - "$S" <<'PY' || fail=1 +import re, sys +h = open(sys.argv[1]).read() + +def txt(s): + return " ".join(re.sub(r"<[^>]+>", " ", s).split()) + +# Each row is a `
` holding one `` +# and one `` — separate elements rather than a single +# text-joined string, so the empty-body row cannot be mangled by HTML's own +# whitespace collapsing. One row per note `ideas()` found registered — a +# faithful count of the registry, not of what the fixture merely asked to mint. +rows = re.findall(r'
(.*?)
', h, re.S) +if len(rows) != 2: + print(f"FAIL: expected 2 registered notes, found {len(rows)}: {rows}"); sys.exit(1) + +def cell(row, cls): + m = re.search(rf'(.*?)', row, re.S) + return txt(m.group(1)) if m else None + +pairs = [(cell(r, "sweep-id"), cell(r, "sweep-body")) for r in rows] +ids = sorted(p[0] for p in pairs) +if ids != ["idea:badiou2002", "idea:smith2020"]: + print(f"FAIL: registered ids are {ids}, wanted idea:badiou2002 and idea:smith2020") + sys.exit(1) + +# NEITHER named `1`: the whole defect this design exists to avoid is `all()` +# reading a single-argument mint as an unnamed note, landing it on the +# sequence counter as `idea:1` instead of under its own key. +if any(p[0] == "idea:1" for p in pairs): + print(f"FAIL: a note minted as the unnamed counter's `idea:1`: {rows}"); sys.exit(1) + +by_id = dict(pairs) +if by_id["idea:badiou2002"] != "A hand-written body.": + print(f"FAIL: the hand-written citation's body is {by_id['idea:badiou2002']!r}, " + f"wanted 'A hand-written body.'") + sys.exit(1) +if by_id["idea:smith2020"] != "": + print(f"FAIL: the swept note's body is {by_id['idea:smith2020']!r}, wanted empty") + sys.exit(1) + +print(f" all(): 2 notes registered — {rows}") +PY + +FL=test/build/fields.html +[ -f "$FL" ] || { echo "FAIL: no $FL — run 'just test' first"; exit 1; } + +# `fields.typ` hides `doi` and `urldate` via the factory's own `show-fields:` — +# their `
` rows must be entirely absent, and a field the entry carries but +# the dictionary doesn't name (`author`) must still be there. +python3 - "$FL" <<'PY' || fail=1 +import re, sys +h = open(sys.argv[1]).read() + +if "
DOI
" in h: + print("FAIL: show-fields hid \"doi\" but
DOI
is still rendered") + sys.exit(1) +if "
Accessed
" in h: + print("FAIL: show-fields hid \"urldate\" but
Accessed
is still rendered") + sys.exit(1) +if "
Author
" not in h: + print("FAIL: show-fields did not name \"author\" but its
Author
is missing") + sys.exit(1) +print(" show-fields: doi and urldate absent, author present") +PY + +EX=test/build/sweep-existing.html +AL=test/build/sweep-all.html +[ -f "$EX" ] || { echo "FAIL: no $EX — run 'just test' first"; exit 1; } +[ -f "$AL" ] || { echo "FAIL: no $AL — run 'just test' first"; exit 1; } + +# `keywords: "existing"` keeps a keyword only where it already matches a tag +# elsewhere in the rookery (`liminal`, seeded by the hand-written `seed` +# note); `keywords: "all"` keeps every keyword regardless. Both fixtures mint +# the same three bibliography entries, so the two `kw-row` scans below are +# directly comparable — the only thing that differs is which keywords +# survive. +python3 - "$EX" "$AL" <<'PY' || fail=1 +import re, sys + +def txt(s): + return " ".join(re.sub(r"<[^>]+>", " ", s).split()) + +def rows(path): + h = open(path).read() + out = {} + for row in re.findall(r'
(.*?)
', h, re.S): + m_id = re.search(r'(.*?)', row, re.S) + m_tags = re.search(r'(.*?)', row, re.S) + out[txt(m_id.group(1))] = txt(m_tags.group(1)) if m_tags else "" + return out + +existing = rows(sys.argv[1]) +all_mode = rows(sys.argv[2]) + +want_existing = { + "idea:aaa": "citation,liminal", + "idea:bbb": "citation", + "idea:ccc": "citation", + "idea:seed": "liminal", +} +want_all = { + "idea:aaa": "brandnew,citation,liminal", + "idea:bbb": "brandnew,citation", + "idea:ccc": "citation,digital-humanities", + "idea:seed": "liminal", +} + +ok = True +if existing != want_existing: + print(f"FAIL: keywords=\"existing\" tags are {existing}, wanted {want_existing}") + ok = False +if all_mode != want_all: + print(f"FAIL: keywords=\"all\" tags are {all_mode}, wanted {want_all}") + ok = False + +def fmt(d): + ids = ("idea:aaa", "idea:bbb", "idea:ccc", "idea:seed") + return " | ".join(f"{k.split(':')[1]}={d[k].replace(',', '+')}" for k in ids) + +print(f' existing: {fmt(existing)}') +print(f' all: {fmt(all_mode)}') + +if not ok: sys.exit(1) +PY + +# Every `class="..."` a note carries is `idea`, `idea-box`, `idea-tag` (the +# permalink pill's own shape hook) or `idea-tag-`, where `` is +# what `keyword-tags` produces (lowercase, hyphen-separated). A raw +# multi-word keyword that skipped slugifying would split into a bogus +# `idea-tag-` token PLUS a stray bare word carrying no `idea-tag-` +# prefix at all — a broken two-class attribute, invisible in a passing +# compile. Scanned across every rendered fixture, not just the keyword ones, +# so this also guards `sweep.html` and any future one. +python3 - test/build/*.html <<'PY' || fail=1 +import re, sys + +allowed = re.compile(r'^(idea|idea-box|idea-tag|idea-tag-[a-z0-9]+(-[a-z0-9]+)*)$') +bad = [] +for path in sys.argv[1:]: + h = open(path).read() + for m in re.finditer(r'class="([^"]*)"', h): + tokens = m.group(1).split() + # Only an attribute that is ALREADY one of a note's own class lists — + # `class="idea idea-tag-.."`, `class="idea-box idea-tag-.."`, + # `class="idea-tag idea-tag-.."` (a permalink pill) — is in scope; an + # unrelated attribute (this fixture's own `kw-row` spans, say) never + # carries an `idea`/`idea-tag`/`idea-tag-` token at all. + if not any(t in ("idea", "idea-box", "idea-tag") or t.startswith("idea-tag-") for t in tokens): + continue + for token in tokens: + if not allowed.match(token): + bad.append((path, m.group(1), token)) + +if bad: + for path, cls, token in bad: + print(f'FAIL: {path}: stray class token {token!r} in class="{cls}" — ' + f'a keyword with a space that was not slugified splits an ' + f'`idea-tag-` class exactly like this') + sys.exit(1) +print(" no stray idea-tag- class tokens") +PY + +if [ "$fail" -eq 0 ]; then echo "sweep OK"; else echo "sweep FAILED"; exit 1; fi diff --git a/bibtex/0.1.0/test/fields.typ b/bibtex/0.1.0/test/fields.typ new file mode 100644 index 00000000..3fecc77d --- /dev/null +++ b/bibtex/0.1.0/test/fields.typ @@ -0,0 +1,11 @@ +// The RENDERED half of the `show-fields:` fixture — asserts on the actual +// markup a hidden field disappears from, not merely on the filter behind it +// (`units.typ` covers that in isolation). The factory hides `doi` and +// `urldate`; `refs.fields(..)` takes no override, so what renders here is the +// factory default reaching `fields-block` through `lib.typ`'s threading. +#import "/src/lib.typ": bibtex + +#let BIB = "@article{hidden2020,\n title = {A Paper},\n author = {Jane Smith},\n year = {2020},\n doi = {10.5555/x},\n urldate = {2024-01-01},\n}\n" +#let refs = bibtex(BIB, show-fields: ("doi": false, "urldate": false)) + +#(refs.fields)("hidden2020") diff --git a/bibtex/0.1.0/test/large.typ b/bibtex/0.1.0/test/large.typ new file mode 100644 index 00000000..a4dac9b8 --- /dev/null +++ b/bibtex/0.1.0/test/large.typ @@ -0,0 +1,32 @@ +// Large-corpus fixture for @rookery/bibtex. Run with `just test` from +// `bibtex/0.1.0`. Same shape as `test/units.typ`: no runner, a failing +// `assert` fails the compile with a line number. + +#import "/src/lib.typ": * + +// A field longer than Typst's 10,000-iteration `while` ceiling. A parser +// that steps character by character cannot read this at all. +#let LONG = "lorem " * 2000 +#let ONE = "@book{k,\n title = {A Book},\n abstract = {" + LONG + "},\n}\n" +#assert.eq(parse-bib(ONE).at("k").title, "A Book") +#assert.eq(parse-bib(ONE).at("k").abstract, LONG.trim()) +#assert.eq(parse-bib(ONE).at("k").abstract.len(), 11999) + +// A corpus, so the cost of the file is exercised rather than the cost of +// one entry: 400 entries, each with an abstract of its own. +// +// The outer parens are load-bearing: a `#let` binding's right-hand side +// stops parsing at the end of its first line, so a method chain that wraps +// across lines (`.map(..)` / `.join(..)` each on their own line) silently +// binds `MANY` to the un-joined array unless the whole expression is inside +// one `(..)` that keeps the parser in code mode across the newlines. +#let MANY = ( + range(400) + .map(i => "@article{k" + str(i) + ",\n title = {Paper " + str(i) + "},\n" + + " abstract = {" + ("filler " * 200) + "},\n}\n") + .join("\n") +) +#let parsed = parse-bib(MANY) +#assert.eq(parsed.len(), 400) +#assert.eq(parsed.at("k399").title, "Paper 399") +#assert.eq(parsed.keys().filter(k => "abstract" in parsed.at(k)).len(), 400) diff --git a/bibtex/0.1.0/test/sweep-all.typ b/bibtex/0.1.0/test/sweep-all.typ new file mode 100644 index 00000000..b5db3fa6 --- /dev/null +++ b/bibtex/0.1.0/test/sweep-all.typ @@ -0,0 +1,30 @@ +// The RENDERED half of the `keywords: "all"` fixture — the same entries as +// `sweep-existing.typ`, so the two fixtures are directly comparable, but +// every keyword becomes a tag whether or not the rookery already has it. +// `aaa` picks up both `liminal` and `brandnew`, `bbb` picks up `brandnew`, +// and `ccc`'s `Digital Humanities` becomes the single tag +// `digital-humanities` — proving the slug, not the raw space-carrying +// keyword, is what lands in the class list (see `check.sh`'s scan for a +// stray class token). +#import "@rookery/core:0.1.0": rookery, idea, ideas +#import "/src/lib.typ": bibtex + +#let BIB = "@book{aaa,\n title = {A},\n keywords = {liminal, brandnew},\n}\n\n@book{bbb,\n title = {B},\n keywords = {brandnew},\n}\n\n@book{ccc,\n title = {C},\n keywords = {Digital Humanities},\n}\n" +#let refs = bibtex(BIB, keywords: "all") + +#show: rookery + +#idea("seed", tags: "liminal")[A hand-written note.] +// `aaa` claimed by hand, exercising `citation`'s plain (non-context) path — +// `"all"` mode needs no registry read, so it never wraps this call. +#(refs.citation)("aaa")[] +#(refs.all)() + +#context { + for i in ideas() { + html.elem("div", attrs: (class: "kw-row"), { + html.elem("span", attrs: (class: "kw-id"), i.id) + html.elem("span", attrs: (class: "kw-tags"), i.tags.sorted().join(",")) + }) + } +} diff --git a/bibtex/0.1.0/test/sweep-existing.typ b/bibtex/0.1.0/test/sweep-existing.typ new file mode 100644 index 00000000..93764025 --- /dev/null +++ b/bibtex/0.1.0/test/sweep-existing.typ @@ -0,0 +1,36 @@ +// The RENDERED half of the `keywords: "existing"` fixture. `seed` is a +// hand-written note carrying the tag `liminal`, written before the sweep +// runs; `aaa`'s keywords are `liminal, brandnew` (the existing tag plus an +// unused one) and `bbb`'s is `brandnew` alone. `ccc`'s keywords, `Digital +// Humanities`, slugify to `digital-humanities` — a tag nothing else in this +// rookery carries, so it too is dropped, but it is here to prove the +// slug — not the raw, space-carrying keyword — is what gets tested against +// the known-tag set. +// +// `units.typ` covers `keyword-tags` and the slug itself; this covers what +// `"existing"` mode actually keeps once mixed with a real registry read. +#import "@rookery/core:0.1.0": rookery, idea, ideas +#import "/src/lib.typ": bibtex + +#let BIB = "@book{aaa,\n title = {A},\n keywords = {liminal, brandnew},\n}\n\n@book{bbb,\n title = {B},\n keywords = {brandnew},\n}\n\n@book{ccc,\n title = {C},\n keywords = {Digital Humanities},\n}\n" +#let refs = bibtex(BIB, keywords: "existing") + +#show: rookery + +#idea("seed", tags: "liminal")[A hand-written note.] +// `aaa` claimed by hand — exercising `citation`'s own context-wrapped path +// for `"existing"` mode, not only `all()`'s, which already runs inside one. +#(refs.citation)("aaa")[] +#(refs.all)() + +// One `
` per registered note, id and sorted tag list in +// their own spans — `check.sh` greps these to confirm which keywords +// survived the existing-tag filter. +#context { + for i in ideas() { + html.elem("div", attrs: (class: "kw-row"), { + html.elem("span", attrs: (class: "kw-id"), i.id) + html.elem("span", attrs: (class: "kw-tags"), i.tags.sorted().join(",")) + }) + } +} diff --git a/bibtex/0.1.0/test/sweep.typ b/bibtex/0.1.0/test/sweep.typ new file mode 100644 index 00000000..e285f56f --- /dev/null +++ b/bibtex/0.1.0/test/sweep.typ @@ -0,0 +1,38 @@ +// The RENDERED half of the `all()` fixture — the sweep that mints a note for +// every bibliography key a hand-written `#citation` has not already claimed. +// `units.typ` covers `bibtex(..)`'s shape; this covers what `all()` actually +// registers, which needs `#show: rookery` and a real note registry to read +// back — a page without it renders no note chrome at all. +// +// Two entries: `badiou2002` is claimed by hand, with an authored body; +// `smith2020` is left for `all()`, which mints it with the empty body a swept +// note always gets (see `lib.typ`'s comment on why that body is load-bearing). +#import "@rookery/core:0.1.0": rookery, ideas +#import "/src/lib.typ": bibtex + +#let BIB = "@book{badiou2002,\n title = {Ethics},\n}\n\n@article{smith2020,\n title = {A Paper},\n}\n" +#let refs = bibtex(BIB) + +#show: rookery + +// Parenthesized field access, not `#refs.citation(..)`: Typst 0.15.1 refuses +// to call a dictionary VALUE with method syntax (`cannot directly call +// dictionary keys as functions`) — `bibtex(..)` returns a plain dictionary of +// functions, not an object with methods, so every call here needs the +// `(refs.field)(..)` form. +#(refs.citation)("badiou2002")[A hand-written body.] +#(refs.all)() + +// One `
` per registered note, id and body in their own +// spans (rather than joined with a text separator, which HTML's whitespace +// collapsing would mangle for the empty-body row) — for `check.sh` to grep. +// `ideas()` reads the registry itself, so this is a faithful count of what +// actually registered, not merely of what this file asked to mint. +#context { + for i in ideas() { + html.elem("div", attrs: (class: "sweep-row"), { + html.elem("span", attrs: (class: "sweep-id"), i.id) + html.elem("span", attrs: (class: "sweep-body"), i.body) + }) + } +} diff --git a/bibtex/0.1.0/test/units.typ b/bibtex/0.1.0/test/units.typ new file mode 100644 index 00000000..661f6cbd --- /dev/null +++ b/bibtex/0.1.0/test/units.typ @@ -0,0 +1,149 @@ +// Unit fixture for @rookery/bibtex. Run with `just test` from +// `bibtex/0.1.0`. There is no runner and no JS: an `assert` that fails +// fails the compile with a line number, and a passing compile is the green +// light — the same shape `@rookery/core` and `@rookery/timeline` use. + +#import "/src/lib.typ": * + +// ---- parse-bib — a two-entry fixture yields both keys ---------------------- +#let TWO = "@book{badiou2002,\n title = {Ethics},\n}\n\n@article{smith2020,\n title = {A Paper},\n}\n" +#assert.eq(parse-bib(TWO).keys().sorted(), ("badiou2002", "smith2020")) + +// The entry type is recorded under its own key, which a real BibTeX field name +// can never carry — a field name cannot contain a hyphen — so it can't collide +// with a field the entry actually has. +#assert.eq(parse-bib(TWO).at("badiou2002").entry-type, "book") + +// Interior braces are dropped entirely, not just the outer pair: in BibTeX an +// interior brace protects capitalization from the citation style rather than +// saying anything about the text. +#assert.eq(parse-bib("@misc{k, title = {{An} Essay},}").at("k").title, "An Essay") + +// A value split across source lines squashes to single spaces — the +// indentation belongs to the file, not to the title. +#assert.eq( + parse-bib("@misc{k, title = {A\n Multi-Line\n Title},}").at("k").title, + "A Multi-Line Title", +) + +// A quoted value and a bare value both parse. +#assert.eq(parse-bib("@misc{k, note = \"quoted\",}").at("k").note, "quoted") +#assert.eq(parse-bib("@misc{k, year = 2002,}").at("k").year, "2002") + +// ---- cite-key — a string, a label, or a ref, all read back the key -------- +#assert.eq(cite-key("badiou2002"), "badiou2002") +#assert.eq(cite-key(), "badiou2002") +#assert.eq(cite-key(ref()), "badiou2002") + +// ---- bib-title — the note title an entry derives --------------------------- +// +// `bib-title` returns CONTENT, not a string, so comparing it needs the exact +// same markup shape it builds rather than a hand-assembled equivalent — +// Typst's content equality is structural, and `[Badiou, ]` + `emph(..)` does +// not equal one sequence built as `[#by, ]#emph(..)`. `_expect` mirrors +// `bib-title`'s own construction so the comparison is meaningful rather than +// weakened to a type check. +#let _expect(by, work, year) = [#if by != none [#by, ]#emph(work)#if year != none [ (#year)]] + +// `shorttitle` wins over `title` when both are present. +#assert.eq( + bib-title((shorttitle: "Ethics", title: "Ethics: An Essay", author: "Alain Badiou", year: "2002")), + _expect("Badiou", "Ethics", "2002"), +) + +// Three or more authors cut to the first surname plus `et al.`. +#assert.eq( + bib-title((title: "A Paper", author: "Jane Smith and John Doe and Alex Lee")), + _expect("Smith et al.", "A Paper", none), +) + +// Neither `author` nor `editor`: the title stands with no byline at all. +#assert.eq(bib-title((title: "No Byline Here")), _expect(none, "No Byline Here", none)) + +// No title at all: `none`, rookery's own "this note has no authored title". +#assert.eq(bib-title((author: "Someone")), none) + +// ---- bibtex(..) — the factory's own shape ----------------------------------- +// +// `all` is a field on the returned dictionary, and a function — the sweep a +// project calls once. What it MINTS is document-level behaviour and needs the +// rendered fixture in `test/sweep.typ`; this only checks the shape. +#assert.eq(type(bibtex(TWO).all), function) + +// `only:` keeps just the named keys, parsing the source down to them rather +// than filtering the parsed result. +#assert.eq(bibtex(TWO, only: ("smith2020",)).bib.keys(), ("smith2020",)) +#assert.eq(bibtex(TWO, only: ()).bib.len(), 0) + +// A key `only` names that the fixture doesn't carry is dropped silently, not +// an error — `entry(key)` is where a missing key raises, not here. +#assert.eq(bibtex(TWO, only: ("nosuchkey",)).bib.len(), 0) + +// `auto`, the default, parses the whole file exactly as `bibtex` behaved +// before `only:` existed. +#assert.eq(bibtex(TWO).bib.keys().sorted(), ("badiou2002", "smith2020")) + +// `all()` mints in `bib.keys().sorted()` order, alphabetical rather than +// insertion order — a fixture whose keys are already alphabetical (like +// `TWO` above) cannot tell the two apart, hence a fixture entered in reverse. +#let UNSORTED = "@book{zeta, title = {Z},}\n\n@book{alpha, title = {A},}\n" +#assert.eq(bibtex(UNSORTED).bib.keys().sorted(), ("alpha", "zeta")) + +// A bad `keywords:` value is rejected, naming the three accepted ones — +// exercised by reading `bibtex(..)`'s own assert message rather than by a +// negative test case: Typst has no way to catch a panic, so a fixture cannot +// assert one without aborting the whole compile. + +// ---- _visible-order — fields-block's show-fields filter -------------------- +// +// `fields-block` returns content, so the filter that decides which fields +// appear is tested directly rather than through rendered markup — `check.sh` +// covers the markup itself, on a fixture that actually renders. +#let _ENTRY = (entry-type: "book", title: "Ethics", author: "Alain Badiou", doi: "10.1/x") + +// Omitted entirely: every field shows, in `_ORDER`'s sequence. +#assert.eq(_visible-order(_ENTRY, (:)), ("entry-type", "author", "title", "doi")) + +// A partial dictionary hides only what it names `false`; a field it doesn't +// mention stays. +#assert.eq(_visible-order(_ENTRY, ("doi": false)), ("entry-type", "author", "title")) + +// `true` is the same as absent: shown. +#assert.eq( + _visible-order(_ENTRY, ("doi": true)), + ("entry-type", "author", "title", "doi"), +) + +// An unknown key changes nothing — `show-fields` is validated on its values, +// not its keys, so a misspelt one silently hides nothing. +#assert.eq( + _visible-order(_ENTRY, ("isbn": false)), + ("entry-type", "author", "title", "doi"), +) + +// Hiding every field the entry carries yields an empty order — `fields-block` +// returns early on this rather than emitting a label over nothing. +#assert.eq( + _visible-order(_ENTRY, ("entry-type": false, "title": false, "author": false, "doi": false)), + (), +) + +// ---- keyword-tags — a BibTeX `keywords` field as rookery tag slugs -------- +// +// Better BibTeX emits either separator depending on export settings, so both +// are accepted; each part is trimmed, lowercased, and every run of +// non-alphanumeric characters collapses to one hyphen with the ends +// stripped. +#assert.eq(keyword-tags("ethics, ontology, badiou"), ("ethics", "ontology", "badiou")) +#assert.eq(keyword-tags("ethics; ontology; badiou"), ("ethics", "ontology", "badiou")) + +// A keyword with a space would otherwise break its CSS class — slugifying +// collapses it to one hyphen. Mixed case folds too. +#assert.eq(keyword-tags("Digital Humanities"), ("digital-humanities",)) +#assert.eq(keyword-tags("ETHICS, Ontology"), ("ethics", "ontology")) + +// Punctuation-only keyword slugifies to the empty string and is dropped. +#assert.eq(keyword-tags("ethics, !!!, badiou"), ("ethics", "badiou")) + +// No `keywords` field at all: an empty array, not an error. +#assert.eq(keyword-tags(none), ()) diff --git a/bibtex/0.1.0/typst.toml b/bibtex/0.1.0/typst.toml new file mode 100644 index 00000000..64872923 --- /dev/null +++ b/bibtex/0.1.0/typst.toml @@ -0,0 +1,24 @@ +[package] +name = "bibtex" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "A BibTeX reader and a #citation note constructor for @rookery/core notes" +repository = "https://github.com/freecomputinglab/rookery" + +# The floor @rookery/core declares, matched rather than guessed: this package mints +# notes through core's `idea` and is only ever built inside a project already +# on a rheo that can resolve the `@rookery` namespace and a ref-fetched package by +# its resolved commit — both of which need 0.6.2. +# +# `min_version` MUST BE DECLARED BEFORE THE SUBTABLE BELOW IT: a format-specific +# subtable placed above it would capture that key into itself instead. +[tool.rheo] +min_version = "0.6.2" + +# The stylesheet for the citation block `#fields-block` draws — a label and one row +# per bibliographic field, sized against whatever type the page already sets. +[tool.rheo.html] +css_stylesheet = "src/bibtex.css" diff --git a/cfps/0.1.0/.gitignore b/cfps/0.1.0/.gitignore new file mode 100644 index 00000000..bc2f3e53 --- /dev/null +++ b/cfps/0.1.0/.gitignore @@ -0,0 +1,2 @@ +dist/ +test/build/ diff --git a/cfps/0.1.0/Justfile b/cfps/0.1.0/Justfile new file mode 100644 index 00000000..d4d52863 --- /dev/null +++ b/cfps/0.1.0/Justfile @@ -0,0 +1,19 @@ +default: + @echo "@rookery/cfps: pure Typst package, entrypoint is src/lib.typ directly — nothing to build" + +# Two fixtures. `test/units.typ` asserts every VALUE the package's constructors +# derive, and `test/view.typ` plus `test/check.sh` assert the MARKUP and the +# order of a cfp's own blocks — the same split @rookery/meetings' Justfile uses. +# +# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS +# package. `--features html` for parity with this repo's other Justfiles. +# +# `mkdir` first: `typst compile` does not create its output directory and +# fails with "No such file or directory" on a fresh checkout, since +# `test/build/` is gitignored and never committed. +test: + mkdir -p test/build + typst compile --features html --root . --format html test/units.typ test/build/units.html + @echo "units OK" + typst compile --features html --root . --format html test/view.typ test/build/view.html + ./test/check.sh diff --git a/cfps/0.1.0/readme.md b/cfps/0.1.0/readme.md new file mode 100644 index 00000000..bf6550cb --- /dev/null +++ b/cfps/0.1.0/readme.md @@ -0,0 +1,252 @@ +# @rookery/cfps + +A venue and its calls for [`@rookery/core`](../../core/0.1.0): a durable place +things are heard from, and one round of it — a deadline, a portal, and what +happened when it was answered. + +```typst +#import "@rookery/core:0.1.0": rookery +#import "@rookery/cfps:0.1.0": cfps +#show: rookery + +#let TODAY = datetime(year: 2026, month: 6, day: 1) +#let (venue, cfp, cfp-state, panel) = cfps(kinds: ( + postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))), +)) + +#venue("acme", title: [Acme University])[A programme that runs every year.] + +#cfp( + "acme-postdoc-26", + venue: , + kind: "postdoc", + deadline: datetime(year: 2026, month: 1, day: 1), + today: TODAY, +)[A round that lapsed with nothing sent.] + +#cfp( + "acme-postdoc-25", + venue: , + kind: "postdoc", + deadline: datetime(year: 2025, month: 1, day: 1), + timeline: (submitted: datetime(year: 2024, month: 12, day: 1), offered: datetime(year: 2025, month: 2, day: 1)), + today: TODAY, +)[A round that was answered, and settled.] + +#panel(state: "settled", today: TODAY) // -> lists only "acme-postdoc-25" +``` + +`#venue`'s `title:` defaults to `auto`, which titles the venue by its own +id — an authored `title:`, as in the example above, overrides it. + +## A call and its answer are one note + +A CFP is one round of a venue — a call for papers or applications, together +with whatever came back. The two are never two different attempts at two +different things; they are one attempt looked at before and after, so +keeping them as two separate notes would mean two places a deadline or a +decision could disagree, and a backlink graph where "what became of this" is +an edge to walk rather than a fact already on the note. `#cfp` folds the two +into one note instead, and its own log — the ordinary dated history +`@rookery/timeline` already gives every note — is where the whole story, +wire to verdict, actually lives. + +A VENUE is the other half, and it is deliberately thin: a durable place a call +recurs from — a programme, a conference series, a journal — carrying no dates +and closing nothing. Two rounds of one programme are two `#cfp`s sharing one +`#venue`. + +## `cfps(kinds:)` — the factory + +`kind`/`ladder` are not this package's vocabulary, so `cfps(..)` is a +factory: bind it to a caller's own once, and it hands back the four names +that vocabulary makes possible. + +```typst +#let (venue, cfp, cfp-state, panel) = cfps(kinds: ( + job: ( + sort: "job", + ladder: (transit: ("submitted", "interview"), terminal: ("offered", "rejected", "dropped")), + ), + journal: ( + sort: "journal", + ladder: (transit: ("submitted", "review-*"), terminal: ("accepted", "desk-rejected")), + ), +)) +``` + +`kinds:` is a dictionary of kind name to `(sort:, ladder:)`, where `ladder:` +is an ordinary `@rookery/timeline` ladder — `transit:` and `terminal:` arrays +of stage names, a trailing `-*` naming a FAMILY of stages (`review-*` matches +`review-1`, `review-2`, ..., see that package's own `ladder.typ`). A bad +`kinds:` fails at this call, not on whichever `#cfp` happens to name a bad +kind first, and `#cfp`'s own `kind:` argument is checked against it the same +way: + +```typst +#cfp("x", kind: "grant", ..) // grant is not job or journal +``` + +``` +@rookery/cfps: #cfp's `kind` must be one of ("job", "journal") — got "grant". +``` + +A worked round-trip, minting both halves and narrowing `panel:` to what has +actually settled: + +```typst +#let (venue, cfp, cfp-state, panel) = cfps(kinds: ( + postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))), +)) + +#let TODAY = datetime(year: 2026, month: 6, day: 1) +#venue("eth", title: [ETH Zürich])[A school.] + +#cfp("eth-postdoc-26", venue: , kind: "postdoc", deadline: datetime(year: 2026, month: 3, day: 1), today: TODAY)[ + Sent, nothing back yet. +] +#cfp( + "eth-postdoc-25", + venue: , + kind: "postdoc", + deadline: datetime(year: 2025, month: 3, day: 1), + timeline: (submitted: datetime(year: 2025, month: 1, day: 1), offered: datetime(year: 2025, month: 4, day: 1)), + today: TODAY, +)[Answered, and settled.] + +#panel(state: "settled", today: TODAY) +``` + +The panel above lists exactly `eth-postdoc-25` — the still-open `26` round +is filtered out by `state: "settled"`. + +## Why `kind`/`ladder` are caller-supplied + +No package can know that `offered` ends a job application while `accepted` +ends a conference submission and is the middle of a journal's review. That +vocabulary is the caller's, project by project, the same reason +[`@rookery/timeline`'s own `ladder.typ`](../../timeline/0.1.0/src/ladder.typ) +takes a ladder as a parameter rather than owning one — see that file's header +for the argument in full; this package just inherits it, one level up. + +## `#cfp` IS a `#todo` + +`#cfp` is built on [`@rookery/todos`](../../todos/0.1.0)' own `todo(..)`, not +a bare tagged note, and that buys a reader two things for free. A cfp shows +up in `#todo-table` with no separate wiring — an open call is +work outstanding, the same as any other todo — and it closes CORRECTLY the +moment it is answered or its deadline lapses: `#cfp` computes the real close +date itself (the earliest real answer, or the deadline if it lapsed +unanswered) and passes it as `todo(..)`'s `done:`, a real dated log entry +rather than a flag. `is-closed`, `#todo-table`, a consumer's own worklists — +everything reading that log agrees, because there is only the one log to +read. `priority:` is `@rookery/todos`' own as well, unchanged: a project +already running that package's worklists gets its cfps sorted into them +automatically, at whatever priority they were given. + +## Integrating `#window` + +A site that wraps `@rookery/todos`' `#window` with its own hiding logic — +transcluding a cfp but hiding it when, say, the real answer was a rejection — +has one thing to get right: that wrapper must pass `closed: true` through on +whichever branch decides to SHOW the note. `@rookery/todos`' own `#window` +independently hides any closed todo unless told otherwise, and every `#cfp` +now closes on either a real answer or a lapsed deadline — so a site's own +hiding decision will otherwise be silently overridden by that second, unrelated +check the moment the deadline passes. This is not a bug in either package; it +is what composing two independent "should this be visible" rules does, and +it is worth knowing before wrapping `#window` around a `#cfp` for the first +time rather than rediscovering it on a live site. + +## `panel:` — the rounds table + +`panel(state:, tags:, countdown:, today:)` draws one row per call — `when | +title | school | verdict` — in date order, bound to the same `kinds`/ladder +`cfp` was: + +```typst +#panel(state: ("open", "in-flight"), countdown: true, today: TODAY) +``` + +`state:` narrows by the same four states `cfp-state` derives (below); +`tags:` narrows further by whatever grouping the caller's own site uses (a +cycle, a kind not already covered by `state:`); `countdown:` washes the date +cell by how many days remain, the same three-week band `@rookery/todos`' +`#todo-table` reads. On a paged target the same rows draw as a plain list — +there is no grid to align there. + +### Every `today:` is explicit + +Every function here that needs a reference date takes it as `today:`. There is +no fallback to the document's own date — a call that omits it panics, naming +the problem. + +## The four states, and why they are net of the reserved stages + +`cfp-state(tags, ladder:, today:)` returns one of `"watching"` (nothing +announced at all), `"open"` (a wire is out, unanswered, not yet lapsed), +`"in-flight"` (a real answer is in and it is a transit rung), or `"settled"` +(a real answer is in and it is a terminal rung). All four are read off the +note's log with `deadline`/`scheduled`/`closed` stripped first +(`real-stage-of` does the stripping; `cfp-state` is the four-state +reading on top of it) — because a raw, unfiltered log answers a different +question than the one a caller is actually asking, in two ways that bit the +site this package was ported from: + +- **A lapsed, unanswered call is `"open"`, not `"in-flight"`.** Read raw, an + overdue `deadline` is the LATEST reached entry, and nothing in it looks + like a transit rung — so a naive reading calls it in flight when nothing + was ever sent. +- **A real answer dated before the deadline still counts.** A call dropped + early — its `timeline:` reaching a terminal stage before the deadline + itself arrives — reads raw as still "deadline", the later of the two dates, + masking the actual answer. + +## What it owns + +- `venue`/`venue-`, flat tags marking a note as a venue, and one per + call kind it hosts. +- `venue-call` (a venue's own standing submissions page) and `venue-school` + (an array of host-institution names, for a joint programme). +- `cfp`, the flat marker `panel:` filters rows on — stamped directly in + `#cfp`'s own tags rather than through an `idea(..)` call, since + `#cfp` mints through `@rookery/todos`' `todo(..)`, which already claims the + `todo` tag for itself. +- `cfp-venue` (the venue's name, valued, optional), `cfp-id` (the call's own + name, so a tag-only filter can identify its row), `submission-apply` (this + round's own portal, distinct from a venue's standing `venue-call`), + `submission-work` (the matched application's own path or URL), and + `submission-estimated` (a flat marker for a body carried over from a past + cycle's call rather than read off this round's own). + +## Requirements + +- Typst 0.15.0 or later (`typst.toml`'s `compiler` floor). +- rheo 0.6.2 or later (`min_version`). +- [`@rookery/core:0.1.0`](../../core/0.1.0), + [`@rookery/timeline:0.1.0`](../../timeline/0.1.0) and + [`@rookery/todos:0.1.0`](../../todos/0.1.0). All three are hard imports — + `#cfp` is a skin on `@rookery/todos`' own `todo(..)`, which is itself a + skin on `@rookery/timeline`'s dated notes. + +## Development + +```sh +cd cfps/0.1.0 +just test +``` + +Two fixtures, no build step: `test/units.typ` asserts every value the +package's constructors derive — the four-state ladder logic, the tags `#cfp` +stamps, that closing is a real log entry rather than a flag — and +`test/view.typ` plus `test/check.sh` assert the rendered markup: that the rail +draws from `deadline:` alone as readily as from a full `timeline:`, the +opportunity table's position relative to it, the venue backlink, and +`panel:`'s row classes. `typst.toml`'s `entrypoint` points straight at +`src/lib.typ`, so `src/` is what ships and an edit takes effect immediately. + +## Future work + +No `demo/` project ships with this package yet — a worked `rheo compile` +target, the way `@rookery/todos`' demo exercises all six of its views, would +be a reasonable thing to add for the next round of work on it. diff --git a/cfps/0.1.0/src/cfp.typ b/cfps/0.1.0/src/cfp.typ new file mode 100644 index 00000000..a58308bc --- /dev/null +++ b/cfps/0.1.0/src/cfp.typ @@ -0,0 +1,368 @@ +// @rookery/cfps — a venue and its calls: a durable place things are heard from, +// and one round of it, with a deadline, a portal, and what happened when it was +// answered. +// +// A VENUE is what recurs — a programme, a conference series, a journal. A CFP is +// one round of it: a call for papers/applications, folded together with its own +// answer, so one note carries both what was sent and what came back. Two rounds +// of one programme are two cfps sharing one venue. +// +// #let (venue, cfp, cfp-state) = cfps(kinds: ( +// postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))), +// )) +// #venue("acme", title: [Acme University])[..] +// #cfp("acme-postdoc-26", venue: , kind: "postdoc", deadline: d)[..] +// +// `kind`/`ladder` ARE CALLER CONFIGURATION, not a vocabulary this package owns. +// `accepted` ends a conference submission and is the middle of a journal's; a +// package cannot know that, the same reason @rookery/timeline's own +// `is-settled`/`rung`/`next-stage` take a ladder as a parameter rather than a +// constant. So `cfps(kinds:)` is a FACTORY, bound to a caller's vocabulary once, +// the same shape @rookery/meetings' `meetings(..)` and @rookery/bibtex's +// `bibtex(..)` already take. +// +// `#cfp` IS A SKIN ON @rookery/todos' `#todo`, not a hand-rolled closing flag. +// It closes through `done:` — a real date folded into the shared +// @rookery/timeline log — so `is-closed`/the flat `todo-closed` marker/ +// `priority`'s worklist behaviour all come free and correct: `todo(..)` derives +// its flat marker FROM the same log entry `done:` writes, so the two can never +// disagree. + +#import "@rookery/core:0.1.0": idea, _merge-base-tags, _norm +#import "@rookery/todos:0.1.0": todo +#import "@rookery/timeline:0.1.0": ( + CLOSED-STAGE, DEADLINE-STAGE, SCHEDULED-STAGE, assert-ladder, timeline-tags, is-settled, normalize-tags, stage-matches, + stage-of, timeline-of, timeline-view, +) + +// ---- Tag keys --------------------------------------------------------------- +// +// Ported from the reference site's own `_lib/template.typ`, minus `CITATION-KEY` +// — that one belongs to a site's own bibliography, not to a venue/cfp pair. + +#let VENUE-KEY = "venue" +#let VENUE-CALL-KEY = "venue-call" +// Host institution(s), as idea NAMES — an array, for a joint programme. +#let SCHOOL-KEY = "venue-school" +#let CFP-KEY = "cfp" +// The venue a call comes from, as a venue idea NAME. Optional: a call can be +// recorded before anything is written about the place it came from. +#let CFP-VENUE-KEY = "cfp-venue" +// A call's own name, carried as a valued tag purely so a tag-only filter can +// identify the row it is looking at. +#let CFP-ID-KEY = "cfp-id" +// THIS ROUND's portal, distinct from the venue's `call:` — a venue's `call:` is +// the standing submissions page; `apply:` is the instance, reissued every cycle. +#let APPLY-KEY = "submission-apply" +#let WORK-KEY = "submission-work" +// Marks a cfp's body as carried over from a past cycle's call rather than read +// off this round's own. +#let ESTIMATED-KEY = "submission-estimated" + +// ---- The two-column metadata table ------------------------------------------- +// +// Both `venue`/`cfp` open their body with this — a venue's `call:`, a cfp's +// `work:` — styled the same as @rookery/meetings' own `.meeting-fields`. +// `none`-valued rows drop out; an empty table draws nothing. +#let _opportunity-table(pairs) = { + let pairs = pairs.filter(p => p.at(1) != none) + if pairs.len() == 0 { return [] } + html.elem("dl", attrs: (class: "opportunity-meta"), { + for (term, value, how) in pairs { + html.elem("dt", term) + html.elem("dd", if how == "url" { + link(value, value) + } else if how == "path" { + raw(value) + } else { + value.replace("-", " ") + }) + } + }) +} + +// The reference date is always an explicit `today:` argument — Typst has no wall +// clock to fall back to. +#let _resolve-today(today) = { + assert( + type(today) == datetime, + message: "@rookery/cfps: #cfp needs a reference date — pass one explicitly, " + + "e.g. `today: datetime(year: 2026, month: 8, day: 25)`. Typst has no wall " + + "clock, so there is nothing to fall back to. Got " + repr(today), + ) + today +} + +// ---- `kinds:` validation ------------------------------------------------------ +// +// Eager, at factory-construction time — a bad `kinds:` fails as soon as it is +// given, not on whichever `#cfp` call happens to hit the bad kind first. What +// makes a `kind` valid is this package's own contract (`sort:` and `ladder:` +// present, `sort:` a string); what makes a `ladder:` valid is +// `@rookery/timeline`'s, so that half is delegated to `assert-ladder`. +#let _assert-kinds(kinds) = { + assert( + type(kinds) == dictionary, + message: "@rookery/cfps: `kinds:` must be a dictionary of kind name -> " + + "(sort: , ladder: (transit: (..), terminal: (..))) — got " + + repr(kinds), + ) + for (name, spec) in kinds.pairs() { + assert( + type(spec) == dictionary and "sort" in spec and "ladder" in spec, + message: "@rookery/cfps: kind " + repr(name) + " must be a dictionary with " + + "`sort:` (a string) and `ladder:` (a @rookery/timeline ladder) — got " + + repr(spec), + ) + assert( + type(spec.sort) == str, + message: "@rookery/cfps: kind " + repr(name) + "'s `sort:` must be a string — got " + repr(spec.sort), + ) + assert-ladder(spec.ladder) + } +} + +// Every stage any configured kind's ladder names, so `#cfp` can reject a typo in +// `timeline:` at the call site rather than rendering a row that cannot be +// placed. The shape of the reference's `STAGES`/`SETTLED-STAGES`, folded over +// `kinds.values()` instead of a hardcoded `LADDERS`. +#let _stages(kinds) = { + let all = () + for (_, spec) in kinds.pairs() { all += spec.ladder.transit + spec.ladder.terminal } + all.dedup() +} + +// ---- `venue` ------------------------------------------------------------------ +// +// A venue is what durably exists: a programme, a conference series, a journal. +// It carries no dates and is not a todo — the one constructor here `#cfp`'s +// closing mechanism does not touch. +#let venue(name, title: auto, call: none, school: none, tags: none, display-tags: true, ..args) = { + // `auto` titles a venue by its own id, the same resolution `#cfp` makes + // for the venue it names. `#idea` takes content or `none` and has no + // `auto`, so this cannot be left for it to sort out. + let title = if title != auto { title } else { raw(_norm(name)) } + let own = (:) + if call != none { own.insert(VENUE-CALL-KEY, call) } + if school != none { + own.insert(SCHOOL-KEY, if type(school) == array { school } else { (school,) }) + } + let pos = args.pos() + let body = if pos.len() == 0 { [] } else { pos.at(0) } + let full = { + _opportunity-table((("Call", call, "url"),)) + body + } + idea( + name, + title: title, + // Merges VENUE-KEY under the built tags, so a caller naming its own + // `tags:` cannot displace the package's key. + tags: _merge-base-tags(VENUE-KEY, own + normalize-tags(tags)), + display-tags: display-tags, + ..args.named(), + full, + ) +} + +// ---- Reading what actually happened, net of `deadline`/`scheduled` ---------- +// +// A cfp's own combined log also carries `deadline`/`scheduled` — the two +// reserved stage names @rookery/timeline folds into every dated note's log +// alongside whatever a call's own answer adds. Read raw, a lapsed deadline with +// nothing sent yet is the LATEST reached entry, so anything asking "what stage +// is this at" off the raw log risks reading an overdue-but-unanswered call as +// answered, or reading a real terminal stage dated BEFORE its own deadline (a +// call dropped early) as still "deadline". So the reserved names are stripped +// first, and only what is left — an actual application stage — answers either +// question. +// +// `CLOSED-STAGE` IS IN THE EXCLUSION TUPLE FROM THE START, unlike the reference +// this was ported from: this package's `#cfp` closes through the log itself +// (see `cfp` below), so its own `closed` entry would otherwise read back as the +// note's current stage, and every settled cfp would report itself "closed" +// instead of whatever it actually settled at. +#let _real-tags(tags) = { + let real = (:) + for e in timeline-of(tags) { + if e.stage not in (DEADLINE-STAGE, SCHEDULED-STAGE, CLOSED-STAGE) { real.insert(e.stage, e.timestamp) } + } + timeline-tags(timeline: real) +} + +// The stage ACTUALLY reached, net of `deadline`/`scheduled`/`closed`. `none` +// where nothing real has happened yet, whether because the log is empty or +// because only its reserved entries have been reached. +#let real-stage-of(tags, today: none) = stage-of(_real-tags(tags), today: today) + +// Four states, all derived from the (stripped) timeline: +// +// watching the raw log is empty; nothing announced yet +// open every real entry is still in the future (or there are none) +// in-flight the latest real entry that has happened is a transit stage +// settled that entry is a terminal one +#let cfp-state(tags, ladder: none, today: none) = { + if timeline-of(tags).len() == 0 { return "watching" } + let real-tags = _real-tags(tags) + if timeline-of(real-tags).len() == 0 { return "open" } + let s = stage-of(real-tags, today: today) + if s == none { return "open" } + if ladder != none and is-settled(real-tags, ladder: ladder, today: today) { return "settled" } + "in-flight" +} + +// ---- `cfps(kinds:)` — the factory --------------------------------------------- +// +// `kinds`: kind name -> (sort: , ladder: (transit: (..), terminal: (..))). +// Validated eagerly here, once, rather than on whichever `#cfp` call happens to +// hit a bad kind first. +#let cfps(kinds: (:)) = { + _assert-kinds(kinds) + let stages = _stages(kinds) + + // ---- `cfp` — one call, and what became of it ------------------------------- + // + // Built on @rookery/todos' `todo(..)` rather than on bare `idea(..)`: that + // package already forwards `deadline:`/`scheduled:`/`timeline:` to the shared + // log exactly as this needs, and adds `done:` — a real closing date, folded + // into the log as a `CLOSED-STAGE` entry — and `priority:`, which this + // constructor exposes directly rather than banning. + // + // `deps:`/`metadata:`/`active:`/`status:`/`type:` are deliberately NOT + // parameters here: `todo(..)` accepts all of them, but nothing about a cfp + // asks for @rookery/todos' dependency graph or its own todo-kind vocabulary. + // Left at `todo(..)`'s own defaults. + let cfp( + name, + venue: none, + title: auto, + kind: none, + deadline: none, + scheduled: none, + apply: none, + priority: none, + timeline: none, + work: none, + estimated: false, + today: none, + tags: none, + display-tags: true, + ..args, + ) = { + assert( + kind != none, + message: "@rookery/cfps: #cfp(" + repr(name) + ") needs a `kind:`, one of " + + repr(kinds.keys()) + ". Without one the call has no sort, so no ladder can " + + "say which of its answer's stages are final — they would read as in flight " + + "forever.", + ) + assert( + kind in kinds, + message: "@rookery/cfps: #cfp's `kind` must be one of " + repr(kinds.keys()) + + " — got " + repr(kind) + ".", + ) + + let log-stages = if timeline == none { (:) } else { timeline } + for (stage, _) in log-stages.pairs() { + assert( + stages.any(p => stage-matches(p, stage)), + message: "@rookery/cfps: #cfp(" + repr(name) + ")'s `timeline:` names the stage " + + repr(stage) + ", which no configured kind's ladder carries. Add the rung to " + + "the right kind's ladder rather than inventing one here: a stage no ladder " + + "names renders but cannot be placed.", + ) + } + + // `CFP-KEY` is stamped here rather than arriving from an + // `idea(CFP-KEY)` call: this `cfp` mints through @rookery/todos' + // `todo(..)`, which tags the note `todo`, so the bare "this is a cfp" + // marker every consumer filters on has to be part of `own` itself. + let own = ((VENUE-KEY + "-" + kind): none, (CFP-KEY): none) + let venue = if venue == none { none } else { _norm(venue) } + + // THE TITLE IS COMPOSED from the venue's id, the way the reference's own + // `#cfp` does — minus the `cycle:` half, which is not this package's + // business (a caller builds its own cycle-aware wrapper on top of this). + let title = if title != auto { title } else if venue == none { none } else { raw(venue) } + + own.insert(CFP-ID-KEY, name) + if venue != none { own.insert(CFP-VENUE-KEY, venue) } + if apply != none { own.insert(APPLY-KEY, apply) } + if work != none { own.insert(WORK-KEY, work) } + if estimated { own.insert(ESTIMATED-KEY, none) } + + let ladder = kinds.at(kind).ladder + let pos = args.pos() + let body = if pos.len() == 0 { [] } else { pos.at(0) } + + // `today:` resolves HERE, inside the one `context` block this note needs — + // for `target()`, exactly as the reference's own rail does — rather than a + // second one. `close-on` is computed in the same block, since it also needs + // a "now" to test `lapsed` against, and the whole note is minted from inside + // it so that `done:` carries the resolved value rather than an unresolved + // fallback. + context { + let resolved-today = _resolve-today(today) + let t = timeline-tags(deadline: deadline, scheduled: scheduled, timeline: timeline) + + // THE CLOSE DATE, not a bool. The earliest REAL (non-reserved) answer if + // one exists — the day applying stopped being outstanding work — else the + // deadline itself if that has lapsed with nothing sent, else `none` (still + // open work). + let lapsed = deadline != none and resolved-today > deadline + let real-dates = log-stages + .pairs() + .map(p => { + let v = p.at(1) + if type(v) == datetime { v } else { v.at("timestamp", default: none) } + }) + .filter(d => d != none) + let close-on = if real-dates.len() > 0 { + real-dates.sorted().first() + } else if lapsed { + deadline + } else { none } + + // THE RAIL — the WHOLE combined log, `deadline`/`scheduled` included, in + // the order it actually happened, rather than treating the wire as a fact + // stated elsewhere. HTML only: `html.elem` means nothing on a paged + // target, where `timeline-view` renders its own list. + let rail = { + if target() == "html" and timeline-of(t).len() > 0 { + html.elem("p", attrs: (class: "idea-timeline-head"), "Timeline") + } + timeline-view((:), t, today: resolved-today, ladder: ladder) + } + + // ORDER: the table, then the rail, then the prose, then the venue + // backlink — the fixed facts first, the story of what happened next, + // then the commentary on both. + let full = { + _opportunity-table((("Work", work, "path"),)) + rail + parbreak() + body + if venue != none { + parbreak() + ref(label("idea:" + venue)) + } + } + + todo( + name, + title: title, + deadline: deadline, + scheduled: scheduled, + timeline: timeline, + done: close-on, + priority: priority, + tags: own + normalize-tags(tags), + display-tags: display-tags, + ..args.named(), + full, + ) + } + } + + (venue: venue, cfp: cfp, cfp-state: cfp-state) +} diff --git a/cfps/0.1.0/src/cfps.css b/cfps/0.1.0/src/cfps.css new file mode 100644 index 00000000..705620d8 --- /dev/null +++ b/cfps/0.1.0/src/cfps.css @@ -0,0 +1,357 @@ +/* @rookery/cfps — the rounds table `#panel` draws, and the two-column metadata + block `#venue`/`#cfp` open with (`_opportunity-table` in `cfp.typ`). + + Structural only, like `@rookery/timeline`'s and `@rookery/todos`' own + stylesheets: every colour and every site-shaped size is a custom property + with a generic fallback, never a literal borrowed from a particular site's + palette — a consuming project sets `--cfps-*` once and this whole panel + follows. + + THE LAYER, for the same reason `@rookery/timeline`'s `timeline.css` states + it: rheo links a package's stylesheet after the project's own in package + RESOLUTION order, not necessarily last, so this file cannot assume it wins a + specificity tie. Wrapping it in `@layer cfps` inverts that — any unlayered + rule in a project's own CSS beats anything here regardless of specificity. + + THE PROPERTIES: + + --cfps-gutter width of the date column, shared with `.opportunity-meta` + --cfps-edge hairline borders (row dividers, the metadata block, the + countdown tooltip) + --cfps-muted a soft/watch date, the school column, the match line + --cfps-fg emphasised text — a firm date, a title link, banded ink + --cfps-link a title/school link + --cfps-mark the open group's first-row tidemark + --cfps-focus the countdown tooltip's focus outline + --cfps-band-* the seven countdown wash colours (`overdue` solid, the + other six washes), falling back to the family-wide + `--rookery-heat-urgent/soon/later` ramp `@rookery/todos`' + own bands already read, then to a literal + --cfps-rung-* the five priority-only washes, same fallback chain + + `.round-badge`'s own ink and radius fall back to `@rookery/core`'s published + `--idea-tag-*` properties instead of a `--cfps-*` one of their own — a badge + here is a rookery tag chip, the same object a pill on a note's hat is, so it + takes that object's colour rather than inventing a second one. */ +@layer cfps { + .round-list { + list-style: none; + margin: 1rem 0; + padding: 0; + } + + /* Four tracks — date, title, school, verdict — align down the page: a column + of dates that does not line up is a column read one row at a time. */ + .round-row { + display: grid; + grid-template-columns: var(--cfps-gutter, 7.5em) 1fr auto auto; + gap: 0 0.9rem; + align-items: baseline; + padding: 0.45rem 0; + border-bottom: 1px solid var(--cfps-edge, GrayText); + } + + .round-row:last-child { + border-bottom: none; + } + + /* A row carried over from a past cycle's call rather than read off this + round's own dims as a whole, rather than any single column of it — the + uncertainty is about the entry, not about one field. */ + .round-row.idea-tag-submission-estimated { + opacity: 0.6; + } + + .round-when { + font-family: ui-monospace, monospace; + font-size: 0.8em; + font-variant-numeric: tabular-nums; + color: var(--cfps-fg, inherit); + white-space: nowrap; + } + + /* A watch-date, not a deadline — it shares the date column because it + answers the same question ("when does this need me"), so it reads as the + softer of the two rather than a date of equal standing. */ + .round-when.soft { + color: var(--cfps-muted, GrayText); + font-style: italic; + } + + .round-title a { + color: var(--cfps-fg, inherit); + } + + .round-title a:hover { + color: var(--cfps-link, LinkText); + } + + /* Between the title and the verdict: left-aligned prose-adjacent text, since + a school is part of naming the row rather than a badge on it. */ + .round-school { + font-size: 0.8em; + color: var(--cfps-muted, GrayText); + white-space: nowrap; + } + + .round-school a { + color: var(--cfps-muted, GrayText); + } + + .round-school a:hover { + color: var(--cfps-link, LinkText); + } + + /* ONE CHIP, EVERYWHERE: a badge here is a rookery tag chip (`idea-tag` + + `idea-tag-`), the same two classes a pill on a note's hat wears — + see `@rookery/core`'s own generated `idea-tag-*` rules for the colour a + themed name publishes onto them. Outline only: a solid chip in the tag's + own hue would put the label on a background of the same colour, unreadable + by construction. */ + .round-badge { + font-size: 0.8em; + line-height: 1; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 0.1em 0.5em; + border-radius: var(--idea-tag-radius, 999px); + white-space: nowrap; + color: var(--idea-tag-color, var(--idea-name-color, GrayText)); + background-color: transparent; + border: 1px solid var(--idea-tag-line, var(--cfps-edge, GrayText)); + } + + /* A transit stage is the qualifier, a terminal one the answer — so a stage's + chip loses its outline and reads as a word beside the verdict rather than + as a second verdict. */ + .round-badge-stage { + border-color: transparent; + padding-left: 0.1em; + padding-right: 0.1em; + } + + /* THE TIDEMARK lands on the date of the first open row. Open rounds sort + oldest first, so that row is either the next thing due or the one already + overdue — the one thing on the page worth marking. `:not([data-countdown])` + keeps a banded date and a marked one off the same cell: a banded date + already carries a stronger signal than "first" does. `Mark`/the system + highlight colour, since this is exactly what `` means. */ + .round-list-open .round-row:first-child .round-when:not([data-countdown]) { + color: var(--cfps-fg, inherit); + background-color: var(--cfps-mark, Mark); + border-radius: 3px; + padding: 0 0.3em; + margin-left: -0.3em; + } + + /* The verdict cell: how far a call got, then what came back. Two spans + rather than one string, so a stage can stand alone — reached but not yet + answered is a real state this record has to be able to say. */ + .round-verdict { + display: flex; + gap: 0.35rem; + align-items: baseline; + } + + /* The matched application's own path, under the title on its own line — a + filename, not prose, so it does not compete with the title for the grid's + date-aligned column. */ + .round-match { + grid-column: 2 / -1; + font-size: 0.8em; + color: var(--cfps-muted, GrayText); + } + + /* THE COUNTDOWN IS THE DATE CELL'S OWN BACKGROUND, not a fifth span: the row + is a four-track grid with no track to auto-place a fifth thing into, and a + dropped span silently doubled the row's height. The colour is the reading + at a glance; the phrase below is a tooltip for whoever wants the number. */ + .round-when[data-countdown] { + position: relative; + color: var(--cfps-fg, inherit); + border-radius: 3px; + padding: 0 0.3em; + margin-left: -0.3em; + cursor: help; + } + + /* THREE WEEKS, seven steps, two per hue past the overdue one — a deadline is + felt in weeks, so this week reads red, next week orange, the one after + yellow, each darkening toward its front. `--cfps-band-` is the + per-band override; under it, the family-wide `--rookery-heat-*` ramp + `@rookery/todos` already reads, so a site setting that ramp once themes + every heat chip in the family, this one included. */ + .round-when-overdue { + background-color: var(--cfps-band-overdue, var(--rookery-heat-urgent, #b3261e)); + color: var(--cfps-band-overdue-fg, #fff); + } + + .round-when-imminent { + background-color: var( + --cfps-band-imminent, + color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 40%, transparent) + ); + } + + .round-when-urgent { + background-color: var( + --cfps-band-urgent, + color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 26%, transparent) + ); + } + + .round-when-soon { + background-color: var( + --cfps-band-soon, + color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 32%, transparent) + ); + } + + .round-when-near { + background-color: var( + --cfps-band-near, + color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 18%, transparent) + ); + } + + .round-when-approaching { + background-color: var( + --cfps-band-approaching, + color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 28%, transparent) + ); + } + + .round-when-distant { + background-color: var( + --cfps-band-distant, + color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 15%, transparent) + ); + } + + /* THE PRIORITY RAMP, only where the countdown has nothing to say — a call + three weeks or more out never earns a band above, so a high-priority one + sitting far out would otherwise read exactly like a low-priority one + beside it. Five rungs, the two coolest falling back to a neutral wash + rather than a fourth hue, since nothing above claims one. */ + .round-when-rung-0 { + background-color: var( + --cfps-rung-0, + color-mix(in oklab, var(--rookery-heat-urgent, #b3261e) 24%, transparent) + ); + } + + .round-when-rung-1 { + background-color: var( + --cfps-rung-1, + color-mix(in oklab, var(--rookery-heat-soon, #b3611e) 20%, transparent) + ); + } + + .round-when-rung-2 { + background-color: var( + --cfps-rung-2, + color-mix(in oklab, var(--rookery-heat-later, #b38f1e) 18%, transparent) + ); + } + + .round-when-rung-3 { + background-color: var(--cfps-rung-3, color-mix(in oklab, currentColor 12%, transparent)); + } + + .round-when-rung-4 { + background-color: var(--cfps-rung-4, color-mix(in oklab, currentColor 6%, transparent)); + } + + /* A banded date is not soft-grey: sitting on a wash it has to hold its own + ink against it, so the colour comes back. The italic stays — that half of + the distinction still reads. */ + .round-when.soft[data-countdown] { + color: var(--cfps-fg, inherit); + } + + /* THE PHRASE, on demand — drawn rather than handed to `title:`, since a + native tooltip cannot be styled, opens on a delay, and would appear beside + this one rather than instead of it. `:focus` (not `:focus-visible`) is + what makes it reachable on a touch screen, where a tap is the only hover + there is. Above the cell, so it never covers the row beneath. */ + .round-when[data-countdown]:hover::after, + .round-when[data-countdown]:focus::after { + content: attr(data-countdown); + position: absolute; + bottom: calc(100% + 0.35rem); + left: 50%; + transform: translateX(-50%); + z-index: 5; + padding: 0.15em 0.5em; + border: 1px solid var(--cfps-edge, GrayText); + border-radius: 3px; + background-color: var(--cfps-tooltip-bg, Canvas); + color: var(--cfps-fg, CanvasText); + font-size: 0.8em; + font-style: normal; + line-height: 1.4; + white-space: nowrap; + pointer-events: none; + } + + .round-when[data-countdown]:focus { + outline: 1px solid var(--cfps-focus, Highlight); + outline-offset: 1px; + } + + .round-empty { + color: var(--cfps-muted, GrayText); + font-style: italic; + } + + /* `#cfp`'s own two-column metadata table (`_opportunity-table` in + `cfp.typ`) — this class is not published by any rookery package, `#cfp` + being the one thing minting it, so the grid itself lives here. Shares + `--cfps-gutter` with `.round-row` above, so a reader who has just read a + rounds table sees the same column line up on the note it links to. */ + .opportunity-meta { + display: grid; + grid-template-columns: var(--cfps-gutter, 7.5em) 1fr; + column-gap: 0.9rem; + margin: 0 0 1.2rem; + border-top: 1px solid var(--cfps-edge, GrayText); + } + + .opportunity-meta dt, + .opportunity-meta dd { + padding: 0.4rem 0; + border-bottom: 1px solid var(--cfps-edge, GrayText); + } + + .opportunity-meta dt { + color: var(--cfps-muted, GrayText); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* `margin: 0` is load-bearing, not tidy: a browser's default `
` carries + `margin-inline-start: 40px`, which in a grid cell indents the value away + from its own column. */ + .opportunity-meta dd { + margin: 0; + } + + /* The date column stops being a column below 768px: a fixed gutter beside a + title leaves the title wrapping at two words. */ + @media (max-width: 768px) { + .round-row { + grid-template-columns: 1fr auto; + } + + .round-when, + .round-school, + .round-match { + grid-column: 1 / -1; + } + + .round-when { + justify-self: start; + } + } +} diff --git a/cfps/0.1.0/src/lib.typ b/cfps/0.1.0/src/lib.typ new file mode 100644 index 00000000..6b2acf92 --- /dev/null +++ b/cfps/0.1.0/src/lib.typ @@ -0,0 +1,14 @@ +#import "cfp.typ": * +#import "cfp.typ" as _cfp +#import "panel.typ": * + +// `cfps(kinds:)` STILL RESOLVES TO ONE FACTORY, not two. `cfp.typ`'s own +// `cfps(kinds:)` cannot build `panel:` itself: doing so needs `panel.typ`, +// which imports `cfp.typ` for its constants and `real-stage-of`/`cfp-state` — +// a package cycle either way round. So this module SHADOWS the star-imported +// `cfps` with one that calls straight through to `cfp.typ`'s own (reached here +// via the aliased import, since the shadow below cannot see itself) and merges +// in `panel:` — the same "a later top-level `#let` shadows a star-imported +// name" device `@rookery/timeline`'s own `lib.typ` uses to decorate +// `idea`. +#let cfps(kinds: (:)) = (.._cfp.cfps(kinds: kinds), panel: _make-panel(kinds)) diff --git a/cfps/0.1.0/src/panel.typ b/cfps/0.1.0/src/panel.typ new file mode 100644 index 00000000..c071af08 --- /dev/null +++ b/cfps/0.1.0/src/panel.typ @@ -0,0 +1,293 @@ +// @rookery/cfps — the rounds table: one row per call, `when | title | school | +// verdict`, in date order. +// +// Ported from the reference site's own `#cfps` panel, minus the vocabulary that +// belongs to that SITE rather than to a venue/cfp pair: no `sort:` (the site's own +// job/conference/journal split), no `cycle:` (a caller's own tagging convention, +// reached instead through `tags:` below), and no `HIDDEN-STAGES` filtering (that +// belongs to `@rookery/todos`' `#window`, a different package). +// +// `CFP-KEY` IS THE ROW FILTER — the bare marker `#cfp` stamps on every note it +// mints (`cfp.typ`'s `own`). It cannot come from an `idea(CFP-KEY)` call, since +// a cfp mints through @rookery/todos' `todo(..)`, so the two halves of this +// package meet on that one key. + +#import "cfp.typ": * +#import "@rookery/core:0.1.0": ideas +#import "@rookery/timeline:0.1.0": deadline-of, scheduled-of +#import "@rookery/todos:0.1.0": priority-of, priority-rung + +// The reference date is always an explicit `today:` argument (a private copy of +// `cfp.typ`'s own `_resolve-today` — Typst has no wall clock, so there is nothing +// to fall back to). +#let _resolve-today(today) = { + assert( + type(today) == datetime, + message: "@rookery/cfps: #panel needs a reference date — pass one explicitly, " + + "e.g. `today: datetime(year: 2026, month: 8, day: 25)`. Got " + repr(today), + ) + today +} + +// `tags:` narrows a panel to whatever grouping the CALLER's site wants (a cycle, a +// kind not already filtered by `state:`, anything else its own tagging convention +// names) — `none`, one tag name, or an array of them, the same three forms +// `#ideas`' own `tags:` accepts. +#let _as-tag-array(tags) = { + if tags == none { + () + } else if type(tags) == str { + (tags,) + } else if type(tags) == array { + tags + } else { + panic("@rookery/cfps: panel's `tags:` must be none, a string, or an array of tag names — got " + repr(tags)) + } +} + +// THE NEXT THING DUE on a note's own log, whatever rung it sits on — the date a +// round row shows once something has answered its call. A call's own deadline is +// the useful date right up until a submission exists; after that it is what the +// attempt itself still waits on (a revision, a resubmission, a booked interview), +// which is why this asks the whole log for its next unpassed entry rather than +// naming one stage. `none` where nothing is still ahead — a settled attempt has +// nothing outstanding, which is the honest reading. +#let _next-open-date(tags, today: none) = { + if today == none { return none } + let ahead = timeline-of(tags).filter(e => e.timestamp > today) + if ahead.len() == 0 { none } else { ahead.first().timestamp } +} + +#let _fmt-date(d) = d.display("[day padding:none] [month repr:short] [year]") + +// A venue's title/href and its schools' labels, by venue NAME — one pass over +// `ideas(values: true)`, cached in a dictionary, rather than a lookup per row. +// The join this package owns is cfp -> venue -> schools; kind/sort/dates stay on +// the cfp's own row, exactly as the ontology already splits them. +#let _venue-index(all) = { + let by-name = all.map(r => (r.name, r)).to-dict() + let school-of(name) = { + let s = by-name.at(name, default: none) + if s == none { none } else { (label: s.label, href: s.href) } + } + name => { + let v = by-name.at(name, default: none) + if v == none { + (title: none, href: none, schools: ()) + } else { + ( + title: v.label, + href: v.href, + schools: v.tags-dict.at(SCHOOL-KEY, default: ()).map(school-of).filter(x => x != none), + ) + } + } +} + +// The `panel:` method `cfps(kinds:)` returns — bound to the SAME `kinds`/ladder +// configuration the factory's `cfp` resolves against, so a row's ladder never +// disagrees with the note it was minted with. +#let _make-panel(kinds) = { + let panel(tags: none, state: "open", countdown: false, empty: [Nothing here.], today: none) = context { + let resolved-today = _resolve-today(today) + let want-state = if type(state) == array { state } else { (state,) } + let want = (CFP-KEY,) + _as-tag-array(tags) + + let all = ideas(values: true) + let venue-of = _venue-index(all) + + let words(s) = s.replace("-", " ") + + let rows = ideas(tagged: want, match: "all", values: true) + .map(r => { + let t = r.tags-dict + let kind = kinds.keys().find(k => (VENUE-KEY + "-" + k) in t) + let ladder = if kind == none { none } else { kinds.at(kind).ladder } + let dl = deadline-of(t) + let sch = scheduled-of(t) + // WHAT HAS ACTUALLY BEEN ANSWERED, net of the reserved stage names — see + // `real-stage-of`'s own comment in `cfp.typ` for why the raw log cannot + // answer this. + let rs = real-stage-of(t, today: resolved-today) + let answered = rs != none + let nxt = _next-open-date(t, today: resolved-today) + let st = cfp-state(t, ladder: ladder, today: resolved-today) + let venue-name = t.at(CFP-VENUE-KEY, default: none) + let v = if venue-name == none { (title: none, href: none, schools: ()) } else { venue-of(venue-name) } + ( + label: r.label, + target: r.href, + schools: v.schools, + kind: kind, + stage: rs, + // The call's own priority — how much it matters, unchanged by what has + // been sent. + priority: priority-of(t), + // Unanswered: the call's own wire. Answered: only what is still + // outstanding. + when: if answered { nxt } else if dl != none { dl } else { sch }, + firm: if answered { nxt != none } else { dl != none }, + state: st, + settled-stage: if st == "settled" { rs } else { none }, + transit-stage: if st == "in-flight" { rs } else { none }, + tags-dict: t, + work: t.at(WORK-KEY, default: none), + ) + }) + .filter(r => want-state.contains(r.state)) + + // A plain string sort in date order, undated rows falling to the end. + let dated = rows.filter(r => r.when != none) + let undated = rows.filter(r => r.when == none) + let rows = dated.sorted(key: r => r.when) + undated + + // The rung ramp is relative to what's actually on THIS page: the distinct + // non-zero priorities among these rows, largest (most important) first. + let scale = rows.map(r => r.priority).filter(p => p > 0).dedup().sorted().rev() + + // PAGED: no anchor to click and no grid to align, so the rows become a plain + // Typst list. + if target() != "html" { + if rows.len() == 0 { return text(gray, emph(empty)) } + return list( + ..rows.map(r => { + let d = r.when + if d != none { [#_fmt-date(d)#if not r.firm { [ (expected)] } — ] } + r.label + if r.schools.len() > 0 { [ #text(gray, "[" + r.schools.map(s => s.label).join(", ") + "]")] } + let parts = (r.kind, r.stage).filter(s => s != none).map(words) + if parts.len() > 0 { [ #text(gray, "(" + parts.join(", ") + ")")] } + if r.work != none { [ #text(gray, "→ " + r.work)] } + }), + ) + } + + if rows.len() == 0 { + return html.elem("p", attrs: (class: "round-empty"), empty) + } + + // The state rides on the list because one rule needs it: the first row's date + // is lit only in the open group, where the top row is either next or already + // overdue. + let list-cls = (("round-list",) + want-state.map(s => "round-list-" + s)).join(" ") + html.elem( + "ul", + attrs: (class: list-cls), + rows + .map(r => html.elem( + "li", + attrs: ( + // `submission-estimated` is what dims a row, read straight off the + // call's own tags. The kind rides on the CHIP rather than the row, + // because a themed tag's colour is a custom property and a custom + // property inherits — with the kind's class on the row, a rejected + // chip would come out tenure-track orange. + class: (("round-row",) + r.tags-dict.keys().map(k => "idea-tag-" + k)).join(" "), + ), + { + let d = r.when + // THE COUNTDOWN IS THE DATE CELL, not a fifth thing beside it — see + // `cfps.css`'s own comment on `.round-when[data-countdown]` for why. + // Three weeks, seven steps, two per hue past the overdue one. + let band = if not countdown or d == none { none } else { + let days = (d - resolved-today).days() + if days < 0 { "overdue" } else if days <= 3 { "imminent" } else if days <= 7 { "urgent" } else if ( + days <= 11 + ) { "soon" } else if days <= 14 { "near" } else if days <= 18 { "approaching" } else if days <= 21 { + "distant" + } else { none } + } + let phrase = if band == none { none } else { + let days = (d - resolved-today).days() + if days == -1 { "yesterday" } else if days < 0 { str(-days) + " days ago" } else if days == 0 { + "today" + } else if days == 1 { "tomorrow" } else { "in " + str(days) + " days" } + } + // PRIORITY, only where the countdown has nothing to say — a call + // three weeks or more out never earns a band above, so this closes + // that gap on the same date cell rather than a second one. + let rung = priority-rung(r.priority, scale, rungs: 5) + let pri-band = if band != none or d == none or rung == none { none } else { + "rung-" + str(rung) + } + let phrase = if phrase != none { phrase } else if pri-band == none { none } else { + "priority " + str(r.priority) + } + let when-cls = ( + ("round-when",) + + (if r.firm { () } else { ("soft",) }) + + ( + if band != none { ("round-when-" + band,) } else if pri-band != none { + ("round-when-" + pri-band,) + } else { () } + ) + ).join(" ") + let when-attrs = if phrase == none { (:) } else { + ( + "data-countdown": phrase, + "aria-label": _fmt-date(d) + ", " + phrase, + "tabindex": "0", + ) + } + html.elem( + "span", + attrs: (class: when-cls) + when-attrs, + if d == none { [—] } else { + html.elem("time", attrs: (datetime: d.display("[year]-[month]-[day]")), _fmt-date(d)) + }, + ) + html.elem("span", attrs: (class: "round-title"), { + if r.target == none { r.label } else { + html.elem("a", attrs: (href: r.target), r.label) + } + }) + // Its own column: a school is a fact a reader scans down for, not a + // clause folded into the title. + html.elem( + "span", + attrs: (class: "round-school"), + r + .schools + .map(s => if s.href == none { s.label } else { html.elem("a", attrs: (href: s.href), s.label) }) + .join([ \/ ]), + ) + // The verdict cell: a stage and an outcome are one vocabulary split + // by the ladder — the transit rung is the qualifier, the terminal one + // the answer. Each badge is a rookery tag chip (`idea-tag` + + // `idea-tag-`), so a themed name colours itself the same way + // any other pill on a note's hat does. + if r.kind != none or r.stage != none { + html.elem("span", attrs: (class: "round-verdict"), { + if r.kind != none { + html.elem( + "span", + attrs: (class: "round-badge idea-tag idea-tag-" + VENUE-KEY + "-" + r.kind), + r.kind, + ) + } + if r.transit-stage != none { + html.elem( + "span", + attrs: (class: "round-badge round-badge-stage idea-tag idea-tag-" + r.transit-stage), + words(r.transit-stage), + ) + } + if r.settled-stage != none { + html.elem( + "span", + attrs: (class: "round-badge round-badge-outcome idea-tag idea-tag-" + r.settled-stage), + words(r.settled-stage), + ) + } + }) + } + if r.work != none { + html.elem("span", attrs: (class: "round-match"), raw(r.work)) + } + }, + )) + .join(), + ) + } + panel +} diff --git a/cfps/0.1.0/test/check.sh b/cfps/0.1.0/test/check.sh new file mode 100755 index 00000000..b87f30c4 --- /dev/null +++ b/cfps/0.1.0/test/check.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Asserts on the rendered fixture's OUTPUT, not merely that it compiled. +# `units.typ` covers every value; this covers the markup — that a rail draws +# from `deadline:` alone, that the opportunity table sits between the title and +# the rail, that a venue backlink is present only where `venue:` was given, and +# that `panel:`'s rows carry the right classes for settled/open/watching — plus +# one thing neither fixture can check: that a bad `kind:` fails LOUDLY, which +# needs its own `typst compile` this script expects to fail. +set -euo pipefail +cd "$(dirname "$0")/.." +H=test/build/view.html +[ -f "$H" ] || { echo "FAIL: no $H — run 'just test' first"; exit 1; } + +python3 - "$H" <<'PY' +import re, sys +h = open(sys.argv[1]).read() +fail = 0 +def note(msg): + global fail + print("FAIL:", msg); fail = 1 + +# One card per note, sliced on the note's own anchor id. +def card(name): + i = h.find('id="idea:%s"' % name) + if i < 0: + note("no card for %s" % name); return "" + j = h.find('', i) + return h[i:j] + +deadline_only = card("acme-deadline-only") +settled = card("acme-settled") +watching = card("no-venue-watching") + +# 1. THE RAIL DRAWS FROM `deadline:` ALONE — no `timeline:` was given here, and +# the rail is the whole point of a call whose only fact so far is its wire. +if '
    ' not in deadline_only: + note("deadline-only: no rail drawn from deadline: alone") +if "idea-timeline-head" not in deadline_only: + note("deadline-only: no 'Timeline' head above the rail") + +# 2. ORDER: opportunity table, then the rail, then the prose, then the venue +# backlink — `acme-settled` is the one fixture carrying `work:`, so the +# table actually renders and its position can be checked. +o_dl = settled.find('class="opportunity-meta"') +o_rail = settled.find('class="idea-timeline-head"') +o_body = settled.find("Answered and settled") +o_ref = settled.find('class="idea-ref"') +if not (0 < o_dl < o_rail < o_body < o_ref): + note("settled: blocks out of order (table %d, rail head %d, body %d, ref %d)" + % (o_dl, o_rail, o_body, o_ref)) +if "
    Work
    " not in settled: + note("settled: opportunity table missing its Work row") + +# 3. A VENUE BACKLINK appears only where `venue:` was given. +if "Acme University" not in deadline_only or "Acme University" not in settled: + note("a cfp naming a venue drew no backlink to it") +if "Acme University" in watching or "idea-ref" in watching: + note("a cfp naming no venue drew a backlink anyway") + +# 4. `panel:`'s rows carry the right classes for settled / open / watching. +def round_list(state): + m = re.search(r'
      (.*?)
    ' % state, h, re.S) + return m.group(1) if m else None + +settled_row = round_list("settled") +if settled_row is None: + note("no round-list-settled panel rendered") +elif "round-badge-outcome" not in settled_row or "idea-tag-offered" not in settled_row: + note("settled row: no terminal-stage outcome badge") + +open_row = round_list("open") +if open_row is None: + note("no round-list-open panel rendered") +elif "round-when soft" in open_row or "—<" not in watching_row: + note("watching row: a call with no wire at all did not render as undated/soft") + +# 5. THE EMPTY STATE: nothing in this fixture ever reaches "in-flight". +if 'class="round-empty"' not in h: + note("no empty-panel state rendered for the state nothing in this fixture reaches") + +sys.exit(fail) +PY +echo "view OK" + +# 6. A `kind:` outside `kinds:` fails LOUDLY — cannot be asserted inside +# units.typ, since a failing assert aborts that whole compile rather than +# being catchable. So this compiles a small standalone fixture EXPECTED to +# fail, and checks the error names the valid kinds rather than crashing +# somewhere unrecognisable. +BAD=test/build/bad-kind.typ +cat > "$BAD" <<'TYP' +#import "/src/lib.typ": cfps +#let cfp = cfps(kinds: ( + postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered",))), +)).cfp +#cfp("bad-kind", kind: "not-a-real-kind", today: datetime(year: 2026, month: 1, day: 1))[Bad.] +TYP +if OUT=$(typst compile --features html --root . --format html "$BAD" test/build/bad-kind.html 2>&1); then + echo "FAIL: a bad kind: compiled instead of failing loudly" + exit 1 +fi +if ! echo "$OUT" | grep -q "postdoc"; then + echo "FAIL: the bad-kind error does not name the valid kinds:" + echo "$OUT" + exit 1 +fi +echo "bad-kind OK" diff --git a/cfps/0.1.0/test/units.typ b/cfps/0.1.0/test/units.typ new file mode 100644 index 00000000..a79b9b26 --- /dev/null +++ b/cfps/0.1.0/test/units.typ @@ -0,0 +1,128 @@ +// Unit fixture: every VALUE `@rookery/cfps` derives — `cfp-state`/`real-stage-of`'s +// four-state ladder logic against hand-built tag dictionaries, the tags `#cfp` +// stamps in its own `own` dictionary, and that closing goes through the shared +// timeline log rather than a flat flag. No runner: an `assert` failing fails the +// compile with a line number, and a passing compile is the green light. The +// MARKUP `#cfp`/`#venue`/`panel:` draw is `test/view.typ`'s business, which needs +// an HTML target this one does not. +// +// One case from the list below is NOT here: "a `kind:` outside `kinds:` fails +// loudly" cannot be asserted inside this file, because a failing `assert`/`panic` +// aborts the whole compile rather than being catchable — Typst has no try/catch. +// `test/check.sh` covers it instead, as a second `typst compile` invocation this +// package EXPECTS to fail, with its stderr checked for the valid-kinds message. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": rookery, tag-data, idea-tag-names +#import "@rookery/timeline:0.1.0": CLOSED-STAGE, timeline-tags, has-stage + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 6, day: 15) +#let day(n) = datetime(year: 2026, month: 1, day: n) + +#let JOB-LADDER = (transit: ("submitted", "interview"), terminal: ("offered", "rejected")) +#let JOURNAL-LADDER = (transit: ("submitted", "review-*"), terminal: ("accepted", "desk-rejected")) +#let KINDS = ( + job: (sort: "job", ladder: JOB-LADDER), + journal: (sort: "journal", ladder: JOURNAL-LADDER), +) + +// `cfp-state`/`real-stage-of` are pure functions of a tag dictionary and a +// ladder — neither needs a `#cfp` mint, so the four cases below build the log +// straight with `timeline-tags()`, the way `cfp.typ`'s own header worked out the bug +// each one guards. + +// 1. A lapsed, unanswered call reads as "open", never "in-flight" — reading the +// RAW log would call this "in-flight" because the deadline is the latest +// reached entry and belongs to no ladder's terminal list. +#let lapsed-tags = timeline-tags(deadline: day(1)) +#assert.eq( + cfp-state(lapsed-tags, ladder: JOB-LADDER, today: TODAY), + "open", + message: "a lapsed, unanswered call did not read as open", +) + +// 2. A stage dated BEFORE the deadline is still detected. Raw `stage-of` would +// pick the deadline itself, at day 10 — the latest entry that has passed — +// and silently mask the real answer dated day 5. +#let early-answer-tags = timeline-tags(deadline: day(10), timeline: (dropped: day(5))) +#assert.eq( + real-stage-of(early-answer-tags, today: day(15)), + "dropped", + message: "a real answer dated before the deadline was masked by it", +) + +// 3. `venue:` is optional on `#cfp` — a cfp naming no venue still builds +// successfully (the assertions below would not run at all had minting +// panicked) and stamps no `CFP-VENUE-KEY`, rather than crashing reading a +// `none` venue as a title fallback. +#let cfp = cfps(kinds: KINDS).cfp +#cfp("no-venue-unit", kind: "job", deadline: day(20), today: TODAY)[No venue named.] +#context { + let t = tag-data().at("idea:no-venue-unit") + assert(CFP-VENUE-KEY not in t, message: "a cfp with no venue stamped a venue key anyway") +} + +// 4. See the file header: a bad `kind:` is `test/check.sh`'s job, not this +// file's — it cannot be asserted inside a compile it would abort. + +// 5. Every stage in every configured kind's ladder is standardized. For EACH +// name in EACH kind's `transit`/`terminal` (a `-*` family rung expanded to a +// concrete instance, e.g. `review-1`), mint a `#cfp` whose `timeline:` uses +// only that stage and check it does not raise (the mint itself is the check — +// a stage the `stage in STAGES` guard rejects would panic here) and that +// `cfp-state` reads it as the right half of the ladder. This is what would +// catch a stage falling through to some path other than `@rookery/timeline`'s +// own — a per-kind flat tag, a bespoke `outcome:` argument, anything that +// records a stage somewhere other than the shared log. +#let expand(pattern) = if pattern.ends-with("-*") { pattern.slice(0, pattern.len() - 1) + "1" } else { pattern } + +#for (kind-name, spec) in KINDS.pairs() { + for (bucket, want-state) in (("transit", "in-flight"), ("terminal", "settled")) { + for pattern in spec.ladder.at(bucket) { + let stage = expand(pattern) + let name = "ladder-" + kind-name + "-" + stage + let tl = (:) + tl.insert(stage, day(1)) + cfp(name, kind: kind-name, timeline: tl, today: day(1))[Ladder coverage.] + context { + let t = tag-data().at("idea:" + name) + assert.eq( + cfp-state(t, ladder: spec.ladder, today: day(1)), + want-state, + message: "stage " + repr(stage) + " of " + repr(kind-name) + "'s " + bucket + " did not read as " + want-state, + ) + } + } + } +} + +// 6. Closing is real, not a flag — both a settled cfp and a merely-lapsed one +// (deadline behind us, nothing ever sent) must show `has-stage(.., CLOSED-STAGE)` +// true on their OWN minted tags, because `@rookery/todos`' `is-closed` (and +// everything built on it, like a consumer's `todo-table`) reads exactly that +// entry, not a flat boolean this package could get away with faking. +#cfp( + "closing-settled", + kind: "job", + deadline: day(1), + timeline: (submitted: datetime(year: 2025, month: 12, day: 1), offered: day(10)), + today: TODAY, +)[Answered and settled.] +#cfp("closing-lapsed", kind: "job", deadline: day(1), today: TODAY)[Lapsed, nothing sent.] +#context { + for name in ("closing-settled", "closing-lapsed") { + let t = tag-data().at("idea:" + name) + assert( + has-stage(t, CLOSED-STAGE), + message: name + " did not close through the shared timeline log", + ) + } +} + +// 7. `#venue`'s own default must not reach `#idea` unresolved — `auto` is +// not a title `#idea` accepts, and reaching it there fails the compile. +#venue("units-venue-auto")[A venue with no authored title.] +#context { + assert.eq(idea-tag-names("units-venue-auto"), ("venue",)) +} diff --git a/cfps/0.1.0/test/view.typ b/cfps/0.1.0/test/view.typ new file mode 100644 index 00000000..12dc782a --- /dev/null +++ b/cfps/0.1.0/test/view.typ @@ -0,0 +1,60 @@ +// Rendered fixture: the MARKUP `#venue`/`#cfp`/`panel:` draw, which +// `test/units.typ` cannot see — that the in-body rail renders whenever ANY of +// `deadline`/`scheduled`/`timeline:` is given (not only `timeline:`), that the +// opportunity table lands below the title and above that rail, that a venue +// backlink appears only when `venue:` was given, and that `panel:`'s rows carry +// the right classes for a settled, an open, and a watching call. Asserted by +// `test/check.sh` against the built HTML, because document order and CSS class +// are facts about the markup, not about a value. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": rookery + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 6, day: 15) +#let (venue, cfp, cfp-state, panel) = cfps(kinds: ( + postdoc: (sort: "job", ladder: (transit: ("submitted",), terminal: ("offered", "rejected"))), +)) + +#venue("acme", title: [Acme University], call: "https://acme.example/apply")[The standing call.] + +// DEADLINE ONLY: no `timeline:` at all — the rail still draws, from `deadline:` +// alone. OPEN: the deadline is still ahead. +#cfp( + "acme-deadline-only", + venue: , + kind: "postdoc", + deadline: datetime(year: 2026, month: 8, day: 1), + today: TODAY, +)[ + Only a deadline, nothing sent. +] + +// SETTLED, and carries `work:` — the one fixture exercising the opportunity +// table, so its order relative to the rail and the title can be checked. +#cfp( + "acme-settled", + venue: , + kind: "postdoc", + deadline: datetime(year: 2026, month: 1, day: 1), + timeline: ( + submitted: datetime(year: 2025, month: 12, day: 1), + offered: datetime(year: 2026, month: 2, day: 1), + ), + work: "acme-postdoc-application.pdf", + today: TODAY, +)[ + Answered and settled. +] + +// WATCHING: no venue, no deadline, no scheduled, no timeline — nothing +// announced yet, and no backlink to draw since there is no venue. +#cfp("no-venue-watching", kind: "postdoc", today: TODAY)[ + Nobody named, nothing heard. +] + +#panel(state: "settled", today: TODAY) +#panel(state: "open", today: TODAY) +#panel(state: "watching", today: TODAY) +// EMPTY: nothing in this fixture ever reaches "in-flight". +#panel(state: "in-flight", today: TODAY) diff --git a/cfps/0.1.0/typst.toml b/cfps/0.1.0/typst.toml new file mode 100644 index 00000000..77cedf0d --- /dev/null +++ b/cfps/0.1.0/typst.toml @@ -0,0 +1,15 @@ +[package] +name = "cfps" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "A venue and its calls for @rookery/core — deadline, portal, and what happened when one was answered" +repository = "https://github.com/freecomputinglab/rookery" + +[tool.rheo] +min_version = "0.6.2" + +[tool.rheo.html] +css_stylesheet = "src/cfps.css" diff --git a/meetings/0.1.0/.gitignore b/meetings/0.1.0/.gitignore new file mode 100644 index 00000000..3f77ebad --- /dev/null +++ b/meetings/0.1.0/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/node_modules/ +build/ diff --git a/meetings/0.1.0/Justfile b/meetings/0.1.0/Justfile new file mode 100644 index 00000000..5d059bcb --- /dev/null +++ b/meetings/0.1.0/Justfile @@ -0,0 +1,26 @@ +default: + @echo "@rookery/meetings: pure Typst package, entrypoint is src/lib.typ directly — nothing to build" + +# Two fixtures. `test/units.typ` asserts every VALUE `#meeting` derives — the tags, +# the date, the synthesized title — and `test/view.typ` plus `test/check.sh` assert +# the MARKUP and, above all, the order of the three blocks in a meeting's card. +# +# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS package. +# `--features html` for parity with this repo's other Justfiles. +# +# BOTH FIXTURES COMPILE TO HTML, where timeline's units fixture compiles to a +# throwaway PDF. `#meeting`'s record block is `html.elem`, which a paged export +# drops with a warning per element — so a paged units run is a wall of warnings +# about markup this package only claims to draw on the web. The cost is that the +# paged branch of `#timeline-view` is not exercised here; it is exercised in +# @rookery/timeline's own fixture, which owns it. +# +# `mkdir` first: unlike `rheo`, `typst compile` does not create its output +# directory and fails with "No such file or directory" on a fresh checkout, since +# `test/build/` is gitignored and never committed. +test: + mkdir -p test/build + typst compile --features html --root . --format html test/units.typ test/build/units.html + @echo "units OK" + typst compile --features html --root . --format html test/view.typ test/build/view.html + ./test/check.sh diff --git a/meetings/0.1.0/readme.md b/meetings/0.1.0/readme.md new file mode 100644 index 00000000..5361a541 --- /dev/null +++ b/meetings/0.1.0/readme.md @@ -0,0 +1,270 @@ +# @rookery/meetings + +A meeting note for [`@rookery/core`](../../core/0.1.0): who was in the room, when +it happened, and what was said. + +```typst +#import "@rookery/core:0.1.0": idea, rookery +#import "@rookery/meetings:0.1.0": meeting + +#show: rookery + +#idea("doshi-velez-finale", title: [Finale Doshi-Velez])[A person.] + +#meeting( + , + with: , + on: datetime(year: 2026, month: 9, day: 10), + today: datetime(year: 2026, month: 9, day: 10), +)[ + What was said. +] +``` + +That note's synthesized title is `Meeting with Finale Doshi-Velez on 10.9.26` — +asserted in `test/units.typ`. `#meeting` wraps `#idea` and adds exactly two +arguments, `with:` and `on:`, and both feed that title: they are the one thing +this package knows about a meeting that `#idea`'s own untitled-note fallback +cannot reach, which otherwise names a meeting by the first sixty characters of +whatever happened to be typed first in its body. + +A meeting given no `
    `), then a `
    ` +holds one row, `With` against the comma-joined refs from `with:`. The rail +follows directly under it, then the body — record, rail, prose, in that exact +order, which `test/check.sh` asserts against the built markup. + +Both the record and the rail live INSIDE the note's body, not in a page +template, for a reason `@rookery/core`'s transclusion forces: a `#window` +renders a note's body wherever it transcludes it, and knows nothing about the +consuming project's page chrome — a rail drawn by a template would exist on +the note's own page and nowhere else, which defeats the point of a note that +can appear inside another one. Putting the record in the body also puts it +where it belongs relative to what was said: a meeting's record is what the +note IS, and prose about what happened should not have to open by restating +who was there. + +The rail is drawn with `tl.timeline-view((:), all-tags, today: today)` — an +empty entry, not the note's own row. `#timeline-view` prepends rookery's +`created` to whatever entry it is given, and `on:` has already set `created` +to the very date the `occurred` entry carries; passing the row would draw that +one day twice. + +The whole block is built with `html.elem`, which contributes nothing at all on +a paged target — the label, the `
    `, none of it exists there. A meeting's +prose still renders under a PDF export; its record does not, the same trade +every rendered view in this family makes. + +## The factory + +`meetings(..tags, today: ..)` returns a `#meeting` that carries a page's own +tags into every call: + +```typst +#import "@rookery/meetings:0.1.0": meetings + +#let meeting = meetings("digital-theory-lab", today: TODAY) +#meeting(, with: , on: d)[..] +``` + +Plural is the factory, singular the note the factory mints — and `#meeting` +itself is exactly `meetings()` called with no arguments, exported under that +name for a project with no page tags of its own to fold in. + +Each positional argument to `meetings(..)` takes any shape a rookery `tags:` +does — a string, an array, a dictionary — and the call is variadic so +`meetings()` with none at all is legal: a page collecting meetings under no +subject of its own should not be forced to write `meetings(none)`. + +The returned closure carries its own `tag:` parameter, the per-call spelling of +the same idea, and that is what lets a page fix one extra tag onto every +meeting it mints from then on, using Typst's own partial application rather +than anything this package adds: + +```typst +#let meeting = meetings(today: TODAY).with(tag: "cassirer") +``` + +## Styling it + +Two classes carry the record: `.meeting-fields-head` for the label, and +`.meeting-fields` for the `
    ` itself. Five custom properties theme it, each +`var(--x, )` so setting one is enough — no rule to override: + +| property | default | +| --- | --- | +| `--meeting-fg` | a field's value text — `inherit` | +| `--meeting-muted` | the label and a field's name — `gray` | +| `--meeting-line` | the rules between fields — `var(--timeline-line, currentColor)` | +| `--meeting-gap` | space between a field's text and the rule under it — `0.4rem` | +| `--meeting-gutter` | width of the name column — `var(--timeline-gutter, 7.5em)` | + +`--meeting-gutter` reads `--timeline-gutter` first rather than defaulting to +`7.5em` on its own, and that is the point: a meeting draws this block and +timeline's rail one after another, and setting the single `--timeline-gutter` +property lines both columns up. Two adjacent tables starting in different +places would read as two conventions rather than one note. + +Everything sits inside `@layer meetings`, so any unlayered rule in a +consuming project's own stylesheet beats it regardless of specificity — rheo +links a package's stylesheet after the project's own, so without a layer this +file would win every tie a project could not otherwise break. + +The markup is `@rookery/bibtex`'s citation block by design — one gutter, one +hairline, one label size across the family that draws this shape — but with +its own classes and its own copy of the rules, so a project using this package +has no dependency on that one and nothing to install to see a meeting's +header. + +## Requirements + +- Typst 0.15.0 or later (`typst.toml`'s `compiler` floor). +- rheo 0.6.2 or later (`min_version`). +- [`@rookery/core:0.1.0`](../../core/0.1.0) and + [`@rookery/timeline:0.1.0`](../../timeline/0.1.0), both imported as aliased + modules (`import ... as core`, `import ... as tl`) rather than star-imported. + A Typst module re-exports every top-level binding it holds, star-imported + ones included — so an unaliased import here would make this package a + second source of `idea`, `window` and `rookery`, and a project star-importing + several rookery packages resolves a name like that by IMPORT ORDER. An + undecorated `window` arriving from this package would silently shadow + `@rookery/todos`' own skinned one. + +## Development + +```sh +cd meetings/0.1.0 +just test +``` + +Two fixtures, no build step: `test/units.typ` asserts every value `#meeting` +derives — the tags it stores, the date it sets, the title it synthesizes — +and `test/view.typ` plus `test/check.sh` assert the rendered markup and the +record/rail/prose order. `typst.toml`'s `entrypoint` points straight at +`src/lib.typ`, so `src/` is what ships and an edit takes effect immediately. diff --git a/meetings/0.1.0/src/lib.typ b/meetings/0.1.0/src/lib.typ new file mode 100644 index 00000000..d0e797eb --- /dev/null +++ b/meetings/0.1.0/src/lib.typ @@ -0,0 +1,268 @@ +// @rookery/meetings — a meeting: who was in the room, when it happened, what was +// said. +// +// A meeting is the smallest note family in this repo and the only one with no +// lifecycle: it is over the moment it happened. What makes it worth a package is +// the two things a plain `#idea` cannot say — WHO it was with, and WHEN it took +// place — and the fact that both are askable across a whole rookery once they are +// stored rather than written into prose. +// +// #import "@rookery/meetings:0.1.0": meeting +// #meeting(, with: , +// on: datetime(year: 2026, month: 9, day: 10), today: TODAY)[..] +// +// Both imports are aliased, and the aliases are load-bearing rather than tidy. A +// Typst module re-exports every top-level binding it holds, star-imported ones +// included, so `#import "@rookery/core:0.1.0": *` here would make this package a +// second source of `idea`, `window` and `rookery` — and a consumer star-importing +// several rookery packages resolves those names by import order, so an undecorated +// `window` arriving from here would silently shadow @rookery/todos' skinned one. +// Aliased, this module exports its own five names and nothing else. +#import "@rookery/core:0.1.0" as core +#import "@rookery/timeline:0.1.0" as tl + +// The flat tag every meeting carries, so `tags:meeting` is askable corpus-wide. +#let MEETING-KEY = "meeting" + +// Who was in the room, as idea names — `("hagen-blix", "ed-ongweso")`. A reference +// to other ideas, which is what earns it a key of its own rather than a place in +// the flat tag list. An array, since a meeting is a thing that happens between +// several. +// +// The names are meant to be people and are not required to be. Nothing here +// checks: the key names the relation rather than a family, so whatever the target +// note turns out to be — a person, a lab, a reading group — "this meeting was with +// that" is the same fact. +// +// A tag key is interpolated into an `idea-tag-` class, so it must stay +// CSS-safe — the reason `with:` lands in a valued key rather than a tag per +// person. +#let MEETING-WITH-KEY = "meeting-with" + +// The stage `on:` writes into @rookery/timeline's log. Not one of that package's +// three reserved names (`scheduled`, `deadline`, `closed`) — those are plans and a +// closing, and this is the event itself. +#let OCCURRED-STAGE = "occurred" + +// Readers, for a consumer building a view over meetings. Both take the tag +// DICTIONARY — `tag-data()`'s per-note value, or an `ideas(values: true)` row's +// `tags-dict` — so neither needs a registry read of its own. +#let meeting-with-of(tags) = tags.at(MEETING-WITH-KEY, default: ()) +#let occurred-of(tags) = tl.stage-date(tags, OCCURRED-STAGE) + +// A page tag about to be interpolated into an `idea-tag-` class. Rejected +// here rather than in a stylesheet, where the only symptom is a rule that silently +// never matches. +#let _CSS-SAFE-RE = regex("^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$") +#let _css-safe(name) = assert( + type(name) == str and name.match(_CSS-SAFE-RE) != none, + message: "@rookery/meetings: the page tag " + + repr(name) + + " is not usable as a CSS class fragment. Use alphanumerics and interior hyphens only.", +) + +// `with:` takes either spelling a rookery reference does — a LABEL +// (``, the form to prefer, since it reads as the reference it is) or a +// bare string — and one name needs no array ceremony. `core._norm` flattens all of +// them, a full `idea:x` included, to the one name the tag stores. +#let _who(with) = { + if with == none { return () } + let given = if type(with) == array { with } else { (with,) } + given.map(core._norm) +} + +// Each name becomes its own `ref`, which does two things no written link can. It +// takes the resolved title of the note it points at (rookery's `show ref: +// hyperlink` rule), so a person's name is typed once, on their own note; and it +// earns them a backlink, so their page lists every meeting they were in. +#let _refs(who) = who.map(n => ref(label("idea:" + n))).join(", ") + +// The header a meeting opens with is a labelled row, not a sentence. "With Hagen +// Blix" as a paragraph reads as the note's first thought; a labelled row reads as +// the note's record, which is what it is — and what a body full of what was +// actually said should not have to open by restating. +// +// The label is a div rather than a heading: it names the block under it, and a +// meeting's body carries real headings, so a heading here would claim a place in +// the page's outline above them. +// +// HTML only: `html.elem` contributes nothing at all on a paged target — element +// and children alike. A meeting's prose still renders there; its record does not. +// That is the same trade every view in this family makes. +#let _fields(who) = { + html.elem("div", attrs: (class: "meeting-fields-head"), "Meeting") + html.elem("dl", attrs: (class: "meeting-fields"), { + html.elem("dt", "With") + // Comma-joined rather than one per line: a person's name carries no commas of + // its own, so a row of them reads as a list without needing a column. + html.elem("dd", _refs(who)) + }) +} + +// `#meetings(..)` -> a `#meeting` factory carrying a page's own tags: +// +// #let meeting = meetings("digital-theory-lab", today: TODAY) +// #meeting(, with: , on: d)[..] +// +// The plural is the factory, the singular the note. Each positional argument takes +// any shape a rookery `tags:` does (a string, an array, a dictionary), variadic so +// that `meetings()` is a legal call — a page collecting meetings under no subject +// of its own wants exactly that, and a required parameter would force it to write +// `meetings(none)`. +// +// `today:` is here because Typst has no clock. The rail below is drawn by +// @rookery/timeline's `#timeline-view`, which needs a reference date to tell what +// has happened from what is booked; that package refuses to guess one and panics +// with a message naming the fix. A project stamps its build date in as an input and +// passes it once, here — a per-call `today:` overrides it for one meeting. A +// document that sets its own `#set document(date:)` needs neither. +#let meetings(..names, today: none) = { + assert( + names.named().len() == 0, + message: "@rookery/meetings: #meetings takes the page's tags POSITIONALLY — " + + "`meetings(TAG_NAME, today: ..)`. Got the named argument(s) " + + names.named().keys().join(", ") + + ", which would be silently dropped.", + ) + let own = names.pos().fold((:), (acc, t) => acc + core._norm-tags(t)) + for (k, _) in own.pairs() { _css-safe(k) } + // Captured under its own name so the closure's `today:` parameter can default to + // the factory's without shadowing the value it falls back to. + let factory-today = today + let mint = core.idea + ( + tags: none, + tag: none, + with: none, + on: none, + today: factory-today, + created: none, + scheduled: none, + deadline: none, + timeline: none, + ..args, + ) => { + let who = _who(with) + assert( + on == none or type(on) == datetime, + message: "@rookery/meetings: `on:` is when the meeting happened and must be a " + + "datetime — got " + repr(on) + ".", + ) + // `on:` sets rookery's own `created:` (see below), so giving both is two + // answers to when this meeting was — and picking one silently would put a date + // nobody wrote on the record. + assert( + not (on != none and created != none), + message: "@rookery/meetings: `on:` and `created:` are the same date for a " + + "meeting — `on:` sets `created:` itself. Give one of them.", + ) + let log = if timeline == none { (:) } else { timeline } + if on != none { + assert( + OCCURRED-STAGE not in log, + message: "@rookery/meetings: `on:` writes the `" + OCCURRED-STAGE + + "` log entry, and `timeline:` already names it. Give one of them.", + ) + log.insert(OCCURRED-STAGE, on) + } + // `#idea`'s OWN POSITIONAL CONTRACT, restated here because the header has to + // land BEFORE the body and so cannot ride through `..args` blind: one + // positional is the body, two are the name and the body. + let pos = args.pos() + assert( + pos.len() == 1 or pos.len() == 2, + message: "@rookery/meetings: #meeting takes a body, optionally preceded by a " + + "name — `#meeting()[..]` or `#meeting[..]` — got " + + str(pos.len()) + + " positional argument(s).", + ) + let name = if pos.len() == 2 { pos.at(0) } else { none } + let body = pos.last() + // Caller's tags first, the page's own on the right — the order that lets a + // meeting say something the page did not. The two derived keys land on top of + // both: neither is the caller's free tags nor the page's subject, and nothing + // else can be writing them. `MEETING-KEY` merges in UNDER all of them, so a + // call site naming it keeps its own value. + let all-tags = core._merge-base-tags( + MEETING-KEY, + core._norm-tags(tags) + core._norm-tags(tag) + own, + ) + if who.len() > 0 { all-tags.insert(MEETING-WITH-KEY, who) } + all-tags += tl.timeline-tags(scheduled: scheduled, deadline: deadline, timeline: log) + // The name an untitled meeting gets: `with:`/`on:` are the whole reason it can + // have one. "Meeting with Finale Doshi-Velez on 10.9.26" is what the note is, + // and it is the one thing this factory knows that `#idea`'s own fallback cannot + // reach — without it a titleless meeting is called by the first sixty + // characters of its body wherever it is named rather than rendered, which for a + // meeting is the first thing that happened to come up in it. A meeting that + // titles itself keeps its own title; nothing here overrides an author. + // + // Refs, not the names as text, for `_fields`' first reason: a person's name is + // typed once, on their own note, and a title built from it by hand would drift + // the moment that note is renamed. The plain-text projection follows the + // reference — @rookery/core's `_ref-text` resolves one to the target's own name + // — so the search index reads "Meeting with Finale Doshi-Velez on 10.9.26". + // + // The date uses @rookery/timeline's own short form, `tl._fmt-day`, rather than + // a format spelled out here: the rail under the header writes its dates that + // way, and a title disagreeing with the rail two lines below it would be this + // package holding two answers to how it writes a date. + let stamp = if on == none { none } else { tl._fmt-day(on) } + let derived = if "title" in args.named() { + (:) + } else if who.len() > 0 and stamp != none { + (title: [Meeting with #_refs(who) on #stamp]) + } else if who.len() > 0 { + (title: [Meeting with #_refs(who)]) + } else if stamp != none { + (title: [Meeting on #stamp]) + } else { + (:) + } + // The record opens the note, above the prose: who was there, then when. It + // lives in the body rather than in a page template for the reason + // @rookery/core's transclusion forces — a `#window` renders the body and knows + // nothing about the consuming project's page chrome, so a rail drawn by a + // template exists on the note's own page and nowhere else. + // + // `(:)` is the entry passed, not the note's own row, and that is what keeps the + // rail one line: `tl.timeline` prepends rookery's `created` to a note's log, + // and `on:` has just set `created` to the very date the `occurred` entry + // carries — so passing the row would draw the same day twice. + let full = { + if who.len() > 0 { _fields(who) } + tl.timeline-view((:), all-tags, today: today) + body + } + // `on:` sets `created:`, which is what makes a meeting's date free to filter + // and sort by: rookery keeps `created` a row field on every `ideas()` row, + // where a tag value — the log included — costs a `tag-data()` walk to reach. + // The log entry is what makes the date a timeline event; the row field is what + // makes it a date. One value, two channels, and no way for them to disagree. + let resolved-created = if created != none { created } else { on } + // A dateless meeting's derived title is refs and nothing else, and a ref + // contributes no text to the pure plain-text projection core slugs an id + // from — so every such meeting would mint `idea:meeting-with` and the + // second one would collide. The participants are the note's own content, + // so they name it: stable wherever the meeting sits in the spine, unlike a + // counter. + let auto-name = if name == none and who.len() > 0 and stamp == none { + core._id-slug("meeting-with-" + who.join("-")) + } else { + none + } + // Two branches because a name is positional and Typst has no way to pass "no + // positional argument here": an unnamed meeting must be called with the body + // alone, not with `none` in front of it, which `#idea` would read as the name. + if name == none and auto-name != none { + mint(auto-name, tags: all-tags, created: resolved-created, ..derived, ..args.named(), full) + } else if name == none { + mint(tags: all-tags, created: resolved-created, ..derived, ..args.named(), full) + } else { + mint(name, tags: all-tags, created: resolved-created, ..derived, ..args.named(), full) + } + } +} + +// The bare form, for a project with no page tags to fold in. `today:` per call. +#let meeting = meetings() diff --git a/meetings/0.1.0/src/meetings.css b/meetings/0.1.0/src/meetings.css new file mode 100644 index 00000000..518656bb --- /dev/null +++ b/meetings/0.1.0/src/meetings.css @@ -0,0 +1,94 @@ +/* @rookery/meetings — the record a meeting opens with: who was in the room, and + the rail of when it happened under it. + + Thin on purpose, like every stylesheet in this family: enough that the block + reads correctly out of the box, and nothing that presumes a page design. + + The cascade layer is not optional. rheo links a package's stylesheet after the + project's own, so on equal specificity this file would win every tie and a + project could not fix it by writing its rule "later" — there is no later. + Wrapping everything in a cascade layer inverts that: any unlayered rule in the + project's CSS beats any layered rule here, whatever its specificity or position. + + Every colour and size is `var(--x, )`, the default being the literal in + the var() call. Set one on `.meeting-fields` (or anywhere it inherits from) and + the block is themed without overriding a rule at all: + + --meeting-fg a field's value + --meeting-muted the label above the block, and a field's name + --meeting-line the rules between fields + --meeting-gap space between a field's text and the rule under it + --meeting-gutter width of the name column + + The gutter matches @rookery/timeline's rail, 7.5em, and the match is the point: a + meeting draws this block and that rail one after the other, and two adjacent + tables whose columns start in different places read as two conventions rather + than one note. The default reads `--timeline-gutter` first, so setting that + single property lines both up. + + This is the same markup @rookery/bibtex gives a citation, deliberately — one + gutter, one hairline, one label size across the family — but its own classes and + its own copy of the rules, because a project using this package must not have to + install that one to see a meeting's header. What differs is the spacing: + bibtex's block is a footer and its gaps are measured for one, which is exactly + wrong at the top of a note. Here the space goes below the block rather than + above it. */ +@layer meetings { + .meeting-fields-head { + margin: 0.25em 0 0; + color: var(--meeting-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* Two columns: the field's name in the gutter, its value to the right. A grid on + the `
    ` itself, with each `
    `/`
    ` auto-placed as its own item — so a + value that wraps to three lines pushes the next row down instead of drifting + out of column. A grid rather than a flex line, for the reason measured across + this family: a flex item's basis is only a hypothetical size, so a long field + name would push its value and no two rows would agree where it starts. */ + .meeting-fields { + display: grid; + grid-template-columns: var(--meeting-gutter, var(--timeline-gutter, 7.5em)) 1fr; + column-gap: 0.9rem; + margin: 0.6rem 0 1.2rem; + border-top: 1px solid var(--meeting-line, var(--timeline-line, currentColor)); + } + + /* The rule between fields is drawn once per row, on both cells, so the two + segments abut into a single line across the block. Horizontal only: the field + names are a label column, not a second column of data. */ + .meeting-fields dt, + .meeting-fields dd { + padding: var(--meeting-gap, 0.4rem) 0; + border-bottom: 1px solid var(--meeting-line, var(--timeline-line, currentColor)); + } + + .meeting-fields dt { + color: var(--meeting-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* `margin: 0` is load-bearing rather than tidy: a browser's default `
    ` + carries `margin-inline-start: 40px`, which in a grid cell indents every value + away from its own column. */ + .meeting-fields dd { + margin: 0; + color: var(--meeting-fg, inherit); + } + + /* A rail following the record carries the block's bottom space instead, so the + table and the rail read as one header rather than two tables with a gap + between them. `.timeline` sets `margin: 0.6rem 0 0` itself, which is the space + above it; what it has no opinion about is the prose underneath. */ + .meeting-fields:has(+ .timeline) { + margin-bottom: 0; + } + + .meeting-fields + .timeline { + margin-bottom: 1.2rem; + } +} diff --git a/meetings/0.1.0/test/check.sh b/meetings/0.1.0/test/check.sh new file mode 100755 index 00000000..57646208 --- /dev/null +++ b/meetings/0.1.0/test/check.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Asserts on the rendered fixture's OUTPUT, not merely that it compiled. +# `units.typ` covers every value; this covers the markup and, above all, the +# ORDER of the three blocks a meeting's card holds: record, rail, prose. +set -euo pipefail +cd "$(dirname "$0")/.." +H=test/build/view.html +[ -f "$H" ] || { echo "FAIL: no $H — run 'just test' first"; exit 1; } + +python3 - "$H" <<'PY' +import re, sys +h = open(sys.argv[1]).read() +fail = 0 +def note(msg): + global fail + print("FAIL:", msg); fail = 1 + +# One card per note, sliced on the note's own anchor id. +def card(name): + i = h.find('id="idea:%s"' % name) + if i < 0: + note("no card for %s" % name); return "" + j = h.find('', i) + return h[i:j] + +held = card("held") +# 1. THE RECORD IS THERE, with the person as a resolved ref. +if 'class="meeting-fields-head"' not in held or 'class="meeting-fields"' not in held: + note("held: no record block") +if "Finale Doshi-Velez" not in held: + note("held: the with: ref did not resolve to the target's title") + +# 2. THE ORDER IS RECORD, RAIL, PROSE — the whole point of the block living in +# the body rather than in a page template. +o_head = held.find('class="meeting-fields-head"') +o_dl = held.find('class="meeting-fields"') +o_rail = held.find('
      ') +o_body = held.find("What was said") +if not (0 < o_head < o_dl < o_rail < o_body): + note("held: blocks out of order (head %d, dl %d, rail %d, body %d)" + % (o_head, o_dl, o_rail, o_body)) + +# 3. ONE ROW, past and current, carrying the date in timeline's short form and +# the `occurred` stage — and no `created` row doubling the same day. +rows = re.findall(r'
    1. (.*?)
    2. ', held, re.S) +if [c for c, _ in rows] != ["timeline-past timeline-current"]: + note("held: rail rows are %r" % [c for c, _ in rows]) +if rows and ("10.9.26" not in rows[0][1] or "occurred" not in rows[0][1]): + note("held: rail row reads %r" % rows[0][1]) + +# 4. A MEETING STILL AHEAD is drawn as booked, which is what a reference date buys. +booked = card("booked") +if "timeline-future" not in booked: + note("booked: a future meeting is not drawn as a future row") + +# 5. NEITHER ARGUMENT, NEITHER BLOCK: no record, no rail, no empty apparatus. +bare = card("bare") +if "meeting-fields" in bare or '
        ' in bare: + note("bare: a meeting with no with:/on: drew a record block anyway") + +sys.exit(fail) +PY +echo "view OK" diff --git a/meetings/0.1.0/test/units.typ b/meetings/0.1.0/test/units.typ new file mode 100644 index 00000000..d2531afd --- /dev/null +++ b/meetings/0.1.0/test/units.typ @@ -0,0 +1,79 @@ +// Unit fixture: every VALUE `#meeting` derives — the tags it stores, the date it +// sets, the title it synthesizes. No runner: an `assert` failing fails the compile +// with a line number, and a passing compile is the green light. The MARKUP is +// `test/view.typ`'s business, which needs an HTML target this one does not. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": idea, ideas, rookery, tag-data +#import "@rookery/timeline:0.1.0": timeline-of + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 9, day: 10) +#let ON = datetime(year: 2026, month: 9, day: 10) +#let meeting = meetings("lab", today: TODAY) + +#idea("doshi-velez-finale", title: [Finale Doshi-Velez])[A person.] +#idea("hagen-blix", title: [Hagen Blix])[Another person.] + +#meeting("dv", with: , on: ON)[What was said.] +#meeting("plain")[Nothing declared.] +#meeting("titled", with: , on: ON, title: [Own title])[Titled.] +#meeting("dated", on: ON)[Nobody named.] + +// Dateless, untitled, unnamed: the case that used to collide on +// `idea:meeting-with` whatever the participants were. +#meeting(with: )[No name, no date, one participant.] +#meeting(with: )[No name, no date, a different participant.] +// Dated and unnamed, WITH participants: the dated branch keeps today's +// title-derived id, untouched by the dateless auto-name above. +#meeting(with: , on: ON)[Dated, unnamed, same participant.] + +#context { + let rows = ideas().map(r => (r.name, r)).to-dict() + + // `on:` SETS `created`, the free row field every date-sorted view reads. + assert.eq(rows.dv.created, ON, message: "created is " + repr(rows.dv.created)) + assert.eq(rows.dated.created, ON) + assert.eq(rows.plain.created, none) + + // The synthesized name, as PLAIN TEXT — a ref resolves to its target's own name. + assert.eq( + rows.dv.label, + "Meeting with Finale Doshi-Velez on 10.9.26", + message: "label is " + repr(rows.dv.label), + ) + assert.eq(rows.dated.label, "Meeting on 10.9.26") + // An author's own title wins outright. + assert.eq(rows.titled.label, "Own title") + + let dv = tag-data().at("idea:dv") + assert.eq(occurred-of(dv), ON) + assert.eq(meeting-with-of(dv), ("doshi-velez-finale",)) + assert("meeting" in dv, message: "no meeting tag: " + repr(dv.keys())) + assert("lab" in dv, message: "the factory's page tag is missing") + assert.eq(timeline-of(dv).len(), 1) + assert.eq(timeline-of(dv).first().stage, "occurred") + + // A meeting with neither argument stores neither key and gets no derived title. + let plain = tag-data().at("idea:plain") + assert.eq(timeline-of(plain).len(), 0) + assert.eq(occurred-of(plain), none) + assert.eq(meeting-with-of(plain), ()) + + // A dateless, untitled, unnamed meeting mints an id from its participants — + // not the fixed `idea:meeting-with` every such meeting used to collide on. + // Reaching each by its derived id (rather than erroring at compile time on + // a duplicate) is itself proof the two below did not collide. + let dv-auto = tag-data().at("idea:meeting-with-doshi-velez-finale") + assert.eq(meeting-with-of(dv-auto), ("doshi-velez-finale",)) + let blix-auto = tag-data().at("idea:meeting-with-hagen-blix") + assert.eq(meeting-with-of(blix-auto), ("hagen-blix",)) + + // The DATED branch (`on:` given) keeps today's title-derived id, unchanged + // by this fix — core's id-slug renders a `ref` as empty text, so the id + // carries only the date, not the participant (`meeting-with-of` below is + // what actually distinguishes participants for the dated case). + let dated-auto = tag-data().at("idea:meeting-with-on-10-9-26") + assert.eq(meeting-with-of(dated-auto), ("doshi-velez-finale",)) + assert.eq(occurred-of(dated-auto), ON) +} diff --git a/meetings/0.1.0/test/view.typ b/meetings/0.1.0/test/view.typ new file mode 100644 index 00000000..19fae5fa --- /dev/null +++ b/meetings/0.1.0/test/view.typ @@ -0,0 +1,26 @@ +// Rendered fixture: the MARKUP a meeting opens with, which `test/units.typ` +// cannot see — the record's own `
        `, the rail under it, and the order of the +// three blocks inside one card. Asserted by `test/check.sh` against the built +// HTML, because "the rail is above the prose" is a fact about document order. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": idea, rookery + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 9, day: 10) +#let meeting = meetings(today: TODAY) + +#idea("doshi-velez-finale", title: [Finale Doshi-Velez])[A person.] + +// HAPPENED: the rail's one row is past, and it is the current stage. +#meeting("held", with: , on: datetime(year: 2026, month: 9, day: 10))[ + What was said. +] + +// BOOKED: a meeting in the diary, drawn as a future row. +#meeting("booked", with: , on: datetime(year: 2026, month: 9, day: 24))[ + Not yet held. +] + +// NEITHER ARGUMENT: no record block at all, and no rail. +#meeting("bare")[Nothing declared.] diff --git a/meetings/0.1.0/typst.toml b/meetings/0.1.0/typst.toml new file mode 100644 index 00000000..22af19d8 --- /dev/null +++ b/meetings/0.1.0/typst.toml @@ -0,0 +1,15 @@ +[package] +name = "meetings" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "A meeting note for @rookery/core — who was in the room, when it happened, and what was said" +repository = "https://github.com/freecomputinglab/rookery" + +[tool.rheo] +min_version = "0.6.2" + +[tool.rheo.html] +css_stylesheet = "src/meetings.css" diff --git a/pinboard/0.1.0/.gitignore b/pinboard/0.1.0/.gitignore new file mode 100644 index 00000000..282c6489 --- /dev/null +++ b/pinboard/0.1.0/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +.direnv/ +build +*.pdf diff --git a/pinboard/0.1.0/Justfile b/pinboard/0.1.0/Justfile new file mode 100644 index 00000000..be3c7d91 --- /dev/null +++ b/pinboard/0.1.0/Justfile @@ -0,0 +1,20 @@ +default: + @just --list + +# Bundles `src/` into `dist/lib.js`. `typst.toml` names `dist/` for the +# release path, so this must run before a project compiling against a +# released copy of this package sees an edit. +build: + pnpm install + pnpm run build + +# The halves that need no browser: the flow layout, the drag arithmetic, the +# collapse state, the store. +test-js: + node --test test/*.test.mjs + +# Asserts on the OUTPUT: a board compiles and every card carries the id +# its position will later be pinned to. +check: build + rheo compile demo/rheo + ./demo/rheo/check.sh diff --git a/pinboard/0.1.0/demo/rheo/check.sh b/pinboard/0.1.0/demo/rheo/check.sh new file mode 100755 index 00000000..bd0abaa8 --- /dev/null +++ b/pinboard/0.1.0/demo/rheo/check.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Asserts on this demo's OUTPUT: the board rendered, and got exactly one card +# per note this fixture declares. Neither is visible from a Typst-only +# check — a compile succeeding proves nothing about what rheo actually wrote +# to disk. +# +# Run through `just check`, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +[ -f "$H/index.html" ] || note "no page at index.html" + +want=$(grep -c '#idea(' content/index.typ) +got=$(grep -o 'data-pinboard-id' "$H/index.html" 2>/dev/null | wc -l | tr -d ' ') +windows=$(grep -o 'data-rookery="window-details"' "$H/index.html" 2>/dev/null | wc -l | tr -d ' ') +hats=$(grep -o 'data-rookery="tab"' "$H/index.html" 2>/dev/null | wc -l | tr -d ' ') +themed=$(grep -o 'data-pinboard-id="[^"]*">
        ]*--idea-border-color' "$H/index.html" 2>/dev/null | wc -l | tr -d ' ') + +grep -q 'data-pinboard=' "$H/index.html" || note "index.html carries no data-pinboard container" + +if [ "$got" -ne "$want" ]; then + note "index.html carries $got data-pinboard-id attributes, wanted $want (one per #idea() note in content/index.typ)" +fi + +# A card IS a core #window: the disclosure is what opens it with no JavaScript +# at all, and the hat is what carries the note's id across its top rule. +if [ "$windows" -ne "$want" ]; then + note "index.html carries $windows window disclosures, wanted $want (one per card, so every card opens without JavaScript)" +fi + +# One hat per card, plus the one each #idea() note wears where it was written. +if [ "$hats" -ne $((want * 2)) ]; then + note "index.html carries $hats hats, wanted $((want * 2)) (one per card and one per note on the page)" +fi + +# The theme this fixture sets reaches the board for free, because core writes a +# project's properties onto the window a card is built from. +if [ "$themed" -ne "$want" ]; then + note "$themed of $want cards carry the project's --idea-border-color" +fi + +if [ "$fail" -eq 0 ]; then + echo " pinboard: $got cards, one per note" + echo "demo/rheo OK" +else + echo "demo/rheo FAILED" + exit 1 +fi diff --git a/pinboard/0.1.0/demo/rheo/content/index.typ b/pinboard/0.1.0/demo/rheo/content/index.typ new file mode 100644 index 00000000..dba57fd0 --- /dev/null +++ b/pinboard/0.1.0/demo/rheo/content/index.typ @@ -0,0 +1,31 @@ +#import "@rookery/core:0.1.0": idea, rookery +#import "@rookery/pinboard:0.1.0": pinboard + +// A THEMED project, deliberately: a card is a `#window`, so the properties +// set here reach the cards on the board exactly as they reach the notes on +// the page, and the demo would not show that with the defaults. +#show: rookery.with(border-color: rgb("#3366ff"), rule-width: "3px") + += `@rookery/pinboard` demo + +A handful of notes, laid out as a board below — each card a window on its +note, draggable by the row carrying its id and openable by clicking it, while +the prose underneath goes on changing. + +#idea("outline", title: [Outline])[ + The shape of the piece: three acts, with the turn landing in the second. +] + +#idea("interview", title: [The interview])[ + What the source actually said, before it was smoothed into prose. +] + +#idea("scene-one", title: [Opening scene])[ + Where the reader is standing when the piece begins. +] + +#idea("counterargument", title: [The counterargument])[ + What a skeptical reader would say, and the answer to it. +] + +#pinboard() diff --git a/pinboard/0.1.0/demo/rheo/rheo.toml b/pinboard/0.1.0/demo/rheo/rheo.toml new file mode 100644 index 00000000..52b34535 --- /dev/null +++ b/pinboard/0.1.0/demo/rheo/rheo.toml @@ -0,0 +1,14 @@ +# @rookery/pinboard'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 would ship src/, dist/ and test/ with nothing +# exercising the DOM `src/board.typ` actually renders. +version = "0.1.0" +content_dir = "content" +formats = ["html"] + +# The Typst cache's `rookery` namespace is a per-machine symlink that can +# point at a different checkout of this repo, so this reads `@rookery/*` out +# of this tree instead. See `slipshow/0.1.0/demo/rheo/rheo.toml` for the full +# argument. +[packages.rookery] +path = "../../../.." diff --git a/pinboard/0.1.0/package.json b/pinboard/0.1.0/package.json new file mode 100644 index 00000000..45f793be --- /dev/null +++ b/pinboard/0.1.0/package.json @@ -0,0 +1,14 @@ +{ + "name": "rookery-pinboard", + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "vite build", + "test": "node --test test/*.test.mjs" + }, + "packageManager": "pnpm@10.21.0", + "devDependencies": { + "linkedom": "^0.18.13", + "vite": "^8.0.5" + } +} diff --git a/pinboard/0.1.0/pnpm-lock.yaml b/pinboard/0.1.0/pnpm-lock.yaml new file mode 100644 index 00000000..248aa92f --- /dev/null +++ b/pinboard/0.1.0/pnpm-lock.yaml @@ -0,0 +1,590 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + linkedom: + specifier: ^0.18.13 + version: 0.18.13 + vite: + specifier: ^8.0.5 + version: 8.3.0 + +packages: + + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + 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] + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + 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'} + + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + + 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.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + 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'} + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + 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.149.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + boolbase@2.0.0: {} + + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-what@8.0.0: {} + + cssom@0.5.0: {} + + detect-libc@2.1.2: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.1.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + 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 + + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + nanoid@3.3.19: {} + + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + uhyphen@0.2.0: {} + + vite@8.3.0: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/pinboard/0.1.0/readme.md b/pinboard/0.1.0/readme.md new file mode 100644 index 00000000..e992c05d --- /dev/null +++ b/pinboard/0.1.0/readme.md @@ -0,0 +1,94 @@ +# @rookery/pinboard + +A board of draggable cards for arranging [`@rookery/core`](../../core/0.1.0) +notes by hand. The motivating use is John McPhee's structural method: write +each component of a piece on its own card, put the cards where you can see +them all at once, and move them around until a sequence appears. A card can be +shut to its title — you arrange labels, not prose — while its body goes on +changing underneath, wherever the note itself is edited. + +**A card is a `#window`.** Every card on the board is core's own transclusion +of the note under it, so it wears core's frame: the left rule, the hat +carrying `[idea:]` across the top of it, the hover tint, and a +disclosure that opens on a click with no JavaScript involved. Anything a +project sets through `#show: rookery.with(border-color: .., rule-width: .., +pad: ..)` therefore themes the board as well as the notes on the page — there +is no second set of properties to keep in step. + +```typst +#import "@rookery/core:0.1.0": idea +#import "@rookery/pinboard:0.1.0": pinboard + +#idea("outline", title: [Outline])[...] +#idea("interview", title: [The interview])[...] + +#pinboard() +``` + +## `#pinboard(id:, notes:, folded:, layout:)` + +- **`id:`** names THIS board. It becomes `data-pinboard=""` on the + container, and is the whole of the storage key + (`rookery-pinboard:`) a saved layout keys on. A project running two + boards gives them two ids; renaming a board's id starts a fresh layout + under a new key. Defaults to `"default"`. +- **`notes:`** an explicit array of [`ideas()`](../../core/0.1.0) rows to show + instead of the whole corpus. `none` (the default) shows every note. This is + how a caller narrows the board without this package growing a query + language of its own: `ideas(tagged: "..")` already narrows, and the result is + handed straight in. +- **`folded:`** the state a card the reader has never touched opens in. + `true` (the default) gives a board of titles alone, which is the McPhee + arrangement; `false` shows each note's body under its title. It is an + initial state only — a card whose state is in the store is restored to + that instead, and a reader may open or shut any card whatever this says. +- **`layout:`** the INITIAL arrangement for a card the store has nothing for, + `"stack"` (the default) or `"flow"`. `"stack"` places every unplaced card + in one column, top to bottom, in the order `ideas()` (or `notes:`) hands + them back — the same sequence a reader already reads every other rookery + view in. `"flow"` is the older wrapping grid, laid out left to right and + wrapping into rows. Either way this only ever places a card the store has + nothing for; one the reader has already dragged never moves. + +## Import both packages + +A project using `#pinboard` imports **both** `@rookery/core` (for `idea` and +whatever else it authors notes with) and `@rookery/pinboard`, in its own +`.typ` files — the same requirement every other JS-shipping `@rookery` +package states, because rheo's package-asset detection scans a project's own +imports, never a package's internal ones. + +## What this release is + +A board of cards, each one a window on its note, whose arrangement is pinned +to that note rather than to the page, the file, or the build. A card's summary +row does both gestures: press and move it and the card drags, press and let go +and the card opens or shuts. A card's place is keyed on `core`'s own stable +per-note id, so it survives edits to the note's title and prose, and moves +with the note between files. The layout lives in the reader's own browser, +under `localStorage` at `rookery-pinboard:` — per-browser, not +shared between readers and not committed to the project. Under `rheo watch`, +a save reloads the whole page (rheo has no lighter refresh hook), and the +board comes back exactly as it was left, because every card's position is +re-read from that store on boot rather than recomputed. + +A note with no stored entry — one written since the board was last +arranged — falls into the board's `layout:`: a column, top to bottom, by +default, or the older wrapping grid under `layout: "flow"`. Either way it +lands after whatever the store already placed, rather than wherever a +full-board layout would put it. + +## Requirements + +- `@rookery/core` 0.1.0. A hard import. +- rheo 0.6.2 or later. +- A built package: `dist/` must exist before a project sees an edit. + +## Development + +```sh +cd pinboard/0.1.0 +just build # bundles src/ into dist/ +just test-js # the flow layout tests +just check # builds, then compiles and asserts on the demo +``` diff --git a/pinboard/0.1.0/src/board.typ b/pinboard/0.1.0/src/board.typ new file mode 100644 index 00000000..8ab944a5 --- /dev/null +++ b/pinboard/0.1.0/src/board.typ @@ -0,0 +1,60 @@ +// The one board: lays every note (or an explicit subset) out as a card the +// author can arrange by hand. Follows Pattern B from this repo's own +// CLAUDE.md — a board needs no CURRENT FILE handle, only the whole corpus +// `ideas()` already hands back with or without rheo — so this takes no +// `ctx:` parameter and panics at nothing. It does assert on `layout:`, which +// is this package's own value, not rheo's. +// +// A CARD IS A `#window`, not a shape of this package's own. Core already +// draws a note as a left rule with the note's id on a hat across the top and +// a `
        ` under it that opens on a click, so a card that reuses it +// inherits the frame, the disclosure (which needs no JavaScript at all), and +// every theme property a project sets through `#show: rookery.with(pad:, +// rule-width:, border-color:, ..)` — core emits those onto the window itself. +// This package draws only the shell that puts the window somewhere on the +// board. +#import "@rookery/core:0.1.0": ideas, window + +// `id:` names THIS board, becoming `data-pinboard=""` on the container — +// the storage key a saved layout keys on. `notes:` is an explicit array of +// `ideas()` rows to show instead of the whole corpus; `none` (the default) +// shows every note. A caller wanting less calls `ideas(tagged: ..)` itself and +// hands the result in, rather than this package growing a query language of +// its own. `folded:` is the INITIAL state of a card the reader has never +// touched, and defaults to `true`: a board of titles alone is the McPhee +// arrangement, and the one a reader takes in whole. `false` opens every card's +// body instead. A card whose state is in the store is restored to that rather +// than to this (`src/pinboard.js`). `layout:` is the INITIAL arrangement for a +// card the store has nothing for, `"stack"` (the default) or `"flow"` — +// emitted as `data-pinboard-layout=""` on the container. `"stack"` +// places every unplaced card in one column, top to bottom, in the order +// `ideas()` (or `notes:`) hands them back — the sequence a reader already +// reads every other rookery view in. `"flow"` is the older wrapping grid. +// Either way this only ever places a card the store has nothing for; one the +// reader has already dragged never moves. +// +// `ideas()` reads `_registry.final()` and must run inside `#context`, so the +// whole body is one. +#let pinboard(id: "default", notes: none, folded: true, layout: "stack") = context { + assert( + layout == "stack" or layout == "flow", + message: "@rookery/pinboard: `layout:` must be \"stack\" or \"flow\", got " + repr(layout), + ) + let rows = if notes != none { notes } else { ideas() } + html.elem( + "div", + attrs: (class: "pinboard", "data-pinboard": id, "data-pinboard-layout": layout), + { + for row in rows { + html.elem( + "article", + attrs: (class: "pinboard-card", "data-pinboard-id": row.id), + // `backlink: false`: a board RENDERS the whole corpus rather than + // pointing at any of it, so a card must not put the page carrying the + // board into every note's Backlinks. + window(row.id, folded: folded, backlink: false), + ) + } + }, + ) +} diff --git a/pinboard/0.1.0/src/collapse.js b/pinboard/0.1.0/src/collapse.js new file mode 100644 index 00000000..5c7de49e --- /dev/null +++ b/pinboard/0.1.0/src/collapse.js @@ -0,0 +1,64 @@ +// Collapse-to-title for pinboard cards. A card is a `@rookery/core` `#window` +// (see `src/board.typ`), so collapsing one is the `
        ` core already +// put there being shut: the `open` attribute is the single source of truth, +// the browser writes it on every click with no JavaScript involved, and this +// module is only what a boot-time restore and the store need to read and +// write the same state from the outside. +// +// The attribute rather than the `open` IDL property, because the two are the +// one thing a non-browser DOM (`node --test` runs these against linkedom) is +// certain to agree about. + +// A card's OWN disclosure, never a nested window's: the card's is first in +// document order, so the first match is the right one whatever a note's body +// transcludes further down. +export function cardDetails(card) { + return card.querySelector('[data-rookery="window-details"]'); +} + +export function isCollapsed(card) { + const details = cardDetails(card); + return details ? !details.hasAttribute("open") : false; +} + +// Sets the card's collapsed state directly, independent of any listener +// having run — this is what lets `src/pinboard.js` restore a persisted state +// on a card that has never been clicked. +export function setCollapsed(card, collapsed) { + const details = cardDetails(card); + if (!details) return; + details.toggleAttribute("open", !collapsed); +} + +// `opts.onChange`, when given, is called with the card once per toggle — +// `src/pinboard.js` supplies the callback that persists the card's new +// collapsed state via `src/store.js`; this module has no dependency on +// storage at all. +// +// ONE delegated listener on the board, the same delegation `makeDraggable` +// uses in `src/drag.js` for the same reason: a board can carry a hundred +// cards, added and removed only by a rebuild. `toggle` does not bubble, so +// the listener runs in the CAPTURE phase, which reaches it anyway — and +// catches a keyboard activation and a `setCollapsed` call as well as a click, +// which a delegated `click` listener would not. +// +// A boot-time restore therefore re-persists the entry it just read, since the +// `toggle` it queues lands after this is wired. Idempotent, and cheaper than +// a flag that has to be cleared correctly. +export function makeCollapsible(board, opts = {}) { + // SCOPED TO `opts.signal`, the same AbortController `src/pinboard.js` holds + // one of per board and aborts before wiring a board a second time — no + // in-flight state to reset here, `toggle` carries none, so scoping the + // listener is the whole fix. + board.addEventListener( + "toggle", + (event) => { + const card = event.target.closest?.(".pinboard-card"); + // A window nested inside a card's body has a disclosure of its own, and + // opening it says nothing about the card's own state. + if (!card || cardDetails(card) !== event.target) return; + opts.onChange?.(card); + }, + { capture: true, signal: opts.signal }, + ); +} diff --git a/pinboard/0.1.0/src/drag.js b/pinboard/0.1.0/src/drag.js new file mode 100644 index 00000000..3f123b42 --- /dev/null +++ b/pinboard/0.1.0/src/drag.js @@ -0,0 +1,251 @@ +// Drag-by-summary for pinboard cards. `makeDraggable(board)` wires ONE set of +// Pointer Event listeners on the board itself and lets events from the cards +// bubble up to it — a board can carry a hundred cards, added and removed +// only by a rebuild, so a delegated listener is both cheaper and simpler +// than one per card. +// +// THE HANDLE IS THE WINDOW'S SUMMARY ROW, which is also the control that +// opens the card (`src/board.typ` renders each card as a `@rookery/core` +// `#window`). Two gestures on one row, told apart by distance: a press that +// stays inside `DRAG_THRESHOLD` is a click and must reach the `` +// under it, anything further is a drag and the click it ends with is +// suppressed. A press on a SELECTED card (`src/select.js`'s `data-selected`) +// drags every selected card together, preserving their relative offsets; a +// press on an unselected one drops the selection first and drags that card +// alone. Hence the two rules below that read as omissions: +// +// - NO `preventDefault` on `pointerdown`. It is the ordinary way to stop a +// drag becoming a text selection, and it also costs the disclosure its +// click; `src/pinboard.css` sets `user-select: none` on the row instead. +// - NO pointer capture until the threshold is crossed. A captured pointer +// retargets the click that follows it to the capturing element, so +// capturing at `pointerdown` would mean no click ever reached a summary. +// +// Position rides on the `--pin-x`/`--pin-y` custom properties `src/pinboard.js` +// already writes; `readPosition`/`writePosition` are the one pair of helpers +// that touch them. +// +// `makeDraggable`'s `opts.onChange` is called once per drag, with the card, +// when the gesture ends — not on every `pointermove`, which would mean a +// synchronous storage write per frame, and not at all for a press that only +// clicked. `src/pinboard.js` supplies the callback that persists a card's new +// position via `src/store.js`; this module has no dependency on storage. +// `opts.onMove`, unlike `onChange`, IS called on every `pointermove` of a +// real drag, so the board's height can follow a card dragged past its +// bottom edge — it must stay cheap and must not write to storage. + +import { selectedCards, isSelected, clearSelection } from "./select.js"; + +const HANDLE = '[data-rookery="window-summary"]'; + +// Pixels of pointer movement that separate a click from a drag. +export const DRAG_THRESHOLD = 3; + +export function readPosition(card) { + return { + x: parseFloat(card.style.getPropertyValue("--pin-x")) || 0, + y: parseFloat(card.style.getPropertyValue("--pin-y")) || 0, + }; +} + +export function writePosition(card, x, y) { + card.style.setProperty("--pin-x", `${x}px`); + card.style.setProperty("--pin-y", `${y}px`); +} + +// Whether the pointer has travelled far enough for this gesture to be a drag +// rather than a click. Either axis on its own is enough: the threshold is a +// square around the press, not a circle, which costs a comparison instead of +// a square root and is indistinguishable at three pixels. +export function movedEnough(startPointer, pointer, threshold = DRAG_THRESHOLD) { + return ( + Math.abs(pointer.x - startPointer.x) >= threshold || + Math.abs(pointer.y - startPointer.y) >= threshold + ); +} + +// Narrows a drag's delta until every card in the group stays inside the +// board, rather than clamping each card on its own — an independent clamp +// deforms the group the moment one member reaches an edge, and the whole +// point of a group drag is that the arrangement travels intact. The bound +// from each card is gathered first and the delta clamped once at the end: +// clamping card by card lets a later card's bound undo an earlier card's, +// so the result would depend on the order the cards happen to arrive in. +// Only `dx` has an upper bound: `dy` has a floor and no ceiling, because a +// drag downward is how the board is made taller, and `src/pinboard.js`'s +// `growBoardFor` raises `--pinboard-height` to follow it. A group wider than +// the board can leave its left and right bounds unsatisfiable together; the +// left bound wins, since a card pushed to a negative `x` cannot be dragged +// back (the board does not scroll leftward), while one left past the right +// edge is still draggable. +export function clampGroupDelta(items, delta, boardWidth) { + let minDx = -Infinity; + let maxDx = Infinity; + let minDy = -Infinity; + for (const item of items) { + minDx = Math.max(minDx, -item.x); + maxDx = Math.min(maxDx, boardWidth - item.width - item.x); + minDy = Math.max(minDy, -item.y); + } + const ceilingDx = Math.max(maxDx, minDx); + // `+ 0` folds a `-0` produced by clamping against a card already at that + // edge (`Math.max`/`Math.min` preserve the sign of a zero bound) back to + // an ordinary zero, which a caller comparing the delta to `0` expects. + return { + x: Math.min(Math.max(delta.x, minDx), ceilingDx) + 0, + y: Math.max(delta.y, minDy) + 0, + }; +} + +// The board's current z-index high-water mark, read off its own cards rather +// than carried in a variable that would not survive a rewire. `makeDraggable` +// runs again after a rheo morph (`src/pinboard.js`'s rehydrate hook), and by +// then a rewired board's inline `z-index` may either still be there (the +// morph matched the card and left its attributes alone) or gone (the morph +// reverted it to the pre-hydration markup's, which sets none) — nothing this +// module can predict from here. Re-reading it off the DOM is right either +// way: it resumes past whatever is actually still raised if the morph kept +// it, and it falls back to the same starting point a fresh boot would use if +// the morph wiped it. A held-over JS value or a hard reset to 0 could each +// resume BELOW a card the DOM still shows raised, which is the one outcome +// that is wrong regardless of which case actually happened. +function currentTopZ(board) { + let max = 0; + for (const card of board.querySelectorAll(":scope > .pinboard-card")) { + const z = parseInt(card.style.zIndex, 10); + if (Number.isFinite(z) && z > max) max = z; + } + return max; +} + +export function makeDraggable(board, opts = {}) { + // SCOPES EVERY LISTENER THIS PASS ADDS, including the click-suppressor + // `suppressNextClick` below. `src/pinboard.js` holds one AbortController + // per board and aborts the previous pass's before calling this again, so + // `signal` is what lets that abort actually drop this pass's own + // listeners rather than leaving them bound to a board a second, unrelated + // pass is now also wiring. + const { signal } = opts; + let topZ = currentTopZ(board); + let drag = null; + // The one-shot listener that eats the click ending a real drag, so letting + // go over the summary does not also toggle the card. Held in a variable + // rather than added with `{ once: true }` alone, because a gesture that ends + // without a click — a `pointercancel`, a drag released outside the board — + // would otherwise leave it armed for the next, unrelated click. + let suppressor = null; + + function clearSuppressor() { + if (!suppressor) return; + board.removeEventListener("click", suppressor, true); + suppressor = null; + } + + function suppressNextClick() { + clearSuppressor(); + suppressor = (event) => { + event.preventDefault(); + event.stopPropagation(); + clearSuppressor(); + }; + board.addEventListener("click", suppressor, { capture: true, signal }); + } + + board.addEventListener("pointerdown", (event) => { + const handle = event.target.closest(HANDLE); + if (!handle) return; + // The hat carries the note's permalink; a press that lands on it (or on + // any other interactive descendant of the row) must not swallow its click. + if (event.target.closest("a, button, input")) return; + if (event.button !== 0) return; + const card = handle.closest(".pinboard-card"); + if (!card) return; + + // A press on a selected card moves the whole selection; a press on an + // unselected one is the "click anywhere that is not a selected handle" + // that drops the selection, and then drags that card alone. + let cards; + if (isSelected(card)) { + cards = selectedCards(board); + } else { + clearSelection(board); + cards = [card]; + } + + clearSuppressor(); + drag = { + cards, + starts: cards.map((c) => readPosition(c)), + startPointer: { x: event.clientX, y: event.clientY }, + pointerId: event.pointerId, + moved: false, + }; + // Raised on the press rather than on the drag: a card the reader is + // reading belongs in front of the ones it overlaps, whether or not they + // go on to move it. + for (const c of cards) c.style.zIndex = String(++topZ); + }, { signal }); + + board.addEventListener("pointermove", (event) => { + if (!drag) return; + const pointer = { x: event.clientX, y: event.clientY }; + const { cards, starts, startPointer } = drag; + if (!drag.moved) { + if (!movedEnough(startPointer, pointer)) return; + drag.moved = true; + for (const c of cards) c.dataset.dragging = ""; + // From here the gesture is a drag, and capture keeps it on the board + // even when the pointer outruns the cards. + board.setPointerCapture(event.pointerId); + } + const items = cards.map((c, i) => ({ + x: starts[i].x, + y: starts[i].y, + width: c.offsetWidth, + })); + const delta = clampGroupDelta( + items, + { x: pointer.x - startPointer.x, y: pointer.y - startPointer.y }, + board.scrollWidth, + ); + cards.forEach((c, i) => { + writePosition(c, starts[i].x + delta.x, starts[i].y + delta.y); + opts.onMove?.(c); + }); + }, { signal }); + + function endDrag() { + if (!drag) return; + const { cards, moved, pointerId } = drag; + drag = null; + if (!moved) return; + for (const c of cards) delete c.dataset.dragging; + if (board.hasPointerCapture(pointerId)) board.releasePointerCapture(pointerId); + suppressNextClick(); + for (const c of cards) opts.onChange?.(c); + } + + // `pointercancel` fires when the browser takes the gesture over — a touch + // turning into a scroll, for instance — and must end the drag exactly as + // `pointerup` does, or the board is left stuck mid-drag until reload. + board.addEventListener("pointerup", endDrag, { signal }); + board.addEventListener("pointercancel", endDrag, { signal }); + + // RESET ANY IN-FLIGHT DRAG WHEN THIS PASS IS SUPERSEDED. This pass's own + // `pointerup`/`pointercancel` are removed by this same abort, so a drag + // that was mid-gesture when the rewire happened would otherwise never + // reach `endDrag` at all — the card left `data-dragging`, its pointer + // still captured on a board about to be handed a second, disjoint wiring + // pass. `endDrag` itself is NOT reusable here: by the time this fires, the + // morph that triggered the rewire has already reverted the card's + // `--pin-x`/`--pin-y` to the pre-hydration markup's (none), so calling + // `endDrag`'s `opts.onChange` now would persist that reverted position + // over whatever was actually saved before the drag started. + signal?.addEventListener("abort", () => { + if (!drag) return; + const { cards, pointerId } = drag; + drag = null; + for (const c of cards) delete c.dataset.dragging; + if (board.hasPointerCapture(pointerId)) board.releasePointerCapture(pointerId); + }); +} diff --git a/pinboard/0.1.0/src/layout.js b/pinboard/0.1.0/src/layout.js new file mode 100644 index 00000000..ab7e99f7 --- /dev/null +++ b/pinboard/0.1.0/src/layout.js @@ -0,0 +1,48 @@ +// Pure layouts for the pinboard's cards: no DOM and no measurement, so both +// are testable under `node --test` with no browser or DOM shim. `src/pinboard.js` +// is the DOM half — it reads a board's cards, measures what a layout needs, +// calls one of these, and applies the result as CSS custom properties. + +const DEFAULTS = { cardWidth: 320, cardHeight: 220, gap: 24, boardWidth: 1200 }; + +// Lays `ids` left to right in rows that wrap at `boardWidth`, returning a +// `Map` from id to `{x, y}`. A pure function of its arguments: the same +// `ids` in the same order always yields the same positions, which is what +// keeps a board's initial layout stable across builds — `ideas()` already +// sorts by id, so this needs no sort of its own. +export function flowPositions(ids, opts = {}) { + const { cardWidth, cardHeight, gap, boardWidth } = { ...DEFAULTS, ...opts }; + const perRow = Math.max(1, Math.floor((boardWidth + gap) / (cardWidth + gap))); + const positions = new Map(); + ids.forEach((id, i) => { + const col = i % perRow; + const row = Math.floor(i / perRow); + positions.set(id, { x: col * (cardWidth + gap), y: row * (cardHeight + gap) }); + }); + return positions; +} + +// Stacks `ids` in one column in the given order, returning a `Map` from id to +// `{x, y}`. Every card shares `x`; each `y` is the running sum of the +// preceding cards' real heights (from `heights`, a `Map` from id to pixels — +// a missing or non-finite entry falls back to `DEFAULTS.cardHeight`, a +// spacing constant rather than a measurement) plus one `gap` each, starting +// at `startY`. Ids keep their given order: no sort here, callers hand this +// an already-sorted sequence. +export function stackPositions(ids, opts = {}) { + const { heights, gap, x, startY } = { + heights: new Map(), + gap: DEFAULTS.gap, + x: 0, + startY: 0, + ...opts, + }; + const positions = new Map(); + let y = startY; + for (const id of ids) { + positions.set(id, { x, y }); + const height = heights.get(id); + y += (Number.isFinite(height) ? height : DEFAULTS.cardHeight) + gap; + } + return positions; +} diff --git a/pinboard/0.1.0/src/lib.typ b/pinboard/0.1.0/src/lib.typ new file mode 100644 index 00000000..80f5be42 --- /dev/null +++ b/pinboard/0.1.0/src/lib.typ @@ -0,0 +1,11 @@ +// @rookery/pinboard: a board of draggable cards for arranging @rookery/core +// notes by hand — John McPhee's structural method, in the browser. Write +// each component of a piece on its own note, see every one as a card on one +// board, and move the cards around until a sequence appears. +// Depended on for its JavaScript, not for any Typst API it exports: this +// import is what puts `@rheo/rehydrate`'s script on the page, which this +// package's own script then reads as the `RheoRehydrate` global to re-wire +// its widgets after a `rheo watch` morph. +#import "@rheo/rehydrate:0.1.0" + +#import "board.typ": * diff --git a/pinboard/0.1.0/src/pinboard.css b/pinboard/0.1.0/src/pinboard.css new file mode 100644 index 00000000..a9f0709f --- /dev/null +++ b/pinboard/0.1.0/src/pinboard.css @@ -0,0 +1,126 @@ +/* @rookery/pinboard: an absolutely-positioned board of note cards. Every + rule is scoped under `.pinboard`/`.pinboard-card` — this stylesheet is + injected into every page of a consuming project, and an unscoped rule + here would restyle pages that carry no board at all. + + A CARD'S FRAME IS CORE'S. `src/board.typ` puts a real `#window` inside + every card, so the left rule, the hat carrying the note's id, the hover + tint and the click-to-open disclosure all come from `core.css` and move + with whatever a project sets through `#show: rookery.with(..)`. What is + left here is the shell that positions that window, and the two things a + board needs from a window that a window standing in prose does not: a + summary row that drags, and a body that scrolls instead of growing the + card past the board. */ + +/* `--pinboard-height` is written by `src/pinboard.js`'s `sizeBoard`, from + the tallest card's bottom edge plus a little slack — a column of + absolutely positioned cards does not otherwise push the board's own + height, so without it a stacked board would clip everything past the + 480px floor. */ +.pinboard { + position: relative; + min-height: max(480px, var(--pinboard-height, 0px)); +} + +/* Position rides on `--pin-x`/`--pin-y`, written by `src/pinboard.js` and by + `src/drag.js` during a drag — never `style.left`/`style.top` directly, so + both have one property pair to touch. */ +.pinboard-card { + position: absolute; + translate: var(--pin-x, 0) var(--pin-y, 0); + width: 300px; + /* A collapsed card shrinks to fit its summary row. `layout.js`'s `cardHeight` + is a spacing parameter for the initial grid, not a rendered height, so + nothing here may pin the card to a fixed height. */ + height: auto; + /* Transparent, so a card carries the board's own ground rather than a second + one over it. A project whose cards overlap enough to need them opaque sets + `--pinboard-card-bg` — `Canvas` for the reader's page colour, which holds + under a dark theme too. */ + background: var(--pinboard-card-bg, transparent); + /* ROOM FOR THE HAT above the air. A window's id sits a whole + `--idea-label-size` above the frame's top edge — core lifts the tab there + — so a card owing only its own padding would clip the id. Read from the + property core lifts by, never a pixel count, so a project retheming the + label keeps the corner inside the card. */ + padding: calc(var(--idea-label-size, 0.57rem) + 0.5em) 0.75em 0.6em; +} + +/* Core pays half the lift back as `padding-top` on the figure it wraps a + window in, for a window in prose whose other half overlaps the margin + above it. A card pays the whole lift itself (above), so this would be a + second helping of it. */ +.pinboard-card > figure { + margin: 0; + padding-top: 0; +} + +/* THE SUMMARY ROW IS THE HANDLE, and it is still the disclosure: a press that + moves is a drag, a press that does not is the click that opens the card + (`src/drag.js`). `grab` describes both — the row answers a press either way. + `user-select: none` is what keeps a drag from turning into a text + selection; suppressing that from JavaScript would take a `preventDefault` + on `pointerdown`, and that costs the click the disclosure needs. */ +.pinboard-card [data-rookery="window-summary"] { + cursor: grab; + user-select: none; +} + +/* The permalink in the hat is a real link and keeps a link's cursor. */ +.pinboard-card [data-rookery="window-summary"] a { + cursor: pointer; +} + +/* While dragging, the card must track the pointer exactly rather than ease + behind it, so any transition on `translate` is suspended for the duration. */ +.pinboard-card[data-dragging] { + transition: none; +} + +.pinboard-card[data-dragging] [data-rookery="window-summary"] { + cursor: grabbing; +} + +/* One long note must not make a card taller than the board. */ +.pinboard-card [data-rookery="window-body"] { + max-height: 200px; + overflow: auto; +} + +/* A selected card is marked for the group drag a press on it will + start. A fill rather than a ring: the window's ground goes to the + project's own border colour, so a selection reads as a block of that + hue and the left rule merges into it. + + The fill lands on the window and not on the card because core writes + `--idea-border-color` as an inline style on the window, and a custom + property reaches descendants only — a rule on the card, which is that + window's ancestor, would resolve the chain to its final fallback + instead of the colour the project set. */ +.pinboard-card[data-selected] [data-rookery="window"] { + background: var(--idea-border-color, var(--idea-link-color, rgba(128, 0, 255, 0.12))); + color: var(--pinboard-select-fg, #fff); +} + +/* The hat's id and an idea's date carry their own colours, which core + emits inline on the window — where a stylesheet cannot outrank them. + One level down it can, and a grey minted to read against the page + would otherwise sit unreadable on the fill. */ +.pinboard-card[data-selected] [data-rookery="window-details"] { + --idea-name-color: var(--pinboard-select-fg, #fff); + --idea-date-color: var(--pinboard-select-fg, #fff); +} + +/* The rubber band, appended to the board by `src/select.js` for the + duration of a marquee drag and positioned in the board's own + coordinate space — the same space the cards' `--pin-x`/`--pin-y` are + in, since `.pinboard` is the positioned ancestor of both. Under the + cards, and transparent to the pointer, so it never takes an event + from the gesture drawing it. */ +.pinboard-marquee { + position: absolute; + z-index: 0; + pointer-events: none; + border: 1px dashed var(--pinboard-select, currentColor); + background: color-mix(in srgb, var(--pinboard-select, currentColor) 8%, transparent); +} diff --git a/pinboard/0.1.0/src/pinboard.js b/pinboard/0.1.0/src/pinboard.js new file mode 100644 index 00000000..fcc51481 --- /dev/null +++ b/pinboard/0.1.0/src/pinboard.js @@ -0,0 +1,201 @@ +// Boot module for @rookery/pinboard, and vite's entry: finds every +// `[data-pinboard]` container already on the page, restores each card's +// pinned position and collapsed state from `src/store.js`, lays out any card +// the store has nothing for with the board's chosen layout from +// `src/layout.js`, and wires the summary-row drag from `src/drag.js` and the +// card's own disclosure from `src/collapse.js` to persist through the same +// store on change. The marquee selection from `src/select.js` is wired +// alongside them and persists nothing: a selection is a gesture in progress, +// not part of a board's arrangement. Position rides on the +// `--pin-x`/`--pin-y` custom properties for `src/pinboard.css` to place a +// card with, and not `style.left`/`style.top`, so the store, the layouts and +// the drag all go through the same pair. +// +// Restoring the store happens synchronously, before a card is ever painted +// with a computed position — a stored layout applied a frame late would be a +// visible jump whether the page just loaded or `rheo watch` just morphed it. +// +// THIS FILE DECLARES `js_rehydrate = true` (`typst.toml`) and pushes `init` +// onto `globalThis.__rheoRehydrate`: a rebuild that touched only `.typ` sources +// now patches the edit into the live DOM (Idiomorph) rather than reloading, +// re-running no script, and writes the PRE-HYDRATION markup back over +// whatever a board's boot already did to it — so `init` has to run again, +// exactly as it did on first load, and running it twice on the same board has +// to be safe. `layOutBoard` gets that safety from a fresh wiring per board +// (`@rheo/rehydrate`'s `wiring`, below), which drops the previous pass's +// drag, collapse and selection listeners before wiring a new set on whatever +// the morph left behind. An asset change still reloads the page outright, +// where nothing here runs at all. +// +// Injected on every page of a rheo project, most of which carry no board at +// all, so absent a `[data-pinboard]` this finds nothing and returns silently +// — no throw, no console output. + +import { flowPositions, stackPositions } from "./layout.js"; +import { makeDraggable, readPosition, writePosition } from "./drag.js"; +import { makeCollapsible, isCollapsed, setCollapsed } from "./collapse.js"; +import { makeSelectable } from "./select.js"; +import { loadBoard, saveCard } from "./store.js"; + +// ONE WIRING PASS PER BOARD. `layOutBoard` calls three separate modules' +// wiring functions on every pass — `makeDraggable`, `makeCollapsible`, +// `makeSelectable` — so one signal keyed on the board scopes all three, rather +// than a wiring per module. +// +// `@rheo/rehydrate` keeps the per-key controller bookkeeping. READ AT CALL +// TIME: script execution order between two packages is whatever order a +// consuming project imported them in, which neither package can see. +// +// The fallback returns a signal without aborting a previous one, which is only +// reached on a rheo too old to have injected the helper — and that is a rheo too +// old to morph, so it reloads the page and there is no previous pass to drop. +const wiring = (key) => + globalThis.RheoRehydrate?.wiring?.(key) ?? new AbortController().signal; + +// Recomputes `--pinboard-height` (read by `src/pinboard.css`) from every +// card's own bottom edge, so the board both grows and shrinks with its +// content — a card opening or a drag ending both call this. `growBoardFor` +// below is the grow-only path used while a drag is still in flight, where +// measuring every card on each `pointermove` would be too slow. +function sizeBoard(board) { + const cards = [...board.querySelectorAll(":scope > .pinboard-card")]; + let maxBottom = 0; + for (const card of cards) { + const bottom = readPosition(card).y + card.getBoundingClientRect().height; + if (bottom > maxBottom) maxBottom = bottom; + } + board.style.setProperty("--pinboard-height", `${maxBottom + 24}px`); +} + +// The per-frame half of `sizeBoard`, for a card being dragged: raises +// `--pinboard-height` to clear this one card's bottom edge and never +// lowers it, so a drag downward grows the board under the pointer +// instead of stopping at its old extent. The drag's end calls `persist`, +// and so `sizeBoard`, which recomputes the exact height from every card. +function growBoardFor(board, card) { + const bottom = readPosition(card).y + card.getBoundingClientRect().height + 24; + const current = parseFloat(board.style.getPropertyValue("--pinboard-height")) || 0; + if (bottom > current) board.style.setProperty("--pinboard-height", `${bottom}px`); +} + +function layOutBoard(board) { + // Abandoned BEFORE anything below runs, so a re-wire cannot briefly leave + // this pass's listeners racing the last one's, and so a board that has + // lost its last card since the previous pass still drops whatever that + // pass wired on it rather than returning early with the old listeners + // still live. `signal` then scopes every listener `makeDraggable`, + // `makeCollapsible` and `makeSelectable` add below, and the NEXT pass's + // abort drops all three sets in one call. + const signal = wiring(board); + + const cards = [...board.querySelectorAll(":scope > .pinboard-card")]; + if (cards.length === 0) return; + + const boardId = board.dataset.pinboard; + const stored = loadBoard(boardId); + + // Shared by both `onChange` callbacks below: a card's persisted entry + // always carries both its position and its collapsed state together, so + // whichever one just changed, the save reads the other off the card + // itself rather than out of stale closure state. Either change can also + // grow the board, so both re-measure it. + function persist(card) { + const pos = readPosition(card); + saveCard(boardId, card.dataset.pinboardId, { + x: pos.x, + y: pos.y, + collapsed: isCollapsed(card), + }); + sizeBoard(board); + } + + // A board built by an older copy of the Typst half carries no + // `data-pinboard-layout` at all — treated as the current default, + // `"stack"`, rather than the old `"flow"`. + const useFlow = board.dataset.pinboardLayout === "flow"; + + const unplaced = []; + let restoredBottom = 0; + for (const card of cards) { + const entry = stored[card.dataset.pinboardId]; + if (!entry) { + unplaced.push(card); + continue; + } + writePosition(card, entry.x, entry.y); + setCollapsed(card, entry.collapsed); + const bottom = entry.y + card.getBoundingClientRect().height; + if (bottom > restoredBottom) restoredBottom = bottom; + } + + // Only the cards the store has nothing for get a computed position — a + // note written since the board was last arranged lands after whatever the + // store already placed, rather than wherever a full-board layout would + // put it. + if (unplaced.length > 0) { + const ids = unplaced.map((c) => c.dataset.pinboardId); + if (useFlow) { + const width = board.getBoundingClientRect().width; + const positions = flowPositions(ids, width > 0 ? { boardWidth: width } : {}); + for (const card of unplaced) { + const pt = positions.get(card.dataset.pinboardId); + if (!pt) continue; + writePosition(card, pt.x, pt.y); + } + } else { + // Read every unplaced card's height before writing any position: an + // interleaved read/write per card is a forced layout per card, and a + // board carries a hundred of them. + const heights = new Map(); + for (const card of unplaced) { + heights.set(card.dataset.pinboardId, card.getBoundingClientRect().height); + } + const positions = stackPositions(ids, { heights, startY: restoredBottom, x: 0 }); + for (const card of unplaced) { + const pt = positions.get(card.dataset.pinboardId); + if (!pt) continue; + writePosition(card, pt.x, pt.y); + } + } + } + + sizeBoard(board); + makeDraggable(board, { onChange: persist, onMove: (card) => growBoardFor(board, card), signal }); + makeCollapsible(board, { onChange: persist, signal }); + makeSelectable(board, { signal }); +} + +function init() { + for (const board of document.querySelectorAll("[data-pinboard]")) layOutBoard(board); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} + +// REHYDRATE AFTER A rheo MORPH. The dev server patches a content edit into +// the live DOM instead of reloading (`docs/contract.md`), which re-runs no +// script — and the markup it patches in is the PRE-HYDRATION build output, +// so every board comes back with no `--pinboard-height`, no restored +// `--pin-x`/`--pin-y` and no restored collapsed state, while listeners bound +// to whatever nodes survived the morph are still live. `js_rehydrate = true` +// in `typst.toml` is the other half of the declaration: without it rheo +// reloads the page and never calls this. +// +// RE-RUNNING `init()` IS SAFE: `layOutBoard` aborts its own previous pass's +// listeners before wiring a new set (`@rheo/rehydrate`'s `wiring`, keyed on +// the board, the same helper `@rookery/search`'s `panel.js` uses), and +// every boot-time DOM write it makes — the custom properties, the +// position/collapsed restore — is a write rather than an append, so +// repeating it changes nothing a second time. Nothing needs preserving by +// hand across the morph either: a board's arrangement lives in +// `localStorage`, read fresh by `src/store.js` on every pass, not in any +// variable this module or `drag.js`/`select.js` would otherwise have to +// carry across the rewire. +// +// `globalThis`, not `window`: the node suites supply a document and no +// `window`, and reading one at module-evaluation time would throw there on +// an access every real page satisfies for free. +(globalThis.__rheoRehydrate ??= []).push(init); diff --git a/pinboard/0.1.0/src/select.js b/pinboard/0.1.0/src/select.js new file mode 100644 index 00000000..999abc1c --- /dev/null +++ b/pinboard/0.1.0/src/select.js @@ -0,0 +1,134 @@ +// Marquee (rubber-band) selection for pinboard cards. Selection is the +// `data-selected` attribute on a card, not a JavaScript set: the DOM is the +// single source of truth, it is stylable straight from CSS, and it lets +// `src/drag.js` read the current selection without importing this module +// back for it. A card's rectangle is its `--pin-x`/`--pin-y` pair plus its +// offset size, since `.pinboard` is the cards' own coordinate space — no +// `getBoundingClientRect` needed for the hit test. + +import { readPosition, movedEnough } from "./drag.js"; + +// The marquee's rectangle from the two pointer positions it was dragged +// between, in board coordinates. Normalized, so a drag up-and-left is the +// same rectangle as the drag down-and-right that traces it backwards. +export function marqueeRect(start, pointer) { + const x = Math.min(start.x, pointer.x); + const y = Math.min(start.y, pointer.y); + return { + x, + y, + width: Math.abs(pointer.x - start.x), + height: Math.abs(pointer.y - start.y), + }; +} + +// Any overlap counts, not containment: a reader dragging a band across a +// column of cards means the ones it crossed, not only the ones it managed +// to swallow whole. Edge-touching does not count. +export function rectsOverlap(a, b) { + return ( + a.x < b.x + b.width && + b.x < a.x + a.width && + a.y < b.y + b.height && + b.y < a.y + a.height + ); +} + +// A card's rectangle in board coordinates. +export function cardRect(card) { + const pos = readPosition(card); + return { x: pos.x, y: pos.y, width: card.offsetWidth, height: card.offsetHeight }; +} + +export function selectedCards(board) { + return [...board.querySelectorAll(".pinboard-card[data-selected]")]; +} + +export function isSelected(card) { + return card.hasAttribute("data-selected"); +} + +export function setSelected(card, selected) { + card.toggleAttribute("data-selected", selected); +} + +export function clearSelection(board) { + for (const card of selectedCards(board)) setSelected(card, false); +} + +export function makeSelectable(board, opts = {}) { + // SCOPES EVERY LISTENER THIS PASS ADDS, the same reason `src/drag.js`'s + // `makeDraggable` takes one: `src/pinboard.js` holds one AbortController + // per board and aborts the previous pass's before calling this again. + const { signal } = opts; + let gesture = null; + + board.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + // A press on a card belongs to `src/drag.js`'s own gesture. + if (event.target.closest(".pinboard-card")) return; + + clearSelection(board); + // The board's rect is read once per gesture, here, rather than on every + // `pointermove` — that would be a forced layout per frame. + const rect = board.getBoundingClientRect(); + gesture = { + startPointer: { x: event.clientX - rect.left, y: event.clientY - rect.top }, + boardRect: rect, + pointerId: event.pointerId, + band: null, + }; + }, { signal }); + + board.addEventListener("pointermove", (event) => { + if (!gesture) return; + const pointer = { + x: event.clientX - gesture.boardRect.left, + y: event.clientY - gesture.boardRect.top, + }; + if (!gesture.band) { + if (!movedEnough(gesture.startPointer, pointer)) return; + gesture.band = document.createElement("div"); + gesture.band.className = "pinboard-marquee"; + board.appendChild(gesture.band); + board.setPointerCapture(event.pointerId); + } + const rect = marqueeRect(gesture.startPointer, pointer); + gesture.band.style.left = `${rect.x}px`; + gesture.band.style.top = `${rect.y}px`; + gesture.band.style.width = `${rect.width}px`; + gesture.band.style.height = `${rect.height}px`; + gesture.lastPointer = pointer; + }, { signal }); + + function endGesture(select) { + if (!gesture) return; + const { band, pointerId, startPointer, lastPointer } = gesture; + gesture = null; + if (!band) return; + if (select) { + const rect = marqueeRect(startPointer, lastPointer); + for (const card of board.querySelectorAll(":scope > .pinboard-card")) { + if (rectsOverlap(cardRect(card), rect)) setSelected(card, true); + } + opts.onChange?.(selectedCards(board)); + } + band.remove(); + if (board.hasPointerCapture(pointerId)) board.releasePointerCapture(pointerId); + } + + board.addEventListener("pointerup", () => endGesture(true), { signal }); + // A gesture the browser takes over must not leave the board stuck with a + // band on it, mirroring `src/drag.js`'s own `pointercancel` handling. + board.addEventListener("pointercancel", () => endGesture(false), { signal }); + + // RESET ANY IN-FLIGHT MARQUEE WHEN THIS PASS IS SUPERSEDED, for the same + // reason `src/drag.js` resets an in-flight drag: this pass's own + // `pointerup`/`pointercancel` are removed by this same abort, so a gesture + // mid-band when the rewire happened would otherwise never reach + // `endGesture` at all. `endGesture(false)` is reusable here, unlike + // `drag.js`'s `endDrag` — the cancel path removes the band and releases + // capture but never reads a card's position, so there is nothing for the + // morph having already reverted the DOM to corrupt. + signal?.addEventListener("abort", () => endGesture(false)); +} diff --git a/pinboard/0.1.0/src/store.js b/pinboard/0.1.0/src/store.js new file mode 100644 index 00000000..fe904d4e --- /dev/null +++ b/pinboard/0.1.0/src/store.js @@ -0,0 +1,84 @@ +// Persists a board's card positions and collapse state to `localStorage`, +// keyed on the board's own id (`data-pinboard`) rather than the page's path — +// moving or renaming the file that holds `#pinboard(id: ..)` must not throw +// the arrangement away, and two boards sharing an id sharing a layout is the +// author's business for choosing that id in the first place. +// +// The entry for each card is keyed on the idea's own stable id (the +// `data-pinboard-id` `src/board.typ` emits from `core`'s `ideas()`), which is +// what lets a card's place survive edits to its title and prose: the note +// keeps its id no matter what changes underneath it. +// +// `localStorage` can throw on mere access — a private window, blocked site +// data, some embedding contexts — so every read and write here is wrapped; +// a board that cannot read or write storage falls back to the flow layout +// and boots anyway, rather than throwing during boot. + +export function storageKey(boardId) { + return `rookery-pinboard:${boardId}`; +} + +function isFiniteNumber(n) { + return typeof n === "number" && Number.isFinite(n); +} + +// Discards a malformed entry rather than the whole board's layout — one bad +// value (a corrupted write, a future shape this version doesn't know) must +// cost that one card its saved position, not every card on the board. +function isValidEntry(entry) { + return ( + entry !== null && + typeof entry === "object" && + isFiniteNumber(entry.x) && + isFiniteNumber(entry.y) + ); +} + +// -> `{ "": { x, y, collapsed } }`. Never throws: a missing key, a +// storage access that throws, a value that isn't valid JSON, or a value that +// doesn't parse to a plain object all fall back to `{}`, and invalid entries +// within an otherwise-valid object are dropped individually. +export function loadBoard(boardId) { + let raw; + try { + raw = localStorage.getItem(storageKey(boardId)); + } catch { + return {}; + } + if (!raw) return {}; + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + + const board = {}; + for (const [ideaId, entry] of Object.entries(parsed)) { + if (!isValidEntry(entry)) continue; + board[ideaId] = { x: entry.x, y: entry.y, collapsed: entry.collapsed === true }; + } + return board; +} + +// Writes the whole entry for one idea id, reading the board's current +// object and writing it back — never a long-lived in-memory copy flushed on +// an interval, which would let two tabs open on the same board silently +// overwrite one another. Entries for ids no longer on the board are left +// untouched here (see `loadBoard`'s caller in `src/pinboard.js` for why +// nothing ever prunes them): a note deleted, renamed, or dropped from one +// `rheo watch` build by an unrelated compile error must find its place again +// when it comes back, and an entry is a few dozen bytes against a corpus +// bounded by the size of the project. +export function saveCard(boardId, ideaId, entry) { + try { + const board = loadBoard(boardId); + board[ideaId] = { x: entry.x, y: entry.y, collapsed: entry.collapsed === true }; + localStorage.setItem(storageKey(boardId), JSON.stringify(board)); + } catch { + // A write that fails (quota, blocked storage, a throwing getter) leaves + // the board unarranged on the next load rather than breaking this one. + } +} diff --git a/pinboard/0.1.0/test/browser/board.mjs b/pinboard/0.1.0/test/browser/board.mjs new file mode 100644 index 00000000..69f2a459 --- /dev/null +++ b/pinboard/0.1.0/test/browser/board.mjs @@ -0,0 +1,413 @@ +// Real-engine assertions for `#pinboard`, against the built demo at +// ../../demo/rheo/build/html/. Node with linkedom has no pointer capture, no +// layout and no `localStorage`, so the board's actual behaviour — does it +// boot into a real position, does a drag track the pointer, does the note's +// own link still open, does a collapse toggle actually change rendered +// height, does the arrangement survive a reload — is otherwise asserted +// nowhere. +// +// The demo's board uses the package's default layout, `"stack"`: every card +// the store has nothing for lands in one column at x=0, in ids() order, so a +// freshly booted board's cards share an x and differ only in y. Every +// assertion below is written against that, not against the older `"flow"` +// wrapping-grid arrangement. +// +// A card is a `@rookery/core` `#window` with NO disclosure open by default +// (`#pinboard`'s `folded: true`), so on a fresh board every card's +// `[data-rookery="window-details"]` starts WITHOUT `open` — there is no +// `.pinboard-card-toggle` button and no `aria-expanded` anywhere in the +// markup; the disclosure is native `
        `/``, toggled by +// clicking the row and read back by the `open` attribute alone. +import assert from "node:assert/strict"; +import { serve, requireBuild, run } from "../../../../test/browser/harness.mjs"; + +const ROOT = new URL("../../demo/rheo/build/html/", import.meta.url).pathname; +requireBuild(`${ROOT}index.html`, "cd pinboard/0.1.0 && just check"); + +const CARD = ".pinboard-card"; +const TITLE = '[data-rookery="window-title"]'; +// The anchor INSIDE the permalink, not the permalink itself: core's +// `_permalink` puts `data-rookery` on a wrapping ``, because Typst's +// `link()` renders a bare `` that carries none of the element's +// own attributes. This suite wants the anchor, for its `href` and its click. +const LABEL = '[data-rookery="label"] a'; +const DETAILS = '[data-rookery="window-details"]'; +const BODY = '[data-rookery="window-body"]'; +// The drag handle `drag.js` requires a press to land inside. +const HANDLE = '[data-rookery="window-summary"]'; + +const cardLocator = (page, id) => page.locator(`${CARD}[data-pinboard-id="${id}"]`); + +const positions = (page) => + page.$$eval(CARD, (cards) => + cards.map((c) => ({ + id: c.dataset.pinboardId, + x: parseFloat(c.style.getPropertyValue("--pin-x")), + y: parseFloat(c.style.getPropertyValue("--pin-y")), + })), + ); + +const isOpen = (card) => card.locator(DETAILS).evaluate((d) => d.hasAttribute("open")); + +const clearStorage = (page) => page.evaluate(() => localStorage.clear()); + +// A press point HIT-TESTED rather than assumed, because the three things that +// make a point draggable are all layout-dependent and this suite runs on three +// engines whose text metrics differ: +// +// - it must land on THIS card. The demo's `"stack"` layout puts every card in +// one column, so a font that renders cards taller than the stack's spacing +// overlaps them, and a press aimed at one card's title can be taken by the +// card painted over it — which moves the wrong card and leaves this one +// exactly where it was. +// - it must land inside the summary row, the handle `drag.js` requires. +// - it must MISS the anchor inside the tab's `[data-rookery="label"]` permalink, and any other +// interactive descendant, which `drag.js` bails on so their click survives. +// +// `elementFromPoint` answers all three from inside the page. Candidates walk +// across the title's FIRST client rect — the first line's own box, not the +// union `getBoundingClientRect` returns, whose centre falls between the lines +// when a title wraps. +// +// `scrollIntoViewIfNeeded` first: a raw client rect is viewport-relative, and a +// navigation leaving the page at a different offset (a `goBack` restoring +// scroll position, a reload) silently mis-aims a `page.mouse` call built from a +// stale box — unlike a locator's own `.click()`, which scrolls before it clicks. +// +// Returns `{ point }` or `{ point: null, tried }`, where `tried` says what each +// candidate hit. The caller turns that into its own failure message: a suite +// that cannot aim a press must say WHY rather than report a card that did not +// move. +const pressPoint = async (page, id) => { + const card = cardLocator(page, id); + await card.locator(TITLE).scrollIntoViewIfNeeded(); + return page.evaluate( + ([id, TITLE, HANDLE]) => { + const card = document.querySelector(`.pinboard-card[data-pinboard-id="${id}"]`); + if (!card) return { point: null, tried: [`no card with id ${id}`] }; + const rect = card.querySelector(TITLE).getClientRects()[0]; + if (!rect) return { point: null, tried: ["the title has no client rect"] }; + const y = rect.y + rect.height / 2; + const tried = []; + // Right of centre first: the permalink sits BEFORE the title in the row, + // so the far end of the first line is the point least likely to be under + // it once metrics shift. + for (const fraction of [0.75, 0.5, 0.9, 0.25]) { + const x = rect.x + rect.width * fraction; + const hit = document.elementFromPoint(x, y); + if (!hit) { + tried.push(`${fraction}: nothing at (${Math.round(x)}, ${Math.round(y)})`); + continue; + } + const hitCard = hit.closest(".pinboard-card"); + const hitId = hitCard ? hitCard.dataset.pinboardId : null; + if (hitId !== id) { + tried.push(`${fraction}: landed on card ${hitId ?? "(none)"}`); + continue; + } + if (!hit.closest(HANDLE)) { + tried.push(`${fraction}: landed outside the summary row`); + continue; + } + if (hit.closest("a, button, input")) { + tried.push(`${fraction}: landed on an interactive ${hit.closest("a, button, input").tagName}`); + continue; + } + return { point: { x, y }, tried }; + } + return { point: null, tried }; + }, + [id, TITLE, HANDLE], + ); +}; + +// What the board actually looks like, for a failure message. A drag that moved +// nothing is explained by the geometry around it — overlapping cards, a card +// somewhere other than where the stack should have put it — and none of that +// survives in `x moved 0`. +const boardGeometry = (page) => + page.$$eval(".pinboard-card", (cards) => + cards.map((c) => { + const r = c.getBoundingClientRect(); + return `${c.dataset.pinboardId} @(${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.width)}x${Math.round(r.height)}`; + }), + ); + +// `page.mouse.move` resolves when the event is DISPATCHED, not when the page +// has handled it, so the last move of a drag can still be in flight when the +// card's position is read back. Gecko's protocol is asynchronous enough for +// that to land a card one interpolated step short of where the drag ended — +// a real failure to wait, not a bug in `drag.js`. +// +// Falls through on timeout rather than raising, so the assertion that follows +// reports the position it actually found instead of a timeout that says +// nothing about how far the card moved. +// `drag.js` sets `data-dragging` on the card the moment a press crosses +// DRAG_THRESHOLD, so this is the gesture's own report that it became a drag +// rather than a click. Waiting on it before the remaining moves is what keeps +// a slow engine from reaching `mouse.up` with the press still undecided — a +// card that never moved at all, rather than one that moved too little. +// +// Falls through on timeout for the same reason as `settleAt`. +const dragStarted = async (page, id) => { + try { + await page + .locator(`.pinboard-card[data-pinboard-id="${id}"][data-dragging]`) + .waitFor({ state: "attached", timeout: 2000 }); + return true; + } catch { + return false; + } +}; + +const settleAt = async (page, id, wantX, tolerance) => { + try { + await page.waitForFunction( + ([id, wantX, tolerance]) => { + const card = document.querySelector(`.pinboard-card[data-pinboard-id="${id}"]`); + if (!card) return false; + return Math.abs(parseFloat(card.style.getPropertyValue("--pin-x")) - wantX) <= tolerance; + }, + [id, wantX, tolerance], + { timeout: 2000 }, + ); + } catch { + /* the assertion below is the diagnostic */ + } +}; + +// Two frames with nothing arriving, which is what lets a "the card did NOT +// move" assertion mean it: an in-flight pointermove would otherwise land +// after the read and the check would pass without having tested anything. +const flushFrames = (page) => + page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + +const { origin, close } = await serve(ROOT); +try { + await run("pinboard-board", async ({ newPage }) => { + // Case 1: the board boots into real, distinct positions with no error. + const page = await newPage(); + await page.goto(`${origin}/index.html`); + await clearStorage(page); + await page.reload(); + + const booted = await positions(page); + assert.equal(booted.length, 4, `expected 4 cards, got ${booted.length}`); + for (const p of booted) { + assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `card ${p.id} has no numeric position`); + } + const seen = new Set(); + for (const p of booted) { + const key = `${p.x},${p.y}`; + assert.ok(!seen.has(key), `two cards share position ${key}`); + seen.add(key); + } + assert.equal(page.errors.length, 0, `page recorded errors on boot: ${page.errors}`); + + // Every card starts closed: `#pinboard`'s `folded: true` default, with + // no stored entry yet to override it. + for (const p of booted) { + const open = await isOpen(cardLocator(page, p.id)); + assert.equal(open, false, `card ${p.id} started open, wanted closed (folded: true default)`); + } + + // Case 2: a real drag on the handle moves the card by the pointer delta. + const outline = cardLocator(page, "idea:outline"); + const before = (await positions(page)).find((p) => p.id === "idea:outline"); + const aim = await pressPoint(page, "idea:outline"); + assert.ok( + aim.point, + `no draggable point on idea:outline's handle — tried ${JSON.stringify(aim.tried)}; ` + + `board: ${JSON.stringify(await boardGeometry(page))}`, + ); + const start = aim.point; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + // EVERY STAGE OF THE GESTURE WAITS ON WHAT IT CAUSED, and the waits happen + // while the pointer is still down — `mouse.up` ends the drag, so a move + // still in flight when it fires is a move that never happens at all. + await page.mouse.move(start.x + 20, start.y + 15, { steps: 3 }); + const started = await dragStarted(page, "idea:outline"); + await page.mouse.move(start.x + 45, start.y + 30, { steps: 3 }); + await page.mouse.move(start.x + 60, start.y + 40, { steps: 3 }); + await settleAt(page, "idea:outline", before.x + 60, 2); + await page.mouse.up(); + const after = (await positions(page)).find((p) => p.id === "idea:outline"); + // The geometry and whether the press ever became a drag both go in the + // message: "moved 0" on its own cannot tell a press that missed from a + // press that landed and was ignored. + const why = + ` (drag ${started ? "started" : "NEVER STARTED"}; pressed (${Math.round(start.x)}, ` + + `${Math.round(start.y)}); board: ${JSON.stringify(await boardGeometry(page))})`; + assert.ok(Math.abs(after.x - before.x - 60) <= 2, `x moved ${after.x - before.x}, wanted ~60${why}`); + assert.ok(Math.abs(after.y - before.y - 40) <= 2, `y moved ${after.y - before.y}, wanted ~40`); + assert.equal(page.errors.length, 0, `page recorded errors during drag: ${page.errors}`); + + // The click that ends a real drag must not also toggle the card open — + // `src/drag.js` suppresses it deliberately. + assert.equal(await isOpen(outline), false, "the drag's ending click reopened the card"); + + // Case 3: the note's own link still opens, and a drag never intercepts it. + const counterargument = cardLocator(page, "idea:counterargument"); + const label = counterargument.locator(LABEL); + // Resolved against the page rather than pasted onto the origin: core mints + // a permalink relative to the page it sits on, so the href reads + // `./ideas/.html` from the root and `../..`-style from a nested one, + // and neither concatenates into a URL the browser will report back. + const href = await label.getAttribute("href"); + const target = new URL(href, page.url()).href; + await label.click(); + await page.waitForURL(target); + assert.equal(page.url(), target, `navigating the label landed on ${page.url()}, wanted ${target}`); + await page.goBack(); + await page.waitForURL(`${origin}/index.html`); + + // Case 4: a press that opens the card, then a drag started from the + // card's own body (not its handle), must not move it at all. + const interview = cardLocator(page, "idea:interview"); + await interview.locator(TITLE).click(); + assert.equal(await isOpen(interview), true, "clicking the title did not open the card"); + + const beforeBody = (await positions(page)).find((p) => p.id === "idea:interview"); + const interviewBody = interview.locator(BODY); + await interviewBody.scrollIntoViewIfNeeded(); + const bodyBox = await interviewBody.boundingBox(); + assert.ok(bodyBox, "an opened card's body has no bounding box"); + const bodyPoint = { x: bodyBox.x + bodyBox.width / 2, y: bodyBox.y + bodyBox.height / 2 }; + await page.mouse.move(bodyPoint.x, bodyPoint.y); + await page.mouse.down(); + await page.mouse.move(bodyPoint.x + 100, bodyPoint.y, { steps: 5 }); + await page.mouse.up(); + await flushFrames(page); + const afterBody = (await positions(page)).find((p) => p.id === "idea:interview"); + assert.deepEqual(afterBody, beforeBody, "a press on the card body moved the card"); + + // Closes interview again: case 6 below persists the closed round trip + // (`collapsed: true`); case 8 persists the open round trip instead. + await interview.locator(TITLE).click(); + assert.equal(await isOpen(interview), false, "closing the title did not close the card again"); + + // Case 5: the collapse toggle actually changes rendered height, and + // reverses cleanly. + const scene = cardLocator(page, "idea:scene-one"); + await scene.scrollIntoViewIfNeeded(); + const closedHeight = (await scene.boundingBox()).height; + const sceneTitle = scene.locator(TITLE); + await sceneTitle.click(); + assert.equal(await isOpen(scene), true, "clicking the title did not open scene-one"); + const openHeight = (await scene.boundingBox()).height; + assert.ok(openHeight > closedHeight, `opened height ${openHeight} not greater than closed height ${closedHeight}`); + await sceneTitle.click(); + assert.equal(await isOpen(scene), false, "a second click did not close scene-one again"); + const reclosedHeight = (await scene.boundingBox()).height; + assert.ok( + Math.abs(reclosedHeight - closedHeight) <= 1, + `reclosed height ${reclosedHeight} does not match original closed height ${closedHeight}`, + ); + assert.equal(page.errors.length, 0, `page recorded errors toggling collapse: ${page.errors}`); + + // Case 6: the arrangement survives a reload, and a fresh page in the + // same context (a `bfcache`-restored page is not what this measures, + // but a fresh navigation reading the same store is the part that + // matters — the store, not the page instance, is what has to persist). + // Checked only for `outline`, `interview` and `scene-one`: each has a + // real stored entry now, from the drag and the two collapse toggles + // above. `counterargument` was only ever navigated away from and back + // to — no drag, no collapse — so the store has nothing for it, and a + // reload legitimately recomputes its position as an unplaced card among + // whichever others are still unplaced; asserting it stays put would be + // asserting a coincidence, not the store's behaviour. + const stored = { + outline: (await positions(page)).find((p) => p.id === "idea:outline"), + interview: (await positions(page)).find((p) => p.id === "idea:interview"), + scene: (await positions(page)).find((p) => p.id === "idea:scene-one"), + }; + assert.equal(await isOpen(outline), false, "outline should still be closed going into the reload"); + assert.equal(await isOpen(interview), false, "interview should still be closed going into the reload"); + assert.equal(await isOpen(scene), false, "scene-one should still be closed going into the reload"); + + const assertPersisted = async (target) => { + const got = await positions(target); + assert.deepEqual(got.find((p) => p.id === "idea:outline"), stored.outline, "outline's position did not persist"); + assert.deepEqual( + got.find((p) => p.id === "idea:interview"), + stored.interview, + "interview's position did not persist", + ); + assert.deepEqual( + got.find((p) => p.id === "idea:scene-one"), + stored.scene, + "scene-one's position did not persist", + ); + assert.equal(await isOpen(cardLocator(target, "idea:outline")), false, "outline's closed state did not persist"); + assert.equal( + await isOpen(cardLocator(target, "idea:interview")), + false, + "interview's closed state did not persist", + ); + assert.equal(await isOpen(cardLocator(target, "idea:scene-one")), false, "scene-one's closed state did not persist"); + }; + + await page.reload(); + await assertPersisted(page); + assert.equal(page.errors.length, 0, `page recorded errors after reload: ${page.errors}`); + + const freshPage = await newPage(); + await freshPage.goto(`${origin}/index.html`); + await assertPersisted(freshPage); + assert.equal(freshPage.errors.length, 0, `fresh page recorded errors: ${freshPage.errors}`); + + // Case 7: a card the store has nothing for is still placed, with a + // position of its own distinct from a card the store names explicitly. + const seedPage = await newPage(); + await seedPage.goto(`${origin}/index.html`); + await clearStorage(seedPage); + await seedPage.evaluate(() => { + localStorage.setItem( + "rookery-pinboard:default", + JSON.stringify({ "idea:outline": { x: 900, y: 900, collapsed: false } }), + ); + }); + await seedPage.reload(); + + const seeded = await positions(seedPage); + const seededOutline = seeded.find((p) => p.id === "idea:outline"); + assert.equal(seededOutline.x, 900, "a stored x was not honoured on reload"); + assert.equal(seededOutline.y, 900, "a stored y was not honoured on reload"); + + const unplaced = seeded.filter((p) => p.id !== "idea:outline"); + assert.equal(unplaced.length, 3, `expected 3 unplaced cards, got ${unplaced.length}`); + const unplacedKeys = new Set(); + for (const p of unplaced) { + assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `unplaced card ${p.id} has no numeric position`); + const key = `${p.x},${p.y}`; + assert.ok(!unplacedKeys.has(key), `two unplaced cards share position ${key}`); + assert.notEqual(key, "900,900", `unplaced card ${p.id} landed on the stored card's own position`); + unplacedKeys.add(key); + } + assert.equal(seedPage.errors.length, 0, `page recorded errors placing an unseen card: ${seedPage.errors}`); + + // Case 8: an OPEN card's disclosure survives a reload too, not just a + // closed one — the gap Case 6's comment above flags. `counterargument` + // has never been dragged or toggled, so its stored entry (if any) is + // whatever `persist()` last wrote for it; open it here, reload, and + // confirm `layOutBoard` restores the open state rather than defaulting + // it back to `#pinboard`'s `folded: true`. + const reopenPage = await newPage(); + await reopenPage.goto(`${origin}/index.html`); + const counterargumentCard = cardLocator(reopenPage, "idea:counterargument"); + await counterargumentCard.locator(TITLE).click(); + assert.equal(await isOpen(counterargumentCard), true, "clicking the title did not open counterargument"); + await reopenPage.reload(); + assert.equal( + await isOpen(cardLocator(reopenPage, "idea:counterargument")), + true, + "an open card's disclosure did not survive a reload", + ); + assert.equal(reopenPage.errors.length, 0, `page recorded errors reopening a card: ${reopenPage.errors}`); + }); +} finally { + await close(); +} diff --git a/pinboard/0.1.0/test/collapse.test.mjs b/pinboard/0.1.0/test/collapse.test.mjs new file mode 100644 index 00000000..77e5ae98 --- /dev/null +++ b/pinboard/0.1.0/test/collapse.test.mjs @@ -0,0 +1,88 @@ +// Unit tests for collapse-to-title. `isCollapsed`/`setCollapsed` are plain +// attribute reads and writes on the `
        ` a card's `#window` puts there, +// which linkedom's lightweight DOM models faithfully — one of the few things +// in this package a node test can exercise with no browser. + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { parseHTML } from "linkedom"; + +import { cardDetails, isCollapsed, setCollapsed } from "../src/collapse.js"; + +// The DOM `src/board.typ` renders, trimmed to what these functions read: the +// card, and the window's disclosure inside the figure core wraps it in. +function makeCard({ nested = false } = {}) { + const body = nested + ? '
        nested
        ' + : "prose"; + const { document } = parseHTML(` +
        +
        +
        + Outline +
        ${body}
        +
        +
        +
        + `); + const card = document.querySelector(".pinboard-card"); + return { card, details: cardDetails(card) }; +} + +test("setCollapsed(card, true) shuts the card's disclosure", () => { + const { card, details } = makeCard(); + setCollapsed(card, true); + assert.ok(!details.hasAttribute("open")); +}); + +test("setCollapsed(card, false) opens it again", () => { + const { card, details } = makeCard(); + setCollapsed(card, true); + setCollapsed(card, false); + assert.ok(details.hasAttribute("open")); +}); + +test("setCollapsed is idempotent", () => { + const { card, details } = makeCard(); + setCollapsed(card, true); + setCollapsed(card, true); + assert.ok(!details.hasAttribute("open")); + setCollapsed(card, false); + setCollapsed(card, false); + assert.ok(details.hasAttribute("open")); +}); + +test("isCollapsed agrees with what setCollapsed last set", () => { + const { card } = makeCard(); + assert.equal(isCollapsed(card), false); + setCollapsed(card, true); + assert.equal(isCollapsed(card), true); + setCollapsed(card, false); + assert.equal(isCollapsed(card), false); +}); + +test("setCollapsed(card, true) works on a card that has never been clicked", () => { + const { card } = makeCard(); + // No listener ever attached or run — this is the boot-time restore path. + setCollapsed(card, true); + assert.ok(isCollapsed(card)); +}); + +test("a window nested in the body does not stand in for the card's own", () => { + const { card, details } = makeCard({ nested: true }); + const inner = card.querySelector('[data-rookery="window-body"] details'); + assert.equal(cardDetails(card), details); + setCollapsed(card, true); + assert.ok(!details.hasAttribute("open")); + assert.ok(inner.hasAttribute("open")); +}); + +test("a card with no window at all reads as open and takes no writes", () => { + const { document } = parseHTML( + '
        ', + ); + const card = document.querySelector(".pinboard-card"); + assert.equal(isCollapsed(card), false); + setCollapsed(card, true); + assert.equal(isCollapsed(card), false); +}); diff --git a/pinboard/0.1.0/test/drag.test.mjs b/pinboard/0.1.0/test/drag.test.mjs new file mode 100644 index 00000000..ae4469a1 --- /dev/null +++ b/pinboard/0.1.0/test/drag.test.mjs @@ -0,0 +1,85 @@ +// Unit tests for the drag arithmetic — the pure half of the drag code, kept +// apart from `makeDraggable` itself so it is testable under `node --test` +// with no browser, DOM shim, or pointer events. `makeDraggable`'s own +// gesture behaviour is exercised separately, under Playwright. + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { + clampGroupDelta, + movedEnough, + DRAG_THRESHOLD, +} from "../src/drag.js"; + +test("movedEnough is false while the press is a click, on either axis", () => { + const start = { x: 100, y: 100 }; + assert.equal(movedEnough(start, start), false); + assert.equal(movedEnough(start, { x: 100 + DRAG_THRESHOLD - 1, y: 100 }), false); + assert.equal(movedEnough(start, { x: 100, y: 100 - (DRAG_THRESHOLD - 1) }), false); +}); + +test("movedEnough is true once either axis reaches the threshold", () => { + const start = { x: 100, y: 100 }; + assert.equal(movedEnough(start, { x: 100 + DRAG_THRESHOLD, y: 100 }), true); + assert.equal(movedEnough(start, { x: 100, y: 100 - DRAG_THRESHOLD }), true); +}); + +test("clampGroupDelta leaves an in-bounds delta untouched", () => { + const items = [ + { x: 40, y: 60, width: 300 }, + { x: 400, y: 200, width: 300 }, + ]; + const delta = clampGroupDelta(items, { x: 20, y: -10 }, 1200); + assert.deepEqual(delta, { x: 20, y: -10 }); +}); + +test("clampGroupDelta narrows a leftward delta so the leftmost member lands at x=0", () => { + const items = [ + { x: 40, y: 60, width: 300 }, + { x: 400, y: 200, width: 300 }, + ]; + const delta = clampGroupDelta(items, { x: -100, y: 0 }, 1200); + assert.deepEqual(delta, { x: -40, y: 0 }); + assert.equal(items[0].x + delta.x, 0); + // The other member keeps its offset from the leftmost one. + assert.equal(items[1].x + delta.x, 360); +}); + +test("clampGroupDelta narrows an upward delta so the topmost member lands at y=0", () => { + const items = [ + { x: 40, y: 60, width: 300 }, + { x: 400, y: 200, width: 300 }, + ]; + const delta = clampGroupDelta(items, { x: 0, y: -100 }, 1200); + assert.deepEqual(delta, { x: 0, y: -60 }); + assert.equal(items[0].y + delta.y, 0); + assert.equal(items[1].y + delta.y, 140); +}); + +test("clampGroupDelta passes a large downward delta through unnarrowed", () => { + const items = [ + { x: 40, y: 60, width: 300 }, + { x: 400, y: 200, width: 300 }, + ]; + const delta = clampGroupDelta(items, { x: 0, y: 5000 }, 1200); + assert.deepEqual(delta, { x: 0, y: 5000 }); +}); + +test("clampGroupDelta is independent of the order its members arrive in", () => { + const a = { x: 0, y: 0, width: 100 }; + const b = { x: 1150, y: 0, width: 100 }; + const forward = clampGroupDelta([a, b], { x: -50, y: 0 }, 1200); + const reversed = clampGroupDelta([b, a], { x: -50, y: 0 }, 1200); + assert.deepEqual(forward, reversed); +}); + +test("clampGroupDelta keeps the leftmost member on the board when the group is too wide for it", () => { + const items = [ + { x: 0, y: 0, width: 100 }, + { x: 1150, y: 0, width: 100 }, + ]; + const delta = clampGroupDelta(items, { x: -50, y: 0 }, 1200); + assert.equal(delta.x, 0); + assert.ok(items[0].x + delta.x >= 0); +}); diff --git a/pinboard/0.1.0/test/layout.test.mjs b/pinboard/0.1.0/test/layout.test.mjs new file mode 100644 index 00000000..cf0cbd6b --- /dev/null +++ b/pinboard/0.1.0/test/layout.test.mjs @@ -0,0 +1,83 @@ +// Unit tests for the pure layouts — the browser-free half of the code, kept +// apart in `src/layout.js` so both are testable under `node --test` with no +// browser or DOM shim. + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { flowPositions, stackPositions } from "../src/layout.js"; + +test("cards that fit the board sit on one row", () => { + const pos = flowPositions(["a", "b"], { cardWidth: 320, gap: 24, boardWidth: 1200 }); + assert.equal(pos.get("a").y, pos.get("b").y); +}); + +test("wraps to a second row once the ids exceed the board width", () => { + const ids = ["a", "b", "c", "d", "e"]; + const pos = flowPositions(ids, { cardWidth: 320, gap: 24, boardWidth: 700 }); + const rows = new Set([...pos.values()].map((p) => p.y)); + assert.ok(rows.size > 1, `expected more than one row, got ${rows.size}`); +}); + +test("every card gets a distinct position", () => { + const ids = ["a", "b", "c", "d", "e", "f"]; + const pos = flowPositions(ids, { cardWidth: 320, gap: 24, boardWidth: 700 }); + const distinct = new Set([...pos.values()].map((p) => `${p.x},${p.y}`)); + assert.equal(distinct.size, ids.length); +}); + +test("the same input yields the same output twice", () => { + const ids = ["a", "b", "c"]; + const first = [...flowPositions(ids).entries()]; + const second = [...flowPositions(ids).entries()]; + assert.deepEqual(first, second); +}); + +test("a single card lands at the origin with default options", () => { + const pos = flowPositions(["a"]); + assert.deepEqual(pos.get("a"), { x: 0, y: 0 }); +}); + +test("stackPositions: every card shares one x", () => { + const heights = new Map([["a", 100], ["b", 200], ["c", 50]]); + const pos = stackPositions(["a", "b", "c"], { heights, x: 40 }); + assert.equal(pos.get("a").x, 40); + assert.equal(pos.get("b").x, 40); + assert.equal(pos.get("c").x, 40); +}); + +test("stackPositions: y increases strictly in the given order", () => { + const heights = new Map([["a", 100], ["b", 200], ["c", 50]]); + const pos = stackPositions(["a", "b", "c"], { heights }); + assert.ok(pos.get("a").y < pos.get("b").y); + assert.ok(pos.get("b").y < pos.get("c").y); +}); + +test("stackPositions: y is the previous card's y plus its height plus the gap", () => { + const heights = new Map([["a", 100], ["b", 200]]); + const pos = stackPositions(["a", "b"], { heights, gap: 24 }); + assert.equal(pos.get("a").y, 0); + assert.equal(pos.get("b").y, 100 + 24); +}); + +test("stackPositions: an id missing from heights falls back to the default spacing", () => { + const pos = stackPositions(["a", "b"], { heights: new Map(), gap: 24 }); + // Default cardHeight is 220 — the fallback used when heights carries + // nothing for an id, never the same y as its neighbour. + assert.equal(pos.get("b").y, 220 + 24); +}); + +test("stackPositions: startY offsets every card", () => { + const heights = new Map([["a", 100]]); + const withoutOffset = stackPositions(["a", "b"], { heights, gap: 24 }); + const withOffset = stackPositions(["a", "b"], { heights, gap: 24, startY: 500 }); + assert.equal(withOffset.get("a").y, withoutOffset.get("a").y + 500); + assert.equal(withOffset.get("b").y, withoutOffset.get("b").y + 500); +}); + +test("stackPositions: the same input yields the same output twice", () => { + const heights = new Map([["a", 100], ["b", 200]]); + const first = [...stackPositions(["a", "b"], { heights }).entries()]; + const second = [...stackPositions(["a", "b"], { heights }).entries()]; + assert.deepEqual(first, second); +}); diff --git a/pinboard/0.1.0/test/select.test.mjs b/pinboard/0.1.0/test/select.test.mjs new file mode 100644 index 00000000..ba5dc722 --- /dev/null +++ b/pinboard/0.1.0/test/select.test.mjs @@ -0,0 +1,40 @@ +// Unit tests for the marquee's pure geometry — testable under `node --test` +// with no browser, DOM shim, or pointer events, the same split +// `test/drag.test.mjs` makes for the drag arithmetic. + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { marqueeRect, rectsOverlap } from "../src/select.js"; + +test("marqueeRect normalizes a backwards drag", () => { + const forward = marqueeRect({ x: 10, y: 10 }, { x: 60, y: 40 }); + const backward = marqueeRect({ x: 60, y: 40 }, { x: 10, y: 10 }); + assert.deepEqual(forward, { x: 10, y: 10, width: 50, height: 30 }); + assert.deepEqual(backward, forward); +}); + +test("rectsOverlap is true for a partial overlap", () => { + const a = { x: 0, y: 0, width: 50, height: 50 }; + const b = { x: 25, y: 25, width: 50, height: 50 }; + assert.equal(rectsOverlap(a, b), true); +}); + +test("rectsOverlap is false for two disjoint rectangles", () => { + const a = { x: 0, y: 0, width: 10, height: 10 }; + const b = { x: 100, y: 100, width: 10, height: 10 }; + assert.equal(rectsOverlap(a, b), false); +}); + +test("rectsOverlap is false for rectangles that only touch edges", () => { + const a = { x: 0, y: 0, width: 10, height: 10 }; + const b = { x: 10, y: 0, width: 10, height: 10 }; + assert.equal(rectsOverlap(a, b), false); +}); + +test("rectsOverlap is true for full containment either way round", () => { + const outer = { x: 0, y: 0, width: 100, height: 100 }; + const inner = { x: 25, y: 25, width: 10, height: 10 }; + assert.equal(rectsOverlap(outer, inner), true); + assert.equal(rectsOverlap(inner, outer), true); +}); diff --git a/pinboard/0.1.0/test/store.test.mjs b/pinboard/0.1.0/test/store.test.mjs new file mode 100644 index 00000000..d14ba987 --- /dev/null +++ b/pinboard/0.1.0/test/store.test.mjs @@ -0,0 +1,69 @@ +// Unit tests for the persistence store. `localStorage` does not exist under +// node, so each test stubs a minimal one on `globalThis` — a `Map` behind +// `getItem`/`setItem` for the happy paths, and a stub whose methods throw +// for the access-failure path `src/store.js` must survive. + +import { strict as assert } from "node:assert"; +import { test, beforeEach } from "node:test"; + +import { loadBoard, saveCard, storageKey } from "../src/store.js"; + +function useMapStorage() { + const map = new Map(); + globalThis.localStorage = { + getItem(key) { + return map.has(key) ? map.get(key) : null; + }, + setItem(key, value) { + map.set(key, String(value)); + }, + }; +} + +function useThrowingStorage() { + globalThis.localStorage = { + getItem() { + throw new Error("storage blocked"); + }, + setItem() { + throw new Error("storage blocked"); + }, + }; +} + +beforeEach(() => { + useMapStorage(); +}); + +test("storageKey namespaces the board id", () => { + assert.equal(storageKey("notes"), "rookery-pinboard:notes"); +}); + +test("saveCard then loadBoard round-trips the entry", () => { + saveCard("notes", "idea-a", { x: 10, y: 20, collapsed: false }); + assert.deepEqual(loadBoard("notes"), { "idea-a": { x: 10, y: 20, collapsed: false } }); +}); + +test("saving one idea id leaves another id's entry untouched", () => { + saveCard("notes", "idea-a", { x: 10, y: 20, collapsed: false }); + saveCard("notes", "idea-b", { x: 5, y: 6, collapsed: true }); + assert.deepEqual(loadBoard("notes"), { + "idea-a": { x: 10, y: 20, collapsed: false }, + "idea-b": { x: 5, y: 6, collapsed: true }, + }); +}); + +test("loadBoard on unparseable JSON returns an empty object rather than throwing", () => { + globalThis.localStorage.setItem(storageKey("notes"), "{{{"); + assert.deepEqual(loadBoard("notes"), {}); +}); + +test("loadBoard drops an entry whose x/y are not finite numbers", () => { + globalThis.localStorage.setItem(storageKey("notes"), '{"a":{"x":"nope","y":3}}'); + assert.deepEqual(loadBoard("notes"), {}); +}); + +test("loadBoard returns an empty object when storage throws on access", () => { + useThrowingStorage(); + assert.deepEqual(loadBoard("notes"), {}); +}); diff --git a/pinboard/0.1.0/typst.toml b/pinboard/0.1.0/typst.toml new file mode 100644 index 00000000..060838d3 --- /dev/null +++ b/pinboard/0.1.0/typst.toml @@ -0,0 +1,41 @@ +[package] +name = "pinboard" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "A board of draggable cards for arranging @rookery/core notes by hand" +repository = "https://github.com/freecomputinglab/rookery" + +[tool.rheo] +min_version = "0.6.2" + +[tool.rheo.html] +js_scripts = "dist/lib.js" +css_stylesheet = "src/pinboard.css" +# DECLARES THAT THIS BUNDLE CAN REBUILD ITS OWN STATE ON DEMAND: `pinboard.js` +# pushes `init` onto `window.__rheoRehydrate`, so rheo's dev server calls it +# instead of reloading when it morphs a content edit into the live DOM. +# `init` re-runs `layOutBoard` for every `[data-pinboard]` board it finds, +# which restores each card's position and collapsed state from +# `src/store.js` — there is nothing else to restore, a board's arrangement +# lives in `localStorage`, not in memory — and re-wires the drag, collapse +# and marquee-selection listeners through a fresh `AbortController` per +# board, so a morph's surviving nodes end up with one live set of listeners +# rather than two. Renders the script tag with `data-rheo-rehydrate`, which +# is what admits the page to the morph path at all — the survey runs over +# EVERY `script[src]` on the page, and one undeclared script disqualifies +# the lot. Leaving this unset is not a defect; it is only the safe reading, +# where a morph falls back to a full reload rather than leave a board +# half-wired. +js_rehydrate = true + +[tool.rheo.source.html] +js_scripts = ["src/store.js", "src/layout.js", "src/select.js", "src/drag.js", "src/collapse.js", "src/pinboard.js"] +js_module = true +# Same declaration as `[tool.rheo.html]` above, needed a second time because +# rheo merges source over release rather than falling one back to the other — +# a git-ref build reading only this table would otherwise take the +# full-reload path a bundled release build does not. +js_rehydrate = true diff --git a/pinboard/0.1.0/vite.config.js b/pinboard/0.1.0/vite.config.js new file mode 100644 index 00000000..d5152af3 --- /dev/null +++ b/pinboard/0.1.0/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { + entry: "src/pinboard.js", + formats: ["iife"], + name: "RookeryPinboard", + fileName: () => "lib.js", + }, + outDir: "dist", + }, +}); diff --git a/search/0.1.0/.gitignore b/search/0.1.0/.gitignore new file mode 100644 index 00000000..282c6489 --- /dev/null +++ b/search/0.1.0/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +.direnv/ +build +*.pdf diff --git a/search/0.1.0/.marrow.typ b/search/0.1.0/.marrow.typ new file mode 100644 index 00000000..f147335f --- /dev/null +++ b/search/0.1.0/.marrow.typ @@ -0,0 +1,120 @@ +// Builds the rookery's search index ONCE per build, at the bundle root, and +// emits it as a single fetched asset. +// +// rheo inlines this file verbatim at the bundle root when a project imports +// `@rookery/search`, so it runs once for the whole build with the finished +// note registry in scope — the same place `@rookery/core`'s own `.marrow.typ` +// mints note pages from. Two things come out of that one pass: +// +// - `rookery/search/index.json`, the whole index as a bundle asset, which +// `#search-index(mode: "asset")` points every page at instead of carrying +// a copy of it. This is what keeps a large rookery's build from scaling +// the index by the page count: MEASURED on a 320-note, 360-page site, +// 29.5s and 43MB of output against 2.3s and 4.9MB. +// - `_rows-cache` and `_corpus-cache`, for `#search-index(mode: "inline")` — +// the `file://` mode, which cannot fetch and so must still carry the island +// in every page. The first holds the finished rows so no page derives them +// again (26.0s to 17.7s on that same site); the second holds the compressed +// body terms alone, and is what a `tags:`-filtered or non-default-knobbed +// index still falls back to. +// +// WITHOUT RHEO there is no bundle root, this file never runs, no asset is +// emitted, `_corpus-cache` keeps its `(:)` default and `#search-index` +// compresses inline as it always did. A miss there is only slower, never +// different; a missing asset is why `mode: "asset"` needs rheo at all. +#import "@rookery/search:0.1.0": ( + _compress-corpus, _corpus-cache, _corpus-key, _date-stamp, _index-asset-path, + _rows-cache, +) +#import "@rookery/core:0.1.0": ideas + +#context { + // `page`, not `href`: `#note-path` is the site-root-relative output path and + // is defined exactly when a note has a minted page, which is the same + // condition `#search-index`'s own `href != none` filter tests from a + // vertebra. `href` is depth-relative and has no meaning at the bundle root, + // where there is no current page to measure from. + let rows = ideas().filter(e => e.page != none) + if rows.len() > 0 { + // The DEFAULTS only. A project calling `#search-index(body-terms: 64)` gets + // a key miss and compresses inline — correct, just not cached. Publishing + // every combination a project might ask for would mean reading the call + // sites' arguments back out of a state the marrow itself feeds, and a state + // that depends on a state fed from the pages it feeds is the one shape + // Typst's convergence cannot be trusted to settle. + let body-terms = 48 + let df-ceiling = 40 + let terms = _compress-corpus( + rows.map(e => e.body), + body-terms: body-terms, + df-ceiling: df-ceiling, + ) + let by-id = (:) + for (i, e) in rows.enumerate() { by-id.insert(e.id, terms.at(i)) } + _corpus-cache.update(c => { + let c = c + c.insert(_corpus-key(body-terms, df-ceiling), by-id) + c + }) + + // THE ASSET'S ROWS ARE `_rank`'s EMPTY-QUERY ORDER, reproduced here rather + // than borrowed: `#search-index` used to get this order by calling + // `search-ideas("")` per page, and the browser is entitled to the same + // sequence from the fetched file. `fuzzy-score` returns 0 for an empty + // query, so every note ties in the name tier and the tie breaks by date — + // dated notes newest first, undated notes last in id order (`ideas()` + // sorts by id, and each `.filter` below preserves that). See `_rank` in + // `src/rank.typ` for the same three steps on the per-page path. + let stamped = rows.enumerate().map(pair => { + let (i, e) = pair + ( + id: e.id, + name: e.name, + text: e.label, + tags: e.tags, + body: terms.at(i), + created: _date-stamp(e.at("created", default: none)), + page: e.page, + ) + }) + let dated = stamped.filter(r => r.created != none) + let undated = stamped.filter(r => r.created == none) + let ordered = () + for s in dated.map(r => r.created).dedup().sorted().rev() { + ordered += dated.filter(r => r.created == s) + } + + // `href` CARRIES THE SITE-ROOT PATH here, where the per-page island's + // carries a depth-relative one: one shared file cannot hold a path + // measured from each of 360 pages. `src/island.js` joins each row's + // `href` onto the `data-rookery-search-base` prefix the page it was + // fetched from published. The field keeps its name so nothing downstream + // of the fetch has to know which mode produced it. + // + // The remaining field rules are `#search-index`'s and are asserted by + // `test/`: `tags` omitted when the note has none, `created` omitted when + // it is undated, `body` always present (the asset is `body-search: true` + // by construction — a page wanting no body tier drops the field on read + // rather than fetching a second file for it). + let final-rows = (ordered + undated).map(r => { + let row = (id: r.id, name: r.name, text: r.text) + if r.tags.len() > 0 { row.insert("tags", r.tags) } + row.insert("body", r.body) + if r.created != none { row.insert("created", r.created) } + row.insert("href", r.page) + row + }) + asset(_index-asset-path, json.encode(final-rows, pretty: false)) + + // THE SAME ROWS, published for `mode: "inline"` to read back. Kept with + // `page` rather than the asset's finished `href` because an inline island's + // href is measured from the page it sits in, which only that page knows — + // see `_rows-cache`. `body` is always present and `body-search: false` + // drops it on read, so one row set serves both switches. + _rows-cache.update(c => { + let c = c + c.insert(_corpus-key(body-terms, df-ceiling), ordered + undated) + c + }) + } +} diff --git a/search/0.1.0/Justfile b/search/0.1.0/Justfile new file mode 100644 index 00000000..ca5528e0 --- /dev/null +++ b/search/0.1.0/Justfile @@ -0,0 +1,28 @@ +build: + pnpm install + pnpm run build + +# Pin the Typst and JavaScript copies of the ranking rule to the same numbers. +parity: + node test/parity.mjs + +# Unit tests for the browser-only half of search.js (tiering, +# highlighting, keyboard-nav selection, the fetched-note extractor). 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 — and the glob +# form also keeps it from picking up test/parity.mjs and test/internal.mjs, +# neither of which is a `*.test.mjs` file. +# +# Assumes an already-installed tree, kept fast for the inner loop — `check` +# is the recipe that guarantees the install first. +test: + node --test test/*.test.mjs + +# Guarantees `pnpm install` before verification runs, unlike `test` above, +# so this cannot fail with an opaque module-not-found error on a fresh +# checkout. Runs the unit suite and parity together, since both are +# verification rather than a build step. +check: build + node --test test/*.test.mjs + node test/parity.mjs diff --git a/search/0.1.0/demo/rheo/Justfile b/search/0.1.0/demo/rheo/Justfile new file mode 100755 index 00000000..e6c6498e --- /dev/null +++ b/search/0.1.0/demo/rheo/Justfile @@ -0,0 +1,19 @@ +# 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. That is the one way this +# fixture differs from @rookery/core's, which is buildless. +build: + rheo compile . + +watch: + rheo watch . + +# Asserts on the OUTPUT, not merely that the build succeeded — the asset +# plumbing and the JSON island only exist after a real rheo build, and neither +# the `node --test` suite nor the parity harness can see them. +check: build + ./check.sh diff --git a/search/0.1.0/demo/rheo/check.sh b/search/0.1.0/demo/rheo/check.sh new file mode 100755 index 00000000..2341766d --- /dev/null +++ b/search/0.1.0/demo/rheo/check.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +# Asserts on this fixture's OUTPUT, not merely that the build succeeded. +# +# Greps rather than a test framework, deliberately, matching @rookery/core's own +# demo check: the package already has a `node --test` suite for its browser half +# and a parity harness for its ranking, and neither can see what rheo actually +# wrote to disk. THAT is what this file is for — the asset plumbing and the +# island, which only exist after a real rheo build. +# +# Run through `just check`, which builds first. +set -euo pipefail +cd "$(dirname "$0")" +H=build/html +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +# 1. The build produced the pages the spine declares, at both depths. The +# nested vertebra is not decoration: every depth assertion below needs a page +# that is not at the root. +for f in index.html sub/page.html; do + [ -f "$H/$f" ] || note "no page at $f" +done + +# 2. The package's assets were copied in by rheo's own package-asset detection, +# which scans a project's `.typ` files for `@rheo/` imports. `dist/` is +# gitignored, so a missing file here usually means `just build` was skipped. +# +# NO JavaScript FILENAME IS NAMED, here or in 3. This package declares its +# scripts twice in `typst.toml` — `[tool.rheo.html]`'s single vite bundle +# (`dist/lib.js`) and `[tool.rheo.source.html]`'s fourteen unbundled modules +# — and which of the two rheo injects depends on how the package was +# RESOLVED: from a directory on disk (what a `path` override is) it serves +# the source list, as a built package it serves the bundle. Both are wanted +# shipping shapes, so a check that greps for `lib.js` is a check the fixture +# can only ever be run one of those two ways. The contract this asserts +# instead is the shape-independent one: SOME search JavaScript was copied +# under `rookery/search/`, and every page links it at that page's own +# prefix. `search.css` is the same file either way and is still named. +js=$(find "$H/rookery/search" -maxdepth 1 -name '*.js' -printf '%f\n' 2>/dev/null | sort) +[ -n "$js" ] || note "no .js copied under rookery/search/ (did you run 'just build'?)" +[ -f "$H/rookery/search/search.css" ] || + note "asset not copied to rookery/search/search.css (did you run 'just build'?)" + +# 3. Both assets are LINKED from every page, at the right depth-relative +# prefix — `rheo/...` at the root, `../rheo/...` one level down. This is the +# assertion a root-only fixture cannot make, and getting it wrong ships a +# site whose search silently never loads on its inner pages. +links_js() { # page prefix — is one of the copied .js files linked at this prefix? + local page=$1 prefix=$2 f + for f in $js; do + if grep -q "\"$prefix$f\"" "$page"; then return 0; fi + done + return 1 +} +links_js "$H/index.html" "rookery/search/" || + note "index.html does not link the search JS at the root-relative prefix" +grep -q '"rookery/search/search.css"' "$H/index.html" || + note "index.html does not link search.css at the root-relative prefix" +links_js "$H/sub/page.html" "../rookery/search/" || + note "sub/page.html does not link the search JS at the depth-relative prefix" +grep -q '"../rookery/search/search.css"' "$H/sub/page.html" || + note "sub/page.html does not link search.css at the depth-relative prefix" + +# 4. The index is present and parses, with one row per registered note. +# `#search-index` filters to notes that have a minted page (`href != none`), +# so this also proves the two packages agree about the registry. +# +# THE FIXTURE BUILDS IN THE DEFAULT `mode: "asset"`, so the rows live in ONE +# `rookery/search/index.json` and every page carries an EMPTY `', h, re.S) + if body and body.group(1).strip(): + print(f"FAIL: {page} still carries inline index JSON ({len(body.group(1))} bytes)") + ok = False + # The src is a path from THIS page, so resolve it that way. + resolved = os.path.normpath(os.path.join(H, os.path.dirname(page), src)) + if not os.path.isfile(resolved): + print(f"FAIL: {page}'s pointer src resolves to no file: {resolved}"); ok = False +if ok: + print(" pointer: src and base correct at both depths, no inline JSON") +sys.exit(0 if ok else 1) +PY + +# 5. Every href in the index resolves to a file rheo actually wrote, FROM EVERY +# PAGE THAT READS IT. A row pointing at nothing is a search result that 404s +# on click, and under the asset mode that depends on the page's `base` as much +# as on the row — so the join is done here exactly as `island.js` does it. +python3 - "$H" <<'PY' || fail=1 +import json, os, sys +H = sys.argv[1] +rows = json.load(open(os.path.join(H, "rookery/search/index.json"))) +ok = True +for page, base in (("index.html", ""), ("sub/page.html", "../")): + here = os.path.join(H, os.path.dirname(page)) + missing = [r["href"] for r in rows + if not os.path.isfile(os.path.normpath(os.path.join(here, base + r["href"])))] + if missing: + print(f"FAIL: from {page}, {len(missing)} href(s) resolve to no file: {missing[:3]}") + ok = False +if ok: + print(f" hrefs: all {len(rows)} resolve from both depths") +sys.exit(0 if ok else 1) +PY + +# 6. Both UI surfaces rendered. They are separate entry points and a project +# may use either, so neither one standing in for the other is enough. +# `class="rookery-search"` is `#search-bar`'s own wrapper; the modal wears +# `rookery-search-modal`. Matched on the exact attribute so the bar's assertion +# cannot be satisfied by the modal's longer prefix. +grep -q 'class="rookery-search"' "$H/index.html" || + note "index.html does not carry the #search-bar element" +grep -q 'class="rookery-search-modal"' "$H/index.html" || + note "index.html does not carry the #search-modal element" + +# 7. #panel — the projection-driven filter. A DIFFERENT surface again, and the +# assertions below are the ones the widget's own design rules turn on rather +# than "it rendered". +python3 - "$H" <<'PANEL' || fail=1 +import os, re, sys +H = sys.argv[1] +h = open(os.path.join(H, "index.html")).read() + +# FOUR PANELS: two `#panel`s over the projection, and two `#filter-panel`s over tags — +# one with authored pills, one with `pills: auto`. The facet assertions below are about +# the first two, so they are separated by MODE rather than by position — +# `data-panel-mode="tags"` is the attribute one script uses to tell the two kinds apart, +# and it is the honest discriminator here too. +panels = re.findall(r'
        ]*>', h) +if len(panels) != 4: + print(f"FAIL: expected 4 panels on index.html, found {len(panels)}"); sys.exit(1) +faceted = [p for p in panels if 'data-panel-mode="tags"' not in p] +tagged = [p for p in panels if 'data-panel-mode="tags"' in p] +if len(faceted) != 2 or len(tagged) != 2: + print(f"FAIL: expected 2 faceted panels and 2 tag panels, got {len(faceted)}/{len(tagged)}") + sys.exit(1) + +# NO JSON ISLAND OF ITS OWN. A panel's facts ride as `data-` attributes on the +# rows, so the markup IS the payload and the two cannot disagree. Asserted as +# "every island on the page is the SEARCH index" rather than as a count: the bar +# and the modal each emit one by default, so this page legitimately carries three, +# all with the same id (MEASURED). A count would only have recorded that number. +# Still the right assertion under `mode: "asset"`, where those three elements are +# empty pointers rather than payloads: what it pins is that no OTHER id appears. +ids = set(re.findall(r' +``` + +Under `mode: "inline"` the same rows go into the page itself, which is what +this package did for every page before the asset existed: + +```html + +``` + +**Prefer the asset. `"inline"` is for `file://` and nothing else.** A `file://` +page cannot fetch, so it must carry its own copy; over `http(s)://` the asset +is better, and the gap widens with the site, because an inline island is +duplicated once per emitted page. MEASURED on a 320-note rookery emitting 360 +pages, where the island had grown to 112 KB: + +| | build | peak RSS | output | +| --- | --- | --- | --- | +| `mode: "inline"` | 29.4s | 5.5 GB | 45 MB | +| `mode: "asset"` | **2.2s** | **0.75 GB** | **5.0 MB** | + +Nothing that makes the per-page work cheaper closes that gap, and it is worth +knowing which dead ends were measured on the same site before reaching for one: +`body-search: false` still costs 20.5s, `body-terms: 8` (a 4 KB island) costs +28.6s, and hoisting the whole row set to the bundle root costs 19.6s. The cost +is the duplication. + +**Predicting the inline cost.** An island is roughly `notes x bytes-per-row`, +and a row is dominated by its `body` — 48 terms averaging about 9 characters, +so ~450 B, plus ~80 B for the id, name, title, tags and href. Call it 500 B a +note, and the inline total is that times the number of pages carrying a bar +(every vertebra, plus every minted note page): + +| notes | island | x 100 pages | x 400 pages | +| ----- | ------ | ----------- | ----------- | +| 40 | 20 kB | 2.0 MB | 8.0 MB | +| 100 | 50 kB | 5.0 MB | 20 MB | +| 320 | 112 kB | 11 MB | 45 MB | + +The bottom-right cell is the site that prompted the asset mode. Past 8 MB the +package says so itself: `#search-index` emits a hidden +`.rookery-search-budget-report` div naming the three numbers and the switch — +a report, never a panic, because inline is a legitimate choice and a `file://` +project has no other. `rheo compile`'s own one-line build summary carries the +page count and total output bytes for every project regardless. + +**A row's `href` is page-relative in both modes.** The shared file carries +site-root paths, because it cannot hold a path measured from each of 360 pages; +each page publishes its own depth prefix as `data-rookery-search-base`, and +`src/island.js` joins the two on read. Everything downstream of that read — +the bar, the modal, the preview pane — sees the same `row.href` either way. + +**A tag-filtered index is always inline.** Note count and document frequency +are properties of the corpus, so a `tags:`-scoped index's terms are genuinely +different terms; one shared file cannot serve them. `#search-index(tags: "phd")` +falls through to the inline path whatever `mode:` says. + +One row per note: `id`, `name`, `text` (the plain-text title, `""` when there +is none), `tags` (the note's own tag array — **the key is absent** when it has +none, rather than written as `[]` per row), `body` (the plain-text body, `""` +when there is none) and `href`. +The field is `text` and not `title` deliberately — it is the same name, +meaning and type as `search-ideas` returns, and a name that meant content in +Typst and a string in JSON is how a consumer gets it wrong. + +**`tags:`/`match:` decide which notes reach the island**, and the `tags` field is +what a READER's own `tags:` expression is evaluated against, per row, once they +are there — the two axes again, and see "Filtering by tag" above. The author's +selection is settled in Typst; the field is the reader's to filter with. + +**`body` is a term budget, not the whole note.** `search-index`'s `body-terms` +parameter (48 by default) keeps each row's most distinctive terms and drops the +rest, and `df-ceiling` (40 by default) drops terms shared across more than that +percentage of the corpus first. MEASURED for rookery.ohrg.org: its +`content/*.typ` sources total ~31 KB across roughly 40 notes, so an uncapped +index would cost on the order of 20-25 KB of JSON per row-set (it compresses +well, being prose). A term the budget cuts stays findable through the +Typst-side `#search-ideas`, which never truncates. The budget matters most +under `mode: "inline"`, where the row-set is duplicated per page; under +`mode: "asset"` it is paid once. + +### The corpus is compressed once per build, not once per page + +Under `mode: "inline"`, `#search-index` runs on every page that carries the +island, and the corpus pass behind `body-search` costs far more than the +island's own JSON. Under rheo the whole compression is hoisted into this +package's `.marrow.typ`, which runs ONCE at the bundle root, and every page +reads the finished terms back out of a state keyed by note id. Under +`mode: "asset"` the same marrow pass writes the finished index straight to +`rookery/search/index.json` and no page runs any of this. + +MEASURED on a synthetic rookery — 200 notes of 1500 words, 40 vertebrae, one +`#search-modal` each: + +| | build | +| --- | --- | +| before, compressed per page | 10.9s | +| after, compressed once | 6.3s | +| `body-search: false` (no corpus pass at all) | 1.0s | + +The island's bytes are identical either way — this is a timing change and +nothing else. What is left is the one corpus pass, which is the irreducible +part. + +Two cases fall back to compressing inline, and both are correct rather than +merely tolerated: + +- **Without rheo.** There is no bundle root, the marrow never runs, and the + state keeps its empty default. Plain `typst compile` behaves exactly as it + did. +- **A tag-filtered index**, `#search-index(tags: "post")`. Note count and + document frequency are properties of the CORPUS, so a filtered index's terms + are genuinely different terms and have to be computed over the notes it + selected. Same for a non-default `body-terms`/`df-ceiling`, which the marrow + does not know to precompute. + +### Ids and titles only: `body-search: false` + +`body-search: false` leaves the `body` field OUT of every row, so the island +carries `id`, `name`, `text`, `href` and a tagged note's `tags` and nothing +else — a reader's `tags:` filter keeps working with body text gone, having never +read that field. It is the one switch +for "search this rookery by name, not full text", and it is accepted by +`#search-ideas`, `#search-index`, `#search-bar` and `#search-modal` alike — +configure it where you invoke the package in your own files: + +```typst +#import "@rookery/search:0.1.0": search-modal +#search-modal(placeholder: "Search weeknotes", body-search: false) +``` + +MEASURED on weeknotes.ohrg.org (56 indexed notes, 69 output pages): the island +goes from **54,610 bytes to 5,456**, a tenth of the size, and the whole build +from 17 MB to 14 MB — the island ships inline on every page, so its bytes are +multiplied by the page count. The `body-chars` cap bounds that cost; this +removes it. + +No JavaScript counterpart is needed, and that is by construction rather than +luck: the browser reads a missing `body` as `""`, and the body matcher returns +no score for an empty haystack, so no row can reach the body tier. + +Two consequences, both intended. A note findable only by a word in its body +becomes unfindable — that is the point. And the modal's keyword-row fallback is +built from this same field, so with it gone the pane shows "No preview" wherever +it cannot fetch the note's own page: `file://`. Over http the fetched preview is +unaffected, so a served site loses nothing but the bytes. + +The hrefs are **relative to the page the call sits on**, so an index emitted +from a site's shared template comes out right on a nested page too — `../ideas/…` +there, `ideas/…` at the root. The rows are id-ordered, so the island is +byte-stable between builds and a diff of the output means something. + +`#search-bar()` emits this for you; call it directly only when you are building +your own UI, or when several bars share one index. Reading it is one line: + +```js +const rows = JSON.parse( + document.getElementById("rookery-search-index").textContent, +); +``` + +Rank those rows with `RookerySearch.score(hay, query)` — the same rule +`#fuzzy-score` applies at compile time, ported. Use it rather than writing a +second one, so a custom UI and the built-in bar agree about what "best match" +means. + +**HTML under rheo, and nothing else.** Every row needs an `href` and only rheo +mints the pages those point at, so under plain `typst compile` the rows filter +to nothing and no island is emitted at all — rather than shipping a browser a +list of `null`s. Under a paged or EPUB target nothing is emitted either: a +``, +// 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 00000000..ea7b89fb --- /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 00000000..24d6c1aa --- /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 00000000..8c3ee7ea --- /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 00000000..dcbd7757 --- /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 00000000..be798c8e --- /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 00000000..b3f7f016 --- /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 00000000..3e7f97ab --- /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 00000000..a0269243 --- /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 00000000..7bc03824 --- /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 00000000..6a1e34c7 --- /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 00000000..5ea8db06 --- /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 00000000..d75a21df --- /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 00000000..2871205c --- /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 00000000..6fc2450c --- /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 00000000..4347dafa --- /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 00000000..e0cb298c --- /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 00000000..6e375c0a --- /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 00000000..8d9a51ec --- /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 00000000..6072191e --- /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 00000000..eca38375 --- /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 00000000..282c6489 --- /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 00000000..41b3a8aa --- /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 00000000..974fc851 --- /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 00000000..a4fd28f0 --- /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 00000000..0a26a5b4 --- /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 00000000..dea166ea --- /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 00000000..9b0254f6 --- /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 00000000..e57de9aa --- /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 00000000..28481629 --- /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 00000000..a5717620 --- /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 00000000..359d6c54 --- /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 00000000..5813155e --- /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 '
        ' + 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 00000000..eaf96b89 --- /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 00000000..b9f7541f --- /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 00000000..0ec1fb7f --- /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 00000000..838ec876 --- /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 00000000..f703e800 --- /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 00000000..1bd2b05c --- /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 00000000..359d6c54 --- /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 00000000..259f746b --- /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 00000000..661e1761 --- /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 00000000..d0e312c0 --- /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 00000000..22dc3c7f --- /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 00000000..d4ef6d22 --- /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 00000000..1c43076b --- /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 00000000..f30a6e9f --- /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 00000000..4ffa15c8 --- /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 00000000..c4c05058 --- /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 00000000..dfe4886c --- /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 00000000..598e2ac3 --- /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 00000000..65f4c0e5 --- /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 00000000..f3a4863c --- /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 00000000..23fd837a --- /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 `idea-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 00000000..5f77e601 --- /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 00000000..661e1761 --- /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 00000000..81fc9f86 --- /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 00000000..bf1c05de --- /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 00000000..4b4bee9f --- /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 00000000..8c4d911e --- /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 00000000..d561dd47 --- /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 00000000..00133a8d --- /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 00000000..e7f8b4c4 --- /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 00000000..fefd17af --- /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 00000000..87c4b529 --- /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 00000000..2aff8808 --- /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 00000000..b726ee33 --- /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 00000000..96a5c79d --- /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 00000000..d73225b1 --- /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 00000000..813fa719 --- /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 00000000..e7995fe8 --- /dev/null +++ b/slipshow/0.1.0/readme.md @@ -0,0 +1,872 @@ +# @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-name: 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-name` | `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-name` 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-name: 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-name: 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-name:`, `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-name` | `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-name: 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 `