Skip to content

Cloud

Configure how the qino cloud UI publishes, searches, sorts, hides fields, and saves uploads.

View Markdown

The cloud UI is in development and not available yet. Setting cloud won't do anything for now: qino build writes the options to the config file, but nothing reads them yet.

The qino cloud UI lets editors change content without touching code. Its only contract with your project is qino/_generated/config.json. Set cloud on initQino and qino build writes that file, including the cloud options below. See Config file.

qino/index.ts
import { initQino } from "@qino/cms";

export default initQino({
  contentFolder: "src/content",
  mediaFolder: "public",
  cloud: {
    publish: { mode: "open_pull_request", reviewBeforePublish: true },
  },
});

Publish

publish.mode decides what happens when an editor publishes:

  • "open_pull_request": the cloud UI pushes a branch and opens a pull request. Branches start with branchPrefix, "qino/" by default.
  • "commit_to_default_branch": the cloud UI commits to the repository's default branch. branchPrefix is a type error here.

publish.reviewBeforePublish is required. When true, an approver signs off in qino before the author can publish, in either mode.

Pass an object instead of true to configure review. bypassIfAdmin lets admins publish before every entry is approved, false by default:

qino/index.ts
publish: {
  mode: "open_pull_request",
  reviewBeforePublish: { bypassIfAdmin: true },
},

In config.json, reviewBeforePublish is false, or its settings with defaults filled in: true becomes { "bypassIfAdmin": false }.

Until an editor publishes, edits are saved as drafts in the cloud UI, not in the repository.

Primitives

Each primitive takes its own cloud option. Setting it without cloud on initQino throws at definition.

qino/collections/posts.ts
export const postCollection = qino.defineCollection({
  directory: "posts",
  extension: ".md",
  schema: z.object({
    title: z.string(),
    draft: z.boolean(),
    cover: z.string(),
    notes: z.string().optional(),
  }),
  media: { cover: { accepted: "image/webp", maxSize: "5MB" } },
  cloud: {
    displayAs: "Blog posts",
    displayEntryAs: "{title}",
    saveEntryAs: "{title}",
    searchableBy: ["title"],
    sortableBy: ["title", "draft"],
    hiddenFields: ["notes"],
    media: { saveFileAs: "posts/{field}/{name}-{hash}" },
  },
});
OptionCollectionsTreesItemsDefaultDescription
displayAsyesyesyesThe idName of the primitive in the cloud UI, such as "Blog posts". A plain string, without tokens
displayEntryAsyesyes"{<titleField>}" on treesLabel of entries in the cloud UI, from their fields, such as "{firstName} {lastName}". Unset on collections, the cloud UI shows the slug
saveEntryAsyesyesName of new entries, from their fields, such as "{firstName}-{lastName}". Unset, editors type the slug
searchableByyesyes[]Top-level fields the cloud UI searches. Empty, there's no search
sortableByyes[]Top-level string, number, or boolean fields the cloud UI sorts by. Empty, no sorting
hiddenFieldsyesyesyes[]Top-level optional fields the cloud UI doesn't show. Their values are kept on save
media.saveFileAsyesyesyes"{id}/{name}-{hash}"File path an upload is saved to when publishing, relative to mediaFolder

Trees are ordered by their order file, so they have no sortableBy. Items are a single file, so they have no saveEntryAs or displayEntryAs, and only take displayAs, hiddenFields, and media.

Which fields hold media, and the files they accept, isn't cloud config: it's the primitive's own media, which works without cloud. The cloud UI reads it from config.json to offer a media picker on each media field and accept only matching uploads, in fields and in the body.

Fields are typed against the schema input, not its output, since the cloud UI reads files on disk: with a transform that renames created-on to createdOn, use "created-on". Fields whose input is unknown, such as z.coerce.date(), can't be sorted by. qino build checks the same rules against the schema's JSON Schema, and also fails when a sortableBy field allows a non-scalar value in any union branch.

Naming new entries

saveEntryAs names the file of an entry an editor creates, from its fields. Each {token} is a top-level field, filled with the value the editor enters, then slugified. Text outside tokens is kept:

saveEntryAsFieldsFile
"{firstName}-{lastName}"firstName: "Jane", lastName: "Doe"jane-doe.json
"post-{title}"title: "Hello, World!"post-hello-world.md

