diff --git a/docs/README.md b/docs/README.md index 833d8cfdb..926cd1f1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ docs/ │ ├── site-transfer.md ← export / import CMS bundles │ ├── site-import.md ← Super Import (static sites → CMS) │ ├── html-import.md ← paste / import HTML into the page tree +│ ├── fragments.md ← portable sections/pages/components: extract, plan, insert │ ├── editor-preferences.md ← catalog-driven editor prefs │ └── canvas-iframe-per-frame.md ← per-breakpoint iframe rendering │ @@ -160,6 +161,7 @@ Three categories, three voices: | [features/site-transfer.md](features/site-transfer.md) | Export / import CMS bundle (JSON round-trip between instances) | | [features/site-import.md](features/site-import.md) | Super Import — static-site files / ZIP → pages, style rules, media | | [features/html-import.md](features/html-import.md) | HTML string → `PageNode` fragment (paste HTML, AI `insertHtml` tool) | +| [features/fragments.md](features/fragments.md) | Site fragments — a section, page or component with its styles, tokens, components, tables and media; extract, plan and insert | | [features/editor-preferences.md](features/editor-preferences.md) | Catalog-driven local UI preferences for the editor | | [features/canvas-iframe-per-frame.md](features/canvas-iframe-per-frame.md) | Per-breakpoint iframe rendering in the visual editor canvas | diff --git a/docs/architecture.md b/docs/architecture.md index 367ecea02..d8c5bbc68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,7 @@ The repo is organized by responsibility, not by feature. Every file has one reas | Framework schemas (leaf) | `src/core/framework-schema/*` | Pure TypeBox schemas + derived types for persisted framework settings (`FrameworkSettings`, `GeneratedClassMetadata`, etc.); no dependency on the engine or page-tree | | CSS value sanitiser (leaf) | `src/core/css-sanitize/*` | Single canonical `sanitiseCssValue` — dependency-free leaf shared by `@core/publisher` and `@core/framework`; blocks `expression()` / `javascript:` / `{}` / `/` and are replaced on pack re-sync (see [`docs/features/plugin-system.md`](features/plugin-system.md)). The inserter's Layouts section groups them accordingly: the user's **Saved** layouts first, then one group per plugin labelled with the plugin's display name (`composeLayoutsSection` + `pluginRuntime.getPluginName`). In the inserter, snapshot-borne hazards disable items inline instead of failing on click: a snapshot carrying a `base.outlet` follows the outlet module's own placement rules, and in VC mode a snapshot whose component refs would create a dependency cycle is disabled. Refs to since-deleted Visual Components are stripped at insertion time. Right-clicking a saved layout in the inserter offers Rename… (closes the inserter and reopens `LayoutNameDialog`) and Delete (immediate — it's an undoable site mutation — confirmed via toast). @@ -668,6 +670,8 @@ Architecture: - **`keybindings.ts`** — declarative keybinding registry. - **`state.ts`, `stateHandlers.ts`** — palette state machine. +**Insert fragment from file…** (`editor.insertFragment`, `commands/fragments.ts`) opens `InsertFragmentModal` (`src/admin/modals/InsertFragment/`), lazily mounted by `AdminCanvasEditorBody` while `insertFragmentModalOpen`; see [`features/fragments.md`](features/fragments.md). + The palette is wired so that **plugin-registered commands work the same as built-in ones**. Spotlight is the editor's keyboard surface. --- diff --git a/docs/features/fragments.md b/docs/features/fragments.md new file mode 100644 index 000000000..2bb6fce79 --- /dev/null +++ b/docs/features/fragments.md @@ -0,0 +1,195 @@ +# Site Fragments + +The portable format for a piece of an Instatic site, and the engine that cuts one out of a site and inserts it into another. + +A fragment is one versioned file that carries a section (one or more sibling subtrees), a whole page, or a Visual Component, together with everything it needs to render the same in any other Instatic site: the style rules its nodes use and the ambient rules built on them, the conditions those rules key on, the component closure, the design tokens its styles read, the data tables its loops and forms use, its media, and its internal page links. `extractFragment` builds one from a site; `planFragmentInsert` decides what an insert adds to a site (the site's own version of anything always wins); `commitFragmentPlan` carries the plan out with one undo step. + +--- + +## TL;DR + +- Code: `src/core/fragments/` (headless, no React/admin/server imports, transitively; gated by `fragments-headless.test.ts`). Barrel: `@core/fragments`. A Bun script can import it by path: `import { extractFragment } from '/src/core/fragments'`. +- Format: `SiteFragmentSchema` (`format: 'instatic-fragment'`, `version: 1`) in `schema.ts`; `parseSiteFragment(unknown)` is the boundary. +- Package file: a zip with `fragment.json` + `media/.` (`writeFragmentPackage` / `readFragmentPackage`, fflate). Media bytes are never inlined in the JSON. +- Sources: `fragmentSourceFromBundleArchive(zipBytes)` (the Export site archive), `fragmentSourceFromBundle(bundle)` (manifest or JSON bundle), `fragmentSourceFromSite(site, { tables, rows, media })` (a live editor document). +- 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. +- Paste, saved layouts, HTML import and site import do not use fragments and behave exactly as before. + +--- + +## Where the code lives + +```text +src/core/fragments/ +├── index.ts public barrel +├── schema.ts SiteFragmentSchema, parseSiteFragment, FragmentParseError +├── source.ts FragmentSource + fragmentSourceFrom{Bundle,BundleArchive,Site} +├── extract.ts extractFragment (orchestrator, selection, components, links) +├── extractStyles.ts framework stubs, @keyframes, outside dependencies, conditions +├── extractTokens.ts var(--x) closure, classified by the framework's generators +├── extractData.ts tables (loops, CMS forms) and media +├── siteVariables.ts indexSiteVariables: which --vars a site defines, and from where +├── references.ts string walkers: media paths, cms:page links, var(), table refs +├── mediaKeys.ts sha256Hex (Web Crypto), mediaPackagePath +├── package.ts writeFragmentPackage / readFragmentPackage +├── planTypes.ts FragmentInsertTarget, FragmentInsertPlan, FragmentInsertReport +├── plan.ts planFragmentInsert, FragmentInsertError +├── planTokens.ts colours, scale steps, fonts, root custom properties +├── planData.ts tables, referencedMedia +├── resolve.ts resolveFragmentPlan (media URLs, table ids, font ids) +├── apply.ts applyResolvedFragment (writes a resolved plan into a SiteDocument) +├── adapter.ts FragmentInsertAdapter (interface only) +├── commit.ts commitFragmentPlan, FragmentCommitError +└── warnings.ts FragmentWarning codes + +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 + +src/admin/pages/site/store/slices/fragmentsSlice.ts insertFragment + dialog flag +src/admin/spotlight/commands/fragments.ts editor.insertFragment +``` + +--- + +## The format + +```ts +SiteFragment = { + format: 'instatic-fragment', version: 1, + kind: 'section' | 'page' | 'component', + origin?: { source?, item?, build? }, // free-form provenance + roots: string[], // section: 1..n siblings; page: its base.body; component: one instance + nodes: Record, + page?: { title, slug }, // kind 'page' + component?: { id, name }, // kind 'component' + styleRules: StyleRule[], // classes + ambient rules (framework utilities by name only) + conditions: ConditionDef[], + components: VisualComponent[], // closure, nested refs included + tokens: { + colors: FrameworkColorToken[], // full definitions, generation flags included + fontTokens: FontToken[], fontFamilies: FontEntry[], + typeSteps: { convention, step }[], spaceSteps: { convention, step }[], + customProperties: Record<'--name', string>, // valued from the source :root/html/body rules + }, + tables: { id, slug, name, kind: 'postType' | 'data', singularLabel, pluralLabel, + routeBase, primaryFieldId, fields, rows?: { id, slug, status, cells }[] }[], + media: { id, key?: sha256, path, filename, mimeType, sizeBytes?, width?, height?, alt?, src }[], + links: { pageId, slug }[], + outsideDependencies?: { selector, classes }[], +} +``` + +Every id is a SOURCE id. `media[].path` is the exact string the content uses (`/uploads/…`) and `media[].src` is where the bytes are (a package path, or a URL the caller resolves). A step names the framework group by its `namingConvention` (`text`, `space`), so `--text-xl` is `{ convention: 'text', step: 'xl' }`. + +`parseSiteFragment` checks the TypeBox shape, then that roots resolve, node keys match ids, children resolve, trees are acyclic, the kind payload is present (a page has one `base.body` root; a component has one instance of `component.id`), and ids are unique. + +--- + +## Extract: the closure + +`extractFragment(source, selection, options?)` with `selection` = `{ pageId, rootNodeIds? }` (no `rootNodeIds` = the whole page) or `{ componentId }`. In a bundle a page id is its `pages` row id. Returns `{ fragment, mediaBytes, warnings }`; `mediaBytes` (by media id) is what `writeFragmentPackage` zips. + +| Step | What is carried | +|---|---| +| Nodes | The selected subtrees, deep-copied. A component selection becomes one `base.visual-component-ref` with its slot instances. | +| Components | Every referenced Visual Component, transitively. Their trees feed every later step. | +| Classes | Every class a carried node or component uses. `framework:*` utilities carry only name + generated metadata; scoped module-style and regular rules carry whole. | +| Ambient rules | Every ambient rule whose selector names a carried class (`extractCssSelectorClasses`), and every `@keyframes` block a carried `animation` uses. | +| Outside dependencies | Rules that style the selection through a class only its ANCESTORS have (a page wrapper, the body's class), when the rightmost compound could match inside the selection. Reported, not carried. | +| Conditions | Non-viewport `contextStyles` keys of carried rules. | +| Tokens | Every `var(--x)` in carried rules and node content, classified by the framework's own generators (`indexSiteVariables`): colour (incl. `-50`, `-d-2`, `-l-1` variants) → the base token; type/space scale variable → the step; font token → the token and its family; other → the source root value (followed recursively). Framework utilities add their colour or step. Families named literally in `font-family` travel too. | +| Tables | Loops with `sourceId: 'data.rows'` carry `filters.tableId` with its rows; CMS forms (`mode: 'cms'`) carry `targetTableId` schema only. Dynamic bindings stay verbatim. | +| Media | Every library asset whose path occurs in carried content, every custom font file, and every media cell (incl. repeaters) of carried rows. With bytes, `key` = SHA-256 and `src` = `media/.`. | +| Links | Every `cms:page:` with the source page's slug. | + +--- + +## Plan: the site always wins + +`planFragmentInsert({ site, tables, document? }, fragment)` is pure. `document` (`{ kind: 'page', pageId }` or `{ kind: 'visualComponent', vcId }`) is required for sections and components. It returns a `FragmentInsertPlan` (everything to add, every id minted) with a `report`. + +| What | Rule | +|---|---| +| Nodes | All new ids. | +| Classes | Matched by name. The site's class is reused as is, styles and all. A missing class is added with a fresh id after every existing rule, in the fragment's relative order (`createStyleRuleOrderAllocator`). Only classes the landing nodes and added components use are added. | +| Scoped classes | Cloned with a fresh id onto the new node id. | +| 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. | +| 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. | +| Components | Matched by name (the storage slug `vcSlugFromName`); the site's component wins and instances point at it. Only components reached through added ones are added. Instances of components neither carried nor present are stripped (warning). | +| Tables | Matched by slug; the site's table wins, its rows are not touched and the fragment's rows are not added; fields it lacks (by id) are appended. A missing table is created with schema and rows. Relation fields survive only when their target table is carried; relation cells never travel. | +| Media | Only what the landing content uses (rows of reused tables excluded). | +| Links | A `cms:page:` whose slug the site has is remapped to that page (a page fragment's links to itself follow it); any other becomes `#` (reported). Links in rows of created tables are rewritten the same way. | +| Pages | Only a page fragment adds a page, with a unique slug (`uniquePageSlug`). | +| Guards | A second `base.outlet` in the document, or a component cycle in VC mode, throws `FragmentInsertError` (`outlet-conflict`, `component-cycle`), like inserting a saved layout. | + +--- + +## Commit + +`commitFragmentPlan(plan, adapter, { readMediaBytes, signal?, onProgress? })`: + +```text +A. media adapter.findMedia (filename + size + MIME, the library has no hash) or adapter.uploadMedia +B. fonts adapter.installGoogleFont / adapter.registerCustomFont (from the uploaded files) +C. tables adapter.createTable (+ createRow, published rows published), adapter.updateTableFields +D. resolveFragmentPlan media paths → URLs, fragment table ids → created ids, family ids → installed ids +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 adapter runs every server write through `runStepUp` (creating a table needs a recent password). + +--- + +## Writing a fragment from a script + +```ts +import { + extractFragment, + fragmentSourceFromBundleArchive, + writeFragmentPackage, +} from '/src/core/fragments' + +const source = fragmentSourceFromBundleArchive(new Uint8Array(await Bun.file('site.zip').arrayBuffer())) +const { fragment, mediaBytes, warnings } = await extractFragment(source, { pageId, rootNodeIds: [sectionId] }) +await Bun.write('section.zip', writeFragmentPackage(fragment, mediaBytes)) +``` + +Bun resolves the `@core/*` aliases from the repo's `tsconfig.json`. `planFragmentInsert` runs the same way against any `SiteDocument`. + +--- + +## Forbidden patterns + +| Pattern | Use instead | +|---|---| +| Overwriting a site class, component, token or table from a fragment | The site always wins; report what was kept | +| Inlining media bytes in `fragment.json` | `media[].src` + the package zip | +| Classifying `var(--x)` with a hand-written regex | `indexSiteVariables` (the framework's generators) | +| Writing inserted custom properties into a user's `:root` rule | The engine-owned rule (`FRAGMENT_PROPERTIES_ORIGIN`) | +| Store writes outside `adapter.commit` | One transaction, one undo step | +| React, admin or server imports anywhere in `src/core/fragments/` (even transitively) | Keep it headless; `fragments-headless.test.ts` | + +--- + +## Related + +- [editor.md](../editor.md) — Saved layouts (same-site snapshots, class match by id) and Spotlight +- [site-import.md](site-import.md) — whole-site imports; shares `createStyleRuleOrderAllocator`, `isRootScopeSelector` and `addMissingConditions` (`@core/page-tree`) +- [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` +- Gate: `src/__tests__/architecture/fragments-headless.test.ts` diff --git a/docs/features/html-import.md b/docs/features/html-import.md index 7cb7bf493..b29f1585a 100644 --- a/docs/features/html-import.md +++ b/docs/features/html-import.md @@ -239,6 +239,7 @@ The importer preserves CSS across two layers, both gated by `isEmittableProperty - [docs/features/modules.md](modules.md) — module definitions, `base.text`, `base.button`, `base.image`, `base.container` - [docs/features/agent.md](agent.md) — AI agent feature; `insertHtml` and `replaceNodeHtml` run through this pipeline +- [docs/features/fragments.md](fragments.md) — inserting a section from another Instatic site with its rules, tokens and media (no HTML round-trip) - [docs/reference/page-tree.md](../reference/page-tree.md) — `NodeTree`, `createNode`, `ImportFragment` shape - Source-of-truth files: - `src/core/htmlImport/index.ts` — public barrel + API documentation diff --git a/docs/features/site-import.md b/docs/features/site-import.md index de6093e12..a22656229 100644 --- a/docs/features/site-import.md +++ b/docs/features/site-import.md @@ -429,6 +429,7 @@ outside the archive are normal and would bury the real misses. - [docs/features/html-import.md](html-import.md) — `@core/htmlImport` is used by `htmlPagePlan.ts` to parse each HTML file's body into a `PageNode` fragment - [docs/features/site-transfer.md](site-transfer.md) — CMS bundle export/import archive format and server endpoints used by the CMS branch of this modal +- [docs/features/fragments.md](fragments.md) — inserting one section, page or component from another site; shares the cascade-order allocator (`createStyleRuleOrderAllocator`, `@core/page-tree`) - [docs/reference/page-tree.md](../reference/page-tree.md) — `NodeTree`, `ImportFragment` shape - [docs/reference/typebox-patterns.md](../reference/typebox-patterns.md) — boundary validation - Source-of-truth files: diff --git a/docs/features/site-transfer.md b/docs/features/site-transfer.md index ea6a6f9b7..054cb31b1 100644 --- a/docs/features/site-transfer.md +++ b/docs/features/site-transfer.md @@ -380,6 +380,7 @@ A nightly cron can hit `/admin/api/cms/export` with an admin session cookie and ## Related - [docs/architecture.md](../architecture.md) — system overview +- [docs/features/fragments.md](fragments.md) — `fragmentSourceFromBundleArchive` reads the export archive to extract fragments - [docs/server.md](../server.md) — handler patterns + transactions - [docs/features/content-storage.md](content-storage.md) — `data_tables` + `data_rows` schemas - [docs/features/media.md](media.md) — imported media falls back to originals when variants are absent diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index c05a9540a..86af3c078 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -154,7 +154,7 @@ The subscription is **dropped on close** to avoid spurious re-renders. | Group | Examples | |--------------------|----------------------------------------------------------------------| -| `editor` | Publish, Save, Undo, Redo, Wrap in container, Toggle preview | +| `editor` | Publish, Save, Undo, Redo, Wrap in container, Toggle preview, Import HTML, Insert fragment from file… (`commands/fragments.ts`, see [fragments.md](fragments.md)) | | `pages` | Add page, Open page settings | | `content` | New post, Edit post | | `navigation` | Go to dashboard, Go to site, Go to media, Go to plugins, … | @@ -472,6 +472,7 @@ run: async (ctx) => { - [docs/architecture.md](../architecture.md) — admin shell mount points - [docs/editor.md](../editor.md) — `SpotlightRoot` placement +- [docs/features/fragments.md](fragments.md) — the Insert fragment command's dialog and engine - [docs/features/plugin-system.md](plugin-system.md) — plugin commands + providers - [docs/reference/design-tokens.md](../reference/design-tokens.md) — global UI token reference - Source-of-truth files: diff --git a/docs/reference/architecture-tests.md b/docs/reference/architecture-tests.md index cb3e752fd..f982429eb 100644 --- a/docs/reference/architecture-tests.md +++ b/docs/reference/architecture-tests.md @@ -6,7 +6,7 @@ Catalog of every test in `src/__tests__/architecture/`. These are structural gat ## TL;DR -- 95 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, content storage, boundary validation, module size, AI, auth, error handling, etc. +- 104 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, content storage, boundary validation, module size, AI, auth, error handling, etc. - Naming convention: `.test.ts` (kebab-case) or `-.test.ts`. A few legacy `task-*` ids remain for live invariants; new gates should use topic names. - Run them all: `bun test src/__tests__/architecture/`. - Most are **import / source scans** — they parse the files in scope and assert / reject patterns. Some are unit-style (a small in-test database, a synthesized page tree). @@ -222,6 +222,14 @@ See [docs/features/publisher.md](../features/publisher.md). See [docs/features/site-import.md](../features/site-import.md). +### Fragments + +| Test | What it enforces | +|-----------------------------------------------|----------------------------------------------------------------------------------| +| `fragments-headless.test.ts` | The runtime import graph reachable from `src/core/fragments/index.ts` (following `@core/*` and relative imports, transitively) contains no `.tsx`, no admin/UI/module/server code, and no npm package besides TypeBox, nanoid and fflate — so build scripts can import the engine by path under Bun. | + +See [docs/features/fragments.md](../features/fragments.md). + ### Site transfer (export / import) | Test | What it enforces | diff --git a/docs/reference/page-tree.md b/docs/reference/page-tree.md index c639acf24..74f320a4f 100644 --- a/docs/reference/page-tree.md +++ b/docs/reference/page-tree.md @@ -178,6 +178,13 @@ All mutations live in `src/core/page-tree/mutations.ts`. They take a `NodeTree

): StyleRule { + return { id, name, kind: 'class', selector: `.${name}`, order: 0, styles, contextStyles: {}, createdAt: 1, updatedAt: 1 } +} + +async function packageFile(): 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-text'] }), + 'src-text': makeNode({ id: 'src-text', moduleId: 'base.text', props: { text: 'From the library' } }), + }, + })], + styleRules: { 'src-banner-class': rule('src-banner-class', 'banner', { padding: '2rem' }) }, + }) + const { fragment, mediaBytes } = await extractFragment(fragmentSourceFromSite(source), { pageId: 'src-page', rootNodeIds: ['src-banner'] }) + const bytes = writeFragmentPackage({ ...fragment, origin: { item: 'banner-1' } }, mediaBytes) + return new File([bytes.slice().buffer as ArrayBuffer], 'banner.zip', { type: 'application/zip' }) +} + +beforeEach(() => { + localStorage.clear() + globalThis.fetch = (async () => new Response(JSON.stringify({ tables: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch + useEditorStore.setState({ + site: makeSite({ + pages: [makePage({ + id: 'page-1', + rootNodeId: 'root-1', + nodes: { 'root-1': makeNode({ id: 'root-1', moduleId: 'base.body', children: [] }) }, + })], + }), + activePageId: 'page-1', + activeDocument: { kind: 'page', pageId: 'page-1' }, + insertFragmentModalOpen: true, + selectedNodeId: null, + selectedNodeIds: [], + hoveredNodeId: null, + canUndo: false, + canRedo: false, + hasUnsavedChanges: false, + } as Parameters[0]) +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +describe('Insert fragment', () => { + it('Spotlight offers the command in the site editor and it opens the dialog', async () => { + useEditorStore.setState({ insertFragmentModalOpen: false }) + const command = getAllCommands().find((c) => c.id === 'editor.insertFragment') + expect(command?.workspaces).toEqual(['site']) + await command!.run({ closeSpotlight: () => {} } as unknown as Parameters['run']>[0]) + expect(useEditorStore.getState().insertFragmentModalOpen).toBe(true) + }) + + it('reviews the plan, inserts on confirm, and reports what was added', async () => { + render() + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [await packageFile()] } }) + + const willAdd = await screen.findByRole('region', { name: 'Will add' }) + expect(within(willAdd).getByText('banner')).toBeTruthy() + expect(screen.getByText('banner-1', { exact: false })).toBeTruthy() + // Nothing changes before Insert. + expect(useEditorStore.getState().site!.pages[0].nodes['root-1'].children).toEqual([]) + + fireEvent.click(screen.getByTestId('insert-fragment-confirm')) + await screen.findByRole('region', { name: 'Added' }) + + const page = useEditorStore.getState().site!.pages[0] + const [bannerId] = page.nodes['root-1'].children + expect(bannerId).toBeDefined() + const banner = page.nodes[bannerId] + expect(useEditorStore.getState().site!.styleRules[banner.classIds[0]].name).toBe('banner') + expect(useEditorStore.getState().selectedNodeId).toBe(bannerId) + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy()) + }) +}) diff --git a/src/__tests__/architecture/fragments-headless.test.ts b/src/__tests__/architecture/fragments-headless.test.ts new file mode 100644 index 000000000..56e6aa664 --- /dev/null +++ b/src/__tests__/architecture/fragments-headless.test.ts @@ -0,0 +1,95 @@ +/** + * Architecture Gate — `src/core/fragments/` is importable outside the app. + * + * Build scripts import the fragment engine by path under Bun: + * + * import { extractFragment } from '/src/core/fragments' + * + * so the WHOLE runtime import graph reachable from the barrel must stay + * headless: no React, no admin or editor code, no UI or module code, no + * server code, no DOM-only libraries, and only the small set of npm packages + * that run anywhere (TypeBox, nanoid, fflate). A direct-import check is not + * enough — one `@core/*` barrel that pulled in React would break the script — + * so this walks the graph transitively, following `@core/*` aliases and + * relative paths. Type-only imports are erased at runtime and are skipped. + */ + +import { describe, expect, it } from 'bun:test' +import { existsSync, readFileSync, statSync } from 'fs' +import { dirname, join, relative, resolve } from 'path' + +const SRC = resolve(import.meta.dir, '../..') +const ENTRY = join(SRC, 'core/fragments/index.ts') + +const ALLOWED_PACKAGES = [/^@sinclair\/typebox(\/.*)?$/, /^nanoid$/, /^fflate$/] + +const RUNTIME_SPECIFIER_RE = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)(?:[^'"`;]*?\sfrom\s+)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g + +function resolveFile(base: string): string | null { + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate + } + return null +} + +interface Graph { + files: string[] + packages: Map + violations: string[] +} + +function walk(): Graph { + const seen = new Set() + const packages = new Map() + const violations: string[] = [] + const queue = [ENTRY] + while (queue.length > 0) { + const file = queue.pop()! + if (seen.has(file)) continue + seen.add(file) + const rel = relative(SRC, file) + if (file.endsWith('.tsx')) violations.push(`${rel}: .tsx file in the fragment import graph`) + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(RUNTIME_SPECIFIER_RE)) { + const specifier = match[1] ?? match[2] + if (!specifier) continue + if (specifier.startsWith('@core/')) { + const target = resolveFile(join(SRC, 'core', specifier.slice('@core/'.length))) + if (!target) violations.push(`${rel}: cannot resolve ${specifier}`) + else queue.push(target) + } else if (specifier.startsWith('.')) { + const target = resolveFile(resolve(dirname(file), specifier)) + if (!target) violations.push(`${rel}: cannot resolve ${specifier}`) + else if (!target.startsWith(join(SRC, 'core'))) violations.push(`${rel}: leaves src/core via ${specifier}`) + else queue.push(target) + } else if (specifier.startsWith('@')) { + if (!ALLOWED_PACKAGES.some((re) => re.test(specifier))) { + violations.push(`${rel}: imports ${specifier}`) + } + packages.set(specifier, rel) + } else { + packages.set(specifier, rel) + if (!ALLOWED_PACKAGES.some((re) => re.test(specifier))) { + violations.push(`${rel}: imports package ${specifier}`) + } + } + } + } + return { files: [...seen], packages, violations } +} + +describe('fragments headless gate', () => { + it('the runtime import graph from src/core/fragments stays headless', () => { + const graph = walk() + expect(graph.files.length).toBeGreaterThan(5) + if (graph.violations.length > 0) { + throw new Error( + '[fragments headless] src/core/fragments must import only headless core modules\n' + + 'and TypeBox / nanoid / fflate, transitively. Violations:\n' + + graph.violations.map((v) => ` ${v}`).join('\n'), + ) + } + expect(graph.violations).toEqual([]) + }) +}) diff --git a/src/__tests__/editor-store/insertFragment.test.ts b/src/__tests__/editor-store/insertFragment.test.ts new file mode 100644 index 000000000..907d4fc0f --- /dev/null +++ b/src/__tests__/editor-store/insertFragment.test.ts @@ -0,0 +1,117 @@ +/** + * insertFragment — a resolved fragment plan lands in the editor store as ONE + * undo step, at the location the current selection resolves to. + */ + +import { beforeEach, describe, expect, it } from 'bun:test' +import { useEditorStore } from '@site/store/store' +import { fragmentInsertDocument } from '@site/store/slices/fragmentsSlice' +import { planFragmentInsert, resolveFragmentPlan, type SiteFragment } from '@core/fragments' +import { extractFromSource } from '../fragments/fixtures' +import '@modules/base/index' + +function freshStore(): void { + useEditorStore.setState({ + site: null, + activePageId: null, + activeDocument: null, + selectedNodeId: null, + selectedNodeIds: [], + hoveredNodeId: null, + _historyPast: [], + _historyFuture: [], + canUndo: false, + canRedo: false, + hasUnsavedChanges: false, + } as Parameters[0]) +} + +beforeEach(freshStore) + +const { fragment: section } = await extractFromSource() + +function resolvedPlanForStore(fragment: SiteFragment) { + const state = useEditorStore.getState() + const plan = planFragmentInsert( + { site: state.site!, tables: [], document: fragmentInsertDocument(state) ?? undefined }, + fragment, + ) + return resolveFragmentPlan(plan, { + mediaUrlByPath: new Map(plan.media.map((m) => [m.path, `/uploads/copied-${m.filename}`])), + tableIdMap: new Map(plan.tables.map((t) => [t.fragmentTableId, `server-${t.slug}`])), + fontEntryByFamilyId: new Map(), + }) +} + +describe('insertFragment', () => { + it('inserts the section, its rules, tokens and components, and selects it', () => { + const site = useEditorStore.getState().createSite('Target') + const rootId = site.pages[0].rootNodeId + const resolved = resolvedPlanForStore(section) + + const result = useEditorStore.getState().insertFragment(resolved) + expect(result?.rootIds).toHaveLength(1) + const after = useEditorStore.getState() + const page = after.site!.pages[0] + expect(page.nodes[rootId].children).toContain(result!.rootIds[0]) + expect(after.selectedNodeId).toBe(result!.rootIds[0]) + expect(after.site!.visualComponents.map((vc) => vc.name).sort()).toEqual(['Badge', 'Card']) + expect(after.site!.settings.framework!.colors.tokens.map((t) => t.slug).sort()).toEqual(['accent', 'primary']) + // The colour utilities were regenerated in the same transaction. + expect(Object.values(after.site!.styleRules).some((r) => r.name === 'text-primary')).toBe(true) + const image = Object.values(page.nodes).find((n) => n.moduleId === 'base.image')! + expect(image.props.src).toBe('/uploads/copied-photo.jpg') + }) + + it('a second insert lands after the still-selected first section, not inside it', () => { + const site = useEditorStore.getState().createSite('Target') + const rootId = site.pages[0].rootNodeId + + const first = useEditorStore.getState().insertFragment(resolvedPlanForStore(section)) + expect(useEditorStore.getState().selectedNodeId).toBe(first!.rootIds[0]) + const second = useEditorStore.getState().insertFragment(resolvedPlanForStore(section)) + + const nodes = useEditorStore.getState().site!.pages[0].nodes + const rootChildren = nodes[rootId].children + expect(rootChildren.indexOf(second!.rootIds[0])).toBe(rootChildren.indexOf(first!.rootIds[0]) + 1) + expect(nodes[first!.rootIds[0]].children).not.toContain(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) ?? [] + expect(before).toContain('font-heading') + useEditorStore.getState().insertFragment(resolvedPlanForStore(section)) + const after = useEditorStore.getState().site!.settings.fonts?.tokens?.map((t) => t.variable) ?? [] + expect(after).toEqual(before) + }) + + it('one undo removes the whole insert', () => { + const site = useEditorStore.getState().createSite('Target') + const before = useEditorStore.getState().site! + const resolved = resolvedPlanForStore(section) + useEditorStore.getState().insertFragment(resolved) + + useEditorStore.getState().undo() + const undone = useEditorStore.getState().site! + expect(undone.pages[0].nodes).toEqual(before.pages[0].nodes) + expect(Object.keys(undone.styleRules).sort()).toEqual(Object.keys(before.styleRules).sort()) + expect(undone.visualComponents).toEqual([]) + expect(undone.settings.framework).toEqual(before.settings.framework) + expect(undone.pages[0].nodes[site.pages[0].rootNodeId].children).toEqual([]) + }) + + it('refuses a plan made for another document', () => { + useEditorStore.getState().createSite('Target') + const resolved = resolvedPlanForStore(section) + expect(useEditorStore.getState().insertFragment({ ...resolved, document: { kind: 'page', pageId: 'elsewhere' } })).toBeNull() + }) + + it('a page fragment adds a page', async () => { + useEditorStore.getState().createSite('Target') + const { fragment } = await extractFromSource({ pageId: 'page-about' }) + const result = useEditorStore.getState().insertFragment(resolvedPlanForStore(fragment)) + const page = useEditorStore.getState().site!.pages.find((p) => p.id === result?.pageId) + expect(page?.slug).toBe('about') + }) +}) diff --git a/src/__tests__/fragments/commit.test.ts b/src/__tests__/fragments/commit.test.ts new file mode 100644 index 000000000..2e2ae2720 --- /dev/null +++ b/src/__tests__/fragments/commit.test.ts @@ -0,0 +1,231 @@ +/** + * extract → plan → commit, end to end, with an in-memory adapter that keeps a + * media library and data tables and applies the store transaction to a plain + * site document (plus the editor's framework reconcile). + */ + +import { describe, expect, it } from 'bun:test' +import { + FragmentCommitError, + applyResolvedFragment, + commitFragmentPlan, + planFragmentInsert, + type FragmentInsertAdapter, + type FragmentMedia, + type SiteFragment, +} from '@core/fragments' +import type { DataField, DataTable } from '@core/data/schemas' +import type { FontEntry } from '@core/fonts' +import type { PageNode, SiteDocument } from '@core/page-tree' +import { reconcileFrameworkClasses } from '@site/store/slices/site/framework/reconcile' +import { makePage, makeSite } from '../fixtures' +import { TARGET_TABLES, extractFromSource, makeTargetSite, mediaBytesFor } from './fixtures' + +interface MemoryServer { + library: { id: string; url: string; filename: string; mimeType: string; sizeBytes: number }[] + tables: DataTable[] + rows: { tableId: string; status: string; cells: Record }[] + fonts: FontEntry[] +} + +function memoryAdapter(site: SiteDocument, tables: DataTable[], placement: { pageId: string; parentId: string }, fail?: 'upload') { + const server: MemoryServer = { library: [], tables: structuredClone(tables), rows: [], fonts: [] } + let seq = 0 + const adapter: FragmentInsertAdapter = { + async findMedia(media) { + const hit = server.library.find((a) => a.filename === media.filename && a.mimeType === media.mimeType && a.sizeBytes === media.sizeBytes) + return hit ? { id: hit.id, url: hit.url } : null + }, + async uploadMedia(file) { + if (fail === 'upload') throw new Error('disk full') + seq += 1 + const asset = { id: `m${seq}`, url: `/uploads/new-${seq}-${file.filename}`, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.bytes.byteLength } + server.library.push(asset) + return { id: asset.id, url: asset.url } + }, + async installGoogleFont(request) { + return { id: `g-${request.family}`, source: 'google', family: request.family, variants: request.variants, subsets: request.subsets, files: [], createdAt: 1, updatedAt: 1 } + }, + async registerCustomFont(request) { + const entry: FontEntry = { + id: `font-${++seq}`, + source: 'custom', + family: request.family, + variants: request.files.map((f) => f.variant), + subsets: ['latin'], + files: request.files.map((f) => ({ + variant: f.variant, + subset: 'latin', + path: server.library.find((a) => a.id === f.mediaAssetId)!.url, + format: 'woff2' as const, + mediaAssetId: f.mediaAssetId, + })), + createdAt: 1, + updatedAt: 1, + } + server.fonts.push(entry) + return entry + }, + async createTable(input) { + const table: DataTable = { ...input, id: `tbl-new-${++seq}`, system: false, createdByUserId: null, updatedByUserId: null, createdAt: '', updatedAt: '' } + server.tables.push(table) + return { id: table.id } + }, + async updateTableFields(tableId: string, fields: DataField[]) { + server.tables.find((t) => t.id === tableId)!.fields = fields + }, + async createRow(tableId, row) { + server.rows.push({ tableId, status: row.status, cells: row.cells }) + return { id: `row-new-${++seq}` } + }, + async commit(plan) { + const page = site.pages.find((p) => p.id === placement.pageId)! + const applied = applyResolvedFragment(site, plan, plan.page ? null : { tree: page, parentId: placement.parentId }) + if (applied.frameworkChanged) reconcileFrameworkClasses(site) + return { rootIds: applied.rootIds, ...(applied.pageId ? { pageId: applied.pageId } : {}) } + }, + } + return { adapter, server } +} + +const readBytes = (fragment: SiteFragment) => (media: FragmentMedia) => { + const source = fragment.media.find((m) => m.id === media.id) + return source ? mediaBytesFor(source.id) : null +} + +/** Plan `fragment` into the populated target's home page, with a memory adapter. */ +function intoTarget(fragment: SiteFragment, fail?: 'upload') { + const site = makeTargetSite() + const plan = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, fragment) + return { site, plan, ...memoryAdapter(site, TARGET_TABLES, { pageId: 't-home', parentId: 't-body' }, fail) } +} + +describe('commitFragmentPlan — into a populated site', () => { + it('lands the section with styles, tokens, media, tables and links resolved', async () => { + const { fragment } = await extractFromSource() + const { site, plan, adapter, server } = intoTarget(fragment) + + const result = await commitFragmentPlan(plan, adapter, { readMediaBytes: readBytes(fragment) }) + const page = site.pages.find((p) => p.id === 't-home')! + + // Placed after the existing section, parent pointers consistent. + expect(page.nodes['t-body'].children).toEqual(['t-section', result.rootIds[0]]) + const hero = page.nodes[result.rootIds[0]] + expect(hero.parentId).toBe('t-body') + + // Every class id a placed node carries exists in the site after commit, + // framework utilities included (the xl step and the accent colour were added). + for (const id of Object.keys(plan.nodes)) { + for (const classId of page.nodes[id].classIds) expect(site.styleRules[classId]).toBeDefined() + } + const title = Object.values(page.nodes).find((n) => n.props.text === 'Hello')! + const titleClasses = title.classIds.map((id) => site.styleRules[id].name).sort() + expect(titleClasses).toEqual(['hero-title', 'text-primary', 'text-xl']) + expect(site.settings.framework!.typography!.groups[0].steps).toBe('xs,s,m,l,xl') + expect(site.settings.framework!.colors.tokens.map((t) => t.slug)).toEqual(['primary', 'accent']) + expect(Object.values(site.styleRules).some((r) => r.name === 'bg-accent')).toBe(true) + + // Media uploaded once each and every reference rewritten. + expect(result.report.media).toEqual({ total: 2, uploaded: 2, reused: 0 }) + const photo = Object.values(page.nodes).find((n) => n.moduleId === 'base.image')! + expect(photo.props.src).toMatch(/^\/uploads\/new-\d+-photo\.jpg$/) + // Fonts: Inter registered from the uploaded file and bound to the new token. + const inter = site.settings.fonts!.items.find((f) => f.family === 'Inter')! + expect(inter.files[0].path).toMatch(/^\/uploads\/new-\d+-inter\.woff2$/) + expect(site.settings.fonts!.tokens!.find((t) => t.variable === 'font-heading')!.familyId).toBe(inter.id) + + // Tables: the created contact table is wired into the form; the reused + // projects table gained the cover field. + const form = Object.values(page.nodes).find((n) => n.moduleId === 'base.form')! + const contact = server.tables.find((t) => t.slug === 'contact')! + expect(form.props.targetTableId).toBe(contact.id) + expect(server.tables.find((t) => t.slug === 'projects')!.fields.map((f) => f.id)).toEqual(['title', 'cover']) + expect(server.rows).toEqual([]) // forms carry no rows; the reused table keeps its own + + // Link remapped to the site's About page; Card instance points at the site's Card. + expect(Object.values(page.nodes).find((n) => n.moduleId === 'base.link')!.props.href).toBe('cms:page:t-about#team') + expect(Object.values(page.nodes).find((n) => n.moduleId === 'base.visual-component-ref')!.props.componentId).toBe('t-vc-card') + + // The engine-owned :root rule holds the custom property the site lacked. + const owned = Object.values(site.styleRules).find((r) => r.description === 'Custom properties added by inserted fragments')! + expect(owned.styles).toEqual({ '--shadow': '0 0 4px var(--primary-50)' }) + // The user's own :root rule is untouched. + expect(site.styleRules['t-root'].styles).toEqual({ '--radius': '2px' }) + }) + + it('a second insert of the same fragment reuses the uploaded media and adds no rules', async () => { + const { fragment } = await extractFromSource() + const { site, plan: first, adapter } = intoTarget(fragment) + await commitFragmentPlan(first, adapter, { readMediaBytes: readBytes(fragment) }) + const rulesAfterFirst = Object.keys(site.styleRules).length + + const second = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, fragment) + expect(second.styleRules.filter((r) => !r.scope)).toEqual([]) + expect(second.customProperties).toBeNull() + const result = await commitFragmentPlan(second, adapter, { readMediaBytes: readBytes(fragment) }) + // Inter is in the library now, so its file is not needed again; the photo + // is found in the media library by name, size and type. + expect(result.report.media).toEqual({ total: 1, uploaded: 0, reused: 1 }) + // Only the per-node scoped rule is new: it belongs to the new node. + expect(Object.keys(site.styleRules).length).toBe(rulesAfterFirst + 1) + }) + + it('stops before touching the document when an upload fails', async () => { + const { fragment } = await extractFromSource() + const { site, plan, adapter } = intoTarget(fragment, 'upload') + const before = structuredClone(site) + await expect(commitFragmentPlan(plan, adapter, { readMediaBytes: readBytes(fragment) })).rejects.toBeInstanceOf(FragmentCommitError) + expect(site).toEqual(before) + }) + + it('honours cancellation before the transaction', async () => { + const { fragment } = await extractFromSource() + const { site, plan, adapter } = intoTarget(fragment) + const before = structuredClone(site) + const controller = new AbortController() + controller.abort() + await expect(commitFragmentPlan(plan, adapter, { readMediaBytes: readBytes(fragment), signal: controller.signal })).rejects.toThrow() + expect(site).toEqual(before) + }) +}) + +describe('commitFragmentPlan — into an empty site', () => { + it('creates everything, rows and media cells included', async () => { + const { fragment } = await extractFromSource() + const site = makeSite({ pages: [makePage({ id: 'e-home', rootNodeId: 'e-body' })] }) + const plan = planFragmentInsert({ site, tables: [], document: { kind: 'page', pageId: 'e-home' } }, fragment) + const { adapter, server } = memoryAdapter(site, [], { pageId: 'e-home', parentId: 'e-body' }) + const result = await commitFragmentPlan(plan, adapter, { readMediaBytes: readBytes(fragment) }) + + expect(site.visualComponents.map((vc) => vc.name).sort()).toEqual(['Badge', 'Card']) + const projects = server.tables.find((t) => t.slug === 'projects')! + expect(projects.fields.map((f) => f.id)).toEqual(['title', 'cover']) + const projectRows = server.rows.filter((r) => r.tableId === projects.id) + expect(projectRows.map((r) => r.status)).toEqual(['published', 'draft']) + const coverId = projectRows[0].cells.cover as string + expect(server.library.find((a) => a.id === coverId)?.filename).toBe('cover.jpg') + // The draft row's rich text links to a page this site does not have. + expect(projectRows[1].cells.title).toBe('Mill more') + + const page = site.pages[0] + const loop = Object.values(page.nodes).find((n) => n.moduleId === 'base.loop') as PageNode + expect((loop.props.filters as Record).tableId).toBe(projects.id) + expect(result.report.media.uploaded).toBe(4) + // Every class the inserted nodes and components use resolves. + for (const node of [...Object.values(page.nodes), ...site.visualComponents.flatMap((vc) => Object.values(vc.tree.nodes))]) { + for (const classId of node.classIds) expect(site.styleRules[classId]).toBeDefined() + } + }) + + it('a page fragment becomes a new page', async () => { + const { fragment } = await extractFromSource({ pageId: 'page-home' }) + const site = makeTargetSite() + const plan = planFragmentInsert({ site, tables: TARGET_TABLES }, fragment) + const { adapter } = memoryAdapter(site, TARGET_TABLES, { pageId: 't-home', parentId: 't-body' }) + const result = await commitFragmentPlan(plan, adapter, { readMediaBytes: readBytes(fragment) }) + const page = site.pages.find((p) => p.id === result.pageId)! + expect(page.slug).toBe('index-2') + expect(page.nodes[page.rootNodeId].moduleId).toBe('base.body') + expect(Object.keys(page.nodes)).toHaveLength(Object.keys(fragment.nodes).length) + }) +}) diff --git a/src/__tests__/fragments/extract.test.ts b/src/__tests__/fragments/extract.test.ts new file mode 100644 index 000000000..ff1aa8a44 --- /dev/null +++ b/src/__tests__/fragments/extract.test.ts @@ -0,0 +1,211 @@ +/** + * extractFragment — the closure a section, page or component carries. + */ + +import { describe, expect, it } from 'bun:test' +import { + FragmentExtractError, + extractFragment, + fragmentSourceFromBundle, + readFragmentPackage, + sha256Hex, + writeFragmentPackage, +} from '@core/fragments' +import { pageToCells } from '@core/data/pageFromRow' +import { visualComponentToCells } from '@core/data/componentFromRow' +import type { DataRow } from '@core/data/schemas' +import type { SiteBundle } from '@core/data/bundleSchema' +import { + SOURCE_FRAMEWORK, + SOURCE_ROWS, + SOURCE_TABLES, + extractFromSource, + frameworkClassId, + makeSourceSite, + mediaBytesFor, + sourceMediaWithBytes, +} from './fixtures' + +// Extraction does not mutate its result, so the section tests share one. +const section = await extractFromSource() + +describe('extractFragment — section closure', () => { + it('carries the subtree, its classes and the ambient rules built on them', async () => { + const { fragment } = section + + expect(fragment.kind).toBe('section') + expect(fragment.roots).toEqual(['hero']) + expect(Object.keys(fragment.nodes).sort()).toEqual( + ['card-ref', 'form', 'hero', 'link', 'loop', 'loop-item', 'photo', 'title'].sort(), + ) + expect(fragment.nodes.hero.parentId).toBeNull() + // The unselected sibling and the ancestor wrapper stay behind. + expect(fragment.nodes.other).toBeUndefined() + + const ruleIds = fragment.styleRules.map((r) => r.id) + expect(ruleIds).toContain('cls-hero') + expect(ruleIds).toContain('cls-hero-title') + expect(ruleIds).toContain('cls-hero-hover') // ambient on carried classes + expect(ruleIds).toContain('cls-scoped') // scoped module-style rule + expect(ruleIds).toContain('cls-keyframes') // the animation the hero runs + expect(ruleIds).toContain('cls-card') // from the Visual Component closure + expect(ruleIds).toContain('cls-badge') // nested component + expect(ruleIds).toContain('cls-wrap-h') // names a carried class + expect(ruleIds).not.toContain('cls-page-wrap') + expect(ruleIds).not.toContain('cls-unrelated') + expect(ruleIds).not.toContain('cls-wrap-other') + }) + + it('carries framework utilities by name only', async () => { + const { fragment } = section + const textXl = fragment.styleRules.find((r) => r.id === frameworkClassId(SOURCE_FRAMEWORK, 'text-xl')) + expect(textXl?.name).toBe('text-xl') + expect(textXl?.styles).toEqual({}) + expect(textXl?.generated?.family).toBe('typography') + }) + + it('reports rules that depend on an ancestor-only class', async () => { + const { fragment } = section + expect(fragment.outsideDependencies).toEqual([ + { selector: '.page-wrap .hero-title', classes: ['page-wrap'] }, + ]) + }) + + it('collects conditions, components, tokens, tables, media and links', async () => { + const { fragment, warnings } = section + + expect(fragment.conditions.map((c) => c.id)).toEqual(['media:(hover: hover)']) + expect(fragment.components.map((c) => c.id).sort()).toEqual(['vc-badge', 'vc-card']) + + // Colours: primary via a shade variant and a framework utility, accent via + // a hover rule and the Card; the unused colour stays home. Full + // definitions travel, generation flags included. + const colors = fragment.tokens.colors + expect(colors.map((c) => c.slug).sort()).toEqual(['accent', 'primary']) + expect(colors.find((c) => c.slug === 'primary')?.generateShades).toEqual({ enabled: true, count: 3 }) + + expect(fragment.tokens.typeSteps).toEqual([{ convention: 'text', step: 'xl' }]) + expect(fragment.tokens.spaceSteps.map((s) => s.step).sort()).toEqual(['l', 's']) + expect(fragment.tokens.fontTokens.map((t) => t.variable)).toEqual(['font-heading']) + expect(fragment.tokens.fontFamilies.map((f) => f.family)).toEqual(['Inter']) + // --shadow is a root property that itself reads --primary-50. + expect(fragment.tokens.customProperties).toEqual({ '--radius': '12px', '--shadow': '0 0 4px var(--primary-50)' }) + + const projects = fragment.tables.find((t) => t.slug === 'projects') + const contact = fragment.tables.find((t) => t.slug === 'contact') + expect(projects?.rows?.map((r) => r.id)).toEqual(['row-1', 'row-2']) + expect(projects?.rows?.[1].status).toBe('draft') + expect(contact?.rows).toBeUndefined() // a form carries the schema only + + expect(fragment.media.map((m) => m.id).sort()).toEqual(['media-bg', 'media-cover', 'media-font', 'media-photo']) + const photo = fragment.media.find((m) => m.id === 'media-photo')! + expect(photo.key).toBe(await sha256Hex(mediaBytesFor('media-photo'))) + expect(photo.src).toBe(`media/${photo.key}.jpg`) + expect(photo.path).toBe('/uploads/photo.jpg') + expect(photo.alt).toBe('A photo') + + expect(fragment.links).toEqual([{ pageId: 'page-about', slug: 'about' }]) + expect(warnings).toEqual([]) + }) + + it('keeps dynamic bindings verbatim', async () => { + const { fragment } = section + expect(fragment.nodes['loop-item'].props.text).toBe('{currentEntry.title}') + expect(fragment.nodes['loop-item'].dynamicBindings).toEqual({ text: { source: 'currentEntry', field: 'title' } }) + }) + + it('throws a typed error for a selection that does not resolve', async () => { + await expect(extractFromSource({ pageId: 'nope' })).rejects.toBeInstanceOf(FragmentExtractError) + await expect(extractFromSource({ pageId: 'page-home', rootNodeIds: ['ghost'] })).rejects.toBeInstanceOf(FragmentExtractError) + }) +}) + +describe('extractFragment — page and component', () => { + it('a whole page carries its body and title/slug', async () => { + const { fragment } = await extractFromSource({ pageId: 'page-home' }) + expect(fragment.kind).toBe('page') + expect(fragment.roots).toEqual(['body']) + expect(fragment.page).toEqual({ title: 'Home', slug: 'index' }) + // The wrapper is inside the page now, so it is carried, not an outside dependency. + expect(fragment.styleRules.map((r) => r.id)).toContain('cls-page-wrap') + expect(fragment.outsideDependencies).toBeUndefined() + }) + + it('a component becomes one instance of it plus the component closure', async () => { + const { fragment } = await extractFromSource({ componentId: 'vc-card' }) + expect(fragment.kind).toBe('component') + expect(fragment.component).toEqual({ id: 'vc-card', name: 'Card' }) + const root = fragment.nodes[fragment.roots[0]] + expect(root.moduleId).toBe('base.visual-component-ref') + expect(root.props.componentId).toBe('vc-card') + expect(fragment.components.map((c) => c.id).sort()).toEqual(['vc-badge', 'vc-card']) + expect(fragment.tokens.colors.map((c) => c.slug).sort()).toEqual(['accent', 'primary']) + }) +}) + +describe('extractFragment — bundle source', () => { + function bundle(): SiteBundle { + const site = makeSourceSite() + const { pages, visualComponents, layouts: _layouts, ...shell } = site + const asRow = (id: string, tableId: string, slug: string, cells: Record): DataRow => ({ + ...SOURCE_ROWS[0], + id, + tableId, + slug, + cells, + }) + return { + schemaVersion: 1, + exportedAt: '2026-10-13T00:00:00.000Z', + site: shell, + tables: [ + { ...SOURCE_TABLES[0], id: 'pages', slug: 'pages', name: 'Pages', kind: 'page', system: true }, + { ...SOURCE_TABLES[0], id: 'components', slug: 'components', name: 'Components', kind: 'component', system: true }, + ...SOURCE_TABLES, + ], + rows: [ + ...pages.map((p) => asRow(p.id, 'pages', p.slug, pageToCells(p))), + ...visualComponents.map((vc) => asRow(vc.id, 'components', vc.id, visualComponentToCells(vc))), + ...SOURCE_ROWS, + ], + media: sourceMediaWithBytes().map((m) => ({ + id: m.id, + filename: m.filename, + mimeType: m.mimeType, + sizeBytes: m.sizeBytes ?? 0, + altText: m.alt ?? '', + caption: '', + title: '', + tags: [], + width: m.width ?? null, + height: m.height ?? null, + durationMs: null, + dominantColor: null, + blurHash: null, + storagePath: m.path.replace('/uploads/', ''), + posterPath: null, + folderIds: [], + bytesBase64: btoa(new TextDecoder().decode(mediaBytesFor(m.id))), + })), + } + } + + it('extracts the same closure from a site-transfer bundle', async () => { + const fromBundle = await extractFragment(fragmentSourceFromBundle(bundle()), { pageId: 'page-home', rootNodeIds: ['hero'] }) + const fromSite = section + expect(fromBundle.fragment.styleRules.map((r) => r.id)).toEqual(fromSite.fragment.styleRules.map((r) => r.id)) + expect(fromBundle.fragment.components.map((c) => c.id).sort()).toEqual(['vc-badge', 'vc-card']) + expect(fromBundle.fragment.tables.map((t) => t.slug).sort()).toEqual(['contact', 'projects']) + expect(fromBundle.fragment.media.map((m) => m.key).sort()).toEqual(fromSite.fragment.media.map((m) => m.key).sort()) + }) +}) + +describe('fragment packages', () => { + it('round-trips a fragment and its media through a zip', async () => { + const { fragment, mediaBytes } = section + const pkg = readFragmentPackage(writeFragmentPackage(fragment, mediaBytes)) + expect(pkg.fragment).toEqual(fragment) + const photo = fragment.media.find((m) => m.id === 'media-photo')! + expect(pkg.readMedia(photo)).toEqual(mediaBytesFor('media-photo')) + }) +}) diff --git a/src/__tests__/fragments/fixtures.ts b/src/__tests__/fragments/fixtures.ts new file mode 100644 index 000000000..4c533c161 --- /dev/null +++ b/src/__tests__/fragments/fixtures.ts @@ -0,0 +1,397 @@ +/** + * Two small made-up sites for the fragment engine tests. + * + * SOURCE ("Atelier") has everything a section can depend on: classes, an + * ambient rule built on them, a custom condition, a colour token with + * generated variants, a font token backed by a custom font, type and space + * scale steps, a root custom property, a nested Visual Component pair, a loop + * over a data table with media cells, a CMS form, an image, a background + * image, a page link, a keyframes animation, and a wrapper class on an + * ancestor (an outside dependency). + * + * TARGET ("Harbour") overlaps on purpose: it already has a `hero` class with + * different styles, a `primary` colour, shorter type and space scales, the + * `Card` component, the `projects` table without the `cover` field, an + * `about` page, and `--radius` on its root. + */ + +import type { DataRow, DataTable } from '@core/data/schemas' +import type { FrameworkSettings } from '@core/framework-schema' +import { generateFrameworkUtilityClasses } from '@core/framework' +import type { PageNode, SiteDocument, StyleRule } from '@core/page-tree' +import { extractFragment, fragmentSourceFromSite, type FragmentSelection, type FragmentSourceMedia } from '@core/fragments' +import { makeNode, makePage, makeSite, makeVC, makeVCNode, makeVCTree } from '../fixtures' + +const T = 1_700_000_000_000 + +export function rule( + id: string, + partial: Partial & Pick, +): StyleRule { + return { + id, + styles: {}, + contextStyles: {}, + createdAt: T, + updatedAt: T, + ...partial, + } +} + +function colorToken(id: string, slug: string, value: string, order: number, shades: boolean) { + return { + id, + category: '', + slug, + lightValue: value, + darkValue: '', + darkModeEnabled: false, + generateUtilities: { text: true, background: true, border: false, fill: false }, + generateTransparent: true, + generateShades: { enabled: shades, count: shades ? 3 : 0 }, + generateTints: { enabled: false, count: 0 }, + order, + createdAt: T, + updatedAt: T, + } +} + +function typeGroup(id: string, steps: string, baseScaleIndex: number) { + return { + id, + name: 'Text', + namingConvention: 'text', + min: { fontSize: 16, scaleRatio: 1.125 }, + max: { fontSize: 18, scaleRatio: 1.333 }, + steps, + baseScaleIndex, + mode: 'fluid' as const, + order: 0, + createdAt: T, + updatedAt: T, + } +} + +function spaceGroup(id: string, steps: string, baseScaleIndex: number) { + return { + id, + name: 'Space', + namingConvention: 'space', + min: { size: 16, scaleRatio: 1.25 }, + max: { size: 28, scaleRatio: 1.414 }, + steps, + baseScaleIndex, + mode: 'fluid' as const, + order: 0, + createdAt: T, + updatedAt: T, + } +} + +function withFrameworkClasses( + framework: FrameworkSettings, + rules: Record, +): Record { + return { ...generateFrameworkUtilityClasses(framework), ...rules } +} + +// --------------------------------------------------------------------------- +// Source +// --------------------------------------------------------------------------- + +export const SOURCE_FRAMEWORK: FrameworkSettings = { + colors: { + tokens: [ + colorToken('col-primary', 'primary', '#3366ff', 0, true), + colorToken('col-accent', 'accent', '#ff8800', 1, false), + colorToken('col-unused', 'unused', '#00aa00', 2, false), + ], + }, + typography: { + groups: [typeGroup('type-1', 'xs,s,m,l,xl,2xl', 2)], + classes: [{ id: 'gen-text', tabId: 'type-1', name: 'text-*', property: ['font-size'] }], + }, + spacing: { + groups: [spaceGroup('space-1', 's,m,l', 1)], + classes: [{ id: 'gen-pad', tabId: 'space-1', name: 'padding-*', property: ['padding-top'] }], + }, +} + +/** The framework id of a generated class, by its name. */ +export function frameworkClassId(framework: FrameworkSettings, name: string): string { + const found = Object.values(generateFrameworkUtilityClasses(framework)).find((c) => c.name === name) + if (!found) throw new Error(`no framework class ${name}`) + return found.id +} + +export const SOURCE_MEDIA: FragmentSourceMedia[] = [ + { id: 'media-photo', path: '/uploads/photo.jpg', filename: 'photo.jpg', mimeType: 'image/jpeg', sizeBytes: 3, width: 800, height: 600, alt: 'A photo' }, + { id: 'media-bg', path: '/uploads/bg.jpg', filename: 'bg.jpg', mimeType: 'image/jpeg', sizeBytes: 3 }, + { id: 'media-cover', path: '/uploads/cover.jpg', filename: 'cover.jpg', mimeType: 'image/jpeg', sizeBytes: 3 }, + { id: 'media-font', path: '/uploads/inter.woff2', filename: 'inter.woff2', mimeType: 'font/woff2', sizeBytes: 3 }, + { id: 'media-unused', path: '/uploads/unused.jpg', filename: 'unused.jpg', mimeType: 'image/jpeg', sizeBytes: 3 }, +] + +/** Deterministic bytes per media id, so hashes are stable across runs. */ +export function mediaBytesFor(id: string): Uint8Array { + return new TextEncoder().encode(`bytes:${id}`) +} + +export function sourceMediaWithBytes(): FragmentSourceMedia[] { + return SOURCE_MEDIA.map((m) => ({ ...m, sizeBytes: mediaBytesFor(m.id).byteLength, readBytes: () => mediaBytesFor(m.id) })) +} + +export const SOURCE_TABLES: DataTable[] = [ + { + id: 'tbl-projects', + name: 'Projects', + slug: 'projects', + kind: 'data', + singularLabel: 'Project', + pluralLabel: 'Projects', + routeBase: '', + primaryFieldId: 'title', + fields: [ + { id: 'title', label: 'Title', type: 'text' }, + { id: 'cover', label: 'Cover', type: 'media', mediaKind: 'image' }, + { id: 'owner', label: 'Owner', type: 'relation', targetTableId: 'tbl-people' }, + ], + system: false, + createdByUserId: null, + updatedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'tbl-contact', + name: 'Contact', + slug: 'contact', + kind: 'data', + singularLabel: 'Message', + pluralLabel: 'Messages', + routeBase: '', + primaryFieldId: 'name', + fields: [ + { id: 'name', label: 'Name', type: 'text', required: true }, + { id: 'email', label: 'Email', type: 'email' }, + ], + system: false, + createdByUserId: null, + updatedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, +] + +function row(id: string, tableId: string, slug: string, cells: Record, status: DataRow['status'] = 'published'): DataRow { + return { + id, + tableId, + cells, + slug, + status, + authorUserId: null, + createdByUserId: null, + updatedByUserId: null, + publishedByUserId: null, + author: null, + createdBy: null, + updatedBy: null, + publishedBy: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + publishedAt: null, + scheduledPublishAt: null, + deletedAt: null, + } +} + +export const SOURCE_ROWS: DataRow[] = [ + row('row-1', 'tbl-projects', 'harbour', { title: 'Harbour', cover: 'media-cover', owner: 'person-1' }), + row('row-2', 'tbl-projects', 'mill', { title: 'Mill more', cover: null }, 'draft'), + row('row-3', 'tbl-contact', 'm1', { name: 'Someone', email: 'a@example.com' }), +] + +export function makeSourceSite(): SiteDocument { + const framework = structuredClone(SOURCE_FRAMEWORK) + const textXl = frameworkClassId(framework, 'text-xl') + const textPrimary = frameworkClassId(framework, 'text-primary') + + const badge = makeVC({ + id: 'vc-badge', + name: 'Badge', + tree: makeVCTree('badge-root', [ + makeVCNode({ id: 'badge-root', moduleId: 'base.container', classIds: ['cls-badge'] }), + ]), + }) + const card = makeVC({ + id: 'vc-card', + name: 'Card', + tree: makeVCTree('card-root', [ + makeVCNode({ id: 'card-root', moduleId: 'base.container', classIds: ['cls-card'], children: ['card-badge'] }), + makeVCNode({ id: 'card-badge', moduleId: 'base.visual-component-ref', props: { componentId: 'vc-badge', propOverrides: {} } }), + ]), + }) + + const nodes: Record = { + body: makeNode({ id: 'body', moduleId: 'base.body', children: ['wrap'] }), + wrap: makeNode({ id: 'wrap', moduleId: 'base.container', classIds: ['cls-page-wrap'], children: ['hero', 'other'] }), + hero: makeNode({ + id: 'hero', + moduleId: 'base.container', + classIds: ['cls-hero', 'cls-scoped'], + children: ['title', 'photo', 'link', 'card-ref', 'loop', 'form'], + }), + title: makeNode({ id: 'title', moduleId: 'base.text', props: { text: 'Hello' }, classIds: ['cls-hero-title', textXl, textPrimary] }), + photo: makeNode({ id: 'photo', moduleId: 'base.image', props: { src: '/uploads/photo.jpg' } }), + link: makeNode({ id: 'link', moduleId: 'base.link', props: { href: 'cms:page:page-about#team', text: 'About' } }), + 'card-ref': makeNode({ id: 'card-ref', moduleId: 'base.visual-component-ref', props: { componentId: 'vc-card', propOverrides: {} } }), + loop: makeNode({ + id: 'loop', + moduleId: 'base.loop', + props: { sourceId: 'data.rows', filters: { tableId: 'tbl-projects' }, orderBy: 'cell:title' }, + children: ['loop-item'], + }), + 'loop-item': makeNode({ + id: 'loop-item', + moduleId: 'base.text', + props: { text: '{currentEntry.title}' }, + dynamicBindings: { text: { source: 'currentEntry', field: 'title' } }, + }), + form: makeNode({ id: 'form', moduleId: 'base.form', props: { mode: 'cms', formId: 'contact', targetTableId: 'tbl-contact' } }), + other: makeNode({ id: 'other', moduleId: 'base.text', props: { text: 'Not selected' }, classIds: ['cls-unrelated'] }), + } + + nodes.hero.inlineStyles = { gap: 'var(--space-s)' } + + const styleRules = withFrameworkClasses(framework, { + 'cls-root': rule('cls-root', { + name: ':root', kind: 'ambient', selector: ':root', order: 0, + styles: { '--radius': '12px', '--shadow': '0 0 4px var(--primary-50)', '--unused-prop': '1px' }, + }), + 'cls-page-wrap': rule('cls-page-wrap', { name: 'page-wrap', kind: 'class', selector: '.page-wrap', order: 1, styles: { maxWidth: '1200px' } }), + 'cls-hero': rule('cls-hero', { + name: 'hero', kind: 'class', selector: '.hero', order: 2, + styles: { + paddingTop: 'var(--space-l)', + backgroundImage: "url('/uploads/bg.jpg')", + borderRadius: 'var(--radius)', + animation: 'fade 1s ease', + }, + contextStyles: { tablet: { paddingTop: '0' }, 'media:(hover: hover)': { opacity: '0.9' } }, + }), + 'cls-hero-title': rule('cls-hero-title', { + name: 'hero-title', kind: 'class', selector: '.hero-title', order: 3, + styles: { fontFamily: 'var(--font-heading)', color: 'var(--primary-d-2)', boxShadow: 'var(--shadow)' }, + }), + 'cls-hero-hover': rule('cls-hero-hover', { + name: '.hero .hero-title:hover', kind: 'ambient', selector: '.hero .hero-title:hover', order: 4, + styles: { color: 'var(--accent)' }, + }), + 'cls-wrap-h': rule('cls-wrap-h', { name: '.page-wrap .hero-title', kind: 'ambient', selector: '.page-wrap .hero-title', order: 5, styles: { margin: '0' } }), + 'cls-wrap-other': rule('cls-wrap-other', { name: '.page-wrap .footer', kind: 'ambient', selector: '.page-wrap .footer', order: 6, styles: { margin: '0' } }), + 'cls-unrelated': rule('cls-unrelated', { name: 'unrelated', kind: 'class', selector: '.unrelated', order: 7, styles: { color: 'var(--unused)' } }), + 'cls-keyframes': rule('cls-keyframes', { + name: '@keyframes fade', kind: 'ambient', selector: '@keyframes fade', order: 8, + rawCss: '@keyframes fade {\n from {\n opacity: 0;\n }\n}', + }), + 'cls-card': rule('cls-card', { name: 'card', kind: 'class', selector: '.card', order: 9, styles: { background: 'var(--accent)', borderRadius: 'var(--radius)' } }), + 'cls-badge': rule('cls-badge', { name: 'badge', kind: 'class', selector: '.badge', order: 10, styles: { color: 'var(--primary)' } }), + 'cls-scoped': rule('cls-scoped', { + name: 'hero-style', kind: 'class', selector: '.hero-style', order: 11, + scope: { type: 'node', nodeId: 'hero', role: 'module-style' }, styles: { margin: '1px' }, + }), + }) + + const site = makeSite({ + id: 'site-atelier', + name: 'Atelier', + pages: [ + makePage({ id: 'page-home', slug: 'index', title: 'Home', rootNodeId: 'body', nodes }), + makePage({ id: 'page-about', slug: 'about', title: 'About', rootNodeId: 'about-body' }), + ], + styleRules, + settings: { + shortcuts: {}, + framework, + fonts: { + items: [{ + id: 'font-inter', + source: 'custom', + family: 'Inter', + variants: ['400'], + subsets: ['latin'], + files: [{ variant: '400', subset: 'latin', path: '/uploads/inter.woff2', format: 'woff2', mediaAssetId: 'media-font' }], + createdAt: T, + updatedAt: T, + }], + tokens: [{ id: 'tok-heading', name: 'Heading', variable: 'font-heading', familyId: 'font-inter', fallback: 'sans-serif', order: 0, createdAt: T, updatedAt: T }], + }, + }, + visualComponents: [card, badge], + }) + site.conditions = [{ id: 'media:(hover: hover)', label: 'Hover', condition: { kind: 'media', query: '(hover: hover)' } }] + return site +} + +/** The source site as an extraction source, media bytes included. */ +export function sourceWithBytes(site: SiteDocument = makeSourceSite()) { + return fragmentSourceFromSite(site, { tables: SOURCE_TABLES, rows: SOURCE_ROWS, media: sourceMediaWithBytes() }) +} + +/** Extract from the source site; by default the `hero` section. */ +export function extractFromSource(selection: FragmentSelection = { pageId: 'page-home', rootNodeIds: ['hero'] }) { + return extractFragment(sourceWithBytes(), selection) +} + +// --------------------------------------------------------------------------- +// Target +// --------------------------------------------------------------------------- + +export function makeTargetSite(): SiteDocument { + const framework: FrameworkSettings = { + colors: { tokens: [colorToken('t-primary', 'primary', '#111111', 0, false)] }, + typography: { + groups: [typeGroup('t-type', 'xs,s,m,l', 2)], + classes: [{ id: 't-gen-text', tabId: 't-type', name: 'text-*', property: ['font-size'] }], + }, + spacing: { groups: [spaceGroup('t-space', 'm,l', 0)] }, + } + const card = makeVC({ + id: 't-vc-card', + name: 'Card', + tree: makeVCTree('t-card-root', [makeVCNode({ id: 't-card-root', moduleId: 'base.container' })]), + }) + return makeSite({ + id: 'site-harbour', + name: 'Harbour', + pages: [ + makePage({ + id: 't-home', slug: 'index', title: 'Home', rootNodeId: 't-body', + nodes: { + 't-body': makeNode({ id: 't-body', moduleId: 'base.body', children: ['t-section'] }), + 't-section': makeNode({ id: 't-section', moduleId: 'base.container', classIds: ['t-hero'] }), + }, + }), + makePage({ id: 't-about', slug: 'about', title: 'About us', rootNodeId: 't-about-body' }), + ], + styleRules: withFrameworkClasses(framework, { + 't-root': rule('t-root', { name: ':root', kind: 'ambient', selector: ':root', order: 0, styles: { '--radius': '2px' } }), + 't-hero': rule('t-hero', { name: 'hero', kind: 'class', selector: '.hero', order: 1, styles: { paddingTop: '99px' } }), + 't-hover': rule('t-hover', { + name: '.hero .hero-title:hover', kind: 'ambient', selector: '.hero .hero-title:hover', order: 2, + styles: { color: 'black' }, + }), + }), + settings: { shortcuts: {}, framework }, + visualComponents: [card], + }) +} + +export const TARGET_TABLES: DataTable[] = [ + { + ...SOURCE_TABLES[0], + id: 't-tbl-projects', + fields: [{ id: 'title', label: 'Title', type: 'text' }], + }, +] diff --git a/src/__tests__/fragments/plan.test.ts b/src/__tests__/fragments/plan.test.ts new file mode 100644 index 000000000..bab0762fe --- /dev/null +++ b/src/__tests__/fragments/plan.test.ts @@ -0,0 +1,281 @@ +/** + * planFragmentInsert — every merge rule, against a populated and an empty site. + */ + +import { describe, expect, it } from 'bun:test' +import { + FRAGMENT_PROPERTIES_ORIGIN, + FragmentInsertError, + extractFragment, + planFragmentInsert, + type FragmentInsertPlan, + type SiteFragment, +} from '@core/fragments' +import { generateFrameworkUtilityClasses } from '@core/framework' +import { buildFontRoleTokens } from '@core/fonts' +import type { PageNode } from '@core/page-tree' +import { makeNode, makePage, makeSite } from '../fixtures' +import { TARGET_TABLES, extractFromSource, makeSourceSite, makeTargetSite, sourceWithBytes } from './fixtures' + +// Planning is pure, so every test can read the same extracted section. +const { fragment } = await extractFromSource() + +function planIntoTarget(target = makeTargetSite(), section: SiteFragment = fragment) { + const plan = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + section, + ) + return { plan, target } +} + +function nodeByModule(plan: FragmentInsertPlan, moduleId: string): PageNode { + const node = Object.values(plan.nodes).find((n) => n.moduleId === moduleId) + if (!node) throw new Error(`no ${moduleId}`) + return node +} + +describe('planFragmentInsert — classes and rules', () => { + it('mints fresh node ids for everything', () => { + const { plan } = planIntoTarget() + for (const id of Object.keys(plan.nodes)) expect(fragment.nodes[id]).toBeUndefined() + expect(plan.roots).toHaveLength(1) + expect(plan.nodes[plan.roots[0]].parentId).toBeNull() + }) + + it("reuses the site's class of the same name, even when its styles differ", () => { + const { plan, target } = planIntoTarget() + const hero = plan.nodes[plan.roots[0]] + expect(hero.classIds).toContain('t-hero') + expect(plan.styleRules.find((r) => r.name === 'hero')).toBeUndefined() + expect(target.styleRules['t-hero'].styles).toEqual({ paddingTop: '99px' }) + expect(plan.report.rules.reused).toContain('hero') + }) + + it('adds missing classes after the existing cascade, in the fragment order', () => { + const { plan, target } = planIntoTarget() + const maxExisting = Math.max(...Object.values(target.styleRules).map((r) => r.order)) + const added = plan.styleRules + for (const rule of added) { + expect(rule.order).toBeGreaterThan(maxExisting) + expect(target.styleRules[rule.id]).toBeUndefined() + } + const names = added.map((r) => r.name) + expect(names.indexOf('hero-title')).toBeLessThan(names.indexOf('@keyframes fade')) + const orders = added.map((r) => r.order) + expect([...orders].sort((a, b) => a - b)).toEqual(orders) + // `card` belongs to the fragment's Card, and the site's Card wins. + expect(plan.report.rules.added).toEqual(['hero-title']) + }) + + it('keeps an ambient rule whose selector the site has, adds the others', () => { + const { plan } = planIntoTarget() + expect(plan.report.rules.ambientKept).toEqual(['.hero .hero-title:hover']) + expect(plan.report.rules.ambientAdded).toEqual(['.page-wrap .hero-title', '@keyframes fade']) + }) + + it('clones scoped module-style rules onto the new node ids', () => { + const { plan } = planIntoTarget() + const scoped = plan.styleRules.find((r) => r.scope) + const hero = plan.nodes[plan.roots[0]] + expect(scoped?.scope?.nodeId).toBe(hero.id) + expect(hero.classIds).toContain(scoped!.id) + }) + + it('matches framework utilities by name after the token merge', () => { + const { plan, target } = planIntoTarget() + const title = nodeByModule(plan, 'base.text') + // text-xl exists only because the insert adds the xl step to the site's scale. + const after = generateFrameworkUtilityClasses({ + ...target.settings.framework!, + typography: { + ...target.settings.framework!.typography!, + groups: [{ ...target.settings.framework!.typography!.groups[0], steps: 'xs,s,m,l,xl' }], + }, + }) + const textXl = Object.values(after).find((c) => c.name === 'text-xl')! + const textPrimary = Object.values(after).find((c) => c.name === 'text-primary')! + expect(title.classIds).toContain(textXl.id) + expect(title.classIds).toContain(textPrimary.id) + }) +}) + +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'] }) + const accent = plan.colorTokens[0] + expect(accent.slug).toBe('accent') + expect(accent.lightValue).toBe('#ff8800') + expect(accent.generateTransparent).toBe(true) + expect(accent.generateUtilities).toEqual({ text: true, background: true, border: false, fill: false }) + expect(accent.id).not.toBe('col-accent') + }) + + 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) + expect(subjects).toEqual(['--primary-d-2']) + }) + + it("inserts missing steps into the site's own scale and keeps its base step", () => { + const { plan } = planIntoTarget() + const typography = plan.scaleSteps.find((s) => s.family === 'typography') + const spacing = plan.scaleSteps.find((s) => s.family === 'spacing') + expect(typography).toEqual({ family: 'typography', groupId: 't-type', steps: 'xs,s,m,l,xl', baseScaleIndex: 2 }) + // `s` lands before the base step `m`, so the base index moves with it. + expect(spacing).toEqual({ family: 'spacing', groupId: 't-space', steps: 's,m,l', baseScaleIndex: 1 }) + }) + + it('adds the missing font token with its family', () => { + const { plan } = planIntoTarget() + expect(plan.report.fonts.tokensAdded).toEqual(['font-heading']) + expect(plan.fontFamilies.map((f) => f.entry.family)).toEqual(['Inter']) + // Bound to the family being installed until the commit knows its real id. + expect(plan.fontTokens[0].familyId).toBe(plan.fontFamilies[0].fragmentFamilyId) + }) + + it("keeps a new site's seeded font roles and installs nothing for them", () => { + const target = makeTargetSite() + target.settings.fonts = { items: [], tokens: buildFontRoleTokens() } + const { plan } = planIntoTarget(target) + expect(plan.report.fonts.tokensKept).toEqual(['font-heading']) + expect(plan.fontTokens).toEqual([]) + // The fragment bound Inter to font-heading; the site's role wins, so Inter + // and its file are not needed. + expect(plan.fontFamilies).toEqual([]) + expect(plan.media.map((m) => m.id)).not.toContain('media-font') + }) + + it('puts root custom properties the site lacks into one engine-owned :root rule', () => { + const { plan, target } = planIntoTarget() + expect(plan.report.customProperties).toEqual({ added: ['--shadow'], kept: ['--radius'] }) + expect(target.styleRules[plan.customProperties!.ruleId]).toBeUndefined() + expect(plan.customProperties?.properties).toEqual({ '--shadow': '0 0 4px var(--primary-50)' }) + }) + + it('reuses the engine-owned :root rule on a second insert', () => { + const target = makeTargetSite() + target.styleRules['owned'] = { + id: 'owned', name: ':root', kind: 'ambient', selector: ':root', order: 50, + styles: { '--other': '1' }, contextStyles: {}, + origin: { source: FRAGMENT_PROPERTIES_ORIGIN, ordinal: 0 }, + createdAt: 1, updatedAt: 1, + } + const { plan } = planIntoTarget(target) + expect(plan.customProperties).toEqual({ ruleId: 'owned', order: 50, properties: { '--shadow': '0 0 4px var(--primary-50)' } }) + }) +}) + +describe('planFragmentInsert — components, tables, links, media', () => { + it("points instances at the site's component of the same name and skips its nested ones", () => { + const { plan } = planIntoTarget() + expect(nodeByModule(plan, 'base.visual-component-ref').props.componentId).toBe('t-vc-card') + expect(plan.report.components).toEqual({ added: [], reused: ['Card'] }) + expect(plan.components).toEqual([]) + }) + + it('adds components the site lacks, nested ones included, with fresh ids', () => { + const target = makeTargetSite() + target.visualComponents = [] + const { plan } = planIntoTarget(target) + expect(plan.components.map((c) => c.name).sort()).toEqual(['Badge', 'Card']) + const card = plan.components.find((c) => c.name === 'Card')! + const badge = plan.components.find((c) => c.name === 'Badge')! + expect(card.id).not.toBe('vc-card') + const nested = Object.values(card.tree.nodes).find((n) => n.moduleId === 'base.visual-component-ref')! + expect(nested.props.componentId).toBe(badge.id) + expect(nodeByModule(plan, 'base.visual-component-ref').props.componentId).toBe(card.id) + const cardRoot = card.tree.nodes[card.tree.rootNodeId] + expect(plan.styleRules.find((r) => r.id === cardRoot.classIds[0])?.name).toBe('card') + }) + + it('reuses a table by slug, adds its missing fields, creates the missing one', () => { + const { plan } = planIntoTarget() + expect(plan.report.tables).toEqual({ created: ['contact'], reused: ['projects'], fieldsAdded: ['projects.cover'], rows: 0 }) + const reuse = plan.tables.find((t) => t.action === 'reuse')! + expect(reuse.action === 'reuse' && reuse.addFields.map((f) => f.id)).toEqual(['cover']) + expect(plan.report.warnings.some((w) => w.code === 'relation-field-dropped' && w.subject === 'projects.owner')).toBe(true) + const loop = nodeByModule(plan, 'base.loop') + expect((loop.props.filters as Record).tableId).toBe('t-tbl-projects') + // The created table's id comes from the server at commit time. + expect(nodeByModule(plan, 'base.form').props.targetTableId).toBe('tbl-contact') + }) + + it('remaps a link to a page the site has by slug, and turns the rest into #', () => { + const { plan } = planIntoTarget() + expect(nodeByModule(plan, 'base.link').props.href).toBe('cms:page:t-about#team') + expect(plan.report.links).toEqual({ remapped: ['about'], unresolved: [] }) + + const target = makeTargetSite() + target.pages = target.pages.filter((p) => p.slug !== 'about') + const { plan: plan2 } = planIntoTarget(target) + expect(nodeByModule(plan2, 'base.link').props.href).toBe('#') + expect(plan2.report.links.unresolved).toEqual(['about']) + }) + + it('plans only the media the inserted content uses', () => { + const { plan } = planIntoTarget() + // cover.jpg is only used by rows of the reused projects table, which are + // not added; bg.jpg only by the fragment's `hero`, and the site's wins. + expect(plan.media.map((m) => m.id).sort()).toEqual(['media-font', 'media-photo']) + }) + + it('repeats the outside dependencies the extractor found', () => { + const { plan } = planIntoTarget() + expect(plan.report.outsideDependencies).toEqual([{ selector: '.page-wrap .hero-title', classes: ['page-wrap'] }]) + }) +}) + +describe('planFragmentInsert — empty site, pages and guards', () => { + it('into an empty site: everything is added', () => { + const empty = makeSite({ pages: [makePage({ id: 'e-home' })] }) + const plan = planFragmentInsert( + { site: empty, tables: [], document: { kind: 'page', pageId: 'e-home' } }, + fragment, + ) + expect(plan.report.colors.added.sort()).toEqual(['accent', 'primary']) + expect(plan.report.rules.added).toEqual(['hero', 'hero-title', 'card', 'badge']) + expect(plan.report.tables.created.sort()).toEqual(['contact', 'projects']) + expect(plan.report.tables.rows).toBe(2) + expect(plan.media.map((m) => m.id).sort()).toEqual(['media-bg', 'media-cover', 'media-font', 'media-photo']) + expect(plan.report.warnings.filter((w) => w.code === 'missing-scale-group').map((w) => w.subject).sort()).toEqual(['space', 'text']) + expect(plan.customProperties?.properties).toEqual({ '--radius': '12px', '--shadow': '0 0 4px var(--primary-50)' }) + }) + + it('a page fragment adds its own page with a unique slug and links to itself', async () => { + const source = makeSourceSite() + source.pages[0].nodes.link.props.href = 'cms:page:page-home' + const { fragment: page } = await extractFragment(sourceWithBytes(source), { pageId: 'page-home' }) + const plan = planFragmentInsert({ site: makeTargetSite(), tables: TARGET_TABLES }, page) + expect(plan.page?.slug).toBe('index-2') + expect(plan.nodes[plan.roots[0]].moduleId).toBe('base.body') + expect(nodeByModule(plan, 'base.link').props.href).toBe(`cms:page:${plan.page!.id}`) + }) + + it('refuses a second content outlet', () => { + const withOutlet = structuredClone(fragment) + withOutlet.nodes.outlet = makeNode({ id: 'outlet', moduleId: 'base.outlet' }) + withOutlet.nodes.hero.children.push('outlet') + const target = makeTargetSite() + target.pages[0].nodes['t-outlet'] = makeNode({ id: 't-outlet', moduleId: 'base.outlet' }) + expect(() => planIntoTarget(target, withOutlet)).toThrow(FragmentInsertError) + }) + + it('refuses to put a component inside itself', () => { + let caught: unknown + try { + planFragmentInsert( + { site: makeTargetSite(), tables: TARGET_TABLES, document: { kind: 'visualComponent', vcId: 't-vc-card' } }, + fragment, + ) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(FragmentInsertError) + expect((caught as FragmentInsertError).code).toBe('component-cycle') + }) + + it('needs a document for a section', () => { + expect(() => planFragmentInsert({ site: makeTargetSite(), tables: [] }, fragment)).toThrow(FragmentInsertError) + }) +}) diff --git a/src/__tests__/fragments/schema.test.ts b/src/__tests__/fragments/schema.test.ts new file mode 100644 index 000000000..5bc797d3a --- /dev/null +++ b/src/__tests__/fragments/schema.test.ts @@ -0,0 +1,68 @@ +/** + * parseSiteFragment — the boundary rejects anything that is not a fragment. + */ + +import { describe, expect, it } from 'bun:test' +import { zipSync, strToU8 } from 'fflate' +import { FragmentParseError, parseSiteFragment, readFragmentPackage } from '@core/fragments' +import { extractFromSource } from './fixtures' + +const { fragment } = await extractFromSource() + +function expectParseError(value: unknown, path: RegExp): void { + let caught: unknown + try { + parseSiteFragment(value) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(FragmentParseError) + expect((caught as FragmentParseError).path).toMatch(path) +} + +describe('parseSiteFragment', () => { + it('accepts what extractFragment writes', () => { + expect(parseSiteFragment(JSON.parse(JSON.stringify(fragment)))).toEqual(fragment) + }) + + it('rejects a foreign format, a future version and non-objects', () => { + expectParseError({ ...fragment, format: 'something-else' }, /format/) + expectParseError({ ...fragment, version: 2 }, /version/) + expectParseError('fragment', /fragment|^$/) + }) + + it('rejects roots that do not resolve and cyclic trees', () => { + expectParseError({ ...fragment, roots: ['ghost'] }, /^roots$/) + const cyclic = structuredClone(fragment) + cyclic.nodes.title.children = ['hero'] + expectParseError(cyclic, /^nodes$/) + const dangling = structuredClone(fragment) + dangling.nodes.title.children = ['nowhere'] + expectParseError(dangling, /^nodes$/) + }) + + it('rejects kind payloads that do not match the kind', () => { + expectParseError({ ...fragment, kind: 'page' }, /^page$/) + expectParseError({ ...fragment, kind: 'component' }, /^component$/) + expectParseError( + { ...fragment, kind: 'component', component: { id: 'vc-card', name: 'Card' } }, + /^roots$/, + ) + }) + + it('rejects malformed media keys and duplicate ids', () => { + const badKey = structuredClone(fragment) + badKey.media[0].key = 'not-a-hash' + expectParseError(badKey, /media/) + const dupe = structuredClone(fragment) + dupe.components.push(structuredClone(dupe.components[0])) + expectParseError(dupe, /^components$/) + }) + + it('rejects a package whose manifest or media is missing', () => { + expect(() => readFragmentPackage(zipSync({ 'other.json': strToU8('{}') }))).toThrow(FragmentParseError) + expect(() => readFragmentPackage(zipSync({ 'fragment.json': strToU8('{oops') }))).toThrow(FragmentParseError) + const withoutMedia = zipSync({ 'fragment.json': strToU8(JSON.stringify(fragment)) }) + expect(() => readFragmentPackage(withoutMedia)).toThrow(/is not in the package/) + }) +}) diff --git a/src/__tests__/framework/insertScaleStep.test.ts b/src/__tests__/framework/insertScaleStep.test.ts new file mode 100644 index 000000000..8d73e906e --- /dev/null +++ b/src/__tests__/framework/insertScaleStep.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test' +import { insertScaleStep } from '@core/framework' + +describe('insertScaleStep', () => { + it('appends a larger step without moving the base', () => { + expect(insertScaleStep('xs,s,m,l', 2, '2xl')).toEqual({ steps: 'xs,s,m,l,2xl', baseScaleIndex: 2 }) + }) + + it('inserts a smaller step before the base and shifts the base index with it', () => { + expect(insertScaleStep('s,m,l', 1, '3xs')).toEqual({ steps: '3xs,s,m,l', baseScaleIndex: 2 }) + }) + + it('inserts between existing steps at the canonical position', () => { + expect(insertScaleStep('xs,m,xl', 1, 's')).toEqual({ steps: 'xs,s,m,xl', baseScaleIndex: 2 }) + expect(insertScaleStep('xs,m,xl', 1, 'l')).toEqual({ steps: 'xs,m,l,xl', baseScaleIndex: 1 }) + }) + + it('leaves a list that already has the step alone', () => { + expect(insertScaleStep('xs,s,m', 2, 's')).toEqual({ steps: 'xs,s,m', baseScaleIndex: 2 }) + }) + + it('has no answer for a step off the ring, or a list holding one', () => { + expect(insertScaleStep('xs,s,m', 2, 'huge')).toBeNull() + expect(insertScaleStep('xs,custom,m', 2, 'l')).toBeNull() + }) +}) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx index bbafba616..2f7d7e3b3 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx @@ -33,6 +33,9 @@ import '@core/loops/sources' const ImportHtmlModal = lazy(() => import('@admin/modals/ImportHtml').then((m) => ({ default: m.ImportHtmlModal })), ) +const InsertFragmentModal = lazy(() => + import('@admin/modals/InsertFragment').then((m) => ({ default: m.InsertFragmentModal })), +) interface AdminCanvasEditorBodyProps { canEditDraftSite: boolean @@ -61,6 +64,7 @@ export function AdminCanvasEditorBody({ const propertiesPanelMode = useEditorStore((s) => s.propertiesPanelMode) const rightSidebarExpanded = useEditorStore(selectRightSidebarExpanded) const importHtmlModalOpen = useEditorStore((s) => s.importHtmlModalOpen) + const insertFragmentModalOpen = useEditorStore((s) => s.insertFragmentModalOpen) const hasRightSidebar = rightSidebarExpanded const narrowChrome = useNarrowEditorChrome() @@ -141,6 +145,14 @@ export function AdminCanvasEditorBody({ )} + + {/* Insert fragment dialog — opens from Spotlight "Insert fragment from + file…". Its chunk carries the zip reader and the fragment planner. */} + {insertFragmentModalOpen && ( + + + + )} ) } diff --git a/src/admin/modals/InsertFragment/FragmentReport.tsx b/src/admin/modals/InsertFragment/FragmentReport.tsx new file mode 100644 index 000000000..9fd7303f9 --- /dev/null +++ b/src/admin/modals/InsertFragment/FragmentReport.tsx @@ -0,0 +1,123 @@ +/** + * FragmentReport — what an insert adds, what it takes from the site as is, + * and what the user should know. Rendered twice: as the review before + * "Insert" (from the plan) and as the result after it (from the commit, + * with media counts filled in). + */ + +import type { FragmentInsertReport } from '@core/fragments' +import { Alert } from '@ui/components/Alert' +import styles from './InsertFragmentModal.module.css' + +interface ReportRow { + label: string + values: string[] +} + +const MAX_LISTED = 8 + +function list(values: string[]): string { + if (values.length <= MAX_LISTED) return values.join(', ') + return `${values.slice(0, MAX_LISTED).join(', ')} and ${values.length - MAX_LISTED} more` +} + +function addedRows(report: FragmentInsertReport, done: boolean): ReportRow[] { + const files = `${report.media.total} file${report.media.total === 1 ? '' : 's'}` + const mediaLine = report.media.total === 0 + ? [] + : done + ? [`${report.media.uploaded} uploaded`, `${report.media.reused} already in the library`] + : [files] + return [ + { label: 'Page', values: report.page ? [`${report.page.title} (/${report.page.slug === 'index' ? '' : report.page.slug})`] : [] }, + { label: 'Classes', values: report.rules.added }, + { label: 'Selectors', values: report.rules.ambientAdded }, + { label: 'Colours', values: report.colors.added }, + { label: 'Type and space steps', values: report.steps.added.map((s) => `${s.convention}-${s.step}`) }, + { label: 'Font tokens', values: report.fonts.tokensAdded }, + { label: 'Font families', values: report.fonts.familiesAdded }, + { label: 'Custom properties', values: report.customProperties.added }, + { label: 'Conditions', values: report.conditions.added }, + { label: 'Components', values: report.components.added }, + { label: 'Tables', values: report.tables.created }, + { label: 'Table fields', values: report.tables.fieldsAdded }, + { label: 'Rows', values: report.tables.rows > 0 ? [String(report.tables.rows)] : [] }, + { label: 'Media', values: mediaLine }, + ].filter((row) => row.values.length > 0) +} + +function keptRows(report: FragmentInsertReport): ReportRow[] { + return [ + { label: 'Classes', values: report.rules.reused }, + { label: 'Selectors', values: report.rules.ambientKept }, + { label: 'Colours', values: report.colors.kept }, + { label: 'Font tokens', values: report.fonts.tokensKept }, + { label: 'Font families', values: report.fonts.familiesKept }, + { label: 'Custom properties', values: report.customProperties.kept }, + { label: 'Components', values: report.components.reused }, + { label: 'Tables', values: report.tables.reused }, + ].filter((row) => row.values.length > 0) +} + +function ReportList({ rows }: { rows: ReportRow[] }) { + return ( +

