Skip to content
Draft
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
20 changes: 15 additions & 5 deletions docs/features/fragments.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A fragment is one versioned file that carries a section (one or more sibling sub
- Pipeline: `extractFragment` → `parseSiteFragment` (or `readFragmentPackage`) → `planFragmentInsert` (pure) → `commitFragmentPlan` (server work, then ONE store transaction).
- Merge rule: the site always wins. Classes, components and tables match by name/slug and the site's version is reused untouched; tokens match by name and are only added when missing; everything added gets fresh ids.
- Editor entry point: Spotlight **Insert fragment from file…** (`editor.insertFragment`) opens `InsertFragmentModal`: pick a package, review the plan, Insert, read what was added. One Cmd+Z removes the whole insert.
- Plugin entry point: `api.editor.fragments.insert` / `.plan` (permission `editor.fragments`), the same plan and commit as the dialog with a caller-supplied `readMediaBytes` and a `placement`. See [plugin-system.md](plugin-system.md#inserting-site-fragments-editorfragments).
- Paste, saved layouts, HTML import and site import do not use fragments and behave exactly as before.

---
Expand Down Expand Up @@ -47,8 +48,10 @@ src/core/fragments/
src/admin/modals/InsertFragment/
├── InsertFragmentModal.tsx pick → review → insert → report
├── FragmentReport.tsx the review / result report
├── fragmentInsertFlow.ts load file, plan against the open document, commit
└── createFragmentInsertAdapter.ts CMS API + editor store implementation of the adapter
├── fragmentInsertFlow.ts load file, plan against the open document, commit, toasts
├── createFragmentInsertAdapter.ts CMS API + editor store implementation of the adapter
├── pluginFragmentsHost.ts api.editor.fragments (insert / plan) on the same flow
└── useBindPluginFragmentsApi.ts binds that host into the plugin runtime while the editor is open

src/admin/pages/site/store/slices/fragmentsSlice.ts insertFragment + dialog flag
src/admin/spotlight/commands/fragments.ts editor.insertFragment
Expand Down Expand Up @@ -121,7 +124,7 @@ Every id is a SOURCE id. `media[].path` is the exact string the content uses (`/
| Framework utilities | Matched by name against the site's framework AFTER the token merge (`generateFrameworkUtilityClasses` on the merged settings), so a colour the insert adds brings its utilities. A name the site does not generate is dropped (warning). |
| Ambient rules | Matched by exact selector; the site's rule wins. Only rules naming a landing class (or no class, like `@keyframes`) are added. |
| Conditions | Matched by id; missing ones added. |
| Colours | Matched by normalised slug; existing never changes. A missing colour is added with the fragment's full definition. A variant the fragment reads that the site's colour does not generate is a warning. |
| Colours | Matched by normalised slug; existing never changes. A missing colour is added with the fragment's full definition, or, when the caller passes `colorSources` (`planFragmentInsert(target, fragment, { colorSources })`), with the values of the site colour it names (`report.colors.filled`). A variant the fragment reads that the site's colour does not generate is a warning. |
| Type / space steps | A step the site's group (same `namingConvention`) lacks is inserted at its canonical position (`insertScaleStep`, `@core/framework`), shifting `baseScaleIndex` so the base step, ratio and sizes are unchanged. No group, a disabled scale or a manual group: warning, nothing invented. |
| Font tokens | Matched by variable; existing wins (a new site's seeded `font-heading`/`font-body`/`font-mono` roles are kept). A missing token is added; its family is reused by name or installed. Families are only installed when an added token binds to them or a carried rule names them. |
| Custom properties | Added only when the site defines no such variable (root rules, colours, scales, font tokens). They go into ONE engine-owned ambient `:root` rule (`origin.source === FRAGMENT_PROPERTIES_ORIGIN`); later inserts add to it. A user-authored `:root` rule is never edited. |
Expand All @@ -148,11 +151,18 @@ E. adapter.commit(resolved) ONE store transaction

- A media or table failure throws `FragmentCommitError` before E: the document is untouched; uploads and tables already made stay (Media and Data workspaces show them). A font install failure is a warning; the token falls back to its stack.
- `signal` aborts at the next boundary, never after E started.
- The editor's E is `insertFragment` (`fragmentsSlice.ts`): `applyResolvedFragment` inside `mutateActiveTreeAndSite` (sections, components: placed right after the selected element by `resolveSiblingInsertLocation`, or at the end of the document when nothing or the root is selected; never inside a selected container, unlike a paste; first root selected) or `mutateSite` (pages), plus `reconcileFrameworkClasses` when a colour or step was added. One undo step; the change syncs through collab like any edit. The plan's `document` must still be the active one.
- The editor's E is `insertFragment(plan, placement?)` (`fragmentsSlice.ts`): `applyResolvedFragment` inside `mutateActiveTreeAndSite` (sections, components; first root selected) or `mutateSite` (pages), plus `reconcileFrameworkClasses` when a colour or step was added. One undo step; the change syncs through collab like any edit. The plan's `document` must still be the active one.
- Placement (`FragmentInsertPlacement`): `after-selection`, the default and the dialog's only mode, places it right after the selected element (`resolveSiblingInsertLocation`), or at the end of the document when nothing or the root is selected, never inside a selected container; `inside-selection` places it inside the selected container like a paste (`resolveInsertLocation`); `end` appends it to the document whatever is selected.
- The editor adapter runs every server write through `runStepUp` (creating a table needs a recent password).

---

## Inserting from a plugin

A plugin with `editor.fragments` calls `api.editor.fragments.insert({ fragment, readMediaBytes, placement?, signal?, onProgress?, silent? })` or the dry run `api.editor.fragments.plan({ fragment })`. Both run on the dialog's own functions (`planFragmentForEditor`, `commitFragmentForEditor` in `fragmentInsertFlow.ts`), so the merge rules, step-up prompt, undo step and result toast are the same. The plugin supplies the bytes (`readMediaBytes`), which is only called for media the plan adds that the library lacks. Failures resolve as `{ ok: false, stage, message }`; a fragment `parseSiteFragment` refuses rejects `insert`. Full API: [plugin-system.md](plugin-system.md#inserting-site-fragments-editorfragments).

---

## Writing a fragment from a script

```ts
Expand Down Expand Up @@ -191,5 +201,5 @@ Bun resolves the `@core/*` aliases from the repo's `tsconfig.json`. `planFragmen
- [site-transfer.md](site-transfer.md) — the export archive `fragmentSourceFromBundleArchive` reads
- [visual-components.md](visual-components.md), [loops.md](loops.md), [cms-native-forms.md](cms-native-forms.md), [media.md](media.md)
- Source of truth: `src/core/fragments/schema.ts`, `src/core/fragments/plan.ts`, `src/core/fragments/commit.ts`
- Tests: `src/__tests__/fragments/`, `src/__tests__/editor-store/insertFragment.test.ts`, `src/__tests__/admin/insertFragmentModal.test.tsx`
- Tests: `src/__tests__/fragments/`, `src/__tests__/editor-store/insertFragment.test.ts`, `src/__tests__/admin/insertFragmentModal.test.tsx`, `src/__tests__/plugins/pluginFragmentsApi.test.ts`
- Gate: `src/__tests__/architecture/fragments-headless.test.ts`
36 changes: 36 additions & 0 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,8 +473,43 @@ Editor surfaces are permission-split:
| `api.editor.canvas.registerOverlay` | `editor.canvas` |
| `api.editor.store.read` | `editor.store.read` |
| `api.editor.store.transaction` | `editor.store.write` |
| `api.editor.fragments.insert`, `api.editor.fragments.plan` | `editor.fragments` |
| `api.cms.storage.collection(...)` from browser plugin code | `cms.storage` |

#### Inserting site fragments (`editor.fragments`)

`api.editor.fragments` inserts a [site fragment](fragments.md) into the document open in the site editor, through the same flow as Spotlight **Insert fragment from file…**: the plan runs against the open document, the site's own classes, components, tokens and tables win, media and table writes go through the step-up prompt, and the whole insert is one undo step with the inserted root selected. Types (`FragmentInsertInput`, `FragmentInsertOutcome`, `FragmentPlanOutcome`, `FragmentInsertReport`, `FragmentMedia`, `FragmentInsertPlacement`) come from the SDK (`src/core/plugin-sdk/types/fragments.ts`).

```js
export function activate(api) {
api.editor.commands.register({
id: 'acme.sections.insert-hero',
label: 'Insert hero section',
run: async () => {
const fragment = await fetch(api.plugin.assetUrl('hero/fragment.json')).then((r) => r.json())
const preview = await api.editor.fragments.plan({ fragment }) // dry run: { ok, report }
if (!preview.ok) return
const outcome = await api.editor.fragments.insert({
fragment,
placement: 'after-selection', // or 'inside-selection', 'end'
readMediaBytes: async (media) => {
const res = await fetch(api.plugin.assetUrl(`hero/${media.src}`))
return res.ok ? new Uint8Array(await res.arrayBuffer()) : null
},
})
if (!outcome.ok) console.warn(outcome.stage, outcome.message)
},
})
}
```

- `placement`: `after-selection` (default) puts it after the selected element, or at the end of the document when nothing is selected; `inside-selection` puts it inside the selected container, like a paste; `end` appends it to the document. A page fragment adds its own page.
- `colorSources` (on `plan` and `insert`) fills a colour the fragment adds from one of the site's own: `{ 'bg-alt': 'bg-surface' }` gives the new `bg-alt` token the site's `bg-surface` light value, dark value and dark-mode flag, so a section's band follows the site's palette. Colours the site already has are never touched; the report lists what was filled in `colors.filled`.
- `readMediaBytes` is called only for media the plan adds that the media library does not already hold (same filename, size and MIME type). Returning null stops the insert with `stage: 'media'` before the document changes.
- `insert` resolves `{ ok: true, report, insertedRootIds, pageId? }` or `{ ok: false, stage, message }` with `stage` one of `unavailable` (no editable document open), `plan`, `media`, `tables`, `commit`, `cancelled`. It rejects only for a malformed input, including a fragment `parseSiteFragment` refuses; `plan` resolves `{ ok: false, message }` for that.
- The editor shows its usual result toast; pass `silent: true` to show your own. `signal` and `onProgress` behave as in `commitFragmentPlan`.
- The site editor binds the host while it is open for a user who can edit the draft site (`useBindPluginFragmentsApi`); `src/core/plugins/runtime.ts` checks the permission and the input shape, `src/admin/modals/InsertFragment/pluginFragmentsHost.ts` runs the insert.

Dashboard widgets are registered from the same unsandboxed browser entrypoint through `api.dashboard.widgets.register(...)` and require `dashboard.widgets.register`:

```jsx
Expand Down Expand Up @@ -979,6 +1014,7 @@ Risk levels:
| `editor.commands` | Editor | Medium | Register editor commands + Spotlight palette commands / providers |
| `editor.store.read` | Editor | Medium | Read editor store state |
| `editor.store.write` | Editor | High | Mutate editor store state through a host transaction |
| `editor.fragments` | Editor | High | Insert site fragments into the open document; may add classes, tokens, components, media and data tables |
| `editor.canvas` | Editor | High | Register canvas overlay React components |
| `editor.panels` | Editor | Medium | Register left-sidebar panels. Use `definePluginPanel({ id, label, iconName, accent? })` from the SDK — `accent` pins a specific rail tint; omit it to let the host derive one automatically from the panel identity. |
| `modules.register` | Editor / manifest | High | Ship new modules to the canvas module library |
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/editor-store/insertFragment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ describe('insertFragment', () => {
expect(nodes[first!.rootIds[0]].children).not.toContain(second!.rootIds[0])
})

it("an explicit placement goes inside the selected container ('inside-selection') or to the end ('end')", () => {
const site = useEditorStore.getState().createSite('Target')
const rootId = site.pages[0].rootNodeId
const first = useEditorStore.getState().insertFragment(resolvedPlanForStore(section))!
const firstId = first.rootIds[0]

const inside = useEditorStore.getState().insertFragment(resolvedPlanForStore(section), 'inside-selection')!
let nodes = useEditorStore.getState().site!.pages[0].nodes
expect(nodes[firstId].children.at(-1)).toBe(inside.rootIds[0])

const second = useEditorStore.getState().insertFragment(resolvedPlanForStore(section), 'after-selection')!
useEditorStore.getState().selectNode(firstId)
const end = useEditorStore.getState().insertFragment(resolvedPlanForStore(section), 'end')!
nodes = useEditorStore.getState().site!.pages[0].nodes
expect(nodes[rootId].children).toEqual([firstId, end.rootIds[0]])
// 'after-selection' with the nested section selected lands next to it, inside the first.
expect(nodes[firstId].children.slice(-2)).toEqual([inside.rootIds[0], second.rootIds[0]])
})

it("never duplicates the seeded font role tokens of a new site", () => {
useEditorStore.getState().createSite('Target')
const before = useEditorStore.getState().site!.settings.fonts?.tokens?.map((t) => t.variable) ?? []
Expand Down
27 changes: 26 additions & 1 deletion src/__tests__/fragments/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('planFragmentInsert — classes and rules', () => {
describe('planFragmentInsert — tokens', () => {
it('adds a missing colour with its full definition and keeps the existing one', () => {
const { plan } = planIntoTarget()
expect(plan.report.colors).toEqual({ added: ['accent'], kept: ['primary'] })
expect(plan.report.colors).toEqual({ added: ['accent'], kept: ['primary'], filled: [] })
const accent = plan.colorTokens[0]
expect(accent.slug).toBe('accent')
expect(accent.lightValue).toBe('#ff8800')
Expand All @@ -111,6 +111,31 @@ describe('planFragmentInsert — tokens', () => {
expect(accent.id).not.toBe('col-accent')
})

it("fills a missing colour from one of the site's own when the caller asks", () => {
const target = makeTargetSite()
const primary = target.settings.framework!.colors.tokens.find((t) => t.slug === 'primary')!
const plan = planFragmentInsert(
{ site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } },
fragment,
{ colorSources: { accent: 'primary', primary: 'accent' } },
)
const accent = plan.colorTokens.find((t) => t.slug === 'accent')!
// The site's values, the fragment's generation flags; a colour the site
// already has is untouched whatever the caller asks.
expect(accent.lightValue).toBe(primary.lightValue)
expect(accent.darkValue).toBe(primary.darkValue)
expect(accent.generateTransparent).toBe(true)
expect(plan.report.colors).toEqual({ added: ['accent'], kept: ['primary'], filled: [{ slug: 'accent', from: 'primary' }] })

const unknown = planFragmentInsert(
{ site: makeTargetSite(), tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } },
fragment,
{ colorSources: { accent: 'no-such-colour' } },
)
expect(unknown.colorTokens.find((t) => t.slug === 'accent')!.lightValue).toBe('#ff8800')
expect(unknown.report.colors.filled).toEqual([])
})

it("warns about colour variants the site's colour does not generate", () => {
const { plan } = planIntoTarget()
const subjects = plan.report.warnings.filter((w) => w.code === 'color-variant-missing').map((w) => w.subject)
Expand Down
Loading