uniweb 0.12.61 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +1138 -1381
- package/src/framework-index.json +8 -8
package/partials/agents.md
CHANGED
|
@@ -1,326 +1,293 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Part 0 — Read this first
|
|
4
4
|
|
|
5
|
-
A Uniweb project separates **what the site says** from **how it's built**. Content authors write markdown — choosing section types, setting params, composing
|
|
5
|
+
A Uniweb project separates **what the site says** from **how it's built**. Content authors write markdown — choosing section types, setting params, composing pages. Component developers build section types that receive pre-parsed content and render it. Neither touches the other's files.
|
|
6
6
|
|
|
7
|
-
Every
|
|
7
|
+
**Why that separation is cheap rather than ceremonial.** Every Uniweb site ships a runtime. A foundation is an ES module that plugs into it — either **standalone** (inlined into the site's own bundle, one self-describing artifact) or **linked** (loaded by URL at runtime as a federated module). Same foundation either way; the packaging is a deploy-time decision, not something your components know about. The runtime owns the content pipeline, section wrapping, backgrounds, theming, light/dark, prerendering, routing, and i18n; it orchestrates rendering and bridges the author's world into yours. Your components receive `{ content, params }` and render. **These are enforced guarantees, not conventions you have to uphold** — so you can drop the defensive code you'd write elsewhere.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| The runtime handles | So components should NOT contain |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Section backgrounds (image, video, gradient, color, overlay) from `background:` | Background rendering code, `bg-white`/`bg-gray-900` on the wrapper |
|
|
12
|
+
| Context classes (`context-light`/`medium`/`dark`) on every section | Theme maps: `const themes = { light: {…}, dark: {…} }` |
|
|
13
|
+
| Token resolution — `text-heading` adapts automatically | Conditionals: `isDark ? 'text-white' : 'text-gray-900'` |
|
|
14
|
+
| Content parsing with a guaranteed shape | Defensive null checks on content fields |
|
|
15
|
+
| Section wrapping in `<section>` with context class | An outer `<section>` with background/theme classes |
|
|
16
|
+
| Light/dark scheme resolution before first paint | `localStorage` reads, `typeof window` guards |
|
|
17
|
+
| i18n via locale-specific content directories | String wrapping with `t()` or `<Trans>` |
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
Concretely, the code you don't write: no theme maps, no `isDark ? … : …`, no null checks on content fields, no `t()` wrapping, no per-component background rendering, no SSR guards, no outer `<section>`. A hero that's 200 lines of undifferentiated React elsewhere is a 40-line component plus a markdown file here — and the markdown is editable by someone who has never opened your code.
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Components *should* contain: layout (`grid`, `flex`, `max-w-7xl`), spacing (`p-6`, `gap-8`), typography scale (`text-3xl`, `font-bold`), animations, border-radius — anything that stays the same regardless of theme context.
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
Once content reaches your component, **it's standard React.** Standard Tailwind. Import any library, use any pattern. The `{ content, params }` interface applies only to *section types* (the components authors select in markdown); everything else in your foundation is ordinary React with ordinary props.
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
You're building a *system* of section types, not individual pages. That's what makes theming, i18n, and multi-site tractable — they're properties of the system rather than things bolted onto each component.
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
### Route yourself
|
|
20
28
|
|
|
21
|
-
|
|
|
29
|
+
| Your situation | Start at |
|
|
22
30
|
|---|---|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
| Section wrapping in `<section>` with context class | Outer `<section>` with background/theme classes |
|
|
28
|
-
| i18n via locale-specific content directories | String wrapping with `t()` or `<Trans>` |
|
|
31
|
+
| New or nearly empty project | **Part 1**, then Part 4 |
|
|
32
|
+
| Existing project, content task (pages, copy, colors, nav) | **Part 2**, then Part 3 |
|
|
33
|
+
| Existing project, component task (new section type, restyle) | **Part 2**, then Part 4 |
|
|
34
|
+
| Porting a design from React / Next / a generated site | **Part 1**, then Part 5 → *Migrating* |
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
### Three commands that replace guessing
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
```bash
|
|
39
|
+
uniweb inspect <path> # print the parsed content shape of a section or page
|
|
40
|
+
uniweb <command> --help # exact flags, always current — no side effects
|
|
41
|
+
uniweb doctor # diagnose project configuration (--fix to repair)
|
|
42
|
+
```
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
`uniweb inspect` is the important one. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
### The four silent failures
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
Most Uniweb mistakes do not fail the build. The build goes green, the terminal is clean, and the page is wrong. If you don't look at the page or run `inspect`, you will report success on broken output.
|
|
39
49
|
|
|
40
|
-
**
|
|
50
|
+
1. **An unknown section type renders a red `Component not found: <Type>` box.** The build does not fail. Never write a `type:` you haven't confirmed exists — see Part 2, step 2.
|
|
51
|
+
2. **An undeclared param is accepted and does nothing.** Frontmatter keys no `meta.js` declares are passed through and ignored, not rejected.
|
|
52
|
+
3. **Content lands in the wrong field.** A heading becomes a `subtitle` when you wanted an item, or the reverse. That's the level rule (Part 3). `uniweb inspect` settles it.
|
|
53
|
+
4. **A section silently doesn't render.** `@`-prefixed files are child sections (only rendered via `nest:`), `_`-prefixed files are drafts, and section types nested below the root of `sections/` without a `meta.js` are never discovered.
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|------|----------|
|
|
44
|
-
| Writing page content | `authoring/writing-content.md` |
|
|
45
|
-
| Theming and styling | `authoring/theming.md` |
|
|
46
|
-
| Building components | `development/creating-components.md` |
|
|
47
|
-
| Kit API (hooks, components) | `reference/kit-reference.md` |
|
|
48
|
-
| Site configuration | `reference/site-configuration.md` |
|
|
49
|
-
| Content shape reference | `reference/content-structure.md` |
|
|
50
|
-
| Component metadata (meta.js) | `reference/component-metadata.md` |
|
|
51
|
-
| Migrating existing designs | `development/converting-existing.md` |
|
|
55
|
+
### Documentation
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
This guide covers what you can't derive by reading code. Everything else is in the full documentation — and **you can reach all of it without guessing at URLs.**
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
**Start at the index.** One address, and it's the only documentation URL worth memorizing:
|
|
56
60
|
|
|
57
61
|
```
|
|
58
|
-
|
|
59
|
-
├── src/ # Component developer's domain (the foundation package)
|
|
60
|
-
├── site/ # Content author's domain
|
|
61
|
-
└── pnpm-workspace.yaml
|
|
62
|
+
https://www.uniweb.io/llms.txt
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
It lists every documentation page with a one-line description and a fetchable link. Go there whenever your question isn't answered in this file — and *especially* when you don't know whether a feature exists at all. That's where guessing does the most damage: **a 404 on a path you invented tells you your guess was wrong, not that the feature is missing.** Concluding "the framework doesn't support X" from a failed fetch is a mistake worth actively guarding against; the index answers the existence question directly.
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
- **Site** (content author, in `site/`): Markdown content + configuration. Each section file references a section type. Authors work here without touching foundation code. It may also contain collections of structured content and/or references to external data sources.
|
|
67
|
+
Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. (Pages also serve markdown to any client that sends `Accept: text/markdown`.)
|
|
68
68
|
|
|
69
|
-
**
|
|
69
|
+
**Clone the repo instead** when you expect several lookups, or may lose network access later:
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
```bash
|
|
72
|
+
git clone --depth 1 https://github.com/uniweb/docs .uniweb-docs # then gitignore it
|
|
73
|
+
```
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
Same content, greppable, and one network operation instead of many. `grep -ri "deferred" .uniweb-docs` beats any number of fetches while you're still exploring.
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
Documentation paths in this guide are given bare — `development/creating-components.md` — so they resolve three ways: as a key in the index, as a file in a clone, or at `https://raw.githubusercontent.com/uniweb/docs/main/{path}`.
|
|
76
78
|
|
|
77
|
-
**
|
|
79
|
+
> **Don't confuse the two doc sites.** `www.uniweb.io/docs` is the **developer** documentation — this is the one you want. `docs.uniweb.app` is the *author-facing* app documentation, written for non-technical people using the visual editor; it won't answer framework questions.
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
**The table below saves you the round trip** for the tasks that come up most:
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
| Task | Page |
|
|
84
|
+
|------|------|
|
|
85
|
+
| Writing page content | `authoring/writing-content.md` |
|
|
86
|
+
| Theming and styling | `authoring/theming.md` |
|
|
87
|
+
| Authoring collections | `authoring/collections.md` |
|
|
88
|
+
| Where-object predicate format | `authoring/predicates.md` |
|
|
89
|
+
| Connecting a backend / custom transports | `development/connecting-a-backend.md` |
|
|
90
|
+
| Building components | `development/creating-components.md` |
|
|
91
|
+
| Schemas in practice | `development/schemas-in-practice.md` |
|
|
92
|
+
| Workspace layouts and their wiring | `development/project-structures.md` |
|
|
93
|
+
| Migrating an existing design | `development/converting-existing.md` |
|
|
94
|
+
| Internationalization (full model) | `development/internationalization.md` |
|
|
95
|
+
| Kit API (hooks, components) | `reference/kit-reference.md` |
|
|
96
|
+
| Content shape reference | `reference/content-structure.md` |
|
|
97
|
+
| Component metadata (`meta.js`) | `reference/component-metadata.md` |
|
|
98
|
+
| Site configuration | `reference/site-configuration.md` |
|
|
99
|
+
| Data fetching model | `reference/data-fetching.md` |
|
|
100
|
+
| Navigation patterns | `reference/navigation-patterns.md` |
|
|
101
|
+
|
|
102
|
+
For anything not in that table, start at the index. For CLI flags, prefer `uniweb <command> --help` over any of this — it's always current.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Part 1 — Starting a project
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
**Always use the CLI to scaffold — never hand-write `package.json`, `vite.config.js`, `entry.js`, or `index.html`.** The CLI resolves correct versions and structure; hand-written config is the most common way to end up with a project that can't build.
|
|
88
109
|
|
|
89
110
|
```bash
|
|
90
|
-
uniweb
|
|
91
|
-
pnpm install
|
|
111
|
+
npx uniweb create my-project --template marketing
|
|
112
|
+
cd my-project && pnpm install
|
|
113
|
+
uniweb dev
|
|
92
114
|
```
|
|
93
115
|
|
|
94
|
-
|
|
116
|
+
**Choosing a template.** `--template <name>` gives you a working site plus a foundation you can study and edit. `--template none` gives you the same two packages with no content — the right choice when you're building a foundation from scratch or porting a design. `--blank` gives you an empty workspace and assumes you'll add packages with `uniweb add`; use it only if you already know the framework.
|
|
95
117
|
|
|
96
|
-
|
|
118
|
+
Official templates: `marketing` (tokens, insets, grids, multi-line headings), `docs` (sidebar nav, code highlighting), `dynamic` (live API data, loading states), `international` (i18n, collections, multi-locale routing), `store` (product grids, e-commerce), `academic` (publications, timeline, math), `extensions` (multi-foundation, runtime loading).
|
|
97
119
|
|
|
98
|
-
|
|
120
|
+
**npm or pnpm.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command in this guide.
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
uniweb add foundation # No name → ./src/ (package: src)
|
|
102
|
-
uniweb add foundation ui # Bare name → ./ui/ (package: ui)
|
|
103
|
-
uniweb add foundation foundations/effects # Slash → folder is the path (package: effects)
|
|
104
|
-
uniweb add foundation marketing --path libs # name + parent → ./libs/marketing/ (package: marketing)
|
|
122
|
+
### What you get
|
|
105
123
|
|
|
106
|
-
uniweb add site # No name → ./site/ (package: site)
|
|
107
|
-
uniweb add site blog # Bare name → ./blog/ (package: blog)
|
|
108
|
-
uniweb add site sites/store # Slash → folder is the path (package: store)
|
|
109
124
|
```
|
|
125
|
+
my-project/
|
|
126
|
+
├── src/ # the foundation — component developer's domain
|
|
127
|
+
├── site/ # the site — content author's domain
|
|
128
|
+
└── pnpm-workspace.yaml
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site`.
|
|
110
132
|
|
|
111
|
-
|
|
133
|
+
- **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
|
|
134
|
+
- **Site** (`site/`): markdown content and configuration, plus optional collections of structured content and references to external data sources.
|
|
112
135
|
|
|
113
|
-
|
|
136
|
+
**The composition boundary.** Authors compose pages from finished section types — choosing types, writing content, setting params. Developers compose section types from building blocks — importing helpers, using libraries, writing JSX. Two different levels of composition, and the section type is the boundary between them. Don't expose building-block composition to authors; build complete, self-contained section types that handle their own internal structure.
|
|
114
137
|
|
|
115
|
-
###
|
|
138
|
+
### Growing the workspace
|
|
116
139
|
|
|
117
140
|
```bash
|
|
118
|
-
uniweb add
|
|
119
|
-
uniweb add
|
|
141
|
+
uniweb add project docs # co-located pair → docs/src + docs/site
|
|
142
|
+
uniweb add foundation [name|path] [--path parent] # no name → ./src/, bare name → ./name/, slash → that path
|
|
143
|
+
uniweb add site [name|path]
|
|
144
|
+
uniweb add extension <name> [--site <name>] # secondary foundation, wired to a site
|
|
145
|
+
uniweb add section Hero [--foundation ui] # sections/Hero/{index.jsx,meta.js}
|
|
120
146
|
```
|
|
121
147
|
|
|
122
|
-
|
|
148
|
+
The CLI creates exactly the folder you ask for — no silent nesting under `foundations/` or `sites/`. If the target exists or the package name is taken, it stops with a precise error and suggests alternatives (using the same `classifyPackage` logic the build uses, so cross-type collisions are caught: you can't `add site marketing` when a foundation named `marketing` exists).
|
|
123
149
|
|
|
124
|
-
|
|
150
|
+
`uniweb add section` scaffolds a CCA-proper starter, and the dev server picks it up with no build or install.
|
|
125
151
|
|
|
126
|
-
|
|
127
|
-
- `defineFoundationConfig()` in vite.config.js
|
|
128
|
-
- Dependencies pinned to current npm versions
|
|
129
|
-
- `@import "@uniweb/kit/theme-tokens.css"` in styles.css
|
|
152
|
+
### Learn from a working template
|
|
130
153
|
|
|
131
|
-
**
|
|
132
|
-
- `defineSiteConfig()` in vite.config.js
|
|
133
|
-
- `react-router-dom` in devDependencies (required by pnpm strict mode)
|
|
134
|
-
- Standard `start()` call in entry.js
|
|
135
|
-
|
|
136
|
-
## Commands
|
|
154
|
+
**When you're unsure how to implement a pattern — data fetching, i18n, layouts, insets, theming — read a real one instead of guessing.** Install an official template as a second project in your workspace:
|
|
137
155
|
|
|
138
156
|
```bash
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
pnpm build # Build for production
|
|
143
|
-
pnpm preview # Preview production build (SSG + SPA)
|
|
144
|
-
|
|
145
|
-
# Ship a site — `uniweb deploy` asks where, if you haven't chosen yet
|
|
146
|
-
uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
|
|
157
|
+
uniweb add project marketing --from marketing
|
|
158
|
+
pnpm install
|
|
159
|
+
```
|
|
147
160
|
|
|
148
|
-
|
|
149
|
-
uniweb publish # The smart path: bring the foundation along (releasing it to the
|
|
150
|
-
# catalog if its code changed), sync content, and go live
|
|
161
|
+
That creates `marketing/src/` + `marketing/site/` alongside your project. You don't need to build or run it. Read:
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
uniweb deploy --host=<adapter> # Build + upload now, from this machine. Same four, plus
|
|
156
|
-
# s3-cloudfront. Drives that host's CLI (wrangler/netlify/vercel/aws)
|
|
157
|
-
uniweb export # Build dist/ for any static host (no Uniweb account)
|
|
163
|
+
- `marketing/src/sections/` — components with `meta.js`: what content they expect, what params they accept, what presets they define
|
|
164
|
+
- `marketing/site/pages/` — real content files, showing the markdown → component mapping
|
|
165
|
+
- `marketing/site/theme.yml` and `site.yml` — theming and configuration in practice
|
|
158
166
|
|
|
159
|
-
|
|
160
|
-
uniweb add ci --target foundation # GitHub Pages workflow → foundations/<name>/<version>/entry.js
|
|
167
|
+
This is the highest-bandwidth way to learn the framework, and it works with no network access. Install several if you like; each is independent. To run one: `cd marketing/site && pnpm dev`.
|
|
161
168
|
|
|
162
|
-
|
|
163
|
-
uniweb push # Push local content to the backend (creates the site on first push)
|
|
164
|
-
uniweb pull # Pull backend content back to local files
|
|
165
|
-
uniweb clone <site-uuid> # Start a new local project from a site already in the backend
|
|
166
|
-
uniweb status # Show what's unpushed / out of sync (local, offline)
|
|
169
|
+
### The loop
|
|
167
170
|
|
|
168
|
-
|
|
169
|
-
uniweb
|
|
170
|
-
|
|
171
|
+
```bash
|
|
172
|
+
uniweb dev # from the workspace root; it picks the site for you
|
|
173
|
+
pnpm build # production build (SSG + SPA)
|
|
174
|
+
pnpm preview # preview the production build
|
|
175
|
+
```
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
|
|
174
|
-
uniweb validate # Check file-based data against your declared schemas (--strict for CI)
|
|
175
|
-
uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix
|
|
176
|
-
# (--dry-run to preview, --yes for non-interactive)
|
|
177
|
+
Markdown, `theme.yml`, and component edits hot-reload. New section types are picked up without a restart.
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
uniweb --help # Top-level help
|
|
180
|
-
uniweb <command> --help # Per-command help (no side effects)
|
|
181
|
-
```
|
|
179
|
+
---
|
|
182
180
|
|
|
183
|
-
|
|
181
|
+
## Part 2 — Working in an existing project
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
The rest of this guide explains how Uniweb works. This part is what to do *first* when you've been handed a project that already uses it, plus a task.
|
|
186
184
|
|
|
187
|
-
|
|
185
|
+
### 1. Orient — what kind of workspace is this?
|
|
188
186
|
|
|
189
|
-
**
|
|
187
|
+
**There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
|
|
190
188
|
|
|
191
|
-
|
|
189
|
+
| Globs | Layout | What it implies |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `src`, `site` | **single** (the default) | foundation in `src/`, site in `site/` |
|
|
192
|
+
| `foundations/*`, `sites/*` | **segregated** | several sites may share one foundation — a foundation edit hits all of them |
|
|
193
|
+
| `*/src`, `*/site` | **co-located projects** | one self-contained pair per project: `docs/src` + `docs/site` |
|
|
194
|
+
| `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
|
|
192
195
|
|
|
193
|
-
|
|
196
|
+
Those names are convention; the globs are the truth. Full guide with the wiring for each: `development/project-structures.md`.
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
**A site is any package with a `site.yml` and a `pages/` folder** — a workspace can hold several. Everything below calls yours `<site>/`.
|
|
196
199
|
|
|
197
|
-
|
|
200
|
+
**Find the foundation by following the wiring, not by guessing the folder.** `foundation:` in `site.yml` is a **package name**, not a path, and folder name matches package name only by convention — co-located projects break it deliberately (folder `docs/src`, package `docs-src`). Go from name to folder via the matching `file:` dependency in `<site>/package.json`:
|
|
198
201
|
|
|
199
202
|
```json
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"version": "1.0.0",
|
|
203
|
-
"uniweb": {
|
|
204
|
-
"id": "marketing",
|
|
205
|
-
"runtimePolicy": "auto-minor"
|
|
206
|
-
},
|
|
207
|
-
"dependencies": {
|
|
208
|
-
"@uniweb/core": "0.7.8",
|
|
209
|
-
"@uniweb/runtime": "0.8.9"
|
|
210
|
-
}
|
|
211
|
-
}
|
|
203
|
+
// docs/site/package.json → the foundation is at docs/src/
|
|
204
|
+
{ "dependencies": { "docs-src": "file:../src" } }
|
|
212
205
|
```
|
|
213
206
|
|
|
214
|
-
|
|
215
|
-
|---|---|---|---|
|
|
216
|
-
| `id` | `uniweb register` | (the bare segment of a scoped `package.json::name`, or `uniweb.id`) | The foundation's registered id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. |
|
|
217
|
-
| `namespace` | `uniweb register` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
|
|
218
|
-
| `runtimePolicy` | `dist/runtime-pin.json` (foundation build) | `"auto-minor"` | Controls how sites using this foundation receive runtime updates. Three values: `"exact"`, `"auto-patch"`, `"auto-minor"`. See "Foundation runtime policy" below. |
|
|
219
|
-
|
|
220
|
-
**How a foundation reaches the catalog.** Foundations on Uniweb hosting always live in the catalog as `@org/name@version`. Two ways to get one there, both from the same `dist/entry.js` artifact:
|
|
207
|
+
If `foundation:` isn't a workspace package name, the source isn't in this repo at all:
|
|
221
208
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
209
|
+
| `foundation:` value | What it means | Can you add a section type? |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| a workspace package name (`src`, `docs-src`, `marketing`) | source is in this repo — follow the `file:` dep to it | **Yes** |
|
|
212
|
+
| a versioned registry ref (`@org/name@1.2.3`) | published; **its source is not in this repo** | **No** — work within the types it offers |
|
|
213
|
+
| an `https://…` URL, or `{ url: … }` | same, loaded from that URL | **No** |
|
|
226
214
|
|
|
227
|
-
|
|
215
|
+
A versionless `@org/name` is an error rather than a shorthand — the build rejects it and asks for a version.
|
|
228
216
|
|
|
229
|
-
|
|
217
|
+
> **Foundations are never npm packages.** They're runtime federated modules, not libraries. Don't `npm install` one, and don't add one to `dependencies` by hand. The four supported shapes are exactly the ones above: a workspace sibling, a `file:` dependency, a versioned registry ref, or a full URL.
|
|
230
218
|
|
|
231
|
-
|
|
219
|
+
**Check `paths:` in `site.yml` before going looking for content.** It mounts outside directories into the page tree (`paths: { pages/docs: ../../../docs }`), so a route's markdown may live in another repo or a submodule rather than under `<site>/pages/`.
|
|
232
220
|
|
|
233
|
-
|
|
221
|
+
### 2. Learn this project's vocabulary — before you write anything
|
|
234
222
|
|
|
235
|
-
|
|
223
|
+
**A foundation is a fixed vocabulary of section types, and every project's is different.** Nothing in the framework tells you what a given project offers; you read it — in the foundation folder you resolved in step 1, not a guessed `src/`:
|
|
236
224
|
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"name": "@uniweb/votiverse",
|
|
241
|
-
"version": "0.1.1",
|
|
242
|
-
"uniweb": {
|
|
243
|
-
"runtimePolicy": "auto-minor"
|
|
244
|
-
},
|
|
245
|
-
"dependencies": {
|
|
246
|
-
"@uniweb/core": "0.7.8"
|
|
247
|
-
}
|
|
248
|
-
}
|
|
225
|
+
```bash
|
|
226
|
+
ls <foundation>/sections/ # the section types available
|
|
227
|
+
cat <foundation>/sections/Hero/meta.js # what one expects and accepts
|
|
249
228
|
```
|
|
250
229
|
|
|
251
|
-
|
|
230
|
+
**Extensions add to that vocabulary.** If `site.yml` carries an `extensions:` list, each entry is a second foundation contributing its own section types, usable by name in exactly the same way. Enumerate their `sections/` too. The primary foundation wins on a name collision, and extensions are checked in declared order.
|
|
252
231
|
|
|
253
|
-
|
|
254
|
-
|---|---|
|
|
255
|
-
| `exact` | The site stays on exactly the runtime version this foundation built against. Newer runtime versions are not auto-applied. |
|
|
256
|
-
| `auto-patch` | The site auto-updates within the same `MAJOR.MINOR.x` (e.g. `0.8.9` → `0.8.10`). Conservative; matches typical npm patch semantics. |
|
|
257
|
-
| `auto-minor` | The site auto-updates within the same `MAJOR.x.y` (e.g. `0.8.9` → `0.9.0`). |
|
|
258
|
-
|
|
259
|
-
**Default when unset:** `auto-minor`. Most foundations don't need to set this field — the platform's runtime is internally backwards-compatible at the minor level by convention, and `auto-minor` lets your foundation's sites pick up bug fixes and additive features without rebuilding the foundation.
|
|
232
|
+
Each `meta.js` is a catalog entry: `description` (what the type is for), `content:` (what markdown it expects), `params:` (what frontmatter it accepts, with defaults), `presets:` (named param bundles). Read them as a menu — that is what they are. There is no CLI command that lists them; reading the folder *is* the discovery step.
|
|
260
233
|
|
|
261
|
-
|
|
234
|
+
> **Never write a `type:` or a param you haven't confirmed exists.** Both failures are silent (Part 0) — invisible from the terminal, visible only on the page.
|
|
262
235
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
```json
|
|
266
|
-
// dist/runtime-pin.json (auto-generated)
|
|
267
|
-
{ "runtime": "0.8.9", "policy": "auto-minor" }
|
|
268
|
-
```
|
|
236
|
+
### 3. Find your lane
|
|
269
237
|
|
|
270
|
-
|
|
238
|
+
The architecture exists to keep content and code separate. Your task sits in one of them. Decide before you edit, then stay there.
|
|
271
239
|
|
|
272
|
-
|
|
240
|
+
| Task | Lane | Files you touch |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| Add / edit / reorder a section on a page | content | `<site>/pages/**` |
|
|
243
|
+
| Add a page | content | `<site>/pages/<name>/` |
|
|
244
|
+
| Change colors, fonts, light/dark | content | `<site>/theme.yml` |
|
|
245
|
+
| Change header, footer, or nav content | content | `<site>/layout/*.md`, or page order in `site.yml` |
|
|
246
|
+
| Change how a section type *looks* everywhere | foundation | `<foundation>/sections/<Type>/` |
|
|
247
|
+
| Add a new section type | foundation | `<foundation>/sections/<NewType>/` |
|
|
248
|
+
| Expose a new knob to authors | foundation | `<foundation>/sections/<Type>/meta.js` + the component |
|
|
273
249
|
|
|
274
|
-
|
|
250
|
+
**If you're on a content task and find yourself wanting to open the foundation, stop.** Usually it means you missed a param the section type already exposes — re-read its `meta.js`. If the knob genuinely doesn't exist, that's a foundation change: say so explicitly rather than quietly crossing the boundary. Editing a section type changes it for every page that uses it — and in a segregated workspace, for **every site in the repo sharing that foundation**, which is exactly the layout chosen when a foundation is the primary deliverable. Check who else depends on it before you edit.
|
|
275
251
|
|
|
276
|
-
|
|
252
|
+
### 4. The loop
|
|
277
253
|
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
└─ devDependencies: "@uniweb/build": "0.12.0"
|
|
281
|
-
└─ pulls in @uniweb/runtime (whatever version that build version locks)
|
|
282
|
-
→ resolved at install time → version goes into dist/runtime-pin.json
|
|
254
|
+
```bash
|
|
255
|
+
uniweb dev # from the workspace root; it picks the site
|
|
283
256
|
```
|
|
284
257
|
|
|
285
|
-
|
|
258
|
+
**When you're unsure what shape your markdown produces, don't reason about it — run it.** From the *site* directory (paths resolve against your shell's working directory):
|
|
286
259
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
260
|
+
```bash
|
|
261
|
+
uniweb inspect pages/home/hero.md # the parsed content shape
|
|
262
|
+
uniweb inspect pages/home/ # every section on the page
|
|
263
|
+
uniweb inspect pages/home/hero.md --full # include empty fields (matches runtime)
|
|
264
|
+
uniweb inspect pages/home/hero.md --sequence # include the sequence array
|
|
265
|
+
uniweb inspect pages/home/hero.md --raw # the ProseMirror AST
|
|
266
|
+
```
|
|
291
267
|
|
|
292
|
-
|
|
268
|
+
This settles any question in Part 3 — whether a heading became a subtitle or an item, whether the parser saw your data block, why `content.title` is an array. One command beats re-deriving the rules.
|
|
293
269
|
|
|
294
|
-
|
|
270
|
+
### 5. Recipes
|
|
295
271
|
|
|
296
|
-
|
|
297
|
-
|---|---|
|
|
298
|
-
| **`uniweb.runtimePolicy` not set in `package.json`** | `dist/runtime-pin.json` is emitted with the runtime version but no `policy` field. At serve time the platform applies `auto-minor` as the implicit default. Most foundations don't need to set `runtimePolicy` — leaving it unset is the correct choice when you want default behavior. |
|
|
299
|
-
| **`@uniweb/runtime` not resolvable at build time** | The build silently skips emitting `runtime-pin.json`. This was the pre-Strategy-S behavior — your foundation falls back to the legacy self-contained build path. New foundations created with `npx uniweb create` always have `@uniweb/runtime` as a dependency, so this only affects unusual workspace setups. |
|
|
300
|
-
| **`runtime-pin.json` is missing or malformed** | The platform's edge dispatcher detects the absence and serves the foundation through the legacy bundling path (the foundation's own `ssr-worker-bundle.js` is used). Your sites still work; they just don't participate in runtime propagation. |
|
|
301
|
-
| **`runtime-pin.json` has a `runtime` version that's not actually deployed to the platform** | The site publish flow rejects the publish with a clear error message asking you to deploy the pinned runtime version first. This is caught at publish time, not at serve time. |
|
|
302
|
-
| **You set `policy: "auto-minor"` but no compatible newer version exists** | The site stays on the version you pinned. The resolver only moves forward when a newer version satisfying the policy is actually available. |
|
|
272
|
+
Paths are relative to the site package you identified in step 1 — `site/` in the default layout, `sites/main/` or `docs/site/` in others.
|
|
303
273
|
|
|
304
|
-
|
|
274
|
+
**Add a section to a page.** `ls <site>/pages/<page>/` to see the existing sections and their numeric prefixes → pick a `type:` from the vocabulary (step 2) → create `<site>/pages/<page>/N-name.md` with frontmatter plus markdown. Use a decimal (`2.5-…`) to slot between existing sections without renaming them.
|
|
305
275
|
|
|
306
|
-
|
|
276
|
+
**Add a page.** Create `<site>/pages/<name>/`, add `page.yml` (at minimum `title:`), add one or more section `.md` files. It gets the route `/<name>` automatically. Only touch `pages:` in the parent `page.yml` / `site.yml` if you need a specific order.
|
|
307
277
|
|
|
308
|
-
|
|
278
|
+
**Change the brand color.** `<site>/theme.yml` → `colors.primary`. One line, and every component follows. Do **not** edit Tailwind color classes in components to change brand color — that is the exact anti-pattern this system removes.
|
|
309
279
|
|
|
310
|
-
|
|
280
|
+
**Change a nav item.** First check how nav is produced. If `<site>/layout/header.md` lists the links (a markdown list, or a `yaml:nav` block), edit it there. If it doesn't, the Header is generating nav from the page hierarchy — change page titles and order in `site.yml` / `page.yml` instead.
|
|
311
281
|
|
|
312
|
-
|
|
282
|
+
**Update the project's Uniweb dependencies — and this file.** `uniweb update`. One command aligns every `@uniweb/*` dependency *and* refreshes this AGENTS.md together, to the version matrix of the CLI that runs it. Preview with `--dry-run`. **Don't reach for `npm update` / `pnpm update`** — see *Staying current* in Part 5 for why that breaks things quietly.
|
|
313
283
|
|
|
314
|
-
**
|
|
284
|
+
**Change one section's columns / spacing / variant.** Check that type's `meta.js` `params:` first. If the knob exists, set it in that section's frontmatter and you're done, in the content lane. If it doesn't, it's a foundation change — see the warning in step 3.
|
|
315
285
|
|
|
316
|
-
|
|
317
|
-
# Site Name ← title — always start with the heading
|
|
318
|
-
 ← icon — component controls where this renders
|
|
319
|
-
```
|
|
286
|
+
---
|
|
320
287
|
|
|
321
|
-
|
|
288
|
+
## Part 3 — The content lane
|
|
322
289
|
|
|
323
|
-
### Section
|
|
290
|
+
### Section format
|
|
324
291
|
|
|
325
292
|
Each `.md` file is a section. Frontmatter on top, content below:
|
|
326
293
|
|
|
@@ -334,7 +301,7 @@ theme: dark
|
|
|
334
301
|
|
|
335
302
|
# Build the system. ← title (the big headline)
|
|
336
303
|
|
|
337
|
-
## Not every page.
|
|
304
|
+
## Not every page. ← subtitle
|
|
338
305
|
|
|
339
306
|
Description paragraph.
|
|
340
307
|
|
|
@@ -343,11 +310,15 @@ Description paragraph.
|
|
|
343
310
|

|
|
344
311
|
```
|
|
345
312
|
|
|
346
|
-
|
|
313
|
+
Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing.
|
|
314
|
+
|
|
315
|
+
**Markdown order ≠ rendering order.** The parser extracts content into a flat structure; the component decides how to arrange it visually. Write markdown in semantic order, not visual order — start with the heading, then add icons, images, and text in any order.
|
|
347
316
|
|
|
348
|
-
|
|
317
|
+
**Placing content *before* the first heading changes the parse:** headings after body content become items, not the section title. This is by design — it's how repeating content groups are created.
|
|
349
318
|
|
|
350
|
-
The
|
|
319
|
+
### The parsed object
|
|
320
|
+
|
|
321
|
+
The semantic parser produces a flat, guaranteed structure. No null checks needed — empty strings and arrays when content is absent:
|
|
351
322
|
|
|
352
323
|
```js
|
|
353
324
|
content = {
|
|
@@ -360,7 +331,7 @@ content = {
|
|
|
360
331
|
icons: [], // { library, name, role }
|
|
361
332
|
videos: [], // { src, alt, role, poster, href }
|
|
362
333
|
insets: [], // Inline @Component references — { refId }
|
|
363
|
-
lists: [], // [[{ paragraphs, links, lists,
|
|
334
|
+
lists: [], // [[{ paragraphs, links, lists, … }]] — each item is an object, not a string
|
|
364
335
|
quotes: [], // Blockquotes
|
|
365
336
|
snippets: [], // Fenced code — [{ language, code }]
|
|
366
337
|
data: {}, // From tagged data blocks (```yaml:tagname, ```json:tagname)
|
|
@@ -370,53 +341,16 @@ content = {
|
|
|
370
341
|
}
|
|
371
342
|
```
|
|
372
343
|
|
|
373
|
-
**
|
|
374
|
-
|
|
375
|
-
```markdown
|
|
376
|
-
# Our Features ← title
|
|
377
|
-
|
|
378
|
-
We built this for you. ← paragraph
|
|
379
|
-
|
|
380
|
-
### Fast ← items[0].title
|
|
381
|
-
 ← items[0].icons[0]
|
|
382
|
-
Lightning quick. ← items[0].paragraphs[0]
|
|
383
|
-
|
|
384
|
-
### Secure ← items[1].title
|
|
385
|
-
 ← items[1].icons[0]
|
|
386
|
-
Enterprise-grade. ← items[1].paragraphs[0]
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
**Items have the full content shape** — this is the most commonly overlooked feature. Each item has `title`, `pretitle`, `subtitle`, `paragraphs`, `links`, `icons`, `lists`, `snippets`, and even `data` (tagged data blocks). You don't need workarounds for structured content within items:
|
|
390
|
-
|
|
391
|
-
```markdown
|
|
392
|
-
### The Problem ← items[0].pretitle
|
|
393
|
-
## Content gets trapped ← items[0].title
|
|
394
|
-
Body text here. ← items[0].paragraphs[0]
|
|
395
|
-
|
|
396
|
-
### The Solution ← items[1].pretitle
|
|
397
|
-
## Separate content from code ← items[1].title
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
If you need an eyebrow label above an item's title, that's `pretitle` — the same heading hierarchy as the top level. Heading hierarchy within items follows the same rules — `####` within a `###` item becomes `items[0].subtitle`. If you need metadata per item, use a tagged block inside the item:
|
|
401
|
-
|
|
402
|
-
````markdown
|
|
403
|
-
### Starter ← items[0].title
|
|
404
|
-
$9/month ← items[0].paragraphs[0]
|
|
405
|
-
|
|
406
|
-
```yaml:details
|
|
407
|
-
trial: 14 days
|
|
408
|
-
seats: 1
|
|
409
|
-
``` ← items[0].data.details = { trial: "14 days", seats: 1 }
|
|
410
|
-
````
|
|
344
|
+
> **Don't predict this — print it.** `uniweb inspect <path>` shows the actual parsed shape of any section or page. The rules below are the ones most often gotten wrong from memory.
|
|
411
345
|
|
|
412
|
-
|
|
346
|
+
### Markdown → content, side by side
|
|
413
347
|
|
|
414
348
|
```markdown
|
|
415
349
|
### Eyebrow │ content.pretitle = "Eyebrow"
|
|
416
350
|
# Our Features │ content.title = "Our Features"
|
|
417
351
|
## Build better products │ content.subtitle = "Build better products"
|
|
418
352
|
│
|
|
419
|
-
We help teams ship faster. │ content.paragraphs[0] = "We help teams
|
|
353
|
+
We help teams ship faster. │ content.paragraphs[0] = "We help teams…"
|
|
420
354
|
│
|
|
421
355
|
[Get Started](/start) │ content.links[0] = { href: "/start", label: "Get Started" }
|
|
422
356
|
│
|
|
@@ -426,109 +360,105 @@ Lightning quick. │ content.items[0].paragraphs[0] = "Lightning
|
|
|
426
360
|
│
|
|
427
361
|
### Secure │ content.items[1].title = "Secure"
|
|
428
362
|
 │ content.items[1].icons[0] = { library: "lu", name: "shield" }
|
|
429
|
-
Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade
|
|
363
|
+
Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade…"
|
|
430
364
|
```
|
|
431
365
|
|
|
432
|
-
|
|
366
|
+
The three rules that produce this: headings *before* the main title become `pretitle`; a heading *after* the title at lower importance becomes `subtitle`; headings appearing *after body content* start the `items` array.
|
|
367
|
+
|
|
368
|
+
### Items have the full content shape
|
|
369
|
+
|
|
370
|
+
This is the most commonly overlooked feature. Each item has `title`, `pretitle`, `subtitle`, `paragraphs`, `links`, `icons`, `lists`, `snippets`, and `data` — you don't need workarounds for structured content inside items. Heading hierarchy within an item follows the same rules (`####` inside a `###` item becomes that item's `subtitle`).
|
|
371
|
+
|
|
372
|
+
````markdown
|
|
373
|
+
### Starter ← items[0].title
|
|
374
|
+
$9/month ← items[0].paragraphs[0]
|
|
375
|
+
|
|
376
|
+
```yaml:details
|
|
377
|
+
trial: 14 days
|
|
378
|
+
seats: 1
|
|
379
|
+
``` ← items[0].data.details = { trial: "14 days", seats: 1 }
|
|
380
|
+
````
|
|
381
|
+
|
|
382
|
+
### Subtitle vs items — the level rule
|
|
433
383
|
|
|
434
|
-
|
|
384
|
+
A heading immediately after the title becomes `subtitle` **only when it is exactly one level deeper** (H1→H2, H2→H3). Skipping levels (H1→H3) breaks the group and the deeper heading starts items instead. To get items with no subtitle, close the title group with a `---` divider or a paragraph:
|
|
435
385
|
|
|
436
386
|
```markdown
|
|
437
387
|
# Our Stats │ content.title = "Our Stats"
|
|
438
|
-
---
|
|
388
|
+
--- │ ← divider closes the title group
|
|
439
389
|
## 15,000+ │ content.items[0].title = "15,000+"
|
|
440
390
|
Students from 90 countries │ content.items[0].paragraphs[0]
|
|
441
|
-
|
|
391
|
+
│
|
|
442
392
|
## 200+ │ content.items[1].title = "200+"
|
|
443
393
|
Programs offered │ content.items[1].paragraphs[0]
|
|
444
394
|
```
|
|
445
395
|
|
|
446
|
-
Without the `---`, `## 15,000+` would become `content.subtitle
|
|
396
|
+
Without the `---`, `## 15,000+` would become `content.subtitle`.
|
|
397
|
+
|
|
398
|
+
### Multi-line headings
|
|
399
|
+
|
|
400
|
+
Consecutive headings at the same level merge into a title array — one heading split across visual lines. Kit's `<H1>`, `<H2>`, … render arrays as a single tag with line breaks.
|
|
401
|
+
|
|
402
|
+
```markdown
|
|
403
|
+
# Build the future │ content.title = ["Build the future", "with confidence"]
|
|
404
|
+
# with confidence │
|
|
405
|
+
|
|
406
|
+
# Build the future │ content.title = [
|
|
407
|
+
# [with confidence]{accent} │ "Build the future",
|
|
408
|
+
│ "<span accent=\"true\">with confidence</span>"
|
|
409
|
+
│ ]
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Rule:** same-level continuation only applies *before* going deeper. Once a subtitle level is reached, same-level headings start new items instead of merging. Use `---` to force separate items where headings would otherwise merge.
|
|
447
413
|
|
|
448
414
|
### Sequential content
|
|
449
415
|
|
|
450
|
-
`content.sequence` is the flat, ordered list of all elements before any grouping. Each element has a `type` (`heading`, `paragraph`, `image`, `codeBlock`, `dataBlock`, `list`, `link`, `divider`, `inset`,
|
|
416
|
+
`content.sequence` is the flat, ordered list of all elements before any grouping. Each element has a `type` (`heading`, `paragraph`, `image`, `codeBlock`, `dataBlock`, `list`, `link`, `divider`, `inset`, …) plus type-specific fields. Use it when grouping isn't the right lens — rendering prose in document order, or finding elements regardless of which group they landed in:
|
|
451
417
|
|
|
452
418
|
```js
|
|
453
|
-
// All data blocks, regardless of heading groups
|
|
454
419
|
const allData = {}
|
|
455
420
|
for (const el of content.sequence) {
|
|
456
421
|
if (el.type === 'dataBlock') allData[el.tag] = el.data
|
|
457
422
|
}
|
|
458
|
-
|
|
459
|
-
// All headings in order
|
|
460
|
-
const headings = content.sequence.filter(e => e.type === 'heading')
|
|
461
423
|
```
|
|
462
424
|
|
|
463
|
-
|
|
425
|
+
Grouped fields and `sequence` are two interpretations of the same content. Grouped suits structured layouts (cards, features); sequential suits prose and cross-group searching.
|
|
464
426
|
|
|
465
427
|
### Choosing how to model content
|
|
466
428
|
|
|
467
|
-
|
|
429
|
+
The decision rule: **would a content author need to change this?** Yes → markdown, frontmatter, or a tagged data block. No → component code.
|
|
468
430
|
|
|
469
|
-
|
|
431
|
+
Start with the content, not the component. Write the markdown an author would naturally write, check what shape the parser produces (`uniweb inspect <file>`), *then* build the component to receive it. You have three layers, and most of the design skill is choosing between them:
|
|
470
432
|
|
|
471
|
-
**
|
|
433
|
+
**Pure markdown** — headings, paragraphs, links, images, lists, items. The default. If the content reads naturally as markdown and the parser captures it, stop here.
|
|
472
434
|
|
|
473
|
-
**
|
|
474
|
-
|
|
475
|
-
Read the markdown out loud. If a content author would understand what every line does and how to edit it, you've chosen the right layer. The moment markdown feels like it's encoding data rather than expressing content, step up to a tagged block — that's fine. A well-documented `yaml:pricing` block is better than a markdown structure that puzzles the author.
|
|
476
|
-
|
|
477
|
-
**You are designing these, not choosing from a menu.** The examples in this guide illustrate patterns, not exhaustive inventories. Any param name works in `meta.js`. Any tag name works for data blocks. Any section type name works. The framework has fixed mechanisms (the content shape, the context modes, the token system); nearly everything else is yours to define.
|
|
478
|
-
|
|
479
|
-
```js
|
|
480
|
-
// You design this — it's not a fixed schema
|
|
481
|
-
export default {
|
|
482
|
-
params: {
|
|
483
|
-
columns: { type: 'number', default: 3 },
|
|
484
|
-
cardStyle: { type: 'select', options: ['minimal', 'bordered', 'elevated'], default: 'minimal' },
|
|
485
|
-
showIcon: { type: 'boolean', default: true },
|
|
486
|
-
maxItems: { type: 'number', default: 6 },
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
```
|
|
435
|
+
**Frontmatter params** — `columns: 3`, `variant: centered`. Configuration an author might change but that isn't *content*. Would changing this alter the section's *meaning* or just its *presentation*? Presentation → param. Meaning → content.
|
|
490
436
|
|
|
491
|
-
|
|
492
|
-
<!-- You invent the tag name — the framework parses it -->
|
|
493
|
-
```yaml:speakers
|
|
494
|
-
- name: Ada Lovelace
|
|
495
|
-
role: Keynote
|
|
496
|
-
topic: The Future of Computing
|
|
497
|
-
```
|
|
498
|
-
````
|
|
499
|
-
Access: `content.data?.speakers` — an array of objects. You defined this. The framework parsed it.
|
|
437
|
+
**Tagged data blocks** — for content that doesn't fit markdown patterns: products with SKUs, team members with roles, event schedules, form definitions. When information is genuinely structured data that an author still owns, a well-named block (`yaml:pricing`, `yaml:speakers`) is clearer than contorting markdown. Formats: `yaml` and `json`. Parsed at build time into `content.data.tagName`.
|
|
500
438
|
|
|
501
|
-
|
|
439
|
+
Read the markdown out loud. If an author would understand what every line does, you've chosen right. The moment markdown feels like it's encoding data rather than expressing content, step up to a tagged block.
|
|
502
440
|
|
|
503
|
-
|
|
441
|
+
**When you are building the foundation, you are designing these, not choosing from a menu.** The examples in this guide illustrate patterns, not exhaustive inventories. Any param name works in `meta.js`, any tag name works for data blocks, any section type name works. The framework has fixed mechanisms (content shape, context modes, token system); nearly everything else is yours to define.
|
|
504
442
|
|
|
505
|
-
|
|
443
|
+
**When you are writing content against a foundation that already exists, the opposite holds.** The vocabulary is fixed and finite: the section types are exactly what `sections/**` declares, and their params are exactly what each `meta.js` lists. Inventing a name there doesn't extend the system — it renders a red `Component not found` box on a build that otherwise reports success.
|
|
506
444
|
|
|
507
|
-
|
|
508
|
-
# Build the future │ content.title = ["Build the future", "with confidence"]
|
|
509
|
-
# with confidence │
|
|
510
|
-
```
|
|
445
|
+
**Parameter naming matters.** Would an author understand it without reading code? `columns: 3` yes, `gridCols: 3` no. `variant: centered` yes, `renderMode: flex-center` no. `align: left` yes, `contentAlignment: flex-start` no.
|
|
511
446
|
|
|
512
|
-
|
|
447
|
+
### Icons
|
|
513
448
|
|
|
514
|
-
|
|
449
|
+
Image syntax with a library prefix — **two interchangeable spellings, the same everywhere** (markdown, and Kit's `<Icon name>`):
|
|
515
450
|
|
|
516
451
|
```markdown
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
│ ]
|
|
452
|
+
 colon + friendly name — every library
|
|
453
|
+
 colon + short code
|
|
454
|
+
 dash — short codes only, to stay unambiguous with ordinary paths
|
|
521
455
|
```
|
|
522
456
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
### Icons
|
|
526
|
-
|
|
527
|
-
Use image syntax with library prefix: ``. Supported libraries: `lu` (Lucide), `hi2` (Heroicons), `fi` (Feather), `pi` (Phosphor), `tb` (Tabler), `bs` (Bootstrap), `md` (Material), `fa6` (Font Awesome 6), and others. Browse at [react-icons.github.io/react-icons](https://react-icons.github.io/react-icons/).
|
|
457
|
+
Short codes: `lu` (Lucide), `hi` / `hi2` (Heroicons v1 / v2), `fi` (Feather), `pi` (Phosphor), `tb` (Tabler), `bs` (Bootstrap), `md` (Material), `ai` (Ant Design), `ri` (Remix), `si` (Simple Icons), `io5` (Ionicons), `bi` (Boxicons), `vsc` (VS Code), `wi` (Weather), `gi` (Game), `fa` / `fa6` (Font Awesome 5 / 6). Browse at [react-icons.github.io/react-icons](https://react-icons.github.io/react-icons/).
|
|
528
458
|
|
|
529
|
-
Custom SVGs: `{role=icon}`
|
|
459
|
+
Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `{role=icon}` (or ``) — always available, no library needed.
|
|
530
460
|
|
|
531
|
-
### Links and
|
|
461
|
+
### Links and media attributes
|
|
532
462
|
|
|
533
463
|
```markdown
|
|
534
464
|
[text](url){target=_blank} <!-- Open in new tab -->
|
|
@@ -536,32 +466,32 @@ Custom SVGs: `{role=icon}`
|
|
|
536
466
|
{role=banner} <!-- Role determines array: images, icons, or videos -->
|
|
537
467
|
```
|
|
538
468
|
|
|
539
|
-
**Quote values
|
|
469
|
+
**Quote values containing spaces:** `{note="Ready to go"}`, not `{note=Ready to go}` — unquoted values end at the first space.
|
|
540
470
|
|
|
541
|
-
Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags
|
|
471
|
+
Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
|
|
542
472
|
|
|
543
473
|
```markdown
|
|
544
474
|
[Primary](/start) ← standalone → content.links[0]
|
|
545
475
|
[One](/a) [Two](/b) ← links-only paragraph → both in content.links[]
|
|
546
|
-
Check out [this](/a) link. ← inline → stays in paragraphs as <a> tag
|
|
476
|
+
Check out [this](/a) link. ← inline → stays in paragraphs as an <a> tag
|
|
547
477
|
```
|
|
548
478
|
|
|
549
|
-
### Inline
|
|
479
|
+
### Inline text styling
|
|
550
480
|
|
|
551
481
|
```markdown
|
|
552
482
|
# Build [faster]{accent} with structure
|
|
553
483
|
This is [less important]{muted} context.
|
|
554
484
|
```
|
|
555
485
|
|
|
556
|
-
`accent` and `callout` (both accent-colored + bold) and `muted` (subtle) are built-in defaults that adapt to context
|
|
486
|
+
`accent` and `callout` (both accent-colored + bold) and `muted` (subtle) are built-in defaults that adapt to context. `--accent` is your brand color unless you declare a separate `colors.accent`, and resolves to the shade you authored — not a darkened one, since accent is decorative emphasis rather than body-size link text. Components receive HTML strings with spans applied: `<span accent="true">faster</span>`.
|
|
557
487
|
|
|
558
|
-
Sites can adjust these or
|
|
488
|
+
Sites can adjust these or add named styles in `theme.yml`'s `inline:` section. Overrides merge **property by property**, so declare only what differs (`accent: { font-weight: inherit }` keeps the default color); to drop a default property rather than change it, give it a neutral value (`initial`, `inherit`, `unset`).
|
|
559
489
|
|
|
560
|
-
### Fenced
|
|
490
|
+
### Fenced code: data blocks vs snippets
|
|
561
491
|
|
|
562
|
-
Fenced code
|
|
492
|
+
Fenced code serves two purposes depending on whether it carries a tag.
|
|
563
493
|
|
|
564
|
-
**Tagged data blocks** — structured data parsed into JS objects. The format (`yaml`/`json`) is a serialization format, not a display language.
|
|
494
|
+
**Tagged data blocks** — structured data parsed into JS objects. The tag is the key in `content.data`; the format (`yaml`/`yml`/`json`) is a serialization format, not a display language.
|
|
565
495
|
|
|
566
496
|
````markdown
|
|
567
497
|
```yaml:form
|
|
@@ -572,354 +502,552 @@ submitLabel: Send
|
|
|
572
502
|
```
|
|
573
503
|
````
|
|
574
504
|
|
|
575
|
-
|
|
505
|
+
→ `content.data?.form` = `{ fields: [...], submitLabel: "Send" }`
|
|
576
506
|
|
|
577
|
-
**Code snippets** — display content with a language for syntax highlighting
|
|
507
|
+
**Code snippets** — display content with a language for syntax highlighting, collected in `content.snippets` as `[{ language, code }]`. Filter with `content.snippets.filter(s => s.language === 'css')`.
|
|
578
508
|
|
|
579
|
-
|
|
580
|
-
```jsx
|
|
581
|
-
function Hello() {
|
|
582
|
-
return <h1>Hello world</h1>
|
|
583
|
-
}
|
|
584
|
-
```
|
|
585
|
-
````
|
|
509
|
+
Both appear in `content.sequence`. `<Prose>` handles the difference automatically — it renders snippets with highlighting and skips tagged data blocks, which components access separately via `content.data`.
|
|
586
510
|
|
|
587
|
-
|
|
511
|
+
### Math (LaTeX)
|
|
588
512
|
|
|
589
|
-
|
|
513
|
+
Authors write LaTeX directly; it compiles to MathML Core **at build time** — no runtime math library, no extra CSS. Three forms, matching Pandoc / GitHub / VS Code / Jupyter / Obsidian convention:
|
|
590
514
|
|
|
591
|
-
|
|
515
|
+
| Form | Mode |
|
|
516
|
+
|---|---|
|
|
517
|
+
| `$x^2$` | Inline |
|
|
518
|
+
| `$$x^2$$` | Display (block on its own line, inline display mid-paragraph) |
|
|
519
|
+
| ` ```math ` fence | Display (multi-line friendly) |
|
|
520
|
+
| `\$` | Literal `$` |
|
|
592
521
|
|
|
593
|
-
|
|
522
|
+
**Disambiguation gotcha:** a `$…$` span counts as math only when the body has no whitespace next to the delimiters *and* the closing `$` isn't immediately followed by a digit. So `It costs $5 and $10 total` and `Budget: $200` stay prose without escaping, while `Let $f(x) = 5$ be a function` is math. Use `\$` when the rules would otherwise trip.
|
|
594
523
|
|
|
595
|
-
|
|
524
|
+
Math rides the normal content pipeline — it appears in prerendered HTML, survives `compile('epub')` and `compile('pagedjs')`, and roundtrips through the editor. Component code needs nothing special.
|
|
596
525
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
| `$$x^2$$` | Display (block when on its own line, inline display mid-paragraph) | `$$\sum_{i=1}^n i$$` |
|
|
601
|
-
| ` ```math ` fence | Display (multi-line friendly) | see below |
|
|
602
|
-
| `\$` | Literal `$` | `The price is \$20.` |
|
|
526
|
+
### Lists as navigation menus
|
|
527
|
+
|
|
528
|
+
Markdown lists model nav, menus, and grouped links. Each list item is a full content object with `paragraphs`, `links`, `icons`, and nested `lists`.
|
|
603
529
|
|
|
604
|
-
|
|
530
|
+
```markdown
|
|
531
|
+
-  [Home](/) ← content.lists[0], each item.links[0] + item.icons[0]
|
|
532
|
+
-  [Docs](/docs)
|
|
533
|
+
|
|
534
|
+
- Product ← nested: group.paragraphs[0] is the label,
|
|
535
|
+
- [Features](/features) ← group.lists[0] holds sub-items
|
|
536
|
+
- [Pricing](/pricing)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**For richer navigation** with icons, descriptions, or hierarchy, use a `yaml:nav` tagged block:
|
|
605
540
|
|
|
606
541
|
````markdown
|
|
607
|
-
```
|
|
608
|
-
|
|
542
|
+
```yaml:nav
|
|
543
|
+
- label: Dashboard
|
|
544
|
+
href: /
|
|
545
|
+
icon: lu:layout-grid # same icon spellings as anywhere else —
|
|
546
|
+
- label: Docs # lu:name, lucide:name, lu-name, or an SVG path
|
|
547
|
+
href: /docs
|
|
548
|
+
icon: /icons/book.svg
|
|
549
|
+
children:
|
|
550
|
+
- label: Getting Started
|
|
551
|
+
href: /docs/quickstart
|
|
609
552
|
```
|
|
610
553
|
````
|
|
611
554
|
|
|
612
|
-
|
|
555
|
+
Access: `content.data?.nav` — an array of `{ label, href, icon, text, children, target }`. Components can support both modes: use `content.data?.nav` when provided, fall back to `website.getPageHierarchy()`. Full pattern: `reference/navigation-patterns.md`.
|
|
613
556
|
|
|
614
|
-
|
|
557
|
+
### Section backgrounds
|
|
615
558
|
|
|
616
|
-
|
|
559
|
+
Set `background` in frontmatter — the runtime renders it:
|
|
617
560
|
|
|
618
|
-
|
|
561
|
+
```yaml
|
|
562
|
+
background: /images/hero.jpg # Image
|
|
563
|
+
background: /videos/hero.mp4 # Video
|
|
564
|
+
background: linear-gradient(135deg, #667eea, #764ba2) # Gradient
|
|
565
|
+
background: '#1a1a2e' # Color (hex — quote in YAML)
|
|
566
|
+
background: primary-900 # Palette token (bare name or var())
|
|
619
567
|
|
|
620
|
-
|
|
568
|
+
background: # Object form for more control
|
|
569
|
+
image: { src: /img.jpg, position: center top }
|
|
570
|
+
overlay: { enabled: true, type: dark, opacity: 0.5 }
|
|
571
|
+
```
|
|
621
572
|
|
|
622
|
-
|
|
573
|
+
Components that render their own background declare `background: 'self'` in `meta.js`.
|
|
623
574
|
|
|
624
|
-
|
|
625
|
-
|
|
575
|
+
### Page organization
|
|
576
|
+
|
|
577
|
+
```
|
|
578
|
+
site/layout/ # header.md, footer.md, left.md — rendered on every page
|
|
579
|
+
site/pages/home/
|
|
580
|
+
├── page.yml # title, description, order
|
|
581
|
+
├── 1-hero.md # Numeric prefix sets order
|
|
582
|
+
├── 2-features.md
|
|
583
|
+
└── 3-cta.md
|
|
626
584
|
```
|
|
627
585
|
|
|
628
|
-
|
|
586
|
+
Decimals insert between: `2.5-testimonials.md` goes between `2-` and `3-`. **Ignored:** `README.md`, and `_*.md` / `_*/` (drafts and private files).
|
|
629
587
|
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
|
|
588
|
+
```yaml
|
|
589
|
+
# page.yml
|
|
590
|
+
title: About Us
|
|
591
|
+
id: about # Stable identity (for page: links, survives moves)
|
|
592
|
+
order: 2 # Navigation sort position
|
|
593
|
+
pages: [team, history, ...] # Child page order (... = rest). Without ... = strict (hides unlisted)
|
|
594
|
+
redirect: academic # Redirect to a child page (relative/absolute path, or URL)
|
|
595
|
+
slug: { fr: a-propos } # Localized URL segment per language
|
|
633
596
|
|
|
634
|
-
|
|
597
|
+
# site.yml
|
|
598
|
+
index: home # Just set the homepage
|
|
599
|
+
pages: [home, about, ...] # Order pages (... = rest, first = homepage); without ... = strict
|
|
600
|
+
```
|
|
635
601
|
|
|
636
|
-
**
|
|
602
|
+
**Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
|
|
637
603
|
|
|
638
|
-
|
|
604
|
+
**Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/`.
|
|
639
605
|
|
|
640
|
-
|
|
606
|
+
**Content-less containers:** folders with `page.yml` but no markdown are structural groups (`hasContent: false`). Visiting one auto-redirects to the first descendant with content — this is what supports courses → modules → lessons at any depth.
|
|
641
607
|
|
|
642
|
-
|
|
643
|
-
pages/home/
|
|
644
|
-
├── 2-dashboard.md # type: Grid, columns: "1fr 2fr"
|
|
645
|
-
├── @sidebar-stats.md # type: StatPanel
|
|
646
|
-
└── @main-chart.md # type: PerformanceChart
|
|
647
|
-
```
|
|
608
|
+
**SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). These render into every page's static `<head>` — Open Graph, Twitter Card, canonical, robots — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level.
|
|
648
609
|
|
|
649
610
|
```yaml
|
|
611
|
+
# site.yml
|
|
612
|
+
keywords: [components, react, cms]
|
|
613
|
+
seo:
|
|
614
|
+
image: /og-default.png
|
|
615
|
+
noindex: false # true keeps the WHOLE site out of search
|
|
616
|
+
|
|
650
617
|
# page.yml
|
|
651
|
-
|
|
652
|
-
|
|
618
|
+
seo:
|
|
619
|
+
image: /og-about.png
|
|
620
|
+
canonical: https://acme.com/about
|
|
653
621
|
```
|
|
654
622
|
|
|
655
|
-
|
|
623
|
+
**Localized URLs:** on a multilingual site (`languages:` in site.yml), `slug: { <lang>: <segment> }` gives a page a native URL segment per language; the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap. See Part 5 for the translation workflow.
|
|
656
624
|
|
|
657
|
-
|
|
625
|
+
### Collections and dynamic routes
|
|
658
626
|
|
|
659
|
-
**
|
|
627
|
+
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **Collections are the other kind: repeating content managed as a set of files**, one item per file, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
|
|
660
628
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
629
|
+
They're delivered through the **same data pipeline as remote APIs**, so from a component's point of view a locally-authored collection and a backend-served one look identical. Whether the records live in files or behind an endpoint is a transport concern (see *Fetching from other sources* in Part 4).
|
|
630
|
+
|
|
631
|
+
```
|
|
632
|
+
site/
|
|
633
|
+
├── pages/
|
|
634
|
+
├── collections/
|
|
635
|
+
│ ├── articles/
|
|
636
|
+
│ │ ├── getting-started.md
|
|
637
|
+
│ │ └── design-tips.md
|
|
638
|
+
│ └── team/
|
|
639
|
+
│ └── alice.yml
|
|
640
|
+
└── site.yml
|
|
641
|
+
```
|
|
666
642
|
|
|
667
|
-
|
|
643
|
+
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
668
644
|
|
|
669
|
-
|
|
645
|
+
| Format | Best for | Notes |
|
|
646
|
+
|---|---|---|
|
|
647
|
+
| `.md` | Items with prose — articles, case studies | Frontmatter holds fields, body holds text; auto-excerpt and first-image extraction |
|
|
648
|
+
| `.yml` / `.yaml` | Structured records with no prose body — team, products | Cleaner than a markdown file with an empty body |
|
|
649
|
+
| `.json` | Same as YAML | When the data comes out of another tool |
|
|
650
|
+
| `.bib` | Bibliographies | Each `@entry{key, …}` is a record, cite key is the slug; normalized to CSL-JSON, LaTeX accents converted to Unicode |
|
|
670
651
|
|
|
671
|
-
**
|
|
652
|
+
**One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
|
|
672
653
|
|
|
673
|
-
|
|
674
|
-
{note="Ready to go"}
|
|
675
|
-
```
|
|
676
|
-
→ CommandBlock receives `content.title = "npm create uniweb"` and `params.note = "Ready to go"`.
|
|
654
|
+
**Keep collection folders flat.** `collections/articles/design-tips.md` works; `collections/articles/2025/design-tips.md` does not.
|
|
677
655
|
|
|
678
|
-
|
|
656
|
+
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
|
|
679
657
|
|
|
680
|
-
|
|
658
|
+
**Declare each collection in `site.yml`:**
|
|
681
659
|
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
660
|
+
```yaml
|
|
661
|
+
collections:
|
|
662
|
+
articles: collections/articles # simple form — just point at the folder
|
|
663
|
+
|
|
664
|
+
team: # extended form
|
|
665
|
+
path: collections/team # or `url:` for a remote source
|
|
666
|
+
sort: order asc # `date desc`, `title asc`, …
|
|
667
|
+
where: { published: { ne: false } } # a predicate — see authoring/predicates.md
|
|
668
|
+
limit: 100
|
|
691
669
|
```
|
|
692
670
|
|
|
671
|
+
**Show a collection on a page** with `data:` in `page.yml` (the whole collection), or `fetch:` in a section's frontmatter (a subset):
|
|
672
|
+
|
|
693
673
|
```yaml
|
|
694
|
-
# page.yml
|
|
695
|
-
|
|
696
|
-
|
|
674
|
+
# pages/blog/page.yml | # a section on the homepage
|
|
675
|
+
title: Blog | ---
|
|
676
|
+
data: articles | type: ArticleTeaser
|
|
677
|
+
| fetch: { collection: articles, limit: 3, sort: date desc }
|
|
678
|
+
| ---
|
|
697
679
|
```
|
|
698
680
|
|
|
699
|
-
**
|
|
700
|
-
- `@`-prefixed files are excluded from the top-level section list
|
|
701
|
-
- `nest:` declares parent-child relationships (parent name → child names)
|
|
702
|
-
- `@@` prefix for deeper nesting (grandchildren)
|
|
703
|
-
- `nest:` is flat: `{ features: [a, b], a: [sub-1] }`
|
|
704
|
-
- Children ordered by position in the `nest:` array
|
|
705
|
-
|
|
706
|
-
```jsx
|
|
707
|
-
import { ChildBlocks } from '@uniweb/kit'
|
|
681
|
+
**Give each item its own page with a `[slug]/` folder** under the list page:
|
|
708
682
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
683
|
+
```
|
|
684
|
+
pages/blog/
|
|
685
|
+
├── page.yml # title: Blog / data: articles
|
|
686
|
+
├── list.md
|
|
687
|
+
└── [slug]/
|
|
688
|
+
├── page.yml
|
|
689
|
+
└── article.md # just `type: Article` — the item arrives automatically
|
|
716
690
|
```
|
|
717
691
|
|
|
718
|
-
`
|
|
692
|
+
`collections/articles/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
|
|
719
693
|
|
|
720
|
-
|
|
694
|
+
> **The record arrives as a single-element array under the collection key** — `content.data.articles[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
|
|
721
695
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
696
|
+
**Two options for bigger collections:**
|
|
697
|
+
|
|
698
|
+
`deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote collection, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based collections emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
|
|
725
699
|
|
|
726
|
-
|
|
700
|
+
`queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
|
|
727
701
|
|
|
728
|
-
|
|
702
|
+
```yaml
|
|
703
|
+
collections:
|
|
704
|
+
members:
|
|
705
|
+
path: collections/members
|
|
706
|
+
queryable:
|
|
707
|
+
department: { type: enum, label: Department, options: [biology, physics, chemistry] }
|
|
708
|
+
tenured: { type: boolean, label: Tenured }
|
|
709
|
+
start_year: { type: range, label: Start year, min: 1800, max: 2025 }
|
|
710
|
+
```
|
|
729
711
|
|
|
730
|
-
The
|
|
712
|
+
The site declares the *surface*; the foundation reads the metadata, renders matching controls (dropdown, toggle, slider), and composes the predicate when the reader picks values.
|
|
731
713
|
|
|
732
|
-
|
|
714
|
+
Full author guide: `authoring/collections.md`. Predicate operators: `authoring/predicates.md`.
|
|
733
715
|
|
|
734
|
-
```markdown
|
|
735
|
-
---
|
|
736
|
-
type: CvEntry
|
|
737
|
-
source: education
|
|
738
716
|
---
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
717
|
+
|
|
718
|
+
## Part 4 — The foundation lane
|
|
719
|
+
|
|
720
|
+
You're not building pages — you're building a **system** of section types that authors compose into pages. Name by purpose, not content: `Testimonial` not `WhatClientsSay`, `SplitContent` not `AboutSection`. Expect consolidation: a React site with 30+ components typically maps to 8–15 Uniweb section types.
|
|
721
|
+
|
|
722
|
+
### Where things go
|
|
723
|
+
|
|
724
|
+
```
|
|
725
|
+
src/ # the foundation package (folder name is `src`)
|
|
726
|
+
├── sections/ # Section types (auto-discovered)
|
|
727
|
+
│ ├── Hero.jsx # Bare file — no folder needed
|
|
728
|
+
│ ├── Features/ # Folder when you need meta.js
|
|
729
|
+
│ │ ├── index.jsx
|
|
730
|
+
│ │ └── meta.js
|
|
731
|
+
│ └── insets/ # Organizational subdirectory (lowercase)
|
|
732
|
+
├── layouts/ # Custom layouts (optional, auto-discovered)
|
|
733
|
+
├── components/ # Your React components — any props you like, no meta.js
|
|
734
|
+
├── utils/ # Helper functions, non-React logic
|
|
735
|
+
├── main.js
|
|
736
|
+
├── styles.css
|
|
737
|
+
└── package.json # name: "src"
|
|
744
738
|
```
|
|
745
739
|
|
|
746
|
-
|
|
740
|
+
**Discovery:** PascalCase files and folders at the root of `sections/` are auto-discovered. Nested levels require a `meta.js`. Lowercase directories are organizational only. `hidden: true` excludes a component entirely. Everything outside `sections/` and `layouts/` is ordinary React.
|
|
747
741
|
|
|
748
|
-
**
|
|
742
|
+
**Source root.** The foundation package's source lives at the package root — the `src/` folder *is* the foundation. The build reads `package.json::main` (for new scaffolds, `main: "./_entry.generated.js"`). Older foundations nest source in `foundation/src/` with `main` pointing at `./src/_entry.generated.js`; both work through the same code path.
|
|
743
|
+
|
|
744
|
+
**Import aliases.** Foundations include subpath imports for shared internals — use them instead of brittle relative paths:
|
|
745
|
+
|
|
746
|
+
| Alias | Maps to | Use for |
|
|
747
|
+
|-------|---------|---------|
|
|
748
|
+
| `#components/*` | `./components/*` | Shared React components |
|
|
749
|
+
| `#utils/*` | `./utils/*` | Helper functions, non-React logic |
|
|
749
750
|
|
|
750
751
|
```jsx
|
|
751
|
-
import
|
|
752
|
+
import LessonHeader from '#components/LessonHeader' // ✅
|
|
753
|
+
import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you reorganize sections/
|
|
754
|
+
```
|
|
752
755
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
756
|
+
Within the same directory, use normal relative imports (`./AIFeedbackCard`).
|
|
757
|
+
|
|
758
|
+
**Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
|
|
759
|
+
|
|
760
|
+
### Props interface
|
|
761
|
+
|
|
762
|
+
```jsx
|
|
763
|
+
function MyComponent({ content, params, block }) {
|
|
764
|
+
const { title, paragraphs, links, items } = content // Guaranteed shape
|
|
765
|
+
const { columns, variant } = params // Defaults from meta.js
|
|
766
|
+
const { website } = useWebsite() // Or block.website
|
|
761
767
|
}
|
|
762
768
|
```
|
|
763
769
|
|
|
764
|
-
|
|
770
|
+
Frontmatter becomes `params`, minus the keys the framework consumes outright: `type` (and its legacy alias `component`), `preset`, `input`, `props`, `fetch`, `data`, `id`. `props:` is the one that isn't dropped but merged *into* params.
|
|
765
771
|
|
|
766
|
-
|
|
772
|
+
**Framework fields you'd expect to be stripped are not.** `background`, `theme`, `source`, `where`, and `vars` are acted on by the runtime *and* passed through — so `params.theme` is readable when a component needs logic beyond CSS tokens (a light vs. dark logo, say). Components ignore the keys they don't use, the same way they ignore unused `content.data` keys.
|
|
767
773
|
|
|
768
|
-
|
|
774
|
+
### Rendering content with Kit
|
|
769
775
|
|
|
770
|
-
|
|
771
|
-
background: /images/hero.jpg # Image
|
|
772
|
-
background: /videos/hero.mp4 # Video
|
|
773
|
-
background: linear-gradient(135deg, #667eea, #764ba2) # Gradient
|
|
774
|
-
background: '#1a1a2e' # Color (hex — quote in YAML)
|
|
775
|
-
background: primary-900 # Palette token (bare name or var())
|
|
776
|
-
```
|
|
776
|
+
Content fields are **HTML strings** — they contain `<strong>`, `<em>`, `<a>` from markdown. **Never render them with raw `{content.title}` in JSX** — that shows HTML tags as visible text. Use Kit components:
|
|
777
777
|
|
|
778
|
-
|
|
778
|
+
```jsx
|
|
779
|
+
import { H1, H2, H3, P, Span } from '@uniweb/kit'
|
|
779
780
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
781
|
+
<H1 text={content.title} className="text-heading text-5xl font-bold" />
|
|
782
|
+
<H2 text={content.subtitle} className="text-heading text-2xl" />
|
|
783
|
+
<P text={content.paragraphs} className="text-body" />
|
|
784
|
+
<Span text={listItem.paragraphs[0]} className="text-subtle" />
|
|
784
785
|
```
|
|
785
786
|
|
|
786
|
-
|
|
787
|
+
Kit provides `H1`–`H6`. These render their own HTML tag — **don't wrap them**: `<H2 text={…} />`, not `<h2><H2 text={…} /></h2>`.
|
|
787
788
|
|
|
788
|
-
|
|
789
|
+
**Full content rendering** (article/docs sections where the author controls the flow):
|
|
790
|
+
|
|
791
|
+
```jsx
|
|
792
|
+
import { Section, Prose } from '@uniweb/kit'
|
|
789
793
|
|
|
794
|
+
<Section block={block} width="lg" padding="md" />
|
|
795
|
+
<Prose content={content} block={block} />
|
|
790
796
|
```
|
|
791
|
-
site/layout/
|
|
792
|
-
├── header.md # type: Header — rendered on every page
|
|
793
|
-
├── footer.md # type: Footer — rendered on every page
|
|
794
|
-
└── left.md # type: Sidebar — optional sidebar
|
|
795
797
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
798
|
+
`Prose` renders from the parsed sequence — headings, paragraphs, images, snippets, lists — with prose typography. Tagged data blocks are **skipped**; access them via `content.data` for custom rendering. Pass `content` (needs `.sequence`), and `block` too if the content uses insets. Also works as a pure typography wrapper: `<Prose>{children}</Prose>`.
|
|
799
|
+
|
|
800
|
+
`Article` is an older alternative rendering from `block.rawContent` (raw ProseMirror), including data blocks. Prefer `Prose` for new components.
|
|
801
|
+
|
|
802
|
+
### The Kit, by use case
|
|
803
|
+
|
|
804
|
+
Names only — for signatures and props, read the package: it's on disk at `node_modules/@uniweb/kit`, and `reference/kit-reference.md` has the full API.
|
|
805
|
+
|
|
806
|
+
**Text:** `H1`–`H6`, `P`, `Span`, `Div`, `Text` (with `as`)
|
|
807
|
+
**Content:** `Section`, `Prose`, `Article`, `Render` (ProseMirror → React), `ChildBlocks`, `splitContent()`
|
|
808
|
+
**Media:** `Visual` (first non-empty: inset/video/image), `Image`, `Media`, `Icon`, `Asset`
|
|
809
|
+
**Navigation:** `Link`, `useActiveRoute()`, `useWebsite()`, `useRouting()`
|
|
810
|
+
**Header/layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
|
|
811
|
+
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`, `useTheme(name)`
|
|
812
|
+
**Theming data:** `useThemeData()`, `useColorContext(block)`
|
|
813
|
+
**Data fetching:** `useFetched`, `useCacheEntry`, `useEntityDetail`
|
|
814
|
+
**Utilities:** `cn()`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`, `getLocaleLabel(locale)`
|
|
815
|
+
**Other styled:** `SidebarLayout`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
|
|
816
|
+
|
|
817
|
+
Four things you won't discover by reading exports:
|
|
818
|
+
|
|
819
|
+
> **Content fields are HTML strings.** Covered above — it's the single most common rendering bug.
|
|
820
|
+
|
|
821
|
+
> **`<Link>` takes `to` or `href`.** `to="page:about"` resolves a page ID (survives moves), external URLs get `target="_blank"` automatically, and `reload` forces a full page load.
|
|
822
|
+
|
|
823
|
+
> **`cn()` gotcha — a later `text-<size>` silently drops an earlier `leading-*`.** Tailwind's size classes set line height too, so `cn()` treats the size as replacing the leading: `cn('leading-[1.1] text-4xl')` → `text-4xl`. Put the size first, or fold the leading into it (`text-[clamp(2rem,5vw,4rem)]/[1.1]`). Most likely to bite when the size comes from a lookup and the leading sits in a shared base string.
|
|
824
|
+
|
|
825
|
+
> **Kit hooks are SSR-safe by design.** If you hit "document is not defined" during a build, don't add `typeof document` guards — reach for the hook instead (`useAppearance()` for scheme, `useScrolled()` for scroll position).
|
|
826
|
+
|
|
827
|
+
### Icon component
|
|
828
|
+
|
|
829
|
+
```jsx
|
|
830
|
+
{content.icons.map((icon, i) => <Icon key={i} {...icon} />)} // From content
|
|
831
|
+
<Icon name="search" /> // Lucide (default)
|
|
832
|
+
<Icon name="hi2-arrow-right" /> // Other library by prefix
|
|
833
|
+
<Icon name="close" /> // Built-in (no network)
|
|
804
834
|
```
|
|
805
835
|
|
|
806
|
-
|
|
836
|
+
Built-ins (instant, no network): `check`, `close`, `menu`, `chevronDown`, `chevronRight`, `externalLink`, `download`, `play`, and a few others. Other props: `svg`, `url`, `size` (default `'24'`), `className`.
|
|
807
837
|
|
|
808
|
-
|
|
838
|
+
### Section wrapper
|
|
809
839
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
840
|
+
The runtime wraps every section in `<section>` with its context class and background. Customize with static properties:
|
|
841
|
+
|
|
842
|
+
```jsx
|
|
843
|
+
function Hero({ content, params }) {
|
|
844
|
+
return <div className="max-w-7xl mx-auto px-6">…</div>
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
Hero.className = 'pt-32 md:pt-48' // adds classes to the runtime wrapper
|
|
848
|
+
Hero.as = 'div' // changes the wrapper element
|
|
849
|
+
export default Hero
|
|
819
850
|
```
|
|
820
851
|
|
|
821
|
-
**
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
852
|
+
**Layout components typically need `p-0`** to suppress default padding:
|
|
853
|
+
|
|
854
|
+
```jsx
|
|
855
|
+
Header.className = 'p-0'
|
|
856
|
+
Header.as = 'header'
|
|
826
857
|
```
|
|
827
858
|
|
|
828
|
-
|
|
829
|
-
```yaml
|
|
830
|
-
# site.yml — defaults for the whole site (incl. the homepage card)
|
|
831
|
-
keywords: [components, react, cms]
|
|
832
|
-
seo:
|
|
833
|
-
image: /og-default.png # social-card image
|
|
834
|
-
ogTitle: Acme
|
|
835
|
-
noindex: false # true keeps the WHOLE site out of search
|
|
859
|
+
### meta.js
|
|
836
860
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
861
|
+
```javascript
|
|
862
|
+
export default {
|
|
863
|
+
title: 'Feature Grid',
|
|
864
|
+
description: 'Grid of feature cards with icons',
|
|
865
|
+
category: 'showcase', // free-form editor grouping — nothing validates it.
|
|
866
|
+
// impact / showcase / structure is a suggested set.
|
|
867
|
+
// It names the kind of *component*, not the kind of
|
|
868
|
+
// site: a genre like 'marketing' belongs to a template
|
|
869
|
+
purpose: 'Explain', // free-form; a single verb reads well
|
|
870
|
+
// hidden: true, // Exclude from export entirely (internal helpers)
|
|
871
|
+
// background: 'self', // Component renders its own background
|
|
872
|
+
// inset: true, // Available for @ComponentName in markdown
|
|
873
|
+
// visuals: 1, // Expects 1 visual
|
|
874
|
+
// children: true, // Accepts child sections
|
|
875
|
+
|
|
876
|
+
content: {
|
|
877
|
+
title: 'Section heading',
|
|
878
|
+
paragraphs: 'Introduction [0-1]',
|
|
879
|
+
items: 'Feature cards with icon, title, description',
|
|
880
|
+
},
|
|
881
|
+
|
|
882
|
+
params: {
|
|
883
|
+
columns: { type: 'number', default: 3 },
|
|
884
|
+
variant: { type: 'select', options: ['default', 'centered', 'split'], default: 'default' },
|
|
885
|
+
},
|
|
886
|
+
|
|
887
|
+
presets: {
|
|
888
|
+
default: { label: 'Standard', params: { columns: 3 } },
|
|
889
|
+
compact: { label: 'Compact', params: { columns: 4 } },
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
// context / initialState: keys are developer-defined, not framework fields.
|
|
893
|
+
context: {}, // Static — neighbors read via getNextBlockInfo().context
|
|
894
|
+
initialState: {}, // Dynamic — neighbors read .state; component updates via useBlockState()
|
|
895
|
+
}
|
|
842
896
|
```
|
|
843
897
|
|
|
844
|
-
**
|
|
898
|
+
**All defaults belong in `meta.js`, not inline in component code.** `meta.js` is also the catalog entry another agent reads to discover your section type (Part 2, step 2) — write `description` and `content:` for that reader.
|
|
845
899
|
|
|
846
|
-
|
|
900
|
+
> **`meta.js` is a user interface, not just metadata.** When a foundation is published, every `meta.js` is registered as the foundation's schema, and the visual editor builds its controls from it: the build generates a `schema.json` from every `meta.js` in the foundation, and the editor renders it: a `select` param with `options` becomes a dropdown, a `boolean` becomes a toggle, `label` and `description` become the words a non-technical author reads, and each entry in `presets` becomes a one-click choice. That's the real reason param naming matters — `variant: centered` and `renderMode: flex-center` aren't a style preference, they're the difference between a legible control and a baffling one. Write `meta.js` as though someone who will never see your code has to use it, because that's exactly who does.
|
|
847
901
|
|
|
848
|
-
|
|
902
|
+
### Two worlds: section types and your own components
|
|
849
903
|
|
|
850
|
-
|
|
904
|
+
The `{ content, params, block }` interface is not "how components work in Uniweb." It is how **section types** work — the components an author names with `type:` in frontmatter. A section type is a *public interface*: its name, params, and content expectations are a contract with content authors, and `meta.js` is its documentation.
|
|
851
905
|
|
|
852
|
-
|
|
906
|
+
**Everything under `components/` is yours.** It's a normal React project in there, and the framework has no opinion about it:
|
|
853
907
|
|
|
854
|
-
|
|
908
|
+
- **Any props you want** — `<PricingCard tier={tier} price={price} featured />`. No `content`, no `params`, no `block` unless you choose to pass them.
|
|
909
|
+
- **Any composition** — children, render props, context, `forwardRef`, custom hooks, whatever the job needs.
|
|
910
|
+
- **Any library** — install it and import it.
|
|
911
|
+
- **No `meta.js`, no naming convention, no auto-discovery, no author visibility.**
|
|
855
912
|
|
|
856
|
-
|
|
857
|
-
```markdown
|
|
858
|
-
-  [Home](/)
|
|
859
|
-
-  [Docs](/docs)
|
|
860
|
-
-  [Contact](/contact)
|
|
861
|
-
```
|
|
862
|
-
Access: `content.lists[0]` — each item has `item.links[0]` and `item.icons[0]`.
|
|
913
|
+
Two symptoms of getting this backwards, both worth checking yourself against:
|
|
863
914
|
|
|
864
|
-
**
|
|
865
|
-
```markdown
|
|
866
|
-
- Product
|
|
867
|
-
- [Features](/features)
|
|
868
|
-
- [Pricing](/pricing)
|
|
869
|
-
- Company
|
|
870
|
-
- [About](/about)
|
|
871
|
-
- [Careers](/careers)
|
|
872
|
-
```
|
|
873
|
-
Access: `content.lists[0]` — `group.paragraphs[0]` (label), `group.lists[0]` (sub-items with `subItem.links[0]`).
|
|
915
|
+
> **Don't give helper components a `{ content, params }` signature.** Copying the section interface downward is the most common version of this mistake. It makes every helper harder to reuse and hides what it actually needs. A card that takes `title`, `price`, and `features` is better than one that takes `content` and digs through it.
|
|
874
916
|
|
|
875
|
-
|
|
917
|
+
> **Don't put helpers in `sections/`.** Anything at the root of `sections/` becomes author-selectable vocabulary. A `Button` or `Card` appearing in the editor's section picker is a bug in your foundation's interface design, not a harmless extra.
|
|
918
|
+
|
|
919
|
+
**A section type is usually a composite.** Its job is to translate parsed content into whatever your own components expect, then arrange them:
|
|
876
920
|
|
|
877
921
|
```jsx
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
922
|
+
// sections/Pricing/index.jsx
|
|
923
|
+
import PricingCard from '#components/PricingCard'
|
|
924
|
+
import formatPrice from '#utils/formatPrice'
|
|
925
|
+
|
|
926
|
+
export default function Pricing({ content, params }) {
|
|
927
|
+
const currency = params.currency || 'USD'
|
|
928
|
+
return (
|
|
929
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
|
930
|
+
{content.items.map((tier, i) => (
|
|
931
|
+
<PricingCard key={i} tier={tier} price={formatPrice(tier.data, currency)} />
|
|
884
932
|
))}
|
|
885
|
-
</
|
|
886
|
-
|
|
887
|
-
|
|
933
|
+
</div>
|
|
934
|
+
)
|
|
935
|
+
}
|
|
888
936
|
```
|
|
889
937
|
|
|
890
|
-
|
|
938
|
+
The author writes `type: Pricing` and defines tiers as content items. `PricingCard` knows nothing about Uniweb — it takes a tier and a price. That boundary is what makes it testable, reusable across section types, and replaceable without touching the author-facing contract.
|
|
891
939
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
940
|
+
**There are many ways to use `components/`**, and no expected shape. Presentational primitives shared across section types (buttons, cards, badges). A thin wrapper adapting the parsed content shape to a third-party component's props. Layout scaffolding several section types share. Stateful widgets. Legacy components carried in during a migration. The Front Desk pattern below is *one* specific arrangement worth knowing — not a requirement, and not the main reason to have helper components.
|
|
941
|
+
|
|
942
|
+
### The Front Desk pattern
|
|
943
|
+
|
|
944
|
+
One arrangement of the above is worth naming, because it comes up whenever a single `type:` needs to cover genuinely different renderings.
|
|
945
|
+
|
|
946
|
+
Section types naturally use params to adjust their own rendering — `variant: flipped` reverses a flex direction, `columns: 3` sets a grid. That's the baseline, not a pattern. The **Front Desk pattern** is the step beyond it: a section type that does virtually no rendering itself, instead reading the author's params, picking the right helper component, and translating author-friendly vocabulary into developer-oriented props.
|
|
947
|
+
|
|
948
|
+
The workers behind the desk need not share an interface. A `Hero` might delegate to a `SliderHero` (image carousel) and a `ContactHero` (quote form) expecting different content and different props. The front desk declares the **union** of everything its workers might need — some content goes unused for a given variant, and that's normal in CCA: params change behavior, and that includes not rendering some content.
|
|
949
|
+
|
|
950
|
+
```js
|
|
951
|
+
// meta.js — the union of all variants' needs
|
|
952
|
+
export default {
|
|
953
|
+
params: {
|
|
954
|
+
variant: { type: 'select', options: ['slider', 'contact'], default: 'slider' },
|
|
955
|
+
slideInterval: { type: 'number', default: 5 },
|
|
956
|
+
density: { type: 'select', options: ['default', 'compact'], default: 'default' },
|
|
957
|
+
style: { type: 'select', options: ['default', 'dramatic'], default: 'default' },
|
|
958
|
+
}
|
|
959
|
+
}
|
|
903
960
|
```
|
|
904
|
-
````
|
|
905
961
|
|
|
906
|
-
|
|
962
|
+
```jsx
|
|
963
|
+
// sections/Hero/index.jsx — the front desk
|
|
964
|
+
import { SliderHero } from '#components/SliderHero'
|
|
965
|
+
import { ContactHero } from '#components/ContactHero'
|
|
907
966
|
|
|
908
|
-
|
|
967
|
+
const variants = { slider: SliderHero, contact: ContactHero }
|
|
968
|
+
|
|
969
|
+
export default function Hero({ content, block, params }) {
|
|
970
|
+
const Variant = variants[params.variant] || SliderHero
|
|
971
|
+
return (
|
|
972
|
+
<Variant
|
|
973
|
+
title={content.title} subtitle={content.paragraphs[0]} links={content.links} block={block}
|
|
974
|
+
images={content.images} // only some variants use these
|
|
975
|
+
formData={content.data?.quote}
|
|
976
|
+
interval={params.slideInterval} // author vocabulary → developer props
|
|
977
|
+
compact={params.density === 'compact'}
|
|
978
|
+
transition={params.style === 'dramatic' ? 'zoom' : 'fade'}
|
|
979
|
+
/>
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
```
|
|
909
983
|
|
|
910
|
-
|
|
984
|
+
`SliderHero` uses `images`, `interval`, `transition` and ignores the rest; `ContactHero` uses `formData` and `compact` and ignores the rest. The author writes `variant: contact` — they don't know or care that `ContactHero` exists.
|
|
911
985
|
|
|
912
|
-
|
|
986
|
+
This is the system-building pattern at its clearest: **section types are the public interface** (author-friendly names, documented in `meta.js`); **helper components are the implementation** (ordinary React props). The section type is the thin translation layer between the two worlds.
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
**When to reach for this pattern:** when a page type has consistent structural elements — header bars, navigation footers, contextual sidebars — that the content author shouldn't have to add as separate sections. If the author would otherwise repeat the same boilerplate sections on every page of a given type, the section component should compose them internally.
|
|
990
|
+
|
|
991
|
+
**Common mistake:** solving structural repetition at the layout level. If only some page types need a content header (lessons do, the homepage doesn't), that's a section concern. The layout owns page-wide chrome; the section owns its internal structure.
|
|
992
|
+
|
|
993
|
+
### Params, component vars, foundation vars
|
|
994
|
+
|
|
995
|
+
Three places a value can live, and picking the right one is most of the design:
|
|
996
|
+
|
|
997
|
+
| | Declared in | Reaches the component as | Author overrides in | CSS scope |
|
|
998
|
+
|---|---|---|---|---|
|
|
999
|
+
| **Param** | `meta.js` `params:` | a JS prop | section frontmatter | — |
|
|
1000
|
+
| **Component var** | `meta.js` `vars:` | a CSS custom property | section frontmatter `vars:` | `#section-{id}` |
|
|
1001
|
+
| **Foundation var** | `main.js` `vars:` | a CSS custom property | `theme.yml` `vars:` | `:root` (global) |
|
|
1002
|
+
|
|
1003
|
+
**Param when the value drives logic** — a count, a variant name, a boolean the JSX branches on. **Component var when it only drives CSS** and belongs to one section type; it never enters JS, so it doesn't re-render anything:
|
|
1004
|
+
|
|
1005
|
+
```js
|
|
1006
|
+
// meta.js — same schema as foundation vars (default, label, type, options, group, description)
|
|
1007
|
+
vars: {
|
|
1008
|
+
'card-gap': { default: '1.5rem', label: 'Card Gap', type: 'select', options: ['1rem', '1.5rem', '2rem'] },
|
|
1009
|
+
'card-radius': { default: 'var(--radius-md)', description: 'Inherits the foundation var by default' },
|
|
1010
|
+
}
|
|
1011
|
+
```
|
|
913
1012
|
|
|
914
1013
|
```jsx
|
|
915
|
-
|
|
916
|
-
|
|
1014
|
+
<div className="grid gap-[var(--card-gap)]"> // emitted on #section-{id}
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
```yaml
|
|
1018
|
+
---
|
|
1019
|
+
type: PricingTable
|
|
1020
|
+
vars: { card-gap: 2rem } # author override — only declared names apply
|
|
1021
|
+
---
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Unknown var names in frontmatter are ignored, and component vars are always context-independent (unlike foundation `color`/`gradient` vars, which resolve per light/dark context).
|
|
1025
|
+
|
|
1026
|
+
**Foundation var when the value must stay consistent *across* components** — shared radii, spacing scales, extra typefaces. A header height is a layout param, not a foundation var; a sidebar width is a layout param too. Declare foundation vars in **`main.js`**, the single source of truth:
|
|
1027
|
+
|
|
1028
|
+
```js
|
|
1029
|
+
export const vars = {
|
|
1030
|
+
'radius-lg': { default: '1rem', description: 'Large border radius' },
|
|
1031
|
+
'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical section padding' },
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
Each entry ships the default as a CSS custom property (so `var(--section-padding-y)` resolves everywhere), gives the visual editor a description and type, and is what a site overrides under `vars:` in `theme.yml`. You don't declare these anywhere else — the defaults reach the browser on their own, in dev and production, bundled or runtime-loaded.
|
|
1036
|
+
|
|
1037
|
+
A `styles.css` `@theme` block is a different tool: it registers a token so **Tailwind generates utilities** from it (`@theme { --breakpoint-xs: 30rem }` → `xs:` variants). Use it to extend Tailwind's vocabulary, not to ship a plain default.
|
|
1038
|
+
|
|
1039
|
+
**A name matching a Tailwind namespace is an intentional override.** A var named after a Tailwind v4 scale — `radius-*`, `shadow-*`, `spacing`, `font-*` — redefines that scale wherever the matching utility appears: declaring `radius-lg` retunes every `rounded-lg` in the foundation. That's the point when you mean it — name deliberately so you don't reshape a utility by accident.
|
|
1040
|
+
|
|
1041
|
+
### Semantic theming
|
|
917
1042
|
|
|
918
|
-
|
|
919
|
-
|
|
1043
|
+
Components use **semantic CSS tokens** instead of hardcoded colors. The runtime applies a context class (`context-light`, `context-medium`, `context-dark`) to each section from its `theme:` frontmatter.
|
|
1044
|
+
|
|
1045
|
+
```jsx
|
|
1046
|
+
<h2 className="text-slate-900">…</h2> // ❌ Hardcoded — breaks in dark context
|
|
1047
|
+
<h2 className="text-heading">…</h2> // ✅ Semantic — adapts to any context and brand
|
|
920
1048
|
```
|
|
921
1049
|
|
|
922
|
-
**Semantic tokens** (available as Tailwind
|
|
1050
|
+
**Semantic tokens** (available as Tailwind `text-*`, `bg-*`, `border-*` classes):
|
|
923
1051
|
|
|
924
1052
|
| Token | Purpose |
|
|
925
1053
|
|-------|---------|
|
|
@@ -937,22 +1065,9 @@ Components use **semantic CSS tokens** instead of hardcoded colors. The runtime
|
|
|
937
1065
|
| `success` / `warning` / `error` / `info` | Status colors |
|
|
938
1066
|
| `success-subtle` / `warning-subtle` / `error-subtle` / `info-subtle` | Status backgrounds (alerts) |
|
|
939
1067
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
**Palette shades** are also available: `text-primary-600`, `bg-neutral-100`, `border-accent-300` — 11 shades (50–950) for each palette color (primary, secondary, accent, neutral). See `theme-tokens.css` for the complete mapping.
|
|
943
|
-
|
|
944
|
-
**Content authors control context** in frontmatter:
|
|
945
|
-
|
|
946
|
-
```markdown
|
|
947
|
-
---
|
|
948
|
-
type: Testimonial
|
|
949
|
-
theme: dark ← sets context-dark, all tokens resolve to dark values
|
|
950
|
-
---
|
|
951
|
-
```
|
|
952
|
-
|
|
953
|
-
Alternate between `light` (default), `medium`, and `dark` across sections for visual rhythm.
|
|
1068
|
+
**Palette shades** are also available — `text-primary-600`, `bg-neutral-100`, `border-accent-300` — 11 shades (50–950) per palette color (primary, secondary, accent, neutral). See `theme-tokens.css` for the complete mapping.
|
|
954
1069
|
|
|
955
|
-
**
|
|
1070
|
+
**Authors control context** via `theme: dark` in frontmatter, alternating `light` (default), `medium`, and `dark` across sections for visual rhythm. **The three presets aren't the limit** — the object form overrides any token per section:
|
|
956
1071
|
|
|
957
1072
|
```yaml
|
|
958
1073
|
theme:
|
|
@@ -962,89 +1077,28 @@ theme:
|
|
|
962
1077
|
primary: neutral-900 # Dark buttons instead of brand color
|
|
963
1078
|
```
|
|
964
1079
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
### theme.yml
|
|
968
|
-
|
|
969
|
-
```yaml
|
|
970
|
-
# site/theme.yml
|
|
971
|
-
colors:
|
|
972
|
-
primary: '#3b82f6' # Your exact hex appears at shade 500
|
|
973
|
-
secondary: '#64748b'
|
|
974
|
-
accent: '#8b5cf6'
|
|
975
|
-
neutral: stone # Named preset: stone, zinc, gray, slate, neutral
|
|
976
|
-
|
|
977
|
-
contexts:
|
|
978
|
-
light:
|
|
979
|
-
section: '#fafaf9' # Override individual tokens per context
|
|
980
|
-
|
|
981
|
-
appearance:
|
|
982
|
-
default: light # 'light' | 'dark' | 'system'; see Light/dark appearance below
|
|
983
|
-
allowToggle: true # offer a visitor light/dark switch
|
|
984
|
-
|
|
985
|
-
fonts:
|
|
986
|
-
import:
|
|
987
|
-
- url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
|
|
988
|
-
heading: "'Inter', system-ui, sans-serif"
|
|
989
|
-
body: "'Inter', system-ui, sans-serif"
|
|
990
|
-
|
|
991
|
-
inline:
|
|
992
|
-
accent:
|
|
993
|
-
color: var(--link)
|
|
994
|
-
font-weight: '600'
|
|
995
|
-
callout:
|
|
996
|
-
color: var(--accent)
|
|
997
|
-
font-weight: '600'
|
|
998
|
-
|
|
999
|
-
vars:
|
|
1000
|
-
radius: 0.75rem
|
|
1001
|
-
```
|
|
1002
|
-
|
|
1003
|
-
Each color generates 11 OKLCH shades (50–950). `neutral` uses a named preset rather than hex. Shade 500 = your exact input color. Context override keys match token names: `section:` not `bg:`, `primary:` not `btn-primary-bg:`.
|
|
1004
|
-
|
|
1005
|
-
### How colors reach components
|
|
1006
|
-
|
|
1007
|
-
Your hex → 11 shades (50–950) → semantic tokens → components.
|
|
1008
|
-
|
|
1009
|
-
Semantic tokens map shades to roles. In light/medium: `--primary` uses shade 600, `--link` uses 600, `--ring` uses 500. In dark: `--primary` uses 500, `--link` uses 400.
|
|
1010
|
-
|
|
1011
|
-
**Buttons use shade 600 — darker than your input color.** This is an accessibility choice for contrast with white text. For brand-exact buttons:
|
|
1012
|
-
|
|
1013
|
-
```yaml
|
|
1014
|
-
colors:
|
|
1015
|
-
primary: "#E35D25"
|
|
1016
|
-
contexts:
|
|
1017
|
-
light:
|
|
1018
|
-
primary: primary-500 # Your exact color on buttons
|
|
1019
|
-
primary-hover: primary-600
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
> **Contrast warning:** Bright brand colors (orange, yellow, light green) at shade 500 may not meet WCAG contrast (4.5:1) with white foreground text. Test buttons for readability — if contrast is insufficient, keep the default shade 600 mapping.
|
|
1080
|
+
`background:` also accepts CSS variables and hex, so authors can alternate `var(--neutral-50)` / `var(--primary-50)` surfaces with no component code. If a source design uses subtle surface variations (`--surface-base` vs `--surface-sunken`), map those to backgrounds or token overrides in frontmatter — not to component code.
|
|
1023
1081
|
|
|
1024
|
-
###
|
|
1082
|
+
### Section context vs site scheme
|
|
1025
1083
|
|
|
1026
|
-
Uniweb splits what other frameworks fuse into one "dark mode"
|
|
1084
|
+
Uniweb splits what other frameworks fuse into one "dark mode":
|
|
1027
1085
|
|
|
1028
|
-
- **Section context** — the `theme:` field on
|
|
1029
|
-
- **Site scheme** — the global light/dark preference
|
|
1086
|
+
- **Section context** — the `theme:` field on one section (`light`/`medium`/`dark`). Per-section, author-controlled.
|
|
1087
|
+
- **Site scheme** — the global light/dark preference under `appearance:` in `theme.yml`. The site-wide toggle a visitor flips.
|
|
1030
1088
|
|
|
1031
|
-
They compose rather than fight: a section with **no `theme:`** inherits the site scheme
|
|
1032
|
-
|
|
1033
|
-
Configure the scheme in `theme.yml`:
|
|
1089
|
+
They compose rather than fight: a section with **no `theme:`** inherits the site scheme and follows the toggle; a section that **pins** `theme: dark` stays dark in either scheme. A dark site can carry one bright white CTA. You never write `isDark ? … : …` — semantic tokens adapt to whichever scheme and context resolve around them.
|
|
1034
1090
|
|
|
1035
1091
|
```yaml
|
|
1036
1092
|
appearance:
|
|
1037
|
-
default: light # 'light' | 'dark' | 'system'
|
|
1093
|
+
default: light # 'light' | 'dark' | 'system'
|
|
1038
1094
|
allowToggle: true # offer a visitor switch (also generates the dark tokens)
|
|
1039
|
-
respectSystemPreference: true # first visit follows the OS;
|
|
1095
|
+
respectSystemPreference: true # first visit follows the OS; false pins `default`
|
|
1040
1096
|
schemes: [light, dark] # optional — declares the available set
|
|
1041
1097
|
```
|
|
1042
1098
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
A site **has dark mode** whenever it offers a toggle, defaults to `dark`/`system`, or lists `dark` in `schemes:`. Any such site follows the visitor's OS on first visit (unless `respectSystemPreference: false`) and remembers a manual choice. Precedence: a stored choice wins over the OS, which wins over `default:`.
|
|
1099
|
+
Shorthands: `appearance: light` / `dark` (fixed, no toggle), `appearance: system` (follow the OS).
|
|
1046
1100
|
|
|
1047
|
-
|
|
1101
|
+
A site **has dark mode** whenever it offers a toggle, defaults to `dark`/`system`, or lists `dark` in `schemes:`. Precedence: a stored choice wins over the OS, which wins over `default:`. Note that `default: light` is a *fallback*, not a guarantee — a dark OS still wins on first visit unless `respectSystemPreference: false`. (`default: system` is just the honest way to say "there is no fixed default — use the OS.")
|
|
1048
1102
|
|
|
1049
1103
|
| Goal | `theme.yml` |
|
|
1050
1104
|
|---|---|
|
|
@@ -1053,9 +1107,7 @@ Common intents:
|
|
|
1053
1107
|
| Toggle, follow the OS on first visit | `appearance: { default: system, allowToggle: true }` |
|
|
1054
1108
|
| Toggle, but always start light | `appearance: { default: light, allowToggle: true, respectSystemPreference: false }` |
|
|
1055
1109
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
**Rendering a toggle (foundation side).** The runtime resolves the scheme and applies it to `<html>` (`scheme-dark` / `scheme-light`) *before the page paints* — you never touch `localStorage` or `document` yourself, and there's no flash of the wrong scheme. A section type only renders the button:
|
|
1110
|
+
**Rendering a toggle.** The runtime resolves the scheme and applies it to `<html>` (`scheme-dark`/`scheme-light`) *before the page paints* — never touch `localStorage` or `document` yourself, and there's no flash of the wrong scheme. A section type only renders the button:
|
|
1059
1111
|
|
|
1060
1112
|
```jsx
|
|
1061
1113
|
import { useAppearance } from '@uniweb/kit'
|
|
@@ -1067,1003 +1119,708 @@ function SchemeToggle() {
|
|
|
1067
1119
|
}
|
|
1068
1120
|
```
|
|
1069
1121
|
|
|
1070
|
-
**Tailwind `dark:` variant.** If your foundation uses
|
|
1122
|
+
**Tailwind `dark:` variant.** If your foundation uses `dark:` utilities, bind them to the site scheme so they track the toggle. In `styles.css`:
|
|
1071
1123
|
|
|
1072
1124
|
```css
|
|
1073
1125
|
@custom-variant dark (&:where(.scheme-dark, .scheme-dark *));
|
|
1074
1126
|
```
|
|
1075
1127
|
|
|
1076
|
-
Without this, `dark:`
|
|
1077
|
-
|
|
1078
|
-
### Fonts
|
|
1079
|
-
|
|
1080
|
-
Font families are a **site** setting, configured in `theme.yml` under `fonts:`. A foundation never installs a font package (no `@fontsource/*`) and never hardcodes a family in its code — if you reach for either, you're off the paved path. The framework provides three roles and wires each onto the standard elements for you:
|
|
1128
|
+
Without this, `dark:` falls back to `@media (prefers-color-scheme: dark)` and ignores the site's setting. Prefer semantic tokens over `dark:` where you can — they adapt to per-section context, which a global `dark:` cannot.
|
|
1081
1129
|
|
|
1082
|
-
|
|
1083
|
-
|---|---|---|
|
|
1084
|
-
| `body` | `body` (all text by default) | paragraphs, UI |
|
|
1085
|
-
| `heading` | `h1, h2, h3` | titles |
|
|
1086
|
-
| `code` | `code, pre, kbd, samp` | code, monospace |
|
|
1130
|
+
### theme.yml
|
|
1087
1131
|
|
|
1088
1132
|
```yaml
|
|
1089
1133
|
# site/theme.yml
|
|
1134
|
+
colors:
|
|
1135
|
+
primary: '#3b82f6' # Your exact hex appears at shade 500
|
|
1136
|
+
secondary: '#64748b'
|
|
1137
|
+
accent: '#8b5cf6'
|
|
1138
|
+
neutral: stone # Named preset: stone, zinc, gray, slate, neutral
|
|
1139
|
+
|
|
1140
|
+
contexts:
|
|
1141
|
+
light:
|
|
1142
|
+
section: '#fafaf9' # Override individual tokens per context
|
|
1143
|
+
|
|
1144
|
+
appearance: { default: light, allowToggle: true }
|
|
1145
|
+
|
|
1090
1146
|
fonts:
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1147
|
+
heading: "'Inter', system-ui, sans-serif"
|
|
1148
|
+
body: "'Inter', system-ui, sans-serif"
|
|
1149
|
+
|
|
1150
|
+
inline:
|
|
1151
|
+
callout: { color: var(--accent), font-weight: '600' }
|
|
1152
|
+
|
|
1153
|
+
vars:
|
|
1154
|
+
radius: 0.75rem
|
|
1094
1155
|
```
|
|
1095
1156
|
|
|
1096
|
-
(`
|
|
1157
|
+
Each color generates 11 OKLCH shades (50–950); shade 500 is your exact input. `neutral` takes a named preset rather than hex. Context override keys match token names: `section:` not `bg:`, `primary:` not `btn-primary-bg:`.
|
|
1097
1158
|
|
|
1098
|
-
|
|
1159
|
+
**How colors reach components:** your hex → 11 shades → semantic tokens → components. In light/medium, `--primary` uses shade 600, `--link` 600, `--ring` 500; in dark, `--primary` uses 500 and `--link` 400.
|
|
1099
1160
|
|
|
1100
|
-
**
|
|
1161
|
+
**Buttons use shade 600 — darker than your input color.** That's an accessibility choice for contrast with white text. For brand-exact buttons:
|
|
1101
1162
|
|
|
1102
1163
|
```yaml
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
- url: "https://fonts.googleapis.com/css2?family=Poppins:wght@600;700"
|
|
1109
|
-
|
|
1110
|
-
# Self-hosted (no CDN — privacy / offline). Put the files under
|
|
1111
|
-
# site/public/fonts/ and declare @font-face faces; the build emits the
|
|
1112
|
-
# @font-face rules + preload hints, and skips any face whose family no
|
|
1113
|
-
# role uses.
|
|
1114
|
-
fonts:
|
|
1115
|
-
heading: "Söhne, sans-serif"
|
|
1116
|
-
faces:
|
|
1117
|
-
- { family: "Söhne", src: /fonts/soehne-bold.woff2, weight: 700 }
|
|
1118
|
-
- { family: "Söhne", src: /fonts/soehne-regular.woff2, weight: 400 }
|
|
1164
|
+
colors: { primary: "#E35D25" }
|
|
1165
|
+
contexts:
|
|
1166
|
+
light:
|
|
1167
|
+
primary: primary-500 # Your exact color on buttons
|
|
1168
|
+
primary-hover: primary-600
|
|
1119
1169
|
```
|
|
1120
1170
|
|
|
1121
|
-
**
|
|
1171
|
+
> **Contrast warning:** bright brand colors (orange, yellow, light green) at shade 500 may fail WCAG 4.5:1 against white text. Test for readability — if contrast is insufficient, keep the default 600 mapping.
|
|
1172
|
+
|
|
1173
|
+
### Fonts
|
|
1122
1174
|
|
|
1123
|
-
|
|
1175
|
+
Font families are a **site** setting, under `fonts:` in `theme.yml`. A foundation never installs a font package (no `@fontsource/*`) and never hardcodes a family — if you reach for either, you're off the paved path. Three roles are wired onto standard elements for you:
|
|
1176
|
+
|
|
1177
|
+
| Role | Wired onto | Typical use |
|
|
1178
|
+
|---|---|---|
|
|
1179
|
+
| `body` | `body` (all text by default) | paragraphs, UI |
|
|
1180
|
+
| `heading` | `h1, h2, h3` | titles |
|
|
1181
|
+
| `code` | `code, pre, kbd, samp` | code, monospace |
|
|
1182
|
+
|
|
1183
|
+
(`code` was previously named `mono`; `fonts.mono` is no longer an alias — rename it.)
|
|
1184
|
+
|
|
1185
|
+
Because the site wires families onto real elements, **the norm is that a foundation doesn't set fonts itself** — render semantic markup (`<H1>`, `<P>`, `<code>` from the kit) and the roles apply. Everything else stays Tailwind.
|
|
1186
|
+
|
|
1187
|
+
**In a component, weight is yours; family is the site's.** Weight/size/style utilities (`font-bold`, `italic`, `text-xl`) are ordinary design vocabulary. A font-**family** utility is different: `font-sans`/`font-serif` resolve to Tailwind's built-in stacks unless the foundation makes them site-controlled, so a bare `font-serif` silently hardcodes a typeface.
|
|
1188
|
+
|
|
1189
|
+
**When a design needs typefaces the three roles can't express** — an editorial serif, a display face for hero titles — declare each as a **font var** in `main.js`. A `font-*`-named var is recognized as a typeface automatically (no `type` needed); a bare-named one takes `type: 'font'`. Either way the site loads and owns the family:
|
|
1124
1190
|
|
|
1125
1191
|
```js
|
|
1126
|
-
// foundation src/main.js
|
|
1127
|
-
// `font-*` names are recognized as fonts automatically (no `type` needed);
|
|
1128
|
-
// defaults are OS stacks, so it renders native until a site opts in.
|
|
1192
|
+
// foundation src/main.js
|
|
1129
1193
|
export const vars = {
|
|
1130
1194
|
'font-serif': {
|
|
1131
1195
|
default: 'ui-serif, Georgia, serif',
|
|
1132
1196
|
description: 'Editorial serif — blurbs, taglines, quotes',
|
|
1133
|
-
applyTo: ['blockquote', '.tagline'], // framework
|
|
1197
|
+
applyTo: ['blockquote', '.tagline'], // framework emits the rule, like a built-in role
|
|
1134
1198
|
},
|
|
1135
1199
|
'font-display': {
|
|
1136
1200
|
default: 'ui-sans-serif, system-ui, sans-serif',
|
|
1137
|
-
description: 'Display — hero titles',
|
|
1138
1201
|
// no applyTo → the component wires it (a `font-display` utility, or var(--font-display))
|
|
1139
1202
|
},
|
|
1140
1203
|
}
|
|
1141
1204
|
```
|
|
1142
1205
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
- **`applyTo: [selectors]`** — the framework emits the `font-family` rule for you, exactly like the three built-in roles. Declarative; no font-family classes in components.
|
|
1146
|
-
- **Omit `applyTo`** — the component wires it: a Tailwind `font-*` utility when the var is named after a Tailwind scale (`font-serif` → the `font-serif` utility), or `font-family: var(--font-display)` for a custom name.
|
|
1147
|
-
|
|
1148
|
-
Either way the site sets the family and loads it — no component changes. The `fonts:` block takes **any** role name, built-in or foundation-added, so a site sets its whole type system in one place:
|
|
1206
|
+
The `fonts:` block takes **any** role name, built-in or foundation-added, so a site sets its whole type system in one place. The three roles are defaults you can **retarget** too — redeclaring a role's var with a new `applyTo` changes which elements it paints, while the site still owns the family.
|
|
1149
1207
|
|
|
1150
1208
|
```yaml
|
|
1151
|
-
# site/theme.yml
|
|
1209
|
+
# site/theme.yml — loading, hosted or self-hosted; both config-only, no dependency
|
|
1152
1210
|
fonts:
|
|
1153
|
-
|
|
1154
|
-
serif: '"Fraunces", Georgia, serif'
|
|
1211
|
+
heading: "Poppins, sans-serif"
|
|
1212
|
+
serif: '"Fraunces", Georgia, serif' # a foundation-declared role, set by name
|
|
1213
|
+
import:
|
|
1214
|
+
- url: "https://fonts.googleapis.com/css2?family=Poppins:wght@600;700"
|
|
1155
1215
|
faces:
|
|
1156
1216
|
- { family: "Fraunces", src: /fonts/fraunces.woff2, weight: "100 900" }
|
|
1157
1217
|
```
|
|
1158
1218
|
|
|
1159
|
-
|
|
1219
|
+
The build auto-adds preconnect hints for imports, emits `@font-face` + preload for faces, and drops any family no role references.
|
|
1160
1220
|
|
|
1161
|
-
|
|
1221
|
+
> **`serif` and `font-serif` are the same font var** — the `font-` spelling is just the one Tailwind's `font-serif` utility reads. Declare it either way in `main.js`; the site sets it by the bare role name under `fonts:`.
|
|
1162
1222
|
|
|
1163
|
-
|
|
1164
|
-
// foundation src/main.js — retarget a built-in role, add a new one.
|
|
1165
|
-
export const vars = {
|
|
1166
|
-
heading: { type: 'font', applyTo: ['h1', 'h2', 'h3', 'h4'] }, // include h4 in the heading font
|
|
1167
|
-
serif: { type: 'font', default: 'ui-serif, Georgia, serif', applyTo: ['blockquote'] },
|
|
1168
|
-
}
|
|
1169
|
-
```
|
|
1223
|
+
> The `code` role owns `--font-code`. Tailwind's `font-mono` utility (which reads `--font-mono`) is a **separate** concern — control it by declaring your own `font-mono` font var. So `fonts.code` styles code without disturbing `font-mono`-styled labels, and vice-versa.
|
|
1170
1224
|
|
|
1171
|
-
|
|
1225
|
+
### Tailwind v4 and styles.css
|
|
1172
1226
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
When you do need one, declare it in **`main.js`** — the single source of truth:
|
|
1227
|
+
```css
|
|
1228
|
+
@import "tailwindcss";
|
|
1229
|
+
@import "@uniweb/kit/theme-tokens.css";
|
|
1230
|
+
@source "./sections/**/*.{js,jsx}";
|
|
1231
|
+
@source "./components/**/*.{js,jsx}";
|
|
1232
|
+
@source "../node_modules/@uniweb/kit/src/**/*.jsx";
|
|
1180
1233
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
'radius-lg': { default: '1rem', description: 'Large border radius' },
|
|
1184
|
-
'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical section padding' },
|
|
1234
|
+
@theme {
|
|
1235
|
+
--breakpoint-xs: 30rem;
|
|
1185
1236
|
}
|
|
1186
1237
|
```
|
|
1187
1238
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
A `styles.css` `@theme` block is a separate tool for a different job: registering a token so **Tailwind generates utility classes** from it (`@theme { --breakpoint-xs: 30rem }` → `xs:` variants). Reach for it to extend Tailwind's vocabulary — not to ship a plain default.
|
|
1191
|
-
|
|
1192
|
-
**A name that matches a Tailwind namespace is an intentional override.** A var named after a Tailwind v4 token scale — `radius-*`, `shadow-*`, `spacing`, `font-*` — redefines that scale wherever the matching utility appears: declaring `radius-lg` retunes every `rounded-lg` in the foundation from Tailwind's default to your value (sites can still override in `theme.yml`). That's the point when you mean it — just name deliberately so you don't reshape a utility by accident.
|
|
1193
|
-
|
|
1194
|
-
**Common mistake:** Using foundation vars for values that belong to a specific component. A header height is a layout param, not a foundation var — the layout component owns it. A sidebar width is a layout param too. Foundation vars are for values that multiple unrelated components share — radii, spacing, shadows.
|
|
1195
|
-
|
|
1196
|
-
### Design richness beyond tokens
|
|
1239
|
+
Semantic tokens come from `theme-tokens.css` (populated from `theme.yml`). Use `@theme` only for values tokens don't cover. **Custom CSS is expected alongside Tailwind** — shadow systems, border hierarchies, gradients, glassmorphism. Tailwind handles layout; tokens handle context; `styles.css` handles everything else.
|
|
1197
1240
|
|
|
1198
|
-
Tokens
|
|
1241
|
+
**Tokens are a floor, not a ceiling.** They solve context adaptation — the hard problem. A great foundation adds design vocabulary on top:
|
|
1199
1242
|
|
|
1200
1243
|
```css
|
|
1201
|
-
/* styles.css */
|
|
1202
1244
|
.border-subtle { border-color: color-mix(in oklch, var(--border), transparent 50%); }
|
|
1203
|
-
.border-strong { border-color: color-mix(in oklch, var(--border), var(--heading) 30%); }
|
|
1204
1245
|
.text-tertiary { color: color-mix(in oklch, var(--body), var(--subtle) 50%); }
|
|
1205
1246
|
```
|
|
1206
1247
|
|
|
1207
|
-
These compose with tokens — they adapt per context because they reference token variables
|
|
1208
|
-
|
|
1209
|
-
**The priority:** Design quality > portability > configurability. A beautiful foundation for one site is more valuable than a generic one that looks flat.
|
|
1248
|
+
These compose with tokens — they adapt per context because they reference token variables — while adding nuance the token set doesn't provide. Use palette shades directly (`var(--primary-300)`, `bg-neutral-200`) for fine-grained control. **The priority: design quality > portability > configurability.** A beautiful foundation for one site is worth more than a generic one that looks flat.
|
|
1210
1249
|
|
|
1211
|
-
|
|
1250
|
+
Two lines a ported CSS reset will try to bring with it, both of which break things:
|
|
1212
1251
|
|
|
1213
|
-
|
|
1252
|
+
> **Don't set `scroll-behavior: smooth` globally.** The runtime owns scrolling: it already smooth-scrolls anchor targets itself (`scrollIntoView({ behavior: 'smooth' })`), so the CSS adds nothing there — but it resets and restores scroll on route changes with the two-argument `scrollTo(x, y)`, which *inherits* the property. Route changes then animate their scroll-to-top, and back-button restoration (which scrolls, checks the position on the next frame, and retries) keeps interrupting its own animation. Scope it to a specific scrollable element if you need it; never to `html` or `body`.
|
|
1214
1253
|
|
|
1215
|
-
|
|
1254
|
+
> **Font smoothing is a per-scheme decision, not a reset.** `-webkit-font-smoothing: antialiased` (macOS only) forces grayscale rasterization, which thins strokes. On dark surfaces that usefully counteracts the bloom of light text on near-black; on light surfaces it costs contrast and makes body text spindly. Scope it — `.scheme-dark { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }` — rather than putting it on `html`.
|
|
1216
1255
|
|
|
1217
|
-
###
|
|
1256
|
+
### Composition: items, child sections, insets
|
|
1218
1257
|
|
|
1219
|
-
|
|
1220
|
-
function MyComponent({ content, params, block }) {
|
|
1221
|
-
const { title, paragraphs, links, items } = content // Guaranteed shape
|
|
1222
|
-
const { columns, variant } = params // Defaults from meta.js
|
|
1223
|
-
const { website } = useWebsite() // Or block.website
|
|
1224
|
-
}
|
|
1225
|
-
```
|
|
1258
|
+
Pages are sequences of sections — the obvious layer. The framework also supports real nesting, without leaving markdown.
|
|
1226
1259
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
```jsx
|
|
1234
|
-
function Article({ content, block }) {
|
|
1235
|
-
if (block.dataLoading) return <DataPlaceholder />
|
|
1236
|
-
const article = content.data.articles?.[0] // focused record on a [slug] page
|
|
1237
|
-
if (!article) return <NotFound />
|
|
1238
|
-
return <ArticleView article={article} />
|
|
1239
|
-
}
|
|
1240
|
-
```
|
|
1260
|
+
| Pattern | How authored | Use when |
|
|
1261
|
+
|---|---|---|
|
|
1262
|
+
| **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
|
|
1263
|
+
| **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
|
|
1264
|
+
| **Insets** (`block.insets`) | `` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
|
|
1241
1265
|
|
|
1242
|
-
|
|
1266
|
+
Does the author write content *inside* the nested element? **Yes** → child sections. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children.
|
|
1243
1267
|
|
|
1244
|
-
**
|
|
1268
|
+
**Insets — embedding components in content.** Many section types need a "visual" — a hero's illustration, a split-content section's media. Classically an image or video. But what if it's a JSX + SVG diagram, a ThreeJS animation, an interactive playground? Elsewhere you'd reach for MDX or prop-drilling. Here the author writes standard image syntax:
|
|
1245
1269
|
|
|
1246
|
-
```
|
|
1247
|
-
|
|
1248
|
-
export default {
|
|
1249
|
-
data: {
|
|
1250
|
-
articles: '@/article', // named ref (this foundation)
|
|
1251
|
-
authors: '@std/person', // named ref (shared standard)
|
|
1252
|
-
pricing: { tier: { type: 'string', default: '' } }, // inline field map
|
|
1253
|
-
},
|
|
1254
|
-
}
|
|
1270
|
+
```markdown
|
|
1271
|
+
{variant=compact}
|
|
1255
1272
|
```
|
|
1256
1273
|
|
|
1257
|
-
|
|
1274
|
+
The developer builds `NetworkDiagram` as an ordinary React component with `inset: true` in `meta.js`. Kit's `<Visual>` renders the first non-empty candidate, so one section type works whether the author supplies an image, a video, or an interactive component:
|
|
1258
1275
|
|
|
1259
|
-
```
|
|
1260
|
-
|
|
1261
|
-
export default { '@acme': '../shared/acme-schemas', '@brand': process.env.BRAND_SCHEMAS }
|
|
1276
|
+
```jsx
|
|
1277
|
+
<Visual inset={block.insets[0]} video={content.videos[0]} image={content.images[0]} className="rounded-2xl" />
|
|
1262
1278
|
```
|
|
1263
1279
|
|
|
1264
|
-
|
|
1280
|
+
**Insets are full section types** — they receive `{ content, params, block }`. The alt text becomes `content.title` and attributes become `params`: `{note="Ready to go"}` → `content.title = "npm create uniweb"`, `params.note = "Ready to go"`.
|
|
1265
1281
|
|
|
1266
|
-
**
|
|
1282
|
+
**Don't use `hidden: true` on insets.** `hidden` means "don't export this component at all" (internal helpers); `inset: true` means "available for `@Component` references in markdown."
|
|
1267
1283
|
|
|
1268
|
-
|
|
1284
|
+
**Child sections.** You hit a complex layout — a 2:1 split with a panel and a main area. Your instinct says build a specialized component. Step back: the panel is a reusable section type, the main area is another, and the split is a Grid with `columns: "1fr 2fr"`. Your child components already adapt to narrow containers — container queries handle that. But hardcoding which components go where means the author can't rearrange or swap them. Child sections solve that:
|
|
1269
1285
|
|
|
1270
|
-
|
|
1286
|
+
```
|
|
1287
|
+
pages/home/
|
|
1288
|
+
├── page.yml
|
|
1289
|
+
├── 2-dashboard.md # Parent section (type: Grid, columns: "1fr 2fr")
|
|
1290
|
+
├── @sidebar-stats.md # Child (@ = not top-level)
|
|
1291
|
+
└── @main-chart.md
|
|
1292
|
+
```
|
|
1271
1293
|
|
|
1272
1294
|
```yaml
|
|
1273
|
-
#
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
where: { published: true, tags: featured }
|
|
1277
|
-
sort: date desc
|
|
1278
|
-
limit: 3
|
|
1295
|
+
# page.yml
|
|
1296
|
+
nest:
|
|
1297
|
+
dashboard: [sidebar-stats, main-chart]
|
|
1279
1298
|
```
|
|
1280
1299
|
|
|
1281
|
-
**
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
See [Data Fetching](https://github.com/uniweb/docs/blob/main/reference/data-fetching.md) for the full model and [Predicates](https://github.com/uniweb/docs/blob/main/authoring/predicates.md) for the where-object format with examples.
|
|
1286
|
-
|
|
1287
|
-
### block properties
|
|
1288
|
-
|
|
1289
|
-
| Property | Type | Description |
|
|
1290
|
-
|----------|------|-------------|
|
|
1291
|
-
| `block.page` | Page | Parent page |
|
|
1292
|
-
| `block.website` | Website | Site-level data and navigation |
|
|
1293
|
-
| `block.type` | string | Component type name |
|
|
1294
|
-
| `block.childBlocks` | Block[] | File-based child sections |
|
|
1295
|
-
| `block.insets` | Block[] | Inline `@Component` references |
|
|
1296
|
-
| `block.getInset(refId)` | Block | Lookup inset by refId |
|
|
1297
|
-
| `block.properties` | object | Raw frontmatter |
|
|
1298
|
-
| `block.rawContent` | object | ProseMirror document — passed internally when using `<Article block={block} />` |
|
|
1299
|
-
| `block.themeName` | string | `"light"`, `"medium"`, `"dark"` |
|
|
1300
|
-
| `block.stableId` | string | Stable ID from filename or `id:` |
|
|
1301
|
-
| `block.key` | string | Unique key across pages (path + id) — use as React key |
|
|
1302
|
-
| `block.path` | string | Page route this block belongs to |
|
|
1303
|
-
|
|
1304
|
-
### Section Wrapper
|
|
1305
|
-
|
|
1306
|
-
The runtime wraps every section in `<section>` with context class and background. Customize with static properties:
|
|
1300
|
+
**Rules:**
|
|
1301
|
+
- `@`-prefixed files are excluded from the top-level section list; `@@` nests deeper (grandchildren)
|
|
1302
|
+
- `nest:` maps parent name → child names, and is **flat**: `{ features: [a, b], a: [sub-1] }`
|
|
1303
|
+
- Children are ordered by position in the `nest:` array
|
|
1307
1304
|
|
|
1308
1305
|
```jsx
|
|
1309
|
-
|
|
1310
|
-
return (
|
|
1311
|
-
<div className="max-w-7xl mx-auto px-6">
|
|
1312
|
-
<h1 className="text-heading text-5xl font-bold">{content.title}</h1>
|
|
1313
|
-
</div>
|
|
1314
|
-
)
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
Hero.className = 'pt-32 md:pt-48' // Override spacing
|
|
1318
|
-
Hero.as = 'div' // Change wrapper element
|
|
1306
|
+
import { ChildBlocks } from '@uniweb/kit'
|
|
1319
1307
|
|
|
1320
|
-
export default
|
|
1308
|
+
export default function Grid({ block, params }) {
|
|
1309
|
+
return <div className={`grid grid-cols-${params.columns || 2} gap-6`}><ChildBlocks from={block} /></div>
|
|
1310
|
+
}
|
|
1321
1311
|
```
|
|
1322
1312
|
|
|
1323
|
-
|
|
1324
|
-
- `Component.as` — changes wrapper element: `'nav'` for headers, `'footer'` for footers.
|
|
1313
|
+
`ChildBlocks` renders each child as a **bare component by default** — no wrapper, no context class, no background. That's right for grid cells, tab panels, carousel slides. For the rare case where children should be independent sections with their own theming and backgrounds, pass `wrapAs="div"`.
|
|
1325
1314
|
|
|
1326
|
-
|
|
1315
|
+
Each child is a regular section with its own type, params, and content — and you're in the middle: wrap each child, filter by type, reorder, add container classes. The author decides *what* goes in the grid; your component decides *how* it renders. Tomorrow the author can swap a child for a different section type with no code change, and your components stay reusable wherever child sections are accepted.
|
|
1327
1316
|
|
|
1328
|
-
|
|
1329
|
-
Header.className = 'p-0'
|
|
1330
|
-
Header.as = 'header'
|
|
1331
|
-
```
|
|
1317
|
+
**Data and child blocks:** page-level `data:` is available to all blocks including children, and each child resolves data independently through the page → site hierarchy. If a child needs data, declare it in the child's `meta.js` or its frontmatter (`data: articles`).
|
|
1332
1318
|
|
|
1333
|
-
|
|
1319
|
+
**SSG:** insets, `<ChildBlocks>`, and `<Visual>` all render correctly during prerender. Inset components using React hooks internally trigger prerender warnings — expected and harmless; the page renders correctly client-side.
|
|
1334
1320
|
|
|
1335
|
-
|
|
1321
|
+
### Dividers — content boundaries
|
|
1336
1322
|
|
|
1337
|
-
|
|
1323
|
+
`---` creates a boundary between content regions; the developer decides what each region means.
|
|
1338
1324
|
|
|
1339
|
-
|
|
1340
|
-
import { H1, H2, H3, P, Span } from '@uniweb/kit'
|
|
1325
|
+
**UI regions.** `splitContent()` from `@uniweb/kit` splits parsed content at divider elements — e.g. lesson prose vs challenge content:
|
|
1341
1326
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
<H3 text={item.title} className="text-heading text-lg font-semibold" />
|
|
1345
|
-
<P text={content.paragraphs} className="text-body" />
|
|
1346
|
-
<Span text={listItem.paragraphs[0]} className="text-subtle" />
|
|
1327
|
+
```jsx
|
|
1328
|
+
const [lesson, challenge] = splitContent(content)
|
|
1347
1329
|
```
|
|
1348
1330
|
|
|
1349
|
-
|
|
1331
|
+
<!-- template:loom -->
|
|
1332
|
+
**Data-driven iteration (Loom).** Dividers separate header/body/footer in a repeated template. The split happens *before* Loom runs, because each segment gets a different variable context — the body contains item-level fields that don't exist on the top-level data. Header and footer are instantiated once against the full data; the body repeats per item.
|
|
1350
1333
|
|
|
1351
|
-
|
|
1334
|
+
```markdown
|
|
1335
|
+
---
|
|
1336
|
+
type: CvEntry
|
|
1337
|
+
source: education
|
|
1338
|
+
---
|
|
1339
|
+
# Education
|
|
1340
|
+
{COUNT OF education} degrees.
|
|
1341
|
+
---
|
|
1342
|
+
## {degree}
|
|
1343
|
+
{institution} — {field} ({start}–{end})
|
|
1344
|
+
```
|
|
1352
1345
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1346
|
+
**Which to use:** different data contexts per region → Loom pre-parse split (content handler). Same data, different UI treatment → kit post-parse split (`splitContent`). A foundation can use both — Loom splits and iterates to produce final content, then the component splits the result to route regions to different UI.
|
|
1347
|
+
<!-- /template:loom -->
|
|
1355
1348
|
|
|
1356
|
-
|
|
1357
|
-
<Prose content={content} block={block} />
|
|
1358
|
-
```
|
|
1349
|
+
### Custom layouts
|
|
1359
1350
|
|
|
1360
|
-
|
|
1351
|
+
Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`.
|
|
1361
1352
|
|
|
1362
1353
|
```jsx
|
|
1363
|
-
|
|
1354
|
+
// layouts/DocsLayout/index.jsx
|
|
1355
|
+
export default function DocsLayout({ header, body, footer, left, right, params }) {
|
|
1364
1356
|
return (
|
|
1365
|
-
<div>
|
|
1366
|
-
|
|
1367
|
-
|
|
1357
|
+
<div className="min-h-screen flex flex-col">
|
|
1358
|
+
{header && <header>{header}</header>}
|
|
1359
|
+
<div className="flex-1 flex">
|
|
1360
|
+
{left && <aside className="w-64">{left}</aside>}
|
|
1361
|
+
<main className="flex-1">{body}</main>
|
|
1362
|
+
{right && <aside className="w-64">{right}</aside>}
|
|
1363
|
+
</div>
|
|
1364
|
+
{footer && <footer>{footer}</footer>}
|
|
1368
1365
|
</div>
|
|
1369
1366
|
)
|
|
1370
1367
|
}
|
|
1371
1368
|
```
|
|
1372
1369
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
`Article` is an older alternative that renders from `block.rawContent` (raw ProseMirror nodes) — it renders everything including data blocks. Prefer `Prose` for new components.
|
|
1376
|
-
|
|
1377
|
-
**Visuals:**
|
|
1378
|
-
|
|
1379
|
-
```jsx
|
|
1380
|
-
import { Visual } from '@uniweb/kit'
|
|
1381
|
-
|
|
1382
|
-
<Visual inset={block.insets[0]} video={content.videos[0]} image={content.images[0]} className="rounded-2xl" />
|
|
1383
|
-
```
|
|
1384
|
-
|
|
1385
|
-
### Kit API by Use Case
|
|
1386
|
-
|
|
1387
|
-
**Rendering text:** `H1`–`H6`, `P`, `Span`, `Div`, `Text` (with `as` prop)
|
|
1388
|
-
|
|
1389
|
-
**Rendering content:** `Section` (full section with prose + layout), `Prose` (prose from parsed content sequence, skips data blocks), `Article` (raw ProseMirror rendering), `Render` (ProseMirror nodes → React), `ChildBlocks` (render child sections)
|
|
1390
|
-
|
|
1391
|
-
**Rendering media:** `Visual` (first non-empty: inset/video/image), `Image`, `Media`, `Icon`
|
|
1392
|
-
|
|
1393
|
-
**Navigation and routing:** `Link` (`to`/`href`, `to="page:about"` for page ID resolution, auto `target="_blank"` for external, `reload` for full page reload), `useActiveRoute()`, `useWebsite()`, `useRouting()`
|
|
1394
|
-
|
|
1395
|
-
**Header and layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
|
|
1396
|
-
|
|
1397
|
-
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`, `useTheme(name)`
|
|
1398
|
-
|
|
1399
|
-
**Data and theming:** `useThemeData()` (programmatic color access), `useColorContext(block)`
|
|
1400
|
-
|
|
1401
|
-
**Utilities:** `cn()` (Tailwind class merge — `cn('px-4', condition && 'bg-primary')` resolves conflicts), `Link`, `Image`, `Asset`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`
|
|
1402
|
-
|
|
1403
|
-
> **`cn()` gotcha — a later `text-<size>` silently drops an earlier `leading-*`.** Tailwind's size classes set line height too, so `cn()` treats the size as replacing the leading: `cn('leading-[1.1] text-4xl')` → `text-4xl`. Put the size first, or fold the leading into it (`text-[clamp(2rem,5vw,4rem)]/[1.1]`). Most likely to bite when the size comes from a lookup and the leading sits in a shared base string.
|
|
1404
|
-
|
|
1405
|
-
**Other styled:** `SidebarLayout`, `Prose`, `Article`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
|
|
1406
|
-
|
|
1407
|
-
### Hook Signatures
|
|
1408
|
-
|
|
1409
|
-
```js
|
|
1410
|
-
useActiveRoute() → { route, rootSegment, isActive(pageOrRoute), isActiveOrAncestor(pageOrRoute) }
|
|
1411
|
-
useMobileMenu() → { isOpen, open, close, toggle } // auto-closes on route change
|
|
1412
|
-
useScrolled(threshold?) → boolean // true when scrolled past threshold (px)
|
|
1413
|
-
useAppearance() → { scheme, setScheme, toggle, canToggle, schemes }
|
|
1414
|
-
useWebsite() → { website } // the Website object
|
|
1415
|
-
useThemeData() → Theme // programmatic color access
|
|
1416
|
-
useColorContext(block) → 'light' | 'medium' | 'dark' // current section context
|
|
1417
|
-
```
|
|
1418
|
-
|
|
1419
|
-
`isActive` and `isActiveOrAncestor` accept a Page object or a route string. `useAppearance` reflects the site scheme from `appearance:` in `theme.yml` — `scheme` is `'light'`|`'dark'`, `canToggle` reflects `allowToggle`. The runtime applies the scheme to `<html>` before paint; the hook reads it, `toggle`/`setScheme` flip it and persist the choice to localStorage. First visit follows the OS when the site has dark mode. Full model: *Light/dark appearance* above.
|
|
1370
|
+
Layouts are full components with their own `params` in `meta.js`, not just structural wrappers — a header height or sidebar width is a layout param.
|
|
1420
1371
|
|
|
1421
|
-
|
|
1372
|
+
**Layout `meta.js`** declares areas and optional scroll behavior: `{ areas: ['header', 'footer', 'left'], scroll: 'self' }`. Area names are arbitrary. `scroll` controls scroll restoration: unset = runtime manages `window` (default), `'self'` = the layout scrolls itself, or a CSS selector (`'main'`) = runtime manages that element.
|
|
1422
1373
|
|
|
1423
|
-
|
|
1374
|
+
**Layout content** lives in `site/layout/` — `header.md`, `footer.md` for the default layout, or a named subdirectory (`site/layout/marketing/`) for named layouts. Named subdirectories are self-contained — no inheritance. Cascade: `page.yml` → `folder.yml` → `site.yml` → foundation `defaultLayout` → `"default"`.
|
|
1424
1375
|
|
|
1425
|
-
|
|
1426
|
-
{content.icons.map((icon, i) => <Icon key={i} {...icon} />)} // From content
|
|
1427
|
-
<Icon name="search" /> // Lucide (default)
|
|
1428
|
-
<Icon name="hi2-arrow-right" /> // Other library
|
|
1429
|
-
<Icon name="close" /> // Built-in (no network)
|
|
1430
|
-
```
|
|
1431
|
-
|
|
1432
|
-
The `name` prop handles everything: built-in names, Lucide icons (default when no library prefix), and other libraries via prefix (`hi2-arrow-right`, `tb-star`). From content, spread the icon object which has `library` + `name` fields.
|
|
1433
|
-
|
|
1434
|
-
Other props: `svg` (direct SVG string), `url` (fetch from URL), `size` (default `'24'`), `className`.
|
|
1435
|
-
|
|
1436
|
-
Built-in icons (instant, no network): `check`, `close`, `menu`, `chevronDown`, `chevronRight`, `externalLink`, `download`, `play`, and a few others.
|
|
1437
|
-
|
|
1438
|
-
### Content Patterns for Header and Footer
|
|
1439
|
-
|
|
1440
|
-
Layout sections (`header.md`, `footer.md`) are regular section types — they support the full content shape including tagged data blocks, lists, links, icons, and items. The only difference is they render on every page instead of one.
|
|
1441
|
-
|
|
1442
|
-
Header and Footer combine several content categories. Use different parts of the content shape for each role:
|
|
1443
|
-
|
|
1444
|
-
**Header** — title for logo, list for nav, standalone link for CTA:
|
|
1376
|
+
Layout sections are regular section types — they support the full content shape, including tagged data blocks, lists, links, and items. The only difference is they render on every page. Each content category takes a different role:
|
|
1445
1377
|
|
|
1446
1378
|
````markdown
|
|
1447
1379
|
---
|
|
1448
1380
|
type: Header
|
|
1449
1381
|
---
|
|
1382
|
+
# Acme Inc ← content.title — the logo
|
|
1450
1383
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
-  [How It Works](/how-it-works)
|
|
1454
|
-
-  [For Teams](/for-teams)
|
|
1384
|
+
-  [How It Works](/how) ← content.lists[0] — the nav
|
|
1455
1385
|
-  [Docs](/docs)
|
|
1456
1386
|
|
|
1457
|
-
[Get Started](/docs/quickstart)
|
|
1387
|
+
[Get Started](/docs/quickstart) ← content.links[0] — the CTA
|
|
1458
1388
|
|
|
1459
1389
|
```yaml:config
|
|
1460
|
-
github: https://github.com/acme
|
|
1461
|
-
version: v2.1.0
|
|
1390
|
+
github: https://github.com/acme ← content.data.config — everything else
|
|
1462
1391
|
```
|
|
1463
1392
|
````
|
|
1464
1393
|
|
|
1465
1394
|
```jsx
|
|
1466
|
-
function Header({ content
|
|
1395
|
+
function Header({ content }) {
|
|
1467
1396
|
const logo = content.title
|
|
1468
1397
|
const navItems = content.lists[0] || []
|
|
1469
1398
|
const cta = content.links[0]
|
|
1470
1399
|
const config = content.data?.config
|
|
1471
1400
|
}
|
|
1401
|
+
|
|
1402
|
+
function Footer({ content }) {
|
|
1403
|
+
const tagline = content.paragraphs[0] // a plain paragraph
|
|
1404
|
+
const columns = (content.lists[0] || []).map(group => ({
|
|
1405
|
+
label: group.paragraphs[0], // nested list → columns
|
|
1406
|
+
links: group.lists[0]?.map(item => item.links[0])
|
|
1407
|
+
}))
|
|
1408
|
+
}
|
|
1472
1409
|
```
|
|
1473
1410
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
````markdown
|
|
1477
|
-
---
|
|
1478
|
-
type: Footer
|
|
1479
|
-
---
|
|
1411
|
+
### Cross-block communication
|
|
1480
1412
|
|
|
1481
|
-
|
|
1413
|
+
Section types sometimes coordinate — the typical case is a Header that needs to know whether the section below supports a floating translucent overlay (a Hero with a full-bleed background does, a plain text section doesn't). The section that **owns the capability declares it**; the section that **needs to adapt reads it**. `getNextBlockInfo()` — and `getPrevBlockInfo()`, `page.getFirstBodyBlockInfo()` — expose two channels:
|
|
1482
1414
|
|
|
1483
|
-
-
|
|
1484
|
-
|
|
1485
|
-
- [Pricing](/pricing)
|
|
1486
|
-
- Developers
|
|
1487
|
-
- [Docs](/docs)
|
|
1488
|
-
- [GitHub](https://github.com/acme){target=_blank}
|
|
1415
|
+
- **`context`** — static capabilities from `meta.js`. Never changes.
|
|
1416
|
+
- **`state`** — dynamic runtime state via `useBlockState()`. Initial value from `initialState` in `meta.js`.
|
|
1489
1417
|
|
|
1490
|
-
```
|
|
1491
|
-
|
|
1418
|
+
```js
|
|
1419
|
+
// Hero/meta.js
|
|
1420
|
+
export default {
|
|
1421
|
+
context: { allowTranslucentTop: true }, // "I always support this"
|
|
1422
|
+
initialState: { allowTranslucentTop: true }, // …or start true and let logic change it
|
|
1423
|
+
}
|
|
1492
1424
|
```
|
|
1493
|
-
````
|
|
1494
1425
|
|
|
1495
1426
|
```jsx
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
const columns = content.lists[0] || []
|
|
1499
|
-
const legal = content.data?.legal
|
|
1427
|
+
// Hero/index.jsx — dynamic case
|
|
1428
|
+
const [state, setState] = block.useBlockState(useState)
|
|
1500
1429
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
}))
|
|
1505
|
-
}
|
|
1430
|
+
// Header/index.jsx — reads dynamic state, falls back to static context
|
|
1431
|
+
const info = block.getNextBlockInfo()
|
|
1432
|
+
const isFloating = info?.state?.allowTranslucentTop ?? info?.context?.allowTranslucentTop ?? false
|
|
1506
1433
|
```
|
|
1507
1434
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
```javascript
|
|
1511
|
-
export default {
|
|
1512
|
-
title: 'Feature Grid',
|
|
1513
|
-
description: 'Grid of feature cards with icons',
|
|
1514
|
-
category: 'marketing',
|
|
1515
|
-
// hidden: true, // Exclude from export
|
|
1516
|
-
// background: 'self', // Component renders its own background
|
|
1517
|
-
// inset: true, // Available for @ComponentName in markdown
|
|
1518
|
-
// visuals: 1, // Expects 1 visual
|
|
1519
|
-
// children: true, // Accepts child sections
|
|
1435
|
+
The key names are yours to design — they're not framework fields.
|
|
1520
1436
|
|
|
1521
|
-
|
|
1522
|
-
title: 'Section heading',
|
|
1523
|
-
paragraphs: 'Introduction [0-1]',
|
|
1524
|
-
items: 'Feature cards with icon, title, description',
|
|
1525
|
-
},
|
|
1437
|
+
### block, website, and page
|
|
1526
1438
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1439
|
+
```jsx
|
|
1440
|
+
const { website } = useWebsite()
|
|
1441
|
+
const page = website.activePage
|
|
1442
|
+
```
|
|
1531
1443
|
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1444
|
+
| `block` property | Description |
|
|
1445
|
+
|----------|-------------|
|
|
1446
|
+
| `block.page` / `block.website` | Parent page / site-level data and navigation |
|
|
1447
|
+
| `block.type` | Component type name |
|
|
1448
|
+
| `block.childBlocks` / `block.insets` | Child sections / inline `@Component` references |
|
|
1449
|
+
| `block.getInset(refId)` | Lookup an inset by refId |
|
|
1450
|
+
| `block.properties` | Raw frontmatter |
|
|
1451
|
+
| `block.rawContent` | ProseMirror document — passed internally by `<Article block={block} />` |
|
|
1452
|
+
| `block.themeName` | `"light"`, `"medium"`, `"dark"` |
|
|
1453
|
+
| `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
|
|
1454
|
+
| `block.path` | Page route this block belongs to |
|
|
1455
|
+
| `block.dataLoading` | True while declared data is still resolving |
|
|
1536
1456
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1457
|
+
```jsx
|
|
1458
|
+
// getPageHierarchy(options) →
|
|
1459
|
+
// [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
|
|
1460
|
+
// Options: for: 'header'|'footer'|<area> (respects hideIn) · nested: true (default)
|
|
1461
|
+
// includeHidden: false · filter: (page) => bool · sort: (a, b) => number
|
|
1462
|
+
website.getPageHierarchy({ for: 'header' }) // = website.getHeaderPages()
|
|
1463
|
+
website.getPageHierarchy({ nested: false }) // = website.getAllPages()
|
|
1539
1464
|
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
// Example: a Hero might declare this so a Header knows it can float.
|
|
1543
|
-
// allowTranslucentTop: true,
|
|
1544
|
-
},
|
|
1465
|
+
website.name, website.basePath
|
|
1466
|
+
website.hasMultipleLocales(), website.getLocales(), website.getActiveLocale(), website.getLocaleUrl('es')
|
|
1545
1467
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
},
|
|
1552
|
-
}
|
|
1468
|
+
page.title, page.label, page.route, page.description
|
|
1469
|
+
page.isHidden(), page.showInHeader(), page.showInFooter()
|
|
1470
|
+
page.hasContent() // true if the page has its own content (not just a folder)
|
|
1471
|
+
page.hasChildren(), page.children, page.parent
|
|
1472
|
+
page.getNavigableRoute() // first descendant route with content (for linking)
|
|
1553
1473
|
```
|
|
1554
1474
|
|
|
1555
|
-
|
|
1475
|
+
Content-less containers appear as group nodes (`hasContent: false`) — use `navigableRoute` for links, `title` for display, `hasContent` to style differently.
|
|
1556
1476
|
|
|
1557
|
-
###
|
|
1477
|
+
### Data
|
|
1478
|
+
|
|
1479
|
+
A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{key}` — no opt-in in `meta.js`.
|
|
1558
1480
|
|
|
1559
|
-
|
|
1481
|
+
**Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a template page (`[slug]/`), the matched record is delivered under the *same* key as a single-element array — the detail section reads `content.data.articles[0]`. When nothing matches, the key is `[]`. The runtime never coerces to a single object and never synthesizes a singular key.
|
|
1482
|
+
|
|
1483
|
+
```jsx
|
|
1484
|
+
function Article({ content, block }) {
|
|
1485
|
+
if (block.dataLoading) return <DataPlaceholder />
|
|
1486
|
+
const article = content.data.articles?.[0] // focused record on a [slug] page
|
|
1487
|
+
if (!article) return <NotFound />
|
|
1488
|
+
return <ArticleView article={article} />
|
|
1489
|
+
}
|
|
1490
|
+
```
|
|
1560
1491
|
|
|
1561
|
-
|
|
1492
|
+
Components can ignore keys in `content.data` they don't need, the same way unused `params` are ignored. When a record genuinely needs to be a single object, that's the foundation's job — read `[0]`, or reshape once with a `handlers.data` hook.
|
|
1562
1493
|
|
|
1563
|
-
|
|
1494
|
+
**Declaring schemas.** `meta.js` declares the schema for each `content.data` key with a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, or an **inline rich-form** (`{ fields: [...] }`, an editor form). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). The schema is a hint — it supplies field defaults and drives the editor, not delivery, which is default-on. For an explicit opt-out (rare), set `data: false`.
|
|
1564
1495
|
|
|
1565
1496
|
```js
|
|
1566
|
-
// meta.js
|
|
1497
|
+
// meta.js
|
|
1567
1498
|
export default {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
}
|
|
1499
|
+
data: {
|
|
1500
|
+
articles: '@/article', // named ref (this foundation)
|
|
1501
|
+
authors: '@std/person', // named ref (shared standard)
|
|
1502
|
+
pricing: { tier: { type: 'string', default: '' } }, // inline field map
|
|
1503
|
+
},
|
|
1574
1504
|
}
|
|
1575
1505
|
```
|
|
1576
1506
|
|
|
1577
|
-
|
|
1578
|
-
// sections/Hero/index.jsx — the front desk
|
|
1579
|
-
import { SliderHero } from '../../components/SliderHero'
|
|
1580
|
-
import { ContactHero } from '../../components/ContactHero'
|
|
1581
|
-
|
|
1582
|
-
const variants = { slider: SliderHero, contact: ContactHero }
|
|
1507
|
+
A foundation can route a scope to a plain folder of schema files instead of a package via an optional `schemas.config.js` at its root — `export default { '@acme': '../shared/acme-schemas' }`. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; a routed scope has no package fallback for a missing schema (it errors rather than silently loading a different definition). Per-schema keys override single entries (most-specific wins: file › directory › package). Worked examples: `development/schemas-in-practice.md`.
|
|
1583
1508
|
|
|
1584
|
-
|
|
1585
|
-
const Variant = variants[params.variant] || SliderHero
|
|
1509
|
+
**Authoring queries.** Fetch declarations accept `where:` (a where-object predicate), `sort:` (e.g. `date desc`), and `limit:`. Whether the source evaluates them or the framework applies them as a runtime fallback is a transport detail controlled by the site's `fetcher.supports:` declaration.
|
|
1586
1510
|
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
// Content that only some variants use
|
|
1595
|
-
images={content.images}
|
|
1596
|
-
formData={content.data?.quote}
|
|
1597
|
-
// Translated params — author vocabulary → developer props
|
|
1598
|
-
interval={params.slideInterval}
|
|
1599
|
-
compact={params.density === 'compact'}
|
|
1600
|
-
transition={params.style === 'dramatic' ? 'zoom' : 'fade'}
|
|
1601
|
-
/>
|
|
1602
|
-
)
|
|
1603
|
-
}
|
|
1511
|
+
```yaml
|
|
1512
|
+
# pages/blog/page.yml
|
|
1513
|
+
fetch:
|
|
1514
|
+
collection: articles
|
|
1515
|
+
where: { published: true, tags: featured }
|
|
1516
|
+
sort: date desc
|
|
1517
|
+
limit: 3
|
|
1604
1518
|
```
|
|
1605
1519
|
|
|
1606
|
-
|
|
1520
|
+
**Lean lists with `deferred:`.** Collections with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` in `site.yml`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based collections) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`.
|
|
1607
1521
|
|
|
1608
|
-
|
|
1522
|
+
**Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useEntityDetail`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
|
|
1609
1523
|
|
|
1610
|
-
|
|
1524
|
+
**Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. Warns by default; `--strict` for a non-zero CI exit. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. Remote (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred.
|
|
1611
1525
|
|
|
1612
|
-
|
|
1526
|
+
### Fetching from other sources (`fetcher:`)
|
|
1613
1527
|
|
|
1614
|
-
|
|
1615
|
-
// sections/Pricing/index.jsx
|
|
1616
|
-
import PricingCard from '#components/PricingCard'
|
|
1617
|
-
import formatPrice from '#utils/formatPrice'
|
|
1528
|
+
A site isn't limited to file-based collections. The `fetcher:` block in `site.yml` tunes the framework's default fetcher and opts into foundation-provided **named transports** per schema:
|
|
1618
1529
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1530
|
+
```yaml
|
|
1531
|
+
# site.yml
|
|
1532
|
+
fetcher:
|
|
1533
|
+
baseUrl: https://api.example.com
|
|
1534
|
+
headers: { X-Tenant: acme }
|
|
1535
|
+
envelope: { collection: data.items, item: data.article, error: errors.0.message }
|
|
1536
|
+
|
|
1537
|
+
supports: [where, limit, sort] # which operators the source evaluates natively
|
|
1538
|
+
|
|
1539
|
+
transports:
|
|
1540
|
+
articles: uniweb # a foundation-registered transport handles `data: articles`
|
|
1541
|
+
events: default # explicitly route back to the default fetcher
|
|
1542
|
+
uniweb: # binding config that transport reads
|
|
1543
|
+
siteFolder: abc-123-def
|
|
1629
1544
|
```
|
|
1630
1545
|
|
|
1631
|
-
|
|
1546
|
+
**`supports:` is a capability declaration, not a switch.** With `supports: []` (the default) the source is treated as static: the whole collection is fetched and the framework applies `where` / `sort` / `limit` in JS afterward, so two pages with different predicates share one cache entry. With `supports: [where]` the predicate ships in the request and the cache splits per predicate. With `[where, limit, sort]` the source returns the final result and the framework passes it through. Pushdown applies only to remote `url:` sources — local `path:` reads are static files and always evaluate operators as a runtime fallback.
|
|
1632
1547
|
|
|
1633
|
-
**
|
|
1548
|
+
**Selection is explicit and site-owned.** For each request: `fetcher.transports[schema]` wins if set; otherwise `fetcher.transports.default` if set; otherwise the framework's default fetcher applies `baseUrl` / `headers` / `envelope`. No route-walking, no `match()` predicates, no silent foundation-owned routing — the site picks.
|
|
1634
1549
|
|
|
1635
|
-
**
|
|
1550
|
+
> **Never put secrets in `site.yml`** — every value in it is public to the browser. Sites needing private credentials proxy through the same origin at the deployment layer, so the site fetches `/api/…` and the proxy attaches the credential server-side.
|
|
1636
1551
|
|
|
1637
|
-
|
|
1552
|
+
**Failures degrade rather than break:** a failed fetch falls back to `[]`, logs a build warning, and the page still renders. Components should handle the empty case — which the guaranteed content shape already encourages.
|
|
1638
1553
|
|
|
1639
|
-
|
|
1640
|
-
src/ # the foundation package (folder name is `src`)
|
|
1641
|
-
├── sections/ # Section types (auto-discovered)
|
|
1642
|
-
│ ├── Hero.jsx # Bare file — no folder needed
|
|
1643
|
-
│ ├── Features/ # Folder when you need meta.js
|
|
1644
|
-
│ │ ├── index.jsx
|
|
1645
|
-
│ │ └── meta.js
|
|
1646
|
-
│ └── insets/ # Organizational subdirectory (lowercase)
|
|
1647
|
-
│ └── Diagram/
|
|
1648
|
-
│ ├── index.jsx
|
|
1649
|
-
│ └── meta.js
|
|
1650
|
-
├── layouts/ # Custom layouts (optional, auto-discovered)
|
|
1651
|
-
│ └── DocsLayout/
|
|
1652
|
-
│ ├── index.jsx
|
|
1653
|
-
│ └── meta.js
|
|
1654
|
-
├── components/ # Your React components (no meta.js, not selectable)
|
|
1655
|
-
│ ├── ui/
|
|
1656
|
-
│ │ └── button.jsx
|
|
1657
|
-
│ └── Card.jsx
|
|
1658
|
-
├── utils/ # Helper functions, non-React logic
|
|
1659
|
-
│ └── splitContent.js
|
|
1660
|
-
├── main.js
|
|
1661
|
-
├── styles.css
|
|
1662
|
-
└── package.json # name: "src"
|
|
1663
|
-
```
|
|
1554
|
+
Recipes for staying on the default fetcher, and for writing a custom transport: `development/connecting-a-backend.md`.
|
|
1664
1555
|
|
|
1665
|
-
|
|
1556
|
+
Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
|
|
1666
1557
|
|
|
1667
|
-
|
|
1558
|
+
<!-- template:loom -->
|
|
1559
|
+
### Content handlers
|
|
1668
1560
|
|
|
1669
|
-
|
|
1561
|
+
Content handlers are a transform layer between data assembly and the component, declared in `main.js` and applied to every section in the foundation. The standard content shape is the default; handlers reshape it. All three are optional, run per block, and are error-isolated — a failing handler logs a warning and falls back to default behavior.
|
|
1670
1562
|
|
|
1671
|
-
|
|
|
1672
|
-
|
|
1673
|
-
|
|
|
1674
|
-
|
|
|
1563
|
+
| Handler | When it runs | Receives | Returns | Purpose |
|
|
1564
|
+
|---|---|---|---|---|
|
|
1565
|
+
| `data` | After data assembly, before content transform | `(data, block)` | New data object, or null | Filter, reshape, or augment assembled data |
|
|
1566
|
+
| `content` | After the data handler | `(data, block)` | ProseMirror document, or null | Transform raw content (Loom instantiation, template expansion) |
|
|
1567
|
+
| `props` | After parsing, defaults, and guarantees | `(content, params, block)` | `{ content, params }`, or null | Post-process the final shape before the component sees it |
|
|
1675
1568
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1569
|
+
The `content` handler receives `block.parsedContent.data` and reads raw ProseMirror from `block.rawContent`, returning a new ProseMirror document that the framework re-parses through the semantic parser. Returning `null` — or the same reference as `block.rawContent` — signals no change.
|
|
1570
|
+
|
|
1571
|
+
> **`block.rawContent` may or may not be wrapped.** Unwrap it defensively — `const doc = block.rawContent?.doc ?? block.rawContent` — before passing it to `instantiateContent` / `instantiateRepeated`. This is the first thing a hand-written handler gets wrong.
|
|
1572
|
+
|
|
1573
|
+
**Loom integration** is the most common use: resolving `{placeholder}` expressions against live data. `@uniweb/loom` provides a factory:
|
|
1574
|
+
|
|
1575
|
+
```js
|
|
1576
|
+
import { createLoomHandlers } from '@uniweb/loom'
|
|
1680
1577
|
|
|
1681
|
-
|
|
1682
|
-
|
|
1578
|
+
export default {
|
|
1579
|
+
handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }),
|
|
1580
|
+
}
|
|
1683
1581
|
```
|
|
1684
1582
|
|
|
1685
|
-
|
|
1583
|
+
`vars` extracts the Loom variable namespace. The returned `content` handler reads the `source` and `where` frontmatter params: without `source` it does simple substitution; with `source` it splits the markdown at `---` dividers and repeats the body per data item. `where` filters the source array first — `type = 'book'` (equality), `year > 1870` (comparison), `refereed` (truthy), `type = 'book' AND refereed` (combination). Aggregates like `{COUNT OF publications}` reflect the filtered set.
|
|
1686
1584
|
|
|
1687
|
-
|
|
1585
|
+
For cases the factory doesn't cover, write handlers directly using `Loom`, `instantiateContent`, and `instantiateRepeated` from `@uniweb/loom`.
|
|
1688
1586
|
|
|
1689
|
-
|
|
1587
|
+
> **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
|
|
1690
1588
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
const page = website.activePage
|
|
1589
|
+
**Reserved frontmatter:** `source` and `where` are convention-level reserved fields — they flow to both `block.properties` (for handler access) and `params` (visible to components), consistent with `background` and `theme`. Components can ignore them. List them in `meta.js` params with descriptions so the editor and schema recognize them.
|
|
1590
|
+
<!-- /template:loom -->
|
|
1694
1591
|
|
|
1695
|
-
|
|
1696
|
-
// Returns [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
|
|
1697
|
-
//
|
|
1698
|
-
// Options:
|
|
1699
|
-
// for: 'header' | 'footer' | <area> — filter by nav area (respects the page's hideIn)
|
|
1700
|
-
// nested: true (default) — nested hierarchy with children; false = flat list
|
|
1701
|
-
// includeHidden: false — include hidden pages
|
|
1702
|
-
// filter: (page) => bool — custom filter function
|
|
1703
|
-
// sort: (a, b) => number — custom sort function
|
|
1704
|
-
//
|
|
1705
|
-
// Convenience methods:
|
|
1706
|
-
// website.getHeaderPages() — same as getPageHierarchy({ for: 'header' })
|
|
1707
|
-
// website.getFooterPages() — same as getPageHierarchy({ for: 'footer' })
|
|
1708
|
-
// website.getAllPages() — flat list: getPageHierarchy({ nested: false })
|
|
1709
|
-
//
|
|
1710
|
-
// Common patterns:
|
|
1711
|
-
website.getPageHierarchy({ for: 'header' }) // Header nav (excludes pages with 'header' in hideIn)
|
|
1712
|
-
website.getPageHierarchy() // Full nested hierarchy (no nav filtering)
|
|
1713
|
-
website.getPageHierarchy({ nested: false }) // Flat list of all visible pages
|
|
1714
|
-
website.getPageHierarchy({ nested: false, includeHidden: true }) // Everything including hidden
|
|
1715
|
-
|
|
1716
|
-
// Core properties
|
|
1717
|
-
website.name // Site name from site.yml
|
|
1718
|
-
website.basePath // Deployment base path (e.g., '/docs/')
|
|
1719
|
-
|
|
1720
|
-
// Locale
|
|
1721
|
-
website.hasMultipleLocales()
|
|
1722
|
-
website.getLocales() // [{ code, label, isDefault }]
|
|
1723
|
-
website.getActiveLocale()
|
|
1724
|
-
website.getLocaleUrl('es')
|
|
1725
|
-
|
|
1726
|
-
// Route detection
|
|
1727
|
-
const { isActive, isActiveOrAncestor } = useActiveRoute()
|
|
1728
|
-
|
|
1729
|
-
// Appearance
|
|
1730
|
-
const { scheme, toggle, canToggle } = useAppearance()
|
|
1731
|
-
|
|
1732
|
-
// Page properties
|
|
1733
|
-
page.title, page.label, page.route, page.description
|
|
1734
|
-
page.isHidden(), page.showInHeader(), page.showInFooter()
|
|
1735
|
-
page.hasContent() // True if page has its own content (not just a folder)
|
|
1736
|
-
page.hasChildren(), page.children // Direct child Page instances
|
|
1737
|
-
page.parent // Parent Page instance (null for root pages)
|
|
1738
|
-
page.getNavigableRoute() // First descendant route with content (for linking)
|
|
1592
|
+
---
|
|
1739
1593
|
|
|
1740
|
-
|
|
1741
|
-
// { route: '/courses/intro', title: 'Introduction', hasContent: false,
|
|
1742
|
-
// navigableRoute: '/courses/intro/lesson-1', children: [...] }
|
|
1743
|
-
// Use navigableRoute for links, title for display, hasContent to style differently.
|
|
1744
|
-
```
|
|
1594
|
+
## Part 5 — Commands, shipping, and migration
|
|
1745
1595
|
|
|
1746
|
-
|
|
1596
|
+
```bash
|
|
1597
|
+
uniweb dev # Start dev server (picks the site for you)
|
|
1598
|
+
pnpm build / pnpm preview # Build for production / preview the build (SSG + SPA)
|
|
1747
1599
|
|
|
1748
|
-
|
|
1600
|
+
uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
|
|
1601
|
+
uniweb deploy --host=<adapter> # Build + upload now, from this machine
|
|
1602
|
+
uniweb add ci --host=<adapter> # CI so every push deploys — usually best for a free host
|
|
1603
|
+
uniweb export # Build dist/ for any static host (no Uniweb account)
|
|
1604
|
+
uniweb publish # Ship to Uniweb hosting, foundation included (needs `uniweb login`)
|
|
1749
1605
|
|
|
1750
|
-
|
|
1606
|
+
uniweb add ci --target foundation # Publish a foundation for free at permanent versioned URLs
|
|
1607
|
+
# (GitHub Pages → foundations/<name>/<version>/entry.js)
|
|
1751
1608
|
|
|
1752
|
-
|
|
1753
|
-
|
|
1609
|
+
uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
|
|
1610
|
+
uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
|
|
1754
1611
|
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
// nextBlockInfo.context → static (meta.js)
|
|
1759
|
-
// nextBlockInfo.state → dynamic (useBlockState)
|
|
1760
|
-
```
|
|
1612
|
+
uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
|
|
1613
|
+
uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
|
|
1614
|
+
uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
|
|
1761
1615
|
|
|
1762
|
-
|
|
1616
|
+
uniweb -v # Installed CLI version — and whether a newer one exists
|
|
1617
|
+
uniweb doctor # Diagnose project configuration (--fix to auto-repair)
|
|
1618
|
+
uniweb validate # Check file-based data against declared schemas (--strict for CI)
|
|
1619
|
+
uniweb update # Align @uniweb/* deps + AGENTS.md to the CLI (--dry-run, --yes)
|
|
1620
|
+
uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
|
|
1763
1621
|
|
|
1764
|
-
|
|
1765
|
-
// Hero/meta.js — "I always support a translucent header over me"
|
|
1766
|
-
export default {
|
|
1767
|
-
context: { allowTranslucentTop: true },
|
|
1768
|
-
}
|
|
1622
|
+
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
1769
1623
|
```
|
|
1770
1624
|
|
|
1771
|
-
|
|
1772
|
-
// Header/index.jsx — adapts based on what's below
|
|
1773
|
-
const nextBlockInfo = block.getNextBlockInfo()
|
|
1774
|
-
const isFloating = nextBlockInfo?.context?.allowTranslucentTop || false
|
|
1775
|
-
```
|
|
1625
|
+
### Where a site can live
|
|
1776
1626
|
|
|
1777
|
-
**
|
|
1627
|
+
**There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
|
|
1778
1628
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1629
|
+
| Path | Command | Good for |
|
|
1630
|
+
|---|---|---|
|
|
1631
|
+
| Free static host, via CI | `uniweb add ci --host=<adapter>` | most self-hosted sites — one command, then every push deploys |
|
|
1632
|
+
| Free static host, from this machine | `uniweb deploy --host=<adapter>` | one-off or manual deploys |
|
|
1633
|
+
| Any static host at all | `uniweb export` | full control; no Uniweb account needed |
|
|
1634
|
+
| Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
|
|
1785
1635
|
|
|
1786
|
-
|
|
1787
|
-
// Hero/index.jsx — conditionally updates
|
|
1788
|
-
function Hero({ content, block }) {
|
|
1789
|
-
const [state, setState] = block.useBlockState(useState)
|
|
1790
|
-
// state.allowTranslucentTop is true initially (from meta.js)
|
|
1791
|
-
// Component logic can change it: setState({ allowTranslucentTop: false })
|
|
1792
|
-
}
|
|
1793
|
-
```
|
|
1636
|
+
`uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. Destination config lives in `deploy.yml` beside `site.yml`; host credentials come from the environment, never from that committed file.
|
|
1794
1637
|
|
|
1795
|
-
|
|
1796
|
-
// Header/index.jsx — reads dynamic state, falls back to static context
|
|
1797
|
-
const nextBlockInfo = block.getNextBlockInfo()
|
|
1798
|
-
const isFloating = nextBlockInfo?.state?.allowTranslucentTop
|
|
1799
|
-
?? nextBlockInfo?.context?.allowTranslucentTop
|
|
1800
|
-
?? false
|
|
1801
|
-
```
|
|
1638
|
+
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
1802
1639
|
|
|
1803
|
-
|
|
1640
|
+
### Uniweb Cloud and the two-sided workflow
|
|
1804
1641
|
|
|
1805
|
-
|
|
1642
|
+
`uniweb publish` ships to Uniweb Cloud, and it's worth understanding what that buys, because it isn't only hosting — it's how a team of technical and non-technical people works on one site.
|
|
1806
1643
|
|
|
1807
|
-
|
|
1644
|
+
**Content authors work visually in the Uniweb App.** They compose the same extended markdown and set the same component params you defined, through a visual editor — never touching code, git, or the CLI. They see exactly the section types your foundation offers and exactly the knobs each one exposes, because **every `meta.js` is registered as the foundation's schema** when you publish. Your `meta.js` is the app's UI (see *meta.js* in Part 4).
|
|
1808
1645
|
|
|
1809
|
-
|
|
1646
|
+
**Sync is developer-only and one-sided by design.** `uniweb push` and `uniweb pull` are your commands, not theirs:
|
|
1810
1647
|
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
name: 'My Template',
|
|
1815
|
-
description: 'A brief description',
|
|
1816
|
-
defaultLayout: 'DocsLayout',
|
|
1817
|
-
}
|
|
1818
|
-
```
|
|
1648
|
+
- **The app is the live source of truth for content** — where authors work and where your push lands live.
|
|
1649
|
+
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
1650
|
+
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
1819
1651
|
|
|
1820
|
-
|
|
1821
|
-
// layouts/DocsLayout/index.jsx
|
|
1822
|
-
export default function DocsLayout({ header, body, footer, left, right, params }) {
|
|
1823
|
-
return (
|
|
1824
|
-
<div className="min-h-screen flex flex-col">
|
|
1825
|
-
{header && <header>{header}</header>}
|
|
1826
|
-
<div className="flex-1 flex">
|
|
1827
|
-
{left && <aside className="w-64">{left}</aside>}
|
|
1828
|
-
<main className="flex-1">{body}</main>
|
|
1829
|
-
{right && <aside className="w-64">{right}</aside>}
|
|
1830
|
-
</div>
|
|
1831
|
-
{footer && <footer>{footer}</footer>}
|
|
1832
|
-
</div>
|
|
1833
|
-
)
|
|
1834
|
-
}
|
|
1835
|
-
```
|
|
1652
|
+
**Conflicts behave like a collaborative document, not like git.** A developer's push arrives the way a live collaborator's edit would. Different sections never conflict, and different params of the same section never conflict — last edit wins. The app warns only when two content edits target the same section at the same time.
|
|
1836
1653
|
|
|
1837
|
-
**
|
|
1654
|
+
**The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
|
|
1838
1655
|
|
|
1839
|
-
|
|
1656
|
+
Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
|
|
1840
1657
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1658
|
+
**Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login`, `--token`, or `UNIWEB_TOKEN`; preview with `--dry-run`.
|
|
1659
|
+
|
|
1660
|
+
### Staying current
|
|
1661
|
+
|
|
1662
|
+
```bash
|
|
1663
|
+
uniweb -v # installed CLI version, and whether a newer one is available
|
|
1664
|
+
uniweb doctor # report drift in this project, changing nothing
|
|
1665
|
+
uniweb update --dry-run # preview exactly what update would change
|
|
1666
|
+
uniweb update # apply: align @uniweb/* deps AND refresh AGENTS.md
|
|
1848
1667
|
```
|
|
1849
1668
|
|
|
1850
|
-
|
|
1669
|
+
**`uniweb update` is the command for bringing a project up to date.** It aligns the project's `@uniweb/*` dependencies *and* this AGENTS.md to the version matrix of the CLI that runs it. Deps and documentation move together — that's the whole point of the verb.
|
|
1851
1670
|
|
|
1852
|
-
|
|
1671
|
+
> **Don't run `npm update` or `pnpm update` on the `@uniweb/*` packages.** They're a matched set resolved by the CLI's version matrix, not independently versioned libraries you upgrade one at a time. Updating them directly gets you a combination nobody tested, and it won't refresh AGENTS.md — so this guide silently drifts out of sync with the code it describes, which is worse than being out of date, because nothing looks wrong.
|
|
1853
1672
|
|
|
1854
|
-
|
|
1673
|
+
Two ordering rules: `update` won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed — either would put the doc ahead of the code. And updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`); `uniweb update` does not do that. To pin a project to the newest published release with no global install: `npx uniweb@latest update --yes`.
|
|
1855
1674
|
|
|
1856
|
-
|
|
1675
|
+
### `package.json` `uniweb` block
|
|
1857
1676
|
|
|
1858
|
-
|
|
1677
|
+
Platform-specific configuration that doesn't belong in npm-standard fields. All fields optional.
|
|
1859
1678
|
|
|
1860
|
-
|
|
1679
|
+
| Field | Where used | Default | Purpose |
|
|
1680
|
+
|---|---|---|---|
|
|
1681
|
+
| `id` | `uniweb register` | bare segment of a scoped `name` | The foundation's registered id — the bare name in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming on the registry doesn't ripple through site dependencies. |
|
|
1682
|
+
| `namespace` | `uniweb register` | none | Legacy explicit org-namespace override; equivalent to a scoped `package.json::name`. Rarely needed. |
|
|
1683
|
+
| `runtimePolicy` | `dist/runtime-pin.json` | `"auto-minor"` | How sites using this foundation receive runtime updates. |
|
|
1861
1684
|
|
|
1862
|
-
|
|
1863
|
-
// main.js
|
|
1864
|
-
export default {
|
|
1865
|
-
handlers: {
|
|
1866
|
-
data: (data, block) => { /* ... */ },
|
|
1867
|
-
content: (data, block) => { /* ... */ },
|
|
1868
|
-
props: (content, params, block) => { /* ... */ },
|
|
1869
|
-
},
|
|
1870
|
-
}
|
|
1871
|
-
```
|
|
1685
|
+
**Runtime policy** (foundation authors only — sites don't set this). At build time a foundation pins the `@uniweb/runtime` version it built against into `dist/runtime-pin.json`, alongside a policy controlling how that version moves forward on already-published sites: `exact` (stay put), `auto-patch` (within `MAJOR.MINOR.x`), `auto-minor` (within `MAJOR.x.y`, the default). Most foundations should leave it unset — the runtime is backwards-compatible at the minor level by convention, so `auto-minor` lets sites pick up fixes without a foundation rebuild. Set `exact` only if you depend on undocumented runtime internals or have audited against one release. Site owners cannot override your choice.
|
|
1872
1686
|
|
|
1873
|
-
|
|
1687
|
+
`@uniweb/runtime` arrives **transitively** through `@uniweb/build`, so your foundation pins a runtime version without declaring one — that's intentional. **Don't add `@uniweb/runtime` to your foundation's dependencies**; to bump the pinned version, bump `@uniweb/build`. If the pin is missing or malformed, the platform serves the foundation through its legacy compatibility path — sites still work, they just don't participate in runtime propagation.
|
|
1874
1688
|
|
|
1875
|
-
|
|
1876
|
-
|---|---|---|---|---|
|
|
1877
|
-
| `data` | After data assembly, before content transform | `(data, block)` | New data object, or null | Filter, reshape, or augment the assembled data |
|
|
1878
|
-
| `content` | After data handler | `(data, block)` | ProseMirror document, or null | Transform raw content (Loom instantiation, template expansion) |
|
|
1879
|
-
| `props` | After parsing, defaults, and guarantees | `(content, params, block)` | `{ content, params }`, or null | Post-process the final shape before the component sees it |
|
|
1689
|
+
### Localization
|
|
1880
1690
|
|
|
1881
|
-
|
|
1691
|
+
**Content reaches components already translated.** There is no `t()`, no runtime lookup, no string wrapping — each language is a complete static build with its own HTML, `hreflang` tags, and search index. The default language has no URL prefix (`/about`); others are prefixed (`/es/about`).
|
|
1882
1692
|
|
|
1883
|
-
|
|
1693
|
+
**Translations live in `locales/`, a sibling of `pages/` at the site root — never inside `pages/`.** The markdown under `pages/` is one language: the default.
|
|
1884
1694
|
|
|
1885
|
-
```
|
|
1886
|
-
|
|
1695
|
+
```yaml
|
|
1696
|
+
# site.yml
|
|
1697
|
+
defaultLanguage: en
|
|
1698
|
+
languages: [en, es, fr] # or [{ code: es, label: Español }, …], or '*' to
|
|
1699
|
+
# auto-discover from the locales/ folder
|
|
1700
|
+
publishLanguages: [en, es] # optional — fr stays a dev-previewable draft,
|
|
1701
|
+
# excluded from production output entirely
|
|
1702
|
+
```
|
|
1887
1703
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1704
|
+
```bash
|
|
1705
|
+
uniweb build # FIRST — extract scans built content, not source
|
|
1706
|
+
uniweb i18n extract # → locales/manifest.json, keyed by 8-char content hash
|
|
1707
|
+
uniweb i18n init es fr # → locales/es.json, locales/fr.json, pre-filled with source
|
|
1708
|
+
# …translate the values…
|
|
1709
|
+
uniweb build # merges translations, emits dist/es/, dist/fr/
|
|
1893
1710
|
```
|
|
1894
1711
|
|
|
1895
|
-
|
|
1712
|
+
Running `extract` before a build is the usual first mistake — it reads the compiled content, so a stale build yields a stale manifest. After content changes, `uniweb i18n sync` updates the manifest and `init` refreshes the language files; `status` and `audit` report coverage and stale entries.
|
|
1896
1713
|
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
where: "type = 'book'"
|
|
1902
|
-
---
|
|
1714
|
+
**Two mechanisms.** *Hash-based strings* (the default) — `locales/{locale}.json` maps content hash → translation, so structure stays identical across languages. When one source string needs different translations depending on where it appears, the value takes an override form:
|
|
1715
|
+
|
|
1716
|
+
```json
|
|
1717
|
+
{ "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
|
|
1903
1718
|
```
|
|
1904
1719
|
|
|
1905
|
-
|
|
1720
|
+
*Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
|
|
1906
1721
|
|
|
1907
|
-
|
|
1722
|
+
```
|
|
1723
|
+
locales/freeform/es/pages/about/hero.md # by page route
|
|
1724
|
+
locales/freeform/es/collections/articles/x.md # collections work too
|
|
1725
|
+
```
|
|
1908
1726
|
|
|
1909
|
-
|
|
1727
|
+
These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
|
|
1910
1728
|
|
|
1911
|
-
|
|
1912
|
-
import { Loom, instantiateContent, instantiateRepeated } from '@uniweb/loom'
|
|
1729
|
+
**Collections translate in the same `extract` run**, into their own manifest at `locales/collections/manifest.json`.
|
|
1913
1730
|
|
|
1914
|
-
|
|
1731
|
+
**Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
|
|
1915
1732
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
content: (data, block) => {
|
|
1919
|
-
const profile = data?.profile?.[0]
|
|
1920
|
-
if (!profile) return null
|
|
1733
|
+
```jsx
|
|
1734
|
+
import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'
|
|
1921
1735
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1736
|
+
function LanguageSwitcher() {
|
|
1737
|
+
const { website } = useWebsite()
|
|
1738
|
+
if (!website.hasMultipleLocales()) return null
|
|
1924
1739
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1740
|
+
return website.getLocales().map(locale => (
|
|
1741
|
+
<Link reload key={locale.code} href={website.getLocaleUrl(locale.code)}>
|
|
1742
|
+
{getLocaleLabel(locale)}
|
|
1743
|
+
</Link>
|
|
1744
|
+
))
|
|
1929
1745
|
}
|
|
1930
1746
|
```
|
|
1931
1747
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
> **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
|
|
1935
|
-
|
|
1936
|
-
### Reserved frontmatter fields
|
|
1937
|
-
|
|
1938
|
-
`source` and `where` are convention-level reserved fields — they flow through to both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. This is consistent with how `background` and `theme` work. List them in `meta.js` params with descriptions so the editor and schema recognize them.
|
|
1748
|
+
> **Use `<Link reload>` — not a plain `<a>`, and not `window.location.href`.** `getLocaleUrl()` returns a root-relative path that does **not** include the deployment base path, so under a subdirectory deploy (`base: /docs/` in `site.yml`) a raw href sends the visitor outside the site. `<Link reload>` prepends `website.basePath` and forces the full page load a locale switch requires — an SPA `<Link>` won't do, because the other language is a different build.
|
|
1939
1749
|
|
|
1940
|
-
|
|
1750
|
+
Full model: `development/internationalization.md`. Author's view: `authoring/translating.md`.
|
|
1941
1751
|
|
|
1942
|
-
|
|
1752
|
+
### Migrating from other frameworks
|
|
1943
1753
|
|
|
1944
1754
|
Don't port line-by-line. Study the source, then rebuild from first principles. Other frameworks produce far more components than Uniweb needs — expect consolidation, not 1:1 correspondence.
|
|
1945
1755
|
|
|
1946
|
-
### The mental model shift
|
|
1947
|
-
|
|
1948
1756
|
| React / conventional | Uniweb equivalent |
|
|
1949
1757
|
|---|---|
|
|
1950
1758
|
| Props with typed data | Frontmatter params + `meta.js` |
|
|
1951
|
-
| Component variants via props | `variant` param
|
|
1759
|
+
| Component variants via props | `variant` param; Front Desk pattern for complex routing |
|
|
1952
1760
|
| Context / ThemeProvider | `theme:` frontmatter + semantic tokens (automatic) |
|
|
1953
1761
|
| Wrapper/layout components | Section nesting or custom layouts |
|
|
1954
1762
|
| Prop-drilling visuals into containers | Insets — `` rendered via `<Visual>` |
|
|
1955
1763
|
| Content in JSX or `.js` data files | Markdown → parser → `content` prop |
|
|
1956
1764
|
| CSS color tokens / design systems | `theme.yml` → palette shades + semantic tokens |
|
|
1957
|
-
| `isDark ?
|
|
1765
|
+
| `isDark ? … : …` conditionals | `text-heading` — context classes handle it |
|
|
1958
1766
|
| Per-component backgrounds | `background:` in frontmatter |
|
|
1959
|
-
| Multiple near-identical components | One section type + `variant` param, or Front Desk
|
|
1767
|
+
| Multiple near-identical components | One section type + `variant` param, or Front Desk |
|
|
1960
1768
|
| i18n wrapping (`t()` / `<Trans>`) | Locale-specific content directories |
|
|
1961
1769
|
|
|
1962
|
-
|
|
1770
|
+
**Approach:** scaffold with `--template none` → use named layouts for different page groups → dump legacy components under `components/` (they're not section types; import them from section types during transition) → create section types one at a time.
|
|
1963
1771
|
|
|
1964
|
-
1. **
|
|
1965
|
-
```bash
|
|
1966
|
-
pnpm create uniweb my-project --template none
|
|
1967
|
-
```
|
|
1772
|
+
**It's incremental, not a rewrite.** Level **0** — paste the original as one section type; routing and dev tooling work immediately, and you have a running site. Level **1** — decompose into section types, consolidating duplicates via `variant` params or Front Desk. Level **2** — move content from JSX to markdown, so authors can edit without code. Level **3** — replace hardcoded colors with semantic tokens, so components work in any context. Each level is shippable; stopping at 1 is a legitimate outcome.
|
|
1968
1773
|
|
|
1969
|
-
|
|
1774
|
+
**The most common mistake** is recreating source colors as CSS custom properties — that bypasses the token system. Instead: primary color → `colors.primary` in `theme.yml`, neutral tone → `colors.neutral`, context needs → `theme:` frontmatter.
|
|
1970
1775
|
|
|
1971
|
-
|
|
1776
|
+
Also: name by purpose, not content (`TheModel` → `SplitContent`, `WorkModes` → `FeatureColumns`), and put UI helpers (buttons, badges, cards) in `components/` with no `meta.js`.
|
|
1972
1777
|
|
|
1973
|
-
|
|
1974
|
-
- **Level 0**: Paste the original as one section type. Routing and dev tooling work immediately.
|
|
1975
|
-
- **Level 1**: Decompose into section types. Consolidate duplicates — use `variant` params or the Front Desk pattern.
|
|
1976
|
-
- **Level 2**: Move content from JSX to markdown. Authors can now edit without code.
|
|
1977
|
-
- **Level 3**: Replace hardcoded colors with semantic tokens. Components work in any context.
|
|
1978
|
-
|
|
1979
|
-
5. **Map source colors to `theme.yml`.** The most common mistake is recreating source colors as CSS custom properties — this bypasses the token system. Instead: primary color → `colors.primary` in theme.yml. Neutral tone → `colors.neutral`. Context needs → `theme:` frontmatter.
|
|
1980
|
-
|
|
1981
|
-
6. **Name by purpose, not content** — `TheModel` → `SplitContent`, `WorkModes` → `FeatureColumns`.
|
|
1982
|
-
|
|
1983
|
-
7. **UI helpers → `components/`** — Buttons, badges, cards in `components/` (no `meta.js`, not selectable by authors).
|
|
1778
|
+
Full guide: `development/converting-existing.md`.
|
|
1984
1779
|
|
|
1985
1780
|
---
|
|
1986
1781
|
|
|
1987
|
-
##
|
|
1988
|
-
|
|
1989
|
-
Foundation styles in `styles.css`:
|
|
1990
|
-
|
|
1991
|
-
```css
|
|
1992
|
-
@import "tailwindcss";
|
|
1993
|
-
@import "@uniweb/kit/theme-tokens.css";
|
|
1994
|
-
@source "./sections/**/*.{js,jsx}";
|
|
1995
|
-
@source "./components/**/*.{js,jsx}";
|
|
1996
|
-
@source "../node_modules/@uniweb/kit/src/**/*.jsx";
|
|
1997
|
-
|
|
1998
|
-
@theme {
|
|
1999
|
-
--breakpoint-xs: 30rem;
|
|
2000
|
-
}
|
|
2001
|
-
```
|
|
2002
|
-
|
|
2003
|
-
Semantic tokens come from `theme-tokens.css` (populated from `theme.yml`). Use `@theme` only for values tokens don't cover. **Custom CSS is expected alongside Tailwind** — shadow systems, border hierarchies, gradients, glassmorphism. Tailwind handles layout; tokens handle context; `styles.css` handles everything else.
|
|
2004
|
-
|
|
2005
|
-
**Don't set `scroll-behavior: smooth` globally.** It's a common line in a hand-written `html { … }` reset, and porting one into a foundation breaks navigation. The runtime owns scrolling: it already smooth-scrolls anchor targets itself (`scrollIntoView({ behavior: 'smooth' })`), so the CSS adds nothing there — but it resets and restores scroll on route changes with the two-argument `scrollTo(x, y)`, which *inherits* the property. Route changes then animate their scroll-to-top, and back-button restoration (which scrolls, checks the position on the next frame, and retries) keeps interrupting its own animation. Scope it to a specific scrollable element if you need it; never to `html` or `body`.
|
|
2006
|
-
|
|
2007
|
-
**Font smoothing is a per-scheme decision, not a reset.** `-webkit-font-smoothing: antialiased` (macOS only) forces grayscale rasterization, which thins strokes. On dark surfaces that usefully counteracts the bloom of light text on near-black; on light surfaces it costs contrast and makes body text spindly. If you want it, scope it — `.scheme-dark { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }` — rather than putting it on `html` the way most CSS resets do.
|
|
1782
|
+
## Part 6 — Troubleshooting
|
|
2008
1783
|
|
|
2009
|
-
|
|
1784
|
+
Most Uniweb failures are **silent** — the build succeeds and the page is wrong. Check these first, because nothing in the terminal will point you at them.
|
|
2010
1785
|
|
|
2011
|
-
**
|
|
1786
|
+
**A red `Component not found: <Type>` box where a section should be** — the `type:` in that section's frontmatter names a section type this foundation doesn't have. `ls <foundation>/sections/` for the real list. The build does not fail on this, so it only surfaces by looking at the page.
|
|
2012
1787
|
|
|
2013
|
-
**
|
|
1788
|
+
**A param has no effect** — it isn't declared in that section type's `meta.js` `params:`. Undeclared frontmatter is passed through and ignored rather than rejected. Read the `meta.js` for the real knobs; if the one you want doesn't exist, exposing it is a foundation change.
|
|
2014
1789
|
|
|
2015
|
-
**
|
|
1790
|
+
**A section doesn't appear at all** — the file is `@`-prefixed (a child section, only rendered via `nest:`), or `_`-prefixed (treated as a draft and skipped), or it's a section type nested below the root of `sections/` without a `meta.js`, which means it was never discovered.
|
|
2016
1791
|
|
|
2017
|
-
**
|
|
1792
|
+
**Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the level rule (exactly one level deeper = subtitle; skipping a level, or any body content first, starts items). Run `uniweb inspect <path>` rather than re-deriving it.
|
|
2018
1793
|
|
|
2019
|
-
**
|
|
1794
|
+
**A var in frontmatter does nothing** — component vars only apply when declared in that section type's `meta.js` `vars:`. Unknown names are ignored silently.
|
|
2020
1795
|
|
|
2021
|
-
|
|
2022
|
-
```bash
|
|
2023
|
-
uniweb inspect pages/home/hero.md # Single section
|
|
2024
|
-
uniweb inspect pages/home/ # Whole page
|
|
2025
|
-
uniweb inspect pages/home/hero.md --raw # ProseMirror AST
|
|
2026
|
-
```
|
|
1796
|
+
Loud failures:
|
|
2027
1797
|
|
|
2028
|
-
|
|
1798
|
+
**"Could not load foundation"** — check that the site's `package.json` depends on the foundation *by its workspace package name*. For the default layout that's `"src": "file:../src"`; for a co-located project (`docs/src` + `docs/site`) it's `"docs-src": "file:../src"`. The key must match the foundation's `package.json::name`, not the folder it happens to sit in.
|
|
2029
1799
|
|
|
2030
|
-
|
|
1800
|
+
**Component not appearing** — verify `meta.js` exists; check for `hidden: true`; rebuild the foundation.
|
|
2031
1801
|
|
|
2032
|
-
|
|
2033
|
-
uniweb add project marketing --from marketing
|
|
2034
|
-
pnpm install
|
|
2035
|
-
```
|
|
1802
|
+
**Styles not applying** — verify `@source` in `styles.css` includes your component paths.
|
|
2036
1803
|
|
|
2037
|
-
|
|
1804
|
+
**Prerender warnings about hooks** — components with `useState`/`useEffect` show SSG warnings during build in local symlinked mode. Expected and harmless.
|
|
2038
1805
|
|
|
2039
|
-
**
|
|
2040
|
-
- `{name}/src/sections/` — components with meta.js (content expectations, params, presets)
|
|
2041
|
-
- `{name}/site/pages/` — real content files showing markdown → component mapping
|
|
2042
|
-
- `{name}/site/theme.yml` + `site.yml` — theming and configuration patterns
|
|
1806
|
+
**"document is not defined" during build** — your component touches `document`, `window`, or `localStorage` during render rather than inside `useEffect`. **Don't add `typeof document` guards** — use the kit hook instead: dark mode → `useAppearance()`, scroll detection → `useScrolled()`. Kit hooks are SSR-safe by design.
|
|
2043
1807
|
|
|
2044
|
-
**
|
|
1808
|
+
**Content not parsing as expected** — `uniweb inspect pages/home/hero.md` (add `--raw` for the ProseMirror AST), or point it at a folder for a whole page.
|
|
2045
1809
|
|
|
2046
|
-
|
|
2047
|
-
|----------|-------------|
|
|
2048
|
-
| `marketing` | Semantic tokens, insets, grids, multi-line headings, inline styling |
|
|
2049
|
-
| `docs` | Sidebar navigation, navigation levels, code highlighting |
|
|
2050
|
-
| `dynamic` | Live API data fetching, loading states, transforms |
|
|
2051
|
-
| `international` | i18n, blog with collections, multi-locale routing |
|
|
2052
|
-
| `store` | Product grid, collections, e-commerce patterns |
|
|
2053
|
-
| `academic` | Publications, team grid, timeline, math |
|
|
2054
|
-
| `extensions` | Multi-foundation architecture, runtime loading |
|
|
1810
|
+
---
|
|
2055
1811
|
|
|
2056
|
-
|
|
1812
|
+
## Documentation index
|
|
2057
1813
|
|
|
2058
|
-
|
|
1814
|
+
**Index of every page: https://www.uniweb.io/llms.txt** — start there when you don't know which page you need.
|
|
2059
1815
|
|
|
2060
|
-
|
|
1816
|
+
Source repo (public, cloneable): **https://github.com/uniweb/docs** · any page as raw markdown at `https://raw.githubusercontent.com/uniweb/docs/main/{section}/{page}.md`. See *Documentation* in Part 0 for when to fetch versus clone.
|
|
2061
1817
|
|
|
2062
|
-
| Section |
|
|
2063
|
-
|
|
2064
|
-
|
|
|
2065
|
-
|
|
|
2066
|
-
|
|
|
2067
|
-
|
|
|
1818
|
+
| Section | Covers |
|
|
1819
|
+
|---------|--------|
|
|
1820
|
+
| `architecture/` | Component Content Architecture — the why behind the patterns in this file |
|
|
1821
|
+
| `getting-started/` | What is Uniweb, quickstart, templates |
|
|
1822
|
+
| `authoring/` | Writing content, site setup, collections, theming, translations, predicates |
|
|
1823
|
+
| `development/` | Foundations, component patterns, project structures, data, layouts, i18n, migration, schemas |
|
|
1824
|
+
| `reference/` | site.yml, page.yml, content structure, meta.js, kit API, navigation, data fetching, CLI, deployment |
|
|
2068
1825
|
|
|
2069
|
-
|
|
1826
|
+
The by-task table is in Part 0. For CLI flags, prefer `uniweb <command> --help` over this file — it's always current.
|