Skip to main content

Content Modeling

A post type declares the fields a post may have. It is what a curator's form is generated from, what an agent's write is validated against, and what a generated TypeScript type is derived from: one declaration, three consumers.


One name-keyed surface

Post types are referenced by name (Article, Page, PageLayout) everywhere: the REST API, MCP calls, the CLI, and the files on disk. There is no id-keyed twin; the one that used to exist was retired.

GET /api/v2/post-type # every type
GET /api/v2/post-type/Article # one type and its fields
POST /api/v2/post-type # create
PUT /api/v2/post-type/Article # replace the definition
POST /api/v2/post-type/Article/rename
DELETE /api/v2/post-type/Article

That choice is what makes a content model versionable: a type is a file called Article.json, and a diff of two revisions is readable.

Renaming is a move, not a create-plus-delete. POST /{name}/rename keeps the type's identity and its content. Deleting a type that content still points at answers a teaching 409 naming what is in the way, rather than orphaning posts.


Fields and widgets

Every field has a name, a label, and a widget. The widget decides three things at once: how a curator edits the value, how the value is stored, and how it projects to a file on disk.

WidgetFor
TextA single line
Text AreaPlain multi-line text
HTML EditorRich text. The first one on a type becomes the document body when the post projects to a Markdown file
Ace Editor - HTML · - CSS · - JavascriptCode. These project to sidecar files beside the Markdown, so a template is editable with real tooling
Check BoxA boolean
Combo BoxOne of a declared list of choices
Multi SelectSeveral values, tags, or a list of references (see below)
DateA date or timestamp
FileAn uploaded file, referenced by the File post it creates
Content SelectA reference to another post
RelatorA repeating group of sub-fields
TabLayout only: groups fields in the console form
HiddenStored, not shown

Two fields can be marked as the type's title and summary, which is what listings, search results and the {{#query}} helper's rows read.

Widget settings

Some widgets need configuration, and it travels as widgetSettings on the field: a JSON string. A Combo Box needs its choices; a Multi Select needs to say whether it holds tags or references:

{ "multiselect": { "references": "post" } }

That declaration is not cosmetic. It is what makes a composition verifiable, with it, shio verify resolves each value as an address and reports the ones that do not exist. A Multi Select without it stays a tag list, because reading every tag as a URL would report a defect for every tag on every post.


Relationships

You wantUse
One post to point at anotherContent Select
An ordered list of other posts (a page composed of sections, say)Multi Select with references: "post"
Repeating structured rows inside one postRelator, with its own sub-fields
A fileFile, which creates a File post you can address like anything else

Sections are the ordered-list case: a Page names its sections by friendly URL, in order, and a region's template walks them with {{#getRelation}}. See Blueprints for the shipped section vocabulary.


System post types

Some types are created by Shio itself and are read-only in the console:

TypeFor
TextGeneral-purpose content
FileAn uploaded asset, with its own dimension and checksum fields
PageLayout · Region · ThemeThe render model
RedirectOne old path in, one address out
SiteScriptsA site's third-party script list, in load order

The render types are created on demand: POST /api/v2/render/post-types, or the render.provision op, rather than at install time, so provision them once before writing a layout. They are otherwise ordinary content: exportable, projectable, lintable.


Asking what a model change would cost

A post type change is a decision with alternatives, not a yes/no: making a field required is either a data cleanup or a mistake, and only the number of posts that would violate it tells you which. Send the definition you are considering with ?dryRun=true and get the cost instead of the change:

PUT /api/v2/post-type/Article?dryRun=true
FindingWhat it tells you
required-field-violatedhow many published posts have no value for a field you are making required, with a sample
removed-field-still-reada template interpolates a field this change removes, so those pages would render nothing there
declared-field-unreadfields no template reads. Not a defect — a field can be delivered over the CDA — but it is the list to check before adding another
stored-key-undeclaredkeys the content stores that no field declares, so nothing validates them and the console cannot edit them

The last two are true of the model as it stands, so sending a type back to itself is a way to review one nobody is currently changing.

Modelling from content you already have

Writing a post type from nothing means naming fields before you know what they hold. If three pages of the shape already exist, ask what they have in common instead:

shio propose --from post:mysite/products/one --from post:mysite/products/two --from post:mysite/products/three --name Product

Every proposed field says how many of the instances carry it and names the ones that do not — a field two of three share is a different proposal from one all three do. A field only a minority carries is reported separately rather than folded in, because it is more likely another type's field than an optional one. The report ends by saying it is evidence about the instances it read, which is the honest limit of the method.

The model as code

A content model is a deployable artefact, not console state.

shio pull # the live model → shio/post-types/*.json
shio push --dry-run # what would change
shio push # apply
shio push --check # exit non-zero if git and the server disagree (CI)

Serialization is canonical and byte-identical: pull twice and nothing changes, so a diff means a real difference.

Authoring in TypeScript

shio/post-types/ also accepts .ts and .mjs modules written with @viglet/shio-model, and the CLI compiles them to exactly the same canonical JSON:

import { postType, text, htmlEditor, comboBox, multiSelect } from "@viglet/shio-model";

export default postType("Article", {
fields: [
text("TITLE", { title: true, required: true }),
htmlEditor("TEXT"),
comboBox("CATEGORY", { choices: { tech: "Technology", biz: "Business" } }),
multiSelect("SECTIONS", { references: "post" }),
],
});

The point is compile-time validation: comboBox requires its choices, so a missing one is a TypeScript error in your editor rather than a 400 from a push. shio build compiles without touching the server, if you want to inspect the output.

Managed by code

Any push marks the types it wrote as managed by code. The console then shows them read-only, badge, disabled form, and a 409 on a console PUT as the server-side backstop. That is deliberate: it makes the boundary between "owned by git" and "edited by hand" explicit instead of a last-writer-wins race.

Generated TypeScript for a front end

shio types --out types/shio.d.ts

Produces one interface per post type from the live model, so post.attrs.TITLE has autocomplete in your front end. See JavaScript Client.


Addressing content

A model is only useful if you can name what it produced:

FormNames
post:mysite/blog/hello-worldone post
folder:mysite/blogone folder
site:mysitea site
id:9f8c…anything, by raw id

Writes take only post: and folder: (plus site:), and a site's home page is post:mysite/. A post's friendly URL is its own field (not its folder path) so reorganising folders does not rewrite URLs.


Publishing

A post has two states, DRAFT and PUBLISHED, and can be in both at once. Editing a published post updates its draft and leaves the live version alone until something publishes it. Publishing can be scheduled. Deleting moves the post to the trash. See Core Concepts, and Preview, history and scheduling for the curator's tools that act on those states.

A post also carries a locale, and a translation is a linked sibling post rather than a field on this one — which decides your URL structure, so read Content in several languages before writing the second language.


Search fields

A field can be mapped to a Turing Semantic Navigation field, title, description, text, date, URL, image, or to a custom field for facets. That mapping only matters if you have enabled Turing indexing, which is off by default; see Search & Caching.


PageDescription
Core ConceptsSites, folders, posts, states
The shio CLIpull, push, build, types
Content as FilesHow posts and code fields project to disk
The Agent Surfacepost.upsert and the field validation it applies
Pages, Layouts & RegionsThe render types in use