Project layout
Every directory and file a Meridian project can hold, what each is for, which bundle reads it, and which files you write and which the tooling writes.
A project is one directory. The engine reads it from the current directory, or from the directory --project names; --config changes the configuration directory’s name from config. Everything in it is text you commit to version control, except the few local directories listed under What not to commit.
The whole tree
Only config/engine.yaml, config/locales.yaml, one site directory and the templates its routes need are required. Every other file is optional: leave it out and the engine uses its defaults.
What reads what
The engine reads config/ and templates/ at start and on every check; schema apply copies the schema into the database, seed apply loads seed/ into it once, and from then on content lives in the database:
flowchart LR
subgraph project["The project directory"]
config["config/<br/>engine, locales, sites, schema, access"]
templates["templates/"]
public["public/"]
seed["seed/"]
end
config -->|schema apply| db[("PostgreSQL<br/>schema copy and content")]
seed -->|seed apply, once| db
config --> engine["The engine<br/>serve, dev up, check"]
templates --> engine
public -->|served as is| engine
db --> engine
engine --> site["Public sites, admin, APIs"]
config -->|guide| agents["AGENTS.md and skills"]Who writes what
You write every file in the project. The tooling writes into your files in three cases only, each one a command you run on purpose, never the running engine:
engine-dmo check --fix, theengine-dmo devverbs andengine-dmo dev upwrite theid:of each configuration element that has none, and the verbs make the edit you asked for. They keep your comments and layout. See Stable ids.engine-dmo initandengine-dmo newwrite new files.engine-dmo config schema --writeandinitwrite the editor schemas under.meridian/schema/and the editor settings that map them.
Content (documents, media, organisations, users) lives in the database, not in the project. The project’s seed/ holds content to load into a database, which is a one-way copy.
config/
Configuration is YAML. Every file is validated as a whole by engine-dmo check, which reports every problem at once with its file, line and column. Secrets never appear as values: a secret key must be a bare ${VAR} reference to an environment variable. Configuration reference lists every key of every file.
Install-wide files
| Path | Holds | Bundle |
|---|---|---|
engine.yaml | The install: server, database, storage, mail, search, authentication, media, cache, logging, jobs, analytics and each module’s own section. Required | Both |
locales.yaml | Every locale the install knows, its formatting and its fallback chain; exactly one is the source locale. Required | Both |
images.yaml | Image breakpoints, defaults and named transforms | Both |
accessibility.yaml | The register of known accessibility issues the accessibility statement lists | Both |
networks.yaml | Networks of sites that share organisations, people and partner accounts. Without it every site is in one network | Both, used by the DMO modules |
memberships.yaml | The membership tiers, in rank order | engine-dmo (Memberships) |
paidlistings.yaml | Prices and payment for paid listings | engine-dmo (Paid listings) |
schema/ | Content types. Any .yaml file at any depth is read; schema/_shared.yaml holds field groups other files include with use:; other files whose names start with _ are not read as types | Both |
blocks/_index.yaml | Blocks shared by every blocks field | Both |
strings/<locale>.yaml | The interface strings templates print with t(), one file per locale | Both |
access/roles.yaml | Staff roles: your own, and replacements for the built-in ones | Both |
access/grants.yaml | Which staff group holds which role, under which scope. The starter’s gives the administrators group the built-in owner role, and user create puts the first user in administrators; keep a grant like it, or no one can manage the install | Both |
access/users.yaml | Custom fields on the staff user record | Both |
analytics/dashboards.yaml | The organisation dashboards and the metrics staff enter by hand | Both |
analytics/reports.yaml | Scheduled member and organisation reports | engine-dmo (Member report) |
analytics/bots.txt | User agent substrings added to the shipped list of bots, one per line | Both |
crm/organisation.yaml, crm/person.yaml | Custom fields on organisations and people | engine-dmo (CRM) |
portal/portal.yaml | The member portal’s install-wide settings | engine-dmo (Member portal) |
portal/enquiries.yaml | Custom fields on enquiries | engine-dmo (Member portal) |
portal/roles.yaml | Partner roles: what a person may do for an organisation in the portal | engine-dmo (CRM) |
tags/vocabulary.yaml | The starting tag vocabulary. Loaded into the database by engine-dmo tags import or a seed; the admin edits it after that | Both |
tags/smartlists.yaml | The starting smart lists, loaded the same way | Both |
search/synonyms.yaml | The starting search synonyms, loaded by engine-dmo search synonyms or a seed | Both |
ai/prompts/<key>.yaml | Your override of one of the engine’s AI prompts | Both (AI) |
guidance/<site>.md, guidance/<site>/<type>.md | Editorial guidance in Markdown that the content MCP gives an agent writing content for that site or type | Both |
A file that belongs to a module the binary does not compile is an error at the file, naming the module. On engine-cms, config/memberships.yaml gives:
config/memberships.yaml:1:1: memberships.yaml belongs to the memberships module, which this bundle (cms) does not compile
1 | tiers:
| ^
= use a bundle that compiles memberships, such as the DMO platform's bin/engine-dmo, or remove it
= error code MRD-1006 A module’s section of engine.yaml (such as portal:) is refused the same way.
config/sites/
One directory per site; the directory name is the site’s key. An install with one site still has config/sites/<key>/.
| Path | Holds | Bundle |
|---|---|---|
site.yaml | The site’s name, domains, enabled locales and URL strategy, template roots, SEO, navigation slots, widgets, delivery mode and settings. Required | Both |
routes.yaml | Which types the site routes and at which URI patterns, index pages, feeds, calendars, landing pages, redirects, special pages and the home page. Without it no type is routable on the site | Both |
schema/ | Types and fields that belong to this site alone | Both |
images.yaml | Image transforms added to the install’s, or replacing one by name | Both |
endpoints.yaml | Declared endpoints: fragment, JSON, stream or redirect responses built from a fixed set of steps | Both |
sources.yaml | Configured, cached, allowlisted upstream data sources the endpoints read | Both |
importers.yaml | Scheduled feed imports | Both |
webhooks.yaml | Outbound webhooks on content events | Both |
plugins.yaml | Plugin binaries the endpoints call (an experimental protocol) | Both |
portal.yaml | This site’s portal over the install’s: path, routes, screens and actions | engine-dmo (Member portal) |
promotions.yaml | Promotion slots and the label each promoted placement shows | engine-dmo (Promotions) |
Sites, locales and routes explains how site.yaml and routes.yaml work together.
templates/
Pongo2 templates (a Django-like syntax), autoescaped. templates/_shared/ holds what every site uses, and templates/<site>/ holds a site’s overrides. A site resolves each template along its chain of roots, most specific first: [<site>, _shared] unless templates.roots in site.yaml says otherwise. Every root a site names must exist as a directory.
| Path in a root | Holds |
|---|---|
base.html (or any layout) | Layouts other templates extend |
<type>/entry.html | A document of the type (the default; a type or variant may name another template) |
<type>/index.html | The type’s index page, for a route with index: |
errors/404.html, errors/500.html | The not found and error pages |
blocks/<block>.html | One block of a blocks field |
components/<name>/ | A component: <name>.html, its contract component.yaml, and optionally co-located CSS |
ptext/<set>/<type>.html | A custom Portable Text block in a rich text field |
emails/ | Transactional mail templates, overriding the engine’s own |
partials/ | Fragments your templates include; the name is yours |
portal/ | The member portal’s screens (engine-dmo) |
engine-dmo check refuses a routed type with no template on a site’s chain, an index route without its index.html, and any template that does not compile, uses an unknown variable root or an unknown filter. Templates and Multisite templates cover the rest.
public/
Static files served as they are, at the same path. A request resolves along the site’s chain: public/<site>/, then public/_shared/, then public/. Built and versioned files (under public/build/) are cached for a year, anything else for an hour; the build’s manifest is never served. public.mounts in engine.yaml serves a directory outside public/ at a path prefix.
seed/
Content to load into a database with engine-dmo seed apply. It goes through the same validation and revision history as an editor’s save. engine-dmo seed verify checks it against the schema without a database, and is what CI runs. It needs every ${VAR} in the configuration set (a placeholder is enough), and it checks types, fields and locales, not values: a value a field would refuse fails only in seed apply.
| Path | Holds |
|---|---|
seed.yaml | The manifest: name, description, locales, optional generated analytics, named profiles, and a media library to import photographs from. locales limits only the generated analytics; documents load every locale their files give |
documents/<type>/<key>.yaml | One document: its values per locale, which locales to publish, and optionally its history, schedule, tags and site sharing |
users/<key>.yaml | A staff user for development: email, name, groups, password and authenticator secret. Never created in production |
navigation/<slot>.yaml | The items of a navigation slot |
assets/ | Media files the seed uploads |
organisations/, people/ | Organisations and people (engine-dmo) |
generate.yaml, generate/ | Rules for engine-dmo seed write, which writes generated records into the seed |
<set>/ | Another seed set, applied with --set <set> |
A starter seed document:
# seed/documents/page/home.yaml
locales:
en:
title: Welcome
slug: home
publish: [en] Frontend build files
The starter has no frontend build: no package.json, src/ or vite.config.js, and its CSS and JavaScript, if any, go in public/. A project with its own CSS and JavaScript build keeps its sources in src/ (entries in src/entries/, one per site by default: src/entries/<site>.js), with package.json and vite.config.js at the project root. The vite extension, named in engine.yaml as extensions: [vite], runs Vite’s dev server beside engine-dmo serve --dev, and engine-dmo build runs the production build and then check. Output goes to public/build/. The frontend build covers it.
tests/
tests/templates/*.test.yaml are template tests: pages to render through the real pipeline against the seeded database, each with what it must contain. engine-dmo template test runs them. Testing templates covers the format.
Agent and editor files
| Path | Written by | Holds |
|---|---|---|
AGENTS.md | engine-dmo init, refreshed by the dev verbs | Working guidance for a coding agent, generated from this project by engine-dmo guide |
.claude/skills/meridian-project/SKILL.md | The same | The same guidance as a skill file |
.claude/skills/meridian/SKILL.md | engine-dmo init, refreshed by the dev verbs | The handbook’s For agents page as a skill, matching the engine version |
.meridian/schema/*.json | engine-dmo init, engine-dmo config schema --write | JSON Schemas of every configuration file, for your editor |
.vscode/settings.json, .zed/settings.json | engine-dmo init --editors | Editor mappings from each configuration file to its schema |
Regenerate AGENTS.md by hand with engine-dmo guide --out AGENTS.md, the project skill with engine-dmo guide --skill --out .claude/skills/meridian-project/SKILL.md, and the meridian skill with engine-dmo docs skill --out .claude/skills/meridian/SKILL.md. init --agents=false writes none of them.
The starter’s README.md points at this page. Its hints, and the engine’s, name handbook pages or commands. Earlier builds, including the 0.8.0 release images, also cite internal design documents in some hints, such as (27-frontend-toolkit.md) or (ADR-0019); those are not part of the handbook and not published, so search the handbook for the topic instead.
What not to commit
The starter’s .gitignore:
/storage/
/node_modules/
/public/build/
/.meridian/
.env | Path | Why it is local |
|---|---|
storage/ | Uploaded media and image derivatives with the filesystem storage driver (storage.filesystem.root, ./storage by default). Back it up with the database, never commit it |
node_modules/ | Installed frontend packages |
public/build/ | Frontend build output, rebuilt by engine-dmo build |
.meridian/ | Editor schemas, and dev-status.json, the last result of engine-dmo dev up |
.env | Local environment variables. The engine never reads it; your shell or process manager does |
Your first project
Create a Meridian project with init or init --from, migrate and apply it to a database, seed it, serve it and sign in to the admin as the first staff user.
The development loop
The commands for day-to-day work on a Meridian project (dev up, serve --dev, the dev authoring verbs, check, schema plan and apply, migrate, seed, describe, render, template tests, guide and docs serve), what each needs and what it exits with.