` 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 `
+
+