Skip to content

v0.78.0: editor tag vocabulary, enforced taxonomy, and the dev-extensibility seam

Choose a tag to compare

@glw907 glw907 released this 30 Jun 06:41

This release rolls two held minors into one publish. 0.77.0 (the developer-extensibility seam) was completed but never published, so upgrading from 0.76.0 to 0.78.0 brings both windows. Apply the Consumers must steps from both sections below.

0.78.0

The taxonomy marker now drives a concept's tags. A concept declares its tag field by marking one
top-level multiselect taxonomy: true, and the content index reads that field's validated value for
each entry's tags. The content index and the feed categories both read the marked field.
The old behavior read a field hardcoded as tags, so a concept whose tag field has another name now
needs the marker. Released as 0.78.0, the first free minor after the held 0.77.0; since 0.77.0
was never published, this publish rolls both windows for a site upgrading from 0.76.0.

A concept marks at most one top-level field, and the marker is top-level only. The field set
constructor throws at startup on a second marked field or a marker nested inside an object or
array, the mirror of the single-SEO-image rule. An unmarked multiselect named tags, freetags, or
categories is legal but draws a taxonomy.unmarked_field build advisory, since it reads as a tag
field a site forgot to mark.

This is breaking. Consumers must: mark each concept's tag field by adding taxonomy: true to its
top-level multiselect. A concept with no tag field needs no change.

createPublicRoutes resolves one entry per request path. It returns { entryLoad, entries };
entryLoad(event) returns the entry payload and throws error(404) on a miss. The pre-0.77.0
archiveLoad, tagIndexLoad, and tagLoad loaders are removed: cairn ships no public tag pages, and a
site renders an archive from site.concept(id).all() and a tag list from the tags-as-data on
ContentSummary.tags.

This is breaking. Consumers must: drop any call to the removed archiveLoad, tagIndexLoad, or
tagLoad; render those surfaces from site.concept(id).all() and ContentSummary.tags in site code.
The catch-all keeps calling entryLoad; no data.kind branching is needed.

A site can now configure an editor-owned tag vocabulary. A new vocabulary key in site.config.yaml
lists the allowed tags as { value, label } entries, validated at build through the new public
validateVocabulary, extractVocabulary, and setVocabulary functions and the VocabularyEntry type.
Once a site configures a vocabulary, the concept's taxonomy field (the multiselect it marks
taxonomy: true) becomes a closed picker on save and on edit: the editor picks from the configured
tags, and a save of a value that is neither in the vocabulary nor already on the entry is rejected. A
value already on an entry that the vocabulary does not list, an orphan, is preserved, never silently
dropped, and renders flagged "not in your tag list." ManifestEntry.tags now carries each entry's
projected tags.

This is opt-in and non-breaking. A site with no vocabulary key is unaffected: the taxonomy field
stays the open creatable multiselect it is today. The build read is unchanged, since tags-as-data is
identical with or without a vocabulary; enforcement is a save-and-edit concern only. Consumers must:
nothing.

An editor can now curate the vocabulary from the admin. A new vocabulary admin view at
/admin/vocabulary, with a saveVocabulary action, lets an editor add a tag, rename a tag's label,
delete an unused tag, and seed the list from tags already in use on posts but absent from the
vocabulary. Deleting a tag that is in use across the default branch or any open edit branch is
rejected, failing closed, so a tag stays until the posts that use it drop it. The screen commits the
curated list to the vocabulary key in site.config.yaml. The size-gated archive tag filter is a
showcase and template surface, the site's own design over ContentSummary.tags; cairn ships no public
filter component.

This is opt-in and non-breaking. The screen appears for any site, and edits it only when an editor
saves; a site that configures no vocabulary simply curates an empty list. Consumers must: nothing.

0.77.0

The developer-extensibility seam: a site can add its own admin screen as a normal SvelteKit route under
/admin/, rendered inside cairn's chrome, behind the editor login, with a data-only sidebar entry. This
entry covers Plan 1 (the capability); the boundary-enforcement work (Plan 2) lands under the same 0.77.0
and the release ships once both are in, so this version stays unpublished until then. cairn is still 0.x
and the contract may change again before a stable 1.0; this release is breaking and applies the "Consumers
must" steps below.

What changed. cairn's admin chrome moves out of the CairnAdmin view switch into a shared
/admin/+layout.svelte that renders the new exported CairnAdminShell component. The catch-all
/admin/[...path] route now renders bare inside that shell. A concrete route you add, such as
/admin/signups, wins over the catch-all and inherits the admin guard, so locals.editor and the
exported requireSession/requireOwner helpers work with no extra wiring. A new adminNav config field
on the adapter's editor group adds a sidebar entry as plain data, validated at startup against a typed
icon allowlist and the built-in routes. See Add a custom admin screen.

The enforced boundary (Plan 2). The public surface is now a versioned, enforced contract, not merely a
documented one, so an extension survives engine updates and a surface change is a deliberate, visible event.
Every export is labeled Stability tier: Extension API (hand-author against this; promised hardest) or
Stability tier: Scaffold API (generated by create-cairn-site), and a build-time gate snapshots the full
declared shape of every export and fails loud on undisclosed drift. cairn-doctor gains a best-effort,
non-blocking check that nudges when the four-file /admin mount looks incomplete. These are engine-internal
gates; the consumer-visible change is the per-export stability tiers in the reference docs and the
LayoutData removal below.

This is breaking. Consumers must, in order:

  1. Add the shell layout mount. Create src/routes/admin/+layout.server.ts with export const load = admin.shellLoad; and src/routes/admin/+layout.svelte that renders <CairnAdminShell data={data.shell}>{@render children()}</CairnAdminShell>. The chrome no longer rides the catch-all
    load; it rides this layout. Copy the showcase files at examples/showcase/src/routes/admin/.
  2. Rename AdminLayout to CairnAdminShell. The component export is renamed. A site on the canonical
    single-mount never imported it directly, so this affects only a hand-rolled per-route mount.
  3. Read siteName and other shell fields from page.data.shell, not data.layout. The per-view
    AdminData members no longer carry a layout field; the shell payload is the one source.
  4. No action for requireOwner. It now accepts a minimal { locals: { editor } } event, which widens
    the old signature, so existing callers keep working and a custom route can pass its standard load
    event.
  5. Remove any import type { LayoutData }. LayoutData is removed from @glw907/cairn-cms/sveltekit;
    read the admin payload from AdminShellData (via page.data.shell) instead.