npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uniweb/schemas

v0.12.0

Published

The Uniweb data-schema format, its standard schema definitions, and the standard section families and site tags

Readme

@uniweb/schemas

Uniweb's standard names, and the language they're written in. Six things, and it helps to know which one you're reaching for:

  • The data-schema format — the language schemas are written in: its type vocabulary, the normalizer that folds friendly type names to canonical kinds, and the conformance checker that validates a record against a schema.
  • The standard schemas — a shared vocabulary of common content types (person, article, event, …) written in that format, referenced as @std/<name>.
  • The section families — the standard section types a component can claim with family: in its meta.js, so an editor can show the right illustration and a label it can translate. Section families.
  • The site tags — the standard words for what a site is for, declared with tags: in site.yml, so a list of sites can be filtered and labelled the same way everywhere. Site tags.
  • The content declaration — a reader for the content: block a section type writes, so a tool can show what a component expects without learning the grammar. The content declaration.
  • Starter content — a component's content: declaration turned into something an author can edit instead of an empty box. Starter content.

A data schema describes a structured content type: its fields and their types. Write the shape once and it does three jobs — uniweb validate checks your data before it ships, a fetch of the type reaches the components that declare it, and the visual editor renders a form authors fill in. A schema never changes a record: a record reaches a component as it is, and a field it lacks is absent.

pnpm add @uniweb/schemas

Add it wherever a @std/<name> ref is used. Foundations that only use @/-refs (their own schema files) or inline schemas don't need it.


Binding a schema

A foundation component declares its structured-data shape with a single data: key in meta.js. Each entry maps a content.data key to a schema:

