uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,3000 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Part 0 — Read this first
|
|
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 pages. Component developers build section types that receive pre-parsed content and render it. Neither touches the other's files.
|
|
6
|
+
|
|
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
|
+
|
|
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>` |
|
|
18
|
+
|
|
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.
|
|
20
|
+
|
|
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.
|
|
22
|
+
|
|
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.
|
|
24
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
### Route yourself
|
|
28
|
+
|
|
29
|
+
| Your situation | Start at |
|
|
30
|
+
|---|---|
|
|
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* |
|
|
35
|
+
|
|
36
|
+
### Three commands that replace guessing
|
|
37
|
+
|
|
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
|
+
```
|
|
43
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
### The four silent failures
|
|
47
|
+
|
|
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.
|
|
49
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
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.**
|
|
58
|
+
|
|
59
|
+
**Start at the index.** One address, and it's the only documentation URL worth memorizing:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
https://www.uniweb.io/llms.txt
|
|
63
|
+
```
|
|
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.
|
|
66
|
+
|
|
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
|
+
|
|
69
|
+
**Clone the repo instead** when you expect several lookups, or may lose network access later:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone --depth 1 https://github.com/uniweb/docs .uniweb-docs # then gitignore it
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Same content, greppable, and one network operation instead of many. `grep -ri "queryable" .uniweb-docs` beats any number of fetches while you're still exploring.
|
|
76
|
+
|
|
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}`.
|
|
78
|
+
|
|
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.
|
|
80
|
+
|
|
81
|
+
**The table below saves you the round trip** for the tasks that come up most:
|
|
82
|
+
|
|
83
|
+
| Task | Page |
|
|
84
|
+
|------|------|
|
|
85
|
+
| Writing page content | `authoring/writing-content.md` |
|
|
86
|
+
| Theming and styling | `authoring/theming.md` |
|
|
87
|
+
| Working with records and queries | `authoring/collections.md` |
|
|
88
|
+
| Where-object predicate format | `authoring/predicates.md` |
|
|
89
|
+
| Data sources / custom transports | `development/data-sources.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
|
+
| Queries — everything a query can say | `reference/queries.md` |
|
|
100
|
+
| Data fetching model | `reference/data-fetching.md` |
|
|
101
|
+
| Navigation patterns | `reference/navigation-patterns.md` |
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Part 1 — Starting a project
|
|
108
|
+
|
|
109
|
+
**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.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx uniweb@latest create my-project --template marketing
|
|
113
|
+
cd my-project && pnpm install
|
|
114
|
+
uniweb dev
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`@latest` matters: a project starts on the packages and templates of the CLI that creates it, and a bare `npx uniweb` runs a globally installed `uniweb` when there is one, however old.
|
|
118
|
+
|
|
119
|
+
**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.
|
|
120
|
+
|
|
121
|
+
Official templates include `marketing` (tokens, insets, grids, multi-line headings), `docs` (sidebar nav, code highlighting), `blog` (records of the standard `@std/article` schema, a query, a parametric `[slug]` page), `dynamic` (live API data, loading states), `international` (i18n, records, multi-locale routing), `store` (product grids, e-commerce), `academic` (publications, timeline, math), `extensions` (multi-foundation, runtime loading). `uniweb template list` shows them all.
|
|
122
|
+
|
|
123
|
+
**npm or pnpm.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command in this guide.
|
|
124
|
+
|
|
125
|
+
### What you get
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
my-project/
|
|
129
|
+
├── src/ # the foundation — component developer's domain
|
|
130
|
+
├── site/ # the site — content author's domain
|
|
131
|
+
└── pnpm-workspace.yaml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
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` — a **workspace** name, how the site depends on it. What the foundation **registers as** (`@org/<name>`, the name sites pin) is `name` in its `main.js`, which `uniweb create` sets to the project's name. **`src` and `foundation` are never a foundation's name** — `uniweb register` refuses them, because every project in an org would register the same one.
|
|
135
|
+
|
|
136
|
+
- **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.
|
|
137
|
+
- **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
|
|
138
|
+
|
|
139
|
+
**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.
|
|
140
|
+
|
|
141
|
+
### Growing the workspace
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
uniweb add project docs # co-located pair → docs/src + docs/site
|
|
145
|
+
uniweb add foundation [name|path] [--path parent] # no name → ./src/, bare name → ./name/, slash → that path
|
|
146
|
+
uniweb add site [name|path]
|
|
147
|
+
uniweb add extension <name> [--site <name>] # secondary foundation, wired to a site
|
|
148
|
+
uniweb add section Hero [--foundation ui] # sections/Hero/{index.jsx,meta.js}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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).
|
|
152
|
+
|
|
153
|
+
`uniweb add section` scaffolds a CCA-proper starter, and the dev server picks it up with no build or install.
|
|
154
|
+
|
|
155
|
+
### Learn from a working template
|
|
156
|
+
|
|
157
|
+
**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:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
uniweb add project marketing --from marketing
|
|
161
|
+
pnpm install
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
That creates `marketing/src/` + `marketing/site/` alongside your project. You don't need to build or run it. Read:
|
|
165
|
+
|
|
166
|
+
- `marketing/src/sections/` — components with `meta.js`: what content they expect, what params they accept, what presets they define
|
|
167
|
+
- `marketing/site/pages/` — real content files, showing the markdown → component mapping
|
|
168
|
+
- `marketing/site/theme.yml` and `site.yml` — theming and configuration in practice
|
|
169
|
+
|
|
170
|
+
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`.
|
|
171
|
+
|
|
172
|
+
### The loop
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
uniweb dev # from the workspace root; it picks the site for you
|
|
176
|
+
pnpm build # production build (SSG + SPA)
|
|
177
|
+
pnpm preview # preview the production build
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Markdown, `theme.yml`, and component edits hot-reload. New section types are picked up without a restart.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Part 2 — Working in an existing project
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
188
|
+
### 1. Orient — what kind of workspace is this?
|
|
189
|
+
|
|
190
|
+
**There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
|
|
191
|
+
|
|
192
|
+
| Globs | Layout | What it implies |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `src`, `site` | **single** (the default) | foundation in `src/`, site in `site/` |
|
|
195
|
+
| `foundations/*`, `sites/*` | **segregated** | several sites may share one foundation — a foundation edit hits all of them |
|
|
196
|
+
| `*/src`, `*/site` | **co-located projects** | one self-contained pair per project: `docs/src` + `docs/site` |
|
|
197
|
+
| a package whose `main.js` says `extension: true` — `effects`, `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
|
|
198
|
+
|
|
199
|
+
Those names are convention; the globs are the truth. Full guide with the wiring for each: `development/project-structures.md`.
|
|
200
|
+
|
|
201
|
+
**A site is any package with a `site.yml` and a `pages/` folder** — a workspace can hold several. Everything below calls yours `<site>/`.
|
|
202
|
+
|
|
203
|
+
**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`:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
// docs/site/package.json → the foundation is at docs/src/
|
|
207
|
+
{ "dependencies": { "docs-src": "file:../src" } }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
If `foundation:` isn't a workspace package name, the source isn't in this repo at all:
|
|
211
|
+
|
|
212
|
+
| `foundation:` value | What it means | Can you add a section type? |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| a workspace package name (`src`, `docs-src`, `marketing`) | source is in this repo — follow the `file:` dep to it | **Yes** |
|
|
215
|
+
| a versioned registry ref (`@org/name@1.2.3`) | published; **its source is not in this repo** | **No** — work within the types it offers |
|
|
216
|
+
| an `https://…` URL, or `{ url: … }` | same, loaded from that URL | **No** |
|
|
217
|
+
|
|
218
|
+
A versionless `@org/name` is an error rather than a shorthand — the build rejects it and asks for a version.
|
|
219
|
+
|
|
220
|
+
> **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.
|
|
221
|
+
|
|
222
|
+
> **The same `@scope/name` shape means different things in different places.** As a `foundation:` value it is a **catalog** ref (never npm). As a data-schema ref in `meta.js` it resolves on disk from an **npm package** — `@std/*` from `@uniweb/schemas`, any other `@org/*` from that org's `@org/schemas`. And `@uniweb/kit` in `dependencies` is an ordinary npm package. Position decides the registry; the shape tells you nothing.
|
|
223
|
+
|
|
224
|
+
**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/`.
|
|
225
|
+
|
|
226
|
+
### 2. Learn this project's vocabulary — before you write anything
|
|
227
|
+
|
|
228
|
+
**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/`:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
ls <foundation>/sections/ # the section types available
|
|
232
|
+
cat <foundation>/sections/Hero/meta.js # what one expects and accepts
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**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.
|
|
236
|
+
|
|
237
|
+
**An extension *is* a foundation** — same build, same output; it just contributes no Layout or theme variables. So each entry takes the **same shapes** `foundation:` does: a workspace package name, a versioned catalog ref (`@org/name@1.2.3`), or a full URL. Use the same table above to decide whether you can add a section type to one.
|
|
238
|
+
|
|
239
|
+
> **A site-relative URL (`/effects/entry.js`) only works where the site serves its own files** — `uniweb export` and `uniweb deploy --host=<adapter>`. A site published to Uniweb hosting ships no JS, so nothing serves that path, and `uniweb publish` rejects it with a pointer to the catalog-ref form. Register the extension (`uniweb register` in its directory) and reference it like any other foundation. When you reference a workspace-local extension, `uniweb publish` brings it along exactly as it does the primary — releasing it if its code changed, and pinning the released version on the published site.
|
|
240
|
+
|
|
241
|
+
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), and optionally `family:` (the standard section family it belongs to). 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.
|
|
242
|
+
|
|
243
|
+
> **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.
|
|
244
|
+
|
|
245
|
+
#### `family:` — so an editor can draw your section
|
|
246
|
+
|
|
247
|
+
An editor showing an author a section picker has to know what each section type
|
|
248
|
+
**is**, to pick its illustration and a label it can translate. Two ways to tell
|
|
249
|
+
it, in this order:
|
|
250
|
+
|
|
251
|
+
1. **Name the component for its family.** Run `uniweb families`, and use one of
|
|
252
|
+
those names when it fits — `Hero`, `Footer`, `Pricing`, `FAQ`, `CardGrid`.
|
|
253
|
+
Then there is nothing to declare: the name resolves on its own.
|
|
254
|
+
2. **Declare `family:`** when the name is yours — a variant, or your own
|
|
255
|
+
vocabulary:
|
|
256
|
+
```js
|
|
257
|
+
// sections/ProfileHero/meta.js
|
|
258
|
+
export default { title: 'Researcher Profile', family: 'profile' }
|
|
259
|
+
```
|
|
260
|
+
One value, the dominant shape: a `HeroWithEstimate` is `family: 'hero'`.
|
|
261
|
+
|
|
262
|
+
**Leave it off when nothing fits.** A section that is genuinely specific to this
|
|
263
|
+
site gets a generic illustration, and that is the right outcome — do not force it
|
|
264
|
+
into the nearest family. `uniweb doctor` shows what each of your sections
|
|
265
|
+
resolved to, and suggests a family for any it recognises by another name.
|
|
266
|
+
|
|
267
|
+
### 3. Find your lane
|
|
268
|
+
|
|
269
|
+
The architecture exists to keep content and code separate. Your task sits in one of them. Decide before you edit, then stay there.
|
|
270
|
+
|
|
271
|
+
| Task | Lane | Files you touch |
|
|
272
|
+
|---|---|---|
|
|
273
|
+
| Add / edit / reorder a section on a page | content | `<site>/pages/**` |
|
|
274
|
+
| Add a page | content | `<site>/pages/<name>/` |
|
|
275
|
+
| Change colors, fonts, light/dark | content | `<site>/theme.yml` |
|
|
276
|
+
| Change header, footer, or nav content | content | `<site>/layout/*.md`, or page order in `site.yml` |
|
|
277
|
+
| Change how a section type *looks* everywhere | foundation | `<foundation>/sections/<Type>/` |
|
|
278
|
+
| Add a new section type | foundation | `<foundation>/sections/<NewType>/` |
|
|
279
|
+
| Expose a new knob to authors | foundation | `<foundation>/sections/<Type>/meta.js` + the component |
|
|
280
|
+
|
|
281
|
+
**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.
|
|
282
|
+
|
|
283
|
+
### 4. The loop
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
uniweb dev # from the workspace root; it picks the site
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**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):
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
uniweb inspect pages/home/hero.md # the parsed content shape
|
|
293
|
+
uniweb inspect pages/home/ # every section on the page
|
|
294
|
+
uniweb inspect pages/home/hero.md --full # include empty fields (matches runtime)
|
|
295
|
+
uniweb inspect pages/home/hero.md --sequence # include the sequence array
|
|
296
|
+
uniweb inspect pages/home/hero.md --raw # the ProseMirror AST
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
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.
|
|
300
|
+
|
|
301
|
+
### 5. Recipes
|
|
302
|
+
|
|
303
|
+
Paths are relative to the site package you identified in step 1 — `site/` in the default layout, `sites/main/` or `docs/site/` in others.
|
|
304
|
+
|
|
305
|
+
**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.
|
|
306
|
+
|
|
307
|
+
**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.
|
|
308
|
+
|
|
309
|
+
**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.
|
|
310
|
+
|
|
311
|
+
**Change how code blocks look.** `<site>/theme.yml` → `code:`. Name one of Shiki's 65 bundled themes (`code: dracula`), name one per scheme (`code: { light: github-light, dark: github-dark }`), or take a theme and adjust it (`code: { theme: github-dark, background: '#0D0D0D' }`). Hand-picking every syntax colour is possible and is the last thing to reach for.
|
|
312
|
+
|
|
313
|
+
**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.
|
|
314
|
+
|
|
315
|
+
**Update the project's Uniweb dependencies — and this file.** `npx uniweb@latest update`. One command aligns every `@uniweb/*` dependency *and* refreshes this AGENTS.md together. Preview with `--dry-run`. ⛔ **The `@latest` is doing the work** — a bare `uniweb update` runs the CLI this project already pins, which aligns you to the matrix you already have and correctly reports nothing to do. **Don't reach for `npm update` / `pnpm update`** — see *Staying current* in Part 5 for why that breaks things quietly.
|
|
316
|
+
|
|
317
|
+
**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.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Part 3 — The content lane
|
|
322
|
+
|
|
323
|
+
### Section format
|
|
324
|
+
|
|
325
|
+
Each `.md` file is a section. Frontmatter on top, content below:
|
|
326
|
+
|
|
327
|
+
```markdown
|
|
328
|
+
---
|
|
329
|
+
type: Hero
|
|
330
|
+
theme: dark
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
#> New in v1 ← pretitle (the label line above the title)
|
|
334
|
+
|
|
335
|
+
# Build the system. ← title (the big headline)
|
|
336
|
+
|
|
337
|
+
## Not every page. ← subtitle (one step smaller, directly below)
|
|
338
|
+
|
|
339
|
+
Description paragraph.
|
|
340
|
+
|
|
341
|
+
[Call to Action](/link)
|
|
342
|
+
|
|
343
|
+

|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing. Write a pretitle as a `#>` label line — any number of leading `#`s spells the same label, so match the heading you are labelling. A smaller ordinary heading directly above the title is *also* read as a pretitle, which is how files written before label lines keep working; prefer `#>`, because it says what it is wherever it lands rather than depending on what follows it.
|
|
347
|
+
|
|
348
|
+
**A section with no `type:` renders through the foundation's default section type — a component named `Section`, unless the foundation's `main.js` sets `defaultSection` to something else.** This is what lets a folder of plain markdown with no frontmatter at all become pages: mounted documentation, an imported wiki, anything written before it met this framework. If such content renders blank, the foundation has no `Section` — that, not the markdown, is what to fix.
|
|
349
|
+
|
|
350
|
+
**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.
|
|
351
|
+
|
|
352
|
+
**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.
|
|
353
|
+
|
|
354
|
+
### The parsed object
|
|
355
|
+
|
|
356
|
+
The semantic parser produces a flat, guaranteed structure. No null checks needed — empty strings and arrays when content is absent:
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
content = {
|
|
360
|
+
title: '', // Main heading (string or string[] for multi-line)
|
|
361
|
+
pretitle: '', // `#>` label line(s) — also filled by smaller headings
|
|
362
|
+
// stacked above the title, for older content
|
|
363
|
+
// (string or string[])
|
|
364
|
+
subtitle: '', // Line(s) one step below the title — each further
|
|
365
|
+
// one-step descent is another line (string or string[])
|
|
366
|
+
paragraphs: [], // Text blocks
|
|
367
|
+
links: [], // { href, label, role } — standalone links (not inside lists)
|
|
368
|
+
images: [], // { src, alt, role, href }
|
|
369
|
+
icons: [], // { library, name, role }
|
|
370
|
+
videos: [], // { src, alt, role, poster, href }
|
|
371
|
+
documents: [], // { url, alt, caption, role: 'pdf', preview?, author?, description? } — from {role=pdf}
|
|
372
|
+
insets: [], // Inline @Component references — { refId }
|
|
373
|
+
lists: [], // [[{ paragraphs, links, lists, … }]] — each item is an object, not a string
|
|
374
|
+
quotes: [], // Blockquotes
|
|
375
|
+
snippets: [], // Fenced code — [{ language, code }]
|
|
376
|
+
data: {}, // Tagged blocks — ```yaml:tag / ```json:tag give the parsed value,
|
|
377
|
+
// ```md:tag gives { items, sequence } (see Concept blocks)
|
|
378
|
+
tables: [], // Only when present — [{ rows: [{ cells: [{ children, header, align }] }] }]
|
|
379
|
+
headings: [], // Only from nested content (quote/list bodies) — a
|
|
380
|
+
// section's headline never spills here
|
|
381
|
+
items: [], // Each has the same flat structure — from headings after body content
|
|
382
|
+
sequence: [], // All elements in document order
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
> **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.
|
|
387
|
+
|
|
388
|
+
### Markdown → content, side by side
|
|
389
|
+
|
|
390
|
+
```markdown
|
|
391
|
+
#> Eyebrow │ content.pretitle = "Eyebrow"
|
|
392
|
+
# Our Features │ content.title = "Our Features"
|
|
393
|
+
## Build better products │ content.subtitle = "Build better products"
|
|
394
|
+
│
|
|
395
|
+
We help teams ship faster. │ content.paragraphs[0] = "We help teams…"
|
|
396
|
+
│
|
|
397
|
+
[Get Started](/start) │ content.links[0] = { href: "/start", label: "Get Started" }
|
|
398
|
+
│
|
|
399
|
+
### Fast │ content.items[0].title = "Fast"
|
|
400
|
+
 │ content.items[0].icons[0] = { library: "lu", name: "zap" }
|
|
401
|
+
Lightning quick. │ content.items[0].paragraphs[0] = "Lightning quick."
|
|
402
|
+
│
|
|
403
|
+
### Secure │ content.items[1].title = "Secure"
|
|
404
|
+
 │ content.items[1].icons[0] = { library: "lu", name: "shield" }
|
|
405
|
+
Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade…"
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The staircase rule produces this — each heading relates to the one before it: the same size adds another line to the same part; **one step smaller** joins the headline as the next part down (the subtitle, then further subtitle lines); **two steps smaller** starts an item; and once body content has begun, *any* heading starts an item. `#>` label lines become `pretitle`; so do smaller headings stacked above the title, which is the older spelling.
|
|
409
|
+
|
|
410
|
+
### Items have the full content shape
|
|
411
|
+
|
|
412
|
+
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`).
|
|
413
|
+
|
|
414
|
+
````markdown
|
|
415
|
+
### Starter ← items[0].title
|
|
416
|
+
$9/month ← items[0].paragraphs[0]
|
|
417
|
+
|
|
418
|
+
```yaml:details
|
|
419
|
+
trial: 14 days
|
|
420
|
+
seats: 1
|
|
421
|
+
``` ← items[0].data.details = { trial: "14 days", seats: 1 }
|
|
422
|
+
````
|
|
423
|
+
|
|
424
|
+
### Subtitle vs items — the step rule
|
|
425
|
+
|
|
426
|
+
A heading directly after the title becomes `subtitle` **only when it is exactly one step smaller** (H1→H2, H2→H3) — and each *further* one-step line continues the subtitle (a three-line header is `subtitle: [line2, line3]`). **Two steps smaller starts items** — the idiom for entries with no lead paragraph:
|
|
427
|
+
|
|
428
|
+
```markdown
|
|
429
|
+
# Our Stats │ content.title = "Our Stats"
|
|
430
|
+
### 15,000+ │ content.items[0].title = "15,000+"
|
|
431
|
+
Students from 90 countries │ content.items[0].paragraphs[0]
|
|
432
|
+
│
|
|
433
|
+
### 200+ │ content.items[1].title = "200+"
|
|
434
|
+
Programs offered │ content.items[1].paragraphs[0]
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
With `## 15,000+` instead, the first stat would join the headline as `content.subtitle`. A `---` divider, or any body content after the headline, also closes it — after body, headings of *any* size start items.
|
|
438
|
+
|
|
439
|
+
### Multi-line headings
|
|
440
|
+
|
|
441
|
+
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.
|
|
442
|
+
|
|
443
|
+
```markdown
|
|
444
|
+
# Build the future │ content.title = ["Build the future", "with confidence"]
|
|
445
|
+
# with confidence │
|
|
446
|
+
|
|
447
|
+
# Build the future │ content.title = [
|
|
448
|
+
# [with confidence]{accent} │ "Build the future",
|
|
449
|
+
│ "<span accent=\"true\">with confidence</span>"
|
|
450
|
+
│ ]
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
**Rule:** a repeated size always continues the part it repeats — title lines, or subtitle lines once the headline has stepped down. Use `---` to force separate items where adjacent headings would otherwise continue the headline.
|
|
454
|
+
|
|
455
|
+
### Sequential content
|
|
456
|
+
|
|
457
|
+
`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:
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
const allData = {}
|
|
461
|
+
for (const el of content.sequence) {
|
|
462
|
+
if (el.type === 'dataBlock') allData[el.tag] = el.data
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Grouped fields and `sequence` are two interpretations of the same content. Grouped suits structured layouts (cards, features); sequential suits prose and cross-group searching.
|
|
467
|
+
|
|
468
|
+
### Choosing how to model content
|
|
469
|
+
|
|
470
|
+
The decision rule: **would a content author need to change this?** Yes → markdown, frontmatter, or a tagged data block. No → component code.
|
|
471
|
+
|
|
472
|
+
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:
|
|
473
|
+
|
|
474
|
+
**Pure markdown** — headings, paragraphs, links, images, lists, items. The default. If the content reads naturally as markdown and the parser captures it, stop here.
|
|
475
|
+
|
|
476
|
+
**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.
|
|
477
|
+
|
|
478
|
+
**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`.
|
|
479
|
+
|
|
480
|
+
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.
|
|
481
|
+
|
|
482
|
+
**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.
|
|
483
|
+
|
|
484
|
+
**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.
|
|
485
|
+
|
|
486
|
+
**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.
|
|
487
|
+
|
|
488
|
+
### Repeated values — `placeholders:`
|
|
489
|
+
|
|
490
|
+
A value that appears on many pages goes in `site.yml` once, not in a dozen markdown files:
|
|
491
|
+
|
|
492
|
+
```yaml
|
|
493
|
+
placeholders:
|
|
494
|
+
product: Uniweb
|
|
495
|
+
vendor:
|
|
496
|
+
email: billing@acme.example
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Any page then references it by name — `Write to {vendor.email}.` Dot paths work, and a record's own field of the same name always wins, so `{title}` on an article page is that article's title regardless of what the site declares.
|
|
500
|
+
|
|
501
|
+
⛔ **Resolution is a foundation capability, not a framework one.** `{…}` is resolved by the foundation's `handlers.content` hook — normally `createLoomHandlers` from `@uniweb/loom`. Under a foundation without one, `placeholders:` is inert and pages render the literal `{vendor.email}`, which reads as a typo. The build warns when a site declares the key and the foundation has no content handler.
|
|
502
|
+
|
|
503
|
+
```js
|
|
504
|
+
// main.js — a foundation opts in
|
|
505
|
+
import { createLoomHandlers } from '@uniweb/loom'
|
|
506
|
+
export default { handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }) }
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Placeholders are the simplest case of a larger language: the same expressions filter, sort, count and format live data (`{COUNT OF publications WHERE refereed}`). Reach for one when a value repeats; reach for the rest when content is genuinely derived from records.
|
|
510
|
+
|
|
511
|
+
### Icons
|
|
512
|
+
|
|
513
|
+
Image syntax with a library prefix — **two interchangeable spellings, the same everywhere** (markdown, and Kit's `<Icon name>`):
|
|
514
|
+
|
|
515
|
+
```markdown
|
|
516
|
+
 colon + friendly name — every library
|
|
517
|
+
 colon + short code
|
|
518
|
+
 dash — short codes only, to stay unambiguous with ordinary paths
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
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/).
|
|
522
|
+
|
|
523
|
+
Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `{role=icon}` (or ``) — always available, no library needed.
|
|
524
|
+
|
|
525
|
+
### Links and media attributes
|
|
526
|
+
|
|
527
|
+
```markdown
|
|
528
|
+
[text](url){target=_blank} <!-- Open in new tab -->
|
|
529
|
+
[text](./file.pdf){download} <!-- Download -->
|
|
530
|
+
{role=banner} <!-- Role determines array: images, icons, videos or documents -->
|
|
531
|
+
{role=video} <!-- → content.videos[], not content.images[] -->
|
|
532
|
+
{role=pdf} <!-- → content.documents[] — a file, drawn as a link -->
|
|
533
|
+
{href=/products} <!-- Clickable media: href + target ride along -->
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=cover position=center}`. A video adds `{poster=./thumb.jpg autoplay muted loop controls}`; a `{role=pdf}` document adds `{preview=./cover.jpg author=… description=…}` and arrives in `content.documents` — `{ url, name, mime, size?, alt, caption, … }` — declare it in `content:` as `documents`.
|
|
537
|
+
|
|
538
|
+
**Quote values containing spaces:** `{note="Ready to go"}`, not `{note=Ready to go}` — unquoted values end at the first space.
|
|
539
|
+
|
|
540
|
+
**Separators are forgiving.** `:` works wherever `=` does, and pairs may be separated by whitespace, a comma, or both — `{role=banner width=1200}`, `{role:banner, width:1200}` and `{role:banner,width:1200}` are identical. `=` and spaces are canonical, and that is what gets written back on save. Two rules keep it unambiguous: the separator must **touch** the key (`{note:warning}` is one pair, `{note : warning}` is two flags), and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
|
|
541
|
+
|
|
542
|
+
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:
|
|
543
|
+
|
|
544
|
+
```markdown
|
|
545
|
+
[Primary](/start) ← standalone → content.links[0]
|
|
546
|
+
[One](/a) [Two](/b) ← links-only paragraph → both in content.links[]
|
|
547
|
+
Check out [this](/a) link. ← inline → stays in paragraphs as an <a> tag
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Inline text styling
|
|
551
|
+
|
|
552
|
+
```markdown
|
|
553
|
+
# Build [faster]{accent} with structure
|
|
554
|
+
This is [less important]{muted} context.
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
`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>`.
|
|
558
|
+
|
|
559
|
+
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`).
|
|
560
|
+
|
|
561
|
+
> **On a site synced with Uniweb Cloud, the built-in style names round-trip *editable*** — an author who opens a page containing `[Get started]{accent}` gets the editor's own styling control for it, and can change or remove it like any text they styled there.
|
|
562
|
+
>
|
|
563
|
+
> **A name the editor doesn't know is preserved exactly, but not editable** — a style you defined yourself in `inline:`, two names on one span, or a `class`/`id` set. Nothing is lost and nothing is approximated: it survives every edit and save untouched, the editor simply offers no control for it. That is a permanent property rather than a gap, because the name set is open by design — you can add styles the editor has no way to know about.
|
|
564
|
+
|
|
565
|
+
### Fenced code: data blocks vs snippets
|
|
566
|
+
|
|
567
|
+
Fenced code serves three purposes depending on its info string: `yaml:`/`json:` for data, `md:` for a named kind of prose (see *Concept blocks*), and a bare language for a code sample.
|
|
568
|
+
|
|
569
|
+
**Tagged data blocks** — structured data parsed into JS objects. The tag is the key in `content.data` — for a section whose component declares that key in `meta.js` `data:`; the format (`yaml`/`yml`/`json`) is a serialization format, not a display language.
|
|
570
|
+
|
|
571
|
+
````markdown
|
|
572
|
+
```yaml:form
|
|
573
|
+
fields:
|
|
574
|
+
- name: email
|
|
575
|
+
type: email
|
|
576
|
+
submitLabel: Send
|
|
577
|
+
```
|
|
578
|
+
````
|
|
579
|
+
|
|
580
|
+
→ `content.data?.form` = `{ fields: [...], submitLabel: "Send" }`, when the component declares `data: { form: … }`. A block under a key the component does not declare stays out of `content.data` (the dev console says so) and is still in `content.sequence`.
|
|
581
|
+
|
|
582
|
+
**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')`.
|
|
583
|
+
|
|
584
|
+
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`.
|
|
585
|
+
|
|
586
|
+
### Math (LaTeX)
|
|
587
|
+
|
|
588
|
+
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:
|
|
589
|
+
|
|
590
|
+
| Form | Mode |
|
|
591
|
+
|---|---|
|
|
592
|
+
| `$x^2$` | Inline |
|
|
593
|
+
| `$$x^2$$` | Display (block on its own line, inline display mid-paragraph) |
|
|
594
|
+
| ` ```math ` fence | Display (multi-line friendly) |
|
|
595
|
+
| `\$` | Literal `$` |
|
|
596
|
+
|
|
597
|
+
**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.
|
|
598
|
+
|
|
599
|
+
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.
|
|
600
|
+
|
|
601
|
+
### Lists as navigation menus
|
|
602
|
+
|
|
603
|
+
Markdown lists model nav, menus, and grouped links. Each list item is a full content object with `paragraphs`, `links`, `icons`, and nested `lists`.
|
|
604
|
+
|
|
605
|
+
```markdown
|
|
606
|
+
-  [Home](/) ← content.lists[0], each item.links[0] + item.icons[0]
|
|
607
|
+
-  [Docs](/docs)
|
|
608
|
+
|
|
609
|
+
- Product ← nested: group.paragraphs[0] is the label,
|
|
610
|
+
- [Features](/features) ← group.lists[0] holds sub-items
|
|
611
|
+
- [Pricing](/pricing)
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**For richer navigation** with icons, descriptions, or hierarchy, use a `yaml:nav` tagged block:
|
|
615
|
+
|
|
616
|
+
````markdown
|
|
617
|
+
```yaml:nav
|
|
618
|
+
- label: Dashboard
|
|
619
|
+
href: /
|
|
620
|
+
icon: lu:layout-grid # same icon spellings as anywhere else —
|
|
621
|
+
- label: Docs # lu:name, lucide:name, lu-name, or an SVG path
|
|
622
|
+
href: /docs
|
|
623
|
+
icon: /icons/book.svg
|
|
624
|
+
children:
|
|
625
|
+
- label: Getting Started
|
|
626
|
+
href: /docs/quickstart
|
|
627
|
+
```
|
|
628
|
+
````
|
|
629
|
+
|
|
630
|
+
Access: `content.data?.nav`, with `data: { nav: {} }` in the component's `meta.js` — 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`.
|
|
631
|
+
|
|
632
|
+
### Section backgrounds
|
|
633
|
+
|
|
634
|
+
Set `background` in frontmatter — the runtime renders it:
|
|
635
|
+
|
|
636
|
+
```yaml
|
|
637
|
+
background: /images/hero.jpg # Image
|
|
638
|
+
background: /videos/hero.mp4 # Video
|
|
639
|
+
background: linear-gradient(135deg, #667eea, #764ba2) # Gradient
|
|
640
|
+
background: '#1a1a2e' # Color (hex — quote in YAML)
|
|
641
|
+
background: primary-900 # Palette token (bare name or var())
|
|
642
|
+
|
|
643
|
+
background: # Object form for more control
|
|
644
|
+
image: { src: /img.jpg, position: center top }
|
|
645
|
+
overlay: { enabled: true, type: dark, opacity: 0.5 }
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Components that render their own background declare `background: 'self'` in `meta.js`, and the runtime then draws none; to draw the author's background themselves they render kit's `<SectionBackground block={block} />`.
|
|
649
|
+
|
|
650
|
+
### Page organization
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
site/layout/ # header.md, footer.md, left.md — rendered on every page
|
|
654
|
+
site/pages/home/
|
|
655
|
+
├── page.yml # title, description, order
|
|
656
|
+
├── 1-hero.md # Numeric prefix sets order
|
|
657
|
+
├── 2-features.md
|
|
658
|
+
└── 3-cta.md
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Decimals insert between: `2.5-testimonials.md` goes between `2-` and `3-`. **Ignored:** `README.md`, and `_*.md` / `_*/` (drafts and private files).
|
|
662
|
+
|
|
663
|
+
```yaml
|
|
664
|
+
# page.yml
|
|
665
|
+
title: About Us
|
|
666
|
+
id: about # Stable identity (for page: links, survives moves)
|
|
667
|
+
order: 2 # Navigation sort position
|
|
668
|
+
sections: [hero, team, ...] # Section order within this page (same ... rule)
|
|
669
|
+
pages: [team, history, ...] # Child page order, when this page has child folders
|
|
670
|
+
redirect: academic # Redirect to a child page (relative/absolute path, or URL)
|
|
671
|
+
slug: { fr: a-propos } # Localized URL segment per language
|
|
672
|
+
|
|
673
|
+
# folder.yml — a folder OF PAGES, where page.yml means a page built from sections
|
|
674
|
+
title: Getting Started
|
|
675
|
+
pages: [quickstart, app-tour, ...] # Child page order (... = rest)
|
|
676
|
+
|
|
677
|
+
# site.yml
|
|
678
|
+
index: home # Just set the homepage
|
|
679
|
+
pages: [home, about, ...] # Order pages (... = rest, first = homepage)
|
|
680
|
+
foundation: '@acme/ui@1.2.0' # The component system (see Part 2, step 1)
|
|
681
|
+
extensions: ['@acme/fx@0.3.1'] # Secondary foundations — same shapes as foundation:
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
**The trailing `...` is not decoration — dropping it changes behaviour.** `pages:`
|
|
685
|
+
in `folder.yml` and `site.yml`, and `sections:` in `page.yml`, all read it the same way:
|
|
686
|
+
|
|
687
|
+
| written | means |
|
|
688
|
+
|---|---|
|
|
689
|
+
| `[a, b, ...]` | **inclusive** — `a` and `b` pinned in that order, **everything else follows** |
|
|
690
|
+
| `['...']` | identical to omitting the key |
|
|
691
|
+
| `[a, b]` — no `...` | **strict** — and every unlisted sibling is dropped from **every** menu |
|
|
692
|
+
|
|
693
|
+
⚠️ **Strict is a navigation filter, not a delete, which is what makes it easy to
|
|
694
|
+
miss.** Unlisted pages stay routed, stay in `dist/`, stay in `llms.txt` and still
|
|
695
|
+
resolve by URL — only the links to them disappear. Your route count does not
|
|
696
|
+
change and nothing warns. **If you list pages in order and mean "these first",
|
|
697
|
+
end the list with `...`.**
|
|
698
|
+
|
|
699
|
+
**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.
|
|
700
|
+
|
|
701
|
+
**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 `/` — declared in `site.yml`, or in the pages directory's own `folder.yml` / `page.yml` (how a content repo carries its order), with `site.yml` winning.
|
|
702
|
+
|
|
703
|
+
**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.
|
|
704
|
+
|
|
705
|
+
**A local folder for a mounted route should use `folder.yml`, not `page.yml`.** The filename is how you say what the folder holds — `page.yml` for a page built from sections, `folder.yml` for a folder of pages — and for a mount that answer also decides how the mounted tree is read. A `page.yml` stub over a mounted folder of pages says "sections", and top-level markdown in the mounted repo collapses into one page instead of becoming pages of its own. Keep `layout:`, `title:` and SEO in that stub; the mounted repo's own `folder.yml` supplies its ordering and title where the stub is silent.
|
|
706
|
+
|
|
707
|
+
**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.
|
|
708
|
+
|
|
709
|
+
```yaml
|
|
710
|
+
# site.yml
|
|
711
|
+
keywords: [components, react, cms]
|
|
712
|
+
seo:
|
|
713
|
+
image: /og-default.png
|
|
714
|
+
noindex: false # true keeps the WHOLE site out of search
|
|
715
|
+
|
|
716
|
+
# page.yml
|
|
717
|
+
seo:
|
|
718
|
+
image: /og-about.png
|
|
719
|
+
canonical: https://acme.com/about
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**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.
|
|
723
|
+
|
|
724
|
+
**Knowledge pages — content for an AI agent, not for a visitor.** `knowledge: true` in a `page.yml` or `folder.yml` marks a page as agent-only: it is **never rendered** — no route, no HTML — and it cascades to the whole subtree by route prefix, so one marker on `pages/kb/` covers everything under it and `/kb` never claims `/kbase`. Write plain markdown; a section `type:` there never selects a component, because nothing renders these pages.
|
|
725
|
+
|
|
726
|
+
⚠️ **A knowledge page is not published to anyone — it powers a service your site offers.** Everything your site *does* publish is published on equal terms: an AI visiting your site sees exactly what a person sees. `llms.txt` and the per-page `.md` files are a convenience for that reader, not a privileged tier — no visitor, human or machine, gets more than another.
|
|
727
|
+
|
|
728
|
+
A knowledge page is different in kind, not in audience. It is never a page anyone reads; it is source material for an assistant the site offers **to its visitors** — they ask, they get an answer, and the material behind it stays unpublished. That is why it is kept out of `llms.txt`, the `.md` projections and the search index: not because some readers are trusted less, but because a knowledge page was never a page.
|
|
729
|
+
|
|
730
|
+
⇒ If what you want is "make this readable by any AI that visits", write an ordinary page. It is already published to them, on the same terms as to everyone else.
|
|
731
|
+
|
|
732
|
+
⛔ **`knowledge:` is not a security control — don't put secrets there.** "Not published" is about who the prose is *written for*, not about confidentiality. The assistant's job is to answer visitors using this material and it can quote it back to whoever asks, so a visitor who prompts it can surface what a knowledge page says — **by design, not by leak.** The test for what belongs here is "is this written for the assistant to reason with?", not "do I want to keep this from people?" Anything that genuinely must not reach a visitor doesn't belong in your site's content at all.
|
|
733
|
+
|
|
734
|
+
```yaml
|
|
735
|
+
# pages/kb/page.yml
|
|
736
|
+
title: Product Knowledge
|
|
737
|
+
knowledge: true
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
What can actually *read* it depends on where you deploy, because reading it needs a server:
|
|
741
|
+
|
|
742
|
+
- **`uniweb deploy`** (backend-hosted) — the content travels with the site, so an assistant running over it can grep and read your knowledge material alongside the public content when answering a visitor's question.
|
|
743
|
+
- **`uniweb export` / `uniweb deploy --host <adapter>`** (static host) — **the build drops these pages and says so**: `Dropped 2 knowledge page(s) from this build: /kb, /kb/pricing`. Files are served as files here; there is no assistant, so nothing would read them. The warning means the flag was honoured.
|
|
744
|
+
|
|
745
|
+
The artifacts **your build emits** to describe the public site never name them — `llms.txt`, the per-page `.md` projections, and the search index all describe pages a visitor reads, and a knowledge page's prose was written for the assistant instead. Two settings **outrank** `knowledge:`, so a contradiction resolves toward the narrower reach: `agents.exclude` in `site.yml`, and any `_`-prefixed route segment. *(A host that derives these artifacts itself, rather than serving the ones your build produced, applies its own rules — ask your host what it does with `knowledge:` before relying on it there.)*
|
|
746
|
+
|
|
747
|
+
Don't confuse it with the visibility flags: `hidden: true` is a **draft** (not published at all), `hideIn` only controls **nav placement** (still reachable by URL), and `knowledge: true` is a **different audience** (never rendered for anyone). ⛔ `hidden: true` on `pages/404/` means a **draft 404** — it is not published and the site falls back to the generic `404.html`; an authored 404 needs no flag, since it is never listed in navigation anyway. A **section** takes `hidden: true` in its frontmatter with the same meaning: previewed in `uniweb dev`, left out of a published build with its child sections, and never passed to the component as a param. (`_name.md` is the other way to keep a section out: no build reads it, dev included.)
|
|
748
|
+
|
|
749
|
+
**A site can be nothing but knowledge — that is a real and useful shape.** A site is a set of routes; it does not have to have HTML pages. Mark the root `knowledge: true` and every page inherits it, so the site renders nothing at all and exists to *be* an agent: a `/_agent/chat` URL that a web app, a mobile app, or someone else's backend sends requests to.
|
|
750
|
+
|
|
751
|
+
```
|
|
752
|
+
site/pages/
|
|
753
|
+
├── page.yml # knowledge: true — cascades to everything
|
|
754
|
+
├── house-style.md
|
|
755
|
+
├── pricing.md
|
|
756
|
+
└── support.md
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Measured on exactly that shape: **the agent corpus holds every page; the public projections hold nothing** — `llms.txt` lists no pages, the search index has 0 entries, and no `.md` files are emitted. Nothing leaks, by construction rather than by configuration, because there is no public surface to leak onto.
|
|
760
|
+
|
|
761
|
+
⛔ **This shape needs a host that actually runs an agent, and deploying is not the same as having one.** Two ways to end up with nothing:
|
|
762
|
+
|
|
763
|
+
- **A static host** (`uniweb export`, `deploy --host`) drops every page — they have no reader there — and you get an empty SPA shell. The build still says *"complete"*, because 0 pages is not an error: it reports `Collected 0 pages` and pre-renders none.
|
|
764
|
+
- **A backend-hosted deployment that does not offer the service.** Whether an agent endpoint exists is the host's to decide, per site — it is not implied by deploying successfully.
|
|
765
|
+
|
|
766
|
+
⇒ **The way to know is to ask, at render:** `isAssistantEnabled()` from `@uniweb/kit` is true only where an agent endpoint is declared. On a site that is *only* knowledge there is no component to ask — so confirm with your host that the agent is enabled for that site before you build an integration against it. If you meant to build an agent endpoint and got silence, this is where to look.
|
|
767
|
+
|
|
768
|
+
### Your site is readable by agents, automatically
|
|
769
|
+
|
|
770
|
+
Every build emits two things an AI agent can use directly, alongside the HTML. **Free, on by default, nothing to install.**
|
|
771
|
+
|
|
772
|
+
| Artifact | What it is |
|
|
773
|
+
|---|---|
|
|
774
|
+
| `/llms.txt` | An annotated index of the site — every page, with a one-line description, linking to the `.md` below |
|
|
775
|
+
| `/{route}.md` | Each page as clean markdown: the content, no navigation, no chrome |
|
|
776
|
+
|
|
777
|
+
The point is the pair. An agent fetches the index, *reads* what each page is about, and goes straight to the one it needs — two requests, no HTML stripping, no guessing at URLs. Descriptions come from `page.yml`'s `description:`, then `seo.ogDescription`, then the page's opening paragraph, so a site usually gets a complete index without writing any of them.
|
|
778
|
+
|
|
779
|
+
**Large sites also get per-branch indexes.** A branch with at least 5 pages gets its own — `/docs/llms.txt` lists just the docs, titled by that folder. The site index still lists everything; a branch index is an extra entry point, not a replacement, so an agent that starts at `/llms.txt` still reaches any page in two hops.
|
|
780
|
+
|
|
781
|
+
```yaml
|
|
782
|
+
# site.yml — all optional; these are the defaults
|
|
783
|
+
agents:
|
|
784
|
+
index: true # /llms.txt
|
|
785
|
+
markdown: true # /{route}.md
|
|
786
|
+
branchIndexes: true # /docs/llms.txt for branches big enough to want one
|
|
787
|
+
branchMinPages: 5 # how big is big enough
|
|
788
|
+
exclude: [/internal] # keep a branch out of both (cascades)
|
|
789
|
+
|
|
790
|
+
agents: false # or turn the whole thing off in one word
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
#### Expecting agent traffic from somewhere specific
|
|
794
|
+
|
|
795
|
+
If your site runs an agent (see *Knowledge pages* above), you can declare where its requests are expected to come from. Useful when the site exists to serve someone else's app rather than its own visitors — a chat endpoint your web app calls.
|
|
796
|
+
|
|
797
|
+
```yaml
|
|
798
|
+
# site.yml
|
|
799
|
+
agents:
|
|
800
|
+
expectedOrigins:
|
|
801
|
+
- https://app.example.com
|
|
802
|
+
- https://partner.example.org
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**It is a declaration, not a gate — the name is the contract.** You are telling the host where you expect callers from; a host that offers an agent may then decline requests whose browser-sent `Origin` is not one of them, before it spends anything answering. Behaviour where it is honoured:
|
|
806
|
+
|
|
807
|
+
| | |
|
|
808
|
+
|---|---|
|
|
809
|
+
| not declared | no check at all — nothing happens unless you ask for it |
|
|
810
|
+
| your own site's origin | always fine, without listing it — declaring a partner must not take down your own chat box |
|
|
811
|
+
| a request with **no** `Origin` | **allowed** — see below, this is deliberate |
|
|
812
|
+
| matching | exact, per entry; no wildcards |
|
|
813
|
+
|
|
814
|
+
⛔ **It is not a spend limit, and it cannot be.** `Origin` is set by the browser and enforced by the browser: anything that is not a browser sends whatever it likes, or nothing at all. So this stops **casual misuse and third-party embedding** — someone dropping your endpoint into their own page — and it does **not** stop anyone determined to run up your bill. Do not size your budget on it.
|
|
815
|
+
|
|
816
|
+
⭐ **And that is why a request with no `Origin` is allowed rather than refused.** There are two shapes of caller and this check only sees one:
|
|
817
|
+
|
|
818
|
+
- your app's **JavaScript**, in a browser — sends `Origin`, covered here;
|
|
819
|
+
- your app's **server**, calling from its own backend — never sends one, and refusing it would break the integration this feature partly exists for.
|
|
820
|
+
|
|
821
|
+
Server-to-server callers are authenticated by other means, arranged with your host — not by anything in `site.yml`. **Never put a shared secret in `site.yml`:** everything in it is delivered to every visitor as part of the site payload.
|
|
822
|
+
|
|
823
|
+
⚠️ Spelling matters and nothing downstream will catch it — the block is forwarded to your host as opaque data, so a typo is carried and simply never acted on. `uniweb doctor` flags unknown keys in `agents:` for exactly this reason; run it before you rely on one.
|
|
824
|
+
|
|
825
|
+
**Set `seo.baseUrl` if you want absolute links** in the index — without it the links are root-relative, which still works for an agent that arrived via the index. `uniweb doctor` warns when it's unset.
|
|
826
|
+
|
|
827
|
+
**What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and a parametric page's URL pattern (`/blog/:slug`). An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
|
|
828
|
+
|
|
829
|
+
**Declaring how your content may be used** is a separate axis from whether it may be fetched, and it goes in `seo.robots`:
|
|
830
|
+
|
|
831
|
+
```yaml
|
|
832
|
+
# site.yml
|
|
833
|
+
seo:
|
|
834
|
+
robots:
|
|
835
|
+
contentSignals:
|
|
836
|
+
search: true # may appear in search results
|
|
837
|
+
ai-input: true # may be retrieved at inference time
|
|
838
|
+
ai-train: false # may not be used to train a model
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
|
|
842
|
+
|
|
843
|
+
### Records, queries and parametric pages
|
|
844
|
+
|
|
845
|
+
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **The other kind is a set of records: repeating content managed as one file per item**, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
|
|
846
|
+
|
|
847
|
+
It is a small database, not a page tree. Three things, deliberately separate:
|
|
848
|
+
|
|
849
|
+
```
|
|
850
|
+
site/
|
|
851
|
+
├── pages/
|
|
852
|
+
├── records/ # your site's records — every file in a schema folder is one
|
|
853
|
+
│ ├── article/ # the folder names their data schema
|
|
854
|
+
│ │ ├── getting-started.md
|
|
855
|
+
│ │ └── design-tips.md
|
|
856
|
+
│ ├── person/
|
|
857
|
+
│ │ └── alice.yml
|
|
858
|
+
│ └── folder.yml # optional: sorts records into folders
|
|
859
|
+
├── queries.yml # how content is REACHED
|
|
860
|
+
└── site.yml
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
**`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
|
|
864
|
+
|
|
865
|
+
| on disk | schema |
|
|
866
|
+
|---|---|
|
|
867
|
+
| `records/article/…` | `@/article` — your foundation's own |
|
|
868
|
+
| `records/std/person/…` | `@std/person` — the shared standard set |
|
|
869
|
+
| `records/acme/project/…` | `@acme/project` — an org's |
|
|
870
|
+
|
|
871
|
+
Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
|
|
872
|
+
|
|
873
|
+
**Links are records too — `@uniweb/link`.** A file in `records/uniweb/link/` holding `url:` and nothing else is a link record (one per file; no body, no other fields). Its display text and tags go on its `records/folder.yml` entry (`- path: uniweb/link/launch-video.yml` with `label:` / `tags:`), like any record's. Select links with a query `schema: '@uniweb/link'` and declare them in `meta.js` as `data: { videos: '@uniweb/link' }`; each arrives as `{ url, $name, $label, $tags }`. A link's `url` is absolute (`https://…`), and `uniweb push` refuses a relative one and a `draft: true` link.
|
|
874
|
+
|
|
875
|
+
**Files are records too — `@uniweb/file`.** The file itself is the record: any file dropped into `records/uniweb/file/` (a PDF, a spreadsheet, any type) is a record named by its file name without the extension; nothing in it is read. Its label and tags go on its `records/folder.yml` entry (`- path: uniweb/file/brochure.pdf`). Query with `schema: '@uniweb/file'`, declare with `data: { downloads: '@uniweb/file' }`; each arrives as `{ file: { url, name, mime, size }, $name, $label, $tags }` — link to `file.url`. `uniweb push` uploads the file with the site's media; a pull fetches it back. Two files of one name with different extensions clash — rename one. On a site that goes to a backend, a component must declare the type (`@uniweb/link` or `@uniweb/file`) for a query over it to be accepted.
|
|
876
|
+
|
|
877
|
+
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
878
|
+
|
|
879
|
+
| Format | Best for | Notes |
|
|
880
|
+
|---|---|---|
|
|
881
|
+
| `.md` | Items with prose — articles, case studies | Frontmatter holds fields, body holds text; auto-excerpt and first-image extraction |
|
|
882
|
+
| `.yml` / `.yaml` | Structured records with no prose body — team, products | Cleaner than a markdown file with an empty body |
|
|
883
|
+
| `.json` | Same as YAML | When the data comes out of another tool |
|
|
884
|
+
| `.bib` | Bibliographies | Each `@entry{key, …}` is a record, its cite key its name (`$name`); normalized to CSL-JSON, LaTeX accents converted to Unicode |
|
|
885
|
+
|
|
886
|
+
**One record per file.** A file's name is its record's name (`records/team/alice.yml` is `alice`); a `slug:` key at its top, or a file holding a list, stops the build — to rename a record, rename its file. A `.bib` file holds many entries, each named by its cite key. **A component reads a record's name as `$name`** — the one name it carries on every site; the delivered record has no `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
|
|
887
|
+
|
|
888
|
+
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out, and a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
|
|
889
|
+
|
|
890
|
+
**A record's file follows its data schema's sections.** A schema with one section (the `fields:` form) is written flat, as above. A schema with more than one is written **by section** — each field under its section's name — and a field left at the top stops the build and a push, naming where it goes. `@std/article` has two, its card `brief` and `body`:
|
|
891
|
+
|
|
892
|
+
```markdown
|
|
893
|
+
<!-- records/std/article/hello.md -->
|
|
894
|
+
---
|
|
895
|
+
brief:
|
|
896
|
+
title: Hello
|
|
897
|
+
author: Ada
|
|
898
|
+
date: 2026-05-01
|
|
899
|
+
---
|
|
900
|
+
|
|
901
|
+
The body — the value of the schema's content field, `body.content`.
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: { schema: '@std/article', whole: true } }` gets each record **whole**, as stored: every section under its own name, the brief's included (`article.brief.title`, `article.body.content`). Both carry `$name`, the handle. Lists carry briefs; a section that shows a record in full declares `whole: true`. A `fields:` schema's brief is the whole record, so its components need no `whole: true`. Full rules: `reference/entity-content.md`.
|
|
905
|
+
|
|
906
|
+
**A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
|
|
907
|
+
|
|
908
|
+
**`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
|
|
909
|
+
|
|
910
|
+
```yaml
|
|
911
|
+
- folder: archive
|
|
912
|
+
label: The Archive
|
|
913
|
+
records:
|
|
914
|
+
- article/2025-*.md
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A `label` — a record entry's or a folder's — is one text in the site's language, or one per language (`label: { en: Launch video, fr: Vidéo de lancement }`); `$label` is the page's language, else its base language, else the site's. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
|
|
918
|
+
|
|
919
|
+
⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
|
|
920
|
+
|
|
921
|
+
**`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
|
|
922
|
+
|
|
923
|
+
```yaml
|
|
924
|
+
recent:
|
|
925
|
+
schema: '@/article'
|
|
926
|
+
sort: date desc # `date desc`, `title asc`, …
|
|
927
|
+
limit: 10
|
|
928
|
+
|
|
929
|
+
team:
|
|
930
|
+
schema: '@std/person'
|
|
931
|
+
where: { active: true } # a predicate — see authoring/predicates.md
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
|
|
935
|
+
|
|
936
|
+
**Show a query on a page** with `query:` in `page.yml` or a section's frontmatter (the whole result), or `fetch:` for anything more — a `limit`, a `where`. A list — `query: [team, articles]` — declares several, each filling its own key in the sections whose components declare it. `query:` takes names only; `data:`, its old name, is now an error.
|
|
937
|
+
|
|
938
|
+
**Who receives it:** a section's own declaration reaches that section; a page's, every section on the page; a parent page's, the sections of the pages directly under it. **The site is the root page:** `query:` in `site.yml` reaches the layout areas (header, footer, …) and the sections of top-level pages — the homepage included — and no page further down. A section on `/docs/setup` that needs site-wide data names the query itself.
|
|
939
|
+
|
|
940
|
+
**A fetch always names a query.** A string in `fetch:` is a query name: `fetch: team` is `fetch: { query: team }`, and `fetch: [team, articles]` is a list of those. A fetch never names a file — `/data/<query>.json` is what the build generates from a query for a site with no backend, and writing that path in a `fetch:` stops the build. That is what keeps a site portable: name the query, debug locally against the generated data, publish, and the same page reads live records from the host with nothing changed.
|
|
941
|
+
|
|
942
|
+
**A query selects a set of records; a `fetch:` takes from that set, never adding to it.** The query's `scope`, `where`, `sort` and `limit` decide which records it selects — its `limit` included: `recent` above is the 10 newest articles. A fetch's `where` keeps the ones that also match, its `sort` re-orders them, and its `limit` takes the first N — never more than the query selects. That is the whole list — `scope` belongs to the query, and the build stops on a `fetch:` that carries one; for another folder branch, declare another query. **Which records get a page is the query's to decide, never a fetch's:** each record `recent` selects gets its page, an 11th-newest article gets none, and a list's `limit: 3` still leaves all 10 their pages — so a condition or count that should decide which pages exist belongs on the query. Two fetches under one key at one level: the first is used, and the build warns.
|
|
943
|
+
|
|
944
|
+
```yaml
|
|
945
|
+
# pages/blog/page.yml | # a section on the homepage
|
|
946
|
+
title: Blog | ---
|
|
947
|
+
query: recent | type: ArticleTeaser
|
|
948
|
+
| fetch: { query: recent, limit: 3 }
|
|
949
|
+
| ---
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
**Give each record its own page with a `[slug]/` folder** — a *parametric page*, what other frameworks call a dynamic route — inside the page that names the query:
|
|
953
|
+
|
|
954
|
+
```
|
|
955
|
+
pages/blog/
|
|
956
|
+
├── page.yml # title: Blog / query: recent
|
|
957
|
+
├── list.md
|
|
958
|
+
└── [slug]/
|
|
959
|
+
├── page.yml
|
|
960
|
+
└── article.md # just `type: Article` — the record arrives automatically
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
`records/article/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.
|
|
964
|
+
|
|
965
|
+
**Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
|
|
966
|
+
|
|
967
|
+
**Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
|
|
968
|
+
|
|
969
|
+
> **The record arrives under the key the component declares, as that key says** — one record for a key declared `single: true` (`content.data.article` for `article: { schema: '@std/article', single: true }` over an `@std/article` query; `null` when the URL names none), a single-element array for any other key (`content.data.recent[0]`). It is the record's brief, or the record whole where the key declares `whole: true` — a section that shows an article's body declares `whole: true`. See *Data* in Part 4.
|
|
970
|
+
|
|
971
|
+
**Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads it as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's own URL (`$route`, and the page a static build writes) is its name, as under `[slug]`: `/blog/my-post` wherever `records/folder.yml` places it — a record does not carry its folder. A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
|
|
972
|
+
|
|
973
|
+
**Two options for bigger sets:**
|
|
974
|
+
|
|
975
|
+
A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring `whole: true` still receives the whole record on a `[slug]` page, and other components fetch it on demand with `useWholeRecord`. File-based records are written one file per record, whole, at `/data/<name>/<name of the record>.json`; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4). (`deferred:` is removed: a schema's brief decides what a list holds.)
|
|
976
|
+
|
|
977
|
+
`queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
|
|
978
|
+
|
|
979
|
+
```yaml
|
|
980
|
+
# queries.yml
|
|
981
|
+
members:
|
|
982
|
+
schema: '@std/person'
|
|
983
|
+
queryable:
|
|
984
|
+
department: { type: enum, label: Department, options: [biology, physics, chemistry] }
|
|
985
|
+
tenured: { type: boolean, label: Tenured }
|
|
986
|
+
start_year: { type: range, label: Start year, min: 1800, max: 2025 }
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
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.
|
|
990
|
+
|
|
991
|
+
Full author guide: `authoring/collections.md`. Every key a query takes: `reference/queries.md`. Predicate operators: `authoring/predicates.md`.
|
|
992
|
+
|
|
993
|
+
---
|
|
994
|
+
|
|
995
|
+
## Part 4 — The foundation lane
|
|
996
|
+
|
|
997
|
+
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.
|
|
998
|
+
|
|
999
|
+
### Where things go
|
|
1000
|
+
|
|
1001
|
+
```
|
|
1002
|
+
src/ # the foundation package (folder name is `src`)
|
|
1003
|
+
├── sections/ # Section types (auto-discovered)
|
|
1004
|
+
│ ├── Hero.jsx # Bare file — no folder needed
|
|
1005
|
+
│ ├── Features/ # Folder when you need meta.js
|
|
1006
|
+
│ │ ├── index.jsx
|
|
1007
|
+
│ │ └── meta.js
|
|
1008
|
+
│ └── insets/ # Organizational subdirectory (lowercase)
|
|
1009
|
+
├── layouts/ # Custom layouts (optional, auto-discovered)
|
|
1010
|
+
├── components/ # Your React components — any props you like, no meta.js
|
|
1011
|
+
├── utils/ # Helper functions, non-React logic
|
|
1012
|
+
├── main.js
|
|
1013
|
+
├── styles.css
|
|
1014
|
+
└── package.json # name: "src"
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
**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.
|
|
1018
|
+
|
|
1019
|
+
**JSX goes in `.jsx` files** (or `.tsx`). Discovery also accepts a `.js` component, but only one without JSX: JSX compiles in no `.js` file, and the build stops on one, naming the file to rename.
|
|
1020
|
+
|
|
1021
|
+
**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.
|
|
1022
|
+
|
|
1023
|
+
**Import aliases.** Foundations include subpath imports for shared internals — use them instead of brittle relative paths:
|
|
1024
|
+
|
|
1025
|
+
| Alias | Maps to | Use for |
|
|
1026
|
+
|-------|---------|---------|
|
|
1027
|
+
| `#components/*` | `./components/*` | Shared React components |
|
|
1028
|
+
| `#utils/*` | `./utils/*` | Helper functions, non-React logic |
|
|
1029
|
+
|
|
1030
|
+
```jsx
|
|
1031
|
+
import LessonHeader from '#components/LessonHeader' // ✅
|
|
1032
|
+
import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you reorganize sections/
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
Within the same directory, use normal relative imports (`./AIFeedbackCard`).
|
|
1036
|
+
|
|
1037
|
+
**Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the foundation's identity — `name` (what it registers as, `@org/<name>`) and `description` — and the capabilities it provides — `defaultLayout`, `defaultSection`, `viewTransitions`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. The capabilities are read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build carries into `default.capabilities` in `dist/entry.js` only what a published page reads of your default export — the code it calls (`handlers`, `defaultInsets`, `xref`, `transports`, `outputs`) and its data, lean (`defaultLayout`, `defaultSection`, `viewTransitions`, `scroll`, each var's `default`, `type` and `applyTo`, each `data:` key's schema ref). `name`, `description`, a var's label or description and any key the framework does not read stay out of the published bundle: they are for the editor, which reads them from the foundation's schema. You never write that wrapper. The file is `main.js` — never `main.jsx`, which the build refuses — and it is plain JavaScript: a component it declares (in `defaultInsets`, `xref`) lives in a `.jsx` file it imports. 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.
|
|
1038
|
+
|
|
1039
|
+
### Props interface
|
|
1040
|
+
|
|
1041
|
+
```jsx
|
|
1042
|
+
function MyComponent({ content, params, block }) {
|
|
1043
|
+
const { title, paragraphs, links, items } = content // Guaranteed shape
|
|
1044
|
+
const { columns, variant } = params // Defaults from meta.js
|
|
1045
|
+
const { website } = useWebsite() // Or block.website
|
|
1046
|
+
}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
**Frontmatter becomes `params` — except the names framework keeps for itself.** Every section can be given these, whatever its type, and none of them reaches your component as a param. So **never declare one in `meta.js` `params:`** — a component that does never receives what an author writes.
|
|
1050
|
+
|
|
1051
|
+
| in a section's frontmatter | what it is | a component reads it from |
|
|
1052
|
+
|---|---|---|
|
|
1053
|
+
| `type`, `id`, `hidden` | the section itself — its section type, its id, and a draft (`hidden: true`: left out of a published build, kept by `uniweb dev`) | — |
|
|
1054
|
+
| `query`, `fetch` | the section's data | `content.data`, under the keys its `meta.js` `data:` declares |
|
|
1055
|
+
| `theme`, `background`, `grid`, `vars` | the section's settings, which framework applies — the color context and the background around your component, the section's theme and your component's CSS variables in the page stylesheet, the child layout | `block`, when the component needs one for its own logic: `useColorContext(block)` for the context (a light vs. dark logo, say), `<SectionBackground block={block} />` to draw the background itself, `<ChildGrid from={block} />` for the grid |
|
|
1056
|
+
| `props` | params written as one map | `params` — its keys are merged in, one by one |
|
|
1057
|
+
| `data`, `preset`, `input` | nothing: `data:` is refused (it was `query:`'s old name); `preset:` and `input:` are ignored with a warning | — |
|
|
1058
|
+
|
|
1059
|
+
The build warns when `params:` declares any of them. A preset may still set `theme` or `background` — a preset's params are written into the section's frontmatter when an author starts a section from it, so `dark: { label: 'Dark', params: { theme: 'dark' } }` is a dark section. Every other key is a param, declared or not; components ignore the ones they don't use.
|
|
1060
|
+
|
|
1061
|
+
### Rendering content with Kit
|
|
1062
|
+
|
|
1063
|
+
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:
|
|
1064
|
+
|
|
1065
|
+
```jsx
|
|
1066
|
+
import { H1, H2, H3, P, Span } from '@uniweb/kit'
|
|
1067
|
+
|
|
1068
|
+
<H1 text={content.title} className="text-heading text-5xl font-bold" />
|
|
1069
|
+
<H2 text={content.subtitle} className="text-heading text-2xl" />
|
|
1070
|
+
<P text={content.paragraphs} className="text-body" />
|
|
1071
|
+
<Span text={listItem.paragraphs[0]} className="text-subtle" />
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
Kit provides `H1`–`H6`. These render their own HTML tag — **don't wrap them**: `<H2 text={…} />`, not `<h2><H2 text={…} /></h2>`.
|
|
1075
|
+
|
|
1076
|
+
**Full content rendering** (article/docs sections where the author controls the flow):
|
|
1077
|
+
|
|
1078
|
+
```jsx
|
|
1079
|
+
import { Section, Prose } from '@uniweb/kit'
|
|
1080
|
+
|
|
1081
|
+
<Section block={block} width="lg" padding="md" />
|
|
1082
|
+
<Prose content={content} block={block} />
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
`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>`.
|
|
1086
|
+
|
|
1087
|
+
`Article` is an older alternative rendering from `block.rawContent` (raw ProseMirror), including data blocks. Prefer `Prose` for new components.
|
|
1088
|
+
|
|
1089
|
+
### The Kit, by use case
|
|
1090
|
+
|
|
1091
|
+
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.
|
|
1092
|
+
|
|
1093
|
+
**Text:** `H1`–`H6`, `P`, `Span`, `Div`, `Text` (with `as`)
|
|
1094
|
+
**Content:** `Section`, `Prose`, `Article`, `Render` (ProseMirror → React), `ChildBlocks`, `splitContent()`
|
|
1095
|
+
**Media:** `Visual` (a `content.media` item by its kind; or the first of inset/video/image), `Image`, `Media` (video), `Icon`, `Asset`
|
|
1096
|
+
**Navigation:** `Link`, `useActiveRoute()`, `useWebsite()`, `useRouting()`
|
|
1097
|
+
**Header/layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
|
|
1098
|
+
**Overlays:** `Overlay` (modals, palettes, drawers, toasts — portals out of the layout, contains focus; read the note below before hand-rolling one)
|
|
1099
|
+
**Keyboard:** `useShortcut('mod+k', fn)`, `useShortcuts({…})`, `useShortcutLabel('mod+k')` — `mod` is Cmd on Apple platforms and Ctrl elsewhere, and the label renders per-platform so a hint never shows the wrong key
|
|
1100
|
+
**Long-form prose:** `<Prose>` and the container you put `<Render>` output in both use Tailwind Typography's `prose` classes. One line in the foundation's `styles.css` supplies them and points them at the site's theme:
|
|
1101
|
+
|
|
1102
|
+
```css
|
|
1103
|
+
@import "@uniweb/kit/prose-tokens.css"; /* the plugin, wired to theme.yml */
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Nothing to install — the import brings the plugin with it. **Skip the import and prose is completely unstyled** — lists lose their bullets and numbers, and text its typography — while the markup looks right. The foundation build warns about it, and `uniweb doctor` flags it. Use **one** `prose` container per subtree — the `--tw-prose-*` variables are inherited, so a nested one silently resets them and the outer container goes on looking correct. The section that renders the document is usually the better owner; a layout should supply column width and padding.
|
|
1107
|
+
|
|
1108
|
+
**Documentation shells:** `useHeadings()` (the page's headings + the one being read, derived from content so it prerenders), `website.getBranchHierarchy({ route, for })` (the page tree for one branch). Kit ships no ready-made layout — a layout is your foundation's design; write it in `src/layouts/` and use these for the behaviour.
|
|
1109
|
+
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`
|
|
1110
|
+
**Theming data:** `useThemeData()`, `useColorContext(block)`
|
|
1111
|
+
**Data fetching:** `useFetched`, `useCacheEntry`, `useWholeRecord`
|
|
1112
|
+
**Forms:** `useFormValues` (an author-designed form's state), `valueAt(values, path)`, `useFormSubmit`, `submitForm`, `resolveSubmitTarget` — see *Forms* below
|
|
1113
|
+
**Utilities:** `cn()`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`, `getLocaleLabel(locale)`
|
|
1114
|
+
**Other styled:** `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
|
|
1115
|
+
|
|
1116
|
+
Four things you won't discover by reading exports:
|
|
1117
|
+
|
|
1118
|
+
> **Content fields are HTML strings.** Covered above — it's the single most common rendering bug.
|
|
1119
|
+
|
|
1120
|
+
> **`<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.
|
|
1121
|
+
|
|
1122
|
+
> **`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.
|
|
1123
|
+
|
|
1124
|
+
> **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).
|
|
1125
|
+
|
|
1126
|
+
> **A modal needs `<Overlay>`, not a bigger z-index.** The runtime gives each layout area its own `view-transition-name` so header, rails and body animate independently. That makes each area a stacking context *and* a containing block for `fixed` children — so a dialog rendered from inside your Header is sealed into the header's context and paints *under* the page body no matter what z-index it carries. Raising the number looks like it should work and never does. `<Overlay onClose={close}>…</Overlay>` renders into `document.body`, where the z-index means what you expect.
|
|
1127
|
+
>
|
|
1128
|
+
> It also does the part that is easy to skip: a modal overlay **contains focus** — moves focus in, cycles Tab within, marks the rest of the page `inert`, and returns focus to whatever opened it. That is what makes the `role="dialog" aria-modal="true"` on your box true rather than merely asserted; without it a keyboard user Tabs straight out into a page a screen-reader user has been told is unreachable. Plus Escape, the scrim click, the scroll lock, and a dimmed scrim you override with `className` (`bg-transparent` removes it). `modal={false}` for a toast: it escapes the stacking context and nothing else, so the page stays usable. Your dialog's markup stays yours.
|
|
1129
|
+
|
|
1130
|
+
### Icon component
|
|
1131
|
+
|
|
1132
|
+
```jsx
|
|
1133
|
+
{content.icons.map((icon, i) => <Icon key={i} {...icon} />)} // From content
|
|
1134
|
+
<Icon name="search" /> // Lucide (default)
|
|
1135
|
+
<Icon name="hi2-arrow-right" /> // Other library by prefix
|
|
1136
|
+
<Icon name="close" /> // Built-in (no network)
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
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`.
|
|
1140
|
+
|
|
1141
|
+
### Section wrapper
|
|
1142
|
+
|
|
1143
|
+
The runtime wraps every section in `<section>` with its context class and background. Customize with static properties:
|
|
1144
|
+
|
|
1145
|
+
```jsx
|
|
1146
|
+
function Hero({ content, params }) {
|
|
1147
|
+
return <div className="max-w-7xl mx-auto px-6">…</div>
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
Hero.className = 'pt-32 md:pt-48' // adds classes to the runtime wrapper
|
|
1151
|
+
Hero.as = 'div' // changes the wrapper element
|
|
1152
|
+
export default Hero
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
**Layout components typically need `p-0`** to suppress default padding:
|
|
1156
|
+
|
|
1157
|
+
```jsx
|
|
1158
|
+
Header.className = 'p-0'
|
|
1159
|
+
Header.as = 'header'
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
### meta.js
|
|
1163
|
+
|
|
1164
|
+
```javascript
|
|
1165
|
+
export default {
|
|
1166
|
+
title: 'Feature Grid',
|
|
1167
|
+
description: 'Grid of feature cards with icons',
|
|
1168
|
+
family: 'features', // the standard section type it is — the editor's
|
|
1169
|
+
// illustration and label (`uniweb families`). Optional;
|
|
1170
|
+
// a name like `Features` resolves on its own
|
|
1171
|
+
// hidden: true, // Exclude from export entirely (internal helpers)
|
|
1172
|
+
// background: 'self', // Component renders its own background
|
|
1173
|
+
// inset: true, // Available for @ComponentName in markdown
|
|
1174
|
+
// children: true, // Accepts child sections
|
|
1175
|
+
|
|
1176
|
+
content: {
|
|
1177
|
+
title: 'Section heading',
|
|
1178
|
+
paragraphs: 'Introduction [0-1]',
|
|
1179
|
+
items: {
|
|
1180
|
+
label: 'Feature cards [3-6]',
|
|
1181
|
+
content: { icon: 'Icon [1]', title: 'Feature', paragraphs: 'Description' }, // one entry
|
|
1182
|
+
},
|
|
1183
|
+
// media: 'Photo, video or diagram [1]', // an image, a video, or an embedded component
|
|
1184
|
+
// sequence: 'Prose and media', // renders content.sequence — the section as written
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
params: {
|
|
1188
|
+
columns: { type: 'number', default: 3 },
|
|
1189
|
+
variant: { type: 'select', options: ['default', 'centered', 'split'], default: 'default' },
|
|
1190
|
+
},
|
|
1191
|
+
|
|
1192
|
+
presets: {
|
|
1193
|
+
default: { label: 'Standard', params: { columns: 3 } },
|
|
1194
|
+
compact: { label: 'Compact', params: { columns: 4 } },
|
|
1195
|
+
},
|
|
1196
|
+
|
|
1197
|
+
// context / initialState: keys are developer-defined, not framework fields.
|
|
1198
|
+
context: {}, // Static — neighbors read via getNextBlockInfo().context
|
|
1199
|
+
initialState: {}, // Dynamic — neighbors read .state; component updates via useBlockState()
|
|
1200
|
+
}
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
**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.
|
|
1204
|
+
|
|
1205
|
+
**`content:` names what the component reads**, each as a label for the author with an optional count (`'Cards [3-6]'`): `title`, `pretitle`, `subtitle`, `paragraphs`, `links`, `lists`, `items`, `media`, `icons`, `documents`, `snippets`, `tables`, `math`, `quotes`, `sequence`.
|
|
1206
|
+
|
|
1207
|
+
- **`media` is an image, a video, or an embedded component.** `media: 'Hero media [1]'` takes any of the three; `media: { label, types: ['image'] }` narrows it; `image:`, `videos:` and `insets:` are the same slot narrowed to one type. Read it as `content.media` — the author's order, each item with its `kind` — and render it with `<Visual media={content.media} block={block} />`. Rendering the slot with `<Image>` drops a diagram an author placed there, and kit's `<Media>` is a video player.
|
|
1208
|
+
- **`sequence`** says the component renders `content.sequence` — the whole section as written, headline included. `{ label, except: ['table', 'math'] }` names the kinds it leaves out.
|
|
1209
|
+
- **`items: { label, content: { … } }`** says what each entry holds, in the same vocabulary.
|
|
1210
|
+
- The build warns on what it can't read, and refuses `visuals:` (declare `media`) and the `content:` element `data` (declare the block's tag in `data:`).
|
|
1211
|
+
|
|
1212
|
+
> **`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.
|
|
1213
|
+
|
|
1214
|
+
### Two worlds: section types and your own components
|
|
1215
|
+
|
|
1216
|
+
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.
|
|
1217
|
+
|
|
1218
|
+
**Everything under `components/` is yours.** It's a normal React project in there, and the framework has no opinion about it:
|
|
1219
|
+
|
|
1220
|
+
- **Any props you want** — `<PricingCard tier={tier} price={price} featured />`. No `content`, no `params`, no `block` unless you choose to pass them.
|
|
1221
|
+
- **Any composition** — children, render props, context, `forwardRef`, custom hooks, whatever the job needs.
|
|
1222
|
+
- **Any library** — install it and import it.
|
|
1223
|
+
- **No `meta.js`, no naming convention, no auto-discovery, no author visibility.**
|
|
1224
|
+
|
|
1225
|
+
Two symptoms of getting this backwards, both worth checking yourself against:
|
|
1226
|
+
|
|
1227
|
+
> **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.
|
|
1228
|
+
|
|
1229
|
+
> **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.
|
|
1230
|
+
|
|
1231
|
+
**A section type is usually a composite.** Its job is to translate parsed content into whatever your own components expect, then arrange them:
|
|
1232
|
+
|
|
1233
|
+
```jsx
|
|
1234
|
+
// sections/Pricing/index.jsx
|
|
1235
|
+
import PricingCard from '#components/PricingCard'
|
|
1236
|
+
import formatPrice from '#utils/formatPrice'
|
|
1237
|
+
|
|
1238
|
+
export default function Pricing({ content, params }) {
|
|
1239
|
+
const currency = params.currency || 'USD'
|
|
1240
|
+
return (
|
|
1241
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
|
1242
|
+
{content.items.map((tier, i) => (
|
|
1243
|
+
<PricingCard key={i} tier={tier} price={formatPrice(tier.data, currency)} />
|
|
1244
|
+
))}
|
|
1245
|
+
</div>
|
|
1246
|
+
)
|
|
1247
|
+
}
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
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.
|
|
1251
|
+
|
|
1252
|
+
**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.
|
|
1253
|
+
|
|
1254
|
+
### The Front Desk pattern
|
|
1255
|
+
|
|
1256
|
+
One arrangement of the above is worth naming, because it comes up whenever a single `type:` needs to cover genuinely different renderings.
|
|
1257
|
+
|
|
1258
|
+
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.
|
|
1259
|
+
|
|
1260
|
+
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.
|
|
1261
|
+
|
|
1262
|
+
```js
|
|
1263
|
+
// meta.js — the union of all variants' needs
|
|
1264
|
+
export default {
|
|
1265
|
+
params: {
|
|
1266
|
+
variant: { type: 'select', options: ['slider', 'contact'], default: 'slider' },
|
|
1267
|
+
slideInterval: { type: 'number', default: 5 },
|
|
1268
|
+
density: { type: 'select', options: ['default', 'compact'], default: 'default' },
|
|
1269
|
+
style: { type: 'select', options: ['default', 'dramatic'], default: 'default' },
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
```
|
|
1273
|
+
|
|
1274
|
+
```jsx
|
|
1275
|
+
// sections/Hero/index.jsx — the front desk
|
|
1276
|
+
import { SliderHero } from '#components/SliderHero'
|
|
1277
|
+
import { ContactHero } from '#components/ContactHero'
|
|
1278
|
+
|
|
1279
|
+
const variants = { slider: SliderHero, contact: ContactHero }
|
|
1280
|
+
|
|
1281
|
+
export default function Hero({ content, block, params }) {
|
|
1282
|
+
const Variant = variants[params.variant] || SliderHero
|
|
1283
|
+
return (
|
|
1284
|
+
<Variant
|
|
1285
|
+
title={content.title} subtitle={content.paragraphs[0]} links={content.links} block={block}
|
|
1286
|
+
images={content.images} // only some variants use these
|
|
1287
|
+
formData={content.data?.quote}
|
|
1288
|
+
interval={params.slideInterval} // author vocabulary → developer props
|
|
1289
|
+
compact={params.density === 'compact'}
|
|
1290
|
+
transition={params.style === 'dramatic' ? 'zoom' : 'fade'}
|
|
1291
|
+
/>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
`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.
|
|
1297
|
+
|
|
1298
|
+
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.
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
**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.
|
|
1302
|
+
|
|
1303
|
+
**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.
|
|
1304
|
+
|
|
1305
|
+
### Params, component vars, foundation vars
|
|
1306
|
+
|
|
1307
|
+
Three places a value can live, and picking the right one is most of the design:
|
|
1308
|
+
|
|
1309
|
+
| | Declared in | Reaches the component as | Author overrides in | CSS scope |
|
|
1310
|
+
|---|---|---|---|---|
|
|
1311
|
+
| **Param** | `meta.js` `params:` | a JS prop | section frontmatter | — |
|
|
1312
|
+
| **Component var** | `meta.js` `vars:` | a CSS custom property | section frontmatter `vars:` | `#section-{id}` |
|
|
1313
|
+
| **Foundation var** | `main.js` `vars:` | a CSS custom property | `theme.yml` `vars:` | `:root` (global) |
|
|
1314
|
+
|
|
1315
|
+
**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:
|
|
1316
|
+
|
|
1317
|
+
```js
|
|
1318
|
+
// meta.js — same schema as foundation vars (default, label, type, options, group, description)
|
|
1319
|
+
vars: {
|
|
1320
|
+
'card-gap': { default: '1.5rem', label: 'Card Gap', type: 'select', options: ['1rem', '1.5rem', '2rem'] },
|
|
1321
|
+
'card-radius': { default: 'var(--radius-md)', description: 'Inherits the foundation var by default' },
|
|
1322
|
+
}
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
```jsx
|
|
1326
|
+
<div className="grid gap-[var(--card-gap)]"> // emitted on #section-{id}
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
```yaml
|
|
1330
|
+
---
|
|
1331
|
+
type: PricingTable
|
|
1332
|
+
vars: { card-gap: 2rem } # author override — only declared names apply
|
|
1333
|
+
---
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
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).
|
|
1337
|
+
|
|
1338
|
+
**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:
|
|
1339
|
+
|
|
1340
|
+
```js
|
|
1341
|
+
export const vars = {
|
|
1342
|
+
'radius-lg': { default: '1rem', description: 'Large border radius' },
|
|
1343
|
+
'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical section padding' },
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
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.
|
|
1348
|
+
|
|
1349
|
+
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.
|
|
1350
|
+
|
|
1351
|
+
**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.
|
|
1352
|
+
|
|
1353
|
+
### Semantic theming
|
|
1354
|
+
|
|
1355
|
+
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.
|
|
1356
|
+
|
|
1357
|
+
```jsx
|
|
1358
|
+
<h2 className="text-slate-900">…</h2> // ❌ Hardcoded — breaks in dark context
|
|
1359
|
+
<h2 className="text-heading">…</h2> // ✅ Semantic — adapts to any context and brand
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
**Semantic tokens** (available as Tailwind `text-*`, `bg-*`, `border-*` classes):
|
|
1363
|
+
|
|
1364
|
+
| Token | Purpose |
|
|
1365
|
+
|-------|---------|
|
|
1366
|
+
| `heading` | Heading text |
|
|
1367
|
+
| `body` | Body text |
|
|
1368
|
+
| `subtle` | Secondary/de-emphasized text |
|
|
1369
|
+
| `section` | Section background |
|
|
1370
|
+
| `card` | Card/panel/well background |
|
|
1371
|
+
| `muted` | Hover states, zebra rows |
|
|
1372
|
+
| `border` | Lines, dividers |
|
|
1373
|
+
| `ring` | Focus indicators |
|
|
1374
|
+
| `link` / `link-hover` | Link colors |
|
|
1375
|
+
| `primary` / `primary-foreground` / `primary-hover` / `primary-border` | Primary actions |
|
|
1376
|
+
| `secondary` / `secondary-foreground` / `secondary-hover` / `secondary-border` | Secondary actions |
|
|
1377
|
+
| `success` / `warning` / `error` / `info` | Status colors |
|
|
1378
|
+
| `success-subtle` / `warning-subtle` / `error-subtle` / `info-subtle` | Status backgrounds (alerts) |
|
|
1379
|
+
|
|
1380
|
+
**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.
|
|
1381
|
+
|
|
1382
|
+
> **Debugging a token that "does nothing": don't probe it in the browser.** Tailwind v4 is JIT — it emits only the classes it finds in *scanned source*. So a class you assign from JavaScript, to a `<div>` you created in the console, **has never been scanned and can never resolve**, whatever the token. A comparison against a deliberately bogus class agrees with you either way, because both are ungenerated, and classes that happen to appear in source elsewhere look like they "pass" — so the probe confirms whichever answer you started with.
|
|
1383
|
+
>
|
|
1384
|
+
> **Ask the build instead.** The one command that splits the question:
|
|
1385
|
+
>
|
|
1386
|
+
> ```bash
|
|
1387
|
+
> grep -o '\.text-link{[^}]*}' dist/assets/*.css
|
|
1388
|
+
> ```
|
|
1389
|
+
>
|
|
1390
|
+
> A rule prints → the utility exists, and something at runtime is overriding it. Nothing prints → Tailwind never generated it, and there are only two reasons: your `styles.css` doesn't `@import "@uniweb/kit/theme-tokens.css"` (or declares its own `@theme inline` that omits the token), or your `@source` globs don't cover the file the class is written in. Either way, write the class into a real source file first — a token that's only ever composed at runtime is invisible to the scanner by design.
|
|
1391
|
+
|
|
1392
|
+
**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** — a section's `theme:` is `theme.yml` for that section: tokens beside `mode`, and `theme.yml`'s own `colors`, `contexts` and `vars`, scoped to it:
|
|
1393
|
+
|
|
1394
|
+
```yaml
|
|
1395
|
+
theme:
|
|
1396
|
+
mode: light
|
|
1397
|
+
section: neutral-100 # Subtle off-white surface
|
|
1398
|
+
card: neutral-50 # Cards lighter than surface
|
|
1399
|
+
primary: neutral-900 # Dark buttons instead of brand color
|
|
1400
|
+
colors: { accent: '#e65100' } # A palette for this section only
|
|
1401
|
+
contexts: # Per context, for a section that follows the site's scheme
|
|
1402
|
+
dark: { link: accent-300 }
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
`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.
|
|
1406
|
+
|
|
1407
|
+
### Section context vs site scheme
|
|
1408
|
+
|
|
1409
|
+
Uniweb splits what other frameworks fuse into one "dark mode":
|
|
1410
|
+
|
|
1411
|
+
- **Section context** — the `theme:` field on one section (`light`/`medium`/`dark`). Per-section, author-controlled.
|
|
1412
|
+
- **Site scheme** — the global light/dark preference under `appearance:` in `theme.yml`. The site-wide toggle a visitor flips.
|
|
1413
|
+
|
|
1414
|
+
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.
|
|
1415
|
+
|
|
1416
|
+
```yaml
|
|
1417
|
+
appearance:
|
|
1418
|
+
default: light # 'light' | 'dark' | 'system'
|
|
1419
|
+
allowToggle: true # offer a visitor switch (also generates the dark tokens)
|
|
1420
|
+
respectSystemPreference: true # first visit follows the OS; false pins `default`
|
|
1421
|
+
schemes: [light, dark] # optional — declares the available set
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
Shorthands: `appearance: light` / `dark` (fixed, no toggle), `appearance: system` (follow the OS).
|
|
1425
|
+
|
|
1426
|
+
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.")
|
|
1427
|
+
|
|
1428
|
+
| Goal | `theme.yml` |
|
|
1429
|
+
|---|---|
|
|
1430
|
+
| Always light, no switch | `appearance: light` |
|
|
1431
|
+
| Always dark, no switch | `appearance: dark` |
|
|
1432
|
+
| Toggle, follow the OS on first visit | `appearance: { default: system, allowToggle: true }` |
|
|
1433
|
+
| Toggle, but always start light | `appearance: { default: light, allowToggle: true, respectSystemPreference: false }` |
|
|
1434
|
+
|
|
1435
|
+
**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:
|
|
1436
|
+
|
|
1437
|
+
```jsx
|
|
1438
|
+
import { useAppearance } from '@uniweb/kit'
|
|
1439
|
+
|
|
1440
|
+
function SchemeToggle() {
|
|
1441
|
+
const { scheme, toggle, canToggle } = useAppearance()
|
|
1442
|
+
if (!canToggle) return null // hidden unless the site enables toggling
|
|
1443
|
+
return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
|
|
1444
|
+
}
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
**Tailwind `dark:` variant.** If your foundation uses `dark:` utilities, bind them to the site scheme so they track the toggle. In `styles.css`:
|
|
1448
|
+
|
|
1449
|
+
```css
|
|
1450
|
+
@custom-variant dark (&:where(.scheme-dark, .scheme-dark *));
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
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.
|
|
1454
|
+
|
|
1455
|
+
### theme.yml
|
|
1456
|
+
|
|
1457
|
+
```yaml
|
|
1458
|
+
# site/theme.yml
|
|
1459
|
+
colors:
|
|
1460
|
+
primary: '#3b82f6' # Your exact hex appears at shade 500
|
|
1461
|
+
secondary: '#64748b'
|
|
1462
|
+
accent: '#8b5cf6'
|
|
1463
|
+
neutral: stone # Named preset: stone, zinc, gray, slate, neutral
|
|
1464
|
+
|
|
1465
|
+
contexts:
|
|
1466
|
+
light:
|
|
1467
|
+
section: '#fafaf9' # Override individual tokens per context
|
|
1468
|
+
|
|
1469
|
+
appearance: { default: light, allowToggle: true }
|
|
1470
|
+
|
|
1471
|
+
fonts:
|
|
1472
|
+
heading: "'Inter', system-ui, sans-serif"
|
|
1473
|
+
body: "'Inter', system-ui, sans-serif"
|
|
1474
|
+
|
|
1475
|
+
inline:
|
|
1476
|
+
callout: { color: var(--accent), font-weight: '600' }
|
|
1477
|
+
|
|
1478
|
+
vars:
|
|
1479
|
+
radius: 0.75rem
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
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:`.
|
|
1483
|
+
|
|
1484
|
+
**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.
|
|
1485
|
+
|
|
1486
|
+
**Buttons use shade 600 — darker than your input color.** That's an accessibility choice for contrast with white text. For brand-exact buttons:
|
|
1487
|
+
|
|
1488
|
+
```yaml
|
|
1489
|
+
colors: { primary: "#E35D25" }
|
|
1490
|
+
contexts:
|
|
1491
|
+
light:
|
|
1492
|
+
primary: primary-500 # Your exact color on buttons
|
|
1493
|
+
primary-hover: primary-600
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
> **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.
|
|
1497
|
+
|
|
1498
|
+
### Fonts
|
|
1499
|
+
|
|
1500
|
+
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:
|
|
1501
|
+
|
|
1502
|
+
| Role | Wired onto | Typical use |
|
|
1503
|
+
|---|---|---|
|
|
1504
|
+
| `body` | `body` (all text by default) | paragraphs, UI |
|
|
1505
|
+
| `heading` | `h1, h2, h3` | titles |
|
|
1506
|
+
| `code` | `code, pre, kbd, samp` | code, monospace |
|
|
1507
|
+
|
|
1508
|
+
(`code` was previously named `mono`; `fonts.mono` is no longer an alias — rename it.)
|
|
1509
|
+
|
|
1510
|
+
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.
|
|
1511
|
+
|
|
1512
|
+
**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.
|
|
1513
|
+
|
|
1514
|
+
**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:
|
|
1515
|
+
|
|
1516
|
+
```js
|
|
1517
|
+
// foundation src/main.js
|
|
1518
|
+
export const vars = {
|
|
1519
|
+
'font-serif': {
|
|
1520
|
+
default: 'ui-serif, Georgia, serif',
|
|
1521
|
+
description: 'Editorial serif — blurbs, taglines, quotes',
|
|
1522
|
+
applyTo: ['blockquote', '.tagline'], // framework emits the rule, like a built-in role
|
|
1523
|
+
},
|
|
1524
|
+
'font-display': {
|
|
1525
|
+
default: 'ui-sans-serif, system-ui, sans-serif',
|
|
1526
|
+
// no applyTo → the component wires it (a `font-display` utility, or var(--font-display))
|
|
1527
|
+
},
|
|
1528
|
+
}
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
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.
|
|
1532
|
+
|
|
1533
|
+
```yaml
|
|
1534
|
+
# site/theme.yml — loading, hosted or self-hosted; both config-only, no dependency
|
|
1535
|
+
fonts:
|
|
1536
|
+
heading: "Poppins, sans-serif"
|
|
1537
|
+
serif: '"Fraunces", Georgia, serif' # a foundation-declared role, set by name
|
|
1538
|
+
import:
|
|
1539
|
+
- url: "https://fonts.googleapis.com/css2?family=Poppins:wght@600;700"
|
|
1540
|
+
faces:
|
|
1541
|
+
- { family: "Fraunces", src: /fonts/fraunces.woff2, weight: "100 900" }
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
The build auto-adds preconnect hints for imports, emits `@font-face` + preload for faces, and drops any family no role references.
|
|
1545
|
+
|
|
1546
|
+
> **`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:`.
|
|
1547
|
+
|
|
1548
|
+
> 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.
|
|
1549
|
+
|
|
1550
|
+
### Tailwind v4 and styles.css
|
|
1551
|
+
|
|
1552
|
+
```css
|
|
1553
|
+
@import "tailwindcss";
|
|
1554
|
+
@import "@uniweb/kit/theme-tokens.css";
|
|
1555
|
+
@source "./sections/**/*.{js,jsx}";
|
|
1556
|
+
@source "./components/**/*.{js,jsx}";
|
|
1557
|
+
@source "../node_modules/@uniweb/kit/src/**/*.jsx";
|
|
1558
|
+
|
|
1559
|
+
@theme {
|
|
1560
|
+
--breakpoint-xs: 30rem;
|
|
1561
|
+
}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
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.
|
|
1565
|
+
|
|
1566
|
+
**Tokens are a floor, not a ceiling.** They solve context adaptation — the hard problem. A great foundation adds design vocabulary on top:
|
|
1567
|
+
|
|
1568
|
+
```css
|
|
1569
|
+
.border-subtle { border-color: color-mix(in oklch, var(--border), transparent 50%); }
|
|
1570
|
+
.text-tertiary { color: color-mix(in oklch, var(--body), var(--subtle) 50%); }
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
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.
|
|
1574
|
+
|
|
1575
|
+
Two lines a ported CSS reset will try to bring with it, both of which break things:
|
|
1576
|
+
|
|
1577
|
+
> **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`.
|
|
1578
|
+
|
|
1579
|
+
> **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`.
|
|
1580
|
+
|
|
1581
|
+
### Composition: items, child sections, insets
|
|
1582
|
+
|
|
1583
|
+
Pages are sequences of sections — the obvious layer. The framework also supports real nesting, without leaving markdown.
|
|
1584
|
+
|
|
1585
|
+
| Pattern | How authored | Use when |
|
|
1586
|
+
|---|---|---|
|
|
1587
|
+
| **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
|
|
1588
|
+
| **Concept blocks** (`content.data[tag]`) | ` ```md:<tag> ` fence around markdown | A named *kind* of content — an FAQ, a callout, a set of steps |
|
|
1589
|
+
| **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
|
|
1590
|
+
| **Insets** (`block.insets`) | `` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
|
|
1591
|
+
| **Block insets** (`block.insets`) | ` ```@Component ` fence around markdown | A component that *wraps* authored prose: callouts, disclosures, admonitions |
|
|
1592
|
+
|
|
1593
|
+
Does the author write content *inside* the nested element? **Yes** → child sections, or a block inset when the wrapper is presentational and lives mid-page. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children; a block inset can contain both.
|
|
1594
|
+
|
|
1595
|
+
**Child sections in columns.** A section type that lays its children out in a grid offers layouts in `meta.js` — `children: { grid: [3, 2, '40/60'] }`, the first being its default — and renders them with kit's `<ChildGrid from={block} fallback={3} />`. The author picks one with `grid:` in the parent's frontmatter (`grid: 3`, `grid: '40/60'`), a section key like `type`, not a param. Each child renders as a section, so its own `theme:` and `background:` apply. Don't declare a param named `grid`.
|
|
1596
|
+
|
|
1597
|
+
### Concept blocks — naming *what* content is
|
|
1598
|
+
|
|
1599
|
+
A ` ```md:<tag> ` fence marks a run of prose as a named kind of thing. The author writes ordinary markdown; the tag says what it is:
|
|
1600
|
+
|
|
1601
|
+
````markdown
|
|
1602
|
+
```md:faq
|
|
1603
|
+
# What plans do you have?
|
|
1604
|
+
Three — Starter, Team and Enterprise.
|
|
1605
|
+
|
|
1606
|
+
# Can I change later?
|
|
1607
|
+
Any time, and we prorate the difference.
|
|
1608
|
+
```
|
|
1609
|
+
````
|
|
1610
|
+
|
|
1611
|
+
Every `#` heading starts an item, so this arrives as `content.data.faq`:
|
|
1612
|
+
|
|
1613
|
+
```js
|
|
1614
|
+
{
|
|
1615
|
+
items: [ { title, paragraphs, links, … }, … ], // one per heading
|
|
1616
|
+
sequence: [ … ] // the same content, in document order
|
|
1617
|
+
}
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
**The shape is fixed by the fence, not by the tag.** Any tag works and none is special — the framework never learns what `faq` means. A body with **no** headings is the same block with a single titleless item, which is what a callout is:
|
|
1621
|
+
|
|
1622
|
+
````markdown
|
|
1623
|
+
```md:warning
|
|
1624
|
+
Back up your database **before** running this. It is not reversible.
|
|
1625
|
+
```
|
|
1626
|
+
````
|
|
1627
|
+
|
|
1628
|
+
**Why this instead of a component reference.** ` ```@Alert ` names *which component renders this*, which is a rendering decision sitting in content. `md:warning` names *what the content is* and leaves rendering to the foundation — so the same content works under a different foundation, and an editor can recognize the concept and offer a surface built for it.
|
|
1629
|
+
|
|
1630
|
+
**A data block whose value is itself a schema.** An author can design a form in the visual editor; it lands as a ` ```yaml:form ` block at `content.data.form`. A component that renders one is the inverse of every other component: it declares the key (`data: { form: … }`) but not the fields — it *receives* them and draws whatever it's given, since the field names are the author's and aren't knowable when you write `meta.js`.
|
|
1631
|
+
|
|
1632
|
+
That makes one distinction worth holding onto. You **may** declare a schema describing the form *definition's envelope* — `title`, `description`, `fields` as a map — and get build-time validation that an authored form is well-formed. What you can't declare is a schema whose fields are *the form's* fields (`name`, `email`, …); that's describing the visitor's answers, which arrive at runtime and belong to a form you've never seen. Declare the key either way — it is what delivers the block — and give it a schema only if you want it checked, and only of the envelope (`{}` declares the key with none).
|
|
1633
|
+
|
|
1634
|
+
**Reading one in a component.** Declare it the way the author writes the fence — `data: { 'md:faq': 'Questions and answers [3+]' }`. The value is the author's label (its count counts the entries), or `{ label, hint, content: { title: 'Question', paragraphs: 'Answer' } }` to say what each entry holds. The component still reads `content.data.faq`, which gives you both views: `items` for anything row-shaped (an accordion, a step list), `sequence` when you don't recognize the tag and want to render it faithfully in document order. Both are derived, so nothing is stored twice. (`faq: {}` also declares the key, saying nothing about the block.)
|
|
1635
|
+
|
|
1636
|
+
```jsx
|
|
1637
|
+
function Faq({ content }) {
|
|
1638
|
+
const faq = content.data?.faq
|
|
1639
|
+
if (!faq) return null
|
|
1640
|
+
return faq.items.map((item, i) => <Disclosure key={i} q={item.title} a={item.paragraphs} />)
|
|
1641
|
+
}
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
**Two tags, not a tag plus a variant.** A warning and a note are different concepts, so they are `md:warning` and `md:note`. The fence takes no params.
|
|
1645
|
+
|
|
1646
|
+
**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:
|
|
1647
|
+
|
|
1648
|
+
```markdown
|
|
1649
|
+
{variant=compact}
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1652
|
+
The developer builds `NetworkDiagram` as an ordinary React component with `inset: true` in `meta.js`. The section that hosts it declares a `media` slot, and kit's `<Visual>` renders the author's item by its kind — so one section type works whether the author supplies an image, a video, or an interactive component:
|
|
1653
|
+
|
|
1654
|
+
```jsx
|
|
1655
|
+
// meta.js: content: { title: 'Headline', media: 'Illustration [1]' }
|
|
1656
|
+
<Visual media={content.media} block={block} className="rounded-2xl" />
|
|
1657
|
+
```
|
|
1658
|
+
|
|
1659
|
+
**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"`.
|
|
1660
|
+
|
|
1661
|
+
**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" — and a visual editor then offers it as an inset only. For a component placed both as a section and as an inset, add `section: true`.
|
|
1662
|
+
|
|
1663
|
+
**Block insets — a component that wraps content.** The image form is a leaf: it takes params but no body. When the component should surround authored prose — a callout, a disclosure, an admonition — use the fenced form instead. Same `@Component{params}` reference, written as a code fence's info string:
|
|
1664
|
+
|
|
1665
|
+
````markdown
|
|
1666
|
+
```@Alert{type=warning}
|
|
1667
|
+
Back up your database **before** running this.
|
|
1668
|
+
|
|
1669
|
+
- The migration is not reversible
|
|
1670
|
+
- Allow ten minutes of downtime
|
|
1671
|
+
```
|
|
1672
|
+
````
|
|
1673
|
+
|
|
1674
|
+
The body is ordinary content, not text: it is parsed exactly like the rest of the page, so headings, lists, tables, icons, inline styling, leaf insets and further containers all work inside one. To nest a code block or another container, open the outer fence with more backticks than the inner one.
|
|
1675
|
+
|
|
1676
|
+
**The component is yours, and it receives parsed content.** `@Alert` resolves against the foundation through the same lookup as `` — declare it with `inset: true` in `meta.js`. Where a leaf inset gets its alt text as `content.title` and nothing else, a container gets its whole body parsed: `title`, `paragraphs`, `items`, `sequence`. Render it with `<Prose content={content} block={block} />` or read the fields directly. A name the foundation doesn't define renders a visible `Component not found: <Name>` error in place of the inset — deliberately, so a typo or a missing declaration is obvious on the page instead of quietly looking like a design choice.
|
|
1677
|
+
|
|
1678
|
+
Prefer a **child section** when the author needs their own section type and independent editing; prefer a **block inset** when the wrapper is presentational and belongs inline in a single file's prose.
|
|
1679
|
+
|
|
1680
|
+
**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:
|
|
1681
|
+
|
|
1682
|
+
```
|
|
1683
|
+
pages/home/
|
|
1684
|
+
├── page.yml
|
|
1685
|
+
├── 2-dashboard.md # Parent section (type: Grid, columns: "1fr 2fr")
|
|
1686
|
+
├── @sidebar-stats.md # Child (@ = not top-level)
|
|
1687
|
+
└── @main-chart.md
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
```yaml
|
|
1691
|
+
# page.yml
|
|
1692
|
+
nest:
|
|
1693
|
+
dashboard: [sidebar-stats, main-chart]
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
**Rules:**
|
|
1697
|
+
- `@`-prefixed files are excluded from the top-level section list; `@@` nests deeper (grandchildren)
|
|
1698
|
+
- `nest:` maps parent name → child names, and is **flat**: `{ features: [a, b], a: [sub-1] }`
|
|
1699
|
+
- Children are ordered by position in the `nest:` array
|
|
1700
|
+
|
|
1701
|
+
```jsx
|
|
1702
|
+
import { ChildBlocks } from '@uniweb/kit'
|
|
1703
|
+
|
|
1704
|
+
export default function Grid({ block, params }) {
|
|
1705
|
+
return <div className={`grid grid-cols-${params.columns || 2} gap-6`}><ChildBlocks from={block} /></div>
|
|
1706
|
+
}
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
`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"`.
|
|
1710
|
+
|
|
1711
|
+
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.
|
|
1712
|
+
|
|
1713
|
+
**Data and child blocks:** page-level `query:` (or `fetch:`) is available to all blocks including children, and each child resolves data independently through the page → parent page → site hierarchy (the site's only on a top-level page). If a child needs data no ancestor declares, give it its own in its frontmatter (`query: articles`, or `fetch:`). Its `meta.js` `data:` declares the keys it receives and their shapes, never where the data comes from — it fetches nothing.
|
|
1714
|
+
|
|
1715
|
+
**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.
|
|
1716
|
+
|
|
1717
|
+
### Dividers — content boundaries
|
|
1718
|
+
|
|
1719
|
+
`---` creates a boundary between content regions; the developer decides what each region means.
|
|
1720
|
+
|
|
1721
|
+
**UI regions.** `splitContent()` from `@uniweb/kit` splits parsed content at divider elements — e.g. lesson prose vs challenge content:
|
|
1722
|
+
|
|
1723
|
+
```jsx
|
|
1724
|
+
const [lesson, challenge] = splitContent(content)
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
<!-- template:loom -->
|
|
1728
|
+
**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.
|
|
1729
|
+
|
|
1730
|
+
```markdown
|
|
1731
|
+
---
|
|
1732
|
+
type: CvEntry
|
|
1733
|
+
source: education
|
|
1734
|
+
---
|
|
1735
|
+
# Education
|
|
1736
|
+
{COUNT OF education} degrees.
|
|
1737
|
+
---
|
|
1738
|
+
## {degree}
|
|
1739
|
+
{institution} — {field} ({start}–{end})
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
**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.
|
|
1743
|
+
<!-- /template:loom -->
|
|
1744
|
+
|
|
1745
|
+
### Custom layouts
|
|
1746
|
+
|
|
1747
|
+
Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`, or name a layout `Default`. With neither, a page that names no layout gets the built-in layout — `header`, the page, `footer`, nothing else — and a `defaultLayout` that names none of your layouts stops the build.
|
|
1748
|
+
|
|
1749
|
+
```jsx
|
|
1750
|
+
// layouts/DocsLayout/index.jsx
|
|
1751
|
+
export default function DocsLayout({ header, body, footer, left, right, params }) {
|
|
1752
|
+
return (
|
|
1753
|
+
<div className="min-h-screen flex flex-col">
|
|
1754
|
+
{header && <header>{header}</header>}
|
|
1755
|
+
<div className="flex-1 flex">
|
|
1756
|
+
{left && <aside className="w-64">{left}</aside>}
|
|
1757
|
+
<main className="flex-1">{body}</main>
|
|
1758
|
+
{right && <aside className="w-64">{right}</aside>}
|
|
1759
|
+
</div>
|
|
1760
|
+
{footer && <footer>{footer}</footer>}
|
|
1761
|
+
</div>
|
|
1762
|
+
)
|
|
1763
|
+
}
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
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.
|
|
1767
|
+
|
|
1768
|
+
**Layout `meta.js`** declares areas and optional scroll behavior: `{ areas: ['header', 'footer', 'left'], scroll: 'self' }`. Area names are arbitrary. `areas` tells an editor what to offer; a page renders the areas its site's `layout/` folder fills. `scroll` controls scroll restoration: unset = runtime manages `window` (default), `'self'` = the layout scrolls itself, or a CSS selector (`'main'`) = runtime manages that element.
|
|
1769
|
+
|
|
1770
|
+
Two optional keys tune how areas behave across a navigation. `transitions` renames or opts regions out of per-area view transitions (`{ left: null }`, or `false` for the whole layout). `layers` sets which area paints on top — every area is stacked above the body by default, so a fixed header works without declaring anything, but areas are equal to each other, so a layout whose chrome overlaps says which wins: `layers: { header: 2, left: 1 }`. Both take an object to override per region, or `false` to opt out. **A `z-index` inside an area cannot lift it past another area** — each area is its own stacking context — so reach for `layers` rather than a bigger number, and for `<Overlay>` when a modal needs to escape the area entirely.
|
|
1771
|
+
|
|
1772
|
+
**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"`. The structure alone decides what an entry is: every folder directly under `layout/` is a named layout (its name matched regardless of case and of a trailing `Layout` — `site/layout/docs/` serves the foundation's `DocsLayout`), and an area with several sections is a folder inside a layout's folder — `site/layout/default/header/1-topbar.md` for the default layout. An area has no `page.yml`; its sections render in filename order.
|
|
1773
|
+
|
|
1774
|
+
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:
|
|
1775
|
+
|
|
1776
|
+
````markdown
|
|
1777
|
+
---
|
|
1778
|
+
type: Header
|
|
1779
|
+
---
|
|
1780
|
+
# Acme Inc ← content.title — the logo
|
|
1781
|
+
|
|
1782
|
+
-  [How It Works](/how) ← content.lists[0] — the nav
|
|
1783
|
+
-  [Docs](/docs)
|
|
1784
|
+
|
|
1785
|
+
[Get Started](/docs/quickstart) ← content.links[0] — the CTA
|
|
1786
|
+
|
|
1787
|
+
```yaml:config
|
|
1788
|
+
github: https://github.com/acme ← content.data.config — everything else
|
|
1789
|
+
```
|
|
1790
|
+
````
|
|
1791
|
+
|
|
1792
|
+
```jsx
|
|
1793
|
+
function Header({ content }) {
|
|
1794
|
+
const logo = content.title
|
|
1795
|
+
const navItems = content.lists[0] || []
|
|
1796
|
+
const cta = content.links[0]
|
|
1797
|
+
const config = content.data?.config // with `data: { config: {} }` in Header's meta.js
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function Footer({ content }) {
|
|
1801
|
+
const tagline = content.paragraphs[0] // a plain paragraph
|
|
1802
|
+
const columns = (content.lists[0] || []).map(group => ({
|
|
1803
|
+
label: group.paragraphs[0], // nested list → columns
|
|
1804
|
+
links: group.lists[0]?.map(item => item.links[0])
|
|
1805
|
+
}))
|
|
1806
|
+
}
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
### Cross-block communication
|
|
1810
|
+
|
|
1811
|
+
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:
|
|
1812
|
+
|
|
1813
|
+
- **`context`** — static capabilities from `meta.js`. Never changes.
|
|
1814
|
+
- **`state`** — dynamic runtime state via `useBlockState()`. Initial value from `initialState` in `meta.js`.
|
|
1815
|
+
|
|
1816
|
+
```js
|
|
1817
|
+
// Hero/meta.js
|
|
1818
|
+
export default {
|
|
1819
|
+
context: { allowTranslucentTop: true }, // "I always support this"
|
|
1820
|
+
initialState: { allowTranslucentTop: true }, // …or start true and let logic change it
|
|
1821
|
+
}
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
```jsx
|
|
1825
|
+
// Hero/index.jsx — dynamic case
|
|
1826
|
+
const [state, setState] = block.useBlockState(useState)
|
|
1827
|
+
|
|
1828
|
+
// Header/index.jsx — reads dynamic state, falls back to static context
|
|
1829
|
+
const info = block.getNextBlockInfo()
|
|
1830
|
+
const isFloating = info?.state?.allowTranslucentTop ?? info?.context?.allowTranslucentTop ?? false
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
The key names are yours to design — they're not framework fields.
|
|
1834
|
+
|
|
1835
|
+
### block, website, and page
|
|
1836
|
+
|
|
1837
|
+
```jsx
|
|
1838
|
+
const { website } = useWebsite()
|
|
1839
|
+
const page = website.activePage
|
|
1840
|
+
```
|
|
1841
|
+
|
|
1842
|
+
| `block` property | Description |
|
|
1843
|
+
|----------|-------------|
|
|
1844
|
+
| `block.page` / `block.website` | Parent page / site-level data and navigation |
|
|
1845
|
+
| `block.type` | Component type name |
|
|
1846
|
+
| `block.childBlocks` / `block.insets` | Child sections / inline `@Component` references |
|
|
1847
|
+
| `block.getInset(refId)` | Lookup an inset by refId |
|
|
1848
|
+
| `block.properties` | Raw frontmatter |
|
|
1849
|
+
| `block.rawContent` | ProseMirror document — passed internally by `<Article block={block} />` |
|
|
1850
|
+
| `block.themeName` | `"light"`, `"medium"`, `"dark"` |
|
|
1851
|
+
| `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
|
|
1852
|
+
| `block.path` | Page route this block belongs to |
|
|
1853
|
+
| `block.dataLoading` | True while declared data is still resolving |
|
|
1854
|
+
| `block.dataError` | `{ <key>: message }` when the fetch filling a declared key FAILED, else `null`. A failed key is `null` in `content.data` — never `[]`, which means "no records" |
|
|
1855
|
+
|
|
1856
|
+
```jsx
|
|
1857
|
+
// getPageHierarchy(options) →
|
|
1858
|
+
// [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
|
|
1859
|
+
// Options: for: 'header'|'footer'|<area> (respects hideIn) · nested: true (default)
|
|
1860
|
+
// includeHidden: false · filter: (page) => bool · sort: (a, b) => number
|
|
1861
|
+
website.getPageHierarchy({ for: 'header' }) // = website.getHeaderPages()
|
|
1862
|
+
website.getPageHierarchy({ nested: false }) // = website.getAllPages()
|
|
1863
|
+
|
|
1864
|
+
website.name, website.basePath
|
|
1865
|
+
website.hasMultipleLocales(), website.getLocales(), website.getActiveLocale(), website.getLocaleUrl('es')
|
|
1866
|
+
|
|
1867
|
+
page.title, page.label, page.route, page.description
|
|
1868
|
+
page.isHidden(), page.showInHeader(), page.showInFooter()
|
|
1869
|
+
page.hasContent() // true if the page has its own content (not just a folder)
|
|
1870
|
+
page.hasChildren(), page.children, page.parent
|
|
1871
|
+
page.getNavigableRoute() // first descendant route with content (for linking)
|
|
1872
|
+
```
|
|
1873
|
+
|
|
1874
|
+
Content-less containers appear as group nodes (`hasContent: false`) — use `navigableRoute` for links, `title` for display, `hasContent` to style differently.
|
|
1875
|
+
|
|
1876
|
+
### Data
|
|
1877
|
+
|
|
1878
|
+
**A section receives the keys its component declares in `meta.js` `data:` — and nothing else** (plus any its foundation declares in `main.js` `data:`). A component that reads `content.data.articles` declares `articles`. Each declared key is filled by, in order: a tagged data block in the section; else the fetch that fills it, level by level from the section's own to the site's — a fetch whose `as` (its query's name by default) is the key, or else, **automatic `as`**, the first fetch under an undeclared key whose query's schema is the key's (`@/x` matches any scope's `x`); else `null`. So `data: { related: '@std/article' }` receives an `articles` query under `related`. `as:` on a fetch picks the key when two keys or two fetches share a schema. A fetch that fills none of a section's keys is not requested for it.
|
|
1879
|
+
|
|
1880
|
+
**A key says how many records it holds, and how much of each — two flags, independent.** A ref alone (`'@std/article'`) is a **list of briefs**: on the query's own page every record the fetch takes, on a parametric page (`[slug]/`) a single-element array, `[]` when nothing matches. Write the key long to change either:
|
|
1881
|
+
|
|
1882
|
+
```js
|
|
1883
|
+
data: {
|
|
1884
|
+
articles: '@std/article', // a list of briefs
|
|
1885
|
+
article: { schema: '@std/article', single: true, whole: true }, // one record, whole
|
|
1886
|
+
card: { schema: '@std/article', single: true }, // one record's brief
|
|
1887
|
+
archive: { schema: '@std/article', whole: true }, // a list of whole records
|
|
1888
|
+
}
|
|
1889
|
+
```
|
|
1890
|
+
|
|
1891
|
+
- **`single: true`** — the key holds **one record**: the first its binding selects (on a `[slug]` page, the record the URL names), or `null` when there is none. The runtime asks for just that one. A binding over many gives the first: sort the query, or give the binding `limit: 1`.
|
|
1892
|
+
- **`whole: true`** — each record **whole**, as stored: every section under its own name, the brief's included — `article.brief.title`, `article.body.content`. Without it, each record is its **brief**: the brief section's fields at the top, the section not named. The runtime asks for what the key declares; a `whole` key its source cannot answer whole is `null`. A `fields:` schema's brief is the whole record, so `whole` is only needed for a schema with more than one section.
|
|
1893
|
+
- The long form takes `schema`, `single` and `whole`, nothing else — the build stops on any other property, and on `single` or `whole` over a schema whose root is a list (`@std/nav`, `@std/form`: the key holds that list). ⛔ `'@std/article/*'` is retired and stops the build: write `whole: true`.
|
|
1894
|
+
|
|
1895
|
+
When nothing fills a key it is `null`. An external query's records have no schema and arrive as its source answers them. Reference: `reference/component-metadata.md` § *One record, or a list* and § *Briefs or whole records*.
|
|
1896
|
+
|
|
1897
|
+
```jsx
|
|
1898
|
+
function Article({ content, block }) { // data: { article: { schema: '@std/article', single: true } }
|
|
1899
|
+
if (block.dataLoading) return <DataPlaceholder />
|
|
1900
|
+
if (block.dataError?.article) return <LoadFailed /> // the request failed — not "no records"
|
|
1901
|
+
const article = content.data.article // the record the [slug] page's URL names, or null
|
|
1902
|
+
if (!article) return <NotFound />
|
|
1903
|
+
return <ArticleView article={article} />
|
|
1904
|
+
}
|
|
1905
|
+
```
|
|
1906
|
+
|
|
1907
|
+
Any other reshaping is the foundation's job — once, with a `handlers.data` hook.
|
|
1908
|
+
|
|
1909
|
+
**Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). 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). A schema drives the editor, checks your data (`uniweb validate`), and lets a fetch of another name fill the key — it fills no field: **a record reaches the component as it is**, so a field it lacks is absent, and what that renders as is your component's choice (`record.status ?? 'published'`). **A named schema declares no `default:`** — the build refuses one; a `default:` belongs only in an inline field map or form, where an editor pre-fills from it. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
|
|
1910
|
+
|
|
1911
|
+
```js
|
|
1912
|
+
// meta.js
|
|
1913
|
+
export default {
|
|
1914
|
+
data: {
|
|
1915
|
+
articles: '@/article', // named ref (this foundation)
|
|
1916
|
+
authors: '@std/person', // named ref (shared standard)
|
|
1917
|
+
pricing: { tier: { type: 'string' } }, // inline field map
|
|
1918
|
+
},
|
|
1919
|
+
}
|
|
1920
|
+
```
|
|
1921
|
+
|
|
1922
|
+
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`.
|
|
1923
|
+
|
|
1924
|
+
**Authoring queries.** Queries and fetch declarations accept `where:` (a where-object predicate), `sort:` (ONE key, e.g. `date desc` — text sorts in the page's language, and a record with no value for the key sorts last either way), and `limit:` — on a query they select its records, on a fetch they take from those. The framework evaluates them in the browser over the records it fetched, the query's first; a host that answers queries evaluates the same language at the source; a foundation transport decides for itself — it receives the query's fields and the fetch's own under `narrow`. The declaration is identical in every case.
|
|
1925
|
+
|
|
1926
|
+
```yaml
|
|
1927
|
+
# pages/blog/page.yml
|
|
1928
|
+
fetch:
|
|
1929
|
+
query: recent
|
|
1930
|
+
where: { tags: featured }
|
|
1931
|
+
sort: date desc
|
|
1932
|
+
limit: 3
|
|
1933
|
+
```
|
|
1934
|
+
|
|
1935
|
+
**Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares its key `whole: true`; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`, which returns it as stored — `full.body.content`. Every file-based record gets its own file, `/data/<name>/<name of the record>.json`. An external query's whole record comes from its `record:` request, below. (`deferred:` is removed — the build stops on it.) The hook is safe to call on any query: when the query has no separate source for one record it returns the record you passed in.
|
|
1936
|
+
|
|
1937
|
+
**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`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
|
|
1938
|
+
|
|
1939
|
+
**Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. A violation fails it (`--lax` only reports); it checks every record file in `records/`, and `push` / `publish` refuse the same findings before sending anything. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`) are reported as deferred — their data isn't in the project; the value of a `ref`/`options` field is not checked against the entity it names.
|
|
1940
|
+
|
|
1941
|
+
### Fetching from other sources (`fetcher:`)
|
|
1942
|
+
|
|
1943
|
+
A site isn't limited to its own records. A public, keyless JSON API is an **external query** — a query with `url:`, declared beside the others and named by pages the same way (`query: posts`):
|
|
1944
|
+
|
|
1945
|
+
```yaml
|
|
1946
|
+
# queries.yml
|
|
1947
|
+
posts:
|
|
1948
|
+
url: https://api.example.com/posts
|
|
1949
|
+
transform: data.items # dot-path to the records in the response
|
|
1950
|
+
record: # one whole post, for a [id] page
|
|
1951
|
+
url: https://api.example.com/posts/{id}
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
It also takes `method: POST` with a `body`, `where` / `sort` / `limit` (evaluated over what arrived, after `transform`), and `queryable`. Its records are entries in no folder and have no `$name`, so a `[slug]` page over it names the field the URL is in the query's own `where` — `where: { slug: :slug }` — which drops on the query's own page. `record:` takes `url`, `method`, `body` and `transform`: `url` and `method` default to the query's, `body` and `transform` never carry over, and a placeholder named by the page's folder (`{id}` for `[id]`, `{slug}` for `[slug]`) is its URL segment. `schema`, `scope`, `deferred`, `excerpt` and `path` are refused beside `url:` — they describe the site's own records. The visitor's browser fetches an external query (a `fetch:` with `prerender: true` makes the build fetch it instead); it is never asked of a host's records service and compiles no `/data` file. On a `[id]` page the list still decides which records exist — `record:` fetches the one it found, whole. A site published to a Uniweb host reads the host's records with no configuration at all.
|
|
1955
|
+
|
|
1956
|
+
⛔ A `fetch:` never carries a source: `path`, `url`, `method`, `body`, `transform`, `detail` and `scope` on a fetch stop the build. The `detail:` forms (`rest`, `query`, a URL pattern) are now an external query's `record:`, and `detailUrl:` is `record.url`.
|
|
1957
|
+
|
|
1958
|
+
A backend that needs a key, headers, paging, its own wire or query language is a **transport**: a named `{ resolve, cacheKey? }` exported by the foundation (or an extension), which the site selects per data key in `fetcher:` — the only thing that block is for:
|
|
1959
|
+
|
|
1960
|
+
```yaml
|
|
1961
|
+
# site.yml
|
|
1962
|
+
fetcher:
|
|
1963
|
+
transports:
|
|
1964
|
+
articles: acme # a foundation-registered transport handles `query: articles`
|
|
1965
|
+
events: default # explicitly route back to the default fetcher
|
|
1966
|
+
acme: # binding config that transport reads
|
|
1967
|
+
apiKey: pk_public_123
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
**Selection is explicit and site-owned.** For each request: `fetcher.transports[as]` wins if set; otherwise `fetcher.transports.default` if set; otherwise the framework's default fetcher. No route-walking, no `match()` predicates, no silent foundation-owned routing — the site picks.
|
|
1971
|
+
|
|
1972
|
+
⛔ `fetcher.baseUrl`, `headers`, `envelope`, `supports` and `request.*` are **retired**: a third party's conventions belong in a transport, not in the runtime every site loads. The build warns once and ignores them.
|
|
1973
|
+
|
|
1974
|
+
> **Never put secrets in `site.yml` or `queries.yml`** — every value in them 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.
|
|
1975
|
+
|
|
1976
|
+
**Failures are visible, not empty:** a fetch that failed leaves its key `null` in `content.data` and names the message on `block.dataError[key]`; it is never delivered as `[]`, which means "no records". The page still renders — a section reads `dataError` to tell the two apart.
|
|
1977
|
+
|
|
1978
|
+
When an external query is enough and when a transport is the answer: `development/data-sources.md`.
|
|
1979
|
+
|
|
1980
|
+
Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
|
|
1981
|
+
|
|
1982
|
+
### Search (`services.search`)
|
|
1983
|
+
|
|
1984
|
+
Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host. Search is on by default; its entry under `services:` (see *Services*, below) turns it off, sets options, or — on a site you publish — asks the host for it:
|
|
1985
|
+
|
|
1986
|
+
```yaml
|
|
1987
|
+
# site.yml
|
|
1988
|
+
services:
|
|
1989
|
+
search:
|
|
1990
|
+
provider: index # default — download an index, match in the browser
|
|
1991
|
+
```
|
|
1992
|
+
|
|
1993
|
+
| Provider | Answers with | Trade-off |
|
|
1994
|
+
|---|---|---|
|
|
1995
|
+
| `index` (default) | `search-index.json`, ranked in the browser | Free, works on **any** host including a plain static one, tolerates typos. Contains only what existed at build time. |
|
|
1996
|
+
| `endpoint` | A server-side search API | Can cover records fetched from an API, and can be re-indexed without rebuilding the site. Requires a host that serves one. |
|
|
1997
|
+
| *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
|
|
1998
|
+
|
|
1999
|
+
```yaml
|
|
2000
|
+
services:
|
|
2001
|
+
search:
|
|
2002
|
+
provider: endpoint
|
|
2003
|
+
endpoint: _search # REQUIRED — there is no default
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
`endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself: `search: true` asks for it, and the host supplies the address. An endpoint of your own asks the host to leave its search off, so yours answers.
|
|
2007
|
+
|
|
2008
|
+
**Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `group`, `item`. `type` is `page`, `section` or `record`; on a record hit, `item` holds the record's fields and `group` names the set it came from. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
|
|
2009
|
+
|
|
2010
|
+
`snippetHtml` is HTML with `<mark>`. Render it through `SafeHtml`, never as text.
|
|
2011
|
+
|
|
2012
|
+
**Failures degrade rather than break** — a failing provider falls back to the local index when one exists, otherwise returns no results with a console warning. A search box never throws at a visitor.
|
|
2013
|
+
|
|
2014
|
+
```jsx
|
|
2015
|
+
import { useSearch } from '@uniweb/kit'
|
|
2016
|
+
|
|
2017
|
+
const { results, isLoading, query } = useSearch(website) // `query` is a function
|
|
2018
|
+
<input onChange={e => query(e.target.value)} /> // debounced internally
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
Full reference: `authoring/search.md`.
|
|
2022
|
+
|
|
2023
|
+
### Forms (`services.submit`)
|
|
2024
|
+
|
|
2025
|
+
Drawing a form is a foundation's job; delivering what a visitor typed needs a
|
|
2026
|
+
server. Where that server is comes from the **site or its host** — never from a
|
|
2027
|
+
section type, same arrangement as `fetcher:` and search.
|
|
2028
|
+
|
|
2029
|
+
A form gets its destination from the first of these that applies:
|
|
2030
|
+
|
|
2031
|
+
1. **One the host supplies** — `config.services.submit` in the payload it serves. Where the
|
|
2032
|
+
host handles submissions, that is the destination. A site published to Uniweb
|
|
2033
|
+
Cloud asks for it with `submit: true` under `services:` and names no address.
|
|
2034
|
+
2. **An address of the site's own** — `submit: <url>` under `services:`, for a
|
|
2035
|
+
static site or a host that does not handle submissions. On a host that does, it
|
|
2036
|
+
asks the host to leave its own off, so this one answers.
|
|
2037
|
+
3. **Neither** — there is no destination: render no form, or fall back to contact
|
|
2038
|
+
details the site already carries.
|
|
2039
|
+
|
|
2040
|
+
That is the general arrangement, not a forms-only one. A host states everything
|
|
2041
|
+
it offers in the served payload's `config.services`, keyed by name, and every service
|
|
2042
|
+
resolves by the same rule — the host's offer, then the site's own address, then
|
|
2043
|
+
neither.
|
|
2044
|
+
|
|
2045
|
+
⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
|
|
2046
|
+
no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isBackendEnabled()`, `isAssistantEnabled()`,
|
|
2047
|
+
`isTrackingEnabled()`.
|
|
2048
|
+
|
|
2049
|
+
```jsx
|
|
2050
|
+
import { isSearchEnabled } from '@uniweb/kit'
|
|
2051
|
+
|
|
2052
|
+
if (!isSearchEnabled()) return null // false ⇒ draw nothing
|
|
2053
|
+
```
|
|
2054
|
+
|
|
2055
|
+
Each answers the same question — *would UI for this service work on this site?* — and `false`
|
|
2056
|
+
always means the same thing: **draw nothing.** `isSearchEnabled()` is true whenever *any* provider
|
|
2057
|
+
answers, including the prebuilt index a static site ships, so a search box gated on it appears
|
|
2058
|
+
wherever search works. The hooks that draw a feature hand you the same answer as a field
|
|
2059
|
+
(`useSearch().isEnabled`, `useFormSubmit().canSubmit`), so a component already using one needs
|
|
2060
|
+
nothing extra.
|
|
2061
|
+
|
|
2062
|
+
When you need the **address** itself, not just whether one exists, ask for it:
|
|
2063
|
+
|
|
2064
|
+
```jsx
|
|
2065
|
+
import { resolveService } from '@uniweb/kit'
|
|
2066
|
+
|
|
2067
|
+
const { url, source } = resolveService(website, 'assistant') // or 'search', or your own
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
**The name is open**: the framework ships clients for what it implements and
|
|
2071
|
+
resolution for anything, so a foundation can define a service the framework has
|
|
2072
|
+
never heard of and a host can fill it — ask for it with
|
|
2073
|
+
`website.isServiceEnabled('booking')`. Same escalation `fetcher.transports`
|
|
2074
|
+
offers for data.
|
|
2075
|
+
|
|
2076
|
+
**Building an "Ask AI" component?** The service name is `assistant`, and a host that runs an agent for a site typically serves it at the conventional path `/_agent`. **You should never need to write that path** — ask the runtime instead:
|
|
2077
|
+
|
|
2078
|
+
```jsx
|
|
2079
|
+
const { url } = resolveService(website, 'assistant')
|
|
2080
|
+
if (!url) return null // this site has no agent — render nothing, or a static fallback
|
|
2081
|
+
```
|
|
2082
|
+
|
|
2083
|
+
**This is a synchronous read, not a request.** Which services a site has was settled when the site was last published and travels in its metadata, so the runtime already holds the answer — there is no handshake, no probe, no await. Your component asks the runtime; the runtime does not ask anyone.
|
|
2084
|
+
|
|
2085
|
+
⛔ **Absent is the answer, not a lookup that failed.** No `url` means the site has no agent — never enabled, or this host runs none. Render for that case; don't retry it. And don't hardcode `/_agent/chat` when nothing was declared: on a static host that turns "no agent here" into a 404 your component can't tell from a broken endpoint. The path is named above so you recognize the shape, not so you can construct it.
|
|
2086
|
+
|
|
2087
|
+
⛔ **And don't tell the visitor.** `resolveService` returns `{ url, source }` and **deliberately nothing else** — there is no explanatory string and there was one, removed because it was a mistake. A visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. Worse, it is unfixably the wrong language: sites here are multilingual, or unilingual and not English, and a canned constant bypasses the site's whole localization pipeline. **Absence is a rendering decision, not a message**, and a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. Any text a visitor should read is *site content* — authored and localized.
|
|
2088
|
+
|
|
2089
|
+
`source` is `'site'`, `'host'` or `null`, and it is a **diagnostic for you** while you wire a site up: it says which tier answered, which is the thing to check when a host's value appears not to be taking effect. `'host'` with a null `url` means the host answered and offered no address.
|
|
2090
|
+
|
|
2091
|
+
*(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
|
|
2092
|
+
|
|
2093
|
+
### Declaring what your foundation supports
|
|
2094
|
+
|
|
2095
|
+
The predicates above, and `resolveService`, are how you ask at render time. The other direction — telling a
|
|
2096
|
+
host, *before* anything renders, which services your foundation is built to use —
|
|
2097
|
+
is one line in the foundation's `package.json`:
|
|
2098
|
+
|
|
2099
|
+
```json
|
|
2100
|
+
{
|
|
2101
|
+
"uniweb": { "supports": ["search", "submit", "tracking"] }
|
|
2102
|
+
}
|
|
2103
|
+
```
|
|
2104
|
+
|
|
2105
|
+
Service names, the same ones you pass to `resolveService`. It reaches a host when
|
|
2106
|
+
the foundation is registered, and it answers a question a host cannot otherwise
|
|
2107
|
+
answer: **a service only does something if the foundation renders something
|
|
2108
|
+
against it.** A host that offers search has no way to know whether your sections
|
|
2109
|
+
draw a search box, so without this it either offers a site something its code
|
|
2110
|
+
will ignore, or withholds something it would have used.
|
|
2111
|
+
|
|
2112
|
+
**What a host receives is one of three answers:**
|
|
2113
|
+
|
|
2114
|
+
| | |
|
|
2115
|
+
|---|---|
|
|
2116
|
+
| a list — `["search"]` | the services this foundation renders against, and only these |
|
|
2117
|
+
| `[]` | none — proven by the build, not assumed |
|
|
2118
|
+
| absent | *unknown* — the build could not tell, and nothing was declared |
|
|
2119
|
+
|
|
2120
|
+
⭐ **The build reads the set off your code.** When the foundation is built, `supports` is derived
|
|
2121
|
+
from what the bundle actually reaches — `resolveService(website, 'search')`, or a predicate like
|
|
2122
|
+
`isSearchEnabled()` — so a foundation that never writes the key still publishes an accurate set.
|
|
2123
|
+
What you write in `package.json` is a **supplement**: the build publishes the union, so a
|
|
2124
|
+
declaration can add a service but never remove one the code reaches.
|
|
2125
|
+
|
|
2126
|
+
**Declare only what the build cannot see.** A service reached through a *computed* name —
|
|
2127
|
+
`resolveService(website, name)` where `name` is a variable — is invisible to it, and the build
|
|
2128
|
+
warns when that happens. List those. `uniweb doctor` compares what you declared with what the build
|
|
2129
|
+
found.
|
|
2130
|
+
|
|
2131
|
+
⚖️ **Baseline behaviour is not yours to declare.** Some services do something for
|
|
2132
|
+
a site whether or not a foundation cooperates — the runtime reports page views
|
|
2133
|
+
wherever tracking is configured, with no help from your components. List
|
|
2134
|
+
`tracking` when you go *beyond* that (your own events on your own components);
|
|
2135
|
+
leaving it out does not switch the baseline off.
|
|
2136
|
+
|
|
2137
|
+
```yaml
|
|
2138
|
+
# site.yml
|
|
2139
|
+
services:
|
|
2140
|
+
submit: true # your host's form handling (Uniweb Cloud)
|
|
2141
|
+
```
|
|
2142
|
+
|
|
2143
|
+
```yaml
|
|
2144
|
+
# site.yml — when YOU are providing the endpoint: `uniweb export`, most `deploy --host` targets
|
|
2145
|
+
services:
|
|
2146
|
+
submit: https://forms.example.com/intake # or /forms, base-relative like search's endpoint
|
|
2147
|
+
```
|
|
2148
|
+
|
|
2149
|
+
```jsx
|
|
2150
|
+
import { useFormSubmit } from '@uniweb/kit'
|
|
2151
|
+
|
|
2152
|
+
const { submit, status, error, canSubmit } = useFormSubmit({
|
|
2153
|
+
block, // supplies section + page context
|
|
2154
|
+
context: { formId: 'contact' },
|
|
2155
|
+
summary: (v) => ({ title: v.name, subtitle: v.email }),
|
|
2156
|
+
})
|
|
2157
|
+
|
|
2158
|
+
if (!canSubmit) return null // nowhere to send — render no form, or fall back
|
|
2159
|
+
// to contact details from the site's own content
|
|
2160
|
+
|
|
2161
|
+
<button type="submit" disabled={status === 'submitting'}>Send</button>
|
|
2162
|
+
```
|
|
2163
|
+
|
|
2164
|
+
> **The framework never invents an endpoint — but a host may supply one.** Don't
|
|
2165
|
+
> write an address reflexively: on Uniweb Cloud an address asks the host to turn its
|
|
2166
|
+
> own form handling off — `submit: true` is the whole declaration there. Reach for an
|
|
2167
|
+
> address when you are the one hosting.
|
|
2168
|
+
>
|
|
2169
|
+
> `canSubmit` is false only when neither the host nor an address of the site's own
|
|
2170
|
+
> supplies a destination. **Check it when you render, not only on the button press** — a
|
|
2171
|
+
> form nobody can send should not be on the page at all. A read that 404s
|
|
2172
|
+
> degrades to `[]` and the page still renders; a write that 404s loses what a
|
|
2173
|
+
> person typed, so it gets no silent fallback.
|
|
2174
|
+
>
|
|
2175
|
+
> ⛔ **Don't tell the visitor why.** There is deliberately no explanatory string:
|
|
2176
|
+
> which services an operator bought is none of a visitor's business, "not enabled
|
|
2177
|
+
> for this site" reads like a breakage when nothing is broken, and any such
|
|
2178
|
+
> sentence would be one language on a site that is often multilingual and rarely
|
|
2179
|
+
> English. Text a visitor reads is **site content** — authored, and localized with
|
|
2180
|
+
> everything else. Degrade to something useful instead: a `mailto:`, a phone
|
|
2181
|
+
> number, an address the site already carries.
|
|
2182
|
+
|
|
2183
|
+
`status` runs `idle → submitting → success | error`; a non-2xx becomes a thrown
|
|
2184
|
+
`Error` carrying the endpoint's own `error` message when it sends one. For file
|
|
2185
|
+
fields, declare `fileSlots` and the endpoint replies with `uploadUrls` — the
|
|
2186
|
+
bytes never ride inside the JSON. `submitForm()` / `resolveSubmitTarget(website)`
|
|
2187
|
+
are the same behaviour without React.
|
|
2188
|
+
|
|
2189
|
+
**Rendering a form the AUTHOR designed** — a `yaml:form` block at
|
|
2190
|
+
`content.data.form` — is the inverse of every other component: you don't declare
|
|
2191
|
+
the fields, you receive them and draw whatever you're given. `useFormValues` owns
|
|
2192
|
+
the part that's identical in every such component, so you write only the controls:
|
|
2193
|
+
|
|
2194
|
+
```jsx
|
|
2195
|
+
import { useFormValues, useFormSubmit, valueAt } from '@uniweb/kit'
|
|
2196
|
+
|
|
2197
|
+
const { controls, values, setValue, missing, formData, files } =
|
|
2198
|
+
useFormValues(content.data.form)
|
|
2199
|
+
const { submit, canSubmit, status } = useFormSubmit({ block })
|
|
2200
|
+
|
|
2201
|
+
{controls.map((c) => (
|
|
2202
|
+
<MyControl key={c.path} control={c} // branch on c.type — your design
|
|
2203
|
+
value={valueAt(values, c.path)}
|
|
2204
|
+
onChange={(v) => setValue(c.path, v)} />
|
|
2205
|
+
))}
|
|
2206
|
+
<button disabled={!canSubmit || missing.length > 0} onClick={() => submit(formData, { files })}>
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
`valueAt(values, path)` reads one control's value back out. It is the companion
|
|
2210
|
+
of `setValue` and is exported for exactly this loop: `values` is **nested**, so a
|
|
2211
|
+
control at `address.street` lives at `values.address.street`, and `values[c.path]`
|
|
2212
|
+
returns `undefined` for every nested control — a form that silently renders blank
|
|
2213
|
+
inputs and submits empty answers. Use `valueAt`, never index by path.
|
|
2214
|
+
|
|
2215
|
+
It seeds each control's `default`, tracks edits, and reports `missing` — the
|
|
2216
|
+
`required` controls still empty — without enforcing anything, the same line
|
|
2217
|
+
`canSubmit` draws. **Submit `formData`, not `values`:** they differ because a
|
|
2218
|
+
`File` inside `formData` would be JSON-serialized to `{}`, so file controls are
|
|
2219
|
+
omitted from it and ride in `files` instead, each tagged with the control it came
|
|
2220
|
+
from. `values` keeps the `File` so your input can show its selection.
|
|
2221
|
+
|
|
2222
|
+
Full reference: `development/receiving-form-submissions.md`.
|
|
2223
|
+
|
|
2224
|
+
### Tracking (`services.tracking`)
|
|
2225
|
+
|
|
2226
|
+
A site may declare one **tracking destination**, and everything worth counting
|
|
2227
|
+
goes there as an event on a single stream. A page visit is just the event the
|
|
2228
|
+
runtime emits by itself.
|
|
2229
|
+
|
|
2230
|
+
```yaml
|
|
2231
|
+
# site.yml — your own collector, on any host
|
|
2232
|
+
services:
|
|
2233
|
+
tracking: https://collector.example.com/events
|
|
2234
|
+
```
|
|
2235
|
+
|
|
2236
|
+
```yaml
|
|
2237
|
+
# or, when it needs more than an address
|
|
2238
|
+
services:
|
|
2239
|
+
tracking:
|
|
2240
|
+
endpoint: /collect
|
|
2241
|
+
consent: required
|
|
2242
|
+
```
|
|
2243
|
+
|
|
2244
|
+
A host may also supply one — `tracking: true` asks for it on a site you publish —
|
|
2245
|
+
and the usual precedence applies: the host's, then yours, then neither. An
|
|
2246
|
+
endpoint of your own asks the host to leave its collector off, so yours answers.
|
|
2247
|
+
|
|
2248
|
+
⚠️ **The endpoint has to accept the framework's own format** — a batched
|
|
2249
|
+
`{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
|
|
2250
|
+
is not a third-party analytics product's public API, which expects that
|
|
2251
|
+
product's own shape.
|
|
2252
|
+
|
|
2253
|
+
A site may also name a vendor's own script under `services.tracking.scripts`, which the
|
|
2254
|
+
runtime loads once, after consent when a gate is declared, and never in a frame
|
|
2255
|
+
or during prerender. That is a **separate path with no connection to the stream
|
|
2256
|
+
below** — the vendor measures its own way, and nothing you `track()` reaches it.
|
|
2257
|
+
|
|
2258
|
+
**Some events you get for free.** These are reported for you, with no call from
|
|
2259
|
+
your foundation and regardless of how it renders:
|
|
2260
|
+
|
|
2261
|
+
| event | when |
|
|
2262
|
+
|---|---|
|
|
2263
|
+
| `page_view` | every route change, including the first |
|
|
2264
|
+
| `outbound_click` | a visitor follows a link off the site — the destination **host** only, never the full URL |
|
|
2265
|
+
| `section_view` | a section first becomes half-visible, on pages that ask for it (`trackSections`, below) |
|
|
2266
|
+
|
|
2267
|
+
⛔ **So don't write your own link-click or scroll-into-view listener for these.**
|
|
2268
|
+
A second one double-counts against a collector that already has them.
|
|
2269
|
+
|
|
2270
|
+
Everything else is yours to report:
|
|
2271
|
+
|
|
2272
|
+
```jsx
|
|
2273
|
+
// In a section type, the block is already in your props.
|
|
2274
|
+
block.track('video_milestone', { milestone: 50 })
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
`block.track` attaches the page path and the section type for you. For an event
|
|
2278
|
+
with no block in hand, use the hook:
|
|
2279
|
+
|
|
2280
|
+
```jsx
|
|
2281
|
+
import { useTracker } from '@uniweb/kit'
|
|
2282
|
+
|
|
2283
|
+
const { track } = useTracker()
|
|
2284
|
+
<button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
|
|
2285
|
+
```
|
|
2286
|
+
|
|
2287
|
+
The event name is yours — there is no list of permitted names. Five are already
|
|
2288
|
+
in use, so reach for these rather than inventing a synonym: the three automatic
|
|
2289
|
+
ones above — **`page_view`**, **`outbound_click`**, **`section_view`** — plus
|
|
2290
|
+
**`video_milestone`**, which kit's `<Media>` reports for any video you render
|
|
2291
|
+
through it, and **`read_depth`** from `useReadingDepth()` below. Put the
|
|
2292
|
+
varying part in a **field**, never in the name — four names for one event turn a
|
|
2293
|
+
collector's event dimension into a cardinality problem.
|
|
2294
|
+
|
|
2295
|
+
### Choosing what a site sends
|
|
2296
|
+
|
|
2297
|
+
By default a site sends `page_view`, `outbound_click` and `section_view` — or,
|
|
2298
|
+
where a host supplies the collector, whatever that host declares it collects.
|
|
2299
|
+
Narrow or widen that with `emit`:
|
|
2300
|
+
|
|
2301
|
+
```yaml
|
|
2302
|
+
# site.yml — your own collector
|
|
2303
|
+
services:
|
|
2304
|
+
tracking:
|
|
2305
|
+
endpoint: https://collector.example.com/events
|
|
2306
|
+
emit: standard # minimal | standard | all — or a list of event names
|
|
2307
|
+
```
|
|
2308
|
+
|
|
2309
|
+
```yaml
|
|
2310
|
+
# site.yml — a host that supplies the collector: say what to send, not where
|
|
2311
|
+
services:
|
|
2312
|
+
tracking:
|
|
2313
|
+
emit: minimal
|
|
2314
|
+
```
|
|
2315
|
+
|
|
2316
|
+
⭐ **`emit` needs no endpoint of its own.** Where a host provides one, the site
|
|
2317
|
+
declares only what it wants sent and the address comes from the host. The two
|
|
2318
|
+
are read key by key, so naming `emit` alone overrides nothing else the host
|
|
2319
|
+
declared. An `endpoint:` of your own means you collect yourself, and asks the host
|
|
2320
|
+
to leave its collector off.
|
|
2321
|
+
|
|
2322
|
+
`minimal` is `page_view` alone. `standard` is the default when the collector is
|
|
2323
|
+
your own. `all` is a standing yes, so an event added in a later framework
|
|
2324
|
+
release is included without you changing anything — which is exactly why
|
|
2325
|
+
`standard` exists as well: it is a curated set that a release cannot grow behind
|
|
2326
|
+
your back.
|
|
2327
|
+
|
|
2328
|
+
⭐ **Saying nothing means two different things, and which one depends on who
|
|
2329
|
+
supplies the address.** A site sending to its own `endpoint:` gets `standard`. A site
|
|
2330
|
+
on a **host-supplied** collector gets **whatever that host declares it
|
|
2331
|
+
collects** — it has no address of its own, so the arrangement is that the host
|
|
2332
|
+
does analytics for it, and the set grows when the host starts collecting
|
|
2333
|
+
something new. **Naming `emit` always wins**, so pin it if you would rather not
|
|
2334
|
+
follow your host.
|
|
2335
|
+
|
|
2336
|
+
A site can also set **`flushIntervalMs`** to widen the batching window (default
|
|
2337
|
+
5000, milliseconds — `30` is thirty *milliseconds*). A host that supplies your
|
|
2338
|
+
collector may set it for you and your own value wins; the trade is fewer
|
|
2339
|
+
requests against more tail loss at the end of a visit.
|
|
2340
|
+
|
|
2341
|
+
⚠️ **`emit` never limits what YOU send.** `block.track()` and `useTracker()` are
|
|
2342
|
+
not filtered by it — the registry is open, and your events are yours. It governs
|
|
2343
|
+
only the ones the framework emits on its own. A host may narrow the list further
|
|
2344
|
+
if it will not store an event, and it can never widen past what you asked for.
|
|
2345
|
+
|
|
2346
|
+
### Reading depth in a long section
|
|
2347
|
+
|
|
2348
|
+
`section_view` tells you a reader *arrived* at a section. For a long-form one —
|
|
2349
|
+
an article, a report, a case study — the question is how far they got:
|
|
2350
|
+
|
|
2351
|
+
```jsx
|
|
2352
|
+
import { useRef } from 'react'
|
|
2353
|
+
import { useReadingDepth } from '@uniweb/kit'
|
|
2354
|
+
|
|
2355
|
+
export default function Article({ content, block }) {
|
|
2356
|
+
const ref = useRef(null)
|
|
2357
|
+
useReadingDepth({ ref, block })
|
|
2358
|
+
return <article ref={ref}>{/* … */}</article>
|
|
2359
|
+
}
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
That reports `read_depth` at 25 / 50 / 75 / 100% **of that element**, once each.
|
|
2363
|
+
Measuring the element rather than the page is the point: two long sections on
|
|
2364
|
+
one page report independently, and adding a section above them changes neither.
|
|
2365
|
+
|
|
2366
|
+
⭐ **This one is a hook rather than automatic because only you know a section is
|
|
2367
|
+
long-form reading.** The framework cannot tell an essay from a row of logos, so
|
|
2368
|
+
it does not guess — and a foundation that never calls this pays nothing for it.
|
|
2369
|
+
|
|
2370
|
+
**`trackSections`** is one page's answer on `section_view`, and it overrides the
|
|
2371
|
+
site in **both** directions — instrument one page of a site that sends `minimal`,
|
|
2372
|
+
or exempt a noisy page of a site that sends `standard`. Say nothing and the
|
|
2373
|
+
site's `emit` decides.
|
|
2374
|
+
|
|
2375
|
+
⭐ **This is the only event with a per-page control, and the asymmetry is the
|
|
2376
|
+
point.** A page view is one event whatever the page and an outbound click is
|
|
2377
|
+
bounded by what a visitor does, but a section view is **one per section** — so a
|
|
2378
|
+
long page costs many times what a short one does. The control sits at the
|
|
2379
|
+
granularity the cost actually varies at.
|
|
2380
|
+
|
|
2381
|
+
```yaml
|
|
2382
|
+
# page.yml
|
|
2383
|
+
trackSections: true # or false to exempt this page
|
|
2384
|
+
```
|
|
2385
|
+
|
|
2386
|
+
⛔ **Never guard a `track()` call.** A site with **no** tracking destination is
|
|
2387
|
+
the default and the majority: the call returns having done nothing, opened no
|
|
2388
|
+
connection, and thrown nothing. Absent is the normal state, not an error — so
|
|
2389
|
+
don't check whether tracking is on, and never render differently because of it.
|
|
2390
|
+
There is no "is tracking enabled" question a component should be asking.
|
|
2391
|
+
|
|
2392
|
+
⚠️ **If a site asks for consent**, tracking holds everything until a visitor
|
|
2393
|
+
answers; granting sends what was buffered, denying discards it. A consent banner
|
|
2394
|
+
is an ordinary component:
|
|
2395
|
+
|
|
2396
|
+
```jsx
|
|
2397
|
+
import { useTrackingConsent } from '@uniweb/kit'
|
|
2398
|
+
|
|
2399
|
+
const { status, grant, deny } = useTrackingConsent()
|
|
2400
|
+
if (status !== 'pending') return null
|
|
2401
|
+
return <CookieBanner onAccept={grant} onReject={deny} />
|
|
2402
|
+
```
|
|
2403
|
+
|
|
2404
|
+
Without `consent: required`, tracking starts immediately — declaring a
|
|
2405
|
+
destination is the site owner's decision to make, not the framework's.
|
|
2406
|
+
|
|
2407
|
+
Every event carries a **`visit`** key on the envelope — opaque, generated at page
|
|
2408
|
+
load, the same for every event of that page load — so a collector can order what
|
|
2409
|
+
happened into a journey.
|
|
2410
|
+
|
|
2411
|
+
> **What is never sent:** no visitor id, no fingerprint, no session spanning days
|
|
2412
|
+
> or tabs, and **nothing is written to the visitor's device** — no cookie, no
|
|
2413
|
+
> local storage. The `visit` key lives in memory and dies with the document, so
|
|
2414
|
+
> it identifies one page load rather than a person. A `page_view` carries the
|
|
2415
|
+
> path; two booleans about the page load — `first_of_load` on the view that
|
|
2416
|
+
> opened the document, and `continues` when the load came from one of your own
|
|
2417
|
+
> pages; and — captured once when the page first loads, then replayed on each
|
|
2418
|
+
> view — the external referrer and any `utm_*` the visitor arrived with. Nothing
|
|
2419
|
+
> else. **Both booleans describe the load, never the visitor**, and neither links
|
|
2420
|
+
> two loads to each other.
|
|
2421
|
+
|
|
2422
|
+
### ⛔ Use kit. Never touch the `uniweb` global
|
|
2423
|
+
|
|
2424
|
+
Everything above reaches the runtime through `@uniweb/kit` or through something
|
|
2425
|
+
handed to your component as a prop. That is the rule, not a stylistic preference:
|
|
2426
|
+
|
|
2427
|
+
- ✅ `useWebsite()`, `useTracker()`, `isSearchEnabled()` and the other service
|
|
2428
|
+
predicates, `resolveService(website, …)` — kit hooks and utilities.
|
|
2429
|
+
- ✅ `block.track(…)`, `block.page`, `block.website` — the block **arrives in your
|
|
2430
|
+
props**, so calling methods on it is not reaching for a global.
|
|
2431
|
+
- ⛔ `globalThis.uniweb`, `window.uniweb` — never, in a foundation.
|
|
2432
|
+
|
|
2433
|
+
The singleton is framework internals: its shape is not part of the contract with
|
|
2434
|
+
foundations, and code that reads it directly breaks on changes that were never
|
|
2435
|
+
breaking changes. If you need something kit does not expose yet, that is worth
|
|
2436
|
+
reporting — not worth reaching around.
|
|
2437
|
+
|
|
2438
|
+
<!-- template:loom -->
|
|
2439
|
+
### Content handlers
|
|
2440
|
+
|
|
2441
|
+
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.
|
|
2442
|
+
|
|
2443
|
+
| Handler | When it runs | Receives | Returns | Purpose |
|
|
2444
|
+
|---|---|---|---|---|
|
|
2445
|
+
| `data` | After data assembly, before content transform | `(data, block)` | New data object, or null | Filter, reshape, or augment assembled data |
|
|
2446
|
+
| `content` | After the data handler | `(data, block)` | ProseMirror document, or null | Transform raw content (Loom instantiation, template expansion) |
|
|
2447
|
+
| `props` | After parsing, defaults, and guarantees | `(content, params, block)` | `{ content, params }`, or null | Post-process the final shape before the component sees it |
|
|
2448
|
+
|
|
2449
|
+
The `content` handler receives `block.parsedContent.data` — the section's declared keys, its foundation's `main.js` `data:` included, so **declare there every key a handler reads** — 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. Anything that is not a document is ignored, with a warning once per handler: the first argument is the section's *data*, so a pass-through `(x) => x` returns the data, not the content.
|
|
2450
|
+
|
|
2451
|
+
> **`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.
|
|
2452
|
+
|
|
2453
|
+
**Loom integration** is the most common use: resolving `{placeholder}` expressions against live data. `@uniweb/loom` provides a factory:
|
|
2454
|
+
|
|
2455
|
+
```js
|
|
2456
|
+
import { createLoomHandlers } from '@uniweb/loom'
|
|
2457
|
+
|
|
2458
|
+
export default {
|
|
2459
|
+
handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }),
|
|
2460
|
+
}
|
|
2461
|
+
```
|
|
2462
|
+
|
|
2463
|
+
`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.
|
|
2464
|
+
|
|
2465
|
+
For cases the factory doesn't cover, write handlers directly using `Loom`, `instantiateContent`, and `instantiateRepeated` from `@uniweb/loom`.
|
|
2466
|
+
|
|
2467
|
+
> **`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.
|
|
2468
|
+
|
|
2469
|
+
**`source` and `where` are ordinary params** that the Loom handler reads — they reach both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. List them in `meta.js` params with descriptions so the editor and schema recognize them. (Unlike `theme` and `background`, which are the section's own settings and never reach `params` — see the table under *Frontmatter becomes `params`*.)
|
|
2470
|
+
<!-- /template:loom -->
|
|
2471
|
+
|
|
2472
|
+
---
|
|
2473
|
+
|
|
2474
|
+
## Part 4b — When the site is also an app
|
|
2475
|
+
|
|
2476
|
+
Everything above is a site: content the author writes, built into pages. Some sites
|
|
2477
|
+
also have **their own backend, the `backend` service** — accounts, per-visitor data, records their members
|
|
2478
|
+
create and edit. `@uniweb/api` is its client.
|
|
2479
|
+
|
|
2480
|
+
⛔ **Only reach for this when the site actually has one.** A site without one is
|
|
2481
|
+
the normal case, and a foundation that assumes one breaks on every other site it is
|
|
2482
|
+
used with.
|
|
2483
|
+
|
|
2484
|
+
```bash
|
|
2485
|
+
npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
|
|
2486
|
+
```
|
|
2487
|
+
|
|
2488
|
+
### Ask before you draw
|
|
2489
|
+
|
|
2490
|
+
```jsx
|
|
2491
|
+
import { isBackendEnabled } from '@uniweb/kit'
|
|
2492
|
+
import { useSession, SignedIn, SignedOut } from '@uniweb/api'
|
|
2493
|
+
|
|
2494
|
+
if (!isBackendEnabled()) return <StaticVersion /> // synchronous — nothing to await
|
|
2495
|
+
```
|
|
2496
|
+
|
|
2497
|
+
⛔ **When the site has no `backend` service, draw nothing** — not a disabled control, and not an
|
|
2498
|
+
explanation. Same rule as `services` in Part 4: which capabilities a site's operator
|
|
2499
|
+
set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
|
|
2500
|
+
when it is simply a feature this site does not have. Render the version of your
|
|
2501
|
+
component that never needed one.
|
|
2502
|
+
|
|
2503
|
+
### Reading
|
|
2504
|
+
|
|
2505
|
+
```jsx
|
|
2506
|
+
const { status, records } = useRecords({ schema: '@/track' }) // summaries: record.brief, no items
|
|
2507
|
+
const { entity } = useEntity({ schema: '@/track', uuid: record.uuid })
|
|
2508
|
+
const sessions = entity?.hydrated?.items.filter((item) => item.section === 'sessions')
|
|
2509
|
+
```
|
|
2510
|
+
|
|
2511
|
+
⛔ **A listed record is a summary, not the entity.** `record.brief` holds the brief
|
|
2512
|
+
section's fields (`record.brief.name`, not `record.name`), and a record carries **no
|
|
2513
|
+
items**. Read one entity for its items. Each item carries `section`, its section's name
|
|
2514
|
+
(the word a write takes), and `id`, which is what the writer takes to update, move or
|
|
2515
|
+
remove it. `scope: 'mine'` lists only the viewer's own records; without it, a list also
|
|
2516
|
+
holds what was shared with the viewer.
|
|
2517
|
+
|
|
2518
|
+
⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
|
|
2519
|
+
mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
|
|
2520
|
+
in) → render the site's authored content. `ready` with `records: []` = the service
|
|
2521
|
+
answered and there is nothing there → render your empty state. Showing "nothing yet"
|
|
2522
|
+
for the first tells a visitor their content is gone when it was never requested.
|
|
2523
|
+
|
|
2524
|
+
### Writing
|
|
2525
|
+
|
|
2526
|
+
```jsx
|
|
2527
|
+
const writer = useEntityWriter({ schema: '@/track', uuid: track.uuid })
|
|
2528
|
+
|
|
2529
|
+
await writer.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
|
|
2530
|
+
await writer.update(itemId, { ...item.data, room: 'Hall A' }) // whole-data replace
|
|
2531
|
+
await writer.move(itemId, { after: previousItemId }) // never an index
|
|
2532
|
+
await writer.remove(itemId)
|
|
2533
|
+
```
|
|
2534
|
+
|
|
2535
|
+
- **`section` is required on `create`.** An entity has several, and a rule declared on
|
|
2536
|
+
one — insert-only, say — does not reach an item that landed in another. Getting it
|
|
2537
|
+
wrong stores the item happily and quietly voids the rule.
|
|
2538
|
+
- **`update` replaces the item's data whole.** Spread what you are not editing.
|
|
2539
|
+
- **Ordering is the server's.** Say `'first'`, `'last'` or `{ after }` — never compute
|
|
2540
|
+
an order number, or two people arranging one list will produce an order neither
|
|
2541
|
+
chose.
|
|
2542
|
+
- ⛔ **`writer.conflict` is reported, not retried.** Someone else changed the item
|
|
2543
|
+
first; a retry would succeed by overwriting a change nobody looked at. Tell the
|
|
2544
|
+
person.
|
|
2545
|
+
|
|
2546
|
+
### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
|
|
2547
|
+
|
|
2548
|
+
Gate your controls on what the viewer may do — a record's own `can_edit` from a
|
|
2549
|
+
single-entity read — but **never rely on that for safety**. A foundation runs with
|
|
2550
|
+
exactly the viewer's own authority, so hiding a button hides a button. ⛔ **Not on
|
|
2551
|
+
`viewer.roles`:** a session signed in on a site holds a member's rights — the operator's
|
|
2552
|
+
too — so its roles never name an administrator, and a gate on `system_admin` shows the
|
|
2553
|
+
operator nothing.
|
|
2554
|
+
|
|
2555
|
+
What protects content is the **entry**: a member writes their own entries and not
|
|
2556
|
+
another's, and the store enforces it. **Models are open** — anyone signed in may
|
|
2557
|
+
create entries of any Model, so a data schema does not restrict who creates
|
|
2558
|
+
(`creatable_by` is retired and refused). What a schema can protect is how an item
|
|
2559
|
+
changes:
|
|
2560
|
+
|
|
2561
|
+
```yaml
|
|
2562
|
+
# foundation/schemas/attendance.yml
|
|
2563
|
+
sections:
|
|
2564
|
+
checkins:
|
|
2565
|
+
many: true
|
|
2566
|
+
append_only: true # may be added; never edited or removed, by anyone
|
|
2567
|
+
```
|
|
2568
|
+
|
|
2569
|
+
`append_only` holds against the item's own author. That is the difference between a
|
|
2570
|
+
permission model and a CSS one.
|
|
2571
|
+
|
|
2572
|
+
### The Admin link
|
|
2573
|
+
|
|
2574
|
+
```jsx
|
|
2575
|
+
const { adminHref } = useSession()
|
|
2576
|
+
{adminHref && <a href={adminHref}>Admin</a>}
|
|
2577
|
+
```
|
|
2578
|
+
|
|
2579
|
+
`adminHref` is set only for a signed-in account the backend's administration admits —
|
|
2580
|
+
the operator, and whoever else administers it — and is `null` for everyone else. It
|
|
2581
|
+
takes them there already signed in. ⛔ **Navigate to it, never `fetch` it**: the answer is
|
|
2582
|
+
a redirect carrying a one-time credential no script on the site is to hold. It decides
|
|
2583
|
+
what to offer, not what is allowed — on the site the operator acts as a member.
|
|
2584
|
+
|
|
2585
|
+
### The links the backend mails
|
|
2586
|
+
|
|
2587
|
+
A sign-up's confirmation, a password reset and an invitation to an address with no
|
|
2588
|
+
account all arrive as a link to the site's root, a token in the query — `?verify=`,
|
|
2589
|
+
`?reset=`, `?offer=` — and the page it lands on finishes the step:
|
|
2590
|
+
|
|
2591
|
+
```jsx
|
|
2592
|
+
const { step, status, result, error, confirm } = useMailedLink()
|
|
2593
|
+
// step: null | 'verify' | 'reset' | 'offer' status: 'idle' | 'working' | 'done' | 'error'
|
|
2594
|
+
```
|
|
2595
|
+
|
|
2596
|
+
- `verify` finishes by itself; then the member signs in.
|
|
2597
|
+
- `reset` waits for `confirm({ new_password })`, and the hook supplies the token. A
|
|
2598
|
+
password the rule refuses is `error.extensions.reason === 'password_too_short'`, and
|
|
2599
|
+
the same link works again.
|
|
2600
|
+
- `offer` waits for a sign-in, then claims by itself. `result` is counts,
|
|
2601
|
+
`{ invitations, grants }`, never names. ⛔ `error.kind === 'absent'` means unknown,
|
|
2602
|
+
revoked or already used, alike: one sentence, no cause. It does not mean "no
|
|
2603
|
+
invitation": a member who confirmed the invited address already holds it.
|
|
2604
|
+
|
|
2605
|
+
Render it where the root renders it — a header or a layout is on every page. ⛔ Never
|
|
2606
|
+
copy the token anywhere: it is a credential, it stays in the URL until the backend has
|
|
2607
|
+
judged it, and then the hook takes it off.
|
|
2608
|
+
|
|
2609
|
+
### Invitations
|
|
2610
|
+
|
|
2611
|
+
An invitation to join an organization waits for the member's answer, whether it was
|
|
2612
|
+
sent to their account or taken up from an `?offer=` link:
|
|
2613
|
+
|
|
2614
|
+
```jsx
|
|
2615
|
+
const { status, invitations, accept, decline, answering } = useInvitations()
|
|
2616
|
+
// invitations: [{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id, created_at }]
|
|
2617
|
+
```
|
|
2618
|
+
|
|
2619
|
+
Draw it wherever a signed-in member lands, not only after a mailed link — an invitation
|
|
2620
|
+
sent to an account comes with no link to follow. `unit_name` may be `null`.
|
|
2621
|
+
`accept(id)` and `decline(id)` answer one, and the list reads again. One that no longer
|
|
2622
|
+
waits — answered in another tab, or withdrawn — is refused with `kind: 'rule'` and
|
|
2623
|
+
leaves the list; its `detail` is for people, so branch on `kind`.
|
|
2624
|
+
|
|
2625
|
+
### A backend on your machine
|
|
2626
|
+
|
|
2627
|
+
You do not need a live backend to build against one. In `site.yml`:
|
|
2628
|
+
|
|
2629
|
+
```yaml
|
|
2630
|
+
services:
|
|
2631
|
+
backend: true # ask your host for it when you publish
|
|
2632
|
+
$devBackend: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
|
|
2633
|
+
```
|
|
2634
|
+
|
|
2635
|
+
```js
|
|
2636
|
+
// site/mock/api.js
|
|
2637
|
+
import { createMockBackend } from '@uniweb/api/mock'
|
|
2638
|
+
export default createMockBackend({ seed }).fetch
|
|
2639
|
+
```
|
|
2640
|
+
|
|
2641
|
+
`uniweb dev` answers your site's `backend` service with it, at an address the dev server
|
|
2642
|
+
supplies on its own origin, so cookies behave exactly as they will in production. Write
|
|
2643
|
+
no address of your own for it: under `services:` an address means a backend you run, and
|
|
2644
|
+
asks your host to leave its own off. It **enforces** who
|
|
2645
|
+
may edit an entry and `append_only`, so a permission you are relying on fails on your
|
|
2646
|
+
machine rather than in front of a user. The mail it would send is printed in the dev
|
|
2647
|
+
server's terminal, its link on your site's root — follow it to try your page. Seed
|
|
2648
|
+
`offers: [{ email, invitations: 1 }]` to try an invitation: mailed to an address no
|
|
2649
|
+
account holds, or waiting at once for the seeded account whose address it is. State is
|
|
2650
|
+
in memory; restart to reset.
|
|
2651
|
+
|
|
2652
|
+
`uniweb create my-event --template conference` is a worked example of all of this.
|
|
2653
|
+
|
|
2654
|
+
---
|
|
2655
|
+
|
|
2656
|
+
## Part 5 — Commands, shipping, and migration
|
|
2657
|
+
|
|
2658
|
+
```bash
|
|
2659
|
+
uniweb dev # Start dev server (picks the site for you)
|
|
2660
|
+
pnpm build / pnpm preview # Build for production / preview the build (SSG + SPA)
|
|
2661
|
+
|
|
2662
|
+
uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
|
|
2663
|
+
uniweb deploy --host=<adapter> # Build + upload now, from this machine
|
|
2664
|
+
uniweb add ci --host=<adapter> # CI so every push deploys — usually best for a free host
|
|
2665
|
+
uniweb export # Build dist/ for any static host (no Uniweb account)
|
|
2666
|
+
uniweb publish # Ship to Uniweb hosting, foundation included (needs `uniweb login`)
|
|
2667
|
+
|
|
2668
|
+
uniweb add ci --target foundation # Publish a foundation for free at permanent versioned URLs
|
|
2669
|
+
# (GitHub Pages → foundations/<name>/<version>/entry.js)
|
|
2670
|
+
|
|
2671
|
+
uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
|
|
2672
|
+
uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
|
|
2673
|
+
uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
|
|
2674
|
+
uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
|
|
2675
|
+
uniweb login / logout # One backend, one workspace at a time: log in (--server, --org) or out
|
|
2676
|
+
uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
|
|
2677
|
+
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
2678
|
+
|
|
2679
|
+
uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
|
|
2680
|
+
uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
|
|
2681
|
+
uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
|
|
2682
|
+
|
|
2683
|
+
uniweb -v # Installed CLI version — and whether a newer one exists
|
|
2684
|
+
uniweb doctor # Diagnose project configuration (--fix to auto-repair)
|
|
2685
|
+
uniweb validate # Check file-based data against declared schemas (fails on a violation; --lax)
|
|
2686
|
+
npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
|
|
2687
|
+
# bare `uniweb update` aligns to the CLI you ALREADY have
|
|
2688
|
+
uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
|
|
2689
|
+
uniweb snapshot # Compose site/public/preview.webp from the site; sets preview: if unset
|
|
2690
|
+
# needs `pnpm add -D -w @uniweb/snapshot` and Chrome or Edge
|
|
2691
|
+
uniweb snapshot --compare # Several looks on one sheet; take one with its flags + --save (site/snapshot.yml)
|
|
2692
|
+
|
|
2693
|
+
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
2694
|
+
```
|
|
2695
|
+
|
|
2696
|
+
**Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
|
|
2697
|
+
|
|
2698
|
+
### Where a site can live
|
|
2699
|
+
|
|
2700
|
+
**There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
|
|
2701
|
+
|
|
2702
|
+
| Path | Command | Good for |
|
|
2703
|
+
|---|---|---|
|
|
2704
|
+
| Free static host, via CI | `uniweb add ci --host=<adapter>` | most self-hosted sites — one command, then every push deploys |
|
|
2705
|
+
| Free static host, from this machine | `uniweb deploy --host=<adapter>` | one-off or manual deploys |
|
|
2706
|
+
| Any static host at all | `uniweb export` | full control; no Uniweb account needed |
|
|
2707
|
+
| Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
|
|
2708
|
+
|
|
2709
|
+
`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`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
|
|
2710
|
+
|
|
2711
|
+
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
2712
|
+
|
|
2713
|
+
### Uniweb Cloud and the two-sided workflow
|
|
2714
|
+
|
|
2715
|
+
`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.
|
|
2716
|
+
|
|
2717
|
+
**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).
|
|
2718
|
+
|
|
2719
|
+
> ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
|
|
2720
|
+
> A workspace is your personal one or an organization's. A site a push or publish **creates** is
|
|
2721
|
+
> created — owned, and its storage billed — there, and there is no CLI command to move it
|
|
2722
|
+
> afterwards. A site the backend keeps in **another** workspace stops the command, which says how
|
|
2723
|
+
> to switch. With no organization, the login works in your personal workspace; with
|
|
2724
|
+
> organizations, the login asks, or you name it:
|
|
2725
|
+
>
|
|
2726
|
+
> ```bash
|
|
2727
|
+
> uniweb login --org @acme # work in an organization
|
|
2728
|
+
> uniweb login --personal # work in your personal workspace
|
|
2729
|
+
> uniweb push --org @acme # this command only
|
|
2730
|
+
> ```
|
|
2731
|
+
>
|
|
2732
|
+
> Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
|
|
2733
|
+
> backend you are logged in to, without logging in again. **Without a
|
|
2734
|
+
> terminal — you, an agent — a login with organizations and no workspace named signs you in but
|
|
2735
|
+
> exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
|
|
2736
|
+
> Until then every command that works on a site refuses, and so does registering a foundation whose
|
|
2737
|
+
> name has no scope. A process logged in with `UNIWEB_TOKEN` names its workspace with
|
|
2738
|
+
> `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
|
|
2739
|
+
> picking for them — a site created in the wrong one cannot be moved from here.
|
|
2740
|
+
>
|
|
2741
|
+
> Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
|
|
2742
|
+
> If you get `Unknown flag`, read the suggestion — it is usually a near miss.
|
|
2743
|
+
|
|
2744
|
+
**Sync is developer-only and one-sided by design.** `uniweb push` and `uniweb pull` are your commands, not theirs:
|
|
2745
|
+
|
|
2746
|
+
- **The app is the live source of truth for content** — where authors work and where your push lands live.
|
|
2747
|
+
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
2748
|
+
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
2749
|
+
|
|
2750
|
+
**Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
|
|
2751
|
+
|
|
2752
|
+
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
|
|
2753
|
+
|
|
2754
|
+
**`services:` in `site.yml` is where a site says which services it uses** — site search, form
|
|
2755
|
+
handling, analytics, an assistant, accounts — one entry per service:
|
|
2756
|
+
|
|
2757
|
+
```yaml
|
|
2758
|
+
services:
|
|
2759
|
+
search: true # on — your host's, or the built-in index
|
|
2760
|
+
submit: false # off
|
|
2761
|
+
tracking: https://collector.example.com/events # a provider you bring
|
|
2762
|
+
backend: # on, with the service's own settings
|
|
2763
|
+
grade: pro
|
|
2764
|
+
```
|
|
2765
|
+
|
|
2766
|
+
An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
|
|
2767
|
+
of options; anything else stops the build and the push. ⚠️ `yes`, `no`, `on` and `off` are text to
|
|
2768
|
+
YAML, not switches — write `true` or `false`. On a site you push or publish it is also **what you ask your host for**: `true` asks for
|
|
2769
|
+
its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
|
|
2770
|
+
yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
|
|
2771
|
+
list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
|
|
2772
|
+
delivered live to the published pages: a query whose records have a data schema shows nothing there
|
|
2773
|
+
without it, and `publish` says so; a static build reads records from files and ignores it. An entry
|
|
2774
|
+
says everything about its service: a setting you remove from it is removed on the next push, and
|
|
2775
|
+
removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
|
|
2776
|
+
`site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
|
|
2777
|
+
your last pull) is left as it is, and one changed both in the file and on the site since your last
|
|
2778
|
+
pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
|
|
2779
|
+
has into `services:` (an off service with no settings only where the file already names it) — pull
|
|
2780
|
+
before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
|
|
2781
|
+
credential is public** — it is built into the site, except `backend`'s settings, which only your host
|
|
2782
|
+
reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
|
|
2783
|
+
`tracking:` / `api:` keys are retired: the build stops on them and says where each one moves. So
|
|
2784
|
+
does `api` under `services:` — the site's own backend is the `backend` service — and `$devApi:`,
|
|
2785
|
+
which is `$devBackend:`.
|
|
2786
|
+
|
|
2787
|
+
**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.
|
|
2788
|
+
|
|
2789
|
+
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.
|
|
2790
|
+
|
|
2791
|
+
**Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. 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. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. 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` (`uniweb login --server <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
|
|
2792
|
+
|
|
2793
|
+
### Staying current
|
|
2794
|
+
|
|
2795
|
+
```bash
|
|
2796
|
+
uniweb -v # installed CLI version, and whether a newer one is available
|
|
2797
|
+
uniweb doctor # report drift in this project, changing nothing
|
|
2798
|
+
npx uniweb@latest update --dry-run # preview exactly what update would change
|
|
2799
|
+
npx uniweb@latest update # apply: align @uniweb/* deps AND refresh AGENTS.md
|
|
2800
|
+
```
|
|
2801
|
+
|
|
2802
|
+
**`npx uniweb@latest 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.
|
|
2803
|
+
|
|
2804
|
+
**What the version numbers mean.** A patch brings fixes and features; a minor version is where Uniweb puts breaking changes — `update` lists each package that crosses one, so read those before shipping — and a major is kept for something really big. Dependencies are written as tilde ranges (`~1.0.1`), so `pnpm install` and `pnpm update` never cross a minor on their own. The move from 0.x to 1.0.1 asks nothing of a project that was on the latest 0.x versions — 1.0.1 carried every package's 0.x line over — though `update` marks each package `major · BREAKING`.
|
|
2805
|
+
|
|
2806
|
+
> ### ⛔ WRITE `@latest`. A BARE `uniweb update` IS USUALLY A NO-OP, AND IT SAYS SO CONVINCINGLY
|
|
2807
|
+
>
|
|
2808
|
+
> `update` reconciles this project against **the matrix of the CLI that runs it** — and in a project,
|
|
2809
|
+
> `uniweb` resolves to the copy in your own `node_modules`, pinned by your own `package.json`. That
|
|
2810
|
+
> copy has no way to know a newer release exists.
|
|
2811
|
+
>
|
|
2812
|
+
> ⇒ **It reports everything aligned and changes nothing — correctly.** It answered the question it
|
|
2813
|
+
> was asked, which is why the output looks like success rather than a mistake. The same applies to
|
|
2814
|
+
> `pnpm uniweb update` and to a stale global install.
|
|
2815
|
+
>
|
|
2816
|
+
> ⭐ **`@latest` is what makes it a question about the newest release** rather than about the one you
|
|
2817
|
+
> already have. It also bumps the pin, so the next bare invocation is no longer stale.
|
|
2818
|
+
|
|
2819
|
+
> **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.
|
|
2820
|
+
|
|
2821
|
+
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.
|
|
2822
|
+
|
|
2823
|
+
### `package.json` `uniweb` block
|
|
2824
|
+
|
|
2825
|
+
Platform-specific configuration that doesn't belong in npm-standard fields. All fields optional.
|
|
2826
|
+
|
|
2827
|
+
| Field | Where used | Default | Purpose |
|
|
2828
|
+
|---|---|---|---|
|
|
2829
|
+
| `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
|
|
2830
|
+
|
|
2831
|
+
**The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else the workspace you work in — an org's scope, or your personal one; when you belong to orgs and chose no workspace, it asks at a terminal and refuses without one — and writes it into the name; a `--scope` naming another scope than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
|
|
2832
|
+
|
|
2833
|
+
**Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
|
|
2834
|
+
|
|
2835
|
+
You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** — you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
|
|
2836
|
+
|
|
2837
|
+
⚠️ **Nothing acts on the recorded version yet** — today it is stated and carried, not enforced. Don't design around it having an effect.
|
|
2838
|
+
|
|
2839
|
+
**The escape hatch,** for the one thing only you can know: if your foundation reaches into undocumented runtime internals, or you have audited it against exactly one runtime build and want no movement at all, set
|
|
2840
|
+
|
|
2841
|
+
```json
|
|
2842
|
+
{ "uniweb": { "runtimePolicy": "exact" } }
|
|
2843
|
+
```
|
|
2844
|
+
|
|
2845
|
+
That freezes sites using your foundation on the recorded version. Only reach for it if one of those two things is actually true — a frozen foundation stops receiving runtime fixes, including security ones. Sites cannot override your choice.
|
|
2846
|
+
|
|
2847
|
+
> Older releases documented `auto-patch` and `auto-minor` here as well. They asked you to pick a compatibility rule out of our version numbers, which was never something you were in a position to evaluate — that determination is ours. Both values are still accepted so existing foundations keep working; neither is worth setting.
|
|
2848
|
+
|
|
2849
|
+
`@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 `@uniweb/runtime` isn't resolvable at build time the pin is simply not emitted, and the build still succeeds — nothing in the toolchain requires it.
|
|
2850
|
+
|
|
2851
|
+
### Localization
|
|
2852
|
+
|
|
2853
|
+
**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`).
|
|
2854
|
+
|
|
2855
|
+
**Translations live in `locales/`, a sibling of `pages/` at the site root — never inside `pages/`.** The markdown under `pages/` is one language: the default.
|
|
2856
|
+
|
|
2857
|
+
```yaml
|
|
2858
|
+
# site.yml
|
|
2859
|
+
defaultLanguage: en
|
|
2860
|
+
languages: [en, es, fr] # codes only — a language's name comes from its code;
|
|
2861
|
+
# or '*' to auto-discover from the locales/ folder
|
|
2862
|
+
publishLanguages: [en, es] # optional — fr stays a dev-previewable draft,
|
|
2863
|
+
# excluded from production output entirely
|
|
2864
|
+
```
|
|
2865
|
+
|
|
2866
|
+
```bash
|
|
2867
|
+
uniweb build # FIRST — extract scans built content, not source
|
|
2868
|
+
uniweb i18n extract # → locales/manifest.json, keyed by 8-char content hash
|
|
2869
|
+
uniweb i18n init es fr # → locales/es.json, locales/fr.json, pre-filled with source
|
|
2870
|
+
# …translate the values…
|
|
2871
|
+
uniweb build # merges translations, emits dist/es/, dist/fr/
|
|
2872
|
+
```
|
|
2873
|
+
|
|
2874
|
+
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.
|
|
2875
|
+
|
|
2876
|
+
**A value is inline Markdown.** A sentence's links and bold go into its translation — `"Voir [Rôles](page:docs/roles)."` — or it renders without them, and coverage still counts it; `uniweb i18n status` lists these under *Loses markup*. A **button** — a line that is only a link — is the exception: translate its label alone and it keeps its link and options (`{role=primary}`). Buttons on consecutive lines are one string; give one label per line (`"Créer un groupe\nEn savoir plus"`).
|
|
2877
|
+
|
|
2878
|
+
**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:
|
|
2879
|
+
|
|
2880
|
+
```json
|
|
2881
|
+
{ "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
|
|
2882
|
+
```
|
|
2883
|
+
|
|
2884
|
+
An override key is the page's route and the section's id (`cta` for `2-cta.md`); a page's own title, label, description and keywords are `/pricing:_meta`, and in `locales/records/` a record is named by its manifest context (`std/article/launch`).
|
|
2885
|
+
|
|
2886
|
+
*Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
|
|
2887
|
+
|
|
2888
|
+
```
|
|
2889
|
+
locales/freeform/es/pages/about/hero.md # by page route
|
|
2890
|
+
locales/freeform/es/records/article/x.md # records work too
|
|
2891
|
+
```
|
|
2892
|
+
|
|
2893
|
+
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.
|
|
2894
|
+
|
|
2895
|
+
**A tagged data block translates by the shape its key declares** in the component's `meta.js` `data:` — a data schema or an inline field map: its text fields, never an enum, a URL or a `translatable: false` field. A key declared `{}` leaves its blocks to a heuristic that offers any string whose field name and value don't look structural, so a `style: display-black` naming one of your component's looks gets translated too. Declare the shape of every block a translator will see — `{ name: 'string', style: { type: 'string', enum: [...] } }`.
|
|
2896
|
+
|
|
2897
|
+
**Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
|
|
2898
|
+
|
|
2899
|
+
**Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
|
|
2900
|
+
|
|
2901
|
+
```jsx
|
|
2902
|
+
import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'
|
|
2903
|
+
|
|
2904
|
+
function LanguageSwitcher() {
|
|
2905
|
+
const { website } = useWebsite()
|
|
2906
|
+
if (!website.hasMultipleLocales()) return null
|
|
2907
|
+
|
|
2908
|
+
return website.getLocales().map(locale => (
|
|
2909
|
+
<Link reload key={locale.code} href={website.getLocaleUrl(locale.code)}>
|
|
2910
|
+
{getLocaleLabel(locale)}
|
|
2911
|
+
</Link>
|
|
2912
|
+
))
|
|
2913
|
+
}
|
|
2914
|
+
```
|
|
2915
|
+
|
|
2916
|
+
> **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.
|
|
2917
|
+
|
|
2918
|
+
Full model: `development/internationalization.md`. Author's view: `authoring/translating.md`.
|
|
2919
|
+
|
|
2920
|
+
### Migrating from other frameworks
|
|
2921
|
+
|
|
2922
|
+
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.
|
|
2923
|
+
|
|
2924
|
+
| React / conventional | Uniweb equivalent |
|
|
2925
|
+
|---|---|
|
|
2926
|
+
| Props with typed data | Frontmatter params + `meta.js` |
|
|
2927
|
+
| Component variants via props | `variant` param; Front Desk pattern for complex routing |
|
|
2928
|
+
| Context / ThemeProvider | `theme:` frontmatter + semantic tokens (automatic) |
|
|
2929
|
+
| Wrapper/layout components | Section nesting or custom layouts |
|
|
2930
|
+
| Prop-drilling visuals into containers | Insets — `` rendered via `<Visual>` |
|
|
2931
|
+
| Content in JSX or `.js` data files | Markdown → parser → `content` prop |
|
|
2932
|
+
| CSS color tokens / design systems | `theme.yml` → palette shades + semantic tokens |
|
|
2933
|
+
| `isDark ? … : …` conditionals | `text-heading` — context classes handle it |
|
|
2934
|
+
| Per-component backgrounds | `background:` in frontmatter |
|
|
2935
|
+
| Multiple near-identical components | One section type + `variant` param, or Front Desk |
|
|
2936
|
+
| i18n wrapping (`t()` / `<Trans>`) | Locale-specific content directories |
|
|
2937
|
+
|
|
2938
|
+
**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.
|
|
2939
|
+
|
|
2940
|
+
**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.
|
|
2941
|
+
|
|
2942
|
+
**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.
|
|
2943
|
+
|
|
2944
|
+
Also: name by purpose, not content (`TheModel` → `SplitContent`, `WorkModes` → `FeatureColumns`), and put UI helpers (buttons, badges, cards) in `components/` with no `meta.js`.
|
|
2945
|
+
|
|
2946
|
+
Full guide: `development/converting-existing.md`.
|
|
2947
|
+
|
|
2948
|
+
---
|
|
2949
|
+
|
|
2950
|
+
## Part 6 — Troubleshooting
|
|
2951
|
+
|
|
2952
|
+
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.
|
|
2953
|
+
|
|
2954
|
+
**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.
|
|
2955
|
+
|
|
2956
|
+
**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.
|
|
2957
|
+
|
|
2958
|
+
**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.
|
|
2959
|
+
|
|
2960
|
+
**Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the step rule (one step smaller and adjacent = the next headline line; two steps, a divider, or any body content first = items). Run `uniweb inspect <path>` rather than re-deriving it.
|
|
2961
|
+
|
|
2962
|
+
**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.
|
|
2963
|
+
|
|
2964
|
+
Loud failures:
|
|
2965
|
+
|
|
2966
|
+
**"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.
|
|
2967
|
+
|
|
2968
|
+
**Component not appearing** — verify `meta.js` exists; check for `hidden: true`; rebuild the foundation.
|
|
2969
|
+
|
|
2970
|
+
**Styles not applying** — verify `@source` in `styles.css` includes your component paths.
|
|
2971
|
+
|
|
2972
|
+
**Prerender warnings about hooks** — components with `useState`/`useEffect` show SSG warnings during build in local symlinked mode. Expected and harmless.
|
|
2973
|
+
|
|
2974
|
+
**"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.
|
|
2975
|
+
|
|
2976
|
+
**A modal renders behind the page** — page content or a sidebar paints over your dialog and its backdrop, and raising the z-index changes nothing. Each layout area carries a `view-transition-name`, which makes it a stacking context your `fixed` element cannot escape. Render through `<Overlay>` from `@uniweb/kit` (see Part 4) instead of a bare `fixed inset-0 z-…` div.
|
|
2977
|
+
|
|
2978
|
+
**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.
|
|
2979
|
+
|
|
2980
|
+
---
|
|
2981
|
+
|
|
2982
|
+
## Documentation index
|
|
2983
|
+
|
|
2984
|
+
**Index of every page: https://www.uniweb.io/llms.txt** — start there when you don't know which page you need.
|
|
2985
|
+
|
|
2986
|
+
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.
|
|
2987
|
+
|
|
2988
|
+
| Section | Covers |
|
|
2989
|
+
|---------|--------|
|
|
2990
|
+
| `architecture/` | Component Content Architecture — the why behind the patterns in this file |
|
|
2991
|
+
| `getting-started/` | What is Uniweb, quickstart, templates |
|
|
2992
|
+
| `authoring/` | Writing content, site setup, records, theming, translations, predicates |
|
|
2993
|
+
| `development/` | Foundations, component patterns, project structures, data, layouts, i18n, migration, schemas |
|
|
2994
|
+
| `reference/` | site.yml, page.yml, content structure, meta.js, kit API, navigation, records, queries, data fetching, parametric pages, CLI, deployment |
|
|
2995
|
+
|
|
2996
|
+
The by-task table is in Part 0. For CLI flags, prefer `uniweb <command> --help` over this file — it's always current.
|
|
2997
|
+
|
|
2998
|
+
## Notes about this project
|
|
2999
|
+
|
|
3000
|
+
Conventions, decisions, where things are — keep them in a file of your own (`NOTES.md`, say). `uniweb update` rewrites this file for each new version, and the one part it keeps is a block between `<!-- project-notes:start -->` and `<!-- project-notes:end -->`, each on a line of its own, which it moves to the end of the file. That block is the place for a line pointing to your notes, or for short notes themselves.
|