The cloud UI adds the extension, and in trees the parent folder, from where the entry is added in the outline: "{title}" names a child "Caching" under guides guides/caching.mdx. When the name is taken, it adds -2, -3, and so on. The name is set once, when the entry is created. Unset, editors type the slug.

Tokens must be fields every entry has: required, without a default, in every variant of a union schema, and a string, number, or boolean. saveEntryAs is type-checked from its literal, so an unknown or optional field, a / or ., or a pattern without a token is a type error. qino build checks the same rules, including for a saveEntryAs passed as a string variable, which types can't check.

Naming primitives and entries

displayAs names the primitive itself, such as "Blog posts" for the posts collection, where the cloud UI lists primitives and titles their pages. It's a plain string: qino build fails on an empty one or one with a {field}. Unset, it's the id: the directory, or the file of an item. An item is a single entry, so displayAs names that entry too.

displayEntryAs is how the cloud UI labels each entry of a collection or tree, from its fields. It uses the same {token} syntax as saveEntryAs, but values aren't slugified, and text outside tokens can be anything:

displayEntryAsFieldsLabel
"{firstName} {lastName}"firstName: "Jane", lastName: "Doe"Jane Doe
"{lastName}, {firstName}"firstName: "Jane", lastName: "Doe"Doe, Jane
"Post: {title}"title: "Welcome"Post: Welcome

Unset, collections' entries show their slug, and trees fall back to titleField, which titles nodes in getTree too. Tokens follow the same rules as saveEntryAs's: required fields every entry has, and strings, numbers, or booleans. They're type-checked from the literal, and qino build checks them too.

Hidden fields

hiddenFields only takes optional fields, .optional() or .default() in zod, so editors can still create entries: a required field is a type error, and qino build fails on one without a default. Hidden fields can also be in searchableBy and sortableBy, but never in saveEntryAs or displayEntryAs, whose fields are required.

Media fields

The cloud UI edits each field of the primitive's media with a media picker, and accepts uploads of its type and size. markdown rules apply to images added to the body. A media path must also be a field of the schema input, as with relations: qino build fails when a transform renames it.

The cloud UI writes the URL the file is served at, starting with /: picking the upload saved at public/posts/cover/cover-1a2b3c4d.webp writes "/posts/cover/cover-1a2b3c4d.webp". That's the URL qino reads images from and qino check resolves against mediaFolder.

Saving uploads

When an editor publishes, each uploaded file is saved to the path media.saveFileAs describes, relative to mediaFolder. As with entries' saveEntryAs, the cloud UI adds the extension: the uploaded file's, lowercased. With mediaFolder: "public" and saveFileAs: "posts/{field}/{name}-{hash}", uploading Cover.webp to the cover field saves public/posts/cover/cover-1a2b3c4d.webp, served at /posts/cover/cover-1a2b3c4d.webp. Tokens:

TokenValue
{id}The primitive's directory, or its file without the extension for items
{field}The media path the file is uploaded to, slugified: cover, gallery for "gallery[*]", seo-image for "seo.image", markdown for the body
{name}The uploaded file's name without its extension, slugified
{hash}The first 8 hex characters of the file's SHA-256

With {hash}, an upload never overwrites another file, and each URL always serves the same content, so it can be cached for good. qino build fails on an unknown token, a . in the file name, a path ending with /, or a path starting with / or containing ...

In config.json

config.json holds every value resolved, so the cloud UI doesn't reimplement defaults: branchPrefix, reviewBeforePublish's settings, and media.saveFileAs are filled in, and lists default to []. Each primitive's media sits next to its relations, with maxSize in bytes, so "5MB" becomes 5242880. displayAs and displayEntryAs sit at the top level of the primitive, next to titleField. displayAs defaults to the id, and trees' displayEntryAs to "{<titleField>}". Collections leave displayEntryAs out when unset.

qino/_generated/config.json
{
  "cloud": {
    "publish": {
      "mode": "open_pull_request",
      "reviewBeforePublish": { "bypassIfAdmin": false },
      "branchPrefix": "qino/"
    }
  },
  "collections": {
    "posts": {
      "displayAs": "Blog posts",
      "displayEntryAs": "{title}",
      "media": {
        "cover": { "accepted": "image/webp", "maxSize": 5242880 }
      },
      "cloud": {
        "searchableBy": ["title"],
        "sortableBy": ["title", "draft"],
        "hiddenFields": ["notes"],
        "media": { "saveFileAs": "posts/{field}/{name}-{hash}" }
      }
    }
  }
}