---
title: "Extensions | Rigel CMS docs"
url: "https://rigelcms.dev/docs/extensions"
description: "Build Svelte article blocks with custom toolbar buttons, grouped commands, validation and saved widget data."
---

Svelte extensions are available since Rigel 1.1.0. Follow [Getting started](/docs/getting-started/) to install or upgrade before using these examples.

Extensions are TypeScript modules you register in your Astro configuration. They can add article blocks, slash commands and toolbar buttons. A block has a Svelte editor for changing its data and a separate Svelte renderer for published HTML and read-only previews.

A saved widget is a block’s registered name plus JSON data in the article body. Components and commands belong to your application code, not the saved content. Extensions are compiled with the site; Rigel does not load plugins or install code at runtime.

## Register an extension

Create the files below in your Astro site. In `astro.config.mjs`, add the extension path to your existing `rigel()` options:

```
import { defineConfig } from "astro/config";
import { rigel } from "@rigel-cms/rigel";

export default defineConfig({
  integrations: [
    rigel({
      extensions: ["./src/rigel/callout.ts"],
    }),
  ],
});
```

Paths are relative to the Astro project root. Keep your existing schema, collections, backend and preview options. Restart the development server after changing the extension list.

Rigel registers the Svelte compiler for its editor and extension modules. Install `@astrojs/svelte` in your site if you want `astro check` to typecheck your Svelte components. The examples use Svelte 5 and need no icon library.

## Define a saved block

Create `src/rigel/callout.ts`:

```
import { defineExtension } from "@rigel-cms/rigel/extensions";
import CalloutEditor from "./CalloutEditor.svelte";
import Callout from "./Callout.svelte";

export default defineExtension({
  name: "site-callouts",
  commands: [{
    name: "insert-callout",
    label: "Insert callout",
    slash: "callout",
    toolbar: true,
    run(context) {
      context.insertBlock("callout", {
        title: "Good to know",
        message: "A note for readers.",
        tone: "info",
      });
    },
  }],
  blocks: [{
    name: "callout",
    label: "Callout",
    editor: CalloutEditor,
    render: Callout,
    validate(data) {
      if (typeof data.title !== "string" || data.title.length > 140) {
        throw new Error("Callout title must be at most 140 characters.");
      }
      if (typeof data.message !== "string" || data.message.length > 2000) {
        throw new Error("Callout message must be at most 2000 characters.");
      }
      if (data.tone !== "info" && data.tone !== "warning") {
        throw new Error("Choose an info or warning tone.");
      }
    },
  }],
});
```

`insertBlock` uses the block’s registered name, not the module name. This command appears as a standalone toolbar button and as `/callout` in the slash menu. A command can also read `context.selection`, insert plain text with `insertText(text)`, or replace the selected custom block’s complete data with `updateBlock(data)`.

### Edit the block

Create `src/rigel/CalloutEditor.svelte`:

```
<script lang="ts">
  import type { ExtensionBlockProps } from "@rigel-cms/rigel/extensions";
  import Callout from "./Callout.svelte";

  let { data, onchange }: ExtensionBlockProps = $props();
</script>

<div class="callout-editor">
  <label>
    Title
    <input maxlength="140" value={String(data.title)}
      oninput={event => onchange({ ...data, title: event.currentTarget.value })} />
  </label>
  <label>
    Message
    <textarea maxlength="2000" rows="3" value={String(data.message)}
      oninput={event => onchange({ ...data, message: event.currentTarget.value })}></textarea>
  </label>
  <label>
    Tone
    <select value={String(data.tone)}
      onchange={event => onchange({ ...data, tone: event.currentTarget.value })}>
      <option value="info">Info</option>
      <option value="warning">Warning</option>
    </select>
  </label>
  <Callout {data} />
</div>

<style>
  .callout-editor { display: grid; gap: 0.75rem; padding: 1rem; border: 1px solid currentColor; }
  label { display: grid; gap: 0.25rem; }
  input, textarea, select { box-sizing: border-box; width: 100%; font: inherit; }
</style>
```

