Foundation Configuration
The main.js file at the foundation package's source root defines customizable CSS variables and configuration for your foundation. Layout components live in src/layouts/ and are auto-discovered.
Overview
Foundations can expose configuration points that sites customize in their theme.yml:
src/ # the foundation package
├── main.js # Name, description, variables, defaultLayout
├── sections/ # Section types
├── components/ # Internal components
├── layouts/ # Layout components (auto-discovered)
├── styles.css # Global styles
├── package.json # name: "src" — the workspace name, not the foundation's
└── vite.config.jsIdentity
A foundation's name is what it registers as — @org/<name> — and what sites pin (foundation: '@acme/marketing@1.2.0'). Set it in main.js:
// src/main.js
export default {
name: 'marketing',
description: 'A modern marketing site with hero, features, and pricing sections.',
}uniweb create and uniweb add write it for you — the project's name, or the name you gave add — and uniweb register asks for one when a foundation has none. The first time you register, it also writes the organization the foundation registers under into the name: name: '@acme/marketing'.
- Form: lowercase letters, digits and hyphens (
marketing,acme-docs), with the organization it registers under once it has one (@acme/marketing). The scope is part of the name — see Identity. - Not
srcorfoundation. Those name the folder the code lives in, and every project in an org would register the same one.uniweb registerrefuses them and asks for a real name (or, when it cannot ask, prints the line to add). - Fallback: with no
nameinmain.js, the name ispackage.json'sname. - The
versionalways comes frompackage.json, and so does thedescriptionunlessmain.jssets one.
The name in the foundation's package.json is a workspace name: it is how the site depends on the foundation ("src": "file:../src") and how site.yml refers to it (foundation: src). It can stay src. Once main.js names the foundation, the two are independent — uniweb rename foundation renames the workspace package without changing what the foundation registers as, and changing main.js's name touches nothing in the workspace.
package.json configuration
The uniweb block in package.json carries platform-specific configuration that doesn't belong in the npm-standard fields. It is optional, and most foundations need none: the platform falls back to sensible defaults when a field is omitted. What a foundation is — its name and the organization it registers under — lives in main.js, above.
Supported fields
| Field | Type | Default | Purpose |
|---|---|---|---|
runtimePolicy | "exact" | (unset) | Escape hatch, rarely needed. Sites using this foundation are given a compatible runtime automatically; compatibility is the framework's determination, not a per-foundation setting. Set "exact" only if the foundation depends on undocumented runtime internals or was audited against exactly one runtime build — it freezes sites on the recorded version and they stop receiving runtime fixes. See uniweb register. |
Removed:
uniweb.id, which set the registered name frompackage.json, anduniweb.scope, which set the organization it registers under. Both live inmain.js'snamenow (name: '@acme/marketing');uniweb registerrefuses a leftover and says what to write instead. (A schemas-only package, which has nomain.js, still records its scope inuniweb.scope— seeuniweb register.)
Identity (scope + name) resolution
A registered foundation's name has two pieces — a scope (the org it's registered under) and its name — written together in main.js: @acme/marketing. Full details in uniweb register → Identity. The summary:
Name: main.js's name, else package.json's name — never src or foundation.
Scope: the one in the name — a namespace, either your personal scope (@<your handle>, no organization needed) or an organization's. A name without one has not been registered yet: the first uniweb register takes --scope @scope, else the workspace you work in — an organization's scope, or your personal one — and writes it into the name. A --scope naming a different scope than the name's is refused. The foundation's own data schemas register under the same scope (@/article → @acme/article), which is the name a site's records and queries use for them. When you publish a site whose local foundation changed, uniweb publish releases it to the catalog automatically — so the same rules apply there.
Why a separate uniweb block
These fields are platform configuration, not standard npm metadata. Keeping them under uniweb (as opposed to spreading them across the top-level package.json or various dotfiles) gives a single, discoverable home and avoids polluting the npm-tooling-recognized surface. Future platform features that need static configuration will land here too — additions will be documented in this section.
Where does the pinned runtime version come from?
You may notice your foundation's dist/runtime-pin.json reports a runtime version (e.g. 0.8.9) without your package.json declaring @uniweb/runtime anywhere. This is intentional.
@uniweb/runtime is pulled in transitively through @uniweb/build (which every foundation has as a devDependency). The runtime version baked into your foundation's pin is whichever version your @uniweb/build version pulled in at install time:
your foundation
└─ devDependencies: "@uniweb/build": "0.12.0"
└─ pulls in @uniweb/runtime (whatever version that build version locks)
→ resolved at install time → version goes into dist/runtime-pin.jsonThe foundation build looks for @uniweb/runtime in two places:
- Transitive resolution from
@uniweb/build's location — the common case. This is what every foundation hits unless it explicitly opts in to (2). - Direct dependency in your foundation's own
node_modules— opt-in escape hatch.
Practical implications
- You don't need to add
@uniweb/runtimeto your foundation's dependencies. This is by design — runtime is the host environment, not a foundation import. - To bump the runtime version your foundation pins, bump your
@uniweb/builddep. When@uniweb/build@0.13.0ships pulling in a newer runtime, updating your devDependency is how you adopt it. - You can override by adding
@uniweb/runtimedirectly to your foundation'sdependencies— but this is rarely needed and creates a source of confusion (now there are two places that know about the runtime version). Don't do this unless you have a specific reason. - You don't declare a compatibility rule — the pin is enough.
uniweb registercarries the recorded version with your foundation, and sites using it are given a compatible runtime. Whether two runtime versions are compatible is the framework's determination, not a per-foundation setting: you have no way to evaluate whether anything your code can reach changed between two releases, so you aren't asked to. The only knob is the"exact"escape hatch above, for a foundation that depends on undocumented internals or was audited against exactly one build.
Build outputs
uniweb build (or vite build) writes your foundation's dist/:
| Path | What it is |
|---|---|
dist/entry.js | The foundation module the browser loads (code-split — kit's optional features like syntax highlighting and search lazy-load as separate chunks). |
dist/entry-ssr.js | A single-file server-render build of the same foundation, for hosts that render your foundation on the server (SSR). Same components as entry.js in one module, with React, the runtime, and the browser-only lazy libraries left external — so it stays foundation-sized and those libraries (which only run in the browser) are never pulled in. Emitted automatically; you don't reference it directly. |
dist/assets/style.css | The foundation's compiled stylesheet (carries your theme-var defaults). |
dist/meta/schema.json | The compiled component + theme schema (params, defaults, layouts) the runtime reads. |
dist/runtime-pin.json | The @uniweb/runtime version this build resolved (see above). |
You normally don't touch these — the CLI's deploy/export flow ships the right ones for the target.
CSS Variables (vars)
Most customization is handled by component params. Both section components and layout components declare their own params in meta.js — layouts are full components with params, not just structural wrappers. A header height, for example, is typically a layout param, not a foundation var.
Foundation-level CSS variables are for values that must stay consistent across multiple components — shared radii, spacing scales, or a typeface the site retunes (beyond the three roles the theming system already provides as body/heading/code; see Font family vars). Don't reach for foundation vars when a component or layout param would do.
Defining Variables
// src/main.js
/**
* CSS custom properties that sites can override in theme.yml
*/
export const vars = {
'radius': {
default: '0.5rem',
description: 'Default border radius for cards and buttons',
},
'radius-lg': {
default: '1rem',
description: 'Large border radius for panels and modals',
},
'section-padding-y': {
default: 'clamp(4rem, 6vw, 7rem)',
description: 'Vertical padding for sections (fluid)',
},
'shadow': {
default: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
description: 'Default shadow for cards and elevated elements',
},
}Variable Schema
Each variable is an object with:
| Field | Type | Required | Description |
|---|---|---|---|
default | string | Yes | Default CSS value |
description | string | No | What the variable controls |
label | string | No | Display name in editor (falls back to humanized var name) |
type | string | No | 'select', 'color', or default text input |
options | array | When type: 'select' | Dropdown options |
group | string | No | Visual grouping in editor (e.g. 'Layout', 'Visual') |
globalOnly | boolean | No | If true, hidden from section-level panel |
When label is omitted, the editor generates one from the var name: radius-lg → "Radius Lg".
Type mapping
| Var type | Editor control | Context-aware? |
|---|---|---|
| (default) | Text input with default as placeholder | No |
select | Dropdown with options | No |
color | Color picker | Yes — stored per light/dark context |
Color-type vars are stored separately from non-color vars. The processor routes them to colorVars (per-context) rather than foundationVars (flat). This means color vars can have different values for light and dark schemes.
Foundation vars vs component vars: Foundation vars are global — they emit on
:rootand apply site-wide. Component vars (declared inmeta.js) are scoped to#section-{id}. See Component Metadata for component-level vars.
Font family vars
The theming system provides three font roles — body, heading, code — that the framework paints onto elements (body, h1–h3, code/pre/kbd/samp) and the site sets in theme.yml. A foundation can go further: declare a font var to add a role (an editorial serif, a display face) or redefine a built-in one. A font-*-named var is recognized as a typeface automatically — no type needed; a bare-named one takes type: 'font'. Either way the family loads and the schema tags it as a font:
// src/main.js
export const vars = {
// `font-*` name → recognized as a font automatically (no `type` needed)
'font-serif': {
default: 'ui-serif, Georgia, serif',
description: 'Editorial serif for pull-quotes and taglines',
applyTo: ['blockquote', '.tagline'], // framework paints it here, like a built-in role
},
}applyTo: [selectors]— the framework emits thefont-familyrule for you, exactly like a built-in role. Omit it and the component wires the typeface itself (a Tailwindfont-*utility, orvar(--font-…)).- Naming & inferred type —
serifandfont-serifare the same role (both emit--font-serif); thefont-serifspelling is what Tailwind'sfont-serifutility reads and inferstype: 'font'for you. So afont-*name needs notype; a bare name (serif,display) takes an explicittype: 'font'. A custom name with no built-in utility is referenced withvar(--font-display). (CSS longhands likefont-weightare never inferred as typefaces.) - Redefining a built-in role — declare
heading/body/codeas atype: 'font'var with your ownapplyToto retarget it (e.g. include<h4>in the heading font). The site still owns the family.
The site sets any role — built-in or foundation-added — by name in theme.yml under fonts: (or vars:), and loads the file with fonts.import / fonts.faces. Defaults should be OS stacks (ui-serif, Georgia, serif) so the foundation renders natively before a site opts into brand faces. See Site Theming → Fonts beyond the three roles for the site side.
Registering Defaults in styles.css
The main.js declaration is metadata — descriptions, types, and editor UI hints that end up in schema.json. To ensure the default values are present in the foundation's actual CSS output, register them in styles.css via @theme inline:
/* src/styles.css */
@theme inline {
--radius: 0.5rem;
--radius-lg: 1rem;
--section-padding-y: clamp(4rem, 6vw, 7rem);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}This is best practice for two reasons:
Guarantees defaults are available. The
@theme inlineblock compiles into the foundation's CSS (dist/assets/style.css, shipped alongsideentry.js). The defaults are present regardless of whether the site'stheme.ymlsets them — important for runtime-loaded foundations where the site build doesn't have access toschema.json.Enables Tailwind shorthand. With registration, you can use
py-(--section-padding-y)instead ofpy-[var(--section-padding-y)].
When a site overrides a variable in theme.yml, the site's theme CSS takes priority over the foundation defaults.
Using Variables in Components
Components reference foundation vars via CSS or Tailwind:
function Card({ children }) {
return (
<div className="rounded-(--radius) shadow-(--shadow) p-6">
{children}
</div>
)
}Or in CSS:
section {
padding: var(--section-padding-y) 0;
}The same vars apply consistently across all components that use them. Changing radius in theme.yml updates every card, button, and panel at once.
Site Overrides
Sites override variables in theme.yml:
# site/theme.yml
vars:
radius: 0.75rem
radius-lg: 1.25rem
section-padding-y: clamp(3rem, 5vw, 5rem) # tighter spacingThe build generates theme CSS with the site's values. These override the foundation defaults (from @theme inline in styles.css) because the site's theme CSS loads with higher priority:
/* Site theme CSS — overrides foundation @theme inline defaults */
:root {
--radius: 0.75rem;
--radius-lg: 1.25rem;
--section-padding-y: clamp(3rem, 5vw, 5rem);
}
/* --shadow keeps its foundation default since the site didn't override it */Default Section Type
When a content section doesn't specify a type: in its frontmatter, the runtime needs to know which section type to use. By default it looks for a component called Section, but you can change this with defaultSection:
// src/main.js
export default {
defaultSection: 'FeatureGrid',
}This is useful when your foundation has a general-purpose component that should handle untyped content. If not set, the fallback is 'Section'.
Data Every Section Receives
A section's content.data holds the keys its component declares in meta.js data:. Keys your foundation's content handlers — or a hook every section uses — read are declared once, in main.js data:, and every section receives them:
// src/main.js
export default {
data: { profile: {}, members: '@/member' },
}Each value is a schema ref, an inline field map, or {} for records with no schema. A section's own data: wins for a key both declare. See Data Fetching → What a section receives.
Custom Layout
Foundations can provide custom Layout components that control page structure. Layouts live in src/layouts/ and are auto-discovered.
Default Behavior
Without a custom Layout, the runtime uses a simple wrapper:
// Default layout
function Layout({ body }) {
return <>{body}</>
}Creating a Layout
Place layout components in src/layouts/:
src/layouts/
├── DocsLayout/
│ ├── index.jsx
│ └── meta.js # Optional: declares areas, params
└── MarketingLayout.jsx # Bare file works tooSet the default in main.js:
// src/main.js
export default {
defaultLayout: 'DocsLayout',
}With no defaultLayout, a layout named Default (or DefaultLayout) is the default. With neither, a page that names no layout gets the built-in one: header, the page, then footer, in a single column — other areas are not drawn. A defaultLayout that names none of your layouts stops the build.
// src/layouts/DocsLayout/index.jsx
export default function DocsLayout({ header, footer, left, right, body }) {
return (
<div className="min-h-screen flex flex-col">
{header && <header>{header}</header>}
<div className="flex-1 flex">
{left && (
<aside className="w-64 border-r">
{left}
</aside>
)}
<main className="flex-1">
{body}
</main>
{right && (
<aside className="w-64 border-l">
{right}
</aside>
)}
</div>
{footer && <footer>{footer}</footer>}
</div>
)
}Layout Props
| Prop | Type | Description |
|---|---|---|
header | ReactNode | Rendered header area (or null) |
footer | ReactNode | Rendered footer area (or null) |
left | ReactNode | Rendered left area (or null) |
right | ReactNode | Rendered right area (or null) |
body | ReactNode | Page content sections |
params | object | Layout parameters (merged with meta.js defaults) |
page | Page | Current page instance |
website | Website | Website instance |
Area names aren't fixed — foundations can declare any areas in meta.js. The props above are the conventional names. See Custom Layouts for details.
Layout meta.js
Layouts can optionally declare which areas they use and what parameters they accept:
// src/layouts/DocsLayout/meta.js
export default {
title: 'Documentation',
description: 'Three-column layout with sidebar navigation',
areas: ['header', 'footer', 'left', 'right'],
params: {
sidebarWidth: {
type: 'select',
options: ['narrow', 'wide'],
default: 'narrow',
},
},
}Per-Page Layout Selection
Pages select a layout and configure it in page.yml:
layout: MarketingLayout
# Or with options:
layout:
name: MarketingLayout
hide: [left, right]
params:
sidebarWidth: wideThe hide array suppresses specific areas on that page. Hidden areas are passed as null to the Layout component — check props directly:
export default function DocsLayout({ header, footer, left, right, body }) {
return (
<div className="min-h-screen flex flex-col">
{header && <header>{header}</header>}
<div className="flex-1 flex">
{left && <aside className="w-64">{left}</aside>}
<main className="flex-1">{body}</main>
{right && <aside className="w-64">{right}</aside>}
</div>
{footer && <footer>{footer}</footer>}
</div>
)
}Document Outputs
Foundations that compile their content into documents (PDF, DOCX, EPUB, Typst source bundles, HTML for Paged.js, etc.) declare the formats they support under outputs:. This is what powers in-page Download buttons and headless tools like unipress — both call compileDocument(website, { format, foundation }) from @uniweb/press, which reads the map and does the rest.
// src/main.js
import { buildTypstOptions, buildEpubOptions } from './compile-options.js'
export default {
defaultLayout: 'BookLayout',
outputs: {
typst: {
extension: 'zip',
getOptions: buildTypstOptions,
},
pdf: {
extension: 'pdf',
via: 'typst', // routes through the typst adapter
getOptions: buildTypstOptions,
},
epub: {
extension: 'epub',
getOptions: buildEpubOptions,
},
},
}Per-output fields
| Field | Type | Required | Description |
|---|---|---|---|
getOptions | (website, hostOptions) → { adapterOptions } (can be async) | No | Foundation-specific assembly: meta from website.config, typst preamble, stylesheets, cover image bytes, etc. The returned adapterOptions go to the Press format adapter. hostOptions are the rest-args the host passes to compileDocument — use them to branch on caller intent (e.g., mode: 'server' vs mode: 'sources' for typst). Omit when the default Press adapter output suits the format unchanged. |
via | string | No | Press format to compile through. Defaults to the output key itself. Use when the user-facing name differs from the Press adapter — pdf aliased to typst, for instance, so a headless host can fetch the typst source bundle and finish compile with its own typst binary. |
extension | string | No | Default file extension the host uses when deriving an output filename. Convention only — the host can override. |
How the shape fits the compile flow
compileDocument(website, { format: 'pdf', foundation, mode: 'sources' })
│
├─ reads foundation.outputs.pdf
│ via: 'typst' → Press uses the typst adapter
│ getOptions(website, { format: 'pdf', mode: 'sources' })
│
├─ calls getOptions(...)
│ → { adapterOptions: { mode, meta, preamble, template, assets } }
│
├─ gathers website.pages.flatMap(p => p.bodyBlocks)
│
└─ dispatches compileSubtree(<ChildBlocks blocks={...}/>, 'typst', { adapterOptions })
→ Blob (a typst source-bundle zip; the host binary turns it into a PDF)Relationship to other foundation callables
outputs.*.getOptions is a per-document callable, invoked once per compile. It is not the same as:
handlers.{data, content, props}— per-block callables in the content pipeline, invoked on every block's prepare-props pass. See Content Handlers.transports[name].resolve— per-request callables in the data pipeline, invoked on every fetch. See Data Transports.
Each subsystem owns its lifecycle moment; outputs owns end-of-pipeline document compile.
Data Transports
A foundation can export one or more named transports — reusable fetchers that a site can opt into by name. The site keeps authority: site.yml picks which transport handles which data key (a fetch's as). A foundation never silently intercepts a site's data request.
When a site declares no transport for a key, the framework's default fetcher handles it — the file the build compiles from a query, a host's live records, or an external query's url (GET, or method: POST with its body) and transform:, with every query operator evaluated over what arrived. It takes no site-level vocabulary for a backend of your own (a base URL, headers, a wire of its own): that is exactly what a transport is for — see Data Sources. Most foundations need no transports at all.
Declared on the default export of main.js alongside identity, theme, and layout fields:
// src/main.js
export default {
defaultLayout: 'MarketingLayout',
transports: {
acme: {
resolve: async (request, ctx) => {
const baseUrl = ctx.website.config?.fetcher?.acme?.baseUrl
const res = await fetch(`${baseUrl}/${request.query ?? request.as}`, {
signal: ctx.signal,
})
if (!res.ok) return { data: [], error: `HTTP ${res.status}` }
return { data: await res.json() }
},
cacheKey: (request) => `acme:${request.query ?? request.as}`,
},
},
}The site then opts in:
# site.yml
fetcher:
transports:
articles: acme # as → transport name
events: default # reserved name — framework default fetcher
acme: # transport-specific binding config
baseUrl: https://api.example.comHow the site selects
Selection is a name lookup, nothing more:
- If
fetcher.transports[request.as]is set, use that named transport from the primary foundation (or any extension that registers a transport by that name). - Otherwise, if
fetcher.transports.defaultis set, use that for every other key. - Otherwise, the framework default fetcher handles the request.
There is no match() predicate, no route-walking, no fallback chain. The site always makes the selection, visible in site.yml, auditable.
The transport contract
A transport is any object with a resolve method (and optionally cacheKey):
{
async resolve(request, ctx) {
// ...
return { data, error?, meta? }
},
cacheKey?: (request) => string, // optional; see Per-transport knobs below
}Request — the normalized fetch config, with an extra dynamicContext on a parametric page's record request:
| Field | Type | Description |
|---|---|---|
as | string | Required. The key under content.data the result will be stored at. |
query | string | The query the request reads, by name. |
path | string | A compiled file under the site's base — /data/<query>.json, or a per-record file. Mutually exclusive with url. |
url | string | An external query's address. Mutually exclusive with path. |
method / body | string / any | An external query's GET or POST, and the POST's body. |
transform | string | Dot-path into the response (e.g. data.items). |
record | object | An external query's record: — { url?, method?, body?, transform? } — the request for one record on a parametric page. |
detail | string | boolean | Set by resolution, never authored: the query has a per-record source — a compiled query's per-record file pattern, or true. |
whole | boolean | Set by resolution, never authored: a question to a host's records service asks for whole records — the key's component declares it whole: true. Absent, the answer is briefs. |
scope | string | The query's folder branch; its records are the ones placed at or below it. |
where | object | The query's predicate (where-object). |
sort / limit | any | The query's order and count. With scope and where they select the query's records — its limit included. |
narrow | object | What this fetch takes of the query's records — its own where, sort and limit, applied after the query's: where keeps the records that also match, sort re-orders them, limit takes the first N. On a parametric page's record question to a host's records service it holds match instead: the one record the URL names. A key declared single: true asks with limit: 1. Absent when the fetch takes all of them. A transport that evaluates at the source applies the query first and narrow second, so a fetch never gets a record the query does not select. |
dynamicContext | object | Present on a parametric page's record fetch: { paramName, paramValue }. |
Context — the framework singletons, handed to the fetcher directly (no globalThis reads needed):
| Field | Description |
|---|---|
website | The active Website. Read per-site transport config via ctx.website.config.fetcher. |
page | The Page scope of the request. null for site-level fetches. |
block | The Block triggering the request. null for page- and site-level fetches. |
signal | AbortSignal. Aborts on block unmount or route change. Pass to fetch(). |
Return — always the same shape:
{
data, // the fetched data. Never undefined — empty is [] or null/{}.
error?: string, // presence signals failure; may coexist with stale data
meta?: object, // fetcher-specific metadata; stored alongside the cache entry
}Throwing is tolerated but not idiomatic — the runtime catches and surfaces { data: [], error: String(err) }. Prefer returning an explicit error field.
Per-transport knobs
Optional fields on a transport object that the dispatcher recognizes:
| Field | Default | Description |
|---|---|---|
cacheKey(request) | {path, url, as, transform, method?, body?, locale, scope, where, sort, limit, narrow} stringified | Override when the transport derives response content from fields the default key doesn't cover, or when two requests should intentionally share a key. |
prerenderable | true | Set false to opt out of build-time (uniweb build) execution. The config is skipped at build and fetched at runtime in the browser. Use for transports that need browser-only APIs. |
Binding config
Under the fetcher: block in site.yml, a foundation's transport can read its own named sub-block. By convention, use a key matching the transport name:
# site.yml
fetcher:
transports:
articles: acme
acme: # binding for the 'acme' transport
baseUrl: https://api.example.com
sources: { blog: 'posts' }The transport reads its config via ctx.website.config.fetcher.{transportName}. The framework does no validation — document the keys your transport reads in the foundation's README.
Values under fetcher: are client-visible — they ride into the site's HTML or __DATA__. The framework does not offer a secret configuration channel. For private credentials, the pattern is same-origin proxying (the site fetches /api/…, a deployment-layer proxy attaches the secret server-side). See Data Sources → Secrets.
Removed: the per-site default-fetcher vocabulary —
fetcher.baseUrl,headers,envelope,supportsandrequest.*. The build warns and drops them. A public, keyless endpoint is an external query; a backend that needs a base URL or headers is a transport.
Composing middleware
@uniweb/fetchers ships small middleware primitives that wrap a transport with cross-cutting behavior. The package is middleware-only — for a complete transport, write your own resolve() against fetch() and compose middleware around it:
import { withAuth } from '@uniweb/fetchers'
const authed = withAuth(myTransport, () => someTokenProvider())Extensions contributing transports
An extension's main.js can export transports: { … } too. The dispatcher merges extension transports into a single registry, keyed by name. On a name collision with the primary foundation, the primary wins with a dev-mode warning. A malformed or throwing extension transport is logged and skipped — it never blocks the rest of the registry (parallels the Promise.allSettled pattern used to load extensions).
This lets a stats-widget extension package its own transport, and sites opt into it by name in site.yml:
# site.yml
fetcher:
transports:
stats: extension-stats # extension-contributed transport nameWhen to skip the transports declaration
Most foundations don't need one. Omit transports: when:
- The site's data is its own records (the default fetcher reads what the build compiles, or a host's live records).
- The site reads a public JSON API an external query can express —
url,method: POSTwith abody,transform— see Data Sources. - Each component calls
fetch()directly insideuseEffect(bundled-style foundations). - A third-party SDK manages transport inside the component.
See Data Fetching for the author surface (fetch: / query: cascade) and Data Sources for the recipes that don't need a custom transport.
Common Variable Patterns
Spacing System
export const vars = {
'spacing-xs': { default: '0.25rem', description: 'Extra small spacing' },
'spacing-sm': { default: '0.5rem', description: 'Small spacing' },
'spacing-md': { default: '1rem', description: 'Medium spacing' },
'spacing-lg': { default: '2rem', description: 'Large spacing' },
'spacing-xl': { default: '4rem', description: 'Extra large spacing' },
}Visual Style
export const vars = {
'border-radius-sm': { default: '0.25rem', description: 'Small radius' },
'border-radius': { default: '0.5rem', description: 'Default radius' },
'border-radius-lg': { default: '1rem', description: 'Large radius' },
'shadow-sm': { default: '0 1px 2px rgba(0,0,0,0.05)', description: 'Small shadow' },
'shadow': { default: '0 4px 6px rgba(0,0,0,0.1)', description: 'Default shadow' },
}Animation
export const vars = {
'transition-fast': { default: '150ms', description: 'Fast transitions' },
'transition-normal': { default: '300ms', description: 'Normal transitions' },
'transition-slow': { default: '500ms', description: 'Slow transitions' },
}Complete Example
// src/main.js
/**
* Cross-cutting design tokens that sites can override in theme.yml.
* Values that belong to a specific component (header height, sidebar width)
* should be params in that component's meta.js instead.
*/
export const vars = {
// Spacing
'section-padding-y': {
default: 'clamp(4rem, 6vw, 7rem)',
description: 'Vertical padding for sections (fluid)',
},
'container-padding-x': {
default: '1.5rem',
description: 'Horizontal padding for containers',
},
// Visual
'radius': {
default: '0.5rem',
description: 'Default border radius for cards and buttons',
},
'radius-lg': {
default: '1rem',
description: 'Large border radius for panels and modals',
},
'shadow': {
default: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
description: 'Default shadow for elevated elements',
},
}
export default {
defaultLayout: 'docs',
}Layout dimensions like header height and sidebar width are layout params, not foundation vars — the layout component owns them:
// src/layouts/docs/meta.js
export default {
params: {
headerHeight: { type: 'text', default: '4rem' },
sidebarWidth: { type: 'text', default: '16rem' },
maxContentWidth: { type: 'text', default: '80rem' },
},
}// src/layouts/docs/index.jsx
export default function DocsLayout({ header, footer, left, right, body, params }) {
// No need for fallback values — meta.js defaults are merged into params
// before the layout component receives them.
const { headerHeight, sidebarWidth, maxContentWidth } = params
return (
<div className="min-h-screen flex flex-col bg-section">
{header && (
<div className="sticky top-0 z-50" style={{ height: headerHeight }}>
{header}
</div>
)}
<div className="flex-1 flex mx-auto w-full" style={{ maxWidth: maxContentWidth }}>
{left && (
<aside className="shrink-0 border-r hidden lg:block" style={{ width: sidebarWidth }}>
<div className="sticky overflow-y-auto" style={{ top: headerHeight, maxHeight: `calc(100vh - ${headerHeight})` }}>
{left}
</div>
</aside>
)}
<main className="flex-1 min-w-0">
{body}
</main>
{right && (
<aside className="shrink-0 border-l hidden xl:block" style={{ width: sidebarWidth }}>
<div className="sticky overflow-y-auto" style={{ top: headerHeight, maxHeight: `calc(100vh - ${headerHeight})` }}>
{right}
</div>
</aside>
)}
</div>
{footer && <footer>{footer}</footer>}
</div>
)
}Runtime Access
Access foundation variables from components:
import { useThemeData } from '@uniweb/kit'
function Component() {
const theme = useThemeData()
// Get a foundation variable value
const radius = theme?.getFoundationVar('radius')
return <div style={{ borderRadius: radius }}>...</div>
}Best Practices
Use semantic names:
radiusnotr1orsize-8Only use vars for cross-cutting values: A header height belongs to the layout component as a param. A border radius used by cards, buttons, and modals belongs as a foundation var. The test: would changing this value need to affect multiple unrelated components simultaneously?
Provide good defaults: Defaults should work out of the box
Document with descriptions: The
descriptionfield helps site authors in the visual editorRegister in
styles.css: Declare vars in bothmain.js(metadata) andstyles.cssvia@theme inline(actual CSS)Consider dark mode: Vars referencing colors should use theme tokens
Test overrides: Verify vars work when sites customize them in
theme.yml
Icon Libraries
Foundations can include icon libraries to enable named icon syntax in content. This allows content authors to use icons without managing SVG files.
Why Include an Icon Library?
Without an icon library, content authors must:
- Provide SVG files for every icon
- Reference icons by file path:
{role=icon}
With an icon library installed, authors can write:
— Named icon from library{size=20 color=blue}— With size and color
Installing an Icon Library
1. Add the package:
cd foundation
pnpm add lucide-react2. Create an icon wrapper component:
// src/components/Icon/index.jsx
import * as LucideIcons from 'lucide-react'
export function Icon({ name, library, size = 24, color, className }) {
// For lucide library
if (library === 'lucide' && name) {
// Convert kebab-case to PascalCase: "arrow-right" → "ArrowRight"
const pascalName = name
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
const LucideIcon = LucideIcons[pascalName]
if (LucideIcon) {
return <LucideIcon size={size} color={color} className={className} />
}
}
// Fallback: render nothing or a placeholder
return null
}3. Use in your render component:
The @uniweb/kit Render component handles named icons automatically when your foundation provides an Icon component. If you're using a custom renderer, check for the library and name attributes:
// In your custom renderer
if (node.attrs?.role === 'icon' && node.attrs?.library) {
const { library, name, size, color } = node.attrs
return <Icon library={library} name={name} size={size} color={color} />
}Supported Libraries
The content parser recognizes these icon library prefixes:
| Prefix | Package | Install Command |
|---|---|---|
lucide: | lucide-react | pnpm add lucide-react |
heroicons: | @heroicons/react | pnpm add @heroicons/react |
phosphor: | @phosphor-icons/react | pnpm add @phosphor-icons/react |
tabler: | @tabler/icons-react | pnpm add @tabler/icons-react |
feather: | react-feather | pnpm add react-feather |
Example: Multi-Library Support
// src/components/Icon/index.jsx
import * as LucideIcons from 'lucide-react'
import * as HeroIcons from '@heroicons/react/24/outline'
function toPascalCase(str) {
return str
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
export function Icon({ name, library, size = 24, color, className }) {
if (!name) return null
const pascalName = toPascalCase(name)
// Lucide icons
if (library === 'lucide') {
const IconComponent = LucideIcons[pascalName]
if (IconComponent) {
return <IconComponent size={size} color={color} className={className} />
}
}
// Heroicons
if (library === 'heroicons') {
const IconComponent = HeroIcons[`${pascalName}Icon`]
if (IconComponent) {
return <IconComponent width={size} height={size} color={color} className={className} />
}
}
console.warn(`[Icon] Unknown icon: ${library}:${name}`)
return null
}Bundle Size Considerations
Icon libraries can be large. Consider these strategies:
1. Tree-shaking (recommended):
Most bundlers (Vite, webpack) tree-shake unused icons automatically when you import from the main package.
2. Individual imports:
Some libraries support individual imports for smaller bundles:
import { Check, ArrowRight, Heart } from 'lucide-react'
const iconMap = { check: Check, 'arrow-right': ArrowRight, heart: Heart }
export function Icon({ name, size, color }) {
const IconComponent = iconMap[name]
return IconComponent ? <IconComponent size={size} color={color} /> : null
}This limits which icons are available but produces smaller bundles.
3. URL-based fallback:
If named icons aren't found, fall back to URL-based icons:
export function Icon({ name, library, url, size = 24, color }) {
// Try named icon first
if (library && name) {
const IconComponent = resolveNamedIcon(library, name)
if (IconComponent) {
return <IconComponent size={size} color={color} />
}
}
// Fall back to URL-based icon
if (url) {
return <img src={url} width={size} height={size} alt="" />
}
return null
}Content Author Usage
Once your foundation includes an icon library, content authors can use named icons:
<!-- In section content -->
 Feature included
 Feature not included
<!-- With attributes -->
{size=32 color=green}
<!-- In a button -->
[Get Started](lucide:arrow-right) [Get Started](/signup){icon=arrow-right}See Also
- Site Theming — Site-level theme customization
- Layout Areas — Header, footer, and sidebar areas
- Component Metadata — Component meta.js schema
- Kit Reference — Accessing theme data in components