2 posts
Reference for every config key, taxonomy, design token, sidebar mode, and JavaScript hook in basalt.
Configure basalt almost entirely through config.toml. Every key below is optional unless marked required; pages that need no customisation beyond front-matter work out of the box.
Site config
The canonical schema lives in config.toml in the theme repo. The table below is the prose companion. Each key sits under [extra].
Primary keys
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
author | string | yes | — | JSON-LD Person markup, RSS author fields. |
author_display | string | yes | — | Shown in the sidebar footer. |
tag | string | yes | — | Site title rendered in monospace in the sidebar header. |
sidebar_subtitle | string | no | — | Small muted line below the title. |
bio | string | no | — | One or two sentences shown in the sidebar on desktop. |
default_theme | "light" | "dark" | no | "light" | Applied before first paint by theme.js to avoid FOUC. |
prompt_glyph | string | no | "❯" | Shown next to the active sidebar nav item. |
comments | "off" | "mastodon" | "bluesky" | no | "off" | Default comment platform for all posts; pages can override in their own front-matter. |
asciinema_theme | string | no | "asciinema" | Asciinema player skin (e.g. "monokai"). |
bg_default_position | string | no | "center" | Default CSS background-position for page backgrounds. |
palette_actions | array | no | [] | Declarative command-palette entries — see JavaScript hooks. |
Navigation
[[extra.sidebar_nav]] is an array of objects. Defining it activates static sidebar mode; omitting it activates filesystem mode. See Sidebar for the full model.
[[extra.sidebar_nav]]
id = "home"
label = "home"
href = "/"
[[extra.sidebar_nav]]
id = "blog"
label = "blog"
href = "/blog/"
[[extra.sidebar_nav]]
id = "docs"
label = "docs"
href = "/docs/"
children = [
id "install" label "install" href "/docs/install/"
id "config" label "config" href "/docs/config/"
]Social links
All keys under [extra.social] are optional. Only keys that are set render in the social row. The value is your handle or username, and the template constructs the full URL. Two exceptions: mastodon (full URL required, so rel="me" can be set) and email (pass a mailto: URI or full URL).
Developer platforms
| Key | Value | URL pattern |
|---|---|---|
github | username | github.com/<username> |
gitlab | username | gitlab.com/<username> |
codeberg | username | codeberg.org/<username> |
stackoverflow | user ID | stackoverflow.com/users/<id> |
hackernews | username | news.ycombinator.com/user?id=<username> |
devto | username | dev.to/<username> |
keybase | username | keybase.io/<username> |
Social
| Key | Value | URL pattern |
|---|---|---|
twitter | handle | twitter.com/<handle> |
bluesky | handle | bsky.app/profile/<handle> |
mastodon | full URL | used as-is (sets rel="me") |
threads | username | threads.net/@<username> |
facebook | username | facebook.com/<username> |
instagram | username | instagram.com/<username> |
tiktok | username | tiktok.com/@<username> |
snapchat | username | snapchat.com/add/<username> |
pinterest | username | pinterest.com/<username> |
linkedin | username | linkedin.com/in/<username> |
Video & streaming
| Key | Value | URL pattern |
|---|---|---|
youtube | channel handle | youtube.com/@<handle> |
twitch | username | twitch.tv/<username> |
Messaging
| Key | Value | URL pattern |
|---|---|---|
discord | invite code | discord.gg/<code> |
telegram | username | t.me/<username> |
whatsapp | phone number | wa.me/<number> |
signal | phone number | signal.me/#p/<number> |
matrix | user:server | matrix.to/#/@<user:server> |
Music & media
| Key | Value | URL pattern |
|---|---|---|
spotify | user ID | open.spotify.com/user/<id> |
bandcamp | subdomain | <subdomain>.bandcamp.com |
soundcloud | username | soundcloud.com/<username> |
letterboxd | username | letterboxd.com/<username> |
goodreads | user ID | goodreads.com/user/show/<id> |
Gaming
| Key | Value | URL pattern |
|---|---|---|
steam | custom URL name | steamcommunity.com/id/<name> |
itchio | username | <username>.itch.io |
reddit | username | reddit.com/user/<username> |
Support & funding
| Key | Value | URL pattern |
|---|---|---|
kofi | username | ko-fi.com/<username> |
patreon | username | patreon.com/<username> |
buymeacoffee | username | buymeacoffee.com/<username> |
Contact
| Key | Value | Notes |
|---|---|---|
email | mailto:you@example.com | Full URI; rendered as @ link. |
rss | feed URL | Shown separately from the social row in the sidebar footer and mobile meta. |
Taxonomies
basalt ships five pre-declared taxonomies. You can omit any from your own config, or add your own.
[[taxonomies]]
name = "tags"
feed = true # generates /tags/atom.xml and /tags/rss.xml
[[taxonomies]]
name = "kind"
[[taxonomies]]
name = "status"
[[taxonomies]]
name = "audience"
[[taxonomies]]
name = "shape"Zola generates a term-list page at /<taxonomy>/ and individual term pages at /<taxonomy>/<term>/. basalt renders these via taxonomy_list.html and taxonomy_single.html. Each term page lists all pages carrying that term.
Blog post taxonomies
Blog posts typically use only tags:
[taxonomies]
tags = ["zola" "rust" "tooling"]Project taxonomies
Project pages use the full set:
[taxonomies]
tags = ["zola" "theme"]
kind = ["weekend"] # weekend / production / wip / archived
status = ["alive"] # alive / paused / archived / stable
audience = ["solo"] # solo / team / public / clients
shape = ["theme" "cli"] # free-form; joined with " / " in panesTaxonomy chips on project pages are links. Clicking a chip goes to the term page listing all projects with that classification.
The partials/tag.html partial renders a chip as a <a> when taxonomy is set, or a <span> otherwise (used for GitHub topics on project cards, which aren't site taxonomies).
Design tokens
All colour values are CSS custom properties declared in sass/_tokens.scss. Nothing else in the codebase hard-codes a hex value; every component references these vars.
Colour tokens
| Token | Light | Dark | Usage |
|---|---|---|---|
--bg | #f8f9fa | #18181b | Page and sidebar background |
--bg-rgb | 248, 249, 250 | 24, 24, 27 | RGB components of --bg. Used by background overlay calculation; keep in sync. |
--surface | #ffffff | #1f1f23 | Cards, panes, code blocks |
--text | #1a1a1a | #f4f4f5 | Primary prose text |
--muted | #6b7280 | #a1a1aa | Secondary / meta text |
--accent | #3b82f6 | #60a5fa | Links, active states, borders |
--term | #10b981 | #34d399 | Terminal green — active nav glyph, online indicators |
--border | #e5e7eb | #3f3f46 | Input and element borders |
--soft-border | #eceef1 | #2a2a2f | Subtle dividers |
--code-bg | #f3f4f6 | #27272a | Inline code and hover backgrounds |
--tag-bg | #eef2ff | #1e293b | Tag / chip backgrounds |
--tag-text | #4338ca | #93c5fd | Tag / chip text |
Heat ramp tokens
Used for freshness or activity indicators. Green = fresh/low, amber = mid, red = hot/stale.
| Token | Light | Dark |
|---|---|---|
--heat-low | #10b981 | #34d399 |
--heat-med | #ca8a04 | #facc15 |
--heat-high | #dc2626 | #f87171 |
Overlay and shadow tokens
| Token | Notes |
|---|---|
--overlay | Semi-transparent dark veil for dialog backdrops |
--shadow-lg | Large drop shadow |
--shadow-md | Medium drop shadow (e.g. sidebar) |
--scrollbar | Custom scrollbar thumb colour |
Asciinema / terminal chrome tokens
--term-chrome-bg, --term-chrome-bar, --term-chrome-border, --term-chrome-text, --term-chrome-btn colour the asciinema player chrome. Light defaults track the one-light player skin; dark defaults track night-owl.
Reskinning
Override any token in your own SCSS without forking the theme:
// your-site/sass/_overrides.scss
:root {
--accent: #c9485b;
--term: #c9485b;
}
[data-theme="dark"] {
--accent: #f87171;
--term: #f87171;
}Import your override file after basalt's token import in your entry SCSS.
Sidebar
The sidebar nav has two modes, selected once per site by the presence or absence of config.extra.sidebar_nav. Both modes emit the same CSS classes (.sidebar-nav-item, .sidebar-nav-children), so styling lives entirely in sass/_sidebar.scss.
There is no per-section mode override, no tree-mode flag, and no collapsible <details> expansion — children are always visible.
Static mode
Activated by: defining [[extra.sidebar_nav]] in config.toml.
The sidebar renders the array in order. Each entry:
| Field | Required | Notes |
|---|---|---|
id | yes | Unique identifier (not rendered; used for active-state matching). |
label | yes | Display text. |
href | yes | Destination URL. |
children | no | Array of { id, label, href } — depth cap 2. |
The active item is highlighted by comparing current_path against href. An item whose href is a prefix of current_path is also highlighted, which is useful for section roots.
[[extra.sidebar_nav]]
id = "home"
label = "home"
href = "/"
[[extra.sidebar_nav]]
id = "options"
label = "options"
href = "/options/"
[[extra.sidebar_nav]]
id = "blog"
label = "blog"
href = "/blog/"
children = [
id "tags" label "tags" href "/tags/"
]Filesystem mode
Activated by: omitting extra.sidebar_nav entirely.
The theme walks the Zola section graph from the root (_index.md), rendering sections and their pages up to three levels deep (root → sub → sub-sub). A "home" entry pointing to / is auto-prepended.
Depth cap is 3 because Tera macros do not survive {% include %} boundaries; the walk is unrolled statically in the template.
Hiding content: set extra.hide_from_sidebar = true in a page or section's front-matter. For sections, omission cascades: the section and all its descendants disappear from the nav.
# content/private/_index.md
[extra]
hide_from_sidebar = true # hides /private/ and everything under itIndividual pages can also set hide_from_sidebar = true in their own front-matter to hide a single page without affecting siblings.
Page backgrounds
basalt has two independent background-image features:
| Feature | Scope | Configured in |
|---|---|---|
| Page background | per-page | extra.* in page (or section) front-matter |
| Sidebar background | site-wide | extra.* in config.toml |
Both are off by default. Both run non-remote images through Zola's resize_image at build time. Sources are resized to ≤ 1920 px wide and converted to WebP (quality 85).
Page background
Set in front-matter on the page that wants a backdrop.
Single image, both themes:
[extra]
bg_image = "content/images/my-photo.jpg"
bg_position = "center top" # optional
bg_opacity = 0.4 # optional; 0.0 = invisible, 1.0 = no overlayPer-theme variants:
[extra]
bg_image_light = "content/images/light-photo.jpg"
bg_image_dark = "content/images/dark-photo.jpg"
bg_position = "center"
bg_opacity = 1.0The browser swaps the image when the user toggles the theme. No extra JS.
| Key | Type | Default | Description |
|---|---|---|---|
bg_image | string | — | Backdrop for both themes when no per-theme variants are set. |
bg_image_light | string | — | Overrides bg_image for light theme. |
bg_image_dark | string | — | Overrides bg_image for dark theme. |
bg_position | string | config.extra.bg_default_position → "center" | CSS background-position. |
bg_opacity | float | 0.5 | 0.0 = invisible, 1.0 = no overlay. |
page.extra takes priority over section.extra. A section's _index.md can set defaults inherited by all pages; individual pages override.
Sidebar background
Set in config.toml under [extra]. Applies site-wide.
[extra]
sidebar_bg_image = "content/images/sidebar.jpg"
# or per-theme:
sidebar_bg_image_light = "content/images/sidebar-light.jpg"
sidebar_bg_image_dark = "content/images/sidebar-dark.jpg"
sidebar_bg_position = "center"
sidebar_bg_opacity = 1.0| Key | Type | Default | Description |
|---|---|---|---|
sidebar_bg_image | string | — | Sidebar backdrop, both themes. |
sidebar_bg_image_light | string | — | Overrides for light theme. |
sidebar_bg_image_dark | string | — | Overrides for dark theme. |
sidebar_bg_position | string | "center" | CSS background-position. |
sidebar_bg_opacity | float | 1.0 | Default 1.0; site-wide images are usually pre-tuned. |
Path resolution
content/images/foo.jpgresolves from the project root.static/img/foo.jpgresolves from the project root.https://example.com/photo.jpgpasses through unchanged; not processed.
If the source image is narrower than 1920 px, the original width is used (no upscaling).
Implementation notes
templates/base.html emits a single inline <style> block when any background feature is active. If neither feature is used, the block is omitted entirely.
sass/_backgrounds.scss reads --page-bg, --page-bg-position, and --page-bg-overlay and applies them to body. sass/_sidebar.scss reads the --sidebar-bg* counterparts and applies them to .sidebar.
JavaScript hooks
basalt exposes two extension surfaces on window.basalt. Both are safe to call before the main bundle loads: the palette queue drains on first open, and the theme API is set up by the inlined theme.js before any deferred script runs.
Theme API
theme.js is inlined in <head> and runs before the first paint. window.basalt.theme is the canonical API:
window.basalt.theme.toggle; // flip between light and dark
window.basalt.theme.set'dark'; // set explicitly
window.basalt.theme.current; // returns 'light' | 'dark'The theme persists to localStorage under the key theme. The data-theme attribute on <html> reflects the current value; all CSS custom-property selectors read from it.
Palette API
Register additional command-palette actions dynamically:
window.basalt = window.basalt || {};
window.basalt.palette = window.basalt.palette || {: };
window.basalt.palette.register{
: 'my-action',
: 'Do a thing',
: 'Actions',
: '◈',
action: () => doAThing,
};Navigate to a URL instead of calling a function:
window.basalt.palette.register{
: 'rss',
: 'Subscribe to RSS',
: 'Links',
: '◉',
: '/atom.xml',
};The full PaletteAction type:
| Field | Type | Notes |
|---|---|---|
id | string | Unique identifier. |
label | string | Primary display text. |
group | string | Section header in the palette. |
action | () => void | Called on selection. One of action, href, or js is required. |
href | string | Navigate to URL on selection. |
js | string | Dotted window.* path called on selection. |
glyph | string | Optional single-character icon. |
sub | string | Optional secondary line below the label. |
hint | string | Optional keyboard shortcut badge shown right of the label. |
Actions can also be declared statically in config.toml without writing JS:
[[extra.palette_actions]]
id = "rss"
label = "Subscribe to RSS"
group = "Links"
glyph = "◉"
href = "/atom.xml"config.toml declares palette items only — never hotkeys. key and inHotkeys fields on palette_actions are stripped at load time. To bind a key, register the command from static/js/main.js:
window.basalt.registerCommand{
: 'subscribe',
: 'Subscribe to RSS',
: 'Links',
: '/atom.xml',
: 'r', // hotkeys live in JS only
};Group ordering
Palette groups render in the order they're encountered. Pin or hide groups via [[extra.palette_groups]]:
[[extra.palette_groups]]
id = "Go"
weight = 0 # floats to the top
[[extra.palette_groups]]
id = "Posts"
weight = 10
[[extra.palette_groups]]
id = "Posts"
label = "Writing" # rename a group at render time
[[extra.palette_groups]]
id = "Debug"
weight = -1 # hideLower weights render first. Groups not listed keep their implicit order at the end.
Lazy loading
basalt's built-in modules (palette, comments, KaTeX, mermaid, asciinema, lightbox, carousel) are dynamic-imported inline from src/main.ts using two small helpers:
onLoad(selector, label, importer)— fire once at init if any matching element exists.onVisible(selector, label, importer)— fire when the first match scrolls into view (IntersectionObserver, 200px rootMargin).
Sites that need their own lazy bundles can copy the pattern from src/main.ts — there's no separate registration API to learn. Each importer is a thunk; failures are logged via console.warn and don't crash the page.
Re-init after dynamic injection
DOM walkers that inject markup at init time (color chips on inline <code>, copy buttons on <pre.giallo>) register themselves with the re-init registry. Site code that injects code after first paint (lazy comments, server fragments, view transitions) can call:
window.basalt.reinitrootElement; // omit rootElement to walk the whole documentEach registered walker is idempotent — re-calling on already-processed nodes is a no-op.
Loading order
The main bundle loads as <script type="module"> (deferred by default). Heavy modules are dynamic-imported only when the relevant DOM is detected. theme.js is inlined into <head> so theme preference is applied before first paint and stays free of bundler concerns. Site-level static/js/main.js (if present) loads defer at body end via basalt's base.html.
Code blocks
basalt renders code blocks from standard markdown triple-backtick fences. There's no shortcode; the language identifier after the opening fence is enough.
```rust
fn main() {
println!("hello");
}
```Renders as a syntax-highlighted block with a copy button in the top-right corner. Drop the language identifier for a plain block with no highlighting. The copy button still appears.
How highlighting works
basalt uses Zola's built-in giallo syntax highlighter with style = "class" in config.toml. Tokens render as CSS classes (.z-keyword, .z-string, etc.) rather than inline styles. basalt ships static/giallo-light.css and static/giallo-dark.css, generated by Zola at build time and linked from head.html. theme.js toggles the disabled attribute on one of them when the theme changes. No re-fetch.
How the copy button works
src/copy-code.ts walks every pre.giallo at module load and appends a <button class="codeblock-copy"> to each one. The button is only injected when navigator.clipboard?.writeText is defined. JS-off users and old browsers see clean code blocks with no copy affordance.
pre.mermaid and other non-giallo <pre> blocks are excluded by the selector.
Customising the chrome
Override .codeblock-copy styles in your own SCSS. The defaults live in sass/_code.scss and use --code-bg, --soft-border, --muted, and --term from sass/_tokens.scss. Reskin via tokens for the cheapest customisation.
/now/ data schema
The /now/ page is rendered by templates/now.html from data/now.json in your site root. Place the file at <project-root>/data/now.json.
{
"status": {
"label": "building something",
"blocked": "too much to do, not enough time",
"mood": "cautiously optimistic"
},
"currently": [
"Working on the new site",
"One bullet per thing you're actively doing"
],
"reading": [
{ "kind": "book", "title": "Title here", "meta": "Author, format" },
{ "kind": "audio", "title": "Audiobook title", "meta": "Author, commute loops" },
{ "kind": "watch", "title": "Series title", "meta": "current season" }
]
}Field reference
| Field | Type | Rendered as |
|---|---|---|
status.label | string | "doing" row in the status pane |
status.blocked | string | "blocked" row, styled heat-high (red) |
status.mood | string | "mood" row, styled heat-med (amber) |
currently | string[] | Bulleted list in the "currently" pane |
reading | object[] | Rows in the "reading · watching" pane |
reading[].kind | string | Left-aligned label (book, audio, watch, or anything) |
reading[].title | string | Middle column |
reading[].meta | string | Right-aligned muted metadata |
The "tinkering with" pane and "latest post" pane on /now/ are pulled from Zola's section graph automatically (projects/ and blog/ respectively). No now.json fields needed for those.