Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/rich-dancers-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds synced section references. Editors can now insert a section as a live reference rather than a copy — the section's current content is resolved at render time, so edits to the section propagate automatically to every post that references it.

- New `emdash-section-ref` Portable Text block type rendered by `<SectionRef>` in `emdash/ui`
- New `getSectionById(id)` public API function
- Section picker modal gains an **Insert copy** / **Synced reference** mode toggle
48 changes: 41 additions & 7 deletions docs/src/content/docs/guides/sections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,43 @@ Editors insert sections using the `/section` slash command in the rich text edit

2. Search or browse available sections

3. Click to insert the section's content at the cursor position
3. Choose an insertion mode (see below), then click a section to insert it

</Steps>

The section's Portable Text content is copied into the document. This means:
### Insertion Modes

- Changes to the section don't affect already-inserted content
- Editors can customize the inserted content
- Content remains self-contained
The section picker offers two modes, selectable via the toggle at the top of the modal:

<Aside type="tip">
For content that should stay synchronized, consider using [Widget Areas](/guides/widgets/) with component widgets instead.
| Mode | Behaviour |
|------|-----------|
| **Insert copy** | Pastes the section's Portable Text blocks directly into the document. Editable and independent — changes to the section do not affect this copy. |
| **Synced reference** | Inserts a reference block that always renders the section's *current* content at page-render time. Editing the section updates every place it is referenced automatically. |

#### When to use each mode

- **Insert copy** — when you want a starting point you plan to customise per-post (e.g. an intro paragraph template).
- **Synced reference** — when the content should stay identical across pages (e.g. a pricing table, a legal disclaimer, a global CTA banner).

### Synced Section References

A synced reference stores only the section's ID in the post's Portable Text content:

```json
{
"_type": "emdash-section-ref",
"_key": "abc123",
"sectionId": "01ABC...",
"sectionTitle": "Pricing Table"
}
```

At render time, `<PortableText>` resolves the referenced section and renders its current content. No re-publishing of posts is required — just edit the section in the Sections library.

In the editor, synced references appear as a labelled block with a **Synced section** badge. They cannot be edited inline; to change the content, open the Sections library.

<Aside type="caution">
Deleting a section that is referenced in published content will cause those references to render nothing. The admin editor shows a warning, but there is currently no broken-reference check before deletion.
</Aside>

## Creating Sections
Expand Down Expand Up @@ -198,6 +223,15 @@ Fetch a section by slug.

**Returns:** `Promise<Section | null>`

### `getSectionById(id)`

Fetch a section by its ULID. Useful when rendering `emdash-section-ref` blocks in custom renderers.

**Parameters:**
- `id` — The section's ULID (string)

**Returns:** `Promise<Section | null>`

### `getSections(options?)`

List sections with optional filters.
Expand Down
50 changes: 43 additions & 7 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
registerPluginBlocks,
resolveIcon,
} from "./editor/PluginBlockNode";
import { SectionRefExtension } from "./editor/SectionRefNode";
import { MediaPickerModal } from "./MediaPickerModal";
import { SectionPickerModal } from "./SectionPickerModal";

