Casca SSG
casca site is the native static-site generator used by the casca.style
portal. make site runs Casca and writes the deploy artifact to
site/_build/.
Quick Start
casca init my-site
cd my-site
casca site
For the portal build:
make site
The direct equivalent is:
bin/casca site --config site/casca.toml --root . --output site/_build
For local development:
bin/casca dev --config site/casca.toml --root . --port 8000
For serving an already-built output directory:
bin/casca preview --output site/_build --port 8001
For project validation:
bin/casca doctor --config site/casca.toml --root .
casca init writes casca.toml, content/index.md,
layouts/default.html, and dist/casca.css. The default layout is the
always-chrome minimal layout; --layout minimal selects the same template
explicitly. The scaffold uses <body class="casca"> with one substantive body
child, <casca-layout>, and fills brand, nav, footer, and markdown content
through slots. The generated config sets [markdown].casca_css_url = "/dist/casca.css", [[layouts]].content_selector = "#casca-layout-main", and
a matching [[promote]] rule so a fresh scaffold can be built immediately with
casca site. Init refuses to overwrite scaffold files unless --force is
passed, and it does not create the output directory or run a build.
Source Layout
The scaffolded layout is:
casca.toml
content/
layouts/
dist/
The portal uses site/pages/ for Phase 4.4.1 compatibility. Configure that
with [site].content_dir = "pages". Page sources are .html, .htm, and
.md. Files whose names start with _ are skipped. Extensions listed in
[build].ignore_extensions are skipped.
HTML sources with a document-root <html> are treated as complete pages.
Fragments and markdown are inserted into a selected layout. Layouts are valid
HTML5; per-page metadata uses a <casca-meta> element and URLs are bare
root-relative paths normalized at build time.
URL Handling
Layouts and fragment pages use bare root-relative URLs. The SSG normalizes
them to page-relative form at build time via a lol_html attribute rewriting
pass, so one build can be served from /, /casca/, or any other URL root.
<link rel="stylesheet" href="/dist/casca.css">
<a href="/docs/">Docs</a>
<img src="/img/hero.png">
The build-time normalization is depth-aware:
| Current output file | Source URL | Rendered URL |
|---|---|---|
index.html | /dist/casca.css | dist/casca.css |
charts/bar.html | /dist/casca.css | ../dist/casca.css |
docs/guides/start.html | /dist/casca.css | ../../dist/casca.css |
a/b/c/page.html | /dist/casca.css | ../../../dist/casca.css |
charts/bar.html | / | ../ |
charts/bar.html | /docs/ | ../docs/ |
docs/guide.html | /docs/ | ../docs/ |
The rewriter recognises URL-bearing attributes on <a>, <link>,
<script>, <img>, <source>, <audio>, <video>, <iframe>,
<form>, <use>, <object>, <embed>, and OpenGraph / Twitter / refresh
<meta> content. Root-relative paths (/foo) are rewritten;
protocol-relative (//host), scheme URIs (https:, mailto:, tel:,
data:), in-page fragments (#anchor), and already-relative paths
(./foo, foo, ../foo) are left untouched.
External URLs do not need any wrapper. Write them directly:
<a href="https://codeberg.org/skell/casca">Code</a>
<link rel="preconnect" href="//cdn.example.test">
Per-page metadata via <casca-meta>
Fragment pages declare per-page <head> content with a top-level
<casca-meta> element. At build time the SSG extracts its children into the
layout's <head> and removes the <casca-meta> element from the output.
<casca-meta>
<title>My Page</title>
<meta name="description" content="...">
</casca-meta>
<section>
Page content here.
</section>
A page's <title>, <meta name="...">, and <link rel="canonical"> from
<casca-meta> win over the corresponding declarations in the layout's
<head>. The layout's stylesheets, preloads, icons, and other always-on
chrome render unchanged. Duplicate <casca-meta> at top level emits a build
warning; a <casca-meta> nested inside other content is a build error.
Compatibility
Bare root-relative URLs and the <casca-meta> element are the public SSG
contract. The build-time normalization pass (Phase 110a) replaced the prior
{{ asset_url(...) }} / {{ page_url(...) }} / {{ canonical_url() }}
layout helpers. Future Casca CLI releases preserve the bare-URL and
<casca-meta> contracts or document a semver-major SSG compatibility
change.
Config Schema
casca.toml supports:
[site]:name,base_url,default_layout,content_dir,layouts_dir,assets_dir,output_dir, andstrict.base_urlis used by the sitemap and feed widgets for absolute URLs.[markdown]:extensions,unsafe_html_passthrough,heading_anchor_class,smart_punctuation,casca_primitive_mapping,wrap_in_casca_layout,default_variant,casca_css_url,casca_brand_label, andcasca_footer_text.casca_css_urlmust be a safe root-relative path such as/dist/casca.css; the brand fallback uses[site].nameunlesscasca_brand_labelis set, and the footer fallback defaults toPowered by Casca.[markdown.wikilinks]:enabled,case_sensitive,ambiguous,transclusion,block_refs,backlinks,backlinks_position,graph,graph_page,per_page_graph, andgraph_hops.[build]:parallelism,clean_output,clean_urls,ignore_extensions,doctype,fail_on_warning,cache_dir, andlink_check, andlive_layer.[dev]: local server defaults forcasca devandcasca preview.[[layouts]]: ordered routing rules.[[widgets]]: built-in widget configuration.[[favicons]]: copy explicit files to root output paths.[[promote]]: copy files or directories from--rootinto the output. Fresh init uses this to copydist/casca.cssinto the rendered site.
Fresh init does not scaffold a theme-picker widget, theme manifest, or theme
source files. The generated CSS bundle still contains the built-in Casca moods;
sites that opt into the theme picker configure that widget and its theme sources
explicitly.
When [markdown].wrap_in_casca_layout = true, a selected layout should either
contain the intended <casca-layout> shell or provide only the insertion target
where the SSG will insert that wrapper. Layouts that author their own light-DOM
header, nav, skip link, or footer should disable wrapping for those routes.
Unknown keys warn by default and become errors in strict mode.
JSON config bridge
casca config get and casca config set expose the panel-editable config
sections as JSON. The editable sections are site, build, markdown, and
favicons; summary is get-only and reports counts for TOML-only sections.
casca config get --section site --format json
casca config set --section site --input - --format json
casca config get --section summary --format json
set rewrites only known keys in the targeted section, validates the edited
document with the normal SSG parser, and preserves comments, unknown keys, and
non-edited TOML sections.
Markdown to Casca primitive mapping
Markdown pages render through CommonMark/GFM first, then casca site maps a
small set of rendered HTML primitives to Casca-classed HTML by default. Raw HTML
written inside markdown is included because the pass operates on the final
rendered HTML fragment.
| Input pattern | Output pattern | Opt-out flag | Accessibility implications | Dependency |
|---|---|---|---|---|
<table> | Add class="casca-data" while preserving existing classes. | tables | Native table semantics are unchanged. | Casca table primitive and Phase 088 prose context. |
<thead> | Add class="casca-data-head" while preserving existing classes. | tables | Header grouping semantics are unchanged. | Casca table primitive. |
<tbody> | Add class="casca-data-body" while preserving existing classes. | tables | Body grouping semantics are unchanged. | Casca table primitive. |
<pre><code class="language-X"> | <pre class="casca-code" data-language="X"><code>. The language-X token is removed from <code>. Other code classes remain. | code_blocks | <pre><code> semantics remain. data-language is metadata only. | Phase 088 prose code styles and future highlighter hook. |
<pre><code> with no language | <pre class="casca-code"><code>. No data-language. | code_blocks | <pre><code> semantics remain. | Phase 088 prose code styles. |
<blockquote> whose first element child is <p> and that paragraph's first element child is <strong> | Wrap the whole blockquote in <aside class="casca-prose-callout" data-callout="note" aria-label="Note">...</aside>. The <strong> remains inside the blockquote. | callouts | The wrapper gets a complementary landmark and accessible name from native <aside> plus the label. The original quoted content remains in document order. | Phase 088 callout CSS. |
<details>, <summary>, <hr>, GFM task lists | No SSG-side change. | None | Existing element semantics and pulldown task list checkbox output remain. | Phase 088 styles these through elements or existing GFM classes. |
The [markdown] table controls the pass:
[markdown]
extensions = ["tables", "strikethrough", "autolinks", "footnotes", "tasklists"]
unsafe_html_passthrough = true
heading_anchor_class = ""
smart_punctuation = false
casca_primitive_mapping = true
wrap_in_casca_layout = true
default_variant = "doc"
[markdown.opt_out]
tables = false
code_blocks = false
callouts = false
Defaults are filled even when [markdown] or [markdown.opt_out] is omitted.
Set casca_primitive_mapping = false to leave rendered markdown primitives in
their pre-v1.5.0 form. Set individual [markdown.opt_out] flags to keep only
that primitive unchanged.
For callouts, a blockquote with any explicit class attribute is treated as
an author opt-out. The value is not interpreted, so class="quote",
class="my-custom-quote", and even an empty class attribute preserve the
blockquote as plain quoted content.
Markdown pages are also wrapped in <casca-layout> by default when the selected
layout shell does not already contain a real <casca-layout> element and the
rendered markdown fragment does not provide a top-level <casca-layout>.
Generated wrappers use:
<casca-layout class="casca" data-variant="doc">
<template shadowrootmode="open">...</template>
<article id="casca-layout-main" tabindex="-1" class="casca-prose">
...
</article>
</casca-layout>
Variant precedence is frontmatter casca.variant, then the matched
[[layouts]] rule's variant, then [markdown].default_variant, then literal
doc. Valid variants are doc, demo, landing, and article. When
[markdown].wrap_in_casca_layout = false, route variants are ignored and the
existing layout content insertion path is used unchanged.
The generated shadow template comes from
tools/release-templates/casca-layout-shell.html. Modern engines that support
Declarative Shadow DOM paint the shadow chrome. Older engines render the
light-DOM markdown article directly without the header, skip link, or footer
chrome. Casca ships no JavaScript polyfill.
Casca Custom Elements in Markdown
Markdown pages can invoke any Casca primitive through two syntaxes: fenced TOML code blocks for data-heavy charts and inline custom elements for controls and layout primitives.
Fenced Block Charts
Data-heavy chart primitives use fenced code blocks with a casca-* language
tag. The body is TOML:
```casca-bar
title = "Monthly Sales"
labels = ["Jan", "Feb", "Mar"]
[[series]]
label = "Revenue"
values = [65, 80, 45]
```
The SSG generates both the visual chart and a screen-reader-accessible
.casca-data table.
Supported chart fenced blocks: casca-bar, casca-line, casca-area,
casca-pie, casca-heatmap, casca-scatter, casca-waterfall,
casca-radar, casca-candlestick.
Inline Custom Elements
Simple controls and layout primitives use HTML custom elements with
attributes or <template slot="..."> children:
<casca-progress value="65" label="Development"></casca-progress>
<casca-card>
<template slot="title">Getting Started</template>
<template slot="body">Content here.</template>
</casca-card>
Attribute-driven inline elements: casca-progress, casca-gauge,
casca-divider-pixel, casca-theme-image, casca-range, casca-date,
casca-color, casca-toggles/casca-toggle, casca-slot-grid,
casca-anchor-nav.
Slot-driven inline elements: casca-card/casca-card-grid, casca-thumb,
casca-toolbar/casca-toolbar-group, casca-stat/casca-stat-grid,
casca-switch, casca-disclosure, casca-filter, casca-segment,
casca-modal, casca-analytics, casca-site-header, casca-hero,
casca-site-footer.
Obsidian Callout Syntax
Blockquotes starting with [!TYPE] are detected as prose callouts:
> [!note] Optional title
> Callout body paragraph.
Supported callout types: note, warning, tip, danger, info,
example, question, abstract, success, failure, bug, todo,
quote.
The existing > **Type:** Body syntax continues to work unchanged.
Opt-Out
Individual primitives can be disabled via casca.toml:
[markdown.opt_out]
primitives = ["bar", "pie"]
Frontmatter
Markdown pages may declare per-page metadata in a leading YAML or TOML frontmatter block. The SSG parses the block and uses its values to override page titles, control layout wrapping, select variants, and carry structured metadata for feeds and sitemaps.
Block format
A frontmatter block is a fenced metadata region at the very top of a markdown file. Two fence styles are supported:
YAML (--- opener and closer):
---
title: My Page
date: 2026-06-01
draft: false
---
TOML (+++ opener and closer):
+++
title = "My Page"
date = "2026-06-01"
draft = false
+++
The fence must start at column 0 on the first line. The opener and closer markers must appear on their own lines. Trailing whitespace after the closer marker is accepted. A missing closer is treated as body text: the entire file is rendered as markdown and no frontmatter is parsed.
Canonical keys
Six keys carry cross-platform semantics. They mean the same thing in Hugo, Zola, Obsidian, and Jekyll:
| Key | Type | Purpose |
|---|---|---|
title | string | Page title override. Takes precedence over the first ATX heading (# Title) in the markdown body. |
description | string | Page description. Stored in page metadata for feed and sitemap consumers. |
date | string | Publication date in ISO-8601 format (e.g., 2026-06-01). Stored as an opaque string; no date parsing or validation is performed. |
draft | boolean | When true, the page is excluded from sitemap and Atom feed. The page is still rendered for preview. |
tags | array of strings | Multi-axis classification. Parsed and stored for future tag-page rendering. |
slug | string | URL slug override. Parsed and stored for future URL-rewriting phases. |
All six keys are optional. Omitted keys are treated as if they were not declared.
Casca namespace
SSG-specific per-page overrides live under a casca key (a sub-table in
TOML, a nested mapping in YAML):
| Key | Type | Default | Purpose |
|---|---|---|---|
casca.variant | string | config default | Layout variant override. Must be one of doc, demo, landing, article. Invalid values produce a warning (non-strict) or error (strict). |
casca.wrap_in_casca_layout | boolean | true | When false, this page's rendered markdown body is not wrapped in <casca-layout>. Overrides the route-level wrap_in_casca_layout setting for this file only. |
casca.primitive_mapping | boolean | true | When false, Casca-class mappings (casca-data, casca-code, casca-prose-callout) are not applied to this page's markdown output. Overrides the global [markdown].casca_primitive_mapping setting for this file only. |
casca.canonical | string | none | Explicit canonical URL for this page. Stored for future use. |
Example TOML frontmatter with Casca namespace:
+++
title = "API Reference"
date = "2026-06-01"
draft = false
[casca]
variant = "doc"
canonical = "https://casca.style/docs/api/"
+++
Example YAML:
---
title: API Reference
date: 2026-06-01
draft: false
casca:
variant: doc
canonical: <https://casca.style/docs/api/>
---
Unknown keys
Keys not in the canonical six or casca namespace are silently ignored
in normal mode. This preserves compatibility with Obsidian vaults, Hugo
params.*, and other tools' metadata conventions.
In strict mode ([site] strict = true), unknown keys produce a build
warning with the key names and file path. Strict mode does not fail the
build on unknown keys because Obsidian vaults require their plugin
metadata to remain in the frontmatter block.
Title precedence
The page title is resolved in this order:
- Frontmatter
titlekey (if present) - First ATX heading (
# Title) in the markdown body (if present) - Filename-based fallback (e.g.,
my-page.mdbecomes "My Page")
Variant precedence
The <casca-layout data-variant="..."> attribute is resolved in this
order:
- Frontmatter
casca.variant(per-page override) - Route-level
[[layouts]].variantfromcasca.toml [markdown].default_variantfromcasca.toml- Literal
doc
Theme Picker Widget
The portal uses the theme-picker widget to render one static picker form from
site/casca-themes.toml instead of duplicating mood markup in each layout:
[[widgets]]
widget = "theme-picker"
manifest = "casca-themes.toml"
selector = "[data-casca-theme-picker-widget]"
Layouts place a normal HTML placeholder:
<div data-casca-theme-picker-widget></div>
The widget replaces the placeholder with
<form class="casca-theme-picker" data-casca-theme-picker method="get" action="/_casca/theme">. Output remains static HTML. casca dev and
casca preview serve /_casca/theme when this manifest is configured, then
transform HTML responses at request time so body[data-casca-theme], the
checked radio, and hidden return_to reflect the resolved mood. casca site
still writes static bytes and does not bake per-theme variants into
site/_build.
Development Server
casca dev builds the same SSG output as casca site, keeps it in memory,
serves it over HTTP by default, watches source files, and broadcasts HMR events
over /_casca/ws. It accepts --config, --root, --host, --port,
--format, --fail-on-warning, --strict, and --quiet.
The [dev] table supports host, port, open, watch_css, and
inject_shim. open is parsed but remains inactive in this phase.
watch_css = true watches repo-root src/**/*.css when that directory exists.
inject_shim = true allows the dev server to append:
<script src="[dev-script]"></script>
The actual dev script path is the reserved /_casca/ path plus dev.js.
The shim is served only by casca dev. It is not written to dist/, not
served by casca preview, and not injected by casca site. Casca library
features remain HTML and CSS only.
Dev responses use:
Cache-Control: no-cache, must-revalidate
HMR Events
The browser shim is receive-only and handles these server events:
{"kind":"css-update","bundle":"/dist/casca.css"}
{"kind":"html-update","page":"<resolved-url>"}
{"kind":"full-reload"}
{"kind":"error","source":"<path>","line":<num>,"column":<num>,"code":"<code>","message":"<msg>","fix":"<optional>"}
{"kind":"error-clear"}
| Event | Payload | Browser behavior |
|---|---|---|
css-update | bundle | Replaces matching stylesheet links after the new link loads. |
html-update | page | Reloads only when the current path matches page. |
full-reload | none | Reloads the current page. |
error | source, location, code, message, fix | Shows the scoped dev overlay. |
error-clear | none | Hides the overlay and resets dismissal. |
The overlay is created by the shim only after an error or connection failure.
Its classes use the casca-dev-overlay prefix followed by double underscore
and its inline CSS avoids
global selectors such as html, body, :root, and universal selectors.
Watcher and Incremental Graph
casca dev watches configured content, layouts, assets, casca.toml, and
repo-root CSS when enabled. It ignores build outputs, target/, bin/,
dependency directories, VCS directories, and common editor temporary files.
The incremental graph tracks source pages, layouts, assets, and built pages.
A page edit targets one built page, a layout edit targets pages using that
layout, an asset edit copies only the asset state, a repo CSS edit rebuilds
/dist/casca.css, a config edit performs a full rebuild, and a
collection-split source edit refreshes the generated collection pages.
CSS HMR preserves form state and scroll position because it swaps stylesheet links. HTML changes reload the page and rely on normal browser scroll restoration.
Theme manifest changes in the config directory use the same rebuild path as
casca.toml changes. A successful rebuild swaps the in-memory output and theme
serving context together. A failed manifest parse or invalid default keeps the
previous valid serving context and broadcasts the existing HMR error event.
Preview Server
casca preview serves an existing output directory. It does not rebuild,
watch files, expose /_casca/ws, serve the reserved dev script path, or
inject the shim.
When --output is omitted, it resolves [site].output_dir from casca.toml.
It uses the same no-cache response header as dev so local smoke checks do not
hide stale files behind browser cache.
When a config is available and includes a theme-picker manifest, preview
loads that manifest once at startup and serves the same local theme setter and
HTML transform as dev. With --output and no config, preview keeps literal
static serving and /_casca/theme is inactive.
Doctor
casca doctor validates a project without writing output files. With no
arguments it discovers casca.toml by walking up from the current directory.
It also accepts --config, --root, --format human|json|brief, --json,
--quiet, and --fail-on-warning.
Doctor always validates the config in strict mode. It checks known config keys and value types, widget names and widget-specific schemas, layout files, layout insertion selectors, layout section paths, collection sources, favicon and promote sources, configured content, layout, and asset directories, markdown front matter, and duplicate output paths. It warns for orphan layouts.
Exit codes are:
0: no errors, and no warnings when--fail-on-warningis active.1: validation errors, or warnings promoted by--fail-on-warning.2: usage or config discovery failure before a project can be validated.
JSON output contains the same diagnostic codes, severity, paths, messages, and summary counts as human output.
Snapshot Parity
casca check-parity <BASELINE_DIR> <CURRENT_DIR> compares two rendered output
snapshots.
The command accepts --format human|json|brief, --json,
--include-ignored, --fail-on-allowed, and --quiet.
The parity oracle builds a normalized DOM for every matching HTML page. It
compares element order, tag names, parser-decoded text, attributes as maps,
heading ids, local link targets, classes as sets, and whitespace-sensitive
text inside pre, code, textarea, script, and style.
Diff classes are:
IGNORED: differences that do not affect the semantic contract, such as block-only whitespace or attribute ordering.ALLOWED: intentional generated artifacts that still need validation. The portal allows current-onlysitemap.xmlandblog/feed.xmlafter XML shape checks.FAILING: missing files, extra HTML files, node changes, text changes, heading id drift, class drift, local link drift, invalid generated XML, and other semantic differences.
The Makefile parity targets from the parallel-run window are retired.
check-parity remains available as a local snapshot utility when a developer
needs to compare two output directories.
TLS
casca dev and casca preview use HTTP by default. HTTPS is opt-in:
bin/casca dev --tls --tls-cert /tmp/casca-dev.crt --tls-key /tmp/casca-dev.key
bin/casca preview --tls --tls-cert /tmp/casca-dev.crt --tls-key /tmp/casca-dev.key
Both certificate and key are required. Casca does not generate certificates in
Phase 4.4.2. Use a local certificate from a tool such as mkcert or openssl.
When TLS is enabled, the browser shim connects to
wss://<host>:<port>/_casca/ws.
Exit Codes
casca dev and casca preview use the same local-serving exit contract:
0: server startup followed by graceful shutdown.1: runtime failure after valid usage, including build, watcher, bind, TLS file, TLS parse, preview output, or server errors.2: usage or config-discovery failure, including missing required config, malformed config, invalid host, invalid port, or invalid TLS flag shape.
IRON RULE 4.4.2
Phase 4.4.2 requires all dev, preview, HMR, TLS, inherited 4.4.1 parallel-run, inherited 4.4.0 byte-identity, lint, site, no-shim-leak, and CLI grammar gates to pass before the implementation is considered complete.
Layout Routing
Each [[layouts]] entry has name, file, match, content_selector, and
content_action. Rules are evaluated in declaration order. The first match
wins. If none match, [site].default_layout is used.
Supported match fields are page, section, include_subsections,
extensions, and not. Phase 4.4.1 supports replace_content. The
replace_element action is parsed but rejected with a clear error.
The content selector must match exactly one element for fragment and markdown
pages. The portal uses main and main > article.
Markdown
Markdown is rendered with pulldown-cmark using configured extensions:
tables, strikethrough, autolinks, footnotes, and task lists. Casca normalizes
the structural details needed by the portal contract: table align
attributes, bare www. autolinks, footnote wrappers and backlinks, and
task-list checkbox structure.
Raw HTML passes through by default because Casca assumes trusted source
content. The SSG is not a sanitizer.
Task-list items render as <li class="task-list-item"> containing a disabled
<input type="checkbox"> with checked="" on completed items, and the parent
list renders as <ul class="task-list">. The checkbox input has no id and is
decorative rather than form-submittable.
Wikilink Graph
When [markdown.wikilinks] enabled = true, Casca generates a static graph page
and per-page mini-graphs by default. The graph page route defaults to /graph
and follows the normal clean-URL rules, so the default clean build writes
graph/index.html. Disable only the generated page with:
[markdown.wikilinks]
enabled = true
graph = false
Set graph_page = "/network" to publish it at another absolute site path. The
graph contains published markdown pages only; drafts, HTML pages, assets, and
generated pages are excluded. backlinks = false disables per-page backlink
sections but does not disable graph edge collection while graph = true.
Per-page mini-graphs are appended to published markdown pages as static SVG
figures. Set per_page_graph = false to disable only those page-local graphs.
Set graph_hops = 2 to include a wider ego-centric neighborhood; the effective
value is clamped to 1..=5.
The graph page ships responsive static SVG and no runtime JavaScript. Public
styling hooks are scoped to .casca-graph-shell and .casca-graph*, including
.casca-graph, .casca-graph-edge, .casca-graph-node,
.casca-graph-node-visual, .casca-graph-circle, .casca-graph-label,
.casca-graph-focus, .casca-graph-focus-edge,
.casca-graph-focus-neighbor, .casca-graph-focus-circle,
.casca-graph-focus-label, .casca-graph-mini, .casca-graph-caption,
.casca-graph-node-ego, .casca-graph-ego-ring, .casca-graph-empty, and
.casca-graph-summary.
Front matter may be TOML between +++ fences or YAML between --- fences.
Reserved keys are title, layout, date, slug, draft, tags, and
aliases. YAML support is intentionally narrow: block mappings, sequences, and
scalars are accepted. Flow style, anchors, aliases, merge keys, custom tags,
and duplicate keys are rejected.
Widgets
Built-in widgets are compiled into the binary.
page-title: sets<title>from the first matching heading, with optional prepend, append, default, andkeep_existing.anchor-headings: addsidattributes to markdown headings that lack one, preserving authored ids. For headings inside a.casca-proseancestor, also emits a child permalink anchor (<a class="casca-prose-anchor" href="#<id>">) with an accessible label, rendered by the prose stylesheet as a#glyph beside the heading on hover or focus.collection-split: splitsCHANGELOG.mdshaped release sections into virtual markdown pages plus an index. Release dates become deterministic page metadata for feeds and sitemaps.feed: writes an Atom 1.0 feed. The portal uses it forblog/feed.xml, with deterministic entry timestamps from release dates.sitemap: writessitemap.xmlwith<loc>, deterministic<priority>for every URL, and<lastmod>only when page metadata provides a deterministic date.link-check: validates local links in the generated output tree. External URLs,mailto,tel, query-only links, and configured ignores are skipped.
Assets
Assets under [site].assets_dir are copied into the output with the asset root
stripped. For example, assets/css/site.css becomes css/site.css.
Favicons
Casca can generate and discover a conventional favicon directory. From one raster source image, run:
bin/casca favicon generate --root site --source site/assets/logo.png
The command writes deterministic files under <root>/_casca/favicons/:
_casca/favicons/favicon.ico
_casca/favicons/favicon-16.png
_casca/favicons/favicon-32.png
_casca/favicons/favicon-48.png
_casca/favicons/apple-touch-icon.png
On casca site and casca dev, recognized files in _casca/favicons/ are
copied to the output root. The SSG injects missing favicon links into every
rendered page head:
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
Generated hrefs are emitted page-relative in the final HTML, matching the
rest of the SSG URL normalization. favicon-48.png is copied for completeness
but is not auto-linked.
Existing layout or <casca-meta> links suppress equivalent generated links.
Matching uses exact rel tokens, so mask-icon does not count as icon.
Explicit outputs also take precedence: if a page, graph page, asset,
markdown embed, [[favicons]], or [[promote]] already claims
favicon.ico, the generated copy and generated link for that output are
skipped.
[[favicons]] remains supported for manual setups. It copies a file from
--root to an explicit output path:
[[favicons]]
repo = "assets/favicon.ico"
output = "favicon.ico"
[[favicons]]
repo = "assets/apple-touch-icon.png"
output = "apple-touch-icon.png"
If you use manual rules, keep the corresponding links in your layout or
<casca-meta>:
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
[[promote]] copies a file or directory from --root to an explicit output
path. The portal uses this to copy dist/ into site/_build/dist/.
PWA manifest
Casca does not generate a Web App Manifest yet (planned). To add one today,
author manifest.webmanifest in your assets directory and wire it with
[[promote]] or [[favicons]], then add the <link> to your layout:
<link rel="manifest" href="/manifest.webmanifest">
Output paths must be relative and must not contain ...
Feed-triggered rebuild
casca rebuild re-renders only the pages whose preprocessor dependency edges
name a changed feed. It is the publisher-side counterpart to casca site:
the publisher writes new feed bytes (typically write -> fsync -> rename)
and then invokes the rebuild as a post-publish hook.
bin/casca rebuild --feed <path>... # one-shot, named feed paths
bin/casca rebuild --feed-key <key>... # one-shot, named feed cache keys
bin/casca rebuild # one-shot, every indexed page
bin/casca rebuild --watch # long-running watcher
--feed PATH resolves against the config directory and accepts absolute,
config-relative, or cwd-relative paths; unknown feeds exit with code 2.
--feed-key KEY is an exact string match against the preprocessor entry's
cache_key. The two flags may be supplied together; the rebuild covers the
union of resolved pages.
Without --strict, a required = true preprocessor entry whose feed is
missing renders the designed never-published state plus a warning and the
rebuild exits 0. With --strict, the rebuild aborts with code 1 the same
way casca site does. --fail-on-warning elevates any warning to a
non-zero exit but does not change the render.
The rebuild path runs write_file_atomic per page, so partial writes are
not observable to a polling consumer (browsers or the optional live layer
sidecar). The link checker is NOT re-run by default; pass --link-check if
the host wants a full check after the rebuild.
Exit codes:
0: rebuilt zero or more pages, optionally with stderr warnings.1: runtime error (IO failure, corrupt config, unknown preprocessor kind).2: usage error (unknown feed, unknown feed-key, conflicting flags, active-watcher lock detected when runningcasca site).
Watch mode and the active-watcher lock
casca rebuild --watch watches each preprocessor entry's resolved
config.source AND its parent directory (catching write -> rename
publishes). On startup it acquires a sentinel at
<output_dir>/_casca/active-watcher.lock containing the watcher's PID
and a heartbeat timestamp. The watcher refreshes the timestamp every 5
seconds and removes the file on clean exit.
casca site checks this lock on startup. If the heartbeat is within the
last 30 seconds, casca site exits with code 2 and a clear error naming
the lock path. The convention is: stop the watcher before running a full
site build. The lock guards against the directory-wipe race where
casca site's default clean_output = true would otherwise erase the
watcher's atomic writes.
casca dev and casca rebuild --watch should not be run simultaneously
against the same output root. Both writers share the same atomic-write
contract per file, so file-level corruption is not possible, but both
watchers would observe the same feed change and emit duplicate log
lines. This case is documented rather than locked: the orchestrator
workflow is one or the other.
casca dev's watch set was extended in this phase to include each
preprocessor's resolved config.source AND its parent directory. A
developer who updates a local feed fixture sees the HMR update without
needing a separate casca rebuild invocation. The dev path defaults to
strict_required = false: a missing required feed during the dev loop
renders the designed never-published state rather than crashing the
loop, matching the dev iteration expectation that fixtures may be
missing.
Disk cache
casca rebuild and casca site write a small _casca/state.json cache
containing the preprocessor dependency edges. The cache is purely an
optimization; the canonical path is to re-derive the edge list from the
source tree on every invocation. The cache is never authoritative, and
the cache file may be deleted at any time by an operator without losing
correctness.
Optional live layer
dist/casca-live.js is a strictly opt-in progressive enhancement for the
analytics block. It is NOT bundled into dist/casca.css; hosts that want it ship it as a separate <script src="/dist/casca-live.js" defer></script> tag. The asset is built by
make build-live, which is not a dependency of make build.
Hosts opt in at two layers:
- Per-site (asset shipping): set
[build] live_layer = trueincasca.toml. Whentrue, the symmetrics preprocessor emits per-block JSON sidecars at_casca/preprocessors/symmetrics/<cache_key>.json, andcasca sitepromotesdist/casca-live.jsinto the site output tree (e.g.site/_build/dist/casca-live.jsunder the standard[[promote]] repo = "dist"rule). Whenfalse(the default), the sidecars are NOT emitted AND thecasca-live.jsasset is NOT promoted, even if the library bundle is present indist/. The library always producesdist/casca-live.jsso it is available to hosts that opt in; the site builder gates whether it actually ships. - Per-block (script activation): the host page template adds
data-casca-live-source="/_casca/preprocessors/symmetrics/<cache_key>.json"on the<section class="casca casca-analytics">element. The optionaldata-casca-live-interval="N"overrides the 60-second default poll cadence.
The script applies field-level text swaps only. It never sets .innerHTML,
never calls Node.replaceWith() / Node.replaceChildren() / sets
Element.outerHTML, never moves focus, never adds role="alert" or
aria-live. Element identity is preserved across every transition.
See docs/API.md for the JSON sidecar shape and the per-state field-clearing
rule the script consumes.
Partial Includes
<casca-include src="..."> expands a partial HTML file at build time (Pass D
in the SSG pipeline). The element is replaced with the resolved partial's
contents before any asset URL normalization or <casca-meta> merging occurs.
Syntax
<casca-include src="partials/site-footer.html">
</casca-include>
The src attribute is required. A missing src is a build error.
Path resolution
Relative paths are resolved against the directory of the file containing the
<casca-include> element:
src="partials/foo.html" -> <source_dir>/partials/foo.html
src="/partials/foo.html" -> <layouts_root>/partials/foo.html
layouts_root is the parent directory of the configured layouts directory
(site/ for the portal).
Recursion and depth cap
Partials may themselves contain <casca-include> elements, which are resolved
recursively before the parent partial is inserted. The maximum include depth is
10 nested includes. Exceeding it is a build error:
include depth exceeded (max 10): a.html -> b.html -> ... -> k.html
Cycle detection
A path_stack tracks the include chain. If any file is included a second time
within the same resolution, the build errors:
circular include: a.html -> b.html -> a.html
Attribute pass-through
Attributes on <casca-include> (except src and unwrap) are passed through
to the included partial's root element as CSS custom properties:
<casca-include src="partials/card.html" variant="featured" columns="3">
</casca-include>
produces:
<div class="casca-card" style="--variant: featured; --columns: 3">
...
</div>
Values containing special characters are CSS-escaped to prevent injection.
Values consisting only of [a-zA-Z0-9_-]+ are emitted unquoted.
When unwrap is present, pass-through attributes are silently ignored (there is
no single root element to receive them).
Unwrap
The unwrap attribute is a boolean HTML attribute. When present, the
outermost element of the partial is stripped and only its children are
inserted:
<casca-include src="partials/items.html" unwrap>
</casca-include>
If partials/items.html contains <ul class="item-list"><li>One</li><li>Two</li></ul>,
the result is <li>One</li><li>Two</li>.
Unwrapping a void element (<br>, <img>, <hr>, etc.) is a build error.
Unwrapping an empty partial is a build error.
Inline slot fills
<casca-include> children may include <template slot="X"> elements that fill
matching <slot name="X"> declarations inside the partial. These inline fills
are resolved during Pass D, before the partial is inserted. See the Slot
Filling section for the full contract; the same conflict rules apply.
<casca-include src="partials/card.html">
<template slot="extra">
<p>Inline content specific to this include</p>
</template>
</casca-include>
File-not-found
Missing partials are a build error with file:line context:
missing include: site/partials/missing.html
from site/layouts/landing.html:45 (<casca-include src="partials/missing.html">)
Page-fragment includes (deferred)
<casca-include> elements inside page fragments (files under site/pages/)
are out of scope for Phase 110b and emit a build warning. They are left
unresolved.
Slot Filling
Layout shells declare named insertion points with standard HTML5 <slot>
elements. Pages fill them with <template slot="..."> elements. Resolution is
build-time only (Pass E in the SSG pipeline); browsers receive fully resolved
HTML with no custom elements.
Slot declaration
<slot name="hero">
<header class="casca-doc-hero">
<h1>Casca docs</h1>
</header>
</slot>
The name attribute is required. A <slot> without name is a build error.
Children of the <slot> serve as fallback content when no template fills the
slot.
Template fill
<template slot="hero">
<header class="casca-doc-hero">
<h1>Custom Docs Landing</h1>
<p>Extended description</p>
</header>
</template>
At build time, the <template slot="hero"> element is removed from output and
its children replace the fallback content of <slot name="hero">.
Conflict rules
- Duplicate slot names: Two
<slot name="X">declarations with the same name are a build error. - Missing slot name: A
<slot>without anameattribute is a build error. - Duplicate templates: Multiple
<template slot="X">for the same slot produce a build warning. The first template in document order wins. - Unmatched templates: A
<template slot="X">with no matching<slot name="X">produces a build warning. The template is removed from output. - Inline vs document fills: Inline fills (children of
<casca-include>) win over document-level fills for the same slot name as a mechanical consequence of pipeline ordering: Pass D resolves inline fills before Pass E processes document-level templates.
Nesting
Slot resolution proceeds outside-in: outer slots fill before inner slots. A
template's content may itself contain <slot> elements, which are filled in
subsequent iterations. The maximum nesting depth is 10 iterations; exceeding it
is a build error:
slot nesting exceeds depth cap (10 iterations)
Shadow-DOM scoping
<slot> and <template slot="..."> elements inside declarative shadow DOM
(<template shadowrootmode="open"> or <template shadowrootmode="closed">)
are skipped by Pass E. The browser owns slot resolution inside shadow roots.
Empty fills
An empty template (<template slot="X"></template>) replaces the slot's
content with nothing. The fill exists, so it wins over fallback. Authors who
want fallback to survive should omit the template rather than writing an empty
one.
Worked example
Layout (site/layouts/doc.html):
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Casca docs</title>
</head>
<body class="casca">
<header class="casca-site-header">
<nav>...</nav>
<casca-include src="partials/codeberg-icon.html"></casca-include>
</header>
<main>
<slot name="hero">
<header class="casca-doc-hero">
<h1>Casca docs</h1>
</header>
</slot>
<!-- page content inserted here by content_selector -->
</main>
<casca-include src="partials/site-footer.html"></casca-include>
</body>
</html>
Page fragment (site/pages/docs/index.html):
<template slot="hero">
<header class="casca-doc-hero">
<h1>Custom Docs Landing</h1>
<p>Extended documentation landing page</p>
</header>
</template>
<section>
<h2>Getting Started</h2>
<p>...</p>
</section>
Build output:
- The Codeberg icon and site footer are expanded from partials.
- The
<slot name="hero">is filled with the page's<template slot="hero">content. - The page body is inserted into
<main>viacontent_selector. - No
<casca-include>,<slot>, or<template>elements remain in the output.
Pass F: Casca Custom Element Resolution
Pass F runs after Pass E (slot filling) and before the asset URL resolution
pass. It rewrites all <casca-*> custom elements to their final CSS-contract
HTML, handling four modes:
- Mode 1 — attribute-driven: Attributes map directly to CSS custom
properties and HTML structure (e.g.,
casca-progress,casca-gauge,casca-range,casca-date,casca-color,casca-divider-pixel,casca-theme-image,casca-toggles). - Mode 2 — slot-driven:
<template slot="...">children fill named slots in the output HTML. Slot content carries metadata attributes on the<template>tag (Pattern A). Primitives:casca-card,casca-card-grid,casca-thumb,casca-toolbar,casca-toolbar-group,casca-anchor-nav,casca-stat,casca-stat-grid,casca-switch,casca-disclosure,casca-filter,casca-segment,casca-modal,casca-site-header,casca-hero,casca-site-footer,casca-analytics,casca-slot-grid. - Mode 3 — chart + a11y: TOML DSL in
data-casca-dslproduces SVG-based chart HTML plus an accessible.casca-datatable. Primitives:casca-bar,casca-line,casca-area,casca-pie,casca-heatmap,casca-scatter,casca-waterfall,casca-radar,casca-candlestick. - Mode 4 — callout expansion: Obsidian
[!TYPE]callout syntax in blockquotes expands to<aside class="casca-prose-callout">elements.
Pattern A: Template Attribute Capture
For Mode 2 primitives, each logical slot is a single <template> element.
Extra attributes on the template carry metadata (e.g., trend="up",
view="1"). The <template>'s slot attribute names the slot; all other
attributes are captured as slot metadata and serialized to data-slot-*-attrs
attributes for handler consumption.
Multiple templates with the same slot name are supported (e.g., multiple
<template slot="view" view="N"> elements for a switch component). Each gets
an indexed attribute (data-slot-name-0, data-slot-name-1, ...) plus
corresponding -attrs attributes.
Body content (non-template children of the custom element) is preserved in
data-inner-html and read by container-element handlers.
Markdown opt-out
Configure [markdown.opt_out].primitives to skip specific primitives during
Pass F resolution. Opted-out elements pass through unchanged so the author
can handle them downstream:
[markdown.opt_out]
primitives = ["analytics", "switch"]
Known Limitations
- Nested same-name element truncation: Markdown pages are immune
(html5ever re-serialization handles this). HTML pages with same-name element
nesting (e.g.,
<casca-card>inside<casca-card>) should use different element names or markdown sources. - Prefixed
--casca-*attributes: The pre-scanner serializes slot content todata-slot-*attributes on the custom element. Content containing double-quotes is escaped with"during serialization and restored during handler consumption. - Iteration cap: Nested casca-* elements are resolved iteratively, up to 10 iterations. Deeply nested structures that hit this cap emit a warning and produce partial output.
Reserved Work
This section tracks features currently reserved but not yet implemented. No features are currently reserved. Named slots and partial includes shipped in Phase 110b (see the Partial Includes and Slot Filling sections below).
Linked references
- Cascalink
- linked as SSG