Layers

Blog (Supabase)

@plutocms/supabase-blog adds a post admin with a rich text editor.

@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:

Terminal
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-blog

Then extend only this layer:

nuxt.config.ts
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

ColumnMeaning
idbigint, primary key
created_atSet by the database
updated_atSet by the API on every create and edit
slugUnique. It forms the public path /posts/<slug>, the same as the core blog.
titleRequired
contentThe body, in Markdown. It can hold inline images.
coverText. The id of a media record, or null.
statusdraft or published. The default is draft.
published_atNull 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):

CapabilityAllows
posts:read_draftsRead a row where status = 'draft'. An anonymous visitor reads only published rows, with or without a capability.
posts:publishInsert a row, or update any row
posts:deleteDelete any row

An admin holds all three. There is no capability for ownership. A holder of posts:publish can edit every post.

Admin views

RoutePurpose
/admin/postsList posts
/admin/post/newCreate a post
/admin/post/edit/:idEdit 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 no content. It has excerpt, the start of the body as plain text.
  • GET /api/pluto/v1/content/post/<id or slug>. One published post. It has the stored content and html: the body rendered and sanitized, the same way as PlutoProse. 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" />
PropDefaultMeaning
contentnoneThe stored value
format'auto''auto', 'markdown', 'html', or 'json'
tag'div'The wrapper element
classnoneMerged onto the wrapper

The slot empty shows when the result is empty.

The pipeline

  1. Detect. detectRichtextFormat() guesses if the value is Markdown, HTML, or Tiptap JSON.
  2. Convert. renderRichtext() makes HTML. Markdown goes through marked with GFM. JSON goes through a small serializer. HTML passes through.
  3. Sanitize. Every path ends with an allowlist sanitizer.
  4. 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.

The format detection is a guess. Markdown that starts with a raw HTML block can be detected as HTML. Set the format prop when you know the format.

Sanitizer rules

  • Kept elements: p, h1 to h6, strong, b, em, i, s, del, u, code, pre, blockquote, ul, ol, li, hr, br, a, img, tables, and checkbox input.
  • 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 and style.
  • href and src accept a relative path, #anchor, ?query, http:, https:, mailto:, and a data:image/* URL for png, jpeg, gif, webp, and avif. data:image/svg+xml is never accepted.

Limits

  • Links get no automatic target or rel.
  • 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.