Everything @rookery/core exports, one idea apiece. #idea hatches an idea by hand, #ideate infers them from prose already written, and #window shows one idea inside another page: those three are what a rookery is written with, and they come first. Then what it is read with — a link, a footnote, an outline. Last is #ideas, which stops rendering a rookery and hands its whole contents back to Typst as data, and which everything you might build on top of this package begins from.
A name nests under the export it is reached through, so finding a function below means finding what you would have been holding when you needed it. Mostly the prefix says where: every #idea-* accessor sits inside #idea, and both #ideate-* beacons inside #ideate. Where it does not, the call site does — #slug under #ideate, whose name: it is written for, and #tag-index with #tag-data under #ideas.
Two kinds of name are left off the list. #rookery is the show rule, documented above as site-wide configuration; the IK, WK and FNK marker constants are element kinds a downstream package queries for, not functions anybody writes.
#ideaFor an intuition on how to think about ideas, see An idea. The #idea function takes the following arguments, which are all optional:
| Argument | Type | Description |
|---|---|---|
name | label | str | A unique name for the idea, allowing it to be referenced as @idea:<name> across the rookery. Positional, and written either as a label or as a string—#idea(<etal>) and #idea("etal") are the same call. In the absence of an explicit name, an idea with a title takes a slug of it; an idea with no usable title instead takes a slug of its own body plus a content digest, as described under auto-naming. |
title | content | The idea’s title, which appears as both the link text in hyperlinks to the idea and the header text in windows on it. |
body | content | The idea’s content, rendered inside its card and transcluded by any window on it. An idea with no body is a title-only stub, which is how a placeholder for an idea not yet written is filed. |
level | int | The heading depth of the idea’s title, so a nested idea can sit under the one containing it. Defaults to 1. |
tags | str | array | dictionary | The idea’s tags, as one name, a list of names, or a dictionary whose values carry arbitrary metadata for the tag. Set at the call site, replacing whatever a constructor bound. |
tag | str | A single tag that is merged into tags rather than replacing it, so a constructor such as idea.with(tag: "note") keeps its tag when the call site names tags of its own. |
base-tags | str | array | dictionary | As tag, but for several tags at once — for a family of ideas that is a narrowing of a broader one, so that every idea the constructor mints is still reachable under the wider tag. |
exclude-tags | str | array | Tags that drop the idea from this build entirely. An excluded idea is absent rather than hidden: nothing renders where it was written, no page is minted for it, and it appears in no outline, search index or backlink list. |
created | datetime | The date the idea was written. In its absence the containing document’s own #set document(date: ..) is used; a date is otherwise never invented. |
display | dictionary | What the idea shows of itself, as a dictionary of nine flags — see below. |
Every display key takes a boolean, and defaults to auto: the rookery-wide setting given to #rookery(display: ..). An idea states an opinion only where it differs from the rest of the rookery.
#idea(
display: (
// the idea's own minted page carries its title as an `<h1>`
title: true,
// the `created` date sits at the right-hand end of the hat
date: true,
// the tags are worn in the hat as pills, metadata tags excepted
tags: true,
// the idea is drawn as a card, with its left rule and indent
frame: false,
// the name is shown in the hat as a permalink
name: true,
// the minted page footer links back to the page written on
context: true,
// the minted page footer lists everything that links here
backlinks: true,
// unused by the card itself — seeds what a later window on
// this idea falls back to when it does not override them
label: true,
background: true,
),
)[..]Each key is also an argument in its own right, with the prefix restored — display-frame: false — and an argument on the same call wins over the dictionary’s value for that key.
Everything else spelled idea- reads one back out, and is nested here: its tags, where its page sits, its body, and the row that stands for it in a list.
#idea-tag-namesOne idea’s tag names, as a flat array.
#import "@rookery/core:0.1.0": idea, idea-tag-names
#idea("etal", title: [Et al.], tags: (note: none, draft: none, priority: 1))[
Attribution flattens a crowd into one name.
]
#context idea-tag-names("etal") // -> ("note", "draft", "priority")Every key, valued tags included — priority comes back alongside the two plain tags, because a tag carrying metadata is still a tag. This is the question of what an idea is tagged, not of what those tags hold. Key order is unspecified.
() comes back both for an untagged idea and for a name that does not exist. A missing idea is not an error here: a caller asking what something is tagged is filtering, not dereferencing. Must be called inside #context.
Where you are walking the whole rookery, read the tags field off #ideas instead: this resolves the registry once per idea, where #ideas resolves it once for the pass.
#idea-tag-valueOne tag’s value on one idea.
#import "@rookery/core:0.1.0": idea, idea-tag-value
#idea("etal", title: [Et al.], tags: (note: none, priority: 1))[
Attribution flattens a crowd into one name.
]
#context {
idea-tag-value("etal", "priority") // -> 1
idea-tag-value("etal", "note") // -> none, a plain tag
idea-tag-value("etal", "nope", default: 4) // -> 4
}Takes the same name forms as #idea-tag-names, and returns default where the idea does not exist or carries no such key.
A plain tag’s value is none, which is indistinguishable from a default of none on a key that is absent. This function answers what a tag is set to, and a plain tag is set to nothing; ask #idea-tag-names or #tag-data when the question is presence. Must be called inside #context.
#idea-hrefWhere an idea’s minted page sits, relative to the page you are calling from. Takes a bare name, a full name or a label — the same forms #window and #hyperlink take — and must be called inside #context.
#import "@rookery/core:0.1.0": idea, idea-href
#idea("etal", title: [Et al.])[
Attribution flattens a crowd into one name.
]
#context idea-href("etal")
// -> "ideas/etal.html" called from a root page
// -> "../ideas/etal.html" called from a page one directory downThe depth arithmetic is measured from the calling page, so the same idea yields a different string on a nested page than on the root one. That is the point of the function, and it is why a caller must never cache the answer across pages.
none wherever no page is minted: a plain typst compile with no Rheo, and the combined PDF target.
#idea-pathThe same page as #idea-href, from the site root rather than from here.
#import "@rookery/core:0.1.0": idea, idea-path
#idea("etal", title: [Et al.])[
Attribution flattens a crowd into one name.
]
#context idea-path("etal")
// -> "ideas/etal.html", from either page aboveReach for it where the caller has no page of its own to measure depth from — a feed configuration or a sitemap invoked once from shared code rather than from a page. It is none under the same two conditions #idea-href is.
#idea-bodyOne idea’s body, rendered, with none of a window’s chrome: no summary line, no permalink, no disclosure. For a consumer that wants to show an idea rather than describe it — a preview pane, a card in a view of the caller’s own design — where #ideas’ body field only hands over plain text.
#import "@rookery/core:0.1.0": idea, idea-body
#idea("etal", title: [Et al.])[
Attribution flattens a crowd into one name.
The convention is older than the citation styles that inherited it.
]
// anywhere after it, on any page
#idea-body("etal") // both paragraphs, no title, no permalink
#idea-body("etal", limit: 1) // the first paragraph alone| Argument | Type | Description |
|---|---|---|
name | str | label | The idea to render, in the same name forms every accessor here takes. |
unfurl | int | auto | The transclusion budget, as on #window. Pinned at 1 rather than auto, so a preview’s size stays bounded however deeply the idea it shows nests. 0 asks for no unfurling and still renders the body — there is no chrome here to fall back to a link. |
limit | int | none | How many blocks of the body to show, the same unit #window’s own limit cuts by. The whole body by default. |
It supplies its own #context, unlike the data accessors on this page, so it can be called anywhere. The body comes wrapped in the same classes a window’s does, minus the box, so every rule the stylesheet already writes for prose inside a window — link colors, code, lists, footnotes — applies with nothing for a consumer to restyle.
#idea-rowOne row of a list of ideas: a date, a title, cells and badges, as an <li>. The shape @rookery/search and @rookery/timeline both draw, kept in core so that a project reaching a row through one of those packages still gets core’s CSS with it.
#import "@rookery/core:0.1.0": idea, ideas, idea-row
#idea(
"etal",
title: [Et al.],
tags: (note: none),
created: datetime(year: 2026, month: 8, day: 20),
)[Attribution flattens a crowd into one name.]
#context html.elem("ul", ideas(tagged: "note").map(e => idea-row(
when: e.created.display("[day] [month repr:short]"),
iso: e.created.display("[year]-[month]-[day]"),
title: e.label,
href: e.href,
tags: e.tags,
badges: e.tags.map(t => (text: t, tag: t)),
)).join())The row is fed from #ideas rather than from the idea directly, which is the usual shape: a view selects its ideas, decides what their dates mean, and hands the row cells that are already content.
| Argument | Type | Description |
|---|---|---|
when | content | none | The date cell, already formatted. none draws an em dash. |
iso | str | none | A machine-readable date, which wraps when in a <time> element carrying it. |
soft | bool | Whether this date answers a different question than the list asked — a booked event standing in for a deadline. Dropped silently on a row with no date, which makes no such claim. |
when-class, when-attrs | str | array, dictionary | Extra classes and attributes on the date cell, for a consumer that bands a deadline by how close it is. Core defines none of these names and styles none of them. |
title | content | The row’s title. |
href | str | none | Where the title links. none renders it as a span instead. |
badges | array | The chip strip at the end of the row. A (text: .., tag: ..) dictionary draws the ordinary chip, wearing the same .idea-tag-<tag> class a pill and an outline row do, so a themed tag colors itself; anything else is placed verbatim and the caller owns its markup. An empty strip is omitted rather than drawn empty, since a childless span still takes a grid track. |
cells | array | Extra content cells between the title and the badges — a host institution, a path — for what a reader scans down a column for rather than reads inside the title. |
tags | array | Tag names, which become .idea-tag-<tag> classes and a data-rookery-tags attribute on the <li>. |
extra, attrs | array, dictionary | Extra classes and attributes on the <li>, merged under the classes this function computes so a caller cannot drop the row’s own. This is what lets @rookery/search’s panels hang their data-panel-* attributes off a row without re-emitting its markup. |
Cells arrive formatted, and that is the whole of the design: the row asks no questions about what a date or a badge means, so one row shape serves a log-derived queue, a filter panel and a hand-built table of submissions, none of which agree about what a date is. It carries no JavaScript — interaction stays in @rookery/search.
HTML only, and deliberately: a paged target has no grid to align and no anchor to click, so every view in this family keeps its own branch that builds a plain list(..) there. Calling it on a paged target fails with a message about the mistake rather than about the element.
#idea-row-bodyThe same row, without the <li> around it. It takes every #idea-row argument except the three that describe that wrapper — tags, extra and attrs — and is for a caller that owns the list item itself.
#import "@rookery/core:0.1.0": idea, ideas, idea-row-body
#idea(
"etal",
title: [Et al.],
tags: (note: none),
created: datetime(year: 2026, month: 8, day: 20),
)[Attribution flattens a crowd into one name.]
#context html.elem("ul", ideas(tagged: "note").map(e => html.elem(
// the `<li>` is the caller's, with its own classes and attributes
"li",
attrs: (class: "my-row", data-my-kind: "note"),
idea-row-body(
when: e.created.display("[day] [month repr:short]"),
iso: e.created.display("[year]-[month]-[day]"),
title: e.label,
href: e.href,
),
)).join())The split is forced rather than tidy: @rookery/search ships two widgets that want this shape and disagree about who owns the <li>. Its filter panel builds its own list and uses #idea-row as the item; its general panel wraps whatever it is handed in an item of its own, which with #idea-row would nest one <li> inside another.
#ideateThe one place in rookery where an idea is inferred rather than written. #ideate takes a block of content and mints ideas from it, either as a plain function on one block or as a document show rule — #show: ideate at the top level hands it the rest of the document. It is opt-in either way, and on a paged target it is a passthrough: a PDF of a block of prose is that block of prose.
| Argument | Type | Description |
|---|---|---|
body | content | The content to mint ideas from. The sole positional argument, which is what lets the function double as a show rule. |
separator | function | none | What starts a new idea: par (or parbreak) for one idea per paragraph, heading.where(level: n) for one per section, or none — the default — to mint the whole body as a single idea. A heading standing alone is passed through as structure rather than wrapped as an idea of its own. |
title | content | function | none | A title given to every idea minted, or a function (content, labels) => content called on each section’s separating heading to compute its own. The function form is heading mode only, and the heading leaves the body when it is used, since the title is already rendered as the idea’s heading. |
name | function | auto | A function (content, labels) => str computing each idea’s name from its separating heading — slug is exported for exactly this. Defaults to auto, which slugs the heading the same way an unnamed #idea’s title is slugged, falling back to #idea’s own untitled-naming rule when the heading yields no usable slug. A fixed value is refused, as it would mint every idea in the body under one name. |
tags | str | array | dictionary | function | Tags put on every idea minted, in the same forms #idea accepts, or a function (content, labels) => tags computing each section’s own. An #ideate-tag(..) beacon placed in a section’s content adds to these, and wins on a conflicting key. |
display | dictionary | As on #idea, and read by every idea minted — see below. |
Two of the nine keys invert #idea’s own defaults, and are given here as they are read when nothing is said:
#show: ideate.with(
display: (
// an inferred idea is not one anybody named, and a frame around
// every paragraph is chrome nobody asked for
frame: false,
// with more force: an inferred idea's name is a sequence number,
// which tells a reader nothing
name: false,
),
)The remaining seven keys — title, date, tags, context, backlinks, label and background — carry their #idea meanings and defaults. As there, each key is also an argument in its own right, with the prefix restored, and an argument on the same call wins over the dictionary’s value for that key.
Every other #idea argument is forwarded to every idea minted. Note that those names are generated rather than authored, so an idea that has to be linkable is written by hand.
#ideate-tagA beacon that tags the idea minted around it, written inside the prose rather than at the #ideate call site. It takes tags in the same four forms #idea’s own tags argument accepts — nothing, one name, a list of names, or a dictionary carrying metadata — and adds to whatever #ideate(tags: ..) already put on every idea, winning on a key the two disagree about.
#show: ideate.with(separator: heading.where(level: 2), tags: "note")
== A section
#ideate-tag(("draft", "phd"))
Its prose, minted as an idea tagged note, draft and phd.Under separator: heading the beacon may sit anywhere in the section’s content; inside a paragraph it tags the group it is written in. It renders nothing of itself.
#ideate-nameThe same device for a name: a beacon naming the one idea minted around it, overriding whatever name would otherwise have derived.
== A section
#ideate-name("the-name-i-want")
Its prose, minted under `idea:the-name-i-want`.Unlike a name function, this works under every separator — it carries its own value rather than reading one off a heading — which makes it the way to pin the one inferred idea that has to be linkable without naming the rest.
#slugA URL-safe slug from content or a string: lowercased, every run of characters outside a–z and 0–9 collapsed to a single hyphen, with none left at either end.
#import "@rookery/core:0.1.0": ideate, slug
#show: ideate.with(
separator: heading.where(level: 2),
name: (h, labels) => "sec-" + slug(h),
)Exported for exactly that: the name function above, naming each section’s idea after its own heading, so inserting or reordering sections does not renumber every name after it.
Text that is nothing but punctuation slugs to the empty string, and the build fails rather than minting an idea under an empty name.
#windowA window shows an idea inside another page — its title, its permalink and its body, as one foldable block. It is pure presentation: a window registers nothing, mints no page and never re-registers the idea it transcludes, so one idea can be windowed anywhere and as often as it earns.
The ideas shown are named, or selected by tag, or both. Selection is rookery-wide, since the registry a window reads is the whole rookery’s — where the window sits makes no difference to what a tag pulls in.
| Argument | Type | Description |
|---|---|---|
names | str | label | array | The idea to show, or an array of them. A name is written bare ("etal") or in full ("idea:etal"), as a string or a label, so #window("etal") and #window(<etal>) are the same call. Several are passed as one array — #window(("a", "b")) — and may be omitted entirely when tags does the selecting. |
tagged | str | array | dictionary | The tags whose ideas to show, instead of naming them or alongside it. A window shows the union of the two, and an idea that is both named and tagged appears once, where it was named. |
match | str | Whether a tagged idea has to carry "any" of the tags — the default — or "all" of them. |
filter | function | none | A predicate of your own over the idea’s tag dictionary, ANDed with tagged and match rather than replacing them. It is what expresses a selection those two cannot: an exclusion, or an OR of ANDs. The same argument #ideas and #outline take. On #window, the ideas it selects also register no backlinks, for the reason given below. |
sort | str | auto | The order the ideas are shown in. auto, the default, keeps the named ideas in the order they were written and appends the tag matches by name; "date" and "lexicographic" order the whole selection instead. |
unfurl | int | auto | How far transclusion nests. 0 renders the idea as a link to its own page and transcludes nothing, 1 renders it and collapses any window written inside it to a bare permalink, and n unfurls n - 1 levels of those. Defaults to the rookery-wide #rookery(window-unfurl: ..), itself 1. Windows are all that count: an idea written inside a transcluded body is rebuilt in full whatever the budget. |
limit | int | none | How many blocks of the body to show — a block being a paragraph or a list, the unit that can be cut without leaving half a sentence. The whole body by default; in HTML and EPUB every footnote in the note is listed regardless, since the tail sits collapsed rather than dropped, and one written in the shown blocks renders twice — under a paged target, where the tail is truly dropped, only the footnotes that survive the cut are listed. |
folded | bool | Whether the window starts closed, leaving its summary alone on the page until a reader opens it. |
foldable | bool | Whether there is a disclosure at all. false renders the body with nothing to click and nothing that can hide it — for a window that is the thing being read rather than a reference to it, such as a slide, where a stray click folding it shut would be a bug. It makes folded inert. |
reserve-title | bool | Whether a titleless window keeps the blank line its summary reserves for the title it does not have. Dead space above a slide’s body; no effect on a window whose idea has a title. |
backlink | bool | Whether the window counts as a link from the page it sits on to the idea it shows. True is right for a window written into an idea’s prose; false is for a derived view — a deck, an index, a preview — which renders an idea rather than pointing at it, and should not fill that idea’s backlinks with pages nobody wrote a link on. true, the default, means every idea the window shows gains a backlink from it, named or tagged alike. |
display | dictionary | What the window shows of itself, as a dictionary of six flags — see below. |
As on #idea, every display key takes a boolean and defaults to auto, the rookery-wide setting given to #rookery(display: ..).
#window(
"etal",
display: (
// a titleless idea is named by the label derived from its first
// line; false names it only where it carries an authored title
label: true,
// the `created` date sits at the right-hand end of the summary
date: true,
// the tags are worn in the summary as pills, metadata tags excepted
tags: true,
// the window is drawn as a card, with its left rule and indent
frame: true,
// the name is shown in the summary as a permalink
name: true,
// the window takes a tint under the cursor
background: true,
),
)Each key is also an argument in its own right, with the prefix restored — display-frame: false — and an argument on the same call wins over the dictionary’s value for that key. #idea’s three remaining keys, title, context and backlinks, describe an idea’s own minted page rather than a window onto it, and a window reads none of them.
A tag selection backlinks exactly as a named one does: every idea a tagged window matches takes a backlink, both from the page the window sits on and from the idea whose prose it is written in. The one selection that registers nothing is filter—a predicate cannot ride in the metadata the backlink walk reads, and it is ANDed with tagged rather than replacing it.
#hyperlinkA plain link to an idea, and the renderer behind every @idea:etal in your prose. For how a hyperlink reads alongside a window, see hyperlinks.
#import "@rookery/core:0.1.0": hyperlink
#hyperlink("etal")[see this]
#hyperlink(<etal>, hyperlink-target-minted: false)[see this, in context]| Argument | Type | Description |
|---|---|---|
name | str | label | The idea to link to, written bare ("etal") or in full, as a string or a label. Positional, and required in a direct call. |
body | content | The link text. Positional, and required: an explicit call has no title to fall back on, where @idea:etal does. |
hyperlink-target-minted | bool | Where the link lands: true, the default, on the idea’s own minted page, false on the anchor where it was hatched. |
The same function is @idea:etal’s renderer, installed as a show ref: rule. An idea’s label lives on a hidden anchor, so without the rule a bare reference resolves to that anchor and renders as a figure number — #show: rookery installs it for you, and refs: false declines it.
#show ref: hyperlink // the minted page
#show ref: hyperlink.with(hyperlink-target-minted: false) // the anchorIn reference mode the link names its target: the idea’s title, or the name derived from its first line where it has none, or its bare name as a last resort — the same name an index row and a search hit call it by, so the three cannot disagree. @idea:etal[custom text] overrides that.
Existence is checked at the call site, so a typo fails the build rather than shipping a dangling link. An idea excluded from the build is a different matter: the body stays and only the link goes, since a hyperlink sits inside a sentence you wrote and deleting it would break the grammar around it.
#footnoteRookery’s own #footnote, which shadows Typst’s and scopes a note to the idea it is written in. Import it alongside #idea and write footnotes exactly as before — the single positional argument is the footnote’s body. That import is needed in every file that writes a footnote, since Typst’s imports are per file; an idea body that reaches Typst’s own #footnote instead of rookery’s fails the build, with an error naming the import to add.
#import "@rookery/core:0.1.0": idea, footnote
#idea("etal")[A claim#footnote[The evidence.] worth qualifying.]Inside an idea, the enclosing idea claims the note, numbers it against itself and lists it in its own Footnotes block — on every surface that idea appears on. Outside one, the rule #show: rookery installs falls back to Typst’s own footnote, so a note in ordinary page prose behaves as it always did. See footnotes for what idea-local numbering means for a page carrying several.
#outlineA contents list over the ideas in your rookery, derived from how you nest them. See outlining ideas for what it lists and what it leaves out.
Typst’s own idiom for an outline over something other than headings is a target: argument, so rookery overloads that call rather than asking you to remember a second name:
#import "@rookery/core:0.1.0": idea, outline
#outline(target: idea, scope: "page", depth: 2) // the ideas on this page
#outline() // Typst's own, over headingstarget: idea takes the arguments below; every other target proxies straight through to Typst’s #outline, unchanged. Passing tagged, match, filter or scope without target: idea is refused by name, rather than forwarded into Typst’s outline to be rejected there with a message about an argument you did not think you were writing.
| Argument | Type | Description |
|---|---|---|
title | content | auto | none | The label above the list. auto prints ‘Contents’, matching Typst’s own #outline; none omits it; any other content replaces it. Rendered as a real heading that neither self-lists in a later outline nor takes the document’s heading numbering. |
depth | int | none | How many levels of nesting to show, counted as Typst counts heading levels: a top-level idea is 1. Levels of containment, not of pages. |
scope | str | How wide to cast: "rookery", the default, lists every idea in the rookery as one tree in spine order; "page" narrows to the ideas written on this page. The whole spine compiles as one document, so the wider reading costs nothing extra. |
tagged | str | array | dictionary | none | The tags to restrict the outline to. |
match | str | Whether a listed idea has to carry "any" of those tags — the default — or "all" of them. |
filter | function | none | A predicate over the idea’s tag dictionary, ANDed with the two above. The same trio #window and #ideas take. |
Filtering happens before depth is applied, and that order is the point: depth: 1 means the top level of what you asked for, not whatever survived from the top level of everything. An idea whose parent was filtered out is promoted to its nearest surviving ancestor’s level rather than left dangling.
Rows are not grouped under per-page headings. An idea’s name is flat and travels between files precisely so a reader never has to know which file holds it; an index that led with filenames would put that back.
The same function is exported as #ideas-outline as well, taking the same six arguments with no target: to write:
#import "@rookery/core:0.1.0": ideas-outline
#ideas-outline(scope: "page", depth: 2)Prefer #outline. Reach for #ideas-outline where you would rather not shadow Typst’s own #outline at all — a project that imports this package with *, or one with show outline: rules of its own to keep clear of. Nothing is lost by the shadow itself, since every non-idea target proxies through untouched; what you are choosing is whether the name in your file is Typst’s or rookery’s.
#ideasYour rookery’s contents are always also available in Typst through the #ideas() function. This function returns all of your ideas as a data structure that you may then use to customize your rookery or power downstream applications. ideas has to be called inside #context, since it reads the rookery’s registry:
#import "@rookery/core:0.1.0": ideas
#context {
for e in ideas(tagged: "concept") [- #link(e.href, e.label)]
}| Argument | Type | Description |
|---|---|---|
tagged | str | array | dictionary | none | The tags to narrow the corpus to. The same argument #window and #outline take, with the same meanings. |
match | str | Whether a matching idea has to carry "any" of those tags — the default — or "all" of them. |
filter | function | none | A predicate over the idea’s tag dictionary, ANDed with tagged and match. Applied before the row is built, so an idea you drop never pays for its own conversions — which is the whole reason it is an argument here rather than a .filter() on the result. |
sort | str | auto | The order the rows come back in. auto and "lexicographic" both mean by name; "date" puts the newest created first and undated ideas last. |
index | dictionary | none | A #tag-index projection, whose declared fields are merged onto every row. The supported way to filter or sort on a tag value. |
values | bool | Whether each row also carries a tags-dict field holding the idea’s whole tag dictionary, values included. Off by default, and absent rather than empty when off, so a consumer cannot read (:) off a row and conclude the idea is untagged. |
Each row is a dictionary of ten fields, in every call:
| Field | Type | What it holds |
|---|---|---|
id | str | The full name, prefix included — "idea:etal". |
name | str | The same name with the prefix stripped — "etal". What every accessor on this page takes. |
title | content | none | The authored title, as content, refs and all. none where the idea has none. |
text | str | That same title as plain text, "" where there is none. A reference inside it reads as its target’s name rather than as a number. |
label | str | What to call this idea, and never empty: the title as text, else the body’s first sixty characters, else the idea’s own name. Reach for this wherever an idea is referred to rather than rendered — a row in an index, a node in a graph, a sort key — and the if r.text == "" { r.name } dance stops being yours to write. |
tags | array | The tag names, every key including the valued ones, () where there are none. A flat array of strings, so it drops into a JSON index unchanged. Tags are unordered and nothing may depend on the sequence; the values live in tags-dict and #tag-data. |
body | str | The idea’s body as plain text, "" where it is empty — matchable and excerptable, but not renderable. #idea-body is how you render one. |
href | str | none | Where the idea’s minted page sits from the page this was called on. See #idea-href. |
page | str | none | The same page from the site root. See #idea-path. |
created | datetime | none | The idea’s date. |
Three things are deliberately not here in bulk: the body as content, the backlink graph, and the tag values. The first would make every consumer a second transclusion engine; the second is the minted page’s own business; the third can be a datetime or content, which a JSON index encodes as a silent blob. This list is a contract other packages are written against, so a field is added rather than changed.
#tag-indexA declared projection of tag values onto an #ideas row. It exists because a tag’s value is arbitrary and a row’s fields must be encodable; a projection makes the value narrow and checked, which is what lets it ride a row at all.
#import "@rookery/core:0.1.0": ideas, tag-index
#let INDEX = tag-index((
cycle: (family: "cycle-"), // -> "26-27"
kind: (family: "venue-", one-of: KINDS), // -> "postdoc"
deadline: (key: "date-deadline", stamp: true), // -> "20261101"
stage: (from: stage-of), // derived
))
#context {
for e in ideas(index: INDEX) [- #e.kind, due #e.deadline]
}Each field names exactly one extractor:
| Form | What it projects |
|---|---|
key: "<tag>" | That tag’s value, or none where the idea does not carry it. |
family: "<prefix>" | The first flat tag whose key starts with the prefix, prefix stripped. one-of: restricts and orders the candidates, so an idea carrying two of a family resolves to the earliest you listed rather than to whichever key order happens to yield first. |
from: <function> | Called with the idea’s whole tag dictionary. A derived value — the current stage of a dated log, how far something got — is a computation rather than a tag value, and this is the only form that makes one filterable or sortable. |
Any of the three may carry stamp: true, which renders a datetime as a zero-padded [year][month][day] string. That is not only a display choice: a fixed-width numeric string sorts lexically in date order, so a stamped field is a free sort key.
A projected value must be a scalar — a string, an integer, a float, a boolean or none — and the build fails where one is not. A field may not take the name of a row field above, for the same reason: naming one href and silently replacing every link on the page is the failure this refusal prevents.
#tag-dataEvery registered idea’s tag dictionary, keyed by full name. The bulk accessor a package builds on when it needs tag values across the whole rookery, where #idea-tag-names and #idea-tag-value answer for one idea at a time.
#import "@rookery/core:0.1.0": ideas, tag-data
#context {
let tags = tag-data()
// -> ("idea:etal": (phd: none, priority: 1), ..)
for e in ideas() [- #e.label: #repr(tags.at(e.id))]
}Takes no arguments, and must be called inside #context. One #ideas plus one #tag-data covers the corpus, and the two join on id — which is the point, since the per-idea accessors each resolve the registry again and walking a rookery through them pays that cost once per idea.
Values are arbitrary Typst values: datetimes, arrays, content, whatever a package put there. Do not serialize this wholesale into a page.
It is not called tags(), though that is the obvious parallel with #ideas: tags is a parameter name on #idea, #ideate and most of this package’s constructors, so a bare tags() would be shadowed by that parameter inside every one of their bodies.