Configuration reference

Everything pullpress.config.yml understands — the one file that turns your repo into a CMS.

The config file lives in the root of your repository and is versioned like everything else. Unlike content edits — which open a change request for review — saving the config is an administrator action that commits straight to your default branch, so a new content model takes effect at once (and is validated before it's written). It has a handful of top-level keys, a list of collections, and a small set of field types.

Prefer editing in the app? The built-in configuration editor offers a visual mode whose field-type picker groups every type with a one-line description, and its YAML mode validates before saving: a syntax typo is pointed out at its exact line and column, and structural problems are listed per field path. The file name is pullpress.config.yml (a .yaml extension works too).

The in-app Configuration editor in Visual mode: Visual and YAML tabs, the Blog posts, Pages and Opening hours collections in a sidebar, and the selected collection's name, label, folder, filename template and field list as a form
The configuration editor: the same file, editable visually or as validated YAML.

Top-level keys

KeyDescription
versionConfig schema version. Currently always 1.
media.folderRepository path where uploaded images are committed, e.g. public/uploads.
media.public_pathURL prefix the published site serves that folder from, e.g. /uploads. Used to write correct image links into your Markdown.
collectionsThe list of content collections editors can work with. Each collection has a name, a label, a type and fields.
componentsOptional. A map of reusable named field groups, referenced from fields with type: component. Define a group once, reuse it across collections.

Collection types

A collection describes one kind of content. Its type decides how entries map to files in the repository.

folder — one file per entry

Every entry is its own Markdown file inside the configured folder. Editors can create, edit and delete entries — ideal for blog posts, news items or team members.

collections:
  - name: blog
    label: Blog posts
    type: folder
    folder: content/blog
    fields:
      - { name: title, label: Title, type: string, required: true }
      - { name: date,  label: Date,  type: date, default: now }
      - { name: body,  label: Body,  type: markdown }

Frontmatter formats. All three frontmatter formats round-trip: YAML between --- lines, TOML between +++lines (Hugo's default) and a bare JSON object at the top of the file. Existing entries always keep the format, key order and unmanaged keys they already have. YAML frontmatter also keeps its # comments, anchors and |/> block strings on save — editing one field produces a diff that touches only that field. (TOML and JSON frontmatter keep values and key order, but comment lines in TOML are not preserved.) Set format: toml (or json) on a collection to create new entries in that format; detection fills this in for you when your existing content uses it.

Sites that use one folder per entry (Astro content collections, Hugo leaf bundles: content/my-post/index.md) set structure: bundle. Add media: colocatedto store an entry's images inside its own folder, referenced relatively (./photo.jpg) so your generator's image pipeline picks them up. Repository detection proposes all of this for you — it recognizes Astro content collections, Hugo leaf and branch bundles (plus contentDir and data/ files), Jekyll collections from _config.yml, Next.js content folders and date-nested bundles, and writes the generated labels in your UI language:

collections:
  - name: projects
    label: Projects
    type: folder
    folder: redesign/content
    structure: bundle      # one folder per entry: content/my-post/index.md
    media: colocated       # images live inside the entry folder (./photo.jpg)
    slug: "{{date}}-{{slug:title}}"
    fields:
      - { name: title, label: Title, type: string, required: true }
      - { name: date,  label: Date,  type: date, default: now }
      - { name: body,  label: Body,  type: markdown }

files — fixed pages

A fixed set of files you list explicitly. Editors can edit them but not add or remove any — right for one-off pages like About or Contact. Each file carries its own fields (nested under that file, not shared at the collection level), so every page can have a different shape.

collections:
  - name: pages
    label: Pages
    type: files
    files:
      - name: about
        label: About page
        file: content/about.md
        fields:
          - { name: title, label: Title, type: string, required: true }
          - { name: body,  label: Body,  type: markdown }
      - name: contact
        label: Contact page
        file: content/contact.md
        fields:
          - { name: heading, label: Heading, type: string, required: true }
          - { name: body,    label: Body,    type: markdown }

Repository detection uses this for section pagestoo: a Hugo branch bundle's _index.md(the landing page of a section) is not an entry of the folder collection, so detection collects every one it finds into a generated “Section pages” files collection — editable like any other page, no hand-wiring needed.

data — a single structured data file

One YAML, JSON or TOML file edited entirely through a form, with no Markdown body. Use it for structured data your templates read directly: opening hours, navigation menus, site settings. Keys in the file that are not in the field config survive every save, in all three formats. YAML files keep their comments too; TOML and JSON files keep their keys and order but are reformatted.

collections:
  - name: opening_hours
    label: Opening hours
    type: data
    file: data/opening-hours.yml
    fields:
      - name: hours
        label: Weekly hours
        type: list
        of:
          type: object
          fields:
            - { name: day,    label: Day,    type: select, options: [mon, tue, wed, thu, fri, sat, sun] }
            - { name: open,   label: Opens,  type: string }
            - { name: close,  label: Closes, type: string }
            - { name: closed, label: Closed all day, type: boolean, default: false }

Some data files are not a map but one big list — an array at the top of the file, like the Astro file() loader reads (src/data/authors.json holding [ …, … ]), or Hugo/Jekyll/Eleventy list data files. Set root: list and declare exactly one field of type list: its items are the file's top-level array, edited as a repeatable form. Items you don't touch are written back verbatim (their key order and any extra keys survive); TOML can't represent a top-level array, so this works for .json, .yaml and .yml files. Repository detection proposes this shape automatically.

CSV and TSV files (Jekyll _data/*.csv, spreadsheet exports) work the same way: they are always tabular, so point a root: list data collection at the file and list the columns as string fields of the single list field. The header row and column order are preserved on save; every value round-trips as text.

collections:
  - name: authors
    label: Authors
    type: data
    file: src/data/authors.json   # the file's root is [ … ], not { … }
    root: list
    fields:                       # exactly one field, of type list
      - name: items
        label: Authors
        type: list
        of:
          type: object
          fields:
            - { name: name,   label: Name,   type: string, required: true }
            - { name: avatar, label: Avatar, type: image }

folder of data entries — no Markdown body

Sometimes each entry is pure structured data with no article body — authors, team members, products, link lists. Give a folder collection a data extension (json, yaml, yml or toml) and every entry becomes a single data file that editors fill in as a form — the same create, edit, delete, review and history you get for Markdown, just without a body. This is exactly how Astro type: 'data' collections work, and repository detection proposes them for you.

collections:
  - name: authors
    label: Authors
    type: folder
    folder: src/content/authors
    extension: json          # each entry is a .json data file (no body)
    slug: "{{slug:name}}"
    fields:
      - { name: name,   label: Name,   type: string, required: true }
      - { name: bio,    label: Bio,    type: text }
      - { name: avatar, label: Avatar, type: image }

The whole file is the data (its format follows the extension), and — like a datacollection — key order and any keys not in your field list are preserved on every save. There's no body field, so don't add one; relations can point at these collections too.

Collection options

Beyond name, label, folder and fields, a folder collection takes these optional keys. Repository detection fills most of them in for you.

KeyDescription
structureflat (default) keeps one file per entry; bundle uses one folder per entry (folder/slug/index.md).
mediasite (default) sends uploads to the media folder; colocated stores them inside the entry's own bundle folder, referenced as ./photo.jpg. colocated requires structure: bundle.
extensionFile type for entries: md (default), markdown or mdx for Markdown pages; html, adoc or org for non-Markdown content (frontmatter still round-trips — declare the body as type: code so it is edited as plain text); or json, yaml, yml or toml to make every entry a data file with no body (a folder of data entries, like Astro data collections).
formatFrontmatter format for new entries: yaml (default), toml or json. Existing entries keep whatever format they already use.
nestedtrue also gathers entries from subfolders (up to five levels deep), grouped per folder. Off by default.
localesExtra language codes (e.g. [fr, en]) that make each entry editable per language, laid out per the i18n key.
i18nWhere translations live: suffix (default) uses sibling files (post.fr.md); subfolder uses one folder per language (Hugo content/<lang>/, Astro/Starlight docs/<lang>/). With subfolder, point folder at the default language's folder and name that segment in defaultLocale — e.g. folder: content/en, defaultLocale: en, locales: [fr, de] puts French entries in content/fr.
slugFilename template for new entries. Default {{slug:title}}. Tokens: {{slug:field}} (slugified field), {{field}}, {{date}}, {{year}}, {{month}}, {{day}}.
sortHow the entry list is ordered: { field, order } with order asc or desc. Defaults to the date field, newest first.

A files collection instead lists each file with its own fields (and an optional per-file format); a data collection points at one file and shares one set of fields.

Field types

Each field in a collection becomes a form control in the editor. The common options label, required and default work on every type. Two more help your editors: a help string shows a hint under the field, and a placeholdershows example text inside an empty input. Required fields get a red asterisk and a friendly “what's missing” summary on submit.

Read-only fields. Add readOnly: trueto show a field with its stored value but keep editors from changing it — a settings document's name, a locked slug, an external id. It's locked in both the web editor and the iOS app, and the server restores the stored value on every save, so a read-only field can never drift no matter how a change arrives.

- name: title
  type: string
  label: Name
  readOnly: true            # shown, but editors can't change it

Per-language text.The interface adapts to each editor's chosen language, and you can do the same for your own label, help and placeholder text. The bare key is shown to everyone; add a _<language> suffix (one of en, nl, fr, de, es) to override it for editors using that language. Nothing is translated automatically — you write each variant.

- name: title
  type: string
  label: Titel              # shown to everyone
  help: De titel van je pagina
  help_en: Your page title  # only for editors using English
TypeDescriptionExample
stringA single line of text.{ name: title, type: string, required: true }
textMulti-line plain text, shown as a textarea.{ name: summary, type: text }
markdownRich text in the clean editor, stored as Markdown.{ name: body, type: markdown }
numberAn integer or decimal number.{ name: price, type: number }
booleanA yes/no toggle.{ name: featured, type: boolean, default: false }
dateA calendar date. default: now fills in today.{ name: date, type: date, default: now }
datetimeA date with a time of day.{ name: published_at, type: datetime }
selectOne choice from a fixed list, via options.{ name: category, type: select, options: [news, recipes] }
multiselectMultiple choices from a fixed list, shown as checkboxes.{ name: categories, type: multiselect, options: [news, events] }
urlA web address, validated as https://…{ name: website, type: url }
emailAn email address, validated.{ name: contact, type: email }
colorA color picker, stored as #rrggbb.{ name: accent, type: color }
imageAn image upload, committed to the media folder.{ name: cover, type: image }
listA repeatable list of items; of is a field definition for the item type.{ name: tags, type: list, of: { type: string } }
objectA group of nested fields, defined with fields.{ name: cta, type: object, fields: [...] }
relationA reference to an entry in another folder collection (stores its slug).{ name: author, type: relation, collection: team }
blocksStackable sections of different shapes (each item carries a _type key; override the key with typeKey).{ name: sections, type: blocks, blocks: [...] }
hiddenNot shown to editors; written along with a fixed value (e.g. a layout key).{ name: layout, type: hidden, default: post }
codeA monospace code block, with an optional language hint.{ name: snippet, type: code, language: js }
mapA location, edited as latitude/longitude (stored as 'lat,lng').{ name: location, type: map }
computeA read-only value derived from a template; each {{token}} is the name of a sibling field.{ name: ref, type: compute, template: '{{brand}}-{{sku}}' }
keyvalueArbitrary key/value pairs, e.g. extra metadata.{ name: meta, type: keyvalue }
seoSEO panel: title + description (with length hints), canonical URL, OG image and noindex, with a search-snippet and JSON-LD preview.{ name: seo, type: seo }
componentExpands a reusable field group from the top-level components map.{ name: cta, type: component, component: callToAction }

list and object compose: a list of object gives you repeatable groups of fields, like the weekly opening hours in the example above.

Validation rules

Beyond required, a field can mirror the exact constraints your generator's schema enforces — so mistakes are caught in the editor with a friendly message, not by a failing build. Number fields take min, max and integer: true; text fields (string, text, markdown, code) take minLength, maxLength and a regular-expression pattern; list and multiselect take minItems and maxItems. Violations show inline while writing (an optional field may stay empty; its rules apply once something is typed), and the same rules run server-side on every save.

- { name: title,  type: string, required: true, maxLength: 70 }
- { name: rating, type: number, min: 1, max: 5, integer: true }
- { name: sku,    type: string, pattern: "^[A-Z]{2}-\\d{4}$" }
- { name: tags,   type: list, of: { type: string }, minItems: 1, maxItems: 8 }

Defining blocks

A blocks field lets editors stack sections of different shapes (a hero, a pull quote, a gallery…). List the section types under blocks; each is a named group of fields (with an optional label), just like an object:

- name: sections
  label: Page sections
  type: blocks
  blocks:
    - name: hero
      label: Hero
      fields:
        - { name: heading, type: string, required: true }
        - { name: image,   type: image }
    - name: quote
      label: Pull quote
      fields:
        - { name: text,   type: text, required: true }
        - { name: source, type: string }

Every item an editor adds records which block it is under a _type key (hero, quote, …), so your generator knows how to render each section. If your schema expects a different key — Astro's z.discriminatedUnion('type', …), or content imported from Decap CMS (which stores it as type) — set typeKey on the field (typeKey: type) and PullPress reads, writes and validates that key instead. The Decap importer sets it automatically, so imported entries keep working unchanged.

Conditional fields (showIf)

Any field can declare showIfto appear only when a sibling field matches. Hidden fields are cleared (so they never land in the frontmatter), and a conditional field's required rule applies only while it is visible.

fields:
  - { name: hasCta, label: Add a call to action?, type: boolean }
  - name: ctaUrl
    label: Button URL
    type: url
    required: true
    showIf: { field: hasCta, truthy: true }   # equals / oneOf also work

Nested collections

Set nested: true on a folder collection to also gather entries from subfolders (up to five levels deep) and show them grouped per folder — handy for documentation trees. Search and relation pickers pick the nested entries up automatically.

collections:
  - name: docs
    label: Documentation
    type: folder
    folder: content/docs
    nested: true            # gather entries from subfolders too
    fields:
      - { name: title, type: string, required: true }
      - { name: body,  type: markdown }

Reusable field components

Define a named field group once under the top-level components map, then drop it into any collection with a component field. It expands to a regular object group when loaded, so a change to the group updates every collection that uses it.

components:
  callToAction:
    - { name: label, type: string }
    - { name: url,   type: url }
collections:
  - name: pages
    type: folder
    folder: content/pages
    fields:
      - { name: title, type: string, required: true }
      - { name: cta,   type: component, component: callToAction }

Where media lives (and why not S3)

Uploads are committed to your repository, next to the content that references them. That is a deliberate choice, not a missing feature: the repo stays the single source of truth, images travel with their posts through branches, review and history, and cloning the repo gives you the complete site — no second system to back up or pay for.

Need a CDN or image resizing? Let your generator or host handle it at build time (Astro's and Next.js's image pipelines, Netlify image CDN, Cloudinary fetch URLs). PullPress keeps the original in Git; your build makes it fast. Uploads are resized to web dimensions client-side before they're sent — even a 20–50 MB phone photo — which in practice keeps repos comfortably small. External object storage (S3 and friends) would break the "your repo is everything" guarantee, so we don't plan it.

AI discoverability (llms.txt)

PullPress can generate an llms.txt at your repository root: a plain-text index of your site — its name, URL and, per folder collection, a list of entry titles — that AI answer engines read to summarize and cite your site correctly. It is built deterministically from your content model (no AI call, nothing leaves your repo).

Administrators find it under a site's Settings → AI discoverability (GEO) panel, which also shows a readiness score: whether your site URL is set, whether llms.txt is present, whether you have published content and description/SEO fields, and — if your site URL is reachable — whether the homepage exposes a meta description, Open Graph tags and JSON-LD. Click Update llms.txtto (re)generate the file; it's committed like any other change — through review, or straight to your default branch on open-publishing sites.

This complements brandvoice.md below: brand voice guides how AI writes for you, while llms.txt helps AI find and citewhat you've already published. It doesn't manage robots.txt or your sitemap — leave those to your generator.

Brand voice for AI agents

Drop an optional brandvoice.mdin your repository root to describe the site's tone, preferred and banned words, and writing level. AI agents connected over MCP can read it (via the get_style_guidetool) and follow it when drafting content. Because it's a normal file in your repo, it's versioned and reviewable like any other content — and unique per client.