Call `onchange` with the complete next object instead of mutating `data`. Rigel validates the update before accepting it into the draft. This example permits empty text while an author types. If your validator rejects incomplete input, keep that input in local component state until it is valid and show a useful error.

The nested `Callout` gives authors a rendered view beside the inputs. Editing changes the in-memory draft, not the stored article. The author must click Save to persist it.

### Render the block

Create `src/rigel/Callout.svelte`:

```
<script lang="ts">
  import type { BlockData } from "@rigel-cms/rigel/extensions";
  let { data }: { data: BlockData } = $props();
</script>

<aside class="callout" class:warning={data.tone === "warning"}>
  {#if data.title}<strong>{String(data.title)}</strong>{/if}
  <p>{String(data.message)}</p>
</aside>

<style>
  .callout { padding: 1rem; border: 1px solid currentColor; border-inline-start-width: 4px; }
  .warning { border-inline-start-style: double; border-inline-start-width: 6px; }
  p { margin: 0.5rem 0 0; white-space: pre-wrap; }
</style>
```

The renderer receives `data` only. Keep it synchronous for Rigel’s string-returning article renderer. Use ordinary Svelte interpolation for content; `{@html}` bypasses escaping and should not receive untrusted values.

## Group multiple commands

A group collects commands under one toolbar trigger. Create `src/rigel/widgets.ts` to offer two presets of the existing `callout` block:

```
import { defineExtension } from "@rigel-cms/rigel/extensions";
import WidgetsButton from "./WidgetsButton.svelte";

export default defineExtension({
  name: "site-widgets",
  groups: [{ name: "widgets", label: "Widgets", button: WidgetsButton }],
  commands: [{
    name: "insert-info-widget",
    label: "Insert information",
    slash: "info-widget",
    group: "widgets",
    run: context => context.insertBlock("callout", {
      title: "Good to know", message: "Add a useful note.", tone: "info",
    }),
  }, {
    name: "insert-warning-widget",
    label: "Insert warning",
    slash: "warning-widget",
    group: "widgets",
    run: context => context.insertBlock("callout", {
      title: "Before you begin", message: "Explain what to watch out for.", tone: "warning",
    }),
  }],
});
```

Register both modules in the same `rigel()` configuration:

```
extensions: ["./src/rigel/callout.ts", "./src/rigel/widgets.ts"],
```

Assign a command with the string `group: "widgets"`. A group assignment implies toolbar visibility unless the command sets `toolbar: false`. The group menu contains only its assigned, toolbar-visible commands; `/info-widget` and `/warning-widget` still work independently. The original `/callout` command keeps its standalone button. Remove its `toolbar: true` if you want only the grouped presets on the toolbar.

Commands can reference groups and blocks declared in other registered modules. Group references resolve regardless of module order. An unknown group name is a configuration error, and an empty group does not create a toolbar button.

### Use an icon trigger

Create `src/rigel/WidgetsButton.svelte`:

```
<script lang="ts">
  import type { ExtensionButtonProps } from "@rigel-cms/rigel/extensions";
  let { label, disabled, onclick }: ExtensionButtonProps = $props();
</script>

<button type="button" {disabled} {onclick} aria-label={label} data-tooltip={label}
  onpointerdown={event => event.preventDefault()}>
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none"
    stroke="currentColor" stroke-width="1.8" aria-hidden="true">
    <rect x="3" y="3" width="7" height="7" />
    <rect x="14" y="3" width="7" height="7" />
    <rect x="3" y="14" width="7" height="7" />
    <rect x="14" y="14" width="7" height="7" />
  </svg>
</button>
```

`onclick` toggles the group menu. Forward `disabled` and provide the accessible `label`; the SVG is decorative. Preventing pointer-down’s default action keeps the article selection while the author uses the toolbar. Native button keyboard activation still works.

Both groups and commands accept an optional `button` component with these props. To customize a standalone command, import your button into its module and set `button: WidgetsButton` on the command. Omit `button` to use Rigel’s default trigger. A custom button changes presentation, not command registration or slash behavior.

