Editing
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.
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_draftsfor 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:
editor | First 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:
<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 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
_versiongives 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 error | HTTP status |
|---|---|
| Conflict | 409 |
| Auth | 401 |
| Forbidden | 403 |
| Rate limit | 503 with Retry-After |
| Not found | 404 |
| GitHub failure | 502 |
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()givesid,name,username(the GitHub login), andavatarUrl.loggedIn()reads the session, which loads in the browser only. It isfalseon 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 }.