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:

questionanswer
records/{schema}/what does this site have?one file per record; the folder names its data schema
records/folder.ymlhow is it organized?optional — folders a query can read one of
queries.ymlhow 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 in records/ as well, one file per record (a .bib file 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.md

2. 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 desc

4. Name the query on a page

# pages/blog/page.yml
title: Blog
query: articles

The 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 diskschema
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.

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=abc

A 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 launch

A 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/ folderno recordsthe backend's records are left as they are
a records/ folderevery record in itevery record in it is sent
an empty records/ folderno recordsthe backend's records are removed — the CLI asks first

Records pushed to a backend are served once the site is published.

File Formats

File typeWhat it isA compiled record holds
.mda content record (article-like)slug, the frontmatter fields, content, excerpt, image
.yml/.yamla data recordslug and the fields you write
.jsona data record, or manyan object: slug and its fields. An array: each entry, as written
.bibbibliographic referencesone 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: general

A 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

FieldSourceNotes
$nameFilenamegetting-started.md → "getting-started". A slug: key in the file stops the build — rename the file instead
$nameslugThe record's handle — the same value as its final slug. A [slug] or [...path] page matches it, on every site
pathrecords/folder.ymlThe folder the record is placed in — "" at the root, "archive" inside a folder: archive. A query reads a branch with scope:
contentMarkdown bodyProseMirror JSON. When the query's schema declares a content field, the body is that field's value instead, where the schema puts it
excerptFrontmatter or bodyMarkdown records — see Excerpts
imageFrontmatter or bodyMarkdown records — see Images
your fieldsFrontmatter / fileEverything 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:

  1. the record's own excerpt: field;
  2. the frontmatter field the query's excerpt.field names, such as description;
  3. 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:

![Hero](./auto-detected.jpg)

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:

![Architecture Diagram](./getting-started-diagram.svg)

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.

PathResolution
./file.jpgThe record's folder
../shared/logo.svgRelative to the record's folder
/images/hero.jpgThe 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: articles

The [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.yml and records/folder.yml are watched too: an edit regenerates the query files and reloads, including a folder.yml created 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

SituationBehavior
A query's schema folder is missingWarning logged, the query has no records
A query matching no recordsWarning logged, the query answers []
Invalid frontmatter, YAML or JSONError naming the file, the file skipped
A records/folder.yml that is not a listThe build stops, naming the problem
A records.yml at the site rootThe build stops, naming the move to records/folder.yml
Two folders with one name at one level of records/folder.ymlThe build stops, naming both entries — merge them into one
A record with draft: trueLeft out of a build for hosting, kept in pnpm dev; pushed to a backend, kept there without being delivered
A record with published: falseThe build stops, naming draft: true
Sorting by a field some records lackThose 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.ymlReported as an error; every record in records/ is one already
A folder three or more levels deepSkipped, 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:

TypeDefaultOverride with
stringtranslatabletranslatable: false to skip
string + enumNOT translatabletranslatable: true to include
markdownalways translatable—
number, boolean, date, datetimenever—
url, email, imagenever—
objectrecurse into fields—
arrayrecurse into items—

Schema discovery order:

  1. Companion file: records/<query name>.schema.js
  2. Standard schema: a matching name in @uniweb/schemas (with simple singularization — events matches the event schema)
  3. 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