### Choose unique identifiers

Use lowercase identifiers beginning with a letter, followed by letters, digits or hyphens, up to 64 characters. Rigel rejects reserved names. Across all selected modules, module names must be unique, command and block names share a unique namespace, group names must be unique, and slash names must be unique. Built-in command and block identifiers are reserved for commands, blocks and slash shortcuts.

If your site already registers a callout or Widgets example, adapt that module instead of registering these examples alongside it with duplicate names. Changing a saved block’s name requires migrating the articles that use it.

## Render articles and previews

Import the public `@rigel-cms/rigel/article` entry in your Astro article template. For a template receiving an article `body` prop:

```
---
import { renderArticle } from "@rigel-cms/rigel/article";

const { body } = Astro.props;
let head = "";
const html = renderArticle(body, { onHead: value => { head = value; } });
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Article</title>
    <Fragment set:html={head} />
  </head>
  <body>
    <article data-rigel-richtext="$body" set:html={html} />
  </body>
</html>
```

Keep your existing article loading and layout. `body` is the structured article body, not a Markdown string. `renderArticle` validates registered blocks and server-renders their Svelte `render` components to HTML. It returns a string. The `onHead` callback captures component head output, including CSS emitted by Svelte, for placement in the page head. Without it, Rigel includes that output in the returned string. Build-extracted CSS remains the build tool’s responsibility.

For the editor canvas, prepare your routes and `$body` binding as described in [Preview](/docs/preview/). Rigel renders the current draft with `renderArticle(body, { preview: true })` as escaped block markers, sanitizes the article HTML, then mounts the registered read-only render components. The editable article uses `editor`; read-only previews use `render`. This does not enable arbitrary site scripts inside the preview.

### Check editing and Save

With Rigel 1.1.0 or later and both modules registered:

-   Restart the Astro development server and open an article in `/rigel/`.
-   Insert `/callout`, or open Widgets and select Insert warning.
-   Change the message and tone. Check the rendered block in the editor and the read-only preview.
-   Click Save, reopen the article and confirm the values remain. The saved body contains the block name and data, not the Svelte component.
-   Build the site and check the article route. It should contain the rendered `<aside>` and saved text, rather than an extension marker.

Save uses the configured backend. With the GitHub backend it saves to the workspace branch; publishing remains a separate step. See [GitHub publishing](/docs/github-publishing/).

## Saved data and validation

A custom block is a root-level article node with no children:

```
{
  "type": "extension",
  "version": 1,
  "name": "callout",
  "data": {
    "title": "Before you begin",
    "message": "Keep a backup.",
    "tone": "warning"
  }
}
```

`data` must be a plain JSON object. Rigel limits each block’s data to 100,000 UTF-8 bytes, a nesting depth of 16 and 10,000 visited values, including the root. Numbers must be finite. Cycles, functions, class instances, sparse arrays and unsafe property names such as `__proto__`, `constructor` and `prototype` are rejected.

Those checks bound the payload; your required `validate(data)` checks its schema and domain rules. Throw a useful error for invalid data. HTML input limits are a convenience, not a substitute for validation. Validate URL schemes or restrict destinations to a known safe local-path format if your block has links. Escaping an HTML attribute does not make a `javascript:` URL safe.

Extension modules and Svelte components are trusted application code. Review them like dependencies. Article content supplies only a registered name and bounded data, never an import path or executable code.

### Preserve blocks when an extension is missing

An unknown block remains in the structured article data and appears as an unavailable-extension fallback in the editor. Preservation does not mean it can render: published HTML throws when no renderer is registered. Keep the extension installed while articles use its blocks, or migrate those articles before removing it.

For Markdown output, import `articleMarkdown` from `@rigel-cms/rigel/article` and call `articleMarkdown(body)`. A block can define `toMarkdown(data)` to supply its representation. Without that callback, Rigel writes a fenced `rigel-extension` placeholder containing the block name and complete JSON data. Unknown blocks also retain their data in this Markdown fallback. The callout example deliberately uses the fallback so exports preserve its tone and fields.

Documentation

On this page