Kits and config as code
A kit is an installable bundle of site structure: content types, vocabularies with their terms, collections, forms and starter pages, with optional sample content. Kits are plain JSON files that ship with the server (packages/kits/<name>/kit.json) or sit in a folder named by KITS_DIR. The same manifest format is how a site's structure is exported and imported, so the structure of a site can live in git and be pushed to many sites by the fleet.
Installing a kit
In the admin, Structure → Kits (managers and admins) lists the bundled kits with what each contains. Install creates the structure; Install with sample content also creates and publishes the kit's example entries so the starter pages have something to show. The report that follows lists every item as created, updated or skipped with the reason.
From the command line:
spillway kit list
spillway kit install higher_ed # structure only
spillway kit install higher_ed --sample # plus sample content
Or over the API: POST /api/kits/<key>/install with { "withSampleContent": true } (needs types.manage and site.settings).
A brand kit ("kind": "brand") installs differently: it never touches the live brand, but opens in Brand → Import brand for review, and the rows applied there go to the brand draft.
A kit or theme published to a package registry installs the same way from its signed index: spillway kit install <name>[@version] --registry <url> [--sample], or POST /api/kits/install-from-registry with { "name", "version"?, "withSampleContent"? } (users.manage). The site downloads the tarball, verifies its sha256 against the index and runs this installer on the kit.json inside; see registry.md.
A kit may require other kits, modules and a core version (requires in the manifest, below). Both installers resolve those first: the Kits screen shows the plan (which kits get installed in which order, which modules come from the registry) in the confirm dialog, GET /api/kits/<key>/plan and POST /api/kits/plan-from-registry return it, and an install that cannot be satisfied is refused with 400 kit_dependency before anything is written.
Installing is idempotent by key. A type, vocabulary, term (by label within its parent), collection, form or page (by slug) that already exists is left exactly as it is and reported as skipped, so installing twice, or installing a kit on a site that already has a person type, never overwrites your changes. Sample content is skipped when an entity of the same type and title exists. The one exception: the starter home page seeded by the setup wizard is replaced by the kit's home page when it has not been edited (its layout still equals the sample), and the report says so.
The bundled kits
| Kit | Key | Types | Vocabularies | Collections | Forms | Pages | Mirrors |
|---|---|---|---|---|---|---|---|
| Higher education | higher_ed | Person, Program, Event, News, Location, Testimonial | departments, topics, degree levels, audiences | 6 | request_info | Home, Programs, Events, News, People, About | the standard KWALL Drupal starter (full-feature-edu) |
| City and local government | city | Service, Department, Official, Meeting, Notice, Project, Facility | departments, service categories, meeting bodies, project status, amenities, notice categories | 7 | report_issue, contact | Home, Services, Government, Meetings, News, Projects, Facilities, Contact | the city variant and kwall_city (Services type, services finder, Report a Water Leak form) |
| Intranet and portal | portal | Announcement, Resource, Policy, Team, Staff member, Event, FAQ | audiences, departments, resource categories, policy status, help topics | 8 | it_help_request, hr_question | Home, Announcements, Resources, Policies, Directory, Events, Help | the portal variant and kwall_portal (audience targeting, documents search) |
| Nonprofit | nonprofit | Program, Impact story, Event, Team member, Partner, Campaign, Press | program areas, story topics, sponsor tiers | 7 | volunteer_signup, newsletter, contact | Home, Programs, Stories, Events, About, Press, Get involved, Donate | the shared directory, calendar, news and webform pieces of the starter, arranged for a nonprofit |
Every kit ships main and footer menus in the manifest, a month calendar with an iCal link on its events or meetings page, a keyword search plus taxonomy filters on each finder page, and sample content with at least one recurring event. The city, portal and nonprofit kit directories carry a README.md with the full field list; the higher-education kit's fields are listed below. The kits are tested in packages/core/src/test/kits.test.ts and kits-variants.test.ts: each installs with sample content on a fresh site and every starter page, filter and feed is rendered.
A fifth bundled kit, docs, is this documentation as a site: one doc type, a section vocabulary, a search box, a feedback block on every page and only a docs menu, with content generated from docs/ by pnpm docs:build (its README, tested in kits-docs.test.ts). spillwaycms.com installs it next to the website's own kit, which lives in the website repository (website.md).
The higher-education kit
higher_ed models what a campus site needs:
| Type | Fields |
|---|---|
Person (/people/{slug}) | name, title, department (taxonomy), email, phone, office, short bio, biography, photo, areas of expertise (topics) |
Program (/programs/{slug}) | name, degree level, department, summary, description, highlights, admission requirements, apply link, image, audiences |
Event (/events/{slug}) | title, date and time (daterange with recurrence), location (reference), free-text location, summary, description, image, registration link, category (topics), audiences |
News (/news/{slug}) | title, summary, body, image, date, topics |
Location (/locations/{slug}) | name, address, map link, hours, description, image |
| Testimonial | name, role, quote, photo |
Vocabularies: departments (hierarchical: schools with their departments), topics (with term pages at /topics/{slug}), degree levels and audiences. Site-wide alerts are not a content type: they live under Alerts in the admin (see the getting-started guide).
Collections: upcoming_events (occurrences from now on, expanded, soonest first), latest_news, people_directory, program_finder, testimonials, campus_locations. A request_info form.
Pages: Home (hero, quick links, upcoming events, latest news, call to action), Programs (finder with keyword, degree level and department filters and a pager), Events (a month calendar with an iCal link plus a filterable list), News (keyword, topic and date filters), People (directory grid with name, department and expertise filters) and About (testimonials and the request-information form). With sample content, the kit adds three locations, four people, four programs, five events (including a weekly seminar series with a spring-break exception, a monthly drop-in and an all-day commencement), four news articles and three testimonials.
Structure export and import
Kits → Export structure downloads structure.json: a manifest with key site holding the current content types, vocabularies with their term trees, collections and forms (webhook secrets are stripped; a repository is not a secret store). GET /api/site/structure returns the same JSON.
Import structure (or POST /api/site/structure, site.settings) applies a manifest with update semantics: types, vocabularies, collections and forms whose key exists are updated in place, missing ones are created, terms are added by label, and the report lists the outcome of every item. Pages and sample content are ignored on import, so applying a structure never touches content. Together, export, a commit and import on another site are config as code for a fleet of sites.
Writing a kit
A manifest is a JSON object:
{
"key": "my_kit",
"label": "My kit",
"description": "What it installs.",
"version": "1.0.0",
"types": [ { "key": "faq", "label": "FAQ", "labelPlural": "FAQs", "hasPath": false, "titleField": "question", "fields": [ { "key": "question", "label": "Question", "type": "text", "required": true } ] } ],
"vocabularies": [ { "key": "topics", "label": "Topics", "hierarchical": false, "terms": [ { "label": "Research", "children": [] } ] } ],
"collections": [ { "key": "faqs", "label": "FAQs", "type": "faq", "filters": [], "sort": { "field": "title", "dir": "asc" }, "limit": 20 } ],
"forms": [],
"pages": [ { "title": "FAQ", "slug": "faq", "status": "published", "layout": { "content": [], "root": { "props": {} } } } ],
"sampleContent": [ { "type": "faq", "title": "When do classes start?", "data": { "question": "When do classes start?" } } ]
}
typesareContentTypeInputobjects, exactly whatPOST /api/typestakes. Taxonomy fields name a vocabulary by key ("type": "taxonomy", "vocabulary": "topics"); list the vocabulary in the same kit or create it first.vocabulariescarry nestedterms;termPathPattern(for example/topics/{slug}) gives every term a public listing page.collectionsandformsare the API input shapes without ids and dates. Event collections may filter and sort onoccurrence.start(values are ISO dates ornow,now+30d,now-7d) and set"expand": "occurrences". A filter value may name one of the kit's terms as$term:<vocabulary>/<Label>(a nested term as$term:topics/Parent > Child), on its own or several in an array ("op": "in"or"has_term"); the installer creates the vocabularies first, resolves the labels to ids and turns the filter into ahas_termfilter (which matches single and multiple taxonomy fields and includes child terms). A label that does not exist skips the collection with a reason in the report. The city kit'scouncil_members(officials by body) andactive_projects(projects by status) and the portal kit'sactive_policiesandmanager_announcements(announcements by audience) are written this way.mediaseeds files:{ "key", "filename", "alt", "title"?, "url"? | "base64"? }, exactly one ofurl(https only; fetched through the SSRF guard like every outbound request) orbase64(inline bytes, adata:prefix is tolerated), at most 2 MB each. The type comes from the bytes, not the name. Each item is stored once under an id derived from the kit and media key, so a reinstall reports it as skipped; keep bundled placeholders tiny (the four kits ship three small SVGs each, under 10 KB).- Sample content and page layouts refer to seeded media as
$media:<key>(becomes the media id, formediafields) or$media:<key>:url(becomes its public URL, for an Image block'ssrc); an unknown key is dropped rather than left as text. pageshold a Puck layout; give every block a uniqueprops.id. A page with"home": truebecomes the site home page when none is set.sampleContententries areEntityInputplusstatus(publishedunlessdraft) and an optionalref. Taxonomy values are written as term labels and resolved to ids on install; reference fields may point at another sample item with{ "$ref": "<ref>" }; media fields use$media:<key>as above.- Keyword filters on a kit's listing pages (
kind: "search"in a Collection block) search the entity's index document, so a word that appears only in a rich text body or a term label still matches.
Keys follow the content-type key rule (^[a-z][a-z0-9_]{1,62}$). Put the file at packages/kits/<directory>/kit.json; the directory name may use hyphens (higher-ed), the key may not. GET /api/kits lists what the server found.
Brand kits
A brand kit carries a site's brand instead of structure: Brand → Export writes one, and Brand → Import brand (the Brand kit source), Structure → Kits, spillway kit install, spillway brand import <file> and a registry install one. It is a kit manifest with "kind": "brand", an empty types list, the logo files in media (inline base64, or an https url) and a brand object:
{
"key": "brand_university",
"label": "Example University brand",
"description": "The published brand of Example University.",
"version": "1.0.0",
"kind": "brand",
"types": [],
"media": [ { "key": "logo", "filename": "logo.svg", "alt": "Example University", "base64": "PHN2ZyB4bWxucz0..." } ],
"brand": {
"theme": { "colorPrimary": "#7a0019", "fontBody": "Georgia, serif", "modes": { "dark": { "enabled": "auto", "colors": { "colorBackground": "#10060a" } } }, "custom": { "brand-stripe": "#ffcc33" } },
"identity": { "logoMediaId": "$media:logo" },
"styles": [ { "block": "Button", "key": "gold", "label": "Gold", "tokens": { "tone": "accent" } } ],
"guide": { "public": true },
"profile": { "name": "Example University", "voice": "Plain and warm.", "bannedWords": ["world-class"] }
}
}
brand.themeis checked like the brand draft (colors are CSS colors, lengths and the ratio in range);identityvalues must name the kit's own media as$media:<key>;stylesare block style inputs, checked against each block's slots when applied;profileholds the voice fields.- A brand kit carries the brand only: types, vocabularies, collections, forms, menus, pages, sample content or
requiresmake it invalid (400). - Installing never writes the live brand. The kit is read into a brand import: the install answers a report whose
brandImportholds the rows (KitInstallReport.brandImport), the Kits screen opens them in Brand → Import brand for review, and Apply to draft writes the checked rows to the brand draft. A person publishes it.spillway kit installandspillway brand importhave no review step, so they apply every row to the draft and print them. The full workflow is in theming.md, Export the brand as a kit.
Dependencies
A kit that builds on another kit, needs a module to run, or relies on a newer core declares it under requires:
{
"key": "admissions",
"version": "1.0.0",
"requires": {
"kits": [{ "key": "higher_ed", "version": "^1.0.0" }],
"modules": [{ "name": "profanity-guard", "version": ">=0.1.0" }],
"core": ">=0.1.0"
},
"types": []
}
kits: other kits by key, each with an optional semver range (^1.0.0,>=1.2 <2,1.x,*). They are resolved recursively and installed dependencies-first, the requested kit last. A kit already installed on the site at a version in range is skipped; otherwise the bundled kit of that key is used when its version is in range, and failing that the kit of that name (higher_ed→higher-ed, or the registry package's ownname) in the registry the site is pointed at (REGISTRY_URL), at the highest listed version in range. Dependency kits install their structure only (no sample content). A cycle (a → b → a), a kit nobody has, or a version nobody has fails the plan.modules: modules by registry name with an optional range. A module already installed at a version in range is skipped; otherwise it is installed from the registry through the module installer (and audited asmodule.installwith the kit that asked for it). A trusted-tier module is server code: the plan fails with a message naming the module unless the operator has setSPILLWAY_TRUSTED_MODULE_INSTALL=1and the installing user is an admin, exactly as for a direct install (see modules.md). Safe mode and a missing or unreachable registry fail the plan too.core: a semver range the server's version (SPILLWAY_VERSION) must satisfy.
The plan (KitInstallPlan) lists the kits in install order with action: install | skip and where each comes from (local or registry), the modules with install | skip | fail and their tier, the core check, ok and the problems. The Kits screen fetches it when you click Install and disables the button while it is not ok; spillway kit install runs the same resolution. The report of a successful install carries one kit item per dependency kit and one module item per module in front of the kit's own items. Installed kits and their versions are recorded in the site's settings (content.kits), which is what "already installed" is checked against.
The requires block is validated with the manifest (keys, names and ranges must parse; a kit may not require itself), so a broken one fails spillway kit list, a structure import and a registry publish alike. On a registry the dependencies check also looks every requirement up in the index: see registry.md. The fixtures under packages/core/src/test/fixtures/kits/ (dep_base, dep_child, dep_grand, a cycle, a core range, a missing kit, a trusted module) are the worked examples, tested in kits-deps.test.ts; the bundled kits declare no dependencies.
Spillway 0.1.0 · Edit this page on GitHub