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

FieldTypeIn the editor
titlestringYes
datedateYes
authorstringYes
leadtextYes
tagslist<string>Yes
publish_date, unpublish_datedateYes
status, publishedenum / booleanNo – owned by the workflow buttons
template, model, slug, url, redirect, rewriterouting keysNo
menu, content, relations, process, media, pdf_thumbnailsstructuredNo
anchors, noindexbooleanNo

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:

TypeDescriptionYAML Example
stringShort text"Hello World"
textMulti-line text"Long description..."
integerWhole number42
numberDecimal number3.14
booleanTrue/falsetrue
dateISO date"2026-06-15"
datetimeISO datetime"2026-06-15T10:30:00"
enumFixed set of values"published"
urlURL (absolute or site-relative)"https://example.com"
emailEmail address"user@example.com"
listArray of typed items[a, b, c]
mapKey-value pairs with typed values{key: value}
referencePath to another page"/blog/my-post"
mediaPath to a media file"hero.jpg"
objectNested fields{width: 100, height: 200}
anyAny 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

ConstraintApplies toDescription
mininteger, numberMinimum numeric value
maxinteger, numberMaximum numeric value
min_lengthstring, textMinimum character count
max_lengthstring, textMaximum character count
patternstring, textRegex pattern to match
min_itemslistMinimum item count
max_itemslistMaximum item count
future_onlydate, datetimeDate must be in the future
past_onlydate, datetimeDate must be in the past

Model Resolution

Accent CMS matches pages to models using this priority:

  1. Explicit model field – Set model: blog in your page’s frontmatter
  2. Template name match – A page using template: event matches models/event.yaml
  3. Directory model – A _model.yaml file 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 core model, 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 description or meta validates 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:

PropertyRequiredDescription
nameyesUnique identifier for the rule
descriptionnoHuman-readable explanation
expressionyesMiniJinja expression to evaluate
severitynoerror (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 allowed produces a ModelDisallowedModule issue
  • A module in required that is missing produces a ModelMissingModule issue
  • Exceeding max modules produces a ModelTooManyModules issue
  • If no modules block 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 validate reports it as an error and exits non-zero, alongside any other issues. It is shown even under --models/--model.
  • accent build refuses to build, naming each file that failed. A site is not published against models that did not load.
  • accent serve keeps 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 list lists 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.yaml that no longer parses and your own models/blog.yaml overrides 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.yaml is the one with the typo, the theme’s blog is 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:

SegmentSyntaxMatchesExamples
literalRaw textExact stringevent, .md
order{order} or {order:3}N-digit integer01, 003
slug{slug}Kebab-case lowercase ASCIImy-first-post
date{date}ISO 8601 date (YYYY-MM-DD)2026-03-03
year{year}4-digit year2026
month{month}2-digit month (01-12)03
day{day}2-digit day (01-31)15
number{number} or {number:4}Digits0001, 42
enum{enum:a,b,c}One of listed valuesbug, feature
field{field:name}Slugified frontmatter valueab-1234
semver{semver}Semantic version1.2.3
lang{lang}ISO 639-1 language codeen, de
any{any}Any non-separator stringWildcard

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"
ModeBehavior
warnLog validation issues but serve pages normally. This is the default.
strictPages with model errors return HTTP 500. Builds halt on errors.
offSkip 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 filename pattern 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-run previews the output without creating files
  • --no-defaults skips 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):

  1. Plugin models (highest) – from plugins/<name>/models/ and register_models hook
  2. Project models – from models/ directory
  3. 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