+ {rows.map((row) => ( +
+
{row.label}
+
{list(row.values)}
+
+ ))} +
+ ) +} + +export function FragmentReport({ report, done }: { report: FragmentInsertReport; done: boolean }) { + const added = addedRows(report, done) + const kept = keptRows(report) + const links = [ + ...report.links.remapped.map((slug) => `/${slug === 'index' ? '' : slug} kept`), + ...report.links.unresolved.map((slug) => `/${slug} has no page here, linked to #`), + ] + + return ( +
+
+

{done ? 'Added' : 'Will add'}

+ {added.length > 0 ? :

Only the layers. Everything they use is already here.

} +
+ + {kept.length > 0 && ( +
+

Kept from this site

+ +
+ )} + + {links.length > 0 && ( +
+

Links

+ +
+ )} + + {report.outsideDependencies.length > 0 && ( + + These rules styled it through a parent class it does not bring:{' '} + {list(report.outsideDependencies.map((d) => d.selector))} + + )} + + {report.warnings.length > 0 && ( + +
    + {report.warnings.slice(0, MAX_LISTED).map((warning, index) => ( +
  • {warning.message}
  • + ))} + {report.warnings.length > MAX_LISTED &&
  • {report.warnings.length - MAX_LISTED} more
  • } +
+
+ )} +
+ ) +} diff --git a/src/admin/modals/InsertFragment/InsertFragmentModal.module.css b/src/admin/modals/InsertFragment/InsertFragmentModal.module.css new file mode 100644 index 000000000..cf9020e66 --- /dev/null +++ b/src/admin/modals/InsertFragment/InsertFragmentModal.module.css @@ -0,0 +1,98 @@ +/* InsertFragmentModal — pick a fragment file, review the plan, read the result. */ + +.pick, +.review { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.body { + margin: 0; + font-size: var(--text-m); + color: var(--text); + line-height: 1.4; +} + +.fileRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-s); +} + +.fileName { + margin: 0; + font-size: var(--text-m); + color: var(--text); + overflow-wrap: anywhere; +} + +.meta { + color: var(--text-subtle); + font-size: var(--text-s); +} + +.progress { + margin: 0; + font-size: var(--text-s); + color: var(--text-subtle); +} + +.report { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.reportHeading { + margin: 0 0 var(--space-xs) 0; + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-subtle); +} + +.reportEmpty { + margin: 0; + font-size: var(--text-s); + color: var(--text-subtle); +} + +.reportList { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-px); + background: var(--bg-surface); + border-radius: var(--radius); + overflow: hidden; +} + +.reportRow { + display: grid; + grid-template-columns: minmax(8rem, max-content) 1fr; + column-gap: var(--space-m); + padding: var(--space-xs) var(--space-s); + background: var(--bg-surface-2); + font-size: var(--text-s); +} + +.reportRow dt { + color: var(--text-subtle); +} + +.reportRow dd { + margin: 0; + color: var(--text); + overflow-wrap: anywhere; +} + +.warningList { + margin: 0; + padding-left: var(--space-m); + display: flex; + flex-direction: column; + gap: var(--space-4xs); +} diff --git a/src/admin/modals/InsertFragment/InsertFragmentModal.tsx b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx new file mode 100644 index 000000000..1fb8b5511 --- /dev/null +++ b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx @@ -0,0 +1,202 @@ +/** + * InsertFragmentModal — Spotlight "Insert fragment from file…". + * + * Pick a fragment package (`.zip`) or a media-free `fragment.json`; the + * dialog plans the insert against the open document and shows what it will + * add and what it keeps from this site; "Insert" runs the server work and the + * single store transaction, then shows what was added. Nothing changes until + * "Insert"; one undo removes the whole insert afterwards. + * + * Mounted lazily by AdminCanvasEditorBody while `insertFragmentModalOpen`. + */ + +import { useRef, useState, type ChangeEvent } from 'react' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +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 { FragmentReport } from './FragmentReport' +import { loadFragmentFile, planLoadedFragment, runFragmentInsert, type LoadedFragment } from './fragmentInsertFlow' +import styles from './InsertFragmentModal.module.css' + +type Step = + | { kind: 'pick' } + | { kind: 'reading'; fileName: string } + | { kind: 'review'; loaded: LoadedFragment; plan: FragmentInsertPlan } + | { kind: 'running'; loaded: LoadedFragment; plan: FragmentInsertPlan; line: string } + | { kind: 'done'; loaded: LoadedFragment; result: FragmentInsertResult } + +type SetStep = (next: Step | ((current: Step) => Step)) => void + +// Module-level so the React Compiler can compile the component body +// (try/catch in an async function inside it would bail out). +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) + setStep({ kind: 'review', loaded, plan }) + } catch (err) { + console.error('[InsertFragmentModal] reading the fragment failed:', err) + pushToast({ kind: 'error', title: 'Could not insert this fragment', body: getErrorMessage(err, 'Unknown fragment error') }) + setStep({ kind: 'pick' }) + } +} + +/** 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, + runStepUp: ReturnType['runStepUp'], + controller: AbortController, + setStep: SetStep, +): 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', + }) + } 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') }) + } + setStep({ kind: 'review', loaded, plan }) + } +} + +function titleFor(step: Step): string { + if (step.kind === 'done') return step.result.pageId ? 'Page added' : 'Fragment inserted' + return 'Insert fragment' +} + +export function InsertFragmentModal() { + const closeModal = useEditorStore((s) => s.closeInsertFragmentModal) + const openPageInCanvas = useEditorStore((s) => s.openPageInCanvas) + const [step, setStep] = useState({ kind: 'pick' }) + const controller = useRef(null) + const { runStepUp } = useStepUp() + + function handleClose() { + if (step.kind === 'running') { + controller.current?.abort() + return + } + closeModal() + } + + function handlePick(event: ChangeEvent) { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void readAndPlan(file, setStep) + } + + function handleInsert() { + if (step.kind !== 'review') return + controller.current = new AbortController() + void insert(step.loaded, step.plan, runStepUp, controller.current, setStep) + } + + function handleOpenPage(pageId: string) { + openPageInCanvas(pageId) + closeModal() + } + + const picker = ( + + + {step.kind === 'review' ? 'Choose a different file' : 'Choose fragment…'} + + ) + + return ( + + {step.kind === 'done' && step.result.pageId && ( + + )} + + {step.kind !== 'done' && ( + + )} + + )} + > + {step.kind === 'pick' && ( +
+