Expand Down Expand Up @@ -273,6 +274,16 @@ function convertPMNode(node: {
style: "lineBreak",
};

case "sectionRef": {
const { sectionId, sectionTitle } = node.attrs ?? {};
return {
_type: "emdash-section-ref",
_key: generateKey(),
sectionId: typeof sectionId === "string" ? sectionId : "",
sectionTitle: typeof sectionTitle === "string" ? sectionTitle : "",
};
}

case "pluginBlock": {
const { blockType, id: pluginId, data } = node.attrs ?? {};
return {
Expand Down Expand Up @@ -547,6 +558,16 @@ function convertPTBlock(block: PortableTextBlock): unknown {
case "break":
return { type: "horizontalRule" };

case "emdash-section-ref": {
return {
type: "sectionRef",
attrs: {
sectionId: typeof block.sectionId === "string" ? block.sectionId : "",
sectionTitle: typeof block.sectionTitle === "string" ? block.sectionTitle : "",
},
};
}

default: {
// Treat unknown block types as plugin blocks (embeds)
// These have an id field (or url for backwards compat) for the embed source,
Expand Down Expand Up @@ -1512,6 +1533,7 @@ export function PortableTextEditor({
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
SectionRefExtension,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
Expand Down Expand Up @@ -1710,18 +1732,29 @@ export function PortableTextEditor({
[editor, slashMenuState.range],
);

// Handle section selection - insert section content at cursor
// Handle section selection - insert section content or a synced reference at cursor
const handleSectionSelect = React.useCallback(
(section: Section) => {
if (!editor || !section.content || section.content.length === 0) return;
(section: Section, mode: "copy" | "ref") => {
if (!editor) return;

// Convert Portable Text to ProseMirror format
if (mode === "ref") {
editor
.chain()
.focus()
.insertContent({
type: "sectionRef",
attrs: { sectionId: section.id, sectionTitle: section.title },
})
.run();
return;
}

// "copy" mode: paste the section's Portable Text blocks directly
if (!section.content || section.content.length === 0) return;
const ptContent = Array.isArray(section.content)
? (section.content as PortableTextBlock[])
: [];
const { content: prosemirrorContent } = portableTextToProsemirror(ptContent);

// Insert the content at current cursor position
editor.chain().focus().insertContent(prosemirrorContent).run();
},
[editor],
Expand Down Expand Up @@ -1790,7 +1823,10 @@ export function PortableTextEditor({
<SectionPickerModal
open={sectionPickerOpen}
onOpenChange={setSectionPickerOpen}
onSelect={handleSectionSelect}
onSelect={(section, mode) => {
handleSectionSelect(section, mode);
setSectionPickerOpen(false);
}}
/>
</div>
);
Expand Down
57 changes: 49 additions & 8 deletions packages/admin/src/components/SectionPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
* Section Picker Modal
*
* A modal for selecting and inserting sections into content.
* Supports two insertion modes:
* - "copy": pastes the section's content blocks directly (editable, independent)
* - "ref": inserts a synced reference that always reflects the section's current content
*/

import { Button, Dialog, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, Stack, FolderOpen } from "@phosphor-icons/react";
import { MagnifyingGlass, Stack, FolderOpen, Copy, Link } from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
Expand All @@ -15,15 +18,18 @@ import { fetchSections, type Section } from "../lib/api";
import { useDebouncedValue } from "../lib/hooks";
import { cn } from "../lib/utils";

type InsertMode = "copy" | "ref";

interface SectionPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (section: Section) => void;
onSelect: (section: Section, mode: InsertMode) => void;
}

export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPickerModalProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
const [mode, setMode] = React.useState<InsertMode>("copy");
const debouncedSearch = useDebouncedValue(searchQuery, 300);

const { data: sectionsData, isLoading: sectionsLoading } = useQuery({
Expand All @@ -36,16 +42,16 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick
});
const sections = sectionsData?.items ?? [];

// Reset search when modal opens
// Reset state when modal opens
React.useEffect(() => {
if (open) {
setSearchQuery("");
setMode("copy");
}
}, [open]);

const handleSelect = (section: Section) => {
onSelect(section);
onOpenChange(false);
onSelect(section, mode);
};

return (
Expand Down Expand Up @@ -73,9 +79,44 @@ export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPick
/>
</div>

{/* Search */}
<div className="flex items-center gap-4 py-4 border-b">
<div className="relative flex-1">
{/* Mode toggle + Search */}
<div className="flex flex-col gap-3 py-4 border-b">
{/* Insert mode toggle */}
<div className="flex items-center gap-1 p-1 rounded-lg bg-kumo-tint w-fit">
<button
type="button"
onClick={() => setMode("copy")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
mode === "copy"
? "bg-kumo-base shadow-sm text-kumo-text"
: "text-kumo-subtle hover:text-kumo-text",
)}
>
<Copy className="h-3.5 w-3.5" />
{t`Insert copy`}
</button>
<button
type="button"
onClick={() => setMode("ref")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
mode === "ref"
? "bg-kumo-base shadow-sm text-kumo-brand"
: "text-kumo-subtle hover:text-kumo-text",
)}
>
<Link className="h-3.5 w-3.5" />
{t`Synced reference`}
</button>
</div>
{mode === "ref" && (
<p className="text-xs text-kumo-subtle">
{t`The section's content will stay in sync — edits to the section update everywhere it's referenced.`}
</p>
)}
{/* Search */}
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
placeholder={t`Search sections...`}
Expand Down
Loading
Loading