Git CMS

Editing

Write posts in the admin. Learn about drafts, the Markdown editor, the post editor and its preview, and conflicts.

Editors write content in the Pluto admin at /admin. The CMS registers the post content type, so the generic list and form pages work. See Lists and forms.

To use your own content types, set plutoGithub.postType: false. The server then registers no post type, and the admin shows no Posts entry. A layer, such as the docs layer, registers its own types.

Drafts and publishing

A draft is a post with status: draft in its frontmatter. The public routes never show a draft.

The adapter sets the timestamps. A payload cannot set them. It sets publishedAt on the first publish only. Without a timestamps setting, the keys are createdAt and updatedAt.

In a public repository, anyone can read a draft on GitHub. The dashboard shows a warning for a public repository. Use a private repository if your drafts must stay private.

Who can do what

  • An anonymous caller sees published items only.
  • A signed-in caller sees drafts only with the read capability of the type (posts:read_drafts for posts). The capability * counts as every capability.
  • Every write needs a signed-in user (401). It needs the write or delete capability (403). A change of the published state needs the publish capability.
  • A user with push access gets the capabilities of every registered content type.

The Markdown editor

The body of a post uses a Markdown editor. The widget is PlutoFieldMarkdown, a richtext field widget. It uses UEditor from Nuxt UI, which is built on Tiptap. The model value is a Markdown string.

The toolbar has:

  • Undo and redo.
  • Headings 1 to 4.
  • Bulleted and numbered lists.
  • Quote and code block.
  • Bold, italic, strikethrough, and inline code.
  • A link button with a popover for the URL.

The image extension is on. The image button shows when the app has a media library. The mention extension is off.

The widget loads on demand, so Tiptap loads on the edit page only. The post type shows the widget in the post editor.

Visual and Source modes

The widget has a Visual and a Source button. Source is PlutoMarkdownSource: a monospace text area that grows with the text. It never parses or rewrites the text.

A richtext field can set editor to choose the first mode:

editorFirst mode
'auto' (default)Source when the text has syntax that the visual editor cannot keep. Visual otherwise.
'visual'Visual.
'source'Source.

findUnsafeMarkdown(text) lists that syntax. It gives one issue { kind, line } for each kind: mdc-block, mdc-inline, attributes, html, table, task-list, footnote, and fence-meta (a code fence with more than a language). Text in a code fence or in inline code is ignored.

While the text has such syntax, the Visual button is off and a tooltip says why. It turns on again when the author removes the syntax in Source. An explicit editor: 'visual' still opens Visual.

A layer can use PlutoMarkdownSource to build its own field widget. It has the props modelValue, disabled, placeholder, rows (default 18), maxRows, ariaLabel, and fill (fill the height of the parent, and scroll the text inside). It has a toolbar slot with the slot props insert and focus. The component exposes insert(text, { block }), focus(), and goToLine(line).

The post editor

The post type edits its body in the post editor, like the page editor of the docs layer. The editor fills the page between two panels:

  • The left panel shows the outline: the headings of the post. Click a heading to go to it in the editor and in the preview.
  • In the center, the compose panel is next to a live preview of the site. Drag the line between them to change their sizes. A Preview badge marks the preview. It fades out while the pointer is on the preview.
  • The footer of the compose panel has the Visual and Markdown switch. Visual mode shows the Markdown editor above. Markdown mode shows the Markdown source in a code editor with syntax colors and a format toolbar (PlutoMarkdownCodeEditor).
  • The right panel has the Post tab with the other fields: the title, the slug, the excerpt, and the cover image.

The editor keeps the last mode in a cookie. While the text has syntax that the visual editor cannot keep, the Visual button is off.

The post type sets it up:

fields: [
  // ...
  { name: 'content', type: 'richtext', widget: 'post-canvas' },
],
form: {
  layout: 'canvas',
  canvasField: 'content',
  leftPanel: { label: 'pluto.post.outline' },
},

