From 0987d9ee44c636baba819adf68da90d644e0f8a7 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 18:16:32 +0200 Subject: [PATCH 1/2] feat(plugins): let editor plugins insert site fragments Plugins that ship sections or pages could only drop them in by mutating the editor store by hand, which skips the merge rules, media uploads, table writes and the single undo step. api.editor.fragments (permission editor.fragments) runs the Insert fragment dialog's own plan and commit with the plugin's media reader and an explicit placement; the store action takes that placement while the dialog keeps placing after the selection. --- docs/features/fragments.md | 18 +- docs/features/plugin-system.md | 35 +++ .../editor-store/insertFragment.test.ts | 19 ++ .../plugins/pluginFragmentsApi.test.ts | 240 ++++++++++++++++++ .../AdminCanvasEditorBody.tsx | 4 + .../InsertFragment/InsertFragmentModal.tsx | 51 ++-- .../createFragmentInsertAdapter.ts | 12 +- .../InsertFragment/fragmentInsertFlow.ts | 85 +++++-- .../InsertFragment/pluginFragmentsHost.ts | 117 +++++++++ .../useBindPluginFragmentsApi.ts | 24 ++ .../pages/site/store/slices/fragmentsSlice.ts | 49 +++- src/core/fragments/adapter.ts | 7 +- src/core/fragments/index.ts | 1 + src/core/fragments/planTypes.ts | 11 + src/core/plugin-sdk/builders/permissions.ts | 1 + src/core/plugin-sdk/capabilities.ts | 7 + src/core/plugin-sdk/types/editorApi.ts | 8 + src/core/plugin-sdk/types/fragments.ts | 75 ++++++ src/core/plugin-sdk/types/index.ts | 1 + src/core/plugin-sdk/types/permissions.ts | 4 + src/core/plugins/runtime.ts | 68 +++++ 21 files changed, 766 insertions(+), 71 deletions(-) create mode 100644 src/__tests__/plugins/pluginFragmentsApi.test.ts create mode 100644 src/admin/modals/InsertFragment/pluginFragmentsHost.ts create mode 100644 src/admin/modals/InsertFragment/useBindPluginFragmentsApi.ts create mode 100644 src/core/plugin-sdk/types/fragments.ts diff --git a/docs/features/fragments.md b/docs/features/fragments.md index 2bb6fce79..36a38a0a5 100644 --- a/docs/features/fragments.md +++ b/docs/features/fragments.md @@ -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. --- @@ -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 @@ -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 @@ -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` diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 069c9e782..614efba49 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -473,8 +473,42 @@ 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. +- `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 @@ -979,6 +1013,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 | diff --git a/src/__tests__/editor-store/insertFragment.test.ts b/src/__tests__/editor-store/insertFragment.test.ts index 907d4fc0f..54a2da56b 100644 --- a/src/__tests__/editor-store/insertFragment.test.ts +++ b/src/__tests__/editor-store/insertFragment.test.ts @@ -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) ?? [] diff --git a/src/__tests__/plugins/pluginFragmentsApi.test.ts b/src/__tests__/plugins/pluginFragmentsApi.test.ts new file mode 100644 index 000000000..6f9e82ead --- /dev/null +++ b/src/__tests__/plugins/pluginFragmentsApi.test.ts @@ -0,0 +1,240 @@ +/** + * api.editor.fragments: a plugin with `editor.fragments` inserts a site + * fragment into the open document through the same flow as the Insert + * fragment dialog (plan, media through the plugin's reader, one undo step). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { bindPluginRuntimeFragmentsHost, createEditorPluginApi } from '@core/plugins/runtime' +import type { EditorPluginApi, FragmentMedia, PluginManifest } from '@core/plugin-sdk' +import { extractFragment, fragmentSourceFromSite, planFragmentInsert, type SiteFragment } from '@core/fragments' +import type { StyleRule } from '@core/page-tree' +import { useEditorStore } from '@site/store/store' +import { fragmentInsertDocument } from '@site/store/slices/fragmentsSlice' +import { insertFragmentForPlugin, planFragmentForPlugin } from '@admin/modals/InsertFragment/pluginFragmentsHost' +import { __resetToastBusForTests, subscribeToasts, type Toast } from '@ui/components/Toast/toastBus' +import { makeNode, makePage, makeSite } from '../fixtures' +import '@modules/base/index' + +const manifest: PluginManifest = { + id: 'acme.sections', + name: 'Sections', + version: '1.0.0', + apiVersion: 1, + description: 'Inserts sections.', + permissions: ['editor.code', 'editor.fragments'], + grantedPermissions: ['editor.code', 'editor.fragments'], + entrypoints: { editor: 'editor/index.js' }, + resources: [], + adminPages: [], +} + +const realFetch = globalThis.fetch +const bytesOf = (name: string) => new TextEncoder().encode(`bytes:${name}`) + +function rule(id: string, name: string): StyleRule { + return { id, name, kind: 'class', selector: `.${name}`, order: 0, styles: { padding: '2rem' }, contextStyles: {}, createdAt: 1, updatedAt: 1 } +} + +/** A banner with two images; `logo.png` is already in the target's library. */ +async function bannerFragment(): Promise { + const source = makeSite({ + pages: [makePage({ + id: 'src-page', + rootNodeId: 'src-body', + nodes: { + 'src-body': makeNode({ id: 'src-body', moduleId: 'base.body', children: ['src-banner'] }), + 'src-banner': makeNode({ id: 'src-banner', moduleId: 'base.container', classIds: ['src-banner-class'], children: ['src-photo', 'src-logo'] }), + 'src-photo': makeNode({ id: 'src-photo', moduleId: 'base.image', props: { src: '/uploads/photo.jpg' } }), + 'src-logo': makeNode({ id: 'src-logo', moduleId: 'base.image', props: { src: '/uploads/logo.png' } }), + }, + })], + styleRules: { 'src-banner-class': rule('src-banner-class', 'banner') }, + }) + const media = [ + { id: 'm-photo', path: '/uploads/photo.jpg', filename: 'photo.jpg', mimeType: 'image/jpeg' }, + { id: 'm-logo', path: '/uploads/logo.png', filename: 'logo.png', mimeType: 'image/png' }, + ].map((m) => ({ ...m, sizeBytes: bytesOf(m.filename).byteLength, readBytes: () => bytesOf(m.filename) })) + const { fragment } = await extractFragment(fragmentSourceFromSite(source, { media }), { pageId: 'src-page', rootNodeIds: ['src-banner'] }) + // A media item nothing in the fragment uses: the plan never adds it. + return { + ...fragment, + media: [...fragment.media, { id: 'm-extra', path: '/uploads/extra.jpg', filename: 'extra.jpg', mimeType: 'image/jpeg', src: 'media/extra.jpg' }], + } +} + +function asset(id: string, filename: string, mimeType: string, sizeBytes: number) { + return { id, filename, mimeType, sizeBytes, publicPath: `/uploads/library/${filename}`, uploadedByUserId: null, createdAt: '2026-01-01T00:00:00.000Z', deletedAt: null } +} + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) +} + +let uploads: string[] = [] + +function stubCmsApi(): void { + uploads = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/data/tables')) return json({ tables: [] }) + if (url.endsWith('/media') && init?.method === 'POST') { + const file = (init.body as FormData).get('file') as File + uploads.push(file.name) + return json({ asset: asset(`new-${file.name}`, file.name, file.type, file.size) }) + } + if (url.endsWith('/media')) return json({ assets: [asset('lib-logo', 'logo.png', 'image/png', bytesOf('logo.png').byteLength)] }) + throw new Error(`unexpected request ${url}`) + }) as unknown as typeof fetch +} + +function seedEditor(): void { + useEditorStore.setState({ + site: makeSite({ + pages: [makePage({ + id: 'page-1', + rootNodeId: 'body', + nodes: { + body: makeNode({ id: 'body', moduleId: 'base.body', children: ['first', 'second'] }), + first: makeNode({ id: 'first', moduleId: 'base.container', children: ['first-text'] }), + 'first-text': makeNode({ id: 'first-text', moduleId: 'base.text', props: { text: 'Hello' } }), + second: makeNode({ id: 'second', moduleId: 'base.container', children: [] }), + }, + })], + }), + activePageId: 'page-1', + activeDocument: { kind: 'page', pageId: 'page-1' }, + selectedNodeId: 'first', + selectedNodeIds: ['first'], + hoveredNodeId: null, + _historyPast: [], + _historyFuture: [], + canUndo: false, + canRedo: false, + hasUnsavedChanges: false, + } as Parameters[0]) +} + +let unbind: () => void = () => {} +let toasts: ReadonlyArray = [] + +beforeEach(() => { + stubCmsApi() + seedEditor() + __resetToastBusForTests() + subscribeToasts((next) => { toasts = next }) + unbind = bindPluginRuntimeFragmentsHost({ + insert: (input) => insertFragmentForPlugin(input, (action) => action()), + plan: planFragmentForPlugin, + }) +}) + +afterEach(() => { + unbind() + globalThis.fetch = realFetch +}) + +function api(granted: PluginManifest['grantedPermissions'] = manifest.grantedPermissions): EditorPluginApi { + return createEditorPluginApi({ ...manifest, grantedPermissions: granted }, globalThis.fetch) +} + +function bodyChildren(): string[] { + return useEditorStore.getState().site!.pages[0].nodes.body.children +} + +describe('api.editor.fragments', () => { + it('is denied without the editor.fragments permission', async () => { + const denied = api(['editor.code']) + await expect(denied.editor.fragments.insert({ fragment: await bannerFragment(), readMediaBytes: () => null })) + .rejects.toThrow('requires permission "editor.fragments"') + await expect(denied.editor.fragments.plan({ fragment: await bannerFragment() })) + .rejects.toThrow('requires permission "editor.fragments"') + expect(bodyChildren()).toEqual(['first', 'second']) + }) + + it('inserts after the selection, reads only the media it uploads, and undoes in one step', async () => { + const fragment = await bannerFragment() + const before = useEditorStore.getState().site!.pages[0].nodes + const state = useEditorStore.getState() + const expected = planFragmentInsert({ site: state.site!, tables: [], document: fragmentInsertDocument(state)! }, fragment).report + const read: string[] = [] + + const outcome = await api().editor.fragments.insert({ + fragment, + silent: true, + readMediaBytes: (media: FragmentMedia) => { + read.push(media.filename) + return bytesOf(media.filename) + }, + }) + + if (!outcome.ok) throw new Error(outcome.message) + expect(bodyChildren()).toEqual(['first', outcome.insertedRootIds[0], 'second']) + expect(useEditorStore.getState().selectedNodeId).toBe(outcome.insertedRootIds[0]) + // logo.png is already in the library and extra.jpg is unused: only photo.jpg is read. + expect(read).toEqual(['photo.jpg']) + expect(uploads).toEqual(['photo.jpg']) + expect(outcome.report.media).toEqual({ total: 2, uploaded: 1, reused: 1 }) + expect({ ...outcome.report, media: expected.media }).toEqual(expected) + expect(toasts).toEqual([]) + + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes).toEqual(before) + }) + + it("places inside the selected container for 'inside-selection' and at the end for 'end'", async () => { + const inside = await api().editor.fragments.insert({ fragment: await bannerFragment(), placement: 'inside-selection', readMediaBytes: (m) => bytesOf(m.filename), silent: true }) + if (!inside.ok) throw new Error(inside.message) + expect(useEditorStore.getState().site!.pages[0].nodes.first.children).toEqual(['first-text', inside.insertedRootIds[0]]) + + useEditorStore.setState({ selectedNodeId: 'first-text', selectedNodeIds: ['first-text'] }) + const end = await api().editor.fragments.insert({ fragment: await bannerFragment(), placement: 'end', readMediaBytes: (m) => bytesOf(m.filename), silent: true }) + if (!end.ok) throw new Error(end.message) + expect(bodyChildren()).toEqual(['first', 'second', end.insertedRootIds[0]]) + }) + + it('shows the result toast unless silent', async () => { + const outcome = await api().editor.fragments.insert({ fragment: await bannerFragment(), readMediaBytes: (m) => bytesOf(m.filename) }) + expect(outcome.ok).toBe(true) + expect(toasts.map((t) => t.title)).toEqual(['Fragment inserted']) + }) + + it('plan reports what an insert would add without changing the store', async () => { + const fragment = await bannerFragment() + const site = useEditorStore.getState().site + const outcome = await api().editor.fragments.plan({ fragment }) + if (!outcome.ok) throw new Error(outcome.message) + expect(outcome.report.rules.added).toEqual(['banner']) + expect(outcome.report.media.total).toBe(2) + expect(useEditorStore.getState().site).toBe(site) + expect(uploads).toEqual([]) + }) + + it('a malformed fragment: plan resolves not ok, insert rejects', async () => { + const plan = await api().editor.fragments.plan({ fragment: { format: 'something-else' } }) + expect(plan.ok).toBe(false) + await expect(api().editor.fragments.insert({ fragment: { format: 'something-else' }, readMediaBytes: () => null })) + .rejects.toThrow() + expect(bodyChildren()).toEqual(['first', 'second']) + }) + + it('rejects a malformed input shape', async () => { + const fragment = await bannerFragment() + await expect(api().editor.fragments.insert({ fragment } as never)).rejects.toThrow('readMediaBytes must be a function') + await expect(api().editor.fragments.insert({ fragment, readMediaBytes: () => null, placement: 'top' as never })) + .rejects.toThrow('placement must be one of') + }) + + it('a media failure resolves with its stage and leaves the document untouched', async () => { + const outcome = await api().editor.fragments.insert({ fragment: await bannerFragment(), readMediaBytes: () => null, silent: true }) + expect(outcome).toMatchObject({ ok: false, stage: 'media' }) + expect(bodyChildren()).toEqual(['first', 'second']) + }) + + it('without an open site editor it resolves unavailable', async () => { + unbind() + const outcome = await api().editor.fragments.insert({ fragment: await bannerFragment(), readMediaBytes: () => null }) + expect(outcome).toMatchObject({ ok: false, stage: 'unavailable' }) + expect(await api().editor.fragments.plan({ fragment: await bannerFragment() })).toMatchObject({ ok: false }) + }) +}) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx index 2f7d7e3b3..dbe04f6ce 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx @@ -10,6 +10,7 @@ import { CanvasRoot } from '@admin/pages/site/canvas' import { CodeEditorPanel, CodeEditorSkeleton } from '@admin/pages/site/code-editor' import { useActiveLivePath } from '@admin/pages/site/hooks/useActiveLivePath' import { useAutoResolveDependencies } from '@admin/pages/site/hooks/useAutoResolveDependencies' +import { useBindPluginFragmentsApi } from '@admin/modals/InsertFragment/useBindPluginFragmentsApi' import { LayoutNameDialog } from '@admin/pages/site/dialogs/LayoutNameDialog' import { PropertiesPanel } from '@admin/pages/site/panels/PropertiesPanel' import { LeftSidebar } from '@admin/pages/site/sidebars/LeftSidebar' @@ -60,6 +61,9 @@ export function AdminCanvasEditorBody({ // their own); lives here, in the lazy body, so the CMS fetch it needs for // postTypes templates stays out of the admin-shell bundle. useActiveLivePath() + // Plugins with `editor.fragments` insert into the document open here, for + // a user who can edit the draft site. + useBindPluginFragmentsApi(canEditDraftSite) const propertiesPanelMode = useEditorStore((s) => s.propertiesPanelMode) const rightSidebarExpanded = useEditorStore(selectRightSidebarExpanded) diff --git a/src/admin/modals/InsertFragment/InsertFragmentModal.tsx b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx index 1fb8b5511..8732d073a 100644 --- a/src/admin/modals/InsertFragment/InsertFragmentModal.tsx +++ b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx @@ -17,12 +17,20 @@ import { FileUpload } from '@ui/components/FileUpload' import { pushToast } from '@ui/components/Toast' import { UploadIcon } from 'pixel-art-icons/icons/upload' import type { FragmentInsertPlan, FragmentInsertResult } from '@core/fragments' -import { isAbortError } from '@core/http' import { getErrorMessage } from '@core/utils/errorMessage' import { useEditorStore } from '@site/store/store' -import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { useStepUp } from '@admin/shared/StepUp' import { FragmentReport } from './FragmentReport' -import { loadFragmentFile, planLoadedFragment, runFragmentInsert, type LoadedFragment } from './fragmentInsertFlow' +import { + commitFragmentForEditor, + fragmentProgressLine, + isFragmentInsertCancel, + loadFragmentFile, + planFragmentForEditor, + toastFragmentInsertFailed, + toastFragmentInserted, + type LoadedFragment, +} from './fragmentInsertFlow' import styles from './InsertFragmentModal.module.css' type Step = @@ -40,7 +48,7 @@ async function readAndPlan(file: File, setStep: SetStep): Promise { setStep({ kind: 'reading', fileName: file.name }) try { const loaded = await loadFragmentFile(file) - const plan = await planLoadedFragment(loaded) + const plan = await planFragmentForEditor(loaded.fragment) setStep({ kind: 'review', loaded, plan }) } catch (err) { console.error('[InsertFragmentModal] reading the fragment failed:', err) @@ -49,12 +57,6 @@ async function readAndPlan(file: File, setStep: SetStep): Promise { } } -/** The user closed the password prompt a table write asked for. */ -function isStepUpCancel(err: unknown): boolean { - const cause = err instanceof Error ? err.cause : undefined - return cause instanceof Error && cause.message === StepUpCancelledMessage -} - async function insert( loaded: LoadedFragment, plan: FragmentInsertPlan, @@ -64,25 +66,20 @@ async function insert( ): Promise { setStep({ kind: 'running', loaded, plan, line: 'Starting' }) try { - const result = await runFragmentInsert(loaded, plan, runStepUp, controller.signal, (line) => - setStep((current) => (current.kind === 'running' ? { ...current, line } : current)), - ) - setStep({ kind: 'done', loaded, result }) - pushToast({ - kind: result.report.warnings.length > 0 ? 'warning' : 'success', - title: result.pageId ? 'Page added' : 'Fragment inserted', - body: result.report.warnings.length > 0 - ? `${result.report.warnings.length} note${result.report.warnings.length === 1 ? '' : 's'} in the report.` - : 'One undo removes it.', - location: 'site-editor', + const result = await commitFragmentForEditor(plan, { + readMediaBytes: loaded.readMedia, + runStepUp, + signal: controller.signal, + onProgress: (progress) => { + const line = fragmentProgressLine(progress) + setStep((current) => (current.kind === 'running' ? { ...current, line } : current)) + }, }) + setStep({ kind: 'done', loaded, result }) + toastFragmentInserted(result) } catch (err) { - if (isAbortError(err) || isStepUpCancel(err)) { - pushToast({ kind: 'info', title: 'Insert cancelled', body: 'Files already uploaded stay in the media library.' }) - } else { - console.error('[InsertFragmentModal] insert failed:', err) - pushToast({ kind: 'error', title: 'Insert failed', body: getErrorMessage(err, 'Unknown insert error') }) - } + if (!isFragmentInsertCancel(err)) console.error('[InsertFragmentModal] insert failed:', err) + toastFragmentInsertFailed(err) setStep({ kind: 'review', loaded, plan }) } } diff --git a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts index b7f5d26ab..322c67e7b 100644 --- a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts +++ b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts @@ -9,14 +9,15 @@ * Fonts: Google families through the Typography panel's installer, custom * families registered from the uploaded files. * Tables and rows: the Data API; a published fragment row is published. - * Commit: `insertFragment` on the editor store — one undo step. + * Commit: `insertFragment` on the editor store at the caller's placement, + * one undo step. * * Every server write runs through `runStepUp`: creating a table is a * sensitive action, and a `step_up_required` answer opens the password * prompt and retries instead of failing the insert. */ -import type { FragmentInsertAdapter } from '@core/fragments' +import type { FragmentInsertAdapter, FragmentInsertPlacement } from '@core/fragments' import { installCmsGoogleFont, registerCustomFont } from '@core/persistence/cmsFonts' import { listCmsMediaAssets, uploadCmsMediaAsset, type CmsMediaAsset } from '@core/persistence/cmsMedia' import { @@ -29,7 +30,10 @@ import { useEditorStore } from '@site/store/store' type RunStepUp = (action: () => Promise) => Promise -export function createFragmentInsertAdapter(runStepUp: RunStepUp): FragmentInsertAdapter { +export function createFragmentInsertAdapter( + runStepUp: RunStepUp, + placement: FragmentInsertPlacement = 'after-selection', +): FragmentInsertAdapter { let library: Promise | null = null return { @@ -72,7 +76,7 @@ export function createFragmentInsertAdapter(runStepUp: RunStepUp): FragmentInser }, async commit(plan) { - const result = useEditorStore.getState().insertFragment(plan) + const result = useEditorStore.getState().insertFragment(plan, placement) if (!result) { throw new Error('The editor could not place the fragment here. Select where it should go and try again.') } diff --git a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts index f316a3a66..62c851150 100644 --- a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts +++ b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts @@ -1,7 +1,10 @@ /** - * The Insert fragment dialog's steps, outside React so the component body - * stays compilable: read a picked file, plan against the open document, and - * run the commit. + * The editor's fragment insert flow, outside React so component bodies stay + * compilable: read a picked file, plan against the open document, run the + * commit, and announce the result. The Insert fragment dialog and the plugin + * API (`api.editor.fragments`, `pluginFragmentsHost.ts`) both go through + * `planFragmentForEditor` and `commitFragmentForEditor`, so there is one + * insert path. */ import { @@ -10,16 +13,25 @@ import { parseSiteFragment, planFragmentInsert, readFragmentPackage, + type CommitFragmentPlanOptions, + type FragmentInsertPlacement, type FragmentInsertPlan, type FragmentInsertResult, type FragmentMedia, type SiteFragment, } from '@core/fragments' +import { isAbortError } from '@core/http' import { listCmsDataTables } from '@core/persistence/cmsData' +import { getErrorMessage } from '@core/utils/errorMessage' +import { pushToast } from '@ui/components/Toast' +import { StepUpCancelledMessage } from '@admin/shared/StepUp' import { useEditorStore } from '@site/store/store' import { fragmentInsertDocument } from '@site/store/slices/fragmentsSlice' import { createFragmentInsertAdapter } from './createFragmentInsertAdapter' +type RunStepUp = (action: () => Promise) => Promise +type FragmentProgress = Parameters>[0] + export interface LoadedFragment { fileName: string fragment: SiteFragment @@ -28,7 +40,7 @@ export interface LoadedFragment { /** * A fragment package (`.zip`: `fragment.json` + media) or a bare - * `fragment.json`. A bare JSON file can only carry a fragment without media — + * `fragment.json`. A bare JSON file can only carry a fragment without media: * its media bytes have nowhere to come from. */ export async function loadFragmentFile(file: File): Promise { @@ -50,32 +62,63 @@ export async function loadFragmentFile(file: File): Promise { return { fileName: file.name, fragment: pkg.fragment, readMedia: pkg.readMedia } } -/** Plan the insert against the open document and the site's data tables. */ -export async function planLoadedFragment(loaded: LoadedFragment): Promise { +/** Plan an insert against the open document and the site's data tables. */ +export async function planFragmentForEditor(fragment: SiteFragment): Promise { const tables = await listCmsDataTables() const state = useEditorStore.getState() if (!state.site) throw new Error('The site is still loading.') return planFragmentInsert( { site: state.site, tables, document: fragmentInsertDocument(state) ?? undefined }, - loaded.fragment, + fragment, ) } -export function runFragmentInsert( - loaded: LoadedFragment, +export interface CommitFragmentRequest extends CommitFragmentPlanOptions { + /** The admin shell's step-up runner: table writes may need a recent password. */ + runStepUp: RunStepUp + /** Default `after-selection`. */ + placement?: FragmentInsertPlacement +} + +/** Media, fonts, tables, then ONE store transaction. */ +export function commitFragmentForEditor( plan: FragmentInsertPlan, - runStepUp: (action: () => Promise) => Promise, - signal: AbortSignal, - onProgress: (line: string) => void, + { runStepUp, placement, ...options }: CommitFragmentRequest, ): Promise { - return commitFragmentPlan(plan, createFragmentInsertAdapter(runStepUp), { - readMediaBytes: loaded.readMedia, - signal, - onProgress: ({ phase, done, total, label }) => { - if (phase === 'media') onProgress(`Adding media ${done + 1} of ${total}${label ? `: ${label}` : ''}`) - else if (phase === 'fonts') onProgress(`Installing fonts${label ? `: ${label}` : ''}`) - else if (phase === 'tables') onProgress(`Writing tables${label ? `: ${label}` : ''}`) - else onProgress('Placing the fragment') - }, + return commitFragmentPlan(plan, createFragmentInsertAdapter(runStepUp, placement), options) +} + +/** The dialog's progress line for one step of the commit. */ +export function fragmentProgressLine({ phase, done, total, label }: FragmentProgress): string { + if (phase === 'media') return `Adding media ${done + 1} of ${total}${label ? `: ${label}` : ''}` + if (phase === 'fonts') return `Installing fonts${label ? `: ${label}` : ''}` + if (phase === 'tables') return `Writing tables${label ? `: ${label}` : ''}` + return 'Placing the fragment' +} + +/** The insert was aborted, or the user closed the password prompt a table write asked for. */ +export function isFragmentInsertCancel(err: unknown): boolean { + if (isAbortError(err)) return true + const cause = err instanceof Error ? err.cause : undefined + return cause instanceof Error && cause.message === StepUpCancelledMessage +} + +/** The result toast after a successful insert. */ +export function toastFragmentInserted(result: FragmentInsertResult): void { + const notes = result.report.warnings.length + pushToast({ + kind: notes > 0 ? 'warning' : 'success', + title: result.pageId ? 'Page added' : 'Fragment inserted', + body: notes > 0 ? `${notes} note${notes === 1 ? '' : 's'} in the report.` : 'One undo removes it.', + location: 'site-editor', }) } + +/** The toast after a failed or cancelled insert. */ +export function toastFragmentInsertFailed(err: unknown): void { + if (isFragmentInsertCancel(err)) { + pushToast({ kind: 'info', title: 'Insert cancelled', body: 'Files already uploaded stay in the media library.' }) + return + } + pushToast({ kind: 'error', title: 'Insert failed', body: getErrorMessage(err, 'Unknown insert error') }) +} diff --git a/src/admin/modals/InsertFragment/pluginFragmentsHost.ts b/src/admin/modals/InsertFragment/pluginFragmentsHost.ts new file mode 100644 index 000000000..88b1ad2ae --- /dev/null +++ b/src/admin/modals/InsertFragment/pluginFragmentsHost.ts @@ -0,0 +1,117 @@ +/** + * The site editor's implementation of `api.editor.fragments` (the plugin + * runtime checks the permission and the input shape, then calls here). + * + * Same flow as the Insert fragment dialog: `planFragmentForEditor` against + * the open document, then `commitFragmentForEditor` (media through the + * plugin's `readMediaBytes`, server writes through the step-up prompt, ONE + * store transaction at the requested placement). Failures resolve as an + * outcome; only a fragment `parseSiteFragment` refuses rejects, since that is + * a malformed input. + * + * Loaded on the first plugin call (`useBindPluginFragmentsApi` imports it + * lazily), so the planner stays out of the editor body's chunk. + */ + +import { + FragmentCommitError, + FragmentInsertError, + parseSiteFragment, + type FragmentInsertPlan, +} from '@core/fragments' +import type { + FragmentInsertFailureStage, + FragmentInsertInput, + FragmentInsertOutcome, + FragmentPlanInput, + FragmentPlanOutcome, +} from '@core/plugin-sdk' +import { getErrorMessage } from '@core/utils/errorMessage' +import { useEditorStore } from '@site/store/store' +import { fragmentInsertDocument } from '@site/store/slices/fragmentsSlice' +import { + commitFragmentForEditor, + isFragmentInsertCancel, + planFragmentForEditor, + toastFragmentInsertFailed, + toastFragmentInserted, +} from './fragmentInsertFlow' + +type RunStepUp = (action: () => Promise) => Promise + +type PlanAttempt = + | { ok: true; plan: FragmentInsertPlan } + | { ok: false; stage: 'unavailable' | 'plan'; message: string } + +async function planForActiveDocument(fragment: unknown): Promise { + // Throws FragmentParseError for a malformed fragment: the caller decides + // whether that rejects (insert) or resolves (plan). + const parsed = parseSiteFragment(fragment) + const state = useEditorStore.getState() + if (!state.site || (parsed.kind !== 'page' && !fragmentInsertDocument(state))) { + return { ok: false, stage: 'unavailable', message: 'Open a page or component in the site editor to insert a fragment.' } + } + try { + return { ok: true, plan: await planFragmentForEditor(parsed) } + } catch (err) { + const message = err instanceof FragmentInsertError + ? err.message + : `Could not plan the insert: ${getErrorMessage(err, 'unknown error')}` + return { ok: false, stage: 'plan', message } + } +} + +function failureStage(err: unknown): FragmentInsertFailureStage { + if (isFragmentInsertCancel(err)) return 'cancelled' + if (err instanceof FragmentCommitError) return err.stage + return 'commit' +} + +export async function planFragmentForPlugin(input: FragmentPlanInput): Promise { + let attempt: PlanAttempt + try { + attempt = await planForActiveDocument(input.fragment) + } catch (err) { + return { ok: false, message: getErrorMessage(err, 'The fragment is not valid.') } + } + return attempt.ok ? { ok: true, report: attempt.plan.report } : { ok: false, message: attempt.message } +} + +export async function insertFragmentForPlugin( + input: FragmentInsertInput, + runStepUp: RunStepUp, +): Promise { + const announce = input.silent !== true + const attempt = await planForActiveDocument(input.fragment) + if (!attempt.ok) { + if (announce) toastFragmentInsertFailed(new Error(attempt.message)) + return attempt + } + if (input.signal?.aborted) { + return { ok: false, stage: 'cancelled', message: 'The insert was cancelled.' } + } + try { + const result = await commitFragmentForEditor(attempt.plan, { + readMediaBytes: input.readMediaBytes, + runStepUp, + placement: input.placement, + signal: input.signal, + onProgress: input.onProgress, + }) + if (announce) toastFragmentInserted(result) + return { + ok: true, + report: result.report, + insertedRootIds: result.rootIds, + ...(result.pageId ? { pageId: result.pageId } : {}), + } + } catch (err) { + if (announce) toastFragmentInsertFailed(err) + const stage = failureStage(err) + return { + ok: false, + stage, + message: stage === 'cancelled' ? 'The insert was cancelled.' : getErrorMessage(err, 'Unknown insert error'), + } + } +} diff --git a/src/admin/modals/InsertFragment/useBindPluginFragmentsApi.ts b/src/admin/modals/InsertFragment/useBindPluginFragmentsApi.ts new file mode 100644 index 000000000..21f6599bb --- /dev/null +++ b/src/admin/modals/InsertFragment/useBindPluginFragmentsApi.ts @@ -0,0 +1,24 @@ +/** + * Binds `api.editor.fragments` for plugins while the site editor body is + * mounted and the user can edit the draft site (`enabled`). The host closes + * over the admin shell's step-up runner (table writes may ask for the + * password) and loads the insert flow on first use, so the planner stays out + * of the editor body's chunk. + */ + +import { useEffect } from 'react' +import { bindPluginRuntimeFragmentsHost } from '@core/plugins/runtime' +import { useStepUp } from '@admin/shared/StepUp' + +const loadHost = () => import('./pluginFragmentsHost') + +export function useBindPluginFragmentsApi(enabled: boolean): void { + const { runStepUp } = useStepUp() + useEffect(() => { + if (!enabled) return undefined + return bindPluginRuntimeFragmentsHost({ + insert: async (input) => (await loadHost()).insertFragmentForPlugin(input, runStepUp), + plan: async (input) => (await loadHost()).planFragmentForPlugin(input), + }) + }, [enabled, runStepUp]) +} diff --git a/src/admin/pages/site/store/slices/fragmentsSlice.ts b/src/admin/pages/site/store/slices/fragmentsSlice.ts index cc2101a4c..083127566 100644 --- a/src/admin/pages/site/store/slices/fragmentsSlice.ts +++ b/src/admin/pages/site/store/slices/fragmentsSlice.ts @@ -8,20 +8,32 @@ * framework utility classes are regenerated in the same recipe when the * insert added a colour or a scale step. * - * Placement: a section or component lands right after the selected element - * (`resolveSiblingInsertLocation`), or at the end of the document when nothing - * or the root is selected, in the document the plan was made for. Unlike a - * paste it never goes INSIDE a selected container, so a second section - * inserted while the first is still selected lands below it. + * Placement (`FragmentInsertPlacement`), in the document the plan was made + * for: + * - `after-selection` (the default, and the Insert fragment dialog's only + * mode): right after the selected element (`resolveSiblingInsertLocation`), + * or at the end of the document when nothing or the root is selected. It + * never goes INSIDE a selected container, so a second section inserted + * while the first is still selected lands below it. + * - `inside-selection`: inside the selected container, like a paste + * (`resolveInsertLocation`); the end of the document when nothing is + * selected. + * - `end`: the end of the document whatever is selected. */ import { applyResolvedFragment, type FragmentApplyResult, type FragmentInsertDocument, + type FragmentInsertPlacement, type ResolvedFragmentPlan, } from '@core/fragments' -import { resolveSiblingInsertLocation } from '@site/store/insertLocation' +import type { NodeTree, PageNode } from '@core/page-tree' +import { + resolveInsertLocation, + resolveSiblingInsertLocation, + type InsertLocation, +} from '@site/store/insertLocation' import type { EditorStore, EditorStoreSliceCreator } from '@site/store/types' import { reconcileFrameworkClasses } from './site/framework/reconcile' import { buildSiteHelpers, resolveActiveTreeTarget } from './site/helpers' @@ -36,11 +48,12 @@ interface FragmentsSlice { /** * Apply a resolved fragment insert plan in ONE undo step. A page fragment * adds its page; a section or component lands in the document the plan was - * made for and its first root is selected. Returns null when that document - * is no longer the active one, there is nowhere to place the fragment, or - * the collab write gate refused the edit. + * made for, at `placement` (default `after-selection`), and its first root + * is selected. Returns null when that document is no longer the active + * one, there is nowhere to place the fragment, or the collab write gate + * refused the edit. */ - insertFragment: (plan: ResolvedFragmentPlan) => FragmentApplyResult | null + insertFragment: (plan: ResolvedFragmentPlan, placement?: FragmentInsertPlacement) => FragmentApplyResult | null } declare module '@site/store/types' { @@ -60,6 +73,18 @@ export function fragmentInsertDocument( return pageId ? { kind: 'page', pageId } : null } +/** Where a section or component lands in `tree` for `placement`. */ +function fragmentInsertLocation( + tree: NodeTree, + selectedNodeId: string | null, + placement: FragmentInsertPlacement, +): InsertLocation | null { + const selected = selectedNodeId && tree.nodes[selectedNodeId] ? selectedNodeId : tree.rootNodeId + if (placement === 'end') return resolveInsertLocation(tree, tree.rootNodeId) + if (placement === 'inside-selection') return resolveInsertLocation(tree, selected) + return resolveSiblingInsertLocation(tree, selected) +} + export const createFragmentsSlice: EditorStoreSliceCreator = (set, get) => { const { mutateSite, mutateActiveTreeAndSite } = buildSiteHelpers(set, get) @@ -68,7 +93,7 @@ export const createFragmentsSlice: EditorStoreSliceCreator = (se openInsertFragmentModal: () => set({ insertFragmentModalOpen: true }), closeInsertFragmentModal: () => set({ insertFragmentModalOpen: false }), - insertFragment: (plan) => { + insertFragment: (plan, placement = 'after-selection') => { const state = get() if (!state.site) return null let result: FragmentApplyResult | null = null @@ -86,7 +111,7 @@ export const createFragmentsSlice: EditorStoreSliceCreator = (se if (!plan.document || !isSameActiveDocument(fragmentInsertDocument(state), plan.document)) return null const target = resolveActiveTreeTarget(state) if (!target) return null - const location = resolveSiblingInsertLocation(target.tree, state.selectedNodeId ?? target.tree.rootNodeId) + const location = fragmentInsertLocation(target.tree, state.selectedNodeId, placement) if (!location) return null const accepted = mutateActiveTreeAndSite((tree, site) => { diff --git a/src/core/fragments/adapter.ts b/src/core/fragments/adapter.ts index 3ba9032ba..71394f8e1 100644 --- a/src/core/fragments/adapter.ts +++ b/src/core/fragments/adapter.ts @@ -2,9 +2,10 @@ * FragmentInsertAdapter — the I/O `commitFragmentPlan` needs, as an interface. * * The engine stays headless; the editor implements this against the CMS API - * and its store (`src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts`), - * tests implement it in memory, and a future plugin API can implement it with - * its own media resolver. + * and its store (`src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts`, + * shared by the Insert fragment dialog and `api.editor.fragments`; the caller + * supplies media bytes through `readMediaBytes`), and tests implement it in + * memory. * * Interface only — no implementation here. */ diff --git a/src/core/fragments/index.ts b/src/core/fragments/index.ts index 2a4cf2a76..68f13a15a 100644 --- a/src/core/fragments/index.ts +++ b/src/core/fragments/index.ts @@ -50,6 +50,7 @@ export type { FragmentInsertErrorCode } from './plan' export { FRAGMENT_PROPERTIES_ORIGIN } from './planTokens' export type { FragmentInsertDocument, + FragmentInsertPlacement, FragmentInsertPlan, FragmentInsertReport, FragmentInsertTarget, diff --git a/src/core/fragments/planTypes.ts b/src/core/fragments/planTypes.ts index 3c90287fa..6efb2e390 100644 --- a/src/core/fragments/planTypes.ts +++ b/src/core/fragments/planTypes.ts @@ -23,6 +23,17 @@ export type FragmentInsertDocument = | { kind: 'page'; pageId: string } | { kind: 'visualComponent'; vcId: string } +/** + * Where an editor places a section or component relative to its selection: + * - `after-selection`: the next sibling of the selected element (the end of + * the document when nothing or the root is selected); + * - `inside-selection`: inside the selected container, like a paste (a leaf + * target gets it as its next sibling); + * - `end`: the end of the document, whatever is selected. + * A page fragment ignores it and adds its own page. + */ +export type FragmentInsertPlacement = 'after-selection' | 'inside-selection' | 'end' + export interface FragmentInsertTarget { site: SiteDocument /** The target's data tables (the editor store holds none; read them from the CMS API). */ diff --git a/src/core/plugin-sdk/builders/permissions.ts b/src/core/plugin-sdk/builders/permissions.ts index 149780bcb..5383ded17 100644 --- a/src/core/plugin-sdk/builders/permissions.ts +++ b/src/core/plugin-sdk/builders/permissions.ts @@ -28,6 +28,7 @@ export const permissions = { editorCommands: 'editor.commands', editorStoreRead: 'editor.store.read', editorStoreWrite: 'editor.store.write', + editorFragments: 'editor.fragments', editorCanvas: 'editor.canvas', editorPanels: 'editor.panels', dashboardWidgetsRegister: 'dashboard.widgets.register', diff --git a/src/core/plugin-sdk/capabilities.ts b/src/core/plugin-sdk/capabilities.ts index 15f4d3e51..114326bed 100644 --- a/src/core/plugin-sdk/capabilities.ts +++ b/src/core/plugin-sdk/capabilities.ts @@ -82,6 +82,13 @@ export const PLUGIN_CAPABILITIES: PluginCapability[] = [ risk: 'high', surfaces: ['editor'], }, + { + permission: 'editor.fragments', + label: 'Insert site fragments', + description: 'Allows the plugin to insert sections, pages and components into the open document from fragment files it supplies. An insert can add classes, colours, fonts and components to the site, upload media files and create data tables; anything the site already has stays as it is. One undo removes the inserted content; uploaded files and created tables stay.', + risk: 'high', + surfaces: ['editor'], + }, { permission: 'editor.canvas', label: 'Add canvas overlays', diff --git a/src/core/plugin-sdk/types/editorApi.ts b/src/core/plugin-sdk/types/editorApi.ts index 53ced368c..454960928 100644 --- a/src/core/plugin-sdk/types/editorApi.ts +++ b/src/core/plugin-sdk/types/editorApi.ts @@ -7,6 +7,7 @@ import type { import type { PluginCanvasOverlay } from './canvasOverlays' import type { PluginCommand, PluginPaletteProvider, PluginToolbarButton } from './commands' import type { PluginDashboardWidget } from './dashboardWidgets' +import type { EditorFragmentsApi } from './fragments' import type { PluginEditorPanel } from './panels' import type { PluginPermission } from './permissions' @@ -60,6 +61,13 @@ export interface EditorPluginApi { read: () => EditorStore transaction: (mutate: (store: EditorStore) => void) => void } + /** + * Insert site fragments into the open document, or plan one as a dry + * run. Requires the `editor.fragments` permission. Works while the site + * editor has a document open; elsewhere `insert` resolves + * `{ ok: false, stage: 'unavailable' }` and `plan` `{ ok: false }`. + */ + fragments: EditorFragmentsApi /** * Command Spotlight (⌘K) integration. * diff --git a/src/core/plugin-sdk/types/fragments.ts b/src/core/plugin-sdk/types/fragments.ts new file mode 100644 index 000000000..5e7453007 --- /dev/null +++ b/src/core/plugin-sdk/types/fragments.ts @@ -0,0 +1,75 @@ +import type { + FragmentInsertPlacement, + FragmentInsertReport, + FragmentMedia, +} from '@core/fragments' + +export type { FragmentInsertPlacement, FragmentInsertReport, FragmentMedia } + +// --------------------------------------------------------------------------- +// api.editor.fragments: insert a site fragment into the open document +// --------------------------------------------------------------------------- + +/** One step of an insert's server work, for a progress line. */ +export interface FragmentInsertProgress { + phase: 'media' | 'fonts' | 'tables' | 'commit' + done: number + total: number + label?: string +} + +export interface FragmentInsertInput { + /** A fragment object (`fragment.json`, parsed). Checked with `parseSiteFragment`. */ + fragment: unknown + /** + * The bytes of one media item the insert adds. Only called for media the + * plan adds that the media library does not already hold. Return null when + * the bytes are unavailable; the insert then stops before touching the + * document. + */ + readMediaBytes: (media: FragmentMedia) => Uint8Array | null | Promise + /** Where a section or component lands. Default `after-selection`. */ + placement?: FragmentInsertPlacement + /** Aborts at the next step boundary; never after the store write started. */ + signal?: AbortSignal + onProgress?: (progress: FragmentInsertProgress) => void + /** Skip the editor's result toast (the plugin shows its own). */ + silent?: boolean +} + +/** + * Why an insert did not happen: + * - `unavailable`: no site editor document is open; + * - `plan`: the fragment cannot go into this document (a second outlet, a + * component cycle) or the site's tables could not be read; + * - `media` / `tables`: a server write failed; the document is untouched; + * - `commit`: the editor could not place it (the document changed, or the + * edit was refused); + * - `cancelled`: `signal` aborted, or the user closed the password prompt. + */ +export type FragmentInsertFailureStage = 'unavailable' | 'plan' | 'media' | 'tables' | 'commit' | 'cancelled' + +export type FragmentInsertOutcome = + | { ok: true; report: FragmentInsertReport; insertedRootIds: string[]; pageId?: string } + | { ok: false; stage: FragmentInsertFailureStage; message: string } + +export interface FragmentPlanInput { + fragment: unknown +} + +export type FragmentPlanOutcome = + | { ok: true; report: FragmentInsertReport } + | { ok: false; message: string } + +export interface EditorFragmentsApi { + /** + * Insert a fragment into the open document with the same engine as the + * Insert fragment dialog: the site's own classes, components, tokens and + * tables win, the whole insert is one undo step, and the inserted root is + * selected. Resolves with an outcome; rejects only for a malformed `input` + * (including a fragment `parseSiteFragment` refuses). + */ + insert: (input: FragmentInsertInput) => Promise + /** Dry run against the open document: what an insert would add. Never changes anything. */ + plan: (input: FragmentPlanInput) => Promise +} diff --git a/src/core/plugin-sdk/types/index.ts b/src/core/plugin-sdk/types/index.ts index 8efc6bf43..165f8de96 100644 --- a/src/core/plugin-sdk/types/index.ts +++ b/src/core/plugin-sdk/types/index.ts @@ -23,6 +23,7 @@ export * from './commands' export * from './panels' export * from './canvasOverlays' export * from './dashboardWidgets' +export * from './fragments' export * from './editorApi' export * from './routes' export * from './hooks' diff --git a/src/core/plugin-sdk/types/permissions.ts b/src/core/plugin-sdk/types/permissions.ts index 5c4a6ac6a..412be704a 100644 --- a/src/core/plugin-sdk/types/permissions.ts +++ b/src/core/plugin-sdk/types/permissions.ts @@ -47,6 +47,10 @@ export const PLUGIN_PERMISSION_VALUES = [ 'editor.panels', 'editor.store.read', 'editor.store.write', + // Insert site fragments into the open document (`api.editor.fragments`): + // adds classes, tokens, components, media and data tables the fragment + // needs, through the same flow as the editor's Insert fragment dialog. + 'editor.fragments', // Builder extensions 'modules.register', 'loops.register', diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts index 8f602c345..80240c973 100644 --- a/src/core/plugins/runtime.ts +++ b/src/core/plugins/runtime.ts @@ -57,8 +57,12 @@ function requireEditorStore(): EditorStoreApi { return editorStoreApi } import type { + EditorFragmentsApi, EditorPluginApi, EditorPluginModule, + FragmentInsertInput, + FragmentInsertPlacement, + FragmentPlanInput, PluginCanvasOverlay, PluginCommand, PluginCommandResult, @@ -74,6 +78,56 @@ import type { import { assertPluginPermission } from '@core/plugin-sdk' import { dashboardWidgetRegistry } from '@core/dashboard' +/** + * Fragment insert host, injected by the site editor body while it is + * mounted (`useBindPluginFragmentsApi`). The implementation plans against + * the open document, uploads media and writes tables through the step-up + * prompt, and commits through the editor store: everything the runtime + * must not import. Unbound (no site editor open), `api.editor.fragments` + * resolves a not-available outcome instead of throwing. + */ +let fragmentsHost: EditorFragmentsApi | null = null + +/** Bind the fragment insert host; returns the unbind for the effect cleanup. */ +export function bindPluginRuntimeFragmentsHost(host: EditorFragmentsApi): () => void { + fragmentsHost = host + return () => { + if (fragmentsHost === host) fragmentsHost = null + } +} + +const FRAGMENT_PLACEMENTS: ReadonlySet = new Set(['after-selection', 'inside-selection', 'end']) +const FRAGMENTS_UNAVAILABLE = 'Open a page or component in the site editor to insert a fragment.' + +function assertFragmentPlanInput(input: unknown, method: string): asserts input is FragmentPlanInput { + if (!input || typeof input !== 'object') { + throw new TypeError(`editor.fragments.${method}: input must be an object`) + } + if (!('fragment' in input) || input.fragment === undefined || input.fragment === null) { + throw new TypeError(`editor.fragments.${method}: input.fragment is required`) + } +} + +function assertFragmentInsertInput(input: unknown): asserts input is FragmentInsertInput { + assertFragmentPlanInput(input, 'insert') + const value = input as Partial> + if (typeof value.readMediaBytes !== 'function') { + throw new TypeError('editor.fragments.insert: input.readMediaBytes must be a function') + } + if (value.placement !== undefined && !FRAGMENT_PLACEMENTS.has(value.placement as string)) { + throw new TypeError(`editor.fragments.insert: input.placement must be one of ${[...FRAGMENT_PLACEMENTS].join(', ')}`) + } + if (value.onProgress !== undefined && typeof value.onProgress !== 'function') { + throw new TypeError('editor.fragments.insert: input.onProgress must be a function') + } + if (value.signal !== undefined && !(value.signal instanceof AbortSignal)) { + throw new TypeError('editor.fragments.insert: input.signal must be an AbortSignal') + } + if (value.silent !== undefined && typeof value.silent !== 'boolean') { + throw new TypeError('editor.fragments.insert: input.silent must be a boolean') + } +} + /** * Icon-name → component resolver injected by the admin shell at boot. * @@ -507,6 +561,20 @@ export function createEditorPluginApi( }) }, }, + fragments: { + async insert(input) { + assertPluginPermission(manifest, 'editor.fragments') + assertFragmentInsertInput(input) + if (!fragmentsHost) return { ok: false, stage: 'unavailable', message: FRAGMENTS_UNAVAILABLE } + return await fragmentsHost.insert(input) + }, + async plan(input) { + assertPluginPermission(manifest, 'editor.fragments') + assertFragmentPlanInput(input, 'plan') + if (!fragmentsHost) return { ok: false, message: FRAGMENTS_UNAVAILABLE } + return await fragmentsHost.plan(input) + }, + }, palette: { registerCommand(cmd) { const granted = (manifest.grantedPermissions ?? []).includes('editor.commands') From ec82bcb536a15628c7dec65ff19d88f54983df90 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 20:13:39 +0200 Subject: [PATCH 2/2] feat(fragments): fill a missing colour from one of the site's own A section that brings a colour the site lacks used to add it with the source site's value, so a pale band could land under a dark site's light text. Callers can now name a site colour to take the values from (colorSources on planFragmentInsert and on the plugin API's plan and insert); the report lists what was filled. --- docs/features/fragments.md | 2 +- docs/features/plugin-system.md | 1 + src/__tests__/fragments/plan.test.ts | 27 ++++++++++++++++++- .../plugins/pluginFragmentsApi.test.ts | 2 ++ .../InsertFragment/fragmentInsertFlow.ts | 4 ++- .../InsertFragment/pluginFragmentsHost.ts | 8 +++--- src/core/fragments/index.ts | 1 + src/core/fragments/plan.ts | 7 ++--- src/core/fragments/planTokens.ts | 11 +++++++- src/core/fragments/planTypes.ts | 14 +++++++++- src/core/plugin-sdk/types/fragments.ts | 8 ++++++ src/core/plugins/runtime.ts | 5 ++++ 12 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docs/features/fragments.md b/docs/features/fragments.md index 36a38a0a5..f0e816511 100644 --- a/docs/features/fragments.md +++ b/docs/features/fragments.md @@ -124,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. | diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 614efba49..538a6a5d0 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -504,6 +504,7 @@ export function activate(api) { ``` - `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`. diff --git a/src/__tests__/fragments/plan.test.ts b/src/__tests__/fragments/plan.test.ts index bab0762fe..bbbc77c97 100644 --- a/src/__tests__/fragments/plan.test.ts +++ b/src/__tests__/fragments/plan.test.ts @@ -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') @@ -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) diff --git a/src/__tests__/plugins/pluginFragmentsApi.test.ts b/src/__tests__/plugins/pluginFragmentsApi.test.ts index 6f9e82ead..d4ac00029 100644 --- a/src/__tests__/plugins/pluginFragmentsApi.test.ts +++ b/src/__tests__/plugins/pluginFragmentsApi.test.ts @@ -223,6 +223,8 @@ describe('api.editor.fragments', () => { await expect(api().editor.fragments.insert({ fragment } as never)).rejects.toThrow('readMediaBytes must be a function') await expect(api().editor.fragments.insert({ fragment, readMediaBytes: () => null, placement: 'top' as never })) .rejects.toThrow('placement must be one of') + await expect(api().editor.fragments.plan({ fragment, colorSources: { 'bg-alt': 3 } as never })) + .rejects.toThrow('colorSources must map colour slugs to colour slugs') }) it('a media failure resolves with its stage and leaves the document untouched', async () => { diff --git a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts index 62c851150..5fe27e332 100644 --- a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts +++ b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts @@ -18,6 +18,7 @@ import { type FragmentInsertPlan, type FragmentInsertResult, type FragmentMedia, + type FragmentPlanOptions, type SiteFragment, } from '@core/fragments' import { isAbortError } from '@core/http' @@ -63,13 +64,14 @@ export async function loadFragmentFile(file: File): Promise { } /** Plan an insert against the open document and the site's data tables. */ -export async function planFragmentForEditor(fragment: SiteFragment): Promise { +export async function planFragmentForEditor(fragment: SiteFragment, options: FragmentPlanOptions = {}): Promise { const tables = await listCmsDataTables() const state = useEditorStore.getState() if (!state.site) throw new Error('The site is still loading.') return planFragmentInsert( { site: state.site, tables, document: fragmentInsertDocument(state) ?? undefined }, fragment, + options, ) } diff --git a/src/admin/modals/InsertFragment/pluginFragmentsHost.ts b/src/admin/modals/InsertFragment/pluginFragmentsHost.ts index 88b1ad2ae..d72b98761 100644 --- a/src/admin/modals/InsertFragment/pluginFragmentsHost.ts +++ b/src/admin/modals/InsertFragment/pluginFragmentsHost.ts @@ -43,7 +43,7 @@ type PlanAttempt = | { ok: true; plan: FragmentInsertPlan } | { ok: false; stage: 'unavailable' | 'plan'; message: string } -async function planForActiveDocument(fragment: unknown): Promise { +async function planForActiveDocument(fragment: unknown, colorSources?: Record): Promise { // Throws FragmentParseError for a malformed fragment: the caller decides // whether that rejects (insert) or resolves (plan). const parsed = parseSiteFragment(fragment) @@ -52,7 +52,7 @@ async function planForActiveDocument(fragment: unknown): Promise { return { ok: false, stage: 'unavailable', message: 'Open a page or component in the site editor to insert a fragment.' } } try { - return { ok: true, plan: await planFragmentForEditor(parsed) } + return { ok: true, plan: await planFragmentForEditor(parsed, colorSources ? { colorSources } : {}) } } catch (err) { const message = err instanceof FragmentInsertError ? err.message @@ -70,7 +70,7 @@ function failureStage(err: unknown): FragmentInsertFailureStage { export async function planFragmentForPlugin(input: FragmentPlanInput): Promise { let attempt: PlanAttempt try { - attempt = await planForActiveDocument(input.fragment) + attempt = await planForActiveDocument(input.fragment, input.colorSources) } catch (err) { return { ok: false, message: getErrorMessage(err, 'The fragment is not valid.') } } @@ -82,7 +82,7 @@ export async function insertFragmentForPlugin( runStepUp: RunStepUp, ): Promise { const announce = input.silent !== true - const attempt = await planForActiveDocument(input.fragment) + const attempt = await planForActiveDocument(input.fragment, input.colorSources) if (!attempt.ok) { if (announce) toastFragmentInsertFailed(new Error(attempt.message)) return attempt diff --git a/src/core/fragments/index.ts b/src/core/fragments/index.ts index 68f13a15a..dc375c00e 100644 --- a/src/core/fragments/index.ts +++ b/src/core/fragments/index.ts @@ -54,6 +54,7 @@ export type { FragmentInsertPlan, FragmentInsertReport, FragmentInsertTarget, + FragmentPlanOptions, PlannedCustomProperties, PlannedFontFamily, PlannedScaleSteps, diff --git a/src/core/fragments/plan.ts b/src/core/fragments/plan.ts index 172e61ed6..2b62d4297 100644 --- a/src/core/fragments/plan.ts +++ b/src/core/fragments/plan.ts @@ -50,6 +50,7 @@ import type { FragmentInsertPlan, FragmentInsertReport, FragmentInsertTarget, + FragmentPlanOptions, } from './planTypes' import type { FragmentWarning } from './warnings' @@ -71,14 +72,14 @@ export class FragmentInsertError extends Error { const VC_REF = 'base.visual-component-ref' -export function planFragmentInsert(target: FragmentInsertTarget, fragment: SiteFragment): FragmentInsertPlan { +export function planFragmentInsert(target: FragmentInsertTarget, fragment: SiteFragment, options: FragmentPlanOptions = {}): FragmentInsertPlan { const { site } = target const warnings: FragmentWarning[] = [] const report = emptyReport(fragment) const hostTree = resolveHostTree(target, fragment) // ── Tokens first: framework utilities resolve against the merged settings ── - const tokens = planTokens(site, fragment, report, warnings) + const tokens = planTokens(site, fragment, report, warnings, options.colorSources) const allocateOrder = createStyleRuleOrderAllocator(site.styleRules) const customProperties = planCustomProperties(site, fragment, tokens, allocateOrder, report, warnings) @@ -264,7 +265,7 @@ function emptyReport(fragment: SiteFragment): FragmentInsertReport { return { kind: fragment.kind, nodes: 0, - colors: { added: [], kept: [] }, + colors: { added: [], kept: [], filled: [] }, fonts: { tokensAdded: [], tokensKept: [], familiesAdded: [], familiesKept: [] }, steps: { added: [] }, customProperties: { added: [], kept: [] }, diff --git a/src/core/fragments/planTokens.ts b/src/core/fragments/planTokens.ts index c44be9bbc..e44dd4540 100644 --- a/src/core/fragments/planTokens.ts +++ b/src/core/fragments/planTokens.ts @@ -54,6 +54,7 @@ export function planTokens( fragment: SiteFragment, report: FragmentInsertReport, warnings: FragmentWarning[], + colorSources: Record = {}, ): TokenPlan { const now = Date.now() const framework = site.settings.framework @@ -71,8 +72,16 @@ export function planTokens( } slugs.add(slug) colorOrder += 1 - colorTokens.push({ ...structuredClone(token), id: nanoid(), slug, order: colorOrder, createdAt: now, updatedAt: now }) + // The caller may fill a missing colour from one of the site's own, so a + // section's band takes the site's surface rather than the kit's. + const sourceSlug = colorSources[slug] ? normalizeFrameworkColorSlug(colorSources[slug]) : null + const source = sourceSlug ? existingColors.find((t) => normalizeFrameworkColorSlug(t.slug) === sourceSlug) : undefined + const values = source + ? { lightValue: source.lightValue, darkValue: source.darkValue, darkModeEnabled: source.darkModeEnabled } + : {} + colorTokens.push({ ...structuredClone(token), ...values, id: nanoid(), slug, order: colorOrder, createdAt: now, updatedAt: now }) report.colors.added.push(slug) + if (source) report.colors.filled.push({ slug, from: source.slug }) } // ── Scale steps ──────────────────────────────────────────────────────────── diff --git a/src/core/fragments/planTypes.ts b/src/core/fragments/planTypes.ts index 6efb2e390..f0c01e772 100644 --- a/src/core/fragments/planTypes.ts +++ b/src/core/fragments/planTypes.ts @@ -90,13 +90,25 @@ export interface PlannedCustomProperties { properties: Record } +/** Choices the caller makes about an insert; the defaults follow the fragment. */ +export interface FragmentPlanOptions { + /** + * For a colour the fragment adds (the site lacks its slug), the slug of one + * of the site's own colours to take its values from instead of the + * fragment's: `{ 'bg-alt': 'bg-surface' }`. Ignored for colours the site + * already has and for source slugs the site does not have. + */ + colorSources?: Record +} + export interface FragmentInsertReport { kind: FragmentKind /** The page a page fragment adds, with its final (possibly suffixed) slug. */ page?: { title: string; slug: string } /** Nodes placed in the document (component trees not counted). */ nodes: number - colors: { added: string[]; kept: string[] } + /** `filled`: added colours whose value was taken from one of the site's own (`colorSources`). */ + colors: { added: string[]; kept: string[]; filled: { slug: string; from: string }[] } fonts: { tokensAdded: string[]; tokensKept: string[]; familiesAdded: string[]; familiesKept: string[] } steps: { added: { family: ScaleFamily; convention: string; step: string }[] } customProperties: { added: string[]; kept: string[] } diff --git a/src/core/plugin-sdk/types/fragments.ts b/src/core/plugin-sdk/types/fragments.ts index 5e7453007..5396c6a3c 100644 --- a/src/core/plugin-sdk/types/fragments.ts +++ b/src/core/plugin-sdk/types/fragments.ts @@ -30,6 +30,8 @@ export interface FragmentInsertInput { readMediaBytes: (media: FragmentMedia) => Uint8Array | null | Promise /** Where a section or component lands. Default `after-selection`. */ placement?: FragmentInsertPlacement + /** See `FragmentPlanInput.colorSources`. */ + colorSources?: Record /** Aborts at the next step boundary; never after the store write started. */ signal?: AbortSignal onProgress?: (progress: FragmentInsertProgress) => void @@ -55,6 +57,12 @@ export type FragmentInsertOutcome = export interface FragmentPlanInput { fragment: unknown + /** + * Fill a colour the fragment adds from one of the site's own colours: + * `{ 'bg-alt': 'bg-surface' }` gives the new `bg-alt` token the site's + * `bg-surface` values. Same option on `insert`. + */ + colorSources?: Record } export type FragmentPlanOutcome = diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts index 80240c973..d52b6c48c 100644 --- a/src/core/plugins/runtime.ts +++ b/src/core/plugins/runtime.ts @@ -106,6 +106,11 @@ function assertFragmentPlanInput(input: unknown, method: string): asserts input if (!('fragment' in input) || input.fragment === undefined || input.fragment === null) { throw new TypeError(`editor.fragments.${method}: input.fragment is required`) } + const sources = (input as { colorSources?: unknown }).colorSources + if (sources !== undefined && (!sources || typeof sources !== 'object' || Array.isArray(sources) + || Object.values(sources).some((v) => typeof v !== 'string'))) { + throw new TypeError(`editor.fragments.${method}: input.colorSources must map colour slugs to colour slugs`) + } } function assertFragmentInsertInput(input: unknown): asserts input is FragmentInsertInput {