Document Models
Document models let you define structured schemas for your content types. Instead of relying on ad-hoc frontmatter fields, you declare which fields a page type should have, what types they are, whether they’re required, and what constraints apply.
Quick Start
Create a models/ directory in your project root and add a YAML file for each content type:
my-site/
config.yaml
models/
blog.yaml
event.yaml
content/
...
Example: Blog Post Model
# models/blog.yaml description: Blog post with author and category fields: author: type: string required: true label: Author Name category: type: enum values: [tutorial, news, opinion, release] required: true featured_image: type: media reading_time: type: integer constraints: min: 1 defaults: category: news
Pages using the blog template will automatically be validated against this model.
The Core Fields
Every page carries a set of fields Accent itself understands: title, date,
author, lead, tags, and the routing and workflow keys behind them. These
are declared by a built-in model named core, and every model inherits it
automatically. You never need to write extends: core – a model with no
extends: is already a child of it. Writing it out says exactly the same
thing, so a model that spells the inheritance out is accepted too.
That has one practical consequence: you can constrain a core field from your own model, exactly like any field you invented.
# models/news.yaml -- a dated post is not publishable without a date fields: date: type: date required: true label: Publication date lead: type: text required: true constraints: max_length: 300
Your declaration replaces the core one for that key, so the label, the requirement, and the constraints are yours. Everything you do not mention keeps the core definition.
Two core fields are filled in for you when you leave them out: title falls
back to the first heading or the filename, and lead to the opening paragraph.
required: true asks for a value you wrote, so a filled-in one does not
satisfy it – title: { required: true } reports every page that carries no
title: of its own, which is the whole point of asking.
A date: is checked against the same formats Accent’s loader accepts, not just
YYYY-MM-DD: 03/15/2026, 15.03.2026, and March 15, 2026 all validate,
because they all load.
What core declares
| Field | Type | In the editor |
|---|---|---|
title | string | Yes |
date | date | Yes |
author | string | Yes |
lead | text | Yes |
tags | list<string> | Yes |
publish_date, unpublish_date | date | Yes |
status, published | enum / boolean | No – owned by the workflow buttons |
template, model, slug, url, redirect, rewrite | routing keys | No |
menu, content, relations, process, media, pdf_thumbnails | structured | No |
anchors, noindex | boolean | No |
Run accent model show <name> to see the merged result for any model,
core fields included.
The one thing you cannot do
You may constrain a core field. You may not retype one, because the value has a fixed shape inside Accent:
fields: title: type: number # rejected: `title` holds a string tags: type: string # rejected: `tags` holds a list
A model that does this is refused at load time with a message naming both
shapes, and the models around it keep working. Types that describe the same
shape are all fine – date, string, url, and enum all store a string,
so any of them is a legal description of title.
A module’s frontmatter is a page frontmatter too, so the same shape rule
applies inside module_models: – where media, content, and menu are
names you might reach for without meaning the page-level field at all. There
the cost is kept to the field: it is dropped with a message, the module’s other
fields keep validating, and the model keeps governing its pages.
core is a reserved model name: a models/core.yaml is refused rather than
silently ignored.
Marking a field non-editable
Any field can be hidden from the admin editor’s form pane with
editable: false. Use it for values a plugin or a build step writes and only
templates read:
fields: search_index_id: type: string editable: false
The field is still validated, still available to templates, and still written by whatever produces it – it just is not offered as an author input.
Field Types
Document models support 15 field types:
| Type | Description | YAML Example |
|---|---|---|
string | Short text | "Hello World" |
text | Multi-line text | "Long description..." |
integer | Whole number | 42 |
number | Decimal number | 3.14 |
boolean | True/false | true |
date | ISO date | "2026-06-15" |
datetime | ISO datetime | "2026-06-15T10:30:00" |
enum | Fixed set of values | "published" |
url | URL (absolute or site-relative) | "https://example.com" |
email | Email address | "user@example.com" |
list | Array of typed items | [a, b, c] |
map | Key-value pairs with typed values | {key: value} |
reference | Path to another page | "/blog/my-post" |
media | Path to a media file | "hero.jpg" |
object | Nested fields | {width: 100, height: 200} |
any | Any shape; presence is checked, shape is not | /moved or {url: /moved} |
Complex Types
Enum fields require a values list:
status: type: enum values: [draft, review, published, archived]
List fields require an items definition:
tags: type: list items: type: string
Map fields use items for the value type:
metadata: type: map items: type: string
Object fields define nested fields:
dimensions: type: object fields: width: type: number required: true height: type: number required: true
Constraints
Add validation constraints to fields:
fields: price: type: number constraints: min: 0 max: 99999 title: type: string constraints: min_length: 1 max_length: 200 pattern: "^[A-Z]" tags: type: list items: { type: string } constraints: min_items: 1 max_items: 10 event_date: type: date constraints: future_only: true
Available Constraints
| Constraint | Applies to | Description |
|---|---|---|
min | integer, number | Minimum numeric value |
max | integer, number | Maximum numeric value |
min_length | string, text | Minimum character count |
max_length | string, text | Maximum character count |
pattern | string, text | Regex pattern to match |
min_items | list | Minimum item count |
max_items | list | Maximum item count |
future_only | date, datetime | Date must be in the future |
past_only | date, datetime | Date must be in the past |
Model Resolution
Accent CMS matches pages to models using this priority:
- Explicit model field – Set
model: blogin your page’s frontmatter - Template name match – A page using
template: eventmatchesmodels/event.yaml - Directory model – A
_model.yamlfile in the content directory applies to all pages in that directory
Explicit Model
--- title: My Event model: event start_date: 2026-08-01 ---
Directory-Level Model
Place a _model.yaml file in a content directory to apply a model to all pages within it:
# content/events/_model.yaml model: event
All markdown pages under content/events/ will be validated against the event model.
Defaults
Models can provide default values for fields. These are applied when a field is absent from the page’s frontmatter.
Per-field defaults (set on individual fields):
fields: capacity: type: integer default: 100
Top-level defaults:
defaults: featured: false
Per-field defaults take precedence over top-level defaults for the same field name. Existing frontmatter values are never overwritten.
A default on a core field that always has a value – status, published,
anchors, process – never fires, because such a field is never absent.
status: published in a defaults: block does nothing; publishing status is
owned by the workflow, not by a model file. Defaults on date, author,
lead, and tags do apply when the page leaves them empty.
Model Inheritance
Models can extend a parent model using extends. The child inherits all parent fields and can add new fields or override parent definitions:
# models/default.yaml name: Default Page fields: subtitle: type: string required: false hero_image: type: media required: false # models/blog.yaml name: Blog Post extends: default fields: category: type: enum required: true values: [tech, design, business, personal] reading_level: type: enum default: intermediate values: [beginner, intermediate, advanced]
The blog model inherits subtitle and hero_image from default, plus adds its own fields.
Inheritance rules:
- Every chain ends at the built-in
coremodel, so the core fields are always present - Child inherits all parent fields, defaults, and validation rules
- Child can override parent field definitions (e.g., make an optional field required)
- Child can add new fields
- Child cannot remove parent fields
- Single inheritance only (no multiple parents)
- Circular inheritance is detected and rejected at load time
- Deep chains work: grandparent -> parent -> child
Sharing a Social / SEO meta Schema via extends
Social sharing tags (Open Graph, Twitter Cards, and any other platform tags)
ride an open meta object on every page. The object always works as a raw map
with no model bound – the share card still renders. Binding a model adds
validation: an over-long description or a typo’d og:type / twitter:card
value is caught at content load instead of surfacing only when a human inspects
the unfurl.
Because object schemas are inline (there is no named $ref), the way to reuse
one meta schema across content types is to put it on a base model and
extends that base. The default theme ships exactly such a base model:
# themes/default/models/base.yaml name: Base Document fields: description: type: string constraints: max_length: 200 # platforms silently truncate longer values meta: type: object label: Social / SEO tags fields: og:type: # colon keys are supported as-is type: enum values: [website, article, profile] default: website twitter:card: type: enum values: [summary, summary_large_image] default: summary og:description: type: string constraints: max_length: 200
A content model inherits the whole schema – the description cap and every
declared meta sub-field – with a one-line extends:
# themes/default/models/docs.yaml name: Documentation Page extends: base
Two things make this safe to apply broadly:
- The object is an open carrier. Only the sub-fields you declare are
validated. Undeclared keys (
fediverse:creator,fb:app_id, the next network) pass through untouched – adding a platform is a content edit, not a schema change. - No field is required and the model is not strict. A page that omits
descriptionormetavalidates cleanly; the schema only catches mistakes in metadata that is actually present.
To validate your own pages, bind a model that extends: base the same way you
bind any model (by template name, an _model.yaml directory file, or an
explicit model: field – see Model Resolution). See the
Social Sharing and SEO Metadata
guide for how the resolved tags are emitted.
Cross-Field Validation Rules
Single-field validation catches type errors and constraint violations, but many content contracts involve relationships between fields. Use rules to define cross-field validation expressions:
# models/event.yaml fields: start_date: { type: date, required: true } end_date: { type: date, required: true } capacity: { type: integer, default: 0 } registration_url: { type: url } is_virtual: { type: boolean, default: false } location: { type: string } rules: - name: end_after_start description: End date must be on or after start date expression: "end_date >= start_date" - name: capacity_with_registration description: If capacity > 0, registration_url should be set expression: "capacity == 0 or registration_url is defined" severity: warning - name: virtual_or_location expression: "is_virtual or location != ''" severity: warning
Rules use MiniJinja expression syntax. Each rule’s expression is evaluated against the page’s frontmatter fields. If the expression evaluates to false, the rule fails.
Rule properties:
| Property | Required | Description |
|---|---|---|
name | yes | Unique identifier for the rule |
description | no | Human-readable explanation |
expression | yes | MiniJinja expression to evaluate |
severity | no | error (default) or warning |
Available operators: ==, !=, >, >=, <, <=, and, or, not, is defined, is undefined
Rules are evaluated only after all referenced fields pass individual validation. If start_date has a type error, the end_date >= start_date rule is skipped to avoid confusing cascading errors.
Module Constraints
For modular pages (pages with _module subdirectories), models can restrict which modules are allowed:
# models/landing.yaml modules: allowed: - hero - features - pricing - testimonials - cta required: - hero max: 8
Validation behavior:
- A module not in
allowedproduces aModelDisallowedModuleissue - A module in
requiredthat is missing produces aModelMissingModuleissue - Exceeding
maxmodules produces aModelTooManyModulesissue - If no
modulesblock is defined, all modules are allowed (backward compatible)
Module Field Validation
Models can also validate frontmatter within individual modules using module_models:
module_models: hero: fields: heading: type: string required: true background: type: media required: false features: fields: columns: type: integer default: 3 constraints: min: 1 max: 4
Section Constraints
For pages with named sections (---section: name--- markers), models can restrict which section names are valid:
# models/wiki.yaml sections: allowed: - overview - details - references - changelog
A section not in the allowed list produces a ModelDisallowedSection issue. If no sections block is defined, all section names are allowed.
Strict Mode
Enable strict_fields: true to warn about frontmatter fields not declared in the model:
strict_fields: true fields: title_field: { type: string } category: { type: enum, values: [a, b] }
The core fields are always allowed in strict mode: your model inherits them, so
they are declared fields like any other. Strict mode flags only keys nothing has
declared. To allow a key without describing its shape, declare it as
type: any.
Theme Models
Themes can provide models in their models/ directory. Project models override theme models with the same name:
themes/default/models/blog.yaml # Theme-provided model
models/blog.yaml # Project override (wins)
Template Access
The resolved model name is available in templates as page.model:
{% if page.model == "event" %}
<div class="event-badge">Event</div>
{% endif %}
Validation
Model violations appear in the debug panel (dev mode) and in accent validate output:
- ModelMissingRequired – A required field is not present
- ModelTypeMismatch – A field value doesn’t match the expected type
- ModelConstraintViolation – A value violates a constraint rule
- ModelUnknownField – An undeclared field is present (strict mode only)
- ModelRuleViolation – A cross-field rule expression evaluated to false
- ModelDisallowedModule – A module not in the allowed list
- ModelMissingModule – A required module is absent
- ModelTooManyModules – Page exceeds the maximum module count
- ModelDisallowedSection – A section name not in the allowed list
- ModelFilenamePatternMismatch – A filename or directory name does not match the model’s pattern
- ModelLoadFailed – A model file was found but could not be loaded
When a model file fails to load
A model file that cannot be parsed is skipped, and every other model still loads. One typo in one file never switches off validation for the rest of your models.
Skipping is never silent, because a model that did not load means content it governs is not being checked at all:
accent validatereports it as an error and exits non-zero, alongside any other issues. It is shown even under--models/--model.accent buildrefuses to build, naming each file that failed. A site is not published against models that did not load.accent servekeeps running on the models that did load – so a half-written model file during a hot-reload edit does not take the dev server down – and prints the failures, reprinting only when they change.accent model listlists what loaded and reports what did not.accent new <model>tells you the model failed to load, rather than reporting it as missing.
Refusing to build costs you nothing: nothing is written and nothing is deleted,
so a previous build in the output directory survives even with --clean.
A model whose extends: parent failed to load, or whose inheritance chain is
circular, is dropped the same way and reported the same way. Unrelated models
are unaffected.
When a failed model does not stop the build
The tiers are plugin > project > theme, and only a failure that leaves
content genuinely unchecked is fatal:
- If your theme ships a
blog.yamlthat no longer parses and your ownmodels/blog.yamloverrides it, nothing is unvalidated – your model is in force, and the build proceeds. You are not asked to fix a file inside a theme you do not maintain. - If your own
blog.yamlis the one with the typo, the theme’sblogis not used as a fallback. Silently reverting to a definition you overrode would apply defaults you never wrote to your pages. The model is dropped and the build stops.
Setting content.validation.mode: off disables document-model validation
entirely, including these checks – a model that fails to load then costs you no
coverage, so neither validate nor build fails on it.
Filename Patterns
Models can declare naming conventions for both the directory name and the file name of content pages. Patterns are composed of typed segments that are validated against the actual filesystem names.
Defining Filename Patterns
# models/event.yaml filename: directory: pattern: "{order}.{date}-{slug}" description: Events are ordered, date-prefixed directories file: pattern: "event.md" description: All event pages use the event template filename
Both directory and file are optional. Models without a filename block impose no naming constraints.
Pattern Segment Types
Each {placeholder} in a pattern maps to a segment type:
| Segment | Syntax | Matches | Examples |
|---|---|---|---|
| literal | Raw text | Exact string | event, .md |
order | {order} or {order:3} | N-digit integer | 01, 003 |
slug | {slug} | Kebab-case lowercase ASCII | my-first-post |
date | {date} | ISO 8601 date (YYYY-MM-DD) | 2026-03-03 |
year | {year} | 4-digit year | 2026 |
month | {month} | 2-digit month (01-12) | 03 |
day | {day} | 2-digit day (01-31) | 15 |
number | {number} or {number:4} | Digits | 0001, 42 |
enum | {enum:a,b,c} | One of listed values | bug, feature |
field | {field:name} | Slugified frontmatter value | ab-1234 |
semver | {semver} | Semantic version | 1.2.3 |
lang | {lang} | ISO 639-1 language code | en, de |
any | {any} | Any non-separator string | Wildcard |
Cross-Validation with Frontmatter
Date and field segments can reference frontmatter fields. The filename value must match the frontmatter value:
filename: directory: pattern: "{order}.{date}-{slug}"
If the directory is 03.2026-06-15-rust-conf but the page’s date frontmatter is 2026-07-01, a mismatch warning is produced.
Multi-Level Directory Patterns
Use / in a directory pattern to validate multiple directory levels:
filename: directory: pattern: "{year}/{month}/{slug}"
This validates the 3 innermost directory levels above the page file.
Inheritance
Filename patterns are inherited from parent models. A child model can override the parent’s patterns:
# models/content.yaml filename: directory: pattern: "{order}.{slug}" # models/event.yaml extends: content filename: directory: pattern: "{order}.{date}-{slug}"
If a child defines only a directory pattern, the parent’s file pattern (if any) is still inherited, and vice versa.
Validation Mode
Control how strictly model violations are enforced with the content.validation.mode setting:
# config.yaml content: validation: mode: warn # "warn" (default), "strict", or "off"
| Mode | Behavior |
|---|---|
warn | Log validation issues but serve pages normally. This is the default. |
strict | Pages with model errors return HTTP 500. Builds halt on errors. |
off | Skip model validation entirely. Useful during migration periods. |
In strict mode, accent build will stop and report errors if any page fails validation. The dev server will return a 500 error page for invalid pages, making issues immediately visible during development.
Individual rules can override the global mode with severity: warning to produce warnings even in strict mode.
CLI Tooling
Inspecting Models
Use accent model to inspect loaded models without reading YAML files directly.
List all models:
$ accent model list Models loaded: blog (A blog article with author, date, and tags) event (A calendar event with date, location, and capacity) 2 model(s) loaded
Show a model’s full schema:
$ accent model show event Model: Event Description: A calendar event with date, location, and capacity Fields: title string required Title start_date date required Start Date end_date date required End Date location string required Location capacity integer optional Maximum Capacity (default: 0) category enum [...] required Event Category Rules: end_after_start End date must be on or after start date (error) Filename: directory: {order}.{date:start_date}-{slug} file: event.md
Both subcommands support --json for programmatic output and --config to specify a config file.
Scaffolding Content
Use accent new to create content pages from a model:
# Create an event with field overrides accent new event "Rust Conf 2026" \ --path content/events/ \ --field start_date=2026-06-15 \ --field end_date=2026-06-17 \ --field location="Portland, OR" \ --field category=conference # Created: content/events/01.2026-06-15-rust-conf-2026/event.md
The generated file includes all model fields with defaults:
--- title: Rust Conf 2026 template: event start_date: 2026-06-15 end_date: 2026-06-17 location: Portland, OR capacity: 0 category: conference speakers: [] price: 0.0 status: published tags: [] --- # Rust Conf 2026
Key behaviors:
- Directory and file names follow the model’s
filenamepattern when defined {order}auto-increments from existing siblings in the target directory{date}and{slug}are derived from field values and the title- Without a filename pattern, defaults to
{slug}/{model}.md --dry-runpreviews the output without creating files--no-defaultsskips optional fields without defaults- Refuses to overwrite existing files
Validate with Model Filters
Filter validation output to model-specific issues:
# Show only model validation issues accent validate --models # Validate only pages matching a specific model accent validate --model=event
Collection Model Filter
Filter content collections by model type in frontmatter:
# Only include pages matching the "event" model content: items: "@self.children" model: event order: by: custom.start_date dir: asc
Query by Model
Filter and display model information in query results:
# Filter pages by model accent query list --model=event # Include model name in output accent query list --show-model
Plugin-Provided Models
Plugins can ship document model definitions alongside their WASM modules and templates. This allows plugins to enforce content contracts without requiring users to manually create matching model files.
Static Model Files
Plugins can include a models/ directory in their distribution:
plugins/
ecommerce/
plugin.toml
plugin.wasm
models/
product.yaml
cart.yaml
templates/
product.html.jinja
Model YAML files in the plugin’s models/ directory are loaded automatically during startup.
Dynamic Model Registration
Plugins can also register models programmatically via the register_models WASM hook. This enables config-driven schemas (e.g., available currencies, custom fields based on plugin settings).
Enable the hook in plugin.toml:
[hooks]
on_register_models = true
The plugin’s register_models function receives the plugin configuration as JSON and returns a JSON array of model definitions.
Override Priority
Models are loaded in priority order (highest priority wins when names conflict):
- Plugin models (highest) – from
plugins/<name>/models/andregister_modelshook - Project models – from
models/directory - Theme models (lowest) – from
themes/<name>/models/
When a conflict occurs, the higher-priority source wins completely (no field-level merging across sources).
Model Source Tracking
The accent model list command shows where each model originates:
$ accent model list Models loaded: product [plugin:ecommerce] (E-commerce product) blog [project] (Blog post) default [theme:default] (Default page)
Error Handling
Invalid plugin models produce startup warnings but do not prevent the server from starting. This ensures a broken plugin model does not take down the entire site.
Configuration
The models directory defaults to ./models relative to your project root. Override it in config.yaml:
content: models_directory: ./schemas validation: mode: warn