# Site Configuration

The `site.yml` file in your site root defines global settings, page ordering, language support, and feature toggles.

## Quick Start

A minimal site.yml:

```yaml
name: My Site
```

That's all you need to get started. Everything else has sensible defaults.

## Full Reference

```yaml
# Identity
name: My Site
description: A brief description for SEO
preview: /images/card.png            # The site's card image in the Uniweb apps (optional)
keywords: [components, react, cms]   # Default meta keywords (pages can override)
seo:                                 # Site-level social card + SEO defaults
  baseUrl: https://my-site.com       # Turns on sitemap.xml, robots.txt, absolute llms.txt links
  image: /og-default.png             # Default Open Graph / social-sharing image
  ogTitle: My Site

# Page Ordering
pages: [home, about, ...]            # Inclusive order (first is homepage, ... = rest)
pages: [home, about, docs]           # Strict order (unlisted hidden from nav)
index: home                          # Or just name the homepage

# Languages
defaultLanguage: en
languages: [en, es, fr]              # Or '*' to auto-discover from locales/

# Code — the foundation and its extensions
foundation: '@acme/marketing@1.2.3'  # Or a workspace package name, or a full URL
extensions:                          # Secondary foundations (optional)
  - '@acme/effects@0.3.1'

# Layout — cascades to every page; folder.yml and page.yml override per field
layout:
  name: DocsLayout
  hide: [right]

# Services — search, forms, tracking, an assistant, accounts: one entry each
services:
  search:                            # on, with options
    exclude: { routes: [/legal] }
  submit: https://forms.example.com/f/abc   # a provider you bring
  backend: true                      # ask your host for the site's own backend
$devBackend: ./mock/api.js           # what answers `backend` in `uniweb dev` (never published)

# Build Options
build:
  prerender: true                    # Generate static HTML

# Data
query: config                        # The site's fetch — see Site-Level Data Fetching

# Queries — or in queries.yml
queries:
  articles:
    schema: '@/article'
    sort: date desc

# Placeholders — values written once, referenced from any page as {name}
placeholders:
  product: Uniweb
  vendor:
    email: billing@acme.example

# Custom Content Paths (optional, for external content)
paths:
  pages: ../docs/pages             # Default: pages/
  layout: ../docs/layout           # Default: layout/
  records: ../content              # Default: records/
```

---

## Identity

