Kits design
Status
This is an accepted implementation design for a future Heine feature. Kits are not implemented yet, so this document is not part of the current user-facing contract.
Decision
Heine will provide kits, not supposedly interchangeable themes. A kit is a complete site design with two separate purposes:
- its runtime presentation resources supply templates, assets, and kit UI messages to a site that selects it;
- its
site/directory is a complete, copyable ordinary Heine site that shows the content structure, configuration, and data conventions the design was made for.
The copied site root is owned by the user. A kit is updated or replaced without editing its files. Root templates adapt public kit originals through explicit namespaced references; root assets, messages, and license texts retain their normal value-overlay behavior. Changing kits is not promised to preserve content conventions and may require a deliberate migration.
Motivation
Conventional SSG themes often include templates that assume particular paths,
front-matter keys, collections, taxonomies, and data shapes. Their expected
site structure is usually documented informally or provided as demo content.
The themes/ name nevertheless suggests that users can freely replace one
theme with another.
Heine cannot make arbitrary complete site designs interchangeable without inventing a universal content model. Such a model would be incomplete and would constrain the explicit content composition Heine supports today. Kits instead make the design's conventions concrete in one buildable reference tree, while keeping the runtime layering narrow and predictable.
Terminology
- Site: an ordinary Heine project rooted at a
heine.tomlfile. - Kit: one optional selected package below
kits/<name>/. - Kit reference site: the required
site/directory inside a kit. It is copied into a new project root; it is never read as a build-input layer. - Root resource: a template, asset, message, or legal input owned by the user's site root. An ordinary template name addresses this source only.
- Kit resource: the selected kit's corresponding runtime input. A kit
template has a
kit/<name>/namespace.
Layout
An installed kit's runtime layout requires these paths:
kits/paper/
├── kit.toml
├── templates/
├── assets/
├── locales/
├── LICENSES/ # only when kit assets need license texts
└── site/
Other top-level files, such as a README.md, changelog, or test support files,
are package metadata. Heine ignores them.
site/ has the ordinary root shape. It may contain heine.toml, content/,
data/, templates/, locales/, deploy/, LICENSES/, and any applicable
subdirectories. Its templates are root-owned adapters or site-specific
templates; every file below site/ is reference-site input, not a kit runtime
input.
A site without a kit has its existing ordinary layout unchanged:
my-site/
├── heine.toml
├── content/
├── data/
├── templates/
├── locales/
├── deploy/
└── LICENSES/
After installing paper and copying its reference site, the combined project
looks like this. The root templates/post.tera is an adapter that explicitly
extends the public kit original, kit/paper/post.tera.
my-site/
├── heine.toml
├── content/
│ └── assets/
│ └── css/
│ └── paper.css # optional root asset override
├── data/
├── templates/
│ └── post.tera # root adapter for kit/paper/post.tera
├── locales/
├── deploy/
├── LICENSES/
└── kits/
└── paper/
├── kit.toml
├── templates/
│ └── post.tera # original, named kit/paper/post.tera
├── assets/
│ └── css/
│ └── paper.css # overridden only when declared public
├── locales/
├── LICENSES/
└── site/ # reference only, never runtime input
Creating a kit-based site
To start from paper, a user installs it below kits/paper/, copies the
contents of kits/paper/site/ into the project root, and builds from that
root. The copied configuration selects the installed kit:
[kit]
name = "paper"
version = "^1.0" # optional SemVer requirement
The reference site's [kit].name must equal its containing manifest's name.
It therefore selects the kit being distributed, never another kit. A reference
site cannot compose kits through its own copied configuration.
Heine does not initially provide a package resolver, an installer, or a
heine new command. Copying is intentionally visible. A later convenience
command may automate this procedure without changing its semantics.
Developing and packaging a kit
A kit author develops runtime resources in their final locations below
kits/<name>/. The surrounding ordinary site root is the live integration
harness: its root templates are adapters that name kit originals, and its
content, data, and configuration exercise the kit as a user would.
When preparing a distributable kit, the author copies the ordinary site's
Heine inputs into kits/<name>/site/: heine.toml, content/, data/,
templates/, locales/, deploy/, LICENSES/, and applicable input
subdirectories. The copy excludes kits/ itself, generated output, VCS data,
and unrelated workspace files. Kit-owned runtime templates, assets, messages,
and license texts already remain in their final kit locations, so their
references do not need to change during packaging.
site/ is a release snapshot, not a live mirror of the authoring root. Kit
tests materialize its inputs into a temporary ordinary site root, install the
kit at that root's kits/<name>/ path, and build the result. This checks that
the copied reference site is complete without giving site/ runtime-input
status or introducing a special development mode. When a kit changes its
assumed site structure, configuration, or reference example, its author
updates the snapshot in the same change. Heine cannot compare an authoring
root with site/: a normal consumer root is expected to diverge from its
starting point.
Manifest and kit settings
kit.toml is a manifest, not another heine.toml. It contains:
name = "paper"
version = "1.0.0"
heine = ">=0.2.0, <0.3.0"
message_prefix = "paper-"
[public]
templates = ["base.tera", "post.tera"]
assets = ["assets/css/paper.css"]
components = ["paper.card"]
[extra]
accent = "violet"
show_search = true
The kit name is a simple identifier and must equal the directory name. It
forms the template namespace and the default component namespace. version
is a semantic version. heine is a semantic-version requirement for the
Heine release that loads the kit. The message prefix is non-empty and prevents
accidental collision between kit UI messages and site messages.
Semantic-version requirements use the semver crate's requirement syntax,
which follows Cargo's familiar comparator and caret conventions. Before
loading any kit runtime resource, Heine always checks its running version
against the manifest's heine requirement. An unsatisfied requirement is a
checked build error that identifies both the running Heine version and the
required range.
The diagnostic explains that the site needs a kit release compatible with the
running Heine version, or a Heine release compatible with the installed kit; it
does not suggest changing a compatibility declaration without establishing
that the kit actually supports the resulting version.
The site's optional [kit].version value is a separate semantic-version
requirement. Heine checks it against the installed kit's version. If the
site omits this requirement, any installed kit version that is compatible with
the running Heine version is accepted. An unsatisfied site requirement is a
checked build error at the authored site setting, with the installed kit
version as related context.
The kit's supported integration API consists of the manifest's [public]
members, every prefixed kit Fluent message and term, and the [extra] schema.
A kit author uses semantic versioning for this surface: removing or renaming a
public template, asset, component, kit message or term, or extra setting
requires a major version increment. The message prefix and every kit-defined
message or term using it are part of that surface: a root site may override a
specific existing entry, so removing or renaming that entry is breaking.
Adding a public member is a minor version change. Adding an optional extra
setting with a manifest default is also a minor version change: sites that do
not override it receive that default. Correcting behavior without changing the
contract is a patch version change.
The values below manifest [extra] are defaults and the complete schema for
the site's [kit.extra] table. Tables merge recursively down to existing leaf
values. Arrays replace the manifest array as a whole and never concatenate or
merge by index. A manifest schema array must be non-empty and contain values
of one TOML type; every replacement value must have that type. Unknown keys,
missing intermediate tables, type changes, empty schema arrays, and
incompatible array element types are errors at the authored setting. TOML does
not preserve an element type for an empty array, so v1 does not invent one or
use sentinel values. A later explicit schema extension may add typed empty
arrays when a concrete kit requires them. This one explicit, checked
configuration overlay is the only configuration merging kits perform.
Every manifest declaration is checked against the selected kit before runtime
loading begins. A declared public template, asset, or component must name one
existing kit resource exactly once. Template and asset paths use the existing
portable relative-path rules; an asset declaration must use its assets/
logical ID. A misleading manifest is an error at its authored declaration,
rather than a later missing-resource failure in the consuming site. Every kit
asset not listed under [public].assets is private.
Runtime sources and precedence
Only one kit may be selected. It contributes these runtime domains:
| Domain | Kit location | Root location | Rule |
|---|---|---|---|
| Templates | kits/<name>/templates/ | templates/ | Kit originals use kit/<name>/…; ordinary names address root templates only. Root templates may name public kit originals explicitly. |
| Copied assets | kits/<name>/assets/ | content/assets/ | Public kit assets use their declared assets/… ID and may be overlaid by a root asset. Private kit assets use kit/<name>/assets/… and are kit-only. |
| Fluent UI messages | kits/<name>/locales/ | locales/ | A present root entry may replace an existing prefixed kit message or term. |
| Asset license texts | kits/<name>/LICENSES/ | LICENSES/ | A present root text wins for the same logical license-text path. |
| Components | not applicable | no replacement path | Defined within kit templates; root templates may invoke declared public components, but cannot replace one. |
Root resources with no kit counterpart continue to work normally. A private kit asset cannot collide with a root asset because its identity and published path are namespaced. It can still collide with a page, deployment file, or generated resource that claims that published path, under Heine's ordinary output-collision checks. Components have no root override path: root templates may invoke only manifest-declared public kit components. A root component definition in the selected kit's component namespace is a build error, so invocation can never quietly become replacement.
Public template and asset paths are literal logical paths in the first version. Glob patterns are deliberately unsupported: a small compatibility surface must remain visible, and a newly added file must not become public merely because it matches a broad pattern.
The asset rule promotes a manifest-declared kit asset such as
kits/paper/assets/css/paper.css to the public logical ID and published path
assets/css/paper.css. content/assets/css/paper.css may then replace it.
A private kit asset such as kits/paper/assets/images/mark.svg instead has the
logical ID and published path kit/paper/assets/images/mark.svg. Root
templates and Markdown cannot address that private ID through asset() or an
asset: URL; kit templates can. As elsewhere, an authored raw URL is not an
asset lookup and is not checked. This keeps internal kit assets out of the root
asset namespace while preserving relative URLs between private kit assets.
“Private” describes the checked kit API, not browser access: the file is a
published static resource, and its namespaced output path intentionally makes
its kit ownership visible. Other regular files in content/ remain ordinary
site-only copied assets.
Heine does not parse or rewrite CSS. CSS url(...) values are ordinary raw
stylesheet URLs, so a kit author must make them match the published layout.
This is the same trade-off as any raw URL in a site: it permits standard CSS
without a second parser, but an incorrect URL cannot become a build diagnostic.
Template source is never inferred from a fallback. An ordinary template name,
such as post.tera, resolves only below root templates/. A kit original uses
its unambiguous namespace:
{% extends "kit/paper/base.tera" %}
The namespaced form always selects the kit original. Every root-authored
template setting, including .page, _directory.toml, and generated-resource
template names in heine.toml, names a root template only. The copied
reference site therefore provides root adapters such as:
{% extends "kit/paper/post.tera" %}
If that root adapter is removed, its ordinary template selection fails at the
authored page or directory setting. It never falls back to a kit template. A
root template may name only a public kit original through the namespace. Kit
templates name their own originals, including private ones, through the same
namespace in extends, include, and import references, so root resources
cannot silently alter kit-internal composition. Kit templates cannot name root
templates. The kit/ template namespace is globally reserved, including for a
site that currently selects no kit, and root templates cannot define resources
beneath it. Kit components must use the kit-name namespace, for example
paper.card, and only manifest-declared public components are available to
root templates.
When a site needs different component markup, it changes the root adapter for the public kit template that invokes the component, using the extension mechanism above when appropriate, and calls a root-owned component or writes the required markup there. A kit author should expose a public template or documented template block when that form of customization is expected. Private kit partials are intentionally not root customization points. For a small structural change, a root adapter overrides a documented block in its public parent template. Replacing the adapter outright remains possible, but is a larger, consciously site-owned presentation change.
Localization
Kit messages are only for kit interface text. Every kit Fluent message and term must use the manifest's message prefix; Heine checks this when it loads the kit. The prefix prevents message collisions and keeps kit-internal term references visibly distinct from site terminology. A root entry using that prefix must correspond to an existing kit message or term, so a spelling error in an intended override is diagnosed instead of becoming a dormant root message. All existing prefixed kit messages and terms are replaceable: Fluent is reserved for human-facing interface text, not machine-readable state or hidden kit configuration. Root-owned interface text uses ordinary, non-kit IDs. Heine also checks that every component defined by a kit uses its kit-name namespace. These are build errors at the kit-authored or root-authored definition, not informal authoring conventions.
The lookup order for a message is:
- root resource for the current locale;
- kit resource for the current locale;
- root resource for the configured default locale;
- kit resource for the configured default locale;
- a checked build error at the template call site.
For each requested locale, Heine resolves every message and term identity with that precedence, then creates one Fluent bundle from the winning definitions. It does not try separate root and kit Fluent lookup passes. Consequently, a root-overridden message may reference an unmodified kit term, including one obtained through the default-locale fallback.
This preserves Heine's locale fallback while allowing a site to replace kit wording for a specific locale. The kit never contributes locale configuration; the root site remains the source of configured locales and text direction.
If no kit is selected, or a different kit is selected, a root template that
names kit/<name>/... fails at that template reference with a clear migration
diagnostic. A normal unqualified template lookup continues to report its
missing template at the authored page or template reference. Other root
templates and resources remain ordinary root inputs; removing [kit] does not
make a root file erroneous merely because it originated in a copied reference
site.
Assets and licensing
Kit assets and license inputs follow the same copied-asset, REUSE-style sidecar, source-root safety, and portable-path rules as their root equivalents. A sidecar belongs to the asset from the source layer that won lookup. If a root asset overrides a kit asset, the kit sidecar does not carry over.
Selected kit assets join the same resolved asset inventory as root assets.
The existing attributions() template view therefore exposes their winning
licensing facts with every other copied asset. Heine does not generate a
separate attribution document.
A root LICENSES/<path> file overrides the selected kit's same logical
license-text path, even when the bytes differ. This lets a site correct a kit
license text without modifying a vendored or submodule kit. Heine exposes the
winning asset and license facts through the existing attribution view; it does
not make a legal-compliance claim. License-text replacement is not manifest
gated: a kit must not make a local legal correction impossible. License-text
paths are independent of copied-asset IDs, so the public or private status of
the asset that cites a text does not namespace or otherwise alter that text's
logical LICENSES/ path.
Assets, Fluent entries, and license texts are value overlays. Removing a root value resumes the selected kit's corresponding value, whether the removal was intentional or corrects an earlier mistake. Templates differ because selecting a page's structural rendering entry point must never silently change source.
Source discovery, output, and development server
- Heine ignores every kit
site/directory during source discovery, rendering, and watching. - It reads only the selected kit's manifest, templates, assets, locales, and attached license inputs.
- Those runtime kit roots reject symbolic links and obey the existing UTF-8, portable-path, collision, output-ownership, integrity, and attribution rules.
- A change to a selected kit runtime input triggers the same complete rebuild required for a root template, asset, locale, or license-input change.
- A change below
kits/<name>/site/triggers no build because it is not a runtime input. - Non-selected kits are ignored. Their invalid files cannot affect a build.
- Changing
[kit], its version requirement, or the selected name requires a full build; quick mode retains its existing configuration-change limitation.
Intentional boundaries
Kits do not contribute or merge these root domains at build time:
heine.toml, except for the checked[kit.extra]overlay described above;content/pages or declared content;data/;deploy/;- root legal declarations other than license inputs attached to selected kit assets.
Kits do not imply multiple-kit composition, package resolution, arbitrary remote loading, a plugin API, a generic transformation language, or a universal semantic content-role model.
Kit templates do not participate in unqualified root-first fallback. A site selects root-owned template entry points, and root adapters name kit originals only where the site intentionally depends on them.
Rejected alternatives
A visual theme without a reference site
This leaves content requirements as informal documentation or template errors.
The required site/ tree is a more reliable and inspectable statement of a
kit's intended structure.
A full runtime overlay of kit and root configuration, content, and data
Merging would make page ownership, declared-content lookup, arrays, collection configuration, metadata requirements, and diagnostics ambiguous. It would also make a kit update silently alter site content. The narrow presentation overlay avoids a second composition system.
A universal content-role or mapping language
No finite role schema covers arbitrary sites. Such a system would either fail to support real content or grow into a competing template/query language. Explicit Tera composition remains the escape hatch for site-specific needs.
Root-first template fallback
Letting an ordinary root name fall back to a kit template would make deleting a root adapter silently change a page's rendering contract. Separate root and kit template namespaces keep source provenance visible and turn a missing root entry point into a checked error at its authored selection.
Components and site-owned adapters as the whole feature
Components are useful inside kits, but they do not communicate a complete design's required content shape. They would provide a visual toolkit, not a coherent starting site.
Multiple kits or kit components
Multiple precedence layers make template, asset, message, configuration, and license resolution order-dependent. One selected kit keeps the mental model small. Revisit composition only after a concrete use case justifies it.
Glob-based public declarations
Globs would shorten a large manifest, but they would also expose future files accidentally and make the kit's compatibility surface less visible. Literal paths keep each public member deliberate and make semantic-version changes auditable.
Treating kits as freely swappable
A kit can be overridden and updated independently, but another kit may expect different content and data conventions. Changing kits is therefore a deliberate migration, not a build-time switch that Heine claims will preserve a site.
Implementation acceptance criteria
Implementation must include focused and fixture-site coverage for:
- selection, missing-kit, name, manifest, kit-version, Heine-compatibility, extra-schema, reference-site self-selection, and symlink errors with authored spans;
- materializing a kit reference site into a root beside its kit and building it successfully;
- root-only template selection, public namespaced kit-template extension, and a missing root adapter that cannot fall back to a kit original;
- documented-block customization of a public kit template, with private kit partials inaccessible to root templates;
- root precedence for declared public assets, messages, and license texts;
- public and private kit asset identities, including root rejection of a private kit asset lookup and the intentionally published private output path;
- manifest declarations for missing, duplicate, or unsafe public templates, assets, and components;
- errors for inaccessible private kit templates, kit templates that reference
root templates, and root definitions that attempt to occupy the globally
reserved
kit/namespace; - missing-selected-kit diagnostics and component namespace isolation, including a root definition that attempts to occupy the selected kit namespace and a root call to an existing non-public kit component;
- locale fallback across root and kit messages and terms, including a root-overridden message that references a kit term;
- message-prefix and component-namespace enforcement at the responsible kit or root-authored spans;
- kit asset integrity, image metadata, licensing, attributions, and output collisions;
- source discovery and watcher behavior for selected, non-selected, and
site/kit paths; - full and quick-build behavior, including the existing quick-build limits.
Public documentation, the reference, tutorial, glossary, and a natural starter example must be updated only when the feature is implemented.