Blog (Supabase)
@plutocms/supabase-blog adds blog posts on top of the Supabase layer. An admin can create, edit, list, and delete posts. A post has a title and a rich text body.
Install
The quickest start is the blog template with the Supabase backend:
npm create pluto@latest my-blog -t blog --backend supabase
The same template works with the Git CMS. See Create a project.
In an existing app, install the package first. extends does not install it.
npm install @plutocms/supabase-blogpnpm add @plutocms/supabase-blogyarn add @plutocms/supabase-blogbun add @plutocms/supabase-blogdeno add npm:@plutocms/supabase-blogThen extend only this layer:
export default defineNuxtConfig({
extends: ['@plutocms/supabase-blog']
})
The layer extends @plutocms/supabase (which extends @plutocms/core). Do not add them to extends and do not install them: the package manager installs them with the layer. You need the Supabase environment variables and the setup of the Supabase layer. The images (the cover, and the images in the body) use the media library of core.
The posts table
| Column | Meaning |
|---|---|
id | bigint, primary key |
created_at | Set by the database |
updated_at | Set by the API on every create and edit |
slug | Unique. It forms the public path /posts/<slug>, the same as the core blog. |
title | Required |
content | The body, in Markdown. It can hold inline images. |
cover | Text. The id of a media record, or null. |
status | draft or published. The default is draft. |
published_at | Null until the first publish. The API sets it on the first publish only. |
A post that is unpublished and then published again keeps its first publish date.
Capabilities
Row level security uses three capabilities, checked with public.has_capability(cap):
| Capability | Allows |
|---|---|
posts:read_drafts | Read a row where status = 'draft'. An anonymous visitor reads only published rows, with or without a capability. |
posts:publish | Insert a row, or update any row |
posts:delete | Delete any row |
An admin holds all three. There is no capability for ownership. A holder of posts:publish can edit every post.
Admin views
| Route | Purpose |
|---|---|
/admin/posts | List posts |
/admin/post/new | Create a post |
/admin/post/edit/:id | Edit a post |
These pages use the generic content UI of core with the post content type. The type uses the canvas layout and the post editor of core (widget: 'post-canvas' on the content field). See The post editor.
- The compose panel is next to a live preview of the site, in both modes.
- Visual mode shows the rich text editor of this layer, with the image upload.
- Markdown mode shows the Markdown source in a code editor with syntax colors.
- The left panel shows the outline of the post and the mode switch.
- The title and the slug are in the Post tab of the right panel.
- The toolbar shows the status (Draft or Published), Save draft or Update, Publish, and an overflow menu with Unpublish and Delete.
See Lists and forms.
The rich text editor
The editor builds a Tiptap editor directly. It does not use the UEditor component of Nuxt UI. UEditor serializes the whole document to Markdown on every keystroke. Under fast typing, that work queues up and freezes the page.
The editor keeps the document as native ProseMirror state while the user types. It writes Markdown to the model only after a 400 ms pause, and on blur.
The editor has a fixed toolbar, a bubble toolbar, a suggestion menu, and a block menu in the drag handle. Mention, Emoji, Horizontal Rule, and Text Align are not in the editor.
Images
The image button opens the media picker of core, with images only. The user picks a file or uploads one. The browser makes a photo WebP, at most 2400 px, with no location data. The editor then inserts the image into the body with its public URL.
The cover field is a media field with accept: ['image/*']. It stores the id of a media record in posts.cover. The public API gives the record in place of the id: url, width, height and alt. Show it with PlutoImage:
<PlutoImage :media="post.cover" sizes="xs:100vw lg:896px" class="h-auto w-full" />
Public site
The public site reads posts through the Public API. The post type is public.
GET /api/pluto/v1/content/post. The published posts. A list item has nocontent. It hasexcerpt, the start of the body as plain text.GET /api/pluto/v1/content/post/<id or slug>. One published post. It has the storedcontentandhtml: the body rendered and sanitized, the same way asPlutoProse. A draft or a missing post gives 404.
In Nuxt, use usePlutoPublicList('post') and usePlutoPublicItem('post', slug). A site in another stack calls the routes. Show html with the core component PlutoHtml (it works with every backend, so a page works with the Git CMS too), or with PlutoProse and format="html".
<PlutoHtml :html="post.html" />
PlutoProse
PlutoProse shows a stored rich text value (Markdown, HTML or Tiptap JSON) as sanitized, styled HTML on a public page. It does not create a Tiptap instance. For the html of the public API, PlutoHtml of core is enough.
<PlutoProse :content="post.html" format="html" />
| Prop | Default | Meaning |
|---|---|---|
content | none | The stored value |
format | 'auto' | 'auto', 'markdown', 'html', or 'json' |
tag | 'div' | The wrapper element |
class | none | Merged onto the wrapper |
The slot empty shows when the result is empty.
The pipeline
- Detect.
detectRichtextFormat()guesses if the value is Markdown, HTML, or Tiptap JSON. - Convert.
renderRichtext()makes HTML. Markdown goes throughmarkedwith GFM. JSON goes through a small serializer. HTML passes through. - Sanitize. Every path ends with an allowlist sanitizer.
- Output. The component sets the HTML as
innerHTML.
richtextToPlainText() runs the same pipeline and strips the result to plain text. Use it for an excerpt or a meta description.
format prop when you know the format.Sanitizer rules
- Kept elements:
p,h1toh6,strong,b,em,i,s,del,u,code,pre,blockquote,ul,ol,li,hr,br,a,img, tables, and checkboxinput. - Dropped with their children:
script,style,iframe,object,embed,svg,form,video, and others of the same kind. - Any other element is unwrapped.
- Every attribute is removed except a short list for each element. This removes
on*handlers andstyle. hrefandsrcaccept a relative path,#anchor,?query,http:,https:,mailto:, and adata:image/*URL forpng,jpeg,gif,webp, andavif.data:image/svg+xmlis never accepted.
Limits
- Links get no automatic
targetorrel. - Images get no automatic
loading="lazy". - The JSON path understands only the nodes that the editor can produce. An unknown node renders as its children.
Translation
The admin is translated to en and pt-BR in the supabase-blog namespace. PlutoProse has no UI text. The public site language belongs to your app. See Internationalization.