Records
Blog posts, team members, schedules, references — any repeating structured content — are records: one file each in records/, reached by a query. .md holds rich content with body text, .yml/.yaml pure structured data, .json data exported from another tool, and .bib bibliographic references.
This page covers the records themselves: where they live, how they are organized, and what a compiled record holds. What a query can say about them is Queries; how a page names a query is Data Fetching.
The Data Layer
Three files answer three separate questions, and keeping them apart is the whole design:
| question | answer | |
|---|---|---|
records/{schema}/ | what does this site have? | one file per record; the folder names its data schema |
records/folder.yml | how is it organized? | optional — folders a query can read one of |
queries.yml | how is it reached? | named queries; a page names one |
A page names a query, never a file. A site with no backend reads the file a build generates from the query in public/data/; the same site published to a host that serves records live reads them from there, with nothing in the page changed.
There are two ways to provide data to components:
Records (records/) — author content as files. Markdown records get ProseMirror content bodies, excerpts, and co-located assets automatically; YAML and JSON records pass through as they are. Use .md for content with body text (blog posts, case studies), .yml or .json for purely structured data (schedules, pricing tiers).
External queries (url: in queries.yml) — for records another system holds and serves as public JSON. Components receive them the same way, through content.data. See Queries → External queries.
Rule of thumb: if authors maintain the content, use records in records/. If it comes from an external system at request time, use an external query.
Don't write to
public/data/. It is the build's output directory. A file you put there is overwritten without warning as soon as a query takes the same name, and it gets none of what a record provides — no i18n extraction, no schema validation, no per-record files, no editor support. Data exported from another tool belongs inrecords/as well, one file per record (a.bibfile holds its entries, each named by its cite key).
Quick Start
1. Put records in a schema folder
Every file in records/ is a record. The folder names the data schema — records/article/ is @/article, your foundation's own; records/std/article/ is @std/article, a shared standard.
site/
└── records/
└── article/
├── getting-started.md
├── design-patterns.md
└── advanced-features.md2. Write content with frontmatter
---
title: Getting Started with Uniweb
date: 2025-01-15
author: Sarah Chen
tags: [tutorial, beginner]
---
Learn how to build your first site with Uniweb.
## Installation
First, create a new project...3. Declare a query in queries.yml
# queries.yml
articles:
schema: '@/article'
sort: date desc4. Name the query on a page
# pages/blog/page.yml
title: Blog
query: articlesThe query's records reach the page's sections whose component declares articles. On a site with no backend, the build generates public/data/articles.json from the query, and that is what the page reads. To show fewer — fetch: { query: articles, limit: 3 } — see Data Fetching → Narrowing a query.
The records/ Folder
Every file in a schema folder of records/ is a record — putting it there is what makes it one. The folder it sits in names its data schema:
| on disk | schema |
|---|---|
records/article/getting-started.md | @/article — the foundation's own |
records/std/person/alice.yml | @std/person — the shared standard set |
records/acme/project/cinder.json | @acme/project — an organization's |
A file whose name starts with _ is not a record, and neither is anything in a folder whose name does — a place for work in progress. The one file at the top of records/ is folder.yml, its organization; any other file there names no schema and is reported. site.yml can move the folder, folder.yml included: paths: { records: ../shared/records }.
Keep schema folders flat. A folder two levels deep names an organization's schema, so records/article/2025/design-tips.md is read as the 2025 schema of an article organization — outside every @/article query. Files three or more levels deep are skipped, with a warning. To group records, place them in folders in records/folder.yml instead.
A data schema gives records a typed shape, used for validation and i18n extraction — and it decides how a record is written and what a component receives: a schema of one section is written flat, any other by section, and a component receives each record as its brief — the brief's fields at the top — or, when it declares its key whole: true, whole, each section under its own name (Entity Content → What a component receives). A list carries briefs, so an article's body is never in its list.
Link records
A link — a video, a page elsewhere, a document online — is a record of @uniweb/link, a schema every site has without declaring one. Put one file per link in records/uniweb/link/, holding its url and nothing else:
# records/uniweb/link/launch-video.yml
url: https://www.youtube.com/watch?v=abcA link's display text and tags are its folder entry's, as for any record — give them in records/folder.yml, where a label can be one text or one per language:
- path: uniweb/link/launch-video.yml
label: Launch video
tags: [media]A query selects links by that schema, and a component declares them the same way:
# queries.yml
videos:
schema: '@uniweb/link'// meta.js
data: { videos: '@uniweb/link' },Each link reaches the component as { url, $name, $label, $tags } — $label and $tags when its entry gives them. A link's url is absolute — a scheme and ://, like https://…; a relative path or a mailto: is refused when you push. A link record has no body and no other fields, and uniweb push sends no draft: true link: a link cannot yet be kept off a published site while it is in the site's folder.
A site that goes to a backend must have a component that declares the type — data: { videos: '@uniweb/link' } — for a query over it to be accepted.
File records
A file — a PDF, a spreadsheet, a download of any type — is a record of @uniweb/file. The file itself is the record: drop it into records/uniweb/file/, and it is a record named by its file name without the extension. Nothing in it is read, whatever its type.
records/uniweb/file/brochure.pdf → a record named `brochure`
records/uniweb/file/price-list.xlsx → a record named `price-list`Its display text and tags go on its folder entry, like any record's:
- path: uniweb/file/brochure.pdf
label: Brochure
tags: [print]A query selects files by that schema and a component declares them the same way (schema: '@uniweb/file', data: { downloads: '@uniweb/file' }). Each reaches the component as { file, $name, $label, $tags }, where file describes the asset:
{
file: { url: '/records/uniweb/file/brochure.pdf', name: 'brochure.pdf', mime: 'application/pdf', size: 48213 },
$name: 'brochure',
$label: 'Brochure',
$tags: ['print'],
}Link to file.url, and show file.name, file.mime or file.size as a downloads list needs. The build publishes each file for the site to serve; uniweb push uploads it with the site's other media, and a pull fetches it back into records/uniweb/file/. Two files with the same name and different extensions are one name too many — rename one.
Folders: records/folder.yml
records/folder.yml is optional. Folders exist so a query can read a branch, not to build a navigation tree, and most sites need none. Every record sits at the top of the site's records folder unless folder.yml places it in one:
# records/folder.yml
- folder: archive
label: The Archive # a folder's display text
records:
- article/2019-*.md # in archive
- path: article/2020-launch.md
tags: [featured, press] # what the folder says about this record
label: The launchA path under a folder is relative to records/, naming one file or matching many; * matches within one folder. A query reads one branch with scope: archive; a record does not carry its folder, and a folder is never part of a record's URL. The organization is yours to choose: it does not mirror the records/ layout, which names schemas and nothing else.
What the folder says about a record. A record's entry can be { path, tags, label } rather than a path. tags are the entry's own labels, kept in the order written, and label is its display text. A component receives them beside the record as $tags and $label — absent when the entry gives none — and a query reads them like any other key: where: { $tags: featured }. A pattern gives every record it matches the same tags. A folder takes tags: too.
A label in several languages. A label — a record entry's or a folder's — is one text, in the site's language, or one text per language:
- path: article/2020-launch.md
label: { en: The launch, fr: Le lancement }$label is the text for the page's language, else its base language (fr-CA → fr), else the site's language — and absent when the label has none of those.
A record sits in one folder. Placing the same file twice is an error, and the build names both entries. A computed subset — "this year", "the five most recent" — is a query, not a second placement.
folder.yml never lists records at the top level — every record in records/ is one already — so a path there is reported as an error. The exception is an entry that gives a record tags: or a label:: - path: news/welcome.md with tags: [pinned] keeps the record at the top and says what the folder knows about it. A folder.yml the build cannot read as a list — invalid YAML, or a mapping — stops the build.
It lives in the directory it organizes, the way a pages folder's folder.yml does: it moves with paths.records, and a records directory that several sites share brings its folders with it. A records.yml at the site root — where this file lived before — stops the build and names the move: git mv records.yml records/folder.yml.
With a backend
uniweb push sends the site's records, and the backend holds them in the site's folder:
on a build (uniweb build, pnpm dev) | on uniweb push | |
|---|---|---|
no records/ folder | no records | the backend's records are left as they are |
a records/ folder | every record in it | every record in it is sent |
an empty records/ folder | no records | the backend's records are removed — the CLI asks first |
Records pushed to a backend are served once the site is published.
File Formats
| File type | What it is | A compiled record holds |
|---|---|---|
.md | a content record (article-like) | slug, the frontmatter fields, content, excerpt, image |
.yml/.yaml | a data record | slug and the fields you write |
.json | a data record, or many | an object: slug and its fields. An array: each entry, as written |
.bib | bibliographic references | one record per @entry, its cite key as slug |
Every record also carries path, its folder in records/folder.yml, and $name, its handle. A single schema folder can hold all of them: some team members with a markdown bio, others as plain YAML.
Markdown
The frontmatter holds the fields, and the body becomes the record's content — or, when the query's schema declares a content field, that field's value, in the section that declares it (Entity Content → Markdown):
---
title: Getting Started with Uniweb
date: 2025-01-15
author: Sarah Chen
tags: [tutorial, beginner]
---
Learn how to build your first site with Uniweb.YAML
For records that are pure structured data — no body text, no excerpts, no images:
# records/schedule/keynote.yml
title: Opening Keynote
speaker: Ada Lovelace
time: "09:00"
room: Main Hall
track: generalA YAML record skips ProseMirror conversion, excerpt generation and image detection: it compiles to slug, path, $name and the fields you wrote.
JSON
A .json file holding an object is one record, its slug taken from the filename:
// records/person/alice.json
{
"name": "Alice",
"role": "Engineer",
"avatar": "/images/alice.jpg"
}A .json file holding an array contributes each entry as a record — useful for importing an existing dataset. Array entries should carry their own slug, since there is no filename to infer one from:
// records/product/catalog.json
[
{ "slug": "widget-a", "name": "Widget A", "price": 29 },
{ "slug": "widget-b", "name": "Widget B", "price": 49 }
]draft: true
A record with draft: true is still a record — it stays in records/ and in its folder — but it is not delivered: a build for hosting leaves it out of every query, in every format, entries of a YAML or JSON array included, and copies none of its files. pnpm dev keeps it, so you can preview it, and uniweb validate checks it. A record without the field is delivered.
uniweb push sends a draft as a draft: a backend keeps it in the site's folder and does not deliver it, and uniweb pull writes draft: true back into a record kept off the site. Remove the line to deliver it again. (published: false, an older spelling, is refused.)
What a Compiled Record Holds
| Field | Source | Notes |
|---|---|---|
$name | Filename | getting-started.md → "getting-started". A slug: key in the file stops the build — rename the file instead |
$name | slug | The record's handle — the same value as its final slug. A [slug] or [...path] page matches it, on every site |
path | records/folder.yml | The folder the record is placed in — "" at the root, "archive" inside a folder: archive. A query reads a branch with scope: |
content | Markdown body | ProseMirror JSON. When the query's schema declares a content field, the body is that field's value instead, where the schema puts it |
excerpt | Frontmatter or body | Markdown records — see Excerpts |
image | Frontmatter or body | Markdown records — see Images |
| your fields | Frontmatter / file | Everything you write passes through: title, date, author, tags, … |
A record of a data schema is compiled in both shapes a component receives: the query's list, /data/<query>.json, holds each record's brief — the brief's fields at the top — and a file of its own, /data/<query>/<name>.json, holds it whole, each section under its own name (Entity Content → What a component receives).
Once a query delivers a record to a component, it also carries $route, the URL of the page that shows it — see Parametric Pages → Linking to a record.
Example
[
{
"slug": "getting-started",
"title": "Getting Started with Uniweb",
"date": "2025-01-15",
"author": "Sarah Chen",
"tags": ["tutorial", "beginner"],
"excerpt": "Learn how to build your first site with Uniweb.",
"content": { "type": "doc", "content": [...] },
"path": "",
"$name": "getting-started"
}
]Excerpts
A markdown record's excerpt is the first of these that exists, cut to 160 characters by default:
- the record's own
excerpt:field; - the frontmatter field the query's
excerpt.fieldnames, such asdescription; - the body's plain text, cut at a word boundary with
....
The query sets the length and the field — see Queries → excerpt.
Images
A markdown record's image is its image: field, or else the first image in its body:
---
title: My Post
image: /images/hero.jpg # explicit, from the site's public/ folder
---
Or automatically extracted from:
Co-located Assets
A record can reference files stored beside it, with a relative path — the record and its images move together.
site/
└── records/
└── article/
├── getting-started.md
├── getting-started-diagram.svg
├── design-patterns.md
└── design-patterns-architecture.png---
title: Getting Started
---
Here's how the architecture works:
At build time a ./ or ../ path in a markdown record's body or frontmatter — or in a YAML or JSON record's field — is copied to public/records/, at its path under records/, and rewritten to that URL:
public/
└── records/
└── article/
├── getting-started-diagram.svg
└── design-patterns-architecture.png{
"slug": "getting-started",
"content": {
"type": "doc",
"content": [
{
"type": "image",
"attrs": {
"src": "/records/article/getting-started-diagram.svg",
"alt": "Architecture Diagram"
}
}
]
}
}A file keeps its place under records/: beside its record it is /records/article/pic.png, in a subfolder /records/article/img/pic.png, and a ../shared/logo.svg elsewhere in records/ is /records/shared/logo.svg — so two files that share a name never collide, and a record reachable by two queries has one copy at one URL. A file outside records/ is copied to /records/_external/, with a short hash of its path in front of its name.
| Path | Resolution |
|---|---|
./file.jpg | The record's folder |
../shared/logo.svg | Relative to the record's folder |
/images/hero.jpg | The site's public/ folder |
https://... | External URL (unchanged) |
Co-located assets work for any media — images, {role=video} videos, {role=pdf} documents.
Complete Example: Blog
site/
├── queries.yml # how it is reached
├── records/ # every file is a record — the folder names the data schema
│ └── article/
│ ├── getting-started.md
│ ├── design-patterns.md
│ └── advanced-features.md
└── pages/
└── blog/
├── page.yml # query: articles
├── 1-list.md # type: ArticleList
└── [slug]/
└── 1-article.md # type: Article# queries.yml
articles:
schema: '@/article'
sort: date desc# pages/blog/page.yml
title: Blog
query: articlesThe [slug] folder is a parametric page: it takes its parent's query, and each record gets a page — /blog/getting-started, /blog/design-patterns. Its sections receive the record the URL names as a list of one, content.data.articles[0]. Any other page can show a few of the same records — fetch: { query: articles, limit: 3 } — each linking to its page through $route.
Development and Build
During development (pnpm dev):
records/is watched: a record added, edited or removed regenerates the query files, and the page reloads.- The runtime fetches local records live, as it would from a host, so what you see matches production.
site.yml,queries.ymlandrecords/folder.ymlare watched too: an edit regenerates the query files and reloads, including afolder.ymlcreated while the server runs.
During a production build (pnpm build), each query compiles to public/data/<query>.json before the Vite build, and the files are included in dist/.
Edge Cases
| Situation | Behavior |
|---|---|
| A query's schema folder is missing | Warning logged, the query has no records |
| A query matching no records | Warning logged, the query answers [] |
| Invalid frontmatter, YAML or JSON | Error naming the file, the file skipped |
A records/folder.yml that is not a list | The build stops, naming the problem |
A records.yml at the site root | The build stops, naming the move to records/folder.yml |
Two folders with one name at one level of records/folder.yml | The build stops, naming both entries — merge them into one |
A record with draft: true | Left out of a build for hosting, kept in pnpm dev; pushed to a backend, kept there without being delivered |
A record with published: false | The build stops, naming draft: true |
| Sorting by a field some records lack | Those records sort last, in either direction |
A folder two levels deep in records/ | Names an organization's schema |
A path at the top level of records/folder.yml | Reported as an error; every record in records/ is one already |
| A folder three or more levels deep | Skipped, with a warning |
Translating Record Data
Record text is extracted for translation alongside page content (see Internationalization), using one of two strategies to find translatable strings.
Schema-guided extraction
Provide a companion schema file in records/, named for the query whose records it describes. It tells the extractor exactly which fields contain translatable text:
records/
├── events/
│ └── events.json
└── events.schema.js # for the `events` query// events.schema.js
export default {
name: 'event',
fields: {
title: { type: 'string' }, // Extracted (string → translatable by default)
description: { type: 'markdown' }, // Extracted (markdown → always translatable)
slug: { type: 'string', translatable: false }, // Skipped (explicit opt-out)
type: { type: 'string', enum: ['workshop', 'talk'] }, // Skipped (enum → not translatable)
startDate: { type: 'datetime' }, // Skipped (datetime → never translatable)
location: {
type: 'object',
fields: {
name: { type: 'string' }, // Extracted (nested string)
url: { type: 'url' }, // Skipped (url → never translatable)
}
},
tags: {
type: 'array',
items: { type: 'string' } // Extracted (array of strings)
},
}
}Type-based defaults:
| Type | Default | Override with |
|---|---|---|
string | translatable | translatable: false to skip |
string + enum | NOT translatable | translatable: true to include |
markdown | always translatable | — |
number, boolean, date, datetime | never | — |
url, email, image | never | — |
object | recurse into fields | — |
array | recurse into items | — |
Schema discovery order:
- Companion file:
records/<query name>.schema.js - Standard schema: a matching name in
@uniweb/schemas(with simple singularization —eventsmatches theeventschema) - No schema found → heuristic fallback
Heuristic extraction (no schema)
When no schema is found, the extractor walks the record data and extracts every string that looks like human-readable text. It skips:
- Structural field names —
slug,id,type,status,href,url,email,icon,target, dates, etc. - Structural string patterns — URLs, email addresses, ISO dates, hex colors, file paths, currency codes, plain numbers
This works well for most data but may occasionally include strings you don't want translated (or miss strings you do). For precise control, provide a companion schema.
Record identification
Records are identified by slug, id, or name (checked in that order). If none is found, the record is labeled unknown in the manifest. Make sure your records have at least one of these fields for clear translation context.
See Also
- Queries — which records a query selects:
schema,scope,where,sort,limit - Data Fetching — naming a query from a page or section
- Parametric Pages — one page per record
- Working with Records — the author's guide to records and queries
- Entity Content Structure — how a record of a multi-section schema is written
- Content Structure — how markdown content is parsed