Skip to content

Schema reference ​

Schemas come from @martipops/cms-core. They're plain, JSON-serializable objects, so the same definition drives the editor in the browser and validation on the server.

ts
import { f, definePage, section, defineCollection } from '@martipops/cms-core'

Pages and sections ​

ts
definePage(name: string, sections: Record<string, Section>)
section(label: string, fields: Record<string, Field>, { description? })
  • Section and field keys must be plain identifiers (hero, orderOnlineUrl). definePage throws otherwise.
  • label is the panel title and the edit button text ("Edit Hero").
  • description shows under the panel title. Use it to point to related editors ("The cards themselves are edited by clicking them").

Collections ​

ts
defineCollection(name, {
  label: 'Location',            // singular
  labelPlural: 'Locations',     // defaults to label + "s"
  titleField: 'shortName',      // each item's title in the editor list
  slugFrom: 'shortName',        // optional; slug generated once, on create
  allow: { create, delete, reorder, hide },   // each defaults to true
  fields: { … },
  initial: [{ slug?: 'highlands', …values }], // inserted once, ever
})

Field types ​

Every builder takes one options object. Every field accepts label (defaults to the key, humanised: orderOnlineUrl → "Order online url") and help (text under the input).

BuilderValueOptions (defaults)Server validationEditor
f.textstringdefault '', max 160 (1000 if multiline), multiline false, required false, placeholdertrimmed; ≤ max; ≥1 char if required; no line breaks unless multilineUInput / UTextarea
f.textareastringsame as text, multiline: trueas textUTextarea (autoresize)
f.numbernumber | nulldefault null, min, max, step, integer falsenumber in range; no decimals if integer; nullableUInputNumber
f.booleanbooleandefault falsestrict booleanUSwitch
f.selectstringoptions (strings or { value, label, icon? }), default = first optionone of the option valuesUSelectMenu
f.iconstringoptions (required), default = first optionone of the option valuessearchable USelectMenu showing icons
f.imagestring | nulldefault null, folder 'images', required falsekey under folder/ that media.exists confirms; nullable unless requiredthumbnail + media picker + Remove
f.filestring | nulldefault null, folder 'files', required falsekey under folder/ that media.exists confirmsfile name + media picker
f.link{ label, url }default { label: '', url: '' }, folder (picker folder; defaults to the linkFolder option)label 1–80 chars; url ≤500, internal (/…), http(s)://, mailto: or tel:label + URL + Choose file
f.urlstringdefault '', required false, prefixes, placeholder≤2000; must start with one of prefixes (or the link rules); empty allowed unless requiredUInput type=url
f.groupobjectf.group({ …fields }, { label? })each child fieldbordered box of child fields
f.listobject[]f.list({ …itemFields }, { min 0, max 50, default [], itemTitle })array length within min..max; each item's fieldscollapsible items (inline if one simple field)
f.customany JSONf.custom(name, { default, options? })the registered type's validatorthe registered component

Types come along for free ​

ts
const hero = section('Hero', {
  title: f.text(),
  stats: f.list({ value: f.text(), big: f.boolean() }),
  cta: f.group({ label: f.text(), href: f.url() }),
})

type Hero = SectionValues<typeof hero>
// { title: string; stats: { value: string; big: boolean }[]; cta: { label: string; href: string } }

type Location = ItemValues<typeof locations>

Use these on the server, and for casting in the browser until typed slot data lands.

List titles ​

In the editor, a list item's title is itemTitle's value, falling back to the first text field, then "Item N". Pick a field staff will recognise (itemTitle: 'name').

Custom field types ​

When none of the built-ins fit, for example an opening-hours grid, a price with sizes or a map pin, define your own type. You need two halves, registered under the same name.

1. Schema helper (optional, but it gives a typed default):

ts
// app/cms/fields.ts
export function weeklyHours(options: { label?: string } = {}) {
  return f.custom('weeklyHours', {
    ...options,
    default: WEEKDAYS.map((day) => ({ day, opens: null, closes: null })),
  })
}

2. Server validator, registered in config/cms.ts → fieldTypes:

ts
export const weeklyHoursType = defineFieldType({
  name: 'weeklyHours',
  validator: (field) =>
    vine
      .array(vine.object({ day: vine.enum(WEEKDAYS), opens: time(), closes: time() }))
      .fixedLength(7)
      .use(tidyHours()), // validators may also clean values up (field.mutate)
  mediaKeys: (value) => [], // optional: media this value references
})

3. Editor component, registered with the Vue plugin (fields: { weeklyHours: WeeklyHoursField }). Its contract:

Prop / eventTypeNotes
modelValuethe valueTreat it as immutable; emit a new value
fieldthe CustomFieldRead field.options for per-use configuration
errorsRecord<path, message>Paths relative to this field ("2.opens", "" for itself)
update:modelValue (emit)the new valueEvery emit becomes a draft and previews live

A custom type that's missing on either side fails loudly. The server throws at boot ("Unknown CMS field type"), and the editor shows "No editor registered for …".

Schema changes over time ​

Stored data is reconciled with the current schema on every read (see normalize), so most changes are free:

ChangeWhat happens to existing data
Add a fieldShows its default until edited
Remove a fieldIts stored value is ignored (and dropped on the next save)
Change a defaultShows everywhere it hasn't been edited
Tighten a rule (lower max)Existing values still render; the next save must satisfy the rule
Remove a select optionItems using it fall back to the default
Add a key to list itemsExisting items get that key's default
Rename a field⚠️ Looks like remove + add: the edited value is lost. See schema migrations
Change a field's type⚠️ Stored values of the old shape fall back to the default