Extensions
Svelte extensions are available since Rigel 1.1.0. Follow 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. 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.
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.