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