```yaml
name: My Site
description: Build modern websites with components
tags: [business, landing-page]
preview: /images/card.png
```

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Site name (used in `<title>`, metadata), and the title on the site's card in the Uniweb apps |
| `description` | string | Default meta description, and the line under the title on the site's card |
| `tags` | list | What the site is for, as standard ids — [the site tags](https://github.com/uniweb/schemas#site-tags). Apps use them to filter and label sites, such as in a template picker. An id that is not on the list is kept, and simply has no label. |
| `preview` | string | The image on the site's card in the Uniweb apps — useful for a template site that wants a picture of its own. A URL, or a site-root path to an image in your project, which `uniweb push` uploads like any other image. Leave it unset and the app generates one, or compose one from your site with [`uniweb snapshot`](cli-commands.md#uniweb-snapshot). |

---

## Code: Foundation and Extensions

A site is content; the code that renders it comes from two declarations, which take the same kinds of value because **an extension is a foundation**.

```yaml
foundation: '@acme/marketing@1.2.3'   # the primary — required
extensions:                            # secondary foundations — optional
  - '@acme/effects@0.3.1'
```

You don't declare a runtime version. A site ships no JavaScript — content, config, data and assets only — so nothing in it links against the runtime and nothing in it breaks when the runtime moves. The host serves a runtime compatible with whatever foundation the site loads.

### `foundation`

The component system your pages are composed from. Four accepted shapes:

| Shape | Example | Meaning |
| --- | --- | --- |
| workspace package name | `src` | a foundation in this repo (follow the `file:` dep to its folder) |
| versioned catalog ref | `@acme/marketing@1.2.3` | published; loaded by the host |
| full URL | `https://cdn.example.com/entry.js` | loaded from that URL |
| object form | `{ url: 'https://…' }` | same, with an explicit CSS URL if needed |

A **versionless** `@org/name` is an error rather than a shorthand — the build asks for a version. Foundations are never npm packages; don't `npm install` one.

### `extensions`

Secondary foundations that contribute additional section types. Each entry accepts the **same shapes** as `foundation:`, and the primary wins on a name collision.

```yaml
extensions:
  - '@acme/effects@0.3.1'              # catalog ref  — recommended
  - effects                            # a workspace package name (local development)
  - https://cdn.example.com/e/entry.js # an absolute URL
```

> **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; `uniweb publish` rejects it and points you at 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 the same way it does the primary: it releases the extension if its code changed or isn't registered yet, and pins the released `@scope/name@version` on the published site.

---

## Layout

Set the layout for the whole site, and adjust it per branch or per page.

```yaml
layout: DocsLayout          # the shorthand — just the layout name
```

```yaml
layout:                     # or the expanded form
  name: DocsLayout
  hide: [right]             # areas of the layout to leave out
  params:                   # layout-specific settings
    sidebarWidth: wide
```

`hide` names layout areas — `header`, `footer`, `right`, or whatever your foundation declares — and turning one off leaves its content alone. Re-enable it later and everything is still there.

### The cascade

The same `layout:` block works in three places, and the nearest one wins **per field**:

| where | applies to |
| --- | --- |
| `site.yml` | every page |
| `folder.yml` | that folder and everything under it |
| `page.yml` | that page |

Because it merges field by field, a page that sets only `hide` keeps the name it inherited:

```yaml
# site.yml
layout: { name: DocsLayout, hide: [right] }

# pages/pricing/page.yml — full width, still DocsLayout
layout: { hide: [] }
```

That last line is worth noticing: `hide: []` is *"hide nothing"*, which is how a page opts out of an inherited `hide`. An absent `hide` inherits; an empty one overrides.

See [page configuration](./page-configuration.md#layout-options) for the page-level form.

---

## SEO & Social Sharing

Site-level metadata for the homepage's social card and search — and the defaults every page inherits. These mirror the page-level `seo:` / `keywords:` in [page.yml](./page-configuration.md#seo-configuration), hoisted to the site root.

```yaml
keywords: [components, react, cms]   # Default keywords (pages can override)
seo:
  baseUrl: https://acme.com          # Where the site lives — turns on sitemap.xml + robots.txt
  image: /og-default.png             # Default Open Graph / social-card image
  ogTitle: Acme — Build with Components
  ogDescription: The component content platform.
  noindex: false                     # Set true to keep the whole site out of search
  robots:                            # robots.txt contents
    disallow: [/private]
```

| Field | Type | Description |
| --- | --- | --- |
| `keywords` | string[] | Default meta keywords; a page's own `keywords` override |
| `seo.baseUrl` | string | The site's public origin. **Setting it turns on `sitemap.xml` and `robots.txt`, and makes `llms.txt` links absolute** — all three are skipped or root-relative without it, because none of them can be written without knowing where the site lives. `uniweb doctor` warns when it is unset |
| `seo.robots` | object | `robots.txt` contents — `disallow`, `allow`, `crawlDelay`, `contentSignals`. Only emitted alongside `seo.baseUrl` |
| `seo.image` | string | Default Open Graph / social-sharing image (the site's social card) |
| `seo.ogTitle` | string | Default social title; a page's own title or `seo.ogTitle` wins |
| `seo.ogDescription` | string | Default social description; a page's description or `seo.ogDescription` wins |
| `seo.noindex` | boolean | Keep the entire site out of search engines (cascades to every page) |

`sitemap.xml` and `robots.txt` are **static-host artifacts**: `uniweb export` and `uniweb deploy --host <adapter>` write them into `dist/`. A site published to a backend does not get them from the build — what that host serves is the host's.

**Cascade:** site-level `seo` and `keywords` are *defaults*. Each page overrides any field it sets — the page wins, the site fills the gaps. The social image is the field most worth setting once at the site level.

These render into the static HTML `<head>` (Open Graph, Twitter Card, canonical, robots) for the homepage and every page, so crawlers and social unfurlers see them without running JavaScript. For arbitrary tags beyond these, use [Custom Head Injection](#custom-head-injection).

---

## Page Ordering

Control the order of top-level pages and designate your homepage.

### Inclusive Order (Recommended)

```yaml
pages: [home, about, ...]
```

The `...` wildcard means "all remaining pages here." Pages before `...` appear first in that order; pages after `...` appear last. Everything else fills the middle.

- First item becomes the homepage (route `/`)
- Other items get their folder name as route (`/about`, `/docs`, `/pricing`)
- `...` expands to all pages not explicitly listed, in their natural order

```yaml
# home first, contact last, everything else in between
pages: [home, ..., contact]

# home first, about second, rest after
pages: [home, about, ...]

# rest first, legal last
pages: [..., legal]
```

### Strict Order

```yaml
pages: [home, about, docs]
```

Without `...`, only listed pages appear in navigation. Unlisted pages are still built and reachable by URL, but suppressed from every nav menu (equivalent to `hideIn: ['*']` — a nav-only exclusion, not `hidden`, which would drop them from the published site).

Use this when you want precise control over what appears in the nav — for example, a landing page with only a few pages in the header.

### Just Set the Homepage

```yaml
index: home
```

Only specify which page is the homepage. Other pages are auto-discovered and sorted by their `order` property.

### Auto-Discovery (Default)

Omit `pages`, `index`, and `order` to auto-discover all pages. They're sorted by the `order` property in each page's `page.yml`, and the lowest `order` becomes the homepage.

### From the Pages Directory

`pages:` and `index:` can also live in the config of the pages directory itself — `pages/folder.yml`, or `pages/page.yml` — where they order the top level the way a folder's config orders its children. This is the place for content that travels: a docs repository that carries its own order keeps it whether it is [mounted below the root](#per-subfolder-mounting) or used as the whole pages directory ([Custom Content Paths](#custom-content-paths)).

`site.yml` wins. Its `pages:` replaces theirs, and a homepage it names — with either key — replaces the one they name, so a `site.yml` `index:` decides the homepage even when the pages directory lists an order.

Only these two keys are read there. The top level has no container page, so a title or an SEO block there describes nothing, and the site's layout is `site.yml`'s `layout:`.

In [folder mode](#folder-mode) — a `folder.yml` in `pages/` — nothing is promoted to `/` unless one of these names it.

---

## Content Mode

By default, `.md` files in a folder are sections of a single page (**page mode**). By placing a `folder.yml` in a directory, you switch it to **folder mode** — where each `.md` file becomes its own page.

| Config file | Mode | Folder is... | `.md` files are... |
| --- | --- | --- | --- |
| `page.yml` | page mode | A page | Sections of that page (default) |
| `folder.yml` | folder mode | A container | Individual child pages |

### Folder Mode

Ideal for documentation sites where each file is a standalone article:

```
pages/docs/
├── folder.yml               # Activates folder mode
├── getting-started.md       # → /docs/getting-started
├── configuration.md         # → /docs/configuration
└── advanced/
    ├── folder.yml
    ├── plugins.md           # → /docs/advanced/plugins
    └── themes.md            # → /docs/advanced/themes
```

Page titles come from the H1 heading in each markdown file. Frontmatter remains section configuration (`type:`, `background:`, etc.).

To activate folder mode for the entire site, place a `folder.yml` in the `pages/` directory itself. Its `pages:` and `index:` then order the top level and name the homepage — see [From the Pages Directory](#from-the-pages-directory).

### Mode Cascade

The mode set by `folder.yml` or `page.yml` cascades to descendant folders:

1. `folder.yml` in a directory → folder mode for that folder and all descendants
2. `page.yml` in a directory → page mode for that folder and all descendants
3. Neither → inherit from parent (default: page mode)

A single `folder.yml` at the top of a docs tree applies folder mode to the entire tree. A subfolder can override back to page mode with a `page.yml`.

### folder.yml

The configuration file for container folders in folder mode. Analogous to `page.yml` but signals that `.md` files are pages, not sections:

```yaml
# folder.yml
title: Documentation
description: API reference and guides
pages: [getting-started, configuration, ...]
label: Docs
layout:
  left: true
```

| Field | Type | Description |
| --- | --- | --- |
| `title` | string | Container title (for navigation, breadcrumbs) |
| `description` | string | Meta description |
| `pages` | array | Child page ordering with `...` wildcard support |
| `index` | string | In the pages directory's own `folder.yml` only: which top-level page is the homepage, unless `site.yml` names one. Deeper in the tree it has no effect — only the top level has a homepage |
| `label` | string | Short navigation label |
| `hidden` | boolean | Hide from navigation |
| `layout` | object | Layout panel overrides |
| `seo` | object | SEO overrides |
| `id` | string | Stable ID for `page:` links |

### Ordering in Folder Mode

Child pages are ordered by:

1. `pages:` array in `folder.yml` with `...` wildcard support (same semantics as site-level)
2. Numeric file prefix (`1-intro.md` before `2-setup.md`)
3. Alphabetical by filename

```yaml
# folder.yml
title: Documentation
pages: [getting-started, configuration, ...]
```

---

## Languages

Enable multi-language support.

```yaml
defaultLanguage: en
languages: [en, es, fr]
```

### Options

| Option | Type | Description |
| --- | --- | --- |
| `defaultLanguage` | string | Primary language (no URL prefix). Defaults to the first entry in `languages`, or `en` |
| `languages` | array | Supported languages — the working set you author in |
| `publishLanguages` | array | Which declared languages ship in a published build. Absent = all of them |

### Language Formats

```yaml
# Codes — each language is named from its code
languages: [en, es, fr]

# Auto-discover from locales/ folder
languages: '*'
```

A language is named by its code: a switcher shows `Español` for `es`, from a built-in table, and any other code in its own language where the platform knows it (`ca` is `Català`). A `{ code, label }` entry is an error — a label cannot travel when a site is pushed to a backend.

### Draft Languages (`publishLanguages`)

`languages` is your working set; `publishLanguages` declares which of them a published build actually ships. A declared language you're still translating stays fully previewable in `uniweb dev` but is excluded from production output until you add it to the list:

```yaml
languages: [en, fr, de]        # working on all three
publishLanguages: [en, fr]     # de is still a draft — dev-only
```

Rules:

- **Absent field** — every declared language is published (the default, and the behavior of all existing sites).
- **Published builds** exclude unlisted languages everywhere a visitor could see them: no `dist/{locale}/` output, no locale-switcher entry, no sitemap or `hreflang` references. To visitors, a draft language is indistinguishable from an undeclared one.
- **`uniweb dev` ignores the list** — drafts render normally so you can work on them, the same way `hidden` pages stay previewable in dev.
- **The default language must be published.** A `publishLanguages` that excludes the effective default (or an empty list) fails the build with a clear error.
- **Codes not in `languages` are kept but inert.** If you remove a language from `languages` while it's listed in `publishLanguages`, the entry stays (with a build warning) — re-declaring the language later restores it as published without touching the list again.
- `uniweb i18n` commands keep operating on the full declared set — drafts are exactly what you're translating.

### Translation Workflow

Translations are extracted and managed through a hash-based system:

```bash
uniweb i18n extract    # Extract translatable strings
uniweb i18n sync       # Detect changes
uniweb i18n status     # Check coverage
```

This generates `locales/manifest.json` with all translatable content, and you provide translations in `locales/{locale}.json` keyed by content hash.

### Generated Routes

| Page | Default Locale | Other Locales |
| --- | --- | --- |
| Home | `/` | `/es/`, `/fr/` |
| About | `/about` | `/es/about`, `/fr/about` |

See [Internationalization](../development/internationalization.md) for the full guide.

### Advanced i18n Options

Less common settings remain under the `i18n:` key:

```yaml
i18n:
  routeTranslations: ...
  localesDir: translations         # Default: locales
```

---

## Site Services

Search, form submissions, tracking, an assistant, accounts and live records are **services**, and a site configures all of them in one place — `services:`, one entry per service:

```yaml
services:
  search:                                   # on, with options
    exclude: { routes: [/legal] }
  submit: https://forms.example.com/f/abc   # a provider you bring
  tracking: { consent: required }
  backend: true                             # ask your host for the site's own backend
  records: true                             # ask your host to deliver your records live
```

Each entry is one of:

| value | means |
| --- | --- |
| `true` | on — your host's, where it offers the service; on a static site, the built-in provider if there is one (search has an index) |
| `false` | off |
| an address — a string, or `endpoint:` in a map | on, answered by a provider you bring — on a host, your host is asked to leave its own off |
| a map | on, with options; `enabled: false` inside turns it off and keeps them |

Anything else stops the build and the push, naming the entry and what to write — an empty entry, a number, a list. ⚠️ **`yes`, `no`, `on` and `off` are words here, not switches**: YAML reads them as text, and text is an address. Write `true` or `false`.

- **On a site your host publishes, a service is off unless `services:` asks for it.** List the services your site uses; the official templates list theirs.
- **An entry says everything about its service.** An option you remove from it is removed from your site on the next push. `enabled` is the switch, never an option.
- **`records` delivers your records live to your published pages.** A query whose records have a data schema is answered by it on a host that offers it; without it, those pages show no records there. A static build reads records from the files it writes, and ignores the entry. Pushing and pulling records works either way.

**On a site you push or publish, `services` is also what you ask your host for.** `uniweb push` and `uniweb publish` send each service `site.yml` lists, as it says it, and switch off one you have removed from the file. A service your site has that this copy has never seen — one turned on in the Uniweb app since your last pull, say — is left as it is. If a service you changed was also changed on your site since your last pull, the push stops and names it: run `uniweb pull`, then push again. `uniweb publish` also says when your pages show records and `services` does not ask for `records`. `uniweb pull` writes the services your site has into `services` — one that is off, with no options, only where `site.yml` already names it.

**Everything but a credential is public.** An entry's options reach the built site — except `backend`'s settings, which only your host reads. A credential (`apiKey`, `token`, `secret`, …) is never sent or published: set it in the app, where secrets are kept.

The services are documented one by one below. For how a component asks for one, and what a host's answer means, see [Site Services](./site-services.md).

> The top-level `search:`, `submit:`, `assistant:`, `tracking:` and `api:` keys are retired. The build stops on them and says where each one moves. So does `api` under `services:`, which is `backend` now, and `$devApi:`, which is `$devBackend:`.

---

## Search

`services.search` — built-in full-text search. It is **on by default**; write it to turn it off, to set an option, or to ask your host for it on a site you publish.

```yaml
services:
  search: true       # on
```

```yaml
services:
  search: false      # off
```

### Full Options

```yaml
services:
  search:
    include:
      pages: true
      sections: true
      headings: true
      paragraphs: true
    exclude:
      routes: [/admin, /draft]
      components: [CodeBlock]
```

A search server of your own is an address — `provider: endpoint` with `endpoint:` — and on a host it asks your host to leave its own search off. See [Site Search](../authoring/search.md) for details.

---

## Agent-Readable Artifacts

Every build emits an index and per-page markdown that an AI agent can read directly. **Free and on by default** — this block only turns things off or narrows them.

```yaml
agents:
  index: true            # /llms.txt — an annotated index of the site
  markdown: true         # /{route}.md — each page as clean markdown
  branchIndexes: true    # /docs/llms.txt for branches large enough to want one
  branchMinPages: 5      # how large is large enough
  exclude: [/internal]   # keep a branch out of both (cascades to its subtree)

agents: false            # or turn the whole capability off in one word
```

`exclude` takes route path prefixes and excludes the whole branch. The leading slash is optional and a trailing one is ignored, so `/internal`, `internal` and `/internal/` are the same. Matching is by whole segment — `/internal` excludes `/internal/pay` and does **not** exclude `/internally-facing`. **Wildcards are not supported**: `/internal*` matches nothing and silently excludes nothing.

`seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, parametric page templates and [knowledge pages](./page-configuration.md#agent-content-knowledge) are excluded already.

### Expected origins

If the site runs an agent, declare where its requests are expected to come from. This suits a site that exists to serve someone else's app — a chat endpoint your web app calls — rather than its own visitors.

```yaml
agents:
  expectedOrigins:
    - https://app.example.com
    - https://partner.example.org
```

**It is a declaration, not a gate.** 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 among them, before spending anything on an answer. Where it is honoured:

|  |  |
| --- | --- |
| not declared | no check at all — nothing happens unless you ask for it |
| your own site's origin | always accepted, without listing it |
| a request with no `Origin` | **accepted** — see below |
| matching | exact, per entry; no wildcards |

> ⛔ **This is not a spend limit and cannot be made into one.** `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. It stops **casual misuse and third-party embedding**, such as someone dropping your endpoint into their own page. It does not stop anyone determined to run up your bill. Do not size a budget on it.

A request with no `Origin` is accepted deliberately, because there are two shapes of caller and this sees only one: your app's **JavaScript** sends `Origin` and is covered; your app's **server** never sends one, and refusing it would break the integration this partly exists for. Server-to-server callers are authenticated by arrangement with your host.

> **Never put a shared secret in `site.yml`.** Everything in it is delivered to every visitor as part of the site payload.

Unknown keys in `agents:` are forwarded to your host as opaque data and simply never acted on, so a typo is silent. `uniweb doctor` flags them.

---

## Accounts

A site can have its own backend — the `backend` service: accounts, per-visitor data, content its members create. On a site you publish, ask your host for one:

```yaml
services:
  backend: true
```

Its address comes from your host. Components never see it: they ask [`@uniweb/api`](https://www.npmjs.com/package/@uniweb/api) — `isBackendEnabled()` is `false` on a site without the service — so a foundation works unchanged on a site with it and on a site without it. A backend you run yourself is an address — `backend: https://your-backend.example/_api` under `services:` — and asks your host to leave its own off.

> **This is the reference for the key.** For how to *build* one — sessions, per-visitor data, what the site does without one, and where a demo fixture belongs — see [Sites with Accounts](../development/sites-with-accounts.md).

### Developing against it: `$devBackend`

Building an app against a live service is slow, and it puts a shared database behind your experiments. Name a local handler instead:

```yaml
services:
  backend: true              # in production, your host's
$devBackend: ./mock/api.js   # in `uniweb dev`, this answers it
```

`./mock/api.js` default-exports a function that takes a `Request` and returns a `Response`:

```js
export default (request) => Response.json({ entities: [], matched: 0 })
```

In `uniweb dev` the dev server answers the site's `backend` service with it, at an address of its own — your site never names one, so nothing in your foundation knows which backend it is talking to. Because it is **same-origin**, sign-in cookies behave exactly as they will in production.

Anything can sit behind it: a hand-written stub, recorded fixtures, or your real service running as a function. If you are building against Uniweb's own `backend` service, `@uniweb/api` ships one that speaks it:

```js
// mock/api.js
import { createMockBackend } from '@uniweb/api/mock'
export default createMockBackend({ seed }).fetch
```

> **`$devBackend` is never published.** Keys beginning with `$` are local to your working copy and are stripped from the built site, so a visitor cannot reach your local handler and a build cannot ship it by accident. Delete the line and the site still works — it simply has no `backend` service in development, and the features that need one disappear rather than break.

The `conference` template is a worked example — a programme that reads as a static site, and becomes an editable app for the people running the event:

```bash
uniweb create my-event --template conference
```

## Form Submissions

`services.submit` — where this site's forms send their submissions.

```yaml
services:
  submit: true                               # your host's form handling
```

```yaml
services:
  submit: https://forms.example.com/intake   # a form service you bring
```

A form's destination comes from the first of these that applies:

1. **Your host's** — where your host handles submissions, that is where they go. `submit: true` asks for it on a site you publish.
2. **An address of your own** — for a static site, or a host that does not handle submissions. On a host that does, it asks your host to leave its own off, so yours answers.
3. **Neither** — there is nowhere to send a visitor's answers, so a component draws no form, or shows contact details the site already carries.

### Full Options

```yaml
services:
  submit: /forms                             # an address on your own site
```

```yaml
services:
  submit:
    endpoint: /forms                         # the same, as a map
```

A relative endpoint resolves against the site's `base:`, so one spelling works whether the site is served from the root or from a subdirectory. An absolute URL is used as written.

See [Receiving Form Submissions](../development/receiving-form-submissions.md) for the component side.

---

## Tracking

Where this site's usage events go. One destination, one stream: page views and anything a component reports share it.

```yaml
services:
  tracking: https://collector.example.com/events
```

Off unless you set it. With no destination configured, nothing is collected and nothing is sent — components that report events simply do nothing.

### Full Options

```yaml
services:
  tracking: /collect                            # an address — a path on your own site
```

```yaml
services:
  tracking:
    endpoint: https://collector.example.com/events   # the same, as a map
    consent: required                           # hold everything until the visitor agrees
    scripts:                                    # a vendor's own script — see below
      - https://vendor.example.com/tag.js
```

A relative endpoint resolves against the site's `base:`, the same way a form's and search's do. An absolute URL is used as written.

On a site you publish, `tracking: true` asks your host to collect for you, and the address comes from the host. An endpoint of your own means you collect yourself, and asks your host to leave its collector off.

> **The endpoint is yours to provide.** Events are sent in the format below, so the destination has to be something that accepts it — your own collector, a function you deploy, or an endpoint your host offers. A third-party analytics product's public API expects that product's own format and will not understand these events. Some accept the request and discard the contents, which looks exactly like success.

### What your collector receives

One `POST` per flush, batched, with `Content-Type: application/json`:

```json
{
  "events": [
    { "event": "page_view", "visit": "b3f1…", "path": "/about", "first_of_load": true, "referrer": "https://news.example/post" },
    { "event": "page_view", "visit": "b3f1…", "path": "/pricing", "referrer": "https://news.example/post" },
    { "event": "video_milestone", "visit": "b3f1…", "path": "/about", "section": "Hero", "milestone": 50 }
  ]
}
```

Reply with any 2xx. The response body is ignored and a failed send is never retried — the last flush of a visit goes out as the page unloads, where there is nothing left to retry with.

**Every event carries `event`, `visit` and `path`.** Components add their own fields beyond that, and the framework constrains none of them.

**`visit`** is an opaque value generated when the page loads, so your collector can tell that a set of events came from the same page load and reconstruct what someone did in what order.

### The events

Three events are sent automatically, with no component involvement.

`page_view` goes out on every route change, including the first:

- **`path`** is site-relative — a site deployed at `/docs/` reports `/about`.
- **`referrer`** is the external page the visitor arrived from, if any. Same-site navigation is not a referral and is not reported.
- **`utm_source`, `utm_medium`, `utm_campaign`** ride along when the visitor arrived with them.

**`first_of_load`** appears on the one `page_view` that opened the document, and on no other. A site is a single-page app: after the first load, moving between pages sends more `page_view` events without loading anything, and those do not carry it. It is sent **only when true**.

It exists so a collector that stores nothing per-event can still answer questions about the page load. With it, "which page did this load start on" is a single field on a single event — no need to remember which `visit` values you have already seen. ⚠️ It says *first view of this page load*, never *this visitor's first ever view*: nothing in this system survives the document, so that second question cannot be asked at all.

**`continues`** appears on a `page_view` when the visitor was already on your site and something loaded a fresh page rather than navigating within the app — switching language is the usual cause. It is sent **only when true**.

It exists because that case and a genuine first arrival otherwise look identical: a visitor arriving from elsewhere carries a `referrer`, and a visitor coming from one of your own pages has that referrer dropped (your site should not appear as its own top referrer) — leaving both with nothing. Without this field, a "landing pages" report counts every language switch as somebody arriving on the translated page.

⚠️ It says only *this page load continues a visit* — never which one, and nothing links the two. And a site that sends `no-referrer` suppresses the signal it is derived from, so the field will not appear there.

⛔ **`continues` describes the page load, not the individual view — so it is repeated on every `page_view` of that load.** A site is a single-page app: after the first load, moving between pages sends more `page_view` events without loading anything, and each one carries the same `continues` the load started with. Counting *every* view that lacks `continues` therefore counts one arrival again for every page the visitor then reads.

To build a landing-pages report: **count a `page_view` that carries `first_of_load` and does not carry `continues`.** That needs no grouping and no stored state — the two fields on one event answer it.

⚠️ Three different situations leave `continues` absent and you cannot tell them apart: a genuine arrival, a site sending `no-referrer`, and a site still running a framework release from before the field existed. Requiring `first_of_load` keeps the third one safe: a site too old to send it is left out of the report entirely, which undercounts, rather than counted as an arrival on every page — an undercount you can describe, instead of a number that grows on its own as sites update.

Referrer and campaign values are read **once, when the page first loads**, and attached to each view of that visit — they exist in the URL only on arrival. So a per-view count of `utm_source` tells you *views by visitors who **arrived** via X*, never *views that carried X*. Same numbers, different sentence.

`outbound_click` goes out when a visitor follows a link that leaves your site. It carries **`hostname`** — the destination host and nothing else. The path and query string never leave the page, so a link someone shares with a search term or a token in it cannot deposit that into your analytics.

`section_view` goes out the first time a section is at least half visible, once per section per page view. It is **off unless a page asks for it** with `trackSections: true` in that page's `page.yml`, because every section of every page is a volume of data most sites have no question for.

Everything else is sent by components. Two ship with the framework's component kit, so they are worth recognising if you build a dashboard:

| event | sent by | fields |
| --- | --- | --- |
| `page_view` | the runtime, automatically | `path`, `first_of_load?`, `continues?`, `referrer?`, `utm_*?` |
| `outbound_click` | the runtime, automatically | `hostname` |
| `section_view` | the runtime, on pages with `trackSections` | `section`, `section_id` |
| `video_milestone` | `<Media>` playing a video | `milestone`, `src` |
| `read_depth` | `useReadingDepth()` in a long section | `depth` — 25, 50, 75, 100 |

Any other event name is one a component chose; there is no list of permitted names. See the AGENTS.md guide in your project for the component side.

### Choosing what is sent (`emit`)

A site sends `page_view`, `outbound_click` and `section_view` unless it says otherwise — or, where your host supplies the collector, whatever that host declares it collects (see *Where your host supplies the collector*, below):

```yaml
services:
  tracking:
    endpoint: https://collector.example.com/events
    emit: standard
```

`emit` stands on its own. Where your host supplies the collector you declare only what to send, and the address comes from the host:

```yaml
services:
  tracking:
    emit: minimal
```

The two are read key by key, so naming `emit` alone overrides nothing else the host declared. An `endpoint:` of your own means you collect yourself, and asks your host to leave its collector off.

| value | sends |
| --- | --- |
| `minimal` | `page_view` |
| `standard` | `page_view`, `outbound_click`, `section_view` — the default when the collector is your own |
| `all` | everything the framework emits, **including events added in later releases** |
| a list | exactly those event names, e.g. `[page_view, section_view]` |

`standard` and `all` select the same events today. They differ the moment a new automatic event ships: `all` picks it up, `standard` does not, so **a framework release never grows what a site sends**.

### Where your host supplies the collector

Naming `emit` is how you decide, and it always wins. **Saying nothing is different depending on who supplies the address:**

| your site | saying nothing about `emit` means |
| --- | --- |
| sends to its own `endpoint:` | `standard` — the curated set above. A later framework release never grows it |
| uses a host-supplied collector | **whatever that host declares it collects** |

The second case is the one to know about. A site on a host has no address of its own — the arrangement is that the host does analytics for it — so leaving `emit` unset means *whatever my host offers*, and the set grows if your host starts collecting something new. That is your host's decision, announced by them, and it is the same event that changes your bill or your quota.

**If you would rather pin it, name it.** `emit: standard` gives you exactly the three events above and nothing a host adds later; `emit: minimal` gives you one; a list gives you precisely what you write. Any of those beats the host's default, including narrowing to less than the host offers.

**`emit` does not limit what components send.** `block.track()` and `useTracker()` are unaffected — it governs only the events the framework emits on its own. A host that supplies your collector may narrow the list further if it will not store an event; it can never widen it beyond what you asked for.

### Batching (`flushIntervalMs`)

Events are batched and sent on a timer — every 5 seconds by default. A page view is sent immediately rather than waiting, and whatever is still queued is flushed when the page is hidden or closed.

```yaml
services:
  tracking:
    endpoint: https://collector.example.com/events
    flushIntervalMs: 30000      # milliseconds
```

Raising it means fewer, larger requests. The cost is **tail loss**: events from the last few seconds of a visit are more likely to be missed if the browser is closed abruptly, since the flush on hide is best-effort.

A host that supplies your collector may set this for you, and your own value overrides theirs — so this is mainly for a site pointing at a collector it chose, where there is no host to set it.

⚠️ **The unit is milliseconds, and the name says so on purpose.** `30` here is thirty *milliseconds*, not thirty seconds — which would flush about thirty times a second. A value that is not a positive number is ignored and the default is used.

### Third-party scripts

If you also use a vendor's own analytics or tag manager, name its script and the framework loads it:

```yaml
services:
  tracking:
    scripts:
      - https://vendor.example.com/tag.js
```

⭐ **This works on every host**, including Uniweb Cloud — the runtime loads the script, so nothing depends on who builds your pages. That is the practical difference from pasting the same tag into [`head.html`](#custom-head-injection), which only applies where the framework builds them. If you are declaring a vendor's tracking script, this is the place to put it.

You can add it alongside an endpoint of your own or on its own — the two are independent:

```yaml
services:
  tracking:
    endpoint: /collect        # optional — your own collector
    consent: required         # optional — gates both
    scripts:
      - https://vendor.example.com/tag.js
```

The vendor's script measures its own way, with its own storage, and reports to the vendor. Nothing is shared or translated between it and the events above.

What the framework guarantees about the load:

- **Once per page load**, as an `async` script in `<head>`.
- **Only after consent**, when `consent: required` is set — the script is not merely suppressed, it is never fetched.
- **Never inside an iframe**, so composing a site in an editing preview does not register as traffic.
- **Never during prerendering** — loading a script is a browser-only step.

A relative path resolves against the site's `base:`, the same as the endpoint.

Each entry is **a URL, and nothing else** — there is no field for inline script. Get it from your vendor's own install instructions.

> **Check that install snippet before relying on this.** Some vendors give you a single `<script src="…">` and nothing more, which is exactly what this loads. Others also run a few inline lines to configure themselves, and those lines have nowhere to go here — for that, use [`head.html`](#custom-head-injection), noting which hosts apply it.

### What is and isn't stored

> **Nothing is stored on the visitor's device.** No cookie, no local storage — the `visit` key lives in memory and is gone on refresh, in a new tab, or tomorrow. It identifies **one page load**, not a person: it cannot be linked to another visit, another tab, or another site. There is no visitor id, no session that spans days, and no fingerprint.

That is a trade, not only a property, and it is worth knowing before you compare numbers with another tool. Counting **unique people**, **sessions**, returning visitors or bounce rate all require storing an identifier on the device that outlives the page — which is how analytics products reporting those numbers get them. This stores nothing, so it counts page views and events and stops there.

### Consent

`consent: required` holds every event until a visitor answers. Nothing leaves the browser before then; agreeing sends what was held, declining discards it.

Without it, tracking starts as soon as the page loads. Declaring a destination is treated as your decision to track — the framework does not assume which laws apply to your site. The section above states what is and isn't stored, by this and by the alternatives, so that the decision is yours to make on the facts.

> **A consent banner is an ordinary component.** Because tracking now comes from the site itself rather than a third-party script, browser blockers and consent tools cannot intercept it — so if you need consent, use this option and a component that sets it.

---

## Build Options

Configure the production build.

```yaml
build:
  prerender: true
  splitContent: auto
```

### Options

| Option | Default | Description |
| --- | --- | --- |
| `prerender` | `true` | Generate static HTML for all pages (SSG) |
| `splitContent` | `auto` | Split page content into separate files for lazy loading |

When `prerender: true`:

- All pages are rendered to HTML at build time
- JavaScript hydrates for interactivity
- Fast initial load, SEO-friendly
- Pages work without JavaScript

When `prerender: false`:

- Single `index.html` with client-side rendering
- Pages render in the browser
- Smaller initial bundle

### Split Content

By default, all page content is embedded in every HTML file. For small sites this is fine — JSON compresses well. For content-heavy sites (large documentation, university websites), the payload grows to several megabytes, duplicated across every prerendered page.

`splitContent` separates page content into individual files (`_pages/*.json`) that are fetched on demand as the user navigates. Only the current page's content is embedded in each HTML file — other pages load lazily.

| Value | Behavior |
| --- | --- |
| `auto` | Split when total content exceeds 100KB (default) |
| `true` | Always split |
| `false` | Never split — all content inline in every HTML file |

With `auto`, a 5-page marketing site stays bundled (fast, no extra requests), while a 50-page docs site splits automatically.

Once a page's content is loaded, it stays cached for the rest of the session — returning to a visited page is instant with no additional fetch.

When [view transitions](../development/view-transitions.md#interaction-with-split-content) are active, the content fetch is hidden behind the transition animation — the user never sees a loading state.

---

## Site-Level Data Fetching

The site's own fetch, for data that belongs to the site rather than to one page:

```yaml
query: config               # same as fetch: { query: config }
```

The site is the root page. Its fetch reaches what a page's fetch would if the site were the parent of the pages directly under `pages/`: the layout areas (header, footer, sidebars) and the sections of the top-level pages, the homepage included. A section there receives the data when its component declares the key — `config`, or a key of the query's schema. A page further down, such as `/docs/setup`, does not receive it; a section there that needs the data names the query itself.

`query:` takes a query name or a list of names; anything more — `as`, `where`, `sort`, `limit`, `prerender` — is `fetch:`. Write one or the other, and not `data:`, the shorthand's former name. A site-level fetch cannot carry `current:`, which belongs to a section of a parametric page. See [Data Fetching](./data-fetching.md) for the full reference.

---

## Queries

Named questions over the site's records. The map can live here, under `queries:`, or in `queries.yml` at the site root; when both declare a query, `queries.yml` wins, key by key.

```yaml
queries:
  articles:
    schema: '@/article'
    sort: date desc
    where:
      featured: true

  team:
    schema: '@/person'
    sort: order asc
```

A page or section names a query — `query: articles` — and never the file a build generates from it. Every key a query can carry — `schema`, `scope`, `where`, `sort`, `limit`, `excerpt`, `queryable`, and `url:` for an external query — is in [Queries](./queries.md).

#### A record's link — `$route`

A query declares no link. Every record it delivers carries `$route`, the URL of the parametric page that shows it — `/blog/my-post` for a `pages/blog/[slug]/` page whose route query is `articles` — and a component links a card with `item.$route` rather than composing the URL itself. See [Parametric Pages → Linking to a record](./dynamic-routes.md#linking-to-a-record).

> **Removed:** `route:` on a query, which wrote a `route` field into every compiled record. The build stops on it.

---

## Placeholders

Values you write once and reference from any page.

```yaml
placeholders:
  product: Uniweb
  vendor:
    organization: Acme Studios
    email: billing@acme.example
```

Any page can then reference them by name:

```markdown
Questions? Write to {vendor.email} and we'll get back to you.
```

Use them for anything repeated across pages that you would otherwise have to change in a dozen files — a company name, a support address, a product name, a current season.

### How they resolve

A placeholder is an ordinary variable, so nested values use dot paths (`{vendor.organization}`) and a record's own field of the same name always wins. On a page showing an article, `{title}` is that article's title even if the site declares a `title` placeholder — the site says what is true of every page, the record what is true of one.

Values are carried as written: strings, numbers, booleans and lists all work.

### Your foundation has to resolve them

**Placeholder resolution is a foundation capability, not something every site gets.** A foundation opts in by declaring a content handler:

```js
// main.js
import { createLoomHandlers } from '@uniweb/loom'

export default {
  handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }),
  data: { profile: {} },   // the data keys `vars` reads — see Content Handlers
}
```

`vars` supplies the data-driven variables — a CV's fields, say — and placeholders sit beneath them; see [Content Handlers](../development/content-handlers.md). Without a content handler, nothing resolves `{…}` and your pages render the literal text `{vendor.email}`. The build warns when a site declares `placeholders:` under a foundation that has no content handler, so you find out at build time rather than on the page.

Once a foundation is wired, placeholders are the simplest thing you can write — but the same expressions can filter, sort, count and format live data. See [`@uniweb/loom`](https://www.npmjs.com/package/@uniweb/loom) for the full language.

---

## Custom Content Paths

By default, site content is read from standard directories relative to the site root: `pages/`, `layout/`, and `records/`. You can override these locations using the `paths:` group in `site.yml`:

```yaml
paths:
  pages: ../shared-content/pages
  layout: ../shared-content/layout
  records: ../shared-content/records
```

Paths are resolved relative to the site root. Absolute paths are also supported.

A `pages` directory with its own `folder.yml` or `page.yml` keeps the order and the homepage it declares there, exactly as it would mounted below the root — see [From the Pages Directory](#from-the-pages-directory).

### Per-Subfolder Mounting

You can mount individual page subfolders from external locations. This is useful when some pages live in a different repository (e.g., a docs submodule) while the rest are local:

```yaml
paths:
  pages/docs: ../../../docs
```

This makes the external `docs/` directory appear as the `docs` subfolder under `pages/`. The site's own `pages/` directory provides the rest of the pages. Multiple subfolder mounts are supported:

```yaml
paths:
  pages/docs: ../../../docs
  pages/blog: ../../../blog-content
```

#### Giving a mounted route a layout or a title

Add a local folder for the route holding a `folder.yml` — `folder.yml` rather than `page.yml`, because what is mounted is a folder of pages, and that filename is also what tells the build how to read the mounted tree:

```
site/
├── site.yml              # paths: { pages/docs: ../../../docs }
└── pages/
    └── docs/
        └── folder.yml    # layout, title, SEO for the branch
```

```yaml
# pages/docs/folder.yml
title: Documentation
layout: DocsLayout        # cascades to every page under /docs
```

**The two configs layer.** The mounted directory's own `folder.yml` supplies what your stub leaves out — its ordering, its title — and anything the stub declares wins. So a docs repository that orders its own sections keeps that order, and your site only states what it wants to differ.

If the mounted directory is empty — an unfetched git submodule, most often — the build says so rather than quietly producing a route with no pages under it. In a production build it fails outright.

### Use Cases

- **Separate content repo** — Content in a git submodule, maintained by a different team
- **Shared content** — Multiple sites reading from the same pages or records
- **Existing docs** — Point `pages` at an existing folder of markdown files
- **Mixed sources** — Some pages local, others from external repos via per-subfolder mounting

When `paths.records` is set, every file in that directory's schema folders is a record — for a build and for `uniweb push` and `pull` alike — instead of `records/` at the site root. The folder's organization, `folder.yml`, moves with it.

### Editing external content in dev

Every directory reached through `paths:` is watched, mounts included — editing a file in a mounted docs repository rebuilds and reloads exactly as editing a local page does. Nothing to configure. Files that arrive in bulk count too, so fetching a git submodule while the server is running brings its pages in without a restart.

**Changing the paths themselves still needs a restart.** Watchers are set up once, from the `paths:` in effect at startup, so pointing a mount somewhere new — or adding one — takes hold on the next `uniweb dev`.

---

## Custom Head Injection

Place a `head.html` file in your site root to inject HTML into `<head>` on every page. The file contents are inserted verbatim — no processing, no YAML wrapping.

```
site/
├── site.yml
├── theme.yml
├── head.html      ← optional, injected into <head>
├── pages/
└── layout/
```

**Common uses:** analytics and tag managers, error monitoring (Sentry), third-party widgets, custom meta tags, font preconnects.

> **`head.html` applies when the framework builds your pages** — `uniweb export`, or `uniweb deploy --host=<adapter>`. On **Uniweb Cloud** (`uniweb publish`) the pages are generated by the host from your synced content, so `head.html` is **not applied there**. The file is still kept and synced, so nothing is lost — but a script placed in it will not run on that host.

> ⭐ **If what you are adding is a vendor's tracking script and their snippet is a single `<script src="…">`, put the URL in [`tracking.scripts`](#third-party-scripts) instead.** Same script, same vendor, same data — but the runtime loads it, so it works on **every** host rather than only the ones where the framework builds your pages. Reach for `head.html` when the vendor's snippet also runs inline configuration lines, which have nowhere to go in a URL field.

> **And separately, on what to measure with.** A vendor's script sets its own cookies, which is what lets it report sessions and unique users; it counts page loads, so in-app navigation needs its own handling. [`services.tracking`](#tracking) loads no script and stores nothing on the visitor's device, reports every route change, and sends to an endpoint you provide — so it counts page views and events but not users or sessions. Different numbers, not better ones; many sites run both.

> For social/SEO meta (Open Graph image, title, description, canonical, robots), use the structured [`seo:` block](#seo--social-sharing) instead — the runtime renders those into every page's `<head>`. Reserve `head.html` for everything else.

### Example: Google Analytics

```html
<!-- site/head.html -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
```

Replace `G-XXXXXXXXXX` with your Measurement ID from Google Analytics.

### How it works

- The build reads `head.html` and injects it before all other head content (theme CSS, SEO tags, site data).
- In dev mode, changes to `head.html` trigger a page reload automatically.
- In production, the content is baked into every pre-rendered HTML file.
- If the file doesn't exist, nothing is injected — there's no error.

**Note:** For Google Fonts, you don't need to add preconnect links manually. When your `theme.yml` includes font imports, the build injects `<link rel="preconnect">` tags automatically. See [Site Theming → Typography](./site-theming.md#typography).

---

## Complete Example

```yaml
# site.yml

# Identity
name: Acme Corp
description: Building the future of widgets

# Structure
pages: [home, products, about, ..., contact]

# Languages
defaultLanguage: en
languages: [en, es]

# Layout — cascades to every page; folder.yml and page.yml override per field
layout:
  name: DocsLayout
  hide: [right]

# Services
services:
  search: true

# Build
build:
  prerender: true

# The site's fetch — layout areas and top-level pages
query: config

# Queries
queries:
  articles:
    schema: '@/article'
    sort: date desc

  products:
    schema: '@/product'
    sort: name asc
```

---

## See Also

- [Page Configuration](./page-configuration.md) — page.yml reference
- [Queries](./queries.md) — Everything a query can say
- [Records](./content-collections.md) — `records/`, its `folder.yml`, and compiled records
- [Data Fetching](./data-fetching.md) — Naming a query from a page or section
- [Site Search](../authoring/search.md) — Full-text search setup
- [Internationalization](../development/internationalization.md) — Multi-language support