// foundation/sections/TeamGrid/meta.js
export default {
  title: 'Team Grid',

  data: {
    team:    '@/member',                                  // named ref (this foundation)
    authors: '@std/person',                               // named ref (shared standard)
    specs:   { cpu: { type: 'string' } },                 // inline field map
    signup:  { fields: [{ id: 'email', type: 'text' }] }, // inline rich-form (editor form)
  },
}
  • The key (team) is the content.data key — where the data lands. The site, author, or editor decides how that key gets filled (a fetched collection, a tagged code block, an editor form); the schema is the same regardless of source.
  • The value is a named ref, an inline field map, or an inline rich-form (distinguished by a fields array rather than a keyed object — it drives the editor's form UI).

A data: declaration is what the section receives: content.data holds the keys the component declares and nothing else, each filled by whatever reaches the section under that key — or null when nothing does. A component with no data:, or data: false, receives no keys of its own.

There is no separate schemas: key, no entity: field, and no inheritData — all folded into this one data: surface.


Three namespaces

A ref names a namespace, never a package path:

| Ref | Namespace | Resolves to | |---|---|---| | @/member | self — this foundation | foundation/schemas/member.{js,json,yml,yaml} | | @std/person | shared standards | the matching standard in this package | | @acme/event | an org | that org's @acme/schemas package (a workspace package locally; a registry scope once published) |

The empty scope in @/member means "this foundation" — the scope in the foundation's own name (@acme/marketing → @acme/member). A foundation's code never needs its org: @/-refs are portable and travel with the foundation, and uniweb register fills in the scope. The name, scope included, is the foundation's identity for registering and editing, and never reaches the bundle a page loads.

@uniweb is reserved for the platform system namespace and is not a data-schema source — use @std for shared standards.

Refs resolve on disk at build time. Nothing is fetched.

Routing a scope elsewhere — schemas.config.js

A foundation can point a scope at a plain folder of schema files, or override a single schema to an exact file — no package, no install:

// <foundation>/schemas.config.js
export default {
  '@agency':        '../shared/agency-schemas',    // scope  → a directory
  '@agency/person': './schemas/agency-person.yml', // schema → an exact file
  '@brand':         process.env.BRAND_SCHEMAS,     // machine-specific, via env
}

Most specific wins: file › directory › package. Relative paths resolve against the foundation source dir; a key whose value is null/undefined (an unset env var) is skipped and falls through to the next source. A routed scope does not fall back to the @org/schemas package — failing loudly beats silently loading a different definition. @/ and @uniweb are never routable.


Writing a schema

A schema file lives in your foundation's schemas/ folder and may be .js, .json, .yml, or .yaml. It declares either fields: (one flat record — the common case) or sections: (a structured type), never both.

# foundation/schemas/member.yml   — referenced as '@/member'
name: member
version: 1.0.0
description: A research group member
fields:
  name:       { type: string, required: true }
  role:       { type: string }
  rank:       { type: string, enum: [assistant, associate, full] }
  tenured:    { type: boolean }
  start_year: { type: number }

name and version are the schema's identity — which named schema at which version a foundation depends on. They are not content.data keys; the content.data key is whatever the section's data: binding names it.

| Schema key | Meaning | |---|---| | name | Schema identity (short name) | | version | Schema version | | label | A display name, for people | | plural | What many entries are called — label: Person, plural: People | | icon | The type's icon — a framework icon name written family-name: lu-user is the icon user of the family lu. One value for every language | | description | Human-readable description | | fields | A flat record's fields — xor sections | | sections | Named sections of a structured type — xor fields | | sort_date | Names a date field records sort by (see The sort axis). sortDate is an accepted alias |

Field types

The friendly type you write folds to a small set of canonical kinds. Write the word that fits the content — or write the canonical kind directly; both work.

| You write | Canonical kind | Holds | |---|---|---| | string | string | A short, single-line value | | text | text | Long-form text | | number | decimal | A number | | integer | int | A whole number | | boolean | bool | true / false | | date / datetime | date / datetime | An ISO-8601 date / timestamp | | image | file | A path or URL to a file | | url / email | string + format | A validated string | | markdown / html | text + format | A rich-content body (a source string) | | richtext | json + format: prosemirror | A rich document edited in the visual app | | json | json | An opaque structured value | | object, group | object | A nested record — declare fields: or values:. group is the author-facing spelling | | array | array | A list — declare items: (or just use many:). Without items: the element type is genuinely unknown, and a registered schema records it as opaque json rather than guessing | | ref | ref | A reference to another schema — { ref: '@/person' } |

A bare type string is shorthand for { type: … }: title: string is title: { type: string }.

Lists — many: true

Any field or section becomes a list by adding many: true. This is the idiom to reach for; array + items is the lower-level form it normalizes to.

fields:
  tags:    { type: string, many: true }          # a list of strings
  courses: { ref: '@/course', many: true }       # a list of references
  results:                                       # a list of records
    type: object
    many: true
    fields:
      metric: { type: string }
      value:  { type: string }

Collection-level metadata (required, label, help, description) rides on the list; the type-bearing keys (type, ref, options, enum, fields, items, format) describe each item.

One limit worth knowing about required. It is enforced on a list of values — { type: string, many: true, required: true } — and on a list of references. It is not enforced on a list of records, or on a nested object: those become sections when the schema is registered, and required binds the record that is written — it cannot force a record to exist. Put the flag on a field inside the record, where it holds, and reach for min_items below when you mean "don't let this become empty".

Nested records and open maps

An object field describes its shape one of two ways, and they answer different questions:

fields:
  address:                          # KNOWN keys
    type: object
    fields:
      street: { type: string }
      city:   { type: string }

  controls:                         # an OPEN MAP — keys belong to the author
    type: object
    values:
      type: object
      fields:
        type:  { type: string, required: true }
        label: { type: string }
  • fields: — the object's known keys.
  • values: — a map whose keys are the author's and whose values all conform to one shape. values is to an object what items is to an array.

Declaring both is an error. Reach for values: whenever the keys are the author's rather than yours — a set of per-region overrides, a bag of feature flags, anything a fields: list could not enumerate because the names do not exist until someone writes them. No standard schema uses it today (@std/form did until it became a list of controls), so it is a construct waiting for the shape rather than a description of one.

References and picklists

fields:
  author:  { ref: '@/person' }                                   # a reference
  status:  { type: string, enum: [draft, published, archived] }  # inline choices
  country: { type: string, options: '@/countries' }              # curated, shared
  • ref: — a reference to another schema. type: ref is inferred, so { ref: '@/person' } is enough.
  • enum: — an inline list of allowed values. Best for a short, fixed set that belongs to the type.
  • options: — a @/<name> ref to a curated options schema. Best when the choices are a managed list reused across fields.

An inline array always belongs on enum:; options: always takes a ref.

Rich content — format

format marks a field as carrying rich content, and it is type-bound — a mismatch is rejected when the schema is read:

| format | Valid on | Use it for | |---|---|---| | markdown | text | A markdown body that round-trips as plain source | | html | text | An HTML body | | prosemirror | json | A rich document edited through a structured editor | | scene | json | A visual scene composition (rendered by @uniweb/scene) | | email, url | string | Value validation, not rich content |

The friendly aliases set these for you: type: markdown is exactly type: text, format: markdown, and type: richtext is exactly type: json, format: prosemirror.

Use richtext for a rich body edited in the visual app — it's the editor's native, lossless document. Use markdown / html for a source body authored as text (file-based projects, or content you want readable as raw source). Don't reach for markdown just because it's the familiar word.

Translatable fields

Text and rich-content fields are translatable by default — one value per locale. Set translatable: false to opt out: an ID, a slug, a machine token that's identical in every language.

fields:
  title: { type: string }                        # translatable by default
  body:  { type: markdown }                      # translatable by default
  sku:   { type: string, translatable: false }   # one value across all locales
  tags:  { type: string, many: true, translatable: false }   # a grouping key

⚠️ A human-readable string can still be the wrong thing to translate. The opt-out is not only for opaque tokens — it is for any value that is compared rather than read: tags, keywords, categories, technology names. Those look like prose and are not, and the default is exactly wrong for them.

Translating a grouping key forks the vocabulary: the en and es sides of one site end up filtering on different values, and "everything tagged research" stops being a single query. Worse, a many field that is translatable is an array of per-language objects — so a list authored as ["design", "craft"] is not merely untranslated, it is invalid, and a locale with no translation renders no tags at all rather than the original ones.

⇒ Ask "would two locales ever need different values here, or the same value shown differently?" The second is a canonical key plus a localized label — two fields, not one translatable field. (@std's tags, keywords and technologies are all translatable: false for this reason.)

Fields constrained by enum:, and strings carrying a value-validator format (email / url), are treated as machine values and are not translated. A content format (markdown / html / prosemirror) still translates.

Field options

| Option | Type | Description | |---|---|---| | type | string | The field type (required, unless inferred from ref: or options:) | | many | boolean | Make it a list; the other keys describe each item | | required | boolean | The field must have a value | | default | any | Only in a component's inline field map or form, where it is what an editor's form starts the field with. A named schema declares none — the build refuses one — and nothing applies one at render | | label | string | Short human-readable name (editor UI) | | description | string | Human-readable description | | help | string | Additional guidance (editor UI) | | format | string | Content or validation format — see Rich content | | enum | array | Inline list of allowed values | | options | string | A @/<name> ref to a curated options schema | | translatable | boolean | Set false to opt a text field out of localization | | fields | object | Nested fields — object type | | values | object | Value shape of an open map — object type | | items | object | Item definition — array type (or use many:) | | ref | string | Target schema — ref type | | constraints | array | Rules for the section this field becomes — object and many-of-object only (see below) | | tree | boolean | The records nest under each other — a many-of-object field only | | append_only | boolean | The records are insert-only — a many-of-object field only |

tree and append_only describe how a list of records behaves, so they need one: a many: true field whose items are objects, or a many: true section. On a single object or a list of plain values they're rejected rather than ignored — they'd be stating something that can't be true. Both are documented under Structured types, and mean the same thing wherever you declare them.

Constraints

A nested record and a list of records become sections when a schema is registered, and a section can carry rules a single field can't express. Declare them with constraints: — on the section in the sections: form, or on the field itself:

fields:
  authors:
    type: object
    many: true
    constraints:
      - { kind: min_items, value: 1 }
    fields:
      name: { type: string, required: true }

min_items is the common one, and it is worth reading precisely:

  • It is a delete floor, not a fill requirement. It refuses a delete that would take the section below N. It does not force an author to populate the section in the first place.
  • It is a write guarantee, never a render guarantee. Your component still handles an empty list — the same schema can be rendered by a foundation that never saw the constraint, so content and code stay independent.

Constraints on a plain leaf field are ignored: a leaf narrows with enum and format instead. They take effect once the schema is registered; for file-based collections there is no write step.


Structured types — sections:

When a single flat record genuinely can't express the content, declare named sections: instead of fields:. Each section is one record by default, or a repeating list with many: true.

# foundation/schemas/handbook.yml
name: handbook
sections:
  identity:
    brief: true                    # the card shown when this type is referenced
    fields:
      title: { type: string, required: true }
  chapters:
    many: true                     # a repeating list of records
    tree: true                     # …that can nest under each other
    fields:
      title: { type: string }
      body:  richtext

The flat fields: form is the common case. Reach for sections: only when you need one of the capabilities below.

| Section key | Meaning | |---|---| | fields | The section's fields | | sections | Child sections (a section carrying only these is a grouping container) | | brief | This section is the card a reference to this type hydrates into. Optional — at most one per schema, and it must be a single record (not many). Without one the type simply isn't referenceable; see A schema whose root is a list | | many | A repeating list of records rather than one | | tree | A many section whose records nest under each other. nestable is the lower-level spelling | | append_only | A many section whose records are insert-only — added, never edited or deleted | | constraints | Cross-cutting write rules for the section | | label, description | Display prose for the section itself. Plain strings — translations live in locales/, never as an inline { en: … } object |

The brief

The brief is the section that represents the whole record when something references it — the card. At most one section may be marked brief: true, and it must be a single record. A schema with no brief is not referenceable as a target (there's no card to show), which is fine for types that are pure lists — @std/nav is exactly that.

A schema whose root is a list

Some content isn't a record with parts — it is a list. A navigation menu is a list of items; a form is a list of controls. Declare that as one many: true section and nothing else:

# @std/nav, in full
name: nav
sections:
  items:
    many: true
    tree: true
    fields:
      label: { type: string, required: true }
      href:  { type: string, translatable: false }

A block's content is then a bare list, with no wrapping key:

```yaml:nav
- label: Home
  href: /
- label: Docs
  href: /docs
```

Two consequences worth knowing:

  • No brief, and that's correct. There's no single record to be the card, so the schema isn't referenceable as an entity_ref target. uniweb validate and the runtime treat this as a normal shape, not a missing one.
  • Exactly one section. Two many sections and no single one would leave "which one is the value?" unanswerable, so it isn't treated as a root list — nothing would be checked.

Everything else works the same: validate checks each record and names its index ([1].label).

A record file holding one such list — a record kept in records/ — writes it under the section's key (items:), since a file whose top level is a list holds several records. uniweb validate checks it, and a push sends it.

Tree sections

tree: true lets a many section's records nest under one another — a chapter tree, a category hierarchy, a threaded discussion. There is no field to declare for the parent/child link and no ID to wire up.

This is what separates tree: from child sections:. A child section nests one named section inside another — a fixed shape you spell out. tree: lets records of a single section nest under one another, so the shape is decided by the author as they write.

Authors nest records under a reserved children: key — the schema declares only one record's fields, and children holds more of the same:

- label: Products
  href: /products
  children:
    - label: Widgets
      href: /products/widgets

You never declare children; declaring tree: true is what makes it meaningful. uniweb validate descends into it to any depth and names the full path — [1].children[0].label — so a deep entry is findable in a large tree.

tree: is only valid on a many: true section — but "section" includes a nested one, and a list of records authored as a field (chapters: { type: object, many: true, tree: true }) works the same way.

Append-only sections

append_only: true marks a many section insert-only: records may be added, but never edited or deleted. Because the rule lives in the content type rather than in a form, it holds for every writer — which makes such a section tamper-evident. Reach for it for activity logs, submissions, and audit trails.

append_only: is only valid on a many: true section, and takes effect once the schema is registered (file-based collections have no write step).

The sort axis

sort_date is a schema-level key naming a date field — the axis a feed, an archive, or a "latest first" listing orders on:

name: post
sort_date: published_on          # names a date field below
fields:
  title:        { type: string, required: true }
  published_on: { type: date }

Its value is a field name, not true/false, and it doesn't go on the field itself. With the sections: form, name a field in the brief section; a schema with no brief has no sort axis. (sortDate is an accepted alias.)

How a source file maps onto sections

Sections are namespaces: each groups its own fields, and two sections may declare fields with the same name.

A record file — a .md with frontmatter, a .yml, one .json object — is written in one of two layouts, decided by the schema:

  • Flat — a schema with one section (the fields: form, or sections: with a single one that is not a list): the record's keys are that section's fields.
  • By section — any other schema: each top-level section under its own name, an object for a single section and a list of records for a many one. A field written at the top of such a record is the flat form, which it does not have; a check reports it (the rule section), naming the section it belongs in.
# a record of @std/article, by section
brief:
  title: Hello
  author: Ada
  date: 2026-05-01

A markdown body is the value of the schema's content field — a markup text field or a richtext one — in whichever single section declares it: body.content for @std/article.

What a component receives is one of two shapes, and its data: declaration says which: a brief — the brief's fields at the top, and no other section — or, for '@std/article/*', the whole record as stored, each section under its own name. @std/article keeps its card (brief) apart from its body (body) so a list and a reference carry the card without the body.

// the same record, as a brief
{ title: 'Hello', author: 'Ada', date: '2026-05-01' }

// and whole
{ brief: { title: 'Hello', author: 'Ada', date: '2026-05-01' }, body: { content: { type: 'doc', … } } }

The standard schemas

| Schema | Ref | Description | |---|---|---| | person | @std/person | Team members, authors, contacts | | article | @std/article | Blog posts, news items, documentation | | event | @std/event | Calendar events, conferences, webinars | | project | @std/project | Portfolio items, case studies | | opportunity | @std/opportunity | Jobs, grants, calls for proposals | | publication | @std/publication | Academic papers, research documents | | nav | @std/nav | Navigation menus (a nestable list) | | scene | @std/scene | Visual scene composition (rendered by @uniweb/scene) | | form | @std/form | A form designed by an author — a list of the controls a visitor will be asked to fill |

Reach for one of these before inventing your own, the same way you'd pull a well-known type off the shelf.

@std/form describes a form definition, not a submission. A component that renders an authored form is the inverse of every other component: it doesn't declare the fields, it receives them and draws whatever it's given. So it can't declare the author's control names — it declares data: { form: '@std/form' }, which asks the only answerable question: is this a well-formed form? The block carries the controls and nothing else; a form's heading and intro are the section's own markdown. What a visitor actually answers arrives at runtime and is not knowable when the foundation is written.


How bound data arrives

A data: binding describes the shape of each item. The runtime delivers a bound collection key as an array, always:

  • A list page receives the full collection.
  • A dynamic [slug] detail page receives a single-element array — the route-matched record — under the same collection key. A detail section reads content.data.<key>[0].
  • A detail page where nothing matches receives an empty array [].

The runtime never coerces an array to a single object and never synthesizes a separate singular key. Reshaping a collection to a single record is the foundation's job — read [0], or reshape content.data once via a foundation handlers.data hook.


What gets published

When a foundation is built, every distinct ref across all section bindings is resolved and loaded into its canonical form — { name, version, description?, fields } for a flat schema, or { name, version, description?, sections } for a structured one — and emitted into the foundation's published metadata under a top-level dataSchemas map keyed by the ref. A consumer of that metadata has every data schema inline and versioned, with no refs left to resolve.

The runtime entry carries none of a schema — only each declared key and its ref, which is how a fetch of another name fills the key. Fields, labels, descriptions and every other editor hint stay in the full schema.


Programmatic API

The format itself is exported, so tooling can read a schema the same way the framework does. @uniweb/build re-exports these, so uniweb validate and this package run one implementation.

// Standard schema objects (tree-shakeable)
import { person, article, event } from '@uniweb/schemas'

// …or look them up by name
import { schemas, getSchema, getSchemaNames, isStandardSchema } from '@uniweb/schemas'

// Validate a record against a schema (name or object)
import { validate } from '@uniweb/schemas'
const { valid, errors } = validate(data, 'person')
// errors: [{ path: 'email', rule: 'format', message: '"x" is not a valid email' }]

validate accepts a schema as authored — the friendly vocabulary (many:, number, richtext, { ref: '@/x' }) and both schema forms are normalized first. It takes a record with the brief's fields at the top and each other section under its name — toDeliveredRecord makes one from a record file.

It throws when the schema is malformed — a bad schema is a programming error, and the message names the offending field. Invalid data comes back as findings.

Lower-level entry points, for tooling that needs the format directly:

import { validateAndNormalizeSchema, parseSchemaRef, collectNestedRefs } from '@uniweb/schemas/format'
import { validateItem, validateRecordFile, recordLayout, toDeliveredRecord } from '@uniweb/schemas/conform'

| Export | Does | |---|---| | validateAndNormalizeSchema(schema, ref) | Validates the authoring format and returns the normalized schema (friendly aliases folded to canonical kinds). Throws, naming the offending field | | parseSchemaRef(ref) | '@std/person' → { scope: 'std', name: 'person' } | | collectNestedRefs(schema) | Every ref/options target a normalized schema depends on | | validateItem(schema, item) | Findings for one record — the brief's fields at the top, each other section under its name — against a normalized schema | | isStaticallyCheckable(schema) | Whether a record of the schema can be checked at all — true for any schema that declares fields or sections | | validateRecordFile(schema, record) | Findings for one record as its file holds it — flat or by section — including a field written flat where the schema is written by section | | recordLayout(schema) | How a record of the schema is laid out: { flat, sections, brief } | | toDeliveredRecord(schema, record) | A record as its file holds it → the brief's fields at the top, each other section under its name | | deliveredFields(schema) | The field map of a record with the brief's fields at the top | | contentBodyField(schema) | Where a markdown body goes — in a record with the brief's fields at the top, and in its file | | misplacedFields(schema, record) | The keys of a record file written flat where the schema is written by section, with the section each belongs under | | referencesOf(schema, record, { delivered }) | Every reference a record holds, with its path, the schema it points at and the value written there — in its file, or as delivered | | mapReferences(schema, record, fn, { delivered }) | The record with each reference replaced by fn(value, { path, ref }) | | validateBound(schema, value) | Findings for a whole bound value — a record or a list. Dispatches on the schema's root shape and descends into a tree's children | | rootListSection(schema) | The section whose records are the value, when the root is a list | | AUTHORING_TYPES | Every word valid as a type: — derived from the vocabulary, so it never drifts | | SCALAR_KINDS, FORMAT_TYPES, … | The type vocabulary |

These are utilities for tooling — none is required to use a schema in a foundation. There you reference a schema by its namespace ref in meta.js and the build does the resolution for you.


See also

  • Data Schemas — the authoring guide, with worked examples: development/data-schemas.md
  • Designing Data Schemas — modeling decisions across related types: development/designing-data-schemas.md
  • Schemas in Practice — where a schema file lives, and how a second project consumes it: development/schemas-in-practice.md
  • Component Metadata — the full data: binding reference: reference/component-metadata.md

Full documentation index: https://www.uniweb.io/llms.txt

License

Apache 2.0


Section families

A section type can say what it is with one optional key:

export default { title: 'Researcher Profile', family: 'profile' }

family is one of 67 standard ids, grouped into 8 for a picker. Most components declare nothing, because the section type is the component name — Hero, Footer, Pricing, FAQ resolve from the name alone.

import { resolveFamily, FAMILIES, GROUPS } from '@uniweb/schemas/families'

resolveFamily(component)   // → { id, label, group, source, unknown }

@uniweb/schemas/families.json is the same data as JSON. English and French labels are at @uniweb/schemas/locales/en and /fr, keyed by id, never by label (family.<id>.label, group.<id>.label, group.<id>.description).

⛔ Nothing in the build, the runtime or a delivered site reads family. It is a claim about shape — never about entitlement, tier or capability — and a component that declares none renders identically. An unrecognized value is legal: it falls back rather than failing.


Site tags

A site says what it is for with standard tags in site.yml:

tags: [blog, personal]

Apps use them to filter and label a list of sites, such as a template picker. The standard tags:

business · landing-page · portfolio · personal · resume · blog · store · documentation · event · publication · community · technology · academic · research · education · nonprofit · local-business · professional-services · health · food · real-estate · arts · photography · music · travel

import { resolveSiteTags, SITE_TAGS } from '@uniweb/schemas/site-tags'

resolveSiteTags(['blog', 'landingpage'])
// → { tags: [{ id: 'blog', label: 'Blog' }], unknown: ['landingpage'] }

@uniweb/schemas/site-tags.json is the same list as JSON. The labels are in the same locale files as the families, as site-tag.<id>.label.

⛔ A tag says what a site is for, never what it can do: there is no multilingual or searchable tag. The list only grows — an id, once shipped, is never renamed or removed — and a tag that is not on it is legal: it stays on the site and simply has no standard label.


The content declaration

A section type says what content it expects:

content: {
  title:      'Headline [1]',
  paragraphs: 'Short pitch [0-1]',
  media:      'Photo, video or diagram [1]',
  items:      { label: 'Feature cards [3-6]', content: { title: 'Feature', paragraphs: 'Description' } },
}

That's a small grammar — an element vocabulary, a count in brackets, a few ways to write a value, and spellings that read naturally (image:, videos:) for what is one element. describeContent lowers it into one canonical list — the form a foundation registers, so a reader never needs the grammar:

import { describeContent } from '@uniweb/schemas/content'

const { elements, problems, declared, summary } = describeContent(component)
[
  { element: 'title',      kind: 'heading', label: 'Headline', min: 1, max: 1 },
  { element: 'paragraphs', kind: 'prose',   label: 'Short pitch', min: 0, max: 1 },
  { element: 'media',      kind: 'media',   types: ['image', 'video', 'inset'], label: 'Photo, video or diagram', min: 1, max: 1 },
  { element: 'items',      kind: 'entries', label: 'Feature cards', min: 3, max: 6,
    content: [{ element: 'title', kind: 'heading', label: 'Feature' }, { element: 'paragraphs', kind: 'prose', label: 'Description' }] },
]
  • element is what the component reads — content.<element>. kind is a closed set, CONTENT_KINDS.
  • One element for the media slot. image, images and thumbnail lower to media with types: ['image'], videos to ['video'], insets to ['inset'], and media to its types — all three when none are written (MEDIA_TYPES). Media is visual: a document is declared as documents.
  • Only what the developer wrote. label and hint appear only where written, and min / max only where a count was. ⛔ A declared label is the foundation's own words, in whatever language the developer chose — show it verbatim. Where there is none, supply your own words; framework's English ones, with a markdown sample and a description, are elementSpec(element).
  • sequence declares content rendered as written — the whole section, in order, the headline included. except leaves kinds out (SEQUENCE_KINDS): sequence: { label: 'Rich content', except: ['table', 'math'] }.
  • What an entry holds. items takes content:, lowered to the list a section's is.
  • Concept blocks. A 'md:<tag>' key in data: — 'md:faq': 'Questions and answers [3+]', or { label, hint, content } — is a concept block the author writes, and joins the list as { key: 'faq', kind: 'concept', … }. A count on its label counts the entries. lowerData gives the data: map with each such key as the key a component reads (faq: {}).
  • problems says, in words, what could not be read — an unknown name, a count on sequence, two declarations that would count one thing twice (media beside image), the retired data element. The rest is still returned.
  • declared: false means the component says nothing about its content. That's supported and common, never an error.
  • A lowered list is read as itself, so one call serves a meta.js and a registered schema entry.

Nothing throws. A foundation is third-party, and one odd key shouldn't cost a section its panel.

children: lowers too: lowerChildren (@uniweb/schemas/component) gives the object form describeChildren returns — true becomes {}, a count { max }.


Starter content

What an author should find in a new section, derived from what the component says it expects.

import { starterContent } from '@uniweb/schemas/starter'

const { params, content, family, unfilled, elementsInferred } = starterContent(component)

component is a foundation schema entry — the same argument resolveFamily takes. content is the flat content structure and params is the frontmatter. Serializing is one call in a package you already have, which is what keeps this one dependency-free:

import { buildDoc } from '@uniweb/semantic-parser'        // → ProseMirror, for an editor
import { serializeSection } from '@uniweb/content-writer'  // → markdown, for a file

const doc = buildDoc(content)
const md = serializeSection(params, doc)

uniweb add section <Name> --starter is the same thing from the command line.

What it reads

| from meta.js | what it decides | |---|---| | content: | which elements to fill, and how many — the count syntax ([3-6], [2+]) is read here. An entry's content: decides what each item holds | | family: | the register the copy comes from — a team gets people, a pricing gets tiers | | params: / presets: | the frontmatter. { preset: 'split' } uses that preset's params instead of the defaults |

It reads the declaration through describeContent, so a registered schema entry — its content already lowered — gives the same starter as its meta.js.

Rules worth knowing

  • The upper bound is what gets generated. A component need not render everything it is handed, and the params that reduce simply render less — so surplus is free while a shortfall leaves a hole. [0-2] gives 2; a floor of 0 still gives one.
  • A component that declares no content: gets its family's canonical elements, and elementsInferred says so.
  • Nothing is repeated to reach a count. Duplicated filler reads as a bug, not a placeholder.
  • Placeholder images are self-contained SVG data URIs. No host is invented. Pass { assets: [{ url, alt }] } to supply real ones.
  • unfilled names declared elements this cannot fill — a document, a table, an equation, a quote, a slot of videos or embedded components, a concept block (md:<tag>) — so a caller can say so rather than silently omit them.

It is derived, never authored

There is no starter: key in meta.js, deliberately. Authored sample copy drifts against the declaration it is meant to match, and it can never be localized — a developer's string is the foundation's own words and is shown verbatim in every UI language. Deriving the copy makes it framework's, which is the one category that can carry a locale file.