Documentation Menu

Components and the builder

9 min read

A component is a JSON Schema stored as the document _schemas/<name> and built field by field in the dashboard, and every save of a document bound to it is checked against it.

Why a body is checked against a shape

A body that can hold anything breaks the site that reads it. It breaks on the live page, too, long after the person who wrote it has moved on. A component states the shape once. The CMS refuses a body that does not fit at the moment it is saved, while its author can still fix it, and the same definition draws both the form a content manager fills in and the TypeScript types an engineer compiles against.

The definition is JSON Schema 2020-12, a standard that the API, the dashboard, the CLI and a repository can all hold. The builder writes it, so nobody has to write it by hand. The name differs by audience: the dashboard says component, while the API, the files and the code say schema.

What a component defines and what it refuses

A component has settings of its own and a list of fields. The CMS checks the definition before it stores it, and from then on it checks every body bound to the component, on save and on publish.

A component's settings are Name (lowercase letters, digits and dashes; it becomes the type name and is fixed once the component exists), Title (1 to 80 characters), Description (which becomes the doc comment in the generated types), Preview field (the text field that labels an instance in listings and pickers) and Singleton (one instance, at _singletons/<name>). A component lives in the project's default locale only. It is versioned and published like any other document.

Each field has a Name (letters, numbers and underscores; it is the key in the stored body), a Label of up to 80 characters, Help of up to 200, Required ("A body without this field is refused.") and Translatable ("Off keeps one value across every locale."). A field is one of twenty-two types, and the builder writes each of them as plain JSON Schema:

Group Type What the builder writes
Text Text, Long text, Markdown, Rich text, Code string; the last four carry the widget textarea, markdown, richtext or code
Values Number, Integer, Boolean, Select number, integer, boolean, a string with an enum
Values Date, Date and time, URL, Email a string with format date, date-time, uri or email
Media Image, Video, File an object with the widget image, video or file, holding { "$asset": "<id>" }
Links Link, Reference an object with the widget link (a web address or a document key), or with x-quillet.ref naming a component
Structure List, Group, Component, Block list an array of plain values, an inline object, a bare $ref, an array whose items is a oneOf of bare $refs