The editor is in core, so any content type with a Markdown richtext body can use it. The Blog layer uses it too. In Visual mode it shows the richtext widget of the app with the canvas prop. A richtext widget of a layer must accept canvas: no label, and a column that fills the canvas.

The preview

The preview is a frame of the page /_pluto/post-preview of your site, so the post shows with the CSS of the site. The page has no admin bar, and search engines do not index it. Links in the preview do not open.

The page renders the Markdown with the same code as the public API. It shows the default component PlutoPostPreview: the title, the date, the excerpt, and the body. To use the layout of your post page, add app/components/PlutoPostPreview.vue with the same props:

app/components/PlutoPostPreview.vue
<script setup lang="ts">
defineProps<{
  contentType: string
  /** The fields of the post, without the body. */
  item: Record<string, unknown>
  /** The body as sanitized HTML. */
  html: string
}>()
</script>

<template>
  <article class="mx-auto max-w-3xl py-12">
    <h1>{{ item.title }}</h1>
    <PlutoHtml :html="html" data-pluto-post-body />
  </article>
</template>

Put data-pluto-post-body on the element that holds the body. The outline finds the headings in it to scroll the preview. The blog templates have this component. When you change your post page, change the preview too.

Limits of the editor

The visual editor knows CommonMark and strikethrough. It has no node for MDC, a GFM table, a task list, or raw HTML. Use Source mode to edit a post that has them.
  • The public page still renders tables and task lists from the stored Markdown.
  • The visual editor can write the Markdown with other marks than the author used. For example, it can use - for a bullet. The text means the same.

Build status

A save is a commit. When the commit starts a deploy, the site changes only after the build. The edit form shows the build of the last commit next to the Save button:

  • Publishing… with a spinner while the build runs.
  • Live with a check mark when the build passed.
  • Build failed when a check or a deployment failed.

The Info button opens the details: the commit, and each check run, commit status, and deployment, with a link to its run or build page. The status shows only with GitHub storage when commit.skipDeploy is off. When the host reports nothing for the commit, nothing shows.

The form asks again every 5 seconds while the build runs. It stops when the build ends, after 15 minutes, and while the browser tab is hidden. The route is GET /api/_pluto/github/build-status. It reads the check runs, the commit statuses, and the deployments of the head commit with the token of the user.

The GitHub App needs read access to Checks, Commit statuses, and Deployments. Without it, the details say so, and an App without any of them shows no status. See Add the build status permissions.

A content type that the site reads at run time sets liveOnSave, for example the pages of the docs layer in hybrid mode. The form then shows Live right after the save, with a small spinner by the Info button while the static build runs. If that build fails, the site still serves the change at run time.

Conflicts

Every item has a _version token. It is the blob id of the file.

  • An update with a stale _version gives 409. The form shows "This item changed since you opened it."
  • A create or a rename to a taken slug gives 409.
  • A rename is one commit. It adds the new path and deletes the old one. For a nested type, a new path in the slug field moves the file the same way. See Nested content types.
  • An update whose new file text equals the stored text makes no commit. The call returns the stored item with the same _version. A hand-written file with a different YAML layout is not equal, so the first real save rewrites it.
  • A commit uses a fresh head as expectedHeadOid. When the head is stale, the adapter retries up to 2 times. It retries only if the target file did not change. Otherwise it gives 409.

Errors

Store errors map to HTTP errors:

Store errorHTTP status
Conflict409
Auth401
Forbidden403
Rate limit503 with Retry-After
Not found404
GitHub failure502

Admin pages and sign-in

The CMS adds no page, no route middleware, and no navbar. Core owns the /admin routes. The CMS is the reference auth driver. See Admin shell.

The client side of the auth driver:

  • user() gives id, name, username (the GitHub login), and avatarUrl.
  • loggedIn() reads the session, which loads in the browser only. It is false on the server and in the first client render. The navbar appears after hydration.
  • signOut() posts to the logout route.

usePlutoGithubSession() gives { user, capabilities, repo, loggedIn, status, ready, refresh, logout }.