+ Insert a section, page or component exported as a fragment. It lands where a pasted + layer would, and brings the classes, colours, fonts, components, tables and media it + needs. Anything this site already has stays as it is. +

+ {picker} +
+ )} + + {step.kind === 'reading' && ( +

Reading {step.fileName}…

+ )} + + {(step.kind === 'review' || step.kind === 'running') && ( +
+
+

+ {step.loaded.fileName} + {' '} + + {step.plan.kind} + {step.loaded.fragment.origin?.item ? ` · ${step.loaded.fragment.origin.item}` : ''} + +

+ {step.kind === 'review' && picker} +
+ {step.kind === 'running' &&

{step.line}

} + +
+ )} + + {step.kind === 'done' && } +
+ ) +} diff --git a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts new file mode 100644 index 000000000..b7f5d26ab --- /dev/null +++ b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts @@ -0,0 +1,82 @@ +/** + * createFragmentInsertAdapter — wires `commitFragmentPlan` to the CMS API and + * the editor store. + * + * Media: the library stores no content hash, so "the same file" is a + * non-deleted asset with the same filename, byte size and MIME type (the + * library is listed once per insert). Anything else is uploaded through the + * same endpoint as the Media workspace. + * 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. + * + * 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 { installCmsGoogleFont, registerCustomFont } from '@core/persistence/cmsFonts' +import { listCmsMediaAssets, uploadCmsMediaAsset, type CmsMediaAsset } from '@core/persistence/cmsMedia' +import { + createCmsDataRow, + createCmsDataTable, + publishCmsDataRow, + updateCmsDataTable, +} from '@core/persistence/cmsData' +import { useEditorStore } from '@site/store/store' + +type RunStepUp = (action: () => Promise) => Promise + +export function createFragmentInsertAdapter(runStepUp: RunStepUp): FragmentInsertAdapter { + let library: Promise | null = null + + return { + async findMedia(media) { + if (media.sizeBytes === undefined) return null + library ??= listCmsMediaAssets() + const match = (await library).find((asset) => + asset.deletedAt === null + && asset.filename === media.filename + && asset.mimeType === media.mimeType + && asset.sizeBytes === media.sizeBytes, + ) + return match ? { id: match.id, url: match.publicPath } : null + }, + + async uploadMedia({ filename, mimeType, bytes, altText, signal }) { + // Bytes come from fflate / File APIs — always a plain ArrayBuffer. + const file = new File([bytes.slice().buffer as ArrayBuffer], filename, { type: mimeType }) + const asset = await runStepUp(() => uploadCmsMediaAsset(file, { signal, altText })) + return { id: asset.id, url: asset.publicPath } + }, + + installGoogleFont: (request) => runStepUp(() => installCmsGoogleFont(request)), + + registerCustomFont: (request) => runStepUp(() => registerCustomFont(request)), + + async createTable(input) { + const table = await runStepUp(() => createCmsDataTable(input)) + return { id: table.id } + }, + + async updateTableFields(tableId, fields) { + await runStepUp(() => updateCmsDataTable(tableId, { fields })) + }, + + async createRow(tableId, row) { + const created = await runStepUp(() => createCmsDataRow(tableId, { cells: row.cells })) + if (row.status === 'published') await runStepUp(() => publishCmsDataRow(created.id)) + return { id: created.id } + }, + + async commit(plan) { + const result = useEditorStore.getState().insertFragment(plan) + if (!result) { + throw new Error('The editor could not place the fragment here. Select where it should go and try again.') + } + return result + }, + } +} diff --git a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts new file mode 100644 index 000000000..f316a3a66 --- /dev/null +++ b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts @@ -0,0 +1,81 @@ +/** + * 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. + */ + +import { + FragmentParseError, + commitFragmentPlan, + parseSiteFragment, + planFragmentInsert, + readFragmentPackage, + type FragmentInsertPlan, + type FragmentInsertResult, + type FragmentMedia, + type SiteFragment, +} from '@core/fragments' +import { listCmsDataTables } from '@core/persistence/cmsData' +import { useEditorStore } from '@site/store/store' +import { fragmentInsertDocument } from '@site/store/slices/fragmentsSlice' +import { createFragmentInsertAdapter } from './createFragmentInsertAdapter' + +export interface LoadedFragment { + fileName: string + fragment: SiteFragment + readMedia(media: FragmentMedia): Uint8Array | null +} + +/** + * A fragment package (`.zip`: `fragment.json` + media) or a bare + * `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 { + const bytes = new Uint8Array(await file.arrayBuffer()) + if (file.name.toLowerCase().endsWith('.json')) { + let raw: unknown + try { + raw = JSON.parse(new TextDecoder().decode(bytes)) + } catch { + throw new FragmentParseError('fragment.json', 'not valid JSON') + } + const fragment = parseSiteFragment(raw) + if (fragment.media.length > 0) { + throw new FragmentParseError('media', 'this fragment has media; insert its .zip package instead') + } + return { fileName: file.name, fragment, readMedia: () => null } + } + const pkg = readFragmentPackage(bytes) + 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 { + 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, + ) +} + +export function runFragmentInsert( + loaded: LoadedFragment, + plan: FragmentInsertPlan, + runStepUp: (action: () => Promise) => Promise, + signal: AbortSignal, + onProgress: (line: string) => void, +): 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') + }, + }) +} diff --git a/src/admin/modals/InsertFragment/index.ts b/src/admin/modals/InsertFragment/index.ts new file mode 100644 index 000000000..8f8b5b38c --- /dev/null +++ b/src/admin/modals/InsertFragment/index.ts @@ -0,0 +1 @@ +export { InsertFragmentModal } from './InsertFragmentModal' diff --git a/src/admin/pages/site/store/insertLocation.ts b/src/admin/pages/site/store/insertLocation.ts index 71411142a..b5ce81969 100644 --- a/src/admin/pages/site/store/insertLocation.ts +++ b/src/admin/pages/site/store/insertLocation.ts @@ -83,3 +83,25 @@ export function resolveInsertLocation( return { parentId: targetNodeId, index: undefined } } + +/** + * Where a fragment lands: right after the selected element, as its next + * sibling, so inserting a second section while the first is still selected + * puts it below the first instead of inside it. With nothing selected or the + * document root selected it goes at the end of the root. A selected slot + * instance (or a component ref, which redirects to its slot) still receives + * content inside it, because a slot's children are its only editable place. + */ +export function resolveSiblingInsertLocation( + page: NodeTree, + targetNodeId: string, +): InsertLocation | null { + const target = page.nodes[targetNodeId] + if (!target || targetNodeId === page.rootNodeId) { + return resolveInsertLocation(page, page.rootNodeId) + } + if (target.moduleId === 'base.slot-instance' || target.moduleId === 'base.visual-component-ref') { + return resolveInsertLocation(page, targetNodeId) + } + return siblingAfter(page, targetNodeId) ?? resolveInsertLocation(page, page.rootNodeId) +} diff --git a/src/admin/pages/site/store/slices/fragmentsSlice.ts b/src/admin/pages/site/store/slices/fragmentsSlice.ts new file mode 100644 index 000000000..cc2101a4c --- /dev/null +++ b/src/admin/pages/site/store/slices/fragmentsSlice.ts @@ -0,0 +1,108 @@ +/** + * Fragments slice — the editor side of `@core/fragments`. + * + * Owns the Insert fragment dialog's open flag (Spotlight "Insert fragment + * from file…") and `insertFragment`, which applies a resolved insert plan in + * ONE undoable store transaction: the core `applyResolvedFragment` writes + * rules, tokens, fonts, components and nodes (or the new page), and the + * 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. + */ + +import { + applyResolvedFragment, + type FragmentApplyResult, + type FragmentInsertDocument, + type ResolvedFragmentPlan, +} from '@core/fragments' +import { resolveSiblingInsertLocation } from '@site/store/insertLocation' +import type { EditorStore, EditorStoreSliceCreator } from '@site/store/types' +import { reconcileFrameworkClasses } from './site/framework/reconcile' +import { buildSiteHelpers, resolveActiveTreeTarget } from './site/helpers' +import { isSameActiveDocument } from './uiSlice' + +interface FragmentsSlice { + /** Whether the Insert fragment dialog is open. */ + insertFragmentModalOpen: boolean + openInsertFragmentModal: () => void + closeInsertFragmentModal: () => void + + /** + * 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. + */ + insertFragment: (plan: ResolvedFragmentPlan) => FragmentApplyResult | null +} + +declare module '@site/store/types' { + // Surface this slice's fields on the combined EditorStore type. + interface EditorStore extends FragmentsSlice {} +} + +/** The document a section or component fragment would land in, or null. */ +export function fragmentInsertDocument( + state: Pick, +): FragmentInsertDocument | null { + if (!state.site) return null + if (state.activeDocument?.kind === 'visualComponent') { + return { kind: 'visualComponent', vcId: state.activeDocument.vcId } + } + const pageId = state.activeDocument?.kind === 'page' ? state.activeDocument.pageId : state.activePageId + return pageId ? { kind: 'page', pageId } : null +} + +export const createFragmentsSlice: EditorStoreSliceCreator = (set, get) => { + const { mutateSite, mutateActiveTreeAndSite } = buildSiteHelpers(set, get) + + return { + insertFragmentModalOpen: false, + openInsertFragmentModal: () => set({ insertFragmentModalOpen: true }), + closeInsertFragmentModal: () => set({ insertFragmentModalOpen: false }), + + insertFragment: (plan) => { + const state = get() + if (!state.site) return null + let result: FragmentApplyResult | null = null + + if (plan.page) { + const accepted = mutateSite((site) => { + const applied = applyResolvedFragment(site, plan, null) + if (applied.frameworkChanged) reconcileFrameworkClasses(site) + result = { rootIds: applied.rootIds, pageId: applied.pageId } + }) + return accepted ? result : null + } + + // The plan was made for one document; never drop it into another. + 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) + if (!location) return null + + const accepted = mutateActiveTreeAndSite((tree, site) => { + const applied = applyResolvedFragment(site, plan, { + tree, + parentId: location.parentId, + index: location.index, + }) + if (applied.frameworkChanged) reconcileFrameworkClasses(site) + result = { rootIds: applied.rootIds } + return true + }) + if (!accepted) return null + const rootId = (result as FragmentApplyResult | null)?.rootIds[0] + if (rootId) get().selectNode(rootId) + return result + }, + } +} diff --git a/src/admin/pages/site/store/slices/site/helpers.ts b/src/admin/pages/site/store/slices/site/helpers.ts index b52978968..fea9addb3 100644 --- a/src/admin/pages/site/store/slices/site/helpers.ts +++ b/src/admin/pages/site/store/slices/site/helpers.ts @@ -11,7 +11,15 @@ import { nanoid } from 'nanoid' import type { StoreApi } from 'zustand' import type { NodeTree, PageNode, StyleRule, SiteDocument } from '@core/page-tree' -import { addPage, createNode, reconcileSiteExplorerInPlace, reindexNodeParents } from '@core/page-tree' +import { + addMissingConditions, + addPage, + createNode, + createStyleRuleOrderAllocator, + reconcileSiteExplorerInPlace, + reindexNodeParents, + type StyleRuleOrderAllocator, +} from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import { syncAllVCRefSlotInstances, allTreeNodeMaps } from '../vcSlotReconcile' import { collectSlotOutletNames } from '@core/visualComponents' @@ -24,10 +32,8 @@ import { applyLocalSitePatches, notifyCollabBlocked } from './collabBinding' import type { EditorStore } from '@site/store/types' import { reconcileFrameworkClasses } from './framework/reconcile' import { - createStyleRuleOrderAllocator, indexStyleRulesByName, linkImportedClassNames, - type StyleRuleOrderAllocator, findReimportedStyleRule, indexStyleRulesByOrigin, registerStyleRuleOrigin, @@ -472,15 +478,7 @@ export function buildSiteHelpers( }, addConditions(conditions): void { - if (conditions.length === 0) return - if (!site.conditions) site.conditions = [] - const existing = new Set(site.conditions.map((c) => c.id)) - for (const def of conditions) { - if (existing.has(def.id)) continue - existing.add(def.id) - site.conditions.push(def) - didMutate = true - } + if (addMissingConditions(site, conditions) > 0) didMutate = true }, addFonts(fonts): { id: string; family: string }[] { diff --git a/src/admin/pages/site/store/slices/site/importLinking.ts b/src/admin/pages/site/store/slices/site/importLinking.ts index 6ea591e83..c2b642f12 100644 --- a/src/admin/pages/site/store/slices/site/importLinking.ts +++ b/src/admin/pages/site/store/slices/site/importLinking.ts @@ -8,28 +8,9 @@ import { nanoid } from 'nanoid' import { classKindSelector } from '@core/page-tree' -import type { StyleRule, StyleRuleOrigin } from '@core/page-tree' +import type { StyleRule, StyleRuleOrderAllocator, StyleRuleOrigin } from '@core/page-tree' import type { NewStyleRule } from '@core/siteImport' -export type StyleRuleOrderAllocator = () => number - -/** - * Allocate monotonically increasing cascade positions for one import - * transaction. The existing registry is scanned once; every subsequent rule - * append is O(1), even when a stylesheet contains tens of thousands of rules. - */ -export function createStyleRuleOrderAllocator( - rules: Record, -): StyleRuleOrderAllocator { - let nextOrder = 0 - for (const rule of Object.values(rules)) { - if (typeof rule.order === 'number' && rule.order >= nextOrder) { - nextOrder = rule.order + 1 - } - } - return () => nextOrder++ -} - /** * Index a StyleRule registry by name → id. * First id wins on duplicates (createClass enforces name uniqueness, so diff --git a/src/admin/pages/site/store/slices/site/nodeActions.ts b/src/admin/pages/site/store/slices/site/nodeActions.ts index 1e5698463..1979763f2 100644 --- a/src/admin/pages/site/store/slices/site/nodeActions.ts +++ b/src/admin/pages/site/store/slices/site/nodeActions.ts @@ -30,6 +30,8 @@ import { wrapNode, wrapNodes, reindexNodeParents, + addMissingConditions, + createStyleRuleOrderAllocator, } from '@core/page-tree' import type { NodeTree, PageNode, SiteDocument } from '@core/page-tree' import { subtreeHasOutlet, treeHasOutlet } from '@core/templates' @@ -37,12 +39,7 @@ import { wouldCreateCycle, syncSlotInstances, applySlotSyncResult } from '@core/ import { pushToast } from '@ui/components/Toast' import { depthInTree, resolveActiveTreeTarget } from './helpers' import { pruneCanvasSelectionDraft } from '../selectionSlice' -import { - createStyleRuleOrderAllocator, - indexStyleRulesByName, - linkImportedClassNames, - mergeImportedStyleRules, -} from './importLinking' +import { indexStyleRulesByName, linkImportedClassNames, mergeImportedStyleRules } from './importLinking' import type { SiteSlice, SiteSliceHelpers } from './types' type NodeActions = Pick< @@ -232,15 +229,7 @@ export function createNodeActions(helpers: SiteSliceHelpers): NodeActions { } // Register any reusable conditions (custom @media / @container / // @supports) the