Code takes a language from a fixed list: plain, javascript, typescript, jsx, tsx, json, html, css, markdown, yaml, python, sql, shell. Image, Video and File take Accept (MIME types such as image/png, or families such as image/*, up to 20) and Largest file (MB). Rich text is sanitised on save against a fixed list of tags, so what is stored is what is served. Component, Block list and Reference have a page of their own.

Nesting has limits. The API refuses a breach when you save, and names the edge or the field that caused it:

  • Components embed one another at most five levels deep along $ref, and a component may not contain itself: "loop would contain itself through loop."
  • A Group is one level of nesting inside the component, and in the builder it holds 1 to 50 plain fields. It is not a component and carries no _component.
  • In the builder, a Block list allows up to 20 components.
  • An object's field order names at most 100 fields, and a body nests at most 64 levels.

The CMS checks the definition itself before storing it, and reports each problem as a 422 at its own path. The root is an object. A $ref is a bare component name, never a #/ pointer. There is no $id, and $schema is either absent or the 2020-12 URI. An x-quillet.order names every field of its object once. A pattern is at most 256 characters and cannot backtrack exponentially. x-quillet holds only keys and widgets the API knows. The names asset, link, ref, resolved-asset and schemas are taken, because the generated types use them.

Every save of a body is checked against the component's current version, and every publish against its published version. A refusal is 422 validation_failed, and it lists every failing field at once, each keyed by its path in the request, such as content.cta.link. A translation is checked on what a reader would get: its own fields merged over the default locale's. A field marked not translatable exists in the default locale only, and a translation that supplies one is refused with "This field is shared across locales; edit it in en-US."

How to

Dashboard

  1. Open the Components tab and press New › Component. The builder opens with the Name box focused.
  2. Type a Name, such as hero, and a Title. Add a Description if the component needs one.
  3. Press Add field, give the field a Name and a Label, and pick its Type. Tick Required where a body must have the field. To reorder fields, drag a row by its handle, or press Alt+Up and Alt+Down.
  4. Choose the Preview field: the text field that names an instance in lists.
  5. Press Save. That creates version 1 of _schemas/hero and takes you to the component's own page.
  6. The tabs above the form are Builder, JSON (the same draft, shown as the JSON Schema the API stores), Content preview (the component drawn with placeholder values or with one of its instances) and Instances (every document the component is in force for, each badged Valid or with a count of problems).
  7. Press Publish. A document bound to the component can be published only once the component is.

Each row of the library has a ⋮ menu with New instance, Duplicate, Versions (with Restore as new draft) and Delete.

API

Set the CMS address, the project, the environment and a CLI key with content:write and content:publish:

export QUILLET_API_URL=<cms-url> QUILLET_PROJECT=<project> QUILLET_ENV=<environment>
export QUILLET_MANAGEMENT_KEY=<keyId>:<secret>
E="$QUILLET_API_URL/api/v1/projects/$QUILLET_PROJECT/envs/$QUILLET_ENV"

Write two components. hero embeds cta with a bare $ref, and its sku is shared across locales. A component is written without ?locale=, so it lands in the default locale.

curl -s -X PUT "$E/documents/_schemas/cta" -u "$QUILLET_MANAGEMENT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","title":"Call to action","content":{
       "type":"object","title":"Call to action","required":["label","link"],"additionalProperties":false,
       "properties":{
         "label":{"type":"string","maxLength":40,"x-quillet":{"label":"Label"}},
         "link":{"type":"object","x-quillet":{"widget":"link","label":"Link"}}},
       "x-quillet":{"preview":"label","order":["label","link"]}}}'

curl -s -X PUT "$E/documents/_schemas/hero" -u "$QUILLET_MANAGEMENT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","title":"Hero","content":{
       "type":"object","title":"Hero","description":"The block at the top of a landing page.",
       "required":["headline"],"additionalProperties":false,
       "properties":{
         "headline":{"type":"string","maxLength":90,"x-quillet":{"label":"Headline"}},
         "prose":{"type":"string","x-quillet":{"widget":"richtext","label":"Text"}},
         "image":{"type":"object","x-quillet":{"widget":"image","accept":["image/*"],"label":"Picture"}},
         "sku":{"type":"string","x-quillet":{"label":"Product code","translatable":false}},
         "cta":{"$ref":"cta"}},
       "x-quillet":{"preview":"headline","order":["headline","prose","image","sku","cta"]}}}'

Now save an instance that does not fit:

curl -s -X PUT "$E/documents/components/heroes/spring-sale" -u "$QUILLET_MANAGEMENT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","schema":"hero","title":"Spring sale","content":{"cta":{"label":"Shop now"}}}'

Nothing is written, and every problem comes back at once:

{"error":"validation_failed","message":"Could not save the document.",
 "fields":{"content.headline":"Missing `headline`.","content.cta.link":"Missing `link`."}}

Fix it, read the library, and publish the components with the instance as one decision:

curl -s -X PUT "$E/documents/components/heroes/spring-sale" -u "$QUILLET_MANAGEMENT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","schema":"hero","title":"Spring sale","content":{"headline":"Spring sale",
       "prose":"<p onclick=\"steal()\">Twenty percent off<script>steal()</script></p>",
       "cta":{"label":"Shop now","link":{"href":"https://www.example.com/shop"}}}}'

curl -s "$E/schemas" -u "$QUILLET_MANAGEMENT_KEY"

curl -s -X POST "$E/publish" -u "$QUILLET_MANAGEMENT_KEY" -H 'Content-Type: application/json' \
  -d '{"documents":[{"key":"_schemas/cta"},{"key":"_schemas/hero"},{"key":"components/heroes/spring-sale"}]}'

The saved body has "prose":"<p>Twenty percent off</p>". The handler and the script were removed on the way in. GET …/schemas reports each component's fields, what it embeds and how many documents use it.

CLI

The CLI reads the same variables, or a .quilletrc:

export QUILLET_API_URL=<cms-url> QUILLET_PROJECT=<project> QUILLET_ENV=<environment>
export QUILLET_MANAGEMENT_KEY=<keyId>:<secret>

A component file is the JSON Schema alone. Write one, push it, and publish it:

cat > cta.json <<'EOF'
{
  "type": "object",
  "title": "Call to action",
  "required": ["label", "link"],
  "additionalProperties": false,
  "properties": {
    "label": { "type": "string", "maxLength": 40, "x-quillet": { "label": "Label" } },
    "link": { "type": "object", "x-quillet": { "widget": "link", "label": "Link" } }
  },
  "x-quillet": { "preview": "label", "order": ["label", "link"] }
}
EOF
npx quillet schemas put cta --file cta.json --message "Label and link"
npx quillet publish _schemas/cta --yes
npx quillet schemas list
npx quillet schemas validate cta

schemas list prints every component with its draft and published versions and how many documents use it, and it marks a page component with page. schemas validate measures every document the component is in force for and prints how many fail; --published measures the published versions instead. In a synced repository, the same file lives at _schemas/cta.en-US.json and is pushed with everything else.

Limits

  • There is no schema-change impact workflow. Saving a component re-checks nothing already stored: a body written under the old shape stays as it was until its next save, which is measured against the new one. Instances and schemas validate report what no longer fits, and they change nothing.
  • A component keeps the name it was created with. There is no rename, because every instance, every embedding component and the generated types all carry that name.
  • The builder has no control for some keywords. A component that carries one opens in the JSON tab, with Builder unavailable and a sentence naming the keyword. A field role and the page flag have no control either, but the builder keeps them when it saves.
  • The document form cannot edit every shape, such as an array of objects. It says so, and the JSON view still works.
  • The API checks a field's default and never applies it; only the dashboard uses it, to seed a new instance. Delivery serves a body exactly as stored.
  • A component cannot be deleted while any document is bound to it. The API answers 409 "This component is in use." and names the documents.
  • A component exists only in the default locale, so the project's default locale cannot change while any component exists.
  • Growing a block list from one allowed component to two makes its stored blocks unsavable until each block carries _component.

Last updated