From 6b9c027424ddcbc4593eea655b4ddbcc0e033cbd Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:29:26 +0200 Subject: [PATCH 01/13] feat(fragments): add the site fragment format and closure extraction A fragment is one versioned file that carries a section, a page or a Visual Component with everything it needs to render in another site: classes and the ambient rules built on them, conditions, the component closure, the tokens its styles read, the tables its loops and forms use, its media and its page links. extractFragment builds one from a live site or a site-transfer bundle, and the barrel stays headless so build scripts can import it under Bun. --- .../architecture/fragments-headless.test.ts | 96 +++++ src/__tests__/fragments/extract.test.ts | 216 ++++++++++ src/__tests__/fragments/fixtures.ts | 387 ++++++++++++++++++ src/__tests__/fragments/schema.test.ts | 88 ++++ .../pages/site/store/slices/site/helpers.ts | 13 +- .../site/store/slices/site/importLinking.ts | 34 +- .../site/store/slices/site/nodeActions.ts | 9 +- src/core/fonts/schemas.ts | 2 +- src/core/fragments/extract.ts | 333 +++++++++++++++ src/core/fragments/extractData.ts | 214 ++++++++++ src/core/fragments/extractStyles.ts | 138 +++++++ src/core/fragments/extractTokens.ts | 132 ++++++ src/core/fragments/index.ts | 46 +++ src/core/fragments/mediaKeys.ts | 35 ++ src/core/fragments/package.ts | 76 ++++ src/core/fragments/references.ts | 157 +++++++ src/core/fragments/schema.ts | 293 +++++++++++++ src/core/fragments/siteVariables.ts | 106 +++++ src/core/fragments/source.ts | 166 ++++++++ src/core/fragments/warnings.ts | 35 ++ src/core/framework-schema/schemas.ts | 2 +- src/core/page-tree/index.ts | 6 +- src/core/page-tree/styleRuleRegistry.ts | 43 ++ 23 files changed, 2581 insertions(+), 46 deletions(-) create mode 100644 src/__tests__/architecture/fragments-headless.test.ts create mode 100644 src/__tests__/fragments/extract.test.ts create mode 100644 src/__tests__/fragments/fixtures.ts create mode 100644 src/__tests__/fragments/schema.test.ts create mode 100644 src/core/fragments/extract.ts create mode 100644 src/core/fragments/extractData.ts create mode 100644 src/core/fragments/extractStyles.ts create mode 100644 src/core/fragments/extractTokens.ts create mode 100644 src/core/fragments/index.ts create mode 100644 src/core/fragments/mediaKeys.ts create mode 100644 src/core/fragments/package.ts create mode 100644 src/core/fragments/references.ts create mode 100644 src/core/fragments/schema.ts create mode 100644 src/core/fragments/siteVariables.ts create mode 100644 src/core/fragments/source.ts create mode 100644 src/core/fragments/warnings.ts create mode 100644 src/core/page-tree/styleRuleRegistry.ts diff --git a/src/__tests__/architecture/fragments-headless.test.ts b/src/__tests__/architecture/fragments-headless.test.ts new file mode 100644 index 000000000..c9c79c71d --- /dev/null +++ b/src/__tests__/architecture/fragments-headless.test.ts @@ -0,0 +1,96 @@ +/** + * Architecture Gate — `src/core/fragments/` is importable outside the app. + * + * Build scripts (the private template pipeline among them) 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__/fragments/extract.test.ts b/src/__tests__/fragments/extract.test.ts new file mode 100644 index 000000000..cfd6d0c6d --- /dev/null +++ b/src/__tests__/fragments/extract.test.ts @@ -0,0 +1,216 @@ +/** + * extractFragment — the closure a section, page or component carries. + */ + +import { describe, expect, it } from 'bun:test' +import { + FragmentExtractError, + extractFragment, + fragmentSourceFromBundle, + fragmentSourceFromSite, + 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, + frameworkClassId, + makeSourceSite, + mediaBytesFor, + sourceMediaWithBytes, +} from './fixtures' + +function source() { + return fragmentSourceFromSite(makeSourceSite(), { + tables: SOURCE_TABLES, + rows: SOURCE_ROWS, + media: sourceMediaWithBytes(), + }) +} + +describe('extractFragment — section closure', () => { + it('carries the subtree, its classes and the ambient rules built on them', async () => { + const { fragment } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + + 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 } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + 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 } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + 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 } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + + 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 } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + 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(extractFragment(source(), { pageId: 'nope' })).rejects.toBeInstanceOf(FragmentExtractError) + await expect(extractFragment(source(), { 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 extractFragment(source(), { 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 extractFragment(source(), { 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 = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + 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 } = await extractFragment(source(), { pageId: 'page-home', rootNodeIds: ['hero'] }) + 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..06c8323ff --- /dev/null +++ b/src/__tests__/fragments/fixtures.ts @@ -0,0 +1,387 @@ +/** + * 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 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, 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 +} + +// --------------------------------------------------------------------------- +// 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/schema.test.ts b/src/__tests__/fragments/schema.test.ts new file mode 100644 index 000000000..89d7d4fd4 --- /dev/null +++ b/src/__tests__/fragments/schema.test.ts @@ -0,0 +1,88 @@ +/** + * parseSiteFragment — the boundary rejects anything that is not a fragment. + */ + +import { describe, expect, it } from 'bun:test' +import { zipSync, strToU8 } from 'fflate' +import { + FragmentParseError, + extractFragment, + fragmentSourceFromSite, + parseSiteFragment, + readFragmentPackage, + type SiteFragment, +} from '@core/fragments' +import { SOURCE_ROWS, SOURCE_TABLES, makeSourceSite, sourceMediaWithBytes } from './fixtures' + +async function validFragment(): Promise { + const source = fragmentSourceFromSite(makeSourceSite(), { + tables: SOURCE_TABLES, + rows: SOURCE_ROWS, + media: sourceMediaWithBytes(), + }) + return (await extractFragment(source, { pageId: 'page-home', rootNodeIds: ['hero'] })).fragment +} + +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', async () => { + const fragment = await validFragment() + expect(parseSiteFragment(JSON.parse(JSON.stringify(fragment)))).toEqual(fragment) + }) + + it('rejects a foreign format, a future version and non-objects', async () => { + const fragment = await validFragment() + expectParseError({ ...fragment, format: 'something-else' }, /format/) + expectParseError({ ...fragment, version: 2 }, /version/) + expectParseError('fragment', /fragment|^$/) + }) + + it('rejects roots that do not resolve and cyclic trees', async () => { + const fragment = await validFragment() + 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', async () => { + const fragment = await validFragment() + 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', async () => { + const fragment = await validFragment() + 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', async () => { + const fragment = await validFragment() + 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/admin/pages/site/store/slices/site/helpers.ts b/src/admin/pages/site/store/slices/site/helpers.ts index b52978968..87f5388b1 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 { + addPage, + createNode, + createStyleRuleOrderAllocator, + indexStyleRulesByName, + 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,7 @@ 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, diff --git a/src/admin/pages/site/store/slices/site/importLinking.ts b/src/admin/pages/site/store/slices/site/importLinking.ts index 6ea591e83..f0e00b2ab 100644 --- a/src/admin/pages/site/store/slices/site/importLinking.ts +++ b/src/admin/pages/site/store/slices/site/importLinking.ts @@ -8,41 +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 - * duplicates only occur in corrupted data — first-wins is a defensive tiebreak). - */ -export function indexStyleRulesByName(rules: Record): Map { - const byName = new Map() - for (const cls of Object.values(rules)) { - if (!byName.has(cls.name)) byName.set(cls.name, cls.id) - } - return byName -} - /** * The identity a re-import reconciles against: import origin (stylesheet + * ordinal) AND selector. Origin alone is not unique in the registry — a class diff --git a/src/admin/pages/site/store/slices/site/nodeActions.ts b/src/admin/pages/site/store/slices/site/nodeActions.ts index 1e5698463..e173ef71f 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, + createStyleRuleOrderAllocator, + indexStyleRulesByName, } 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 { linkImportedClassNames, mergeImportedStyleRules } from './importLinking' import type { SiteSlice, SiteSliceHelpers } from './types' type NodeActions = Pick< diff --git a/src/core/fonts/schemas.ts b/src/core/fonts/schemas.ts index dab86d82d..1bf747288 100644 --- a/src/core/fonts/schemas.ts +++ b/src/core/fonts/schemas.ts @@ -211,7 +211,7 @@ function parseFontEntry(raw: unknown): FontEntry | null { // FontToken // --------------------------------------------------------------------------- -const FontTokenSchema = Type.Object({ +export const FontTokenSchema = Type.Object({ id: Type.String({ minLength: 1 }), name: Type.String({ minLength: 1 }), /** diff --git a/src/core/fragments/extract.ts b/src/core/fragments/extract.ts new file mode 100644 index 000000000..f1c21a0ca --- /dev/null +++ b/src/core/fragments/extract.ts @@ -0,0 +1,333 @@ +/** + * extractFragment — cut a section, a page, or a Visual Component out of a + * source site together with its closure, as a portable `SiteFragment`. + * + * The closure, in the order it is collected: + * + * 1. Nodes — the selected subtrees, deep-copied (a component selection + * becomes one instance of the component, slot instances materialised). + * 2. Visual Components — every `base.visual-component-ref` target, + * transitively; their trees feed every later step. + * 3. Class rules — every class a carried node (or component) uses. Framework + * utilities are carried by NAME only (styles stripped; the target + * regenerates its own), scoped module-style rules and regular rules whole. + * 4. Ambient rules — every ambient rule whose selector names a carried class, + * plus each `@keyframes` block a carried declaration animates with. + * Rules that style the selection through a class on one of its + * ANCESTORS are not carried; they are reported as outside dependencies. + * 5. Conditions — every non-viewport context id a carried rule keys on. + * 6. Tokens — every `var(--x)` the content reads, classified by the + * framework's own generators: colour (full token definition), type or + * space scale step (by name), font token (+ its library entry), or any + * other custom property (valued from the source root rules). + * 7. Tables — loops over `data.rows` carry their table and rows; CMS forms + * carry their target table's schema. + * 8. Media — every library asset a string in the closure references by + * path, every custom font file, every media cell of a carried row. + * 9. Links — every `cms:page:` target with the source page's slug. + * + * Pure apart from reading media bytes through the source (and hashing them); + * returns a fragment that has passed `parseSiteFragment`. + */ + +import { nanoid } from 'nanoid' +import { collectSubtreeIds, extractCssSelectorClasses, type Page, type PageNode, type StyleRule } from '@core/page-tree' +import { + applySlotSyncResult, + forEachVCRef, + syncSlotInstances, + type VisualComponent, +} from '@core/visualComponents' +import { collectPageRefIds, forEachString, nodeContent } from './references' +import { + SITE_FRAGMENT_FORMAT, + SITE_FRAGMENT_VERSION, + parseSiteFragment, + type FragmentKind, + type FragmentLink, + type FragmentTable, + type SiteFragment, +} from './schema' +import type { FragmentSource } from './source' +import type { FragmentWarning } from './warnings' +import { + FRAMEWORK_ID_PREFIX, + carryKeyframes, + collectConditions, + findOutsideDependencies, + frameworkStub, +} from './extractStyles' +import { collectTokens, type FrameworkStepUse } from './extractTokens' +import { collectMedia, collectTables, type MediaSrcOption } from './extractData' + +/** + * What to extract: part of a page (`rootNodeIds`, siblings in document + * order), a whole page (no `rootNodeIds`), or a Visual Component. + * In a bundle, a page id is its `pages` row id. + */ +export type FragmentSelection = + | { pageId: string; rootNodeIds?: string[] } + | { componentId: string } + +export interface ExtractFragmentOptions { + origin?: SiteFragment['origin'] + /** + * How `media[].src` is written. Default: the package path + * (`media/.`) when the bytes were read, else the source path. + */ + mediaSrc?: MediaSrcOption +} + +export interface ExtractFragmentResult { + fragment: SiteFragment + /** Bytes read while extracting, by fragment media id — what a package zips. */ + mediaBytes: Map + warnings: FragmentWarning[] +} + +/** The selection does not resolve in the source. */ +export class FragmentExtractError extends Error { + constructor(message: string) { + super(message) + this.name = 'FragmentExtractError' + } +} + + +export async function extractFragment( + source: FragmentSource, + selection: FragmentSelection, + options: ExtractFragmentOptions = {}, +): Promise { + const warnings: FragmentWarning[] = [] + const { site } = source + + // ── 1. Nodes ─────────────────────────────────────────────────────────────── + const picked = pickSelection(source, selection) + const { kind, roots, nodes } = picked + + // ── 2. Visual Component closure ──────────────────────────────────────────── + const components = collectComponents(site.visualComponents, nodes, warnings) + const nodeSets: Record[] = [nodes, ...components.map((vc) => vc.tree.nodes as Record)] + const allNodes = nodeSets.flatMap((set) => Object.values(set)) + + // ── 3. Class rules ───────────────────────────────────────────────────────── + const usedClassIds = new Set() + for (const node of allNodes) for (const id of node.classIds) usedClassIds.add(id) + for (const vc of components) for (const id of vc.classIds) usedClassIds.add(id) + + const carried = new Map() + const frameworkColorIds = new Set() + const frameworkSteps: FrameworkStepUse[] = [] + for (const classId of usedClassIds) { + const rule = site.styleRules[classId] + if (!rule) continue + if (classId.startsWith(FRAMEWORK_ID_PREFIX)) { + carried.set(classId, frameworkStub(rule)) + const generated = rule.generated + if (generated?.family === 'color') frameworkColorIds.add(generated.sourceId) + else if (generated) { + frameworkSteps.push({ family: generated.family, convention: generated.tokenName, step: generated.step }) + } + } else { + carried.set(classId, structuredClone(rule)) + } + } + const carriedClassNames = new Set([...carried.values()].map((rule) => rule.name)) + + // ── 4. Ambient rules + outside dependencies ──────────────────────────────── + for (const rule of Object.values(site.styleRules)) { + if (rule.kind !== 'ambient' || carried.has(rule.id)) continue + const names = extractCssSelectorClasses(rule.selector).map((token) => token.name) + if (names.some((name) => carriedClassNames.has(name))) carried.set(rule.id, structuredClone(rule)) + } + carryKeyframes(site.styleRules, carried) + const outsideDependencies = picked.page && kind === 'section' + ? findOutsideDependencies(picked.page, roots, site.styleRules, carried, carriedClassNames) + : [] + + const styleRules = [...carried.values()].sort((a, b) => a.order - b.order) + + // ── 5. Conditions ────────────────────────────────────────────────────────── + const conditions = collectConditions(styleRules, site.breakpoints.map((bp) => bp.id), site.conditions ?? []) + + // ── 7. Tables (before tokens/media: rows reference media and pages) ──────── + const tables = collectTables(source, allNodes, warnings) + + // ── 6. Tokens ────────────────────────────────────────────────────────────── + const tokens = collectTokens(source, styleRules, allNodes, frameworkColorIds, frameworkSteps, warnings) + + // ── 8. Media ─────────────────────────────────────────────────────────────── + const { media, mediaBytes } = await collectMedia( + source, + { styleRules, allNodes, tables, fontFamilies: tokens.fontFamilies }, + options.mediaSrc, + warnings, + ) + + // ── 9. Links ─────────────────────────────────────────────────────────────── + const links = collectLinks(site.pages, allNodes, tables, warnings) + + const fragment: SiteFragment = { + format: SITE_FRAGMENT_FORMAT, + version: SITE_FRAGMENT_VERSION, + kind, + ...(options.origin ? { origin: options.origin } : {}), + roots, + nodes, + ...(picked.page && kind === 'page' ? { page: { title: picked.page.title, slug: picked.page.slug } } : {}), + ...(picked.component ? { component: picked.component } : {}), + styleRules, + conditions, + components, + tokens, + tables, + media, + links, + ...(outsideDependencies.length > 0 ? { outsideDependencies } : {}), + } + + return { fragment: parseSiteFragment(fragment), mediaBytes, warnings } +} + +// --------------------------------------------------------------------------- +// 1. Selection +// --------------------------------------------------------------------------- + +interface PickedSelection { + kind: FragmentKind + roots: string[] + nodes: Record + page?: Page + component?: { id: string; name: string } +} + +function pickSelection(source: FragmentSource, selection: FragmentSelection): PickedSelection { + if ('componentId' in selection) { + const vc = source.site.visualComponents.find((c) => c.id === selection.componentId) + if (!vc) throw new FragmentExtractError(`Visual Component "${selection.componentId}" not found`) + // One instance of the component, slot instances materialised exactly the + // way inserting it from the module inserter would. + const ref: PageNode = { + id: nanoid(), + moduleId: 'base.visual-component-ref', + props: { componentId: vc.id, propOverrides: {} }, + breakpointOverrides: {}, + children: [], + classIds: [], + parentId: null, + } + const nodes: Record = { [ref.id]: ref } + applySlotSyncResult(nodes, syncSlotInstances(ref, vc, nodes), ref.id) + return { kind: 'component', roots: [ref.id], nodes, component: { id: vc.id, name: vc.name } } + } + + const page = source.site.pages.find((p) => p.id === selection.pageId) + if (!page) throw new FragmentExtractError(`Page "${selection.pageId}" not found`) + + const requested = selection.rootNodeIds ?? [] + if (requested.length === 0) { + return { kind: 'page', roots: [page.rootNodeId], nodes: copySubtrees(page, [page.rootNodeId]), page } + } + + for (const id of requested) { + if (!page.nodes[id]) throw new FragmentExtractError(`Node "${id}" not found on page "${page.id}"`) + if (id === page.rootNodeId) throw new FragmentExtractError('Select sections inside the page, or no nodes for the whole page') + } + // A root inside another selected root is already carried by it. + const unique = [...new Set(requested)] + const roots = unique.filter((id) => { + const inside = new Set() + for (const other of unique) { + if (other === id) continue + for (const descendant of collectSubtreeIds(page.nodes, other)) inside.add(descendant) + } + return !inside.has(id) + }) + return { kind: 'section', roots, nodes: copySubtrees(page, roots), page } +} + +function copySubtrees(page: Page, roots: string[]): Record { + const nodes: Record = {} + for (const rootId of roots) { + for (const id of collectSubtreeIds(page.nodes, rootId)) { + const node = page.nodes[id] + if (node) nodes[id] = structuredClone(node) + } + } + for (const rootId of roots) { + const root = nodes[rootId] + if (root) root.parentId = null + } + return nodes +} + +// --------------------------------------------------------------------------- +// 2. Visual Components +// --------------------------------------------------------------------------- + +function collectComponents( + all: readonly VisualComponent[], + nodes: Record, + warnings: FragmentWarning[], +): VisualComponent[] { + const byId = new Map(all.map((vc) => [vc.id, vc])) + const out: VisualComponent[] = [] + const seen = new Set() + const queue: string[] = [] + const enqueue = (nodeMap: unknown) => + forEachVCRef(nodeMap, ({ componentId }) => { + if (!seen.has(componentId)) { + seen.add(componentId) + queue.push(componentId) + } + }) + enqueue(nodes) + while (queue.length > 0) { + const id = queue.shift()! + const vc = byId.get(id) + if (!vc) { + warnings.push({ + code: 'missing-component', + message: `A component instance references component "${id}", which the source site does not have.`, + subject: id, + }) + continue + } + const copy = structuredClone(vc) + out.push(copy) + enqueue(copy.tree.nodes) + } + return out +} + +// --------------------------------------------------------------------------- +// 9. Links +// --------------------------------------------------------------------------- + +function collectLinks( + pages: readonly Page[], + allNodes: PageNode[], + tables: FragmentTable[], + warnings: FragmentWarning[], +): FragmentLink[] { + const ids = new Set() + const scan = (text: string) => collectPageRefIds(text, ids) + for (const node of allNodes) for (const part of nodeContent(node)) forEachString(part, scan) + for (const table of tables) for (const row of table.rows ?? []) forEachString(row.cells, scan) + + const links: FragmentLink[] = [] + for (const pageId of ids) { + const page = pages.find((p) => p.id === pageId) + if (!page) { + warnings.push({ + code: 'missing-page-link', + message: `A link points at page "${pageId}", which the source site does not have; it inserts as "#".`, + subject: pageId, + }) + continue + } + links.push({ pageId, slug: page.slug }) + } + return links +} diff --git a/src/core/fragments/extractData.ts b/src/core/fragments/extractData.ts new file mode 100644 index 000000000..f75dbe9c8 --- /dev/null +++ b/src/core/fragments/extractData.ts @@ -0,0 +1,214 @@ +/** + * Data closure for `extractFragment`: the tables loops and CMS forms read, and + * every media asset the carried content, fonts and rows reference. + */ + +import type { PageNode, StyleRule } from '@core/page-tree' +import type { FontEntry } from '@core/fonts' +import type { DataField, DataRow, DataTable } from '@core/data/schemas' +import { createPathFinder, forEachString, nodeContent, ruleContent } from './references' +import type { FragmentMedia, FragmentTable, FragmentTableRow } from './schema' +import type { FragmentSource, FragmentSourceMedia } from './source' +import type { FragmentWarning } from './warnings' +import { mediaPackagePath, sha256Hex } from './mediaKeys' + +/** How `media[].src` is written (see `ExtractFragmentOptions.mediaSrc`). */ +export type MediaSrcOption = (media: { id: string; key?: string; filename: string; mimeType: string; path: string }) => string + +// --------------------------------------------------------------------------- +// 7. Tables +// --------------------------------------------------------------------------- + +const BUILT_IN_LOOP_SOURCES_WITHOUT_TABLE = new Set(['entry.field', 'site.pages', 'site.media']) + +export function collectTables( + source: FragmentSource, + allNodes: PageNode[], + warnings: FragmentWarning[], +): FragmentTable[] { + const withRows = new Set() + const schemaOnly = new Set() + for (const node of allNodes) { + if (node.moduleId === 'base.loop') { + const sourceId = typeof node.props.sourceId === 'string' ? node.props.sourceId : '' + if (sourceId === 'data.rows') { + const filters = node.props.filters + const tableId = filters && typeof filters === 'object' && !Array.isArray(filters) + ? (filters as Record).tableId + : undefined + if (typeof tableId === 'string' && tableId) withRows.add(tableId) + } else if (sourceId && !BUILT_IN_LOOP_SOURCES_WITHOUT_TABLE.has(sourceId)) { + warnings.push({ + code: 'unsupported-loop-source', + message: `A loop reads "${sourceId}", a source that only exists where its plugin is installed.`, + subject: sourceId, + }) + } + } else if (node.moduleId === 'base.form' && node.props.mode === 'cms') { + const tableId = node.props.targetTableId + if (typeof tableId === 'string' && tableId) schemaOnly.add(tableId) + } + } + + const out: FragmentTable[] = [] + for (const tableId of [...withRows, ...schemaOnly]) { + if (out.some((t) => t.id === tableId)) continue + const table = source.tables.find((t) => t.id === tableId) + if (!table) { + warnings.push({ + code: 'missing-table', + message: `A loop or form reads table "${tableId}", which the source does not include.`, + subject: tableId, + }) + continue + } + if (table.kind !== 'postType' && table.kind !== 'data') { + warnings.push({ + code: 'unsupported-table', + message: `Table "${table.name}" holds ${table.kind} rows, which a fragment does not carry.`, + subject: table.slug, + }) + continue + } + out.push(toFragmentTable(table, withRows.has(tableId) ? rowsOf(source.rows, tableId) : undefined)) + } + return out +} + +function rowsOf(rows: readonly DataRow[], tableId: string): FragmentTableRow[] { + return rows + .filter((row) => row.tableId === tableId && row.deletedAt === null) + .map((row) => ({ + id: row.id, + slug: row.slug, + status: row.status === 'published' ? 'published' : 'draft', + cells: structuredClone(row.cells), + })) +} + +function toFragmentTable(table: DataTable, rows: FragmentTableRow[] | undefined): FragmentTable { + return { + id: table.id, + slug: table.slug, + name: table.name, + kind: table.kind === 'postType' ? 'postType' : 'data', + singularLabel: table.singularLabel, + pluralLabel: table.pluralLabel, + routeBase: table.routeBase, + primaryFieldId: table.primaryFieldId, + fields: structuredClone(table.fields), + ...(rows ? { rows } : {}), + } +} + +/** Media ids a row's media cells hold (repeater items included). */ +function mediaIdsInCells(fields: DataField[], cells: Record, into: Set): void { + for (const field of fields) { + const value = cells[field.id] + if (field.type === 'media') { + if (typeof value === 'string') into.add(value) + else if (Array.isArray(value)) for (const id of value) if (typeof id === 'string') into.add(id) + } else if (field.type === 'repeater' && Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === 'object' && 'cells' in item) { + const itemCells = (item as { cells: unknown }).cells + if (itemCells && typeof itemCells === 'object') { + mediaIdsInCells(field.fields, itemCells as Record, into) + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// 8. Media +// --------------------------------------------------------------------------- + +export async function collectMedia( + source: FragmentSource, + closure: { + styleRules: StyleRule[] + allNodes: PageNode[] + tables: FragmentTable[] + fontFamilies: FontEntry[] + }, + mediaSrc: MediaSrcOption | undefined, + warnings: FragmentWarning[], +): Promise<{ media: FragmentMedia[]; mediaBytes: Map }> { + const byPath = new Map(source.media.map((m) => [m.path, m])) + const byId = new Map(source.media.map((m) => [m.id, m])) + const foundPaths = new Set() + const find = createPathFinder(byPath.keys()) + const scan = (text: string) => find(text, foundPaths) + + for (const rule of closure.styleRules) for (const part of ruleContent(rule)) forEachString(part, scan) + for (const node of closure.allNodes) for (const part of nodeContent(node)) forEachString(part, scan) + + const ids = new Set() + for (const path of foundPaths) { + const media = byPath.get(path) + if (media) ids.add(media.id) + } + for (const family of closure.fontFamilies) { + for (const file of family.files) { + if (file.mediaAssetId && byId.has(file.mediaAssetId)) ids.add(file.mediaAssetId) + else if (byPath.has(file.path)) ids.add(byPath.get(file.path)!.id) + } + } + for (const table of closure.tables) { + for (const row of table.rows ?? []) { + const cellIds = new Set() + mediaIdsInCells(table.fields, row.cells, cellIds) + for (const id of cellIds) if (byId.has(id)) ids.add(id) + forEachString(row.cells, scan) + } + } + // Paths found in row text (rich text with an inline image) join last. + for (const path of foundPaths) { + const media = byPath.get(path) + if (media) ids.add(media.id) + } + + const media: FragmentMedia[] = [] + const mediaBytes = new Map() + for (const id of ids) { + const asset = byId.get(id)! + const bytes = asset.readBytes ? await asset.readBytes() : null + let key: string | undefined + if (bytes) { + key = await sha256Hex(bytes) + mediaBytes.set(asset.id, bytes) + } else if (asset.readBytes) { + warnings.push({ + code: 'missing-media-bytes', + message: `The bytes of ${asset.filename} are not in the source; the fragment points at ${asset.path}.`, + subject: asset.path, + }) + } + media.push(toFragmentMedia(asset, key, mediaSrc)) + } + return { media, mediaBytes } +} + +function toFragmentMedia( + asset: FragmentSourceMedia, + key: string | undefined, + mediaSrc: MediaSrcOption | undefined, +): FragmentMedia { + const describe = { id: asset.id, ...(key ? { key } : {}), filename: asset.filename, mimeType: asset.mimeType, path: asset.path } + const src = mediaSrc?.(describe) + ?? (key ? mediaPackagePath(key, asset.filename, asset.mimeType) : asset.path) + return { + id: asset.id, + ...(key ? { key } : {}), + path: asset.path, + filename: asset.filename, + mimeType: asset.mimeType, + ...(asset.sizeBytes !== undefined ? { sizeBytes: asset.sizeBytes } : {}), + ...(typeof asset.width === 'number' ? { width: asset.width } : {}), + ...(typeof asset.height === 'number' ? { height: asset.height } : {}), + ...(asset.alt ? { alt: asset.alt } : {}), + src, + } +} diff --git a/src/core/fragments/extractStyles.ts b/src/core/fragments/extractStyles.ts new file mode 100644 index 000000000..2ad0621ad --- /dev/null +++ b/src/core/fragments/extractStyles.ts @@ -0,0 +1,138 @@ +/** + * Style-rule closure for `extractFragment`: framework stubs, keyframes the + * carried declarations animate with, ancestor-only selector dependencies, and + * the conditions carried rules key on. + */ + +import { + extractCssSelectorClasses, + splitCssSelectorList, + type ConditionDef, + type Page, + type StyleRule, +} from '@core/page-tree' +import type { FragmentOutsideDependency } from './schema' + +export const FRAMEWORK_ID_PREFIX = 'framework:' +const KEYFRAMES_PREFIX = '@keyframes ' + +// --------------------------------------------------------------------------- +// 3–4. Rules +// --------------------------------------------------------------------------- + +/** A framework utility, carried by name: the target regenerates its styles. */ +export function frameworkStub(rule: StyleRule): StyleRule { + const { stylePriorities: _stylePriorities, contextStylePriorities: _contextStylePriorities, ...rest } = rule + return { ...structuredClone(rest), styles: {}, contextStyles: {} } +} + +/** Carry each `@keyframes` block a carried declaration animates with. */ +export function carryKeyframes(all: Record, carried: Map): void { + const keyframes = Object.values(all).filter( + (rule) => rule.kind === 'ambient' && rule.selector.startsWith(KEYFRAMES_PREFIX), + ) + if (keyframes.length === 0) return + const animationText: string[] = [] + for (const rule of carried.values()) { + for (const bag of [rule.styles, ...Object.values(rule.contextStyles)]) { + for (const [property, value] of Object.entries(bag)) { + if (/^animation(Name)?$/.test(property) && typeof value === 'string') animationText.push(value) + } + } + } + const words = new Set(animationText.join(' ').split(/[\s,]+/)) + for (const rule of keyframes) { + const name = rule.selector.slice(KEYFRAMES_PREFIX.length).trim() + if (words.has(name) && !carried.has(rule.id)) carried.set(rule.id, structuredClone(rule)) + } +} + +/** + * Rules that style the selection through a class only its ANCESTORS carry + * (a page wrapper, the body's site class). A rule counts when it is carried + * anyway, or when the rightmost compound of one of its selectors could match + * inside the selection: it names no class (`.wrap h2`), or names a class the + * selection uses (`.dark .title`). + */ +export function findOutsideDependencies( + page: Page, + roots: string[], + rules: Record, + carried: Map, + selectionClassNames: Set, +): FragmentOutsideDependency[] { + const parentOf = new Map() + for (const node of Object.values(page.nodes)) { + for (const childId of node.children) parentOf.set(childId, node.id) + } + const ancestorClassNames = new Set() + for (const rootId of roots) { + let current = parentOf.get(rootId) + const seen = new Set() + while (current && !seen.has(current)) { + seen.add(current) + for (const classId of page.nodes[current]?.classIds ?? []) { + const name = rules[classId]?.name + if (name && !selectionClassNames.has(name)) ancestorClassNames.add(name) + } + current = parentOf.get(current) + } + } + if (ancestorClassNames.size === 0) return [] + + const out: FragmentOutsideDependency[] = [] + const seenSelectors = new Set() + for (const rule of Object.values(rules).sort((a, b) => a.order - b.order)) { + if (rule.id.startsWith(FRAMEWORK_ID_PREFIX) || seenSelectors.has(rule.selector)) continue + const deps = [...new Set( + extractCssSelectorClasses(rule.selector).map((t) => t.name).filter((name) => ancestorClassNames.has(name)), + )] + if (deps.length === 0) continue + const relevant = carried.has(rule.id) || splitCssSelectorList(rule.selector).some((part) => { + const last = extractCssSelectorClasses(lastCompound(part)).map((t) => t.name) + return last.length === 0 || last.some((name) => selectionClassNames.has(name)) + }) + if (!relevant) continue + seenSelectors.add(rule.selector) + out.push({ selector: rule.selector, classes: deps.sort() }) + } + return out +} + +/** The rightmost compound selector of one complex selector. */ +function lastCompound(selector: string): string { + let depth = 0 + let quote: string | null = null + let start = 0 + for (let index = 0; index < selector.length; index += 1) { + const char = selector[index] + if (quote) { + if (char === '\\') index += 1 + else if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") quote = char + else if (char === '(' || char === '[') depth += 1 + else if (char === ')' || char === ']') depth -= 1 + else if (depth === 0 && (char === ' ' || char === '>' || char === '+' || char === '~')) start = index + 1 + } + return selector.slice(start).trim() +} + +// --------------------------------------------------------------------------- +// 5. Conditions +// --------------------------------------------------------------------------- + +export function collectConditions( + rules: StyleRule[], + breakpointIds: string[], + defs: ConditionDef[], +): ConditionDef[] { + const viewport = new Set(breakpointIds) + const used = new Set() + for (const rule of rules) { + for (const key of Object.keys(rule.contextStyles)) if (!viewport.has(key)) used.add(key) + for (const key of Object.keys(rule.contextStylePriorities ?? {})) if (!viewport.has(key)) used.add(key) + } + return defs.filter((def) => used.has(def.id)).map((def) => structuredClone(def)) +} diff --git a/src/core/fragments/extractTokens.ts b/src/core/fragments/extractTokens.ts new file mode 100644 index 000000000..a1b5eb73b --- /dev/null +++ b/src/core/fragments/extractTokens.ts @@ -0,0 +1,132 @@ +/** + * Token closure for `extractFragment`: every `var(--x)` the carried content + * reads, classified by the framework's own generators (see `siteVariables`). + */ + +import type { PageNode, StyleRule } from '@core/page-tree' +import type { FontEntry, FontToken } from '@core/fonts' +import type { FrameworkColorToken } from '@core/framework-schema' +import { collectVarNames, forEachString, nodeContent, ruleContent } from './references' +import { indexSiteVariables, type ScaleFamily } from './siteVariables' +import type { FragmentScaleStep, SiteFragment } from './schema' +import type { FragmentSource } from './source' +import type { FragmentWarning } from './warnings' + +/** A framework scale utility (`text-xl`) a carried node uses, by name. */ +export interface FrameworkStepUse extends FragmentScaleStep { + family: ScaleFamily +} + +// --------------------------------------------------------------------------- +// 6. Tokens +// --------------------------------------------------------------------------- + +export function collectTokens( + source: FragmentSource, + rules: StyleRule[], + allNodes: PageNode[], + frameworkColorIds: Set, + frameworkSteps: FrameworkStepUse[], + warnings: FragmentWarning[], +): SiteFragment['tokens'] { + const { site } = source + const index = indexSiteVariables(site) + + const pending = new Set() + const scan = (text: string) => collectVarNames(text, pending) + for (const rule of rules) for (const part of ruleContent(rule)) forEachString(part, scan) + for (const node of allNodes) for (const part of nodeContent(node)) forEachString(part, scan) + + // Properties the fragment declares itself (`.card { --pad: 1rem }`) resolve + // wherever it lands. + const local = new Set() + for (const rule of rules) { + for (const bag of [rule.styles, ...Object.values(rule.contextStyles)]) { + for (const property of Object.keys(bag)) if (property.startsWith('--')) local.add(property) + } + } + for (const node of allNodes) { + for (const property of Object.keys(node.inlineStyles ?? {})) if (property.startsWith('--')) local.add(property) + } + + const colorIds = new Set(frameworkColorIds) + const steps: Record = { typography: [], spacing: [] } + for (const use of frameworkSteps) steps[use.family].push({ convention: use.convention, step: use.step }) + + const fontTokens = new Map() + const customProperties: Record = {} + const visited = new Set() + while (pending.size > 0) { + const [name] = pending + pending.delete(name) + if (visited.has(name)) continue + visited.add(name) + + const colorId = index.colorTokenIdByVar.get(name) + if (colorId) { + colorIds.add(colorId) + continue + } + const step = index.scaleStepByVar.get(name) + if (step) { + steps[step.family].push({ convention: step.convention, step: step.step }) + continue + } + const fontToken = index.fontTokenByVar.get(name) + if (fontToken) { + fontTokens.set(fontToken.id, structuredClone(fontToken)) + continue + } + if (local.has(name)) continue + const value = index.rootCustomProperties.get(name) + if (value !== undefined) { + customProperties[name] = value + collectVarNames(value, pending) + continue + } + warnings.push({ + code: 'unresolved-custom-property', + message: `The styles read ${name}, which the source site does not define at its root.`, + subject: name, + }) + } + + const colors: FrameworkColorToken[] = (site.settings.framework?.colors.tokens ?? []) + .filter((token) => colorIds.has(token.id)) + .map((token) => structuredClone(token)) + + const familyIds = new Set([...fontTokens.values()].flatMap((t) => (t.familyId ? [t.familyId] : []))) + // Families named literally in a carried `font-family` declaration travel too, + // so a section that never went through a token still finds its face. + const declaredFamilies = new Set() + for (const rule of rules) { + for (const bag of [rule.styles, ...Object.values(rule.contextStyles)]) { + const value = bag.fontFamily + if (typeof value === 'string') { + for (const part of value.split(',')) declaredFamilies.add(part.trim().replace(/^["']|["']$/g, '').toLowerCase()) + } + } + } + const fontFamilies: FontEntry[] = (site.settings.fonts?.items ?? []) + .filter((entry) => familyIds.has(entry.id) || declaredFamilies.has(entry.family.toLowerCase())) + .map((entry) => structuredClone(entry)) + + return { + colors, + fontTokens: [...fontTokens.values()], + fontFamilies, + typeSteps: uniqueSteps(steps.typography), + spaceSteps: uniqueSteps(steps.spacing), + customProperties, + } +} + +function uniqueSteps(steps: FragmentScaleStep[]): FragmentScaleStep[] { + const seen = new Set() + return steps.filter((s) => { + const key = `${s.convention}\u0000${s.step}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} diff --git a/src/core/fragments/index.ts b/src/core/fragments/index.ts new file mode 100644 index 000000000..d8feb86b4 --- /dev/null +++ b/src/core/fragments/index.ts @@ -0,0 +1,46 @@ +/** + * Public barrel for site fragments — the portable "piece of a site" format and + * the engine that extracts and inserts it. + * + * Everything here is headless: no React, no admin, no server. A build script + * can import this barrel by path under Bun (`src/core/fragments`), extract + * fragments from a site-transfer archive and plan inserts against a site + * document without loading the editor. + */ + +export { + SITE_FRAGMENT_FORMAT, + SITE_FRAGMENT_VERSION, + SiteFragmentSchema, + FragmentParseError, + parseSiteFragment, +} from './schema' +export type { + FragmentKind, + FragmentLink, + FragmentMedia, + FragmentOutsideDependency, + FragmentScaleStep, + FragmentTable, + FragmentTableRow, + FragmentTokens, + SiteFragment, +} from './schema' + +export type { FragmentWarning, FragmentWarningCode } from './warnings' + +export { + FragmentSourceError, + fragmentSourceFromBundle, + fragmentSourceFromBundleArchive, + fragmentSourceFromSite, +} from './source' +export type { FragmentSource, FragmentSourceMedia } from './source' + +export { FragmentExtractError, extractFragment } from './extract' +export type { ExtractFragmentOptions, ExtractFragmentResult, FragmentSelection } from './extract' + +export { FRAGMENT_PACKAGE_MANIFEST, readFragmentPackage, writeFragmentPackage } from './package' +export type { FragmentPackage } from './package' + +export { mediaPackagePath, sha256Hex } from './mediaKeys' diff --git a/src/core/fragments/mediaKeys.ts b/src/core/fragments/mediaKeys.ts new file mode 100644 index 000000000..7f95cf2f2 --- /dev/null +++ b/src/core/fragments/mediaKeys.ts @@ -0,0 +1,35 @@ +/** + * Content keys for fragment media: the SHA-256 of the bytes, and the path a + * fragment package stores those bytes under (`media/.`). + * + * Uses Web Crypto, which Bun, Node 20+ and every browser expose as + * `crypto.subtle`, so the same code hashes in the editor and in a build script. + */ + +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes as Uint8Array) + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +const EXTENSION_BY_MIME: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/avif': 'avif', + 'image/svg+xml': 'svg', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'font/woff2': 'woff2', + 'font/woff': 'woff', + 'font/ttf': 'ttf', + 'font/otf': 'otf', + 'application/pdf': 'pdf', +} + +/** `media/.` — the extension from the filename, else the MIME type. */ +export function mediaPackagePath(key: string, filename: string, mimeType: string): string { + const fromName = /\.([A-Za-z0-9]{1,8})$/.exec(filename)?.[1]?.toLowerCase() + const ext = fromName ?? EXTENSION_BY_MIME[mimeType] ?? 'bin' + return `media/${key}.${ext}` +} diff --git a/src/core/fragments/package.ts b/src/core/fragments/package.ts new file mode 100644 index 000000000..a7b9accff --- /dev/null +++ b/src/core/fragments/package.ts @@ -0,0 +1,76 @@ +/** + * Fragment packages — the file a fragment travels in. + * + * A package is a zip holding `fragment.json` plus every media file under the + * path its `media[].src` names (`media/.` as written by + * `extractFragment`). Media entries are stored uncompressed: images, video and + * fonts are already compressed, and storing keeps packing cheap. + */ + +import { strFromU8, strToU8, unzipSync, zipSync, type Zippable } from 'fflate' +import { FragmentParseError, parseSiteFragment, type FragmentMedia, type SiteFragment } from './schema' + +export const FRAGMENT_PACKAGE_MANIFEST = 'fragment.json' + +export interface FragmentPackage { + fragment: SiteFragment + /** Bytes of one media item, or null when its `src` is not inside the package. */ + readMedia(media: FragmentMedia): Uint8Array | null +} + +/** A `src` that names a file inside the package (not a URL, no traversal). */ +function isPackagePath(src: string): boolean { + return !/^[a-z][a-z0-9+.-]*:/i.test(src) && !src.startsWith('/') && !src.split('/').includes('..') +} + +/** + * Zip a fragment with its media. `mediaBytes` is keyed by fragment media id + * (what `extractFragment` returns). Every media item whose `src` is a package + * path must have bytes. + */ +export function writeFragmentPackage( + fragment: SiteFragment, + mediaBytes: ReadonlyMap, +): Uint8Array { + const entries: Zippable = { + [FRAGMENT_PACKAGE_MANIFEST]: [strToU8(JSON.stringify(fragment)), { level: 6 }], + } + for (const media of fragment.media) { + if (!isPackagePath(media.src)) continue + const bytes = mediaBytes.get(media.id) + if (!bytes) { + throw new FragmentParseError(`media.${media.id}`, `no bytes for ${media.filename} (${media.src})`) + } + entries[media.src] = [bytes, { level: 0 }] + } + return zipSync(entries) +} + +/** Unzip and validate a package. Throws `FragmentParseError` when it is not one. */ +export function readFragmentPackage(bytes: Uint8Array): FragmentPackage { + let entries: Record + try { + entries = unzipSync(bytes) + } catch (err) { + throw new FragmentParseError('package', `not a zip archive (${err instanceof Error ? err.message : String(err)})`) + } + const manifest = entries[FRAGMENT_PACKAGE_MANIFEST] + if (!manifest) throw new FragmentParseError('package', `${FRAGMENT_PACKAGE_MANIFEST} is missing`) + + let raw: unknown + try { + raw = JSON.parse(strFromU8(manifest)) + } catch { + throw new FragmentParseError(FRAGMENT_PACKAGE_MANIFEST, 'not valid JSON') + } + const fragment = parseSiteFragment(raw) + fragment.media.forEach((media, index) => { + if (isPackagePath(media.src) && !entries[media.src]) { + throw new FragmentParseError(`media.${index}.src`, `${media.src} is not in the package`) + } + }) + return { + fragment, + readMedia: (media) => (isPackagePath(media.src) ? entries[media.src] ?? null : null), + } +} diff --git a/src/core/fragments/references.ts b/src/core/fragments/references.ts new file mode 100644 index 000000000..1ad98e296 --- /dev/null +++ b/src/core/fragments/references.ts @@ -0,0 +1,157 @@ +/** + * Reference walkers shared by extract, plan and resolve. + * + * A fragment's portable references all live inside STRING values: media paths + * (`/uploads/…` in an image `src`, a CSS `url(…)`, a rich-text ``), page + * links (`cms:page:`), and custom properties (`var(--x)`). Rather than + * enumerate every prop that might hold one, the walkers visit every string in + * a node's content, a rule's declarations, or a row's cells, so a reference in + * an `htmlAttributes` bag or a slot's prop override is found the same way as + * one in `src`. + */ + +import type { PageNode, StyleRule } from '@core/page-tree' + +/** Visit every string inside a JSON-like value. */ +export function forEachString(value: unknown, visit: (text: string) => void): void { + if (typeof value === 'string') { + visit(value) + return + } + if (Array.isArray(value)) { + for (const item of value) forEachString(item, visit) + return + } + if (value && typeof value === 'object') { + for (const item of Object.values(value)) forEachString(item, visit) + } +} + +/** + * Map every string inside a JSON-like value. Returns the input reference when + * nothing changed, so callers can cheaply skip untouched objects. + */ +export function mapStrings(value: T, map: (text: string) => string): T { + if (typeof value === 'string') return map(value) as T + if (Array.isArray(value)) { + let changed = false + const next = value.map((item) => { + const mapped = mapStrings(item, map) + if (mapped !== item) changed = true + return mapped + }) + return (changed ? next : value) as T + } + if (value && typeof value === 'object') { + let changed = false + const next: Record = {} + for (const [key, item] of Object.entries(value)) { + const mapped = mapStrings(item, map) + if (mapped !== item) changed = true + next[key] = mapped + } + return (changed ? next : value) as T + } + return value +} + +/** The parts of a node that hold authored content (and so references). */ +export function nodeContent(node: PageNode): unknown[] { + return [node.props, node.breakpointOverrides, node.inlineStyles] +} + +/** Return `node` with `map` applied to every string in its content. */ +export function mapNodeStrings(node: TNode, map: (text: string) => string): TNode { + const props = mapStrings(node.props, map) + const breakpointOverrides = mapStrings(node.breakpointOverrides, map) + const inlineStyles = node.inlineStyles ? mapStrings(node.inlineStyles, map) : undefined + if ( + props === node.props + && breakpointOverrides === node.breakpointOverrides + && inlineStyles === node.inlineStyles + ) { + return node + } + return { + ...node, + props, + breakpointOverrides, + ...(inlineStyles ? { inlineStyles } : {}), + } +} + +/** The parts of a style rule that hold declarations. */ +export function ruleContent(rule: StyleRule): unknown[] { + return [rule.styles, rule.contextStyles, rule.rawCss] +} + +/** Return `rule` with `map` applied to every declaration string. */ +export function mapRuleStrings(rule: StyleRule, map: (text: string) => string): StyleRule { + const styles = mapStrings(rule.styles, map) + const contextStyles = mapStrings(rule.contextStyles, map) + const rawCss = rule.rawCss === undefined ? undefined : map(rule.rawCss) + if (styles === rule.styles && contextStyles === rule.contextStyles && rawCss === rule.rawCss) { + return rule + } + return { ...rule, styles, contextStyles, ...(rawCss !== undefined ? { rawCss } : {}) } +} + +const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g + +/** Every `--name` a CSS value reads through `var()`, fallbacks included. */ +export function collectVarNames(text: string, into: Set): void { + for (const match of text.matchAll(VAR_RE)) into.add(match[1]) +} + +const PAGE_REF_RE = /cms:page:([A-Za-z0-9_-]+)(#[^\s"'<>]*)?/g + +/** Every page id a string links to through `cms:page:`. */ +export function collectPageRefIds(text: string, into: Set): void { + for (const match of text.matchAll(PAGE_REF_RE)) into.add(match[1]) +} + +/** + * Rewrite `cms:page:` references. `resolve` returns the target page id to + * link to, or `null` for a page the target site does not have: a value that + * IS the reference becomes `#` (a safe no-op link, like an unresolvable ref at + * publish time), and a reference inside longer text (a rich-text `href`) + * becomes `#` in place. + */ +export function rewritePageRefs(text: string, resolve: (pageId: string) => string | null): string { + if (!text.includes('cms:page:')) return text + return text.replace(PAGE_REF_RE, (_match, pageId: string, fragment: string | undefined) => { + const next = resolve(pageId) + if (next === null) return '#' + return `cms:page:${next}${fragment ?? ''}` + }) +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Build a replacer for a set of literal paths. Longer paths win, so + * `/uploads/a.jpg.webp` never half-matches `/uploads/a.jpg`. + */ +export function createPathReplacer(replacements: ReadonlyMap): (text: string) => string { + if (replacements.size === 0) return (text) => text + const paths = [...replacements.keys()].sort((a, b) => b.length - a.length) + const pattern = new RegExp(paths.map(escapeRegExp).join('|'), 'g') + return (text) => { + pattern.lastIndex = 0 + if (!pattern.test(text)) return text + pattern.lastIndex = 0 + return text.replace(pattern, (match) => replacements.get(match) ?? match) + } +} + +/** Every literal path from `paths` that occurs in `text` (longest first). */ +export function createPathFinder(paths: Iterable): (text: string, into: Set) => void { + const sorted = [...new Set(paths)].filter(Boolean).sort((a, b) => b.length - a.length) + if (sorted.length === 0) return () => {} + const pattern = new RegExp(sorted.map(escapeRegExp).join('|'), 'g') + return (text, into) => { + for (const match of text.matchAll(pattern)) into.add(match[0]) + } +} diff --git a/src/core/fragments/schema.ts b/src/core/fragments/schema.ts new file mode 100644 index 000000000..631de0a01 --- /dev/null +++ b/src/core/fragments/schema.ts @@ -0,0 +1,293 @@ +/** + * Site fragment — the one portable format for "a piece of an Instatic site". + * + * A fragment 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 Visual Component + * closure, the design tokens the styles reference (with their source + * definitions), the data tables its loops and forms read, the media it shows, + * and the internal page links it contains. + * + * Every id inside a fragment is a SOURCE id: node ids, rule ids, component + * ids, table ids and media ids are whatever the source site used. The insert + * planner (`planFragmentInsert`) mints fresh ids for everything it adds, so a + * fragment never collides with the site it lands in. + * + * Media bytes are never inlined: `media[].src` says where the bytes are (a + * path inside a fragment package zip, or a URL the caller resolves), and + * `media[].path` is the exact string the fragment's nodes, rules and fonts use + * to reference that asset, so the insert can rewrite every reference to the + * uploaded copy. + * + * `parseSiteFragment` is the boundary: TypeBox for the shape, then the + * invariants JSON Schema cannot express (roots resolve, trees are acyclic, + * kind-specific payloads are present). + */ + +import { Type, type Static } from '@core/utils/typeboxHelpers' +import { compiledSafeParseValue } from '@core/utils/typeboxCompiler' +import { + ConditionDefSchema, + PageNodeSchema, + StyleRuleSchema, + assertValidNodeTree, + type PageNode, +} from '@core/page-tree' +import { VisualComponentSchema } from '@core/visual-components-schema' +import { FrameworkColorTokenSchema } from '@core/framework-schema' +import { FontEntrySchema, FontTokenSchema } from '@core/fonts' +import { DataFieldSchema } from '@core/data/schemas' + +export const SITE_FRAGMENT_FORMAT = 'instatic-fragment' +export const SITE_FRAGMENT_VERSION = 1 + +const FragmentKindSchema = Type.Union([ + Type.Literal('section'), + Type.Literal('page'), + Type.Literal('component'), +]) +export type FragmentKind = Static + +/** Free-form provenance: which library, item and build produced the fragment. */ +const FragmentOriginSchema = Type.Object({ + source: Type.Optional(Type.String()), + item: Type.Optional(Type.String()), + build: Type.Optional(Type.String()), +}) + +/** + * One step of a fluid typography or spacing scale the fragment's styles + * reference, named the way the framework names it: the group's + * `namingConvention` (`text`, `space`) plus the step label (`xl`, `3xs`). + */ +const FragmentScaleStepSchema = Type.Object({ + convention: Type.String({ minLength: 1 }), + step: Type.String({ minLength: 1 }), +}) +export type FragmentScaleStep = Static + +const FragmentTokensSchema = Type.Object({ + /** Full colour token definitions, generation flags included. */ + colors: Type.Array(FrameworkColorTokenSchema), + /** Font tokens referenced through `var(--font-*)`. */ + fontTokens: Type.Array(FontTokenSchema), + /** The font library entries those tokens bind to. */ + fontFamilies: Type.Array(FontEntrySchema), + typeSteps: Type.Array(FragmentScaleStepSchema), + spaceSteps: Type.Array(FragmentScaleStepSchema), + /** Other `--custom-properties` the styles use, valued from the source root. */ + customProperties: Type.Record(Type.String(), Type.String()), +}) +export type FragmentTokens = Static + +const FragmentTableRowSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + slug: Type.String(), + status: Type.Union([Type.Literal('draft'), Type.Literal('published')]), + cells: Type.Record(Type.String(), Type.Unknown()), +}) +export type FragmentTableRow = Static + +/** + * A data table the fragment's loops or CMS forms read. `id` is the source + * table id the fragment's nodes reference (`filters.tableId`, + * `targetTableId`). Loops carry their rows; forms carry the schema only. + */ +const FragmentTableSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + slug: Type.String({ minLength: 1 }), + name: Type.String({ minLength: 1 }), + kind: Type.Union([Type.Literal('postType'), Type.Literal('data')]), + singularLabel: Type.String(), + pluralLabel: Type.String(), + routeBase: Type.String(), + primaryFieldId: Type.String(), + fields: Type.Array(DataFieldSchema), + rows: Type.Optional(Type.Array(FragmentTableRowSchema)), +}) +export type FragmentTable = Static + +const FragmentMediaSchema = Type.Object({ + /** Source media id — what media cells of carried rows hold. */ + id: Type.String({ minLength: 1 }), + /** SHA-256 of the bytes, lowercase hex. Absent when the extractor had no bytes. */ + key: Type.Optional(Type.String({ pattern: '^[0-9a-f]{64}$' })), + /** The exact reference string the fragment content uses (`/uploads/…`). */ + path: Type.String({ minLength: 1 }), + filename: Type.String({ minLength: 1 }), + mimeType: Type.String({ minLength: 1 }), + sizeBytes: Type.Optional(Type.Number({ minimum: 0 })), + width: Type.Optional(Type.Number()), + height: Type.Optional(Type.Number()), + alt: Type.Optional(Type.String()), + /** Where the bytes come from: a path inside the package zip, or a URL. */ + src: Type.String({ minLength: 1 }), +}) +export type FragmentMedia = Static + +/** A `cms:page:` link target found in the fragment, with the source slug. */ +const FragmentLinkSchema = Type.Object({ + pageId: Type.String({ minLength: 1 }), + slug: Type.String(), +}) +export type FragmentLink = Static + +/** + * A rule outside the fragment that styled the selection through a class on + * one of its ancestors (a page wrapper, the body's site class). The fragment + * does not carry it — inserted elsewhere, that styling is gone — so the + * extractor records it and the insert report repeats it. + */ +const FragmentOutsideDependencySchema = Type.Object({ + selector: Type.String(), + /** The ancestor-only class names the selector depends on. */ + classes: Type.Array(Type.String()), +}) +export type FragmentOutsideDependency = Static + +export const SiteFragmentSchema = Type.Object({ + format: Type.Literal(SITE_FRAGMENT_FORMAT), + version: Type.Literal(SITE_FRAGMENT_VERSION), + kind: FragmentKindSchema, + origin: Type.Optional(FragmentOriginSchema), + /** + * Ordered root node ids inside `nodes`. A section has 1..n sibling roots; a + * page has exactly one, its `base.body`; a component has one + * `base.visual-component-ref` instance of `component.id`. + */ + roots: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), + nodes: Type.Record(Type.String(), PageNodeSchema), + page: Type.Optional(Type.Object({ + title: Type.String(), + slug: Type.String(), + })), + component: Type.Optional(Type.Object({ + id: Type.String({ minLength: 1 }), + name: Type.String({ minLength: 1 }), + })), + /** + * Class rules the nodes use (framework classes by name only — their styles + * are regenerated by the target), scoped module-style rules, and every + * ambient rule whose selector names a carried class. + */ + styleRules: Type.Array(StyleRuleSchema), + conditions: Type.Array(ConditionDefSchema), + /** Visual Component closure, nested references included. */ + components: Type.Array(VisualComponentSchema), + tokens: FragmentTokensSchema, + tables: Type.Array(FragmentTableSchema), + media: Type.Array(FragmentMediaSchema), + links: Type.Array(FragmentLinkSchema), + outsideDependencies: Type.Optional(Type.Array(FragmentOutsideDependencySchema)), +}) + +export type SiteFragment = Static + +/** A fragment failed validation. `path` points at the offending value. */ +export class FragmentParseError extends Error { + readonly path: string + constructor(path: string, message: string) { + super(`${path}: ${message}`) + this.name = 'FragmentParseError' + this.path = path + } +} + +const VIRTUAL_ROOT_ID = '\u0000fragment-root' + +/** + * Validate an untrusted value as a `SiteFragment`. Throws `FragmentParseError` + * on the first problem; returns the typed fragment otherwise. + */ +export function parseSiteFragment(value: unknown): SiteFragment { + const result = compiledSafeParseValue(SiteFragmentSchema, value) + if (!result.ok) { + const first = result.errors[0] + throw new FragmentParseError(first?.path || 'fragment', first?.message ?? 'Invalid fragment') + } + const fragment = result.value + assertFragmentTree(fragment) + assertKindPayload(fragment) + assertComponentTrees(fragment) + assertUniqueIds(fragment) + return fragment +} + +function assertFragmentTree(fragment: SiteFragment): void { + for (const rootId of fragment.roots) { + if (!fragment.nodes[rootId]) { + throw new FragmentParseError('roots', `root node "${rootId}" not found in nodes`) + } + } + if (new Set(fragment.roots).size !== fragment.roots.length) { + throw new FragmentParseError('roots', 'root ids must be unique') + } + // A virtual parent over every root lets the shared tree invariant check + // (key/id parity, resolvable children, no reachable cycles) run unchanged. + const virtualRoot: PageNode = { + id: VIRTUAL_ROOT_ID, + moduleId: 'base.body', + props: {}, + breakpointOverrides: {}, + children: [...fragment.roots], + classIds: [], + } + try { + assertValidNodeTree( + { nodes: { ...fragment.nodes, [VIRTUAL_ROOT_ID]: virtualRoot }, rootNodeId: VIRTUAL_ROOT_ID }, + 'nodes', + ) + } catch (err) { + throw new FragmentParseError('nodes', err instanceof Error ? err.message : String(err)) + } +} + +function assertKindPayload(fragment: SiteFragment): void { + if (fragment.kind === 'page') { + if (!fragment.page) throw new FragmentParseError('page', 'a page fragment needs page.title and page.slug') + const root = fragment.roots.length === 1 ? fragment.nodes[fragment.roots[0]] : undefined + if (!root || root.moduleId !== 'base.body') { + throw new FragmentParseError('roots', 'a page fragment has exactly one root, its base.body') + } + } + if (fragment.kind === 'component') { + const component = fragment.component + if (!component) throw new FragmentParseError('component', 'a component fragment needs component.id and component.name') + if (!fragment.components.some((vc) => vc.id === component.id)) { + throw new FragmentParseError('component.id', `component "${component.id}" is not in components`) + } + const root = fragment.roots.length === 1 ? fragment.nodes[fragment.roots[0]] : undefined + if ( + !root + || root.moduleId !== 'base.visual-component-ref' + || root.props.componentId !== component.id + ) { + throw new FragmentParseError('roots', 'a component fragment has exactly one root, an instance of the component') + } + } +} + +function assertComponentTrees(fragment: SiteFragment): void { + fragment.components.forEach((vc, index) => { + try { + assertValidNodeTree(vc.tree, `components.${index}.tree`) + } catch (err) { + throw new FragmentParseError(`components.${index}.tree`, err instanceof Error ? err.message : String(err)) + } + }) +} + +function assertUniqueIds(fragment: SiteFragment): void { + const checks: [string, string[]][] = [ + ['components', fragment.components.map((vc) => vc.id)], + ['styleRules', fragment.styleRules.map((rule) => rule.id)], + ['tables', fragment.tables.map((table) => table.id)], + ['media', fragment.media.map((media) => media.id)], + ] + for (const [path, ids] of checks) { + if (new Set(ids).size !== ids.length) { + throw new FragmentParseError(path, 'ids must be unique') + } + } +} diff --git a/src/core/fragments/siteVariables.ts b/src/core/fragments/siteVariables.ts new file mode 100644 index 000000000..f1317a5c4 --- /dev/null +++ b/src/core/fragments/siteVariables.ts @@ -0,0 +1,106 @@ +/** + * Which CSS custom properties a site defines, and where each comes from. + * + * Built from the framework's own generators (`generateFrameworkColorVariableSets`, + * `generateFrameworkTypographyVariables`, `generateFrameworkSpacingVariables`) + * and the font-token naming helper, so a `var(--primary-d-2)` is classified by + * exactly the code that emits `--primary-d-2` — never by a parallel regex + * that could drift from it. + */ + +import type { SiteSettings, StyleRule } from '@core/page-tree' +import { + generateFrameworkColorVariableSets, + generateFrameworkSpacingVariables, + generateFrameworkTypographyVariables, + resolveFrameworkPreferences, +} from '@core/framework' +import { fontTokenCssVariable, type FontToken } from '@core/fonts' + +export type ScaleFamily = 'typography' | 'spacing' + +export interface ScaleStepRef { + family: ScaleFamily + groupId: string + /** The group's `namingConvention` (`text`, `space`). */ + convention: string + step: string +} + +export interface SiteVariableIndex { + /** Every colour variable (base + generated variants, light + dark) → token id. */ + colorTokenIdByVar: Map + scaleStepByVar: Map + fontTokenByVar: Map + /** `--name` → value declared on a root-scope ambient rule (last in cascade wins). */ + rootCustomProperties: Map +} + +const ROOT_SCOPE_SELECTORS = new Set([':root', 'html', 'body']) + +/** + * A selector that targets only the document root: `:root`, `html`, `body`, or + * a comma group of those. Qualified selectors (`:root.theme-alt`) are not root + * scope — their variables apply conditionally. + */ +export function isRootScopeSelector(selector: string): boolean { + const parts = selector.split(',').map((part) => part.trim().toLowerCase()) + return parts.length > 0 && parts.every((part) => ROOT_SCOPE_SELECTORS.has(part)) +} + +export function indexSiteVariables(site: { + settings: SiteSettings + styleRules: Record +}): SiteVariableIndex { + const framework = site.settings.framework + const colorTokenIdByVar = new Map() + const colorSets = generateFrameworkColorVariableSets(framework?.colors) + for (const variable of [...colorSets.light, ...colorSets.dark]) { + colorTokenIdByVar.set(variable.name, variable.tokenId) + } + + const scaleStepByVar = new Map() + const preferences = resolveFrameworkPreferences(framework?.preferences) + const typographyConventions = new Map( + (framework?.typography?.groups ?? []).map((group) => [group.id, group.namingConvention]), + ) + for (const variable of generateFrameworkTypographyVariables(framework?.typography, preferences)) { + scaleStepByVar.set(variable.name, { + family: 'typography', + groupId: variable.groupId, + convention: typographyConventions.get(variable.groupId) ?? '', + step: variable.step, + }) + } + const spacingConventions = new Map( + (framework?.spacing?.groups ?? []).map((group) => [group.id, group.namingConvention]), + ) + for (const variable of generateFrameworkSpacingVariables(framework?.spacing, preferences)) { + scaleStepByVar.set(variable.name, { + family: 'spacing', + groupId: variable.groupId, + convention: spacingConventions.get(variable.groupId) ?? '', + step: variable.step, + }) + } + + const fontTokenByVar = new Map() + for (const token of site.settings.fonts?.tokens ?? []) { + const name = fontTokenCssVariable(token.variable) + if (name) fontTokenByVar.set(name, token) + } + + const rootCustomProperties = new Map() + const rootRules = Object.values(site.styleRules) + .filter((rule) => rule.kind === 'ambient' && isRootScopeSelector(rule.selector)) + .sort((a, b) => a.order - b.order) + for (const rule of rootRules) { + for (const [property, value] of Object.entries(rule.styles)) { + if (property.startsWith('--') && typeof value === 'string') { + rootCustomProperties.set(property, value) + } + } + } + + return { colorTokenIdByVar, scaleStepByVar, fontTokenByVar, rootCustomProperties } +} diff --git a/src/core/fragments/source.ts b/src/core/fragments/source.ts new file mode 100644 index 000000000..4ffcccf2d --- /dev/null +++ b/src/core/fragments/source.ts @@ -0,0 +1,166 @@ +/** + * Fragment sources — the one read model `extractFragment` works on, built + * either from a site-transfer bundle (the archive the export endpoint writes, + * or its in-memory JSON form) or from a live editor `SiteDocument`. + * + * Both shapes reduce to: the assembled `SiteDocument` (shell + pages + + * Visual Components), the user data tables and their rows, and the media + * library — each media item with the exact public path content uses to + * reference it and, when the source has them, a way to read its bytes. + */ + +import { unzipSync, strFromU8 } from 'fflate' +import { reindexNodeParents, type SiteDocument } from '@core/page-tree' +import type { DataRow, DataTable } from '@core/data/schemas' +import { pageFromRow } from '@core/data/pageFromRow' +import { visualComponentFromRow } from '@core/data/componentFromRow' +import { + BUNDLE_ARCHIVE_MANIFEST_PATH, + SiteBundleArchiveManifestSchema, + mediaArchivePath, + type SiteBundleArchiveManifest, +} from '@core/data/bundleArchive' +import type { MediaAssetMetadata, SiteBundle } from '@core/data/bundleSchema' +import { safeParseJson } from '@core/utils/jsonValidate' + +export interface FragmentSourceMedia { + id: string + /** The public path the site's content uses to reference this asset. */ + path: string + filename: string + mimeType: string + sizeBytes?: number + width?: number | null + height?: number | null + alt?: string + /** Read the original bytes. Absent when the source cannot (a live site). */ + readBytes?: () => Uint8Array | null | Promise +} + +export interface FragmentSource { + site: SiteDocument + /** Every data table the source has (system tables included). */ + tables: readonly DataTable[] + /** Rows of the non-editor tables (posts, post types, data tables). */ + rows: readonly DataRow[] + media: readonly FragmentSourceMedia[] +} + +/** The source is missing something the extraction cannot do without. */ +export class FragmentSourceError extends Error { + constructor(message: string) { + super(message) + this.name = 'FragmentSourceError' + } +} + +/** + * A live site. `site` is the editor's in-memory document; tables, rows and the + * media library come from the CMS API (the editor store holds none of them). + */ +export function fragmentSourceFromSite( + site: SiteDocument, + extras: { + tables?: readonly DataTable[] + rows?: readonly DataRow[] + media?: readonly FragmentSourceMedia[] + } = {}, +): FragmentSource { + return { + site, + tables: extras.tables ?? [], + rows: extras.rows ?? [], + media: extras.media ?? [], + } +} + +type BundleLike = SiteBundleArchiveManifest | SiteBundle + +/** + * A site-transfer bundle: either the archive manifest (`.instatic/site-bundle.json`, + * media bytes stored beside it) or the in-memory JSON form with embedded + * `bytesBase64`. `readMediaBytes` supplies archive bytes by `storagePath`; + * embedded bytes are read directly. + */ +export function fragmentSourceFromBundle( + bundle: BundleLike, + options: { readMediaBytes?: (storagePath: string) => Uint8Array | null } = {}, +): FragmentSource { + if (!bundle.site) { + throw new FragmentSourceError('The bundle has no site shell (it was exported without "includeSite").') + } + const pageTableIds = new Set(bundle.tables.filter((t) => t.kind === 'page').map((t) => t.id)) + const componentTableIds = new Set(bundle.tables.filter((t) => t.kind === 'component').map((t) => t.id)) + const editorTableIds = new Set( + bundle.tables.filter((t) => t.kind === 'page' || t.kind === 'component' || t.kind === 'layout').map((t) => t.id), + ) + + const live = bundle.rows.filter((row) => row.deletedAt === null) + const pages = live.filter((row) => pageTableIds.has(row.tableId)).map((row) => { + const page = pageFromRow(row) + reindexNodeParents(page.nodes) + return page + }) + const visualComponents = live + .filter((row) => componentTableIds.has(row.tableId)) + .flatMap((row) => { + const vc = visualComponentFromRow(row) + return vc ? [vc] : [] + }) + + const site: SiteDocument = { ...bundle.site, pages, visualComponents, layouts: [] } + const media = (bundle.media ?? []).map((asset): FragmentSourceMedia => ({ + id: asset.id, + path: `/uploads/${asset.storagePath}`, + filename: asset.filename, + mimeType: asset.mimeType, + sizeBytes: asset.sizeBytes, + width: asset.width, + height: asset.height, + ...(asset.altText ? { alt: asset.altText } : {}), + readBytes: () => readBundleMediaBytes(asset, options.readMediaBytes), + })) + + return { + site, + tables: bundle.tables, + rows: live.filter((row) => !editorTableIds.has(row.tableId)), + media, + } +} + +function readBundleMediaBytes( + asset: MediaAssetMetadata | (MediaAssetMetadata & { bytesBase64: string }), + readMediaBytes: ((storagePath: string) => Uint8Array | null) | undefined, +): Uint8Array | null { + if ('bytesBase64' in asset && typeof asset.bytesBase64 === 'string') { + return decodeBase64(asset.bytesBase64) + } + return readMediaBytes?.(asset.storagePath) ?? null +} + +function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +/** + * A site-transfer archive (`.zip` from Export site): the manifest is validated + * at this boundary and media bytes are read from `media/`. + */ +export function fragmentSourceFromBundleArchive(archive: Uint8Array): FragmentSource { + const entries = unzipSync(archive) + const manifestBytes = entries[BUNDLE_ARCHIVE_MANIFEST_PATH] + if (!manifestBytes) { + throw new FragmentSourceError(`Not a site-transfer archive: ${BUNDLE_ARCHIVE_MANIFEST_PATH} is missing.`) + } + const parsed = safeParseJson(strFromU8(manifestBytes), SiteBundleArchiveManifestSchema) + if (!parsed.ok) { + throw new FragmentSourceError(`The archive manifest is invalid: ${parsed.error.message}`) + } + return fragmentSourceFromBundle(parsed.value, { + readMediaBytes: (storagePath) => entries[mediaArchivePath(storagePath)] ?? null, + }) +} diff --git a/src/core/fragments/warnings.ts b/src/core/fragments/warnings.ts new file mode 100644 index 000000000..60f873ad5 --- /dev/null +++ b/src/core/fragments/warnings.ts @@ -0,0 +1,35 @@ +/** + * Notes the fragment engine raises while extracting, planning or committing. + * None of them stop the operation — anything that must stop it throws a typed + * error instead (`FragmentExtractError`, `FragmentInsertError`, + * `FragmentCommitError`). Each note is something the insert report shows the + * user so nothing changes silently. + */ +export type FragmentWarningCode = + // extract + | 'unresolved-custom-property' + | 'missing-component' + | 'missing-table' + | 'unsupported-table' + | 'unsupported-loop-source' + | 'missing-page-link' + | 'missing-media-bytes' + // plan + | 'framework-class-dropped' + | 'color-variant-missing' + | 'missing-scale-group' + | 'scale-step-not-added' + | 'missing-context' + | 'component-ref-dropped' + | 'relation-field-dropped' + | 'field-type-mismatch' + | 'link-unresolved' + // commit + | 'font-install-failed' + +export interface FragmentWarning { + code: FragmentWarningCode + message: string + /** The name, id or path the note is about, for grouping in a report. */ + subject?: string +} diff --git a/src/core/framework-schema/schemas.ts b/src/core/framework-schema/schemas.ts index 8a5c22efa..8a380468b 100644 --- a/src/core/framework-schema/schemas.ts +++ b/src/core/framework-schema/schemas.ts @@ -116,7 +116,7 @@ const FrameworkColorVariantOptionsSchema = withFallback( { enabled: true, count: 4 }, ) -const FrameworkColorTokenSchema = Type.Object({ +export const FrameworkColorTokenSchema = Type.Object({ id: Type.String(), /** * Free-form category label. Empty string means "uncategorized". diff --git a/src/core/page-tree/index.ts b/src/core/page-tree/index.ts index da462b2de..b4f6021ed 100644 --- a/src/core/page-tree/index.ts +++ b/src/core/page-tree/index.ts @@ -72,7 +72,7 @@ export { DEFAULT_BREAKPOINTS, breakpointMediaQuery, defaultBreakpointMediaQuery export { DEFAULT_SITE_SETTINGS, createNewSiteSettings } from './siteSettings' // Condition helpers -export { conditionId, conditionLabel, makeConditionDef, parseConditions } from './condition' +export { ConditionDefSchema, conditionId, conditionLabel, makeConditionDef, parseConditions } from './condition' // Tolerant parsers — boundary helpers for persisted data. export { parseBreakpoint } from './breakpoint' @@ -110,6 +110,7 @@ export { pagePublicPath, isHomePage, findHomePage, + uniquePageSlug, } from './slugs' export { makePageRef, @@ -161,6 +162,9 @@ export { cloneNodeWithRemap } from './cloneNode' export { cloneScopedClassesForNodeMap } from './scopedClassClone' +export { createStyleRuleOrderAllocator, indexStyleRulesByName } from './styleRuleRegistry' +export type { StyleRuleOrderAllocator } from './styleRuleRegistry' + export { getNode, getNodeOrThrow, diff --git a/src/core/page-tree/styleRuleRegistry.ts b/src/core/page-tree/styleRuleRegistry.ts new file mode 100644 index 000000000..e41a42e61 --- /dev/null +++ b/src/core/page-tree/styleRuleRegistry.ts @@ -0,0 +1,43 @@ +/** + * Style rule registry helpers shared by every flow that merges foreign rules + * into a site: HTML paste (`insertImportedNodes`), the whole-site import + * (`mutateAllPagesAndSite`), and the fragment insert planner + * (`@core/fragments`). + * + * Pure: operates on a plain `Record` (or a Mutative draft of + * one) and never touches editor state. + */ + +import type { StyleRule } from './styleRule' + +export type StyleRuleOrderAllocator = () => number + +/** + * Allocate monotonically increasing cascade positions for one merge + * 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 + * duplicates only occur in corrupted data — first-wins is a defensive tiebreak). + */ +export function indexStyleRulesByName(rules: Record): Map { + const byName = new Map() + for (const cls of Object.values(rules)) { + if (!byName.has(cls.name)) byName.set(cls.name, cls.id) + } + return byName +} From 3615b8f39767e94bf3e17327d10885641ea6ff77 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:36:01 +0200 Subject: [PATCH 02/13] feat(fragments): plan fragment inserts with the site always winning planFragmentInsert decides everything an insert adds without touching the site: fresh node ids, classes and components reused by name, ambient rules and conditions only when missing, colours added with their full definitions, scale steps slotted into the site's own groups without moving the base, tables matched by slug, links remapped by slug or turned into "#", and the same outlet and component cycle guards saved layouts use. The report lists every addition so nothing changes silently. --- src/__tests__/fragments/plan.test.ts | 302 +++++++++++ .../framework/insertScaleStep.test.ts | 26 + src/core/fragments/extractData.ts | 24 +- src/core/fragments/index.ts | 16 + src/core/fragments/plan.ts | 480 ++++++++++++++++++ src/core/fragments/planData.ts | 157 ++++++ src/core/fragments/planTokens.ts | 263 ++++++++++ src/core/fragments/planTypes.ts | 136 +++++ src/core/fragments/references.ts | 21 + src/core/framework/scale.ts | 37 ++ 10 files changed, 1440 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/fragments/plan.test.ts create mode 100644 src/__tests__/framework/insertScaleStep.test.ts create mode 100644 src/core/fragments/plan.ts create mode 100644 src/core/fragments/planData.ts create mode 100644 src/core/fragments/planTokens.ts create mode 100644 src/core/fragments/planTypes.ts diff --git a/src/__tests__/fragments/plan.test.ts b/src/__tests__/fragments/plan.test.ts new file mode 100644 index 000000000..c08b5b0c9 --- /dev/null +++ b/src/__tests__/fragments/plan.test.ts @@ -0,0 +1,302 @@ +/** + * planFragmentInsert — every merge rule, against a populated and an empty site. + */ + +import { describe, expect, it } from 'bun:test' +import { + FRAGMENT_PROPERTIES_ORIGIN, + FragmentInsertError, + extractFragment, + fragmentSourceFromSite, + planFragmentInsert, + type FragmentInsertPlan, + type SiteFragment, +} from '@core/fragments' +import { generateFrameworkUtilityClasses } from '@core/framework' +import type { PageNode } from '@core/page-tree' +import { makeNode, makePage, makeSite } from '../fixtures' +import { + SOURCE_ROWS, + SOURCE_TABLES, + TARGET_TABLES, + makeSourceSite, + makeTargetSite, + sourceMediaWithBytes, +} from './fixtures' + +async function sectionFragment(): Promise { + const source = fragmentSourceFromSite(makeSourceSite(), { + tables: SOURCE_TABLES, + rows: SOURCE_ROWS, + media: sourceMediaWithBytes(), + }) + return (await extractFragment(source, { pageId: 'page-home', rootNodeIds: ['hero'] })).fragment +} + +async function planIntoTarget(): Promise<{ plan: FragmentInsertPlan; target: ReturnType }> { + const target = makeTargetSite() + const plan = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + await sectionFragment(), + ) + 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', async () => { + const { plan } = await planIntoTarget() + const fragment = await sectionFragment() + 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", async () => { + const { plan, target } = await 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', async () => { + const { plan, target } = await 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', async () => { + const { plan } = await 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', async () => { + const { plan } = await 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', async () => { + const { plan, target } = await 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', async () => { + const { plan } = await 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", async () => { + const { plan } = await 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", async () => { + const { plan } = await 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', async () => { + const { plan } = await 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('puts root custom properties the site lacks into one engine-owned :root rule', async () => { + const { plan } = await planIntoTarget() + expect(plan.report.customProperties).toEqual({ added: ['--shadow'], kept: ['--radius'] }) + expect(plan.customProperties?.create).toBe(true) + expect(plan.customProperties?.properties).toEqual({ '--shadow': '0 0 4px var(--primary-50)' }) + }) + + it('reuses the engine-owned :root rule on a second insert', async () => { + 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 = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + await sectionFragment(), + ) + expect(plan.customProperties).toEqual({ ruleId: 'owned', create: false, 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", async () => { + const { plan } = await 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', async () => { + const target = makeTargetSite() + target.visualComponents = [] + const plan = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + await sectionFragment(), + ) + 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', async () => { + const { plan } = await 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 #', async () => { + const { plan } = await 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 plan2 = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + await sectionFragment(), + ) + expect(nodeByModule(plan2, 'base.link').props.href).toBe('#') + expect(plan2.report.links.unresolved).toEqual(['about']) + }) + + it('plans only the media the inserted content uses', async () => { + const { plan } = await 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', async () => { + const { plan } = await 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', async () => { + const empty = makeSite({ pages: [makePage({ id: 'e-home' })] }) + const plan = planFragmentInsert( + { site: empty, tables: [], document: { kind: 'page', pageId: 'e-home' } }, + await sectionFragment(), + ) + 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 } = await extractFragment( + fragmentSourceFromSite(source, { tables: SOURCE_TABLES, rows: SOURCE_ROWS, media: sourceMediaWithBytes() }), + { pageId: 'page-home' }, + ) + const plan = planFragmentInsert({ site: makeTargetSite(), tables: TARGET_TABLES }, fragment) + 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', async () => { + const fragment = await sectionFragment() + fragment.nodes.outlet = makeNode({ id: 'outlet', moduleId: 'base.outlet' }) + fragment.nodes.hero.children.push('outlet') + const target = makeTargetSite() + target.pages[0].nodes['t-outlet'] = makeNode({ id: 't-outlet', moduleId: 'base.outlet' }) + expect(() => planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + fragment, + )).toThrow(FragmentInsertError) + }) + + it('refuses to put a component inside itself', async () => { + const target = makeTargetSite() + let caught: unknown + try { + planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'visualComponent', vcId: 't-vc-card' } }, + await sectionFragment(), + ) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(FragmentInsertError) + expect((caught as FragmentInsertError).code).toBe('component-cycle') + }) + + it('needs a document for a section', async () => { + expect(() => planFragmentInsert({ site: makeTargetSite(), tables: [] }, structuredClone(sectionFragmentSync))).toThrow(FragmentInsertError) + }) +}) + +const sectionFragmentSync = await sectionFragment() 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/core/fragments/extractData.ts b/src/core/fragments/extractData.ts index f75dbe9c8..711c91d15 100644 --- a/src/core/fragments/extractData.ts +++ b/src/core/fragments/extractData.ts @@ -5,8 +5,8 @@ import type { PageNode, StyleRule } from '@core/page-tree' import type { FontEntry } from '@core/fonts' -import type { DataField, DataRow, DataTable } from '@core/data/schemas' -import { createPathFinder, forEachString, nodeContent, ruleContent } from './references' +import type { DataRow, DataTable } from '@core/data/schemas' +import { createPathFinder, forEachString, mediaIdsInCells, nodeContent, ruleContent } from './references' import type { FragmentMedia, FragmentTable, FragmentTableRow } from './schema' import type { FragmentSource, FragmentSourceMedia } from './source' import type { FragmentWarning } from './warnings' @@ -101,26 +101,6 @@ function toFragmentTable(table: DataTable, rows: FragmentTableRow[] | undefined) } } -/** Media ids a row's media cells hold (repeater items included). */ -function mediaIdsInCells(fields: DataField[], cells: Record, into: Set): void { - for (const field of fields) { - const value = cells[field.id] - if (field.type === 'media') { - if (typeof value === 'string') into.add(value) - else if (Array.isArray(value)) for (const id of value) if (typeof id === 'string') into.add(id) - } else if (field.type === 'repeater' && Array.isArray(value)) { - for (const item of value) { - if (item && typeof item === 'object' && 'cells' in item) { - const itemCells = (item as { cells: unknown }).cells - if (itemCells && typeof itemCells === 'object') { - mediaIdsInCells(field.fields, itemCells as Record, into) - } - } - } - } - } -} - // --------------------------------------------------------------------------- // 8. Media // --------------------------------------------------------------------------- diff --git a/src/core/fragments/index.ts b/src/core/fragments/index.ts index d8feb86b4..ca9e4ef38 100644 --- a/src/core/fragments/index.ts +++ b/src/core/fragments/index.ts @@ -44,3 +44,19 @@ export { FRAGMENT_PACKAGE_MANIFEST, readFragmentPackage, writeFragmentPackage } export type { FragmentPackage } from './package' export { mediaPackagePath, sha256Hex } from './mediaKeys' + +export { FragmentInsertError, planFragmentInsert } from './plan' +export type { FragmentInsertErrorCode } from './plan' +export { FRAGMENT_PROPERTIES_ORIGIN } from './planTokens' +export type { + FragmentInsertDocument, + FragmentInsertPlan, + FragmentInsertReport, + FragmentInsertTarget, + PlannedCustomProperties, + PlannedFontFamily, + PlannedScaleSteps, + PlannedTable, + PlannedTableCreate, + PlannedTableReuse, +} from './planTypes' diff --git a/src/core/fragments/plan.ts b/src/core/fragments/plan.ts new file mode 100644 index 000000000..e104731e3 --- /dev/null +++ b/src/core/fragments/plan.ts @@ -0,0 +1,480 @@ +/** + * planFragmentInsert — decide everything an insert will add to a site, and + * report it, without changing anything. Pure: no I/O, no store access. + * + * The site always wins (decided by the product owner): + * + * - Node ids: all new. + * - Classes match by NAME. The site's class is reused as is, even when its + * styles differ; a class the site lacks is added with a fresh id at the + * end of the cascade, in the fragment's relative order. Scoped + * module-style rules are cloned onto the new node ids. Framework + * utilities match by name against the site's framework AFTER the token + * merge (so a colour the insert adds brings its utilities), and are + * dropped when the site does not generate them. + * - Ambient rules match by exact selector; the site's rule wins. + * - Conditions match by id. + * - Tokens: see `planTokens.ts`. Tables: see `planData.ts`. + * - Visual Components match by name (the storage slug); the site's component + * wins and instances point at it. A missing one is added with fresh ids. + * - Links: `cms:page:` to a page whose slug the site has is remapped to + * that page; any other becomes `#` and is reported. + * - A fragment never adds a page unless it IS a page. + * - The one-outlet-per-document invariant and the component cycle guard + * hold exactly as they do for saved layouts. + */ + +import { nanoid } from 'nanoid' +import { + cloneNodeWithRemap, + collectSubtreeIds, + createStyleRuleOrderAllocator, + extractCssSelectorClasses, + reindexNodeParents, + removeNodeSubtrees, + uniquePageSlug, + type NodeTree, + type PageNode, + type SiteDocument, + type StyleRule, +} from '@core/page-tree' +import { generateFrameworkUtilityClasses } from '@core/framework' +import { firstOutletId, treeHasOutlet } from '@core/templates' +import { forEachVCRef, vcSlugFromName, wouldCreateCycle, type VisualComponent } from '@core/visualComponents' +import { mapNodeStrings, rewritePageRefs } from './references' +import { planCustomProperties, planTokens, type TokenPlan } from './planTokens' +import { planTables, referencedMedia } from './planData' +import type { SiteFragment } from './schema' +import type { + FragmentInsertPlan, + FragmentInsertReport, + FragmentInsertTarget, +} from './planTypes' +import type { FragmentWarning } from './warnings' + +export type FragmentInsertErrorCode = + | 'no-document' + | 'document-not-found' + | 'outlet-conflict' + | 'component-cycle' + +/** The insert cannot happen here. `code` lets the UI say why. */ +export class FragmentInsertError extends Error { + readonly code: FragmentInsertErrorCode + constructor(code: FragmentInsertErrorCode, message: string) { + super(message) + this.name = 'FragmentInsertError' + this.code = code + } +} + +const FRAMEWORK_ID_PREFIX = 'framework:' +const VC_REF = 'base.visual-component-ref' + +export function planFragmentInsert(target: FragmentInsertTarget, fragment: SiteFragment): FragmentInsertPlan { + const { site } = target + const warnings: FragmentWarning[] = [] + const report = emptyReport(fragment) + const hostTree = resolveHostTree(target, fragment) + + // ── Tokens first: framework utilities resolve against the merged settings ── + const tokens = planTokens(site, fragment, report, warnings) + const allocateOrder = createStyleRuleOrderAllocator(site.styleRules) + const customProperties = planCustomProperties(site, fragment, tokens, allocateOrder, report, warnings) + + // ── Visual Components: reuse by name, mint ids for the rest ─────────────── + const { vcIdMap, addedVcs } = planComponents(site, fragment, report) + + // ── Node ids for everything that lands: placed nodes + added trees ───────── + const nodeIdMap = new Map() + for (const rootId of fragment.roots) { + for (const id of collectSubtreeIds(fragment.nodes, rootId)) nodeIdMap.set(id, nanoid()) + } + const vcNodeIdMaps = new Map>() + for (const vc of addedVcs) { + const map = new Map() + for (const id of Object.keys(vc.tree.nodes)) map.set(id, nanoid()) + vcNodeIdMaps.set(vc.id, map) + } + const scopeNodeId = (id: string) => + nodeIdMap.get(id) ?? [...vcNodeIdMaps.values()].find((map) => map.has(id))?.get(id) + + // ── Rules: only what the landing nodes and added components use ─────────── + const usedClassIds = new Set() + for (const id of nodeIdMap.keys()) for (const classId of fragment.nodes[id]?.classIds ?? []) usedClassIds.add(classId) + for (const vc of addedVcs) { + for (const classId of vc.classIds) usedClassIds.add(classId) + for (const node of Object.values(vc.tree.nodes)) for (const classId of node.classIds) usedClassIds.add(classId) + } + const { styleRules, classIdMap } = planRules(site, fragment, usedClassIds, tokens, allocateOrder, scopeNodeId, report, warnings) + const classIdRemap = (id: string) => classIdMap.get(id) ?? null + + // ── Conditions ───────────────────────────────────────────────────────────── + const conditionIds = new Set((site.conditions ?? []).map((c) => c.id)) + const conditions = fragment.conditions.filter((c) => !conditionIds.has(c.id)).map((c) => structuredClone(c)) + for (const c of conditions) report.conditions.added.push(c.label || c.id) + warnMissingContexts(site, [...conditionIds, ...conditions.map((c) => c.id)], styleRules, fragment, warnings) + + // ── Tables ───────────────────────────────────────────────────────────────── + const { tables, tableIdMap } = planTables(target, fragment, report, warnings) + + // ── Page (kind 'page' only) ──────────────────────────────────────────────── + const page = fragment.kind === 'page' && fragment.page + ? { id: nanoid(), title: fragment.page.title, slug: uniquePageSlug(fragment.page.slug, site.pages) } + : undefined + if (page) report.page = { title: page.title, slug: page.slug } + + // ── Content rewrites shared by placed nodes and added component trees ────── + const resolveLink = linkResolver(site, fragment, page?.id, report) + const rewrite = (node: PageNode): PageNode => { + let next = mapNodeStrings(node, (text) => rewritePageRefs(text, resolveLink)) + next = remapTableRefs(next, tableIdMap) + if (next.moduleId === VC_REF && typeof next.props.componentId === 'string') { + const mapped = vcIdMap.get(next.props.componentId) + if (mapped && mapped !== next.props.componentId) next = { ...next, props: { ...next.props, componentId: mapped } } + } + return next + } + + // Placed nodes. + const nodes: Record = {} + for (const [oldId, newId] of nodeIdMap) { + const node = fragment.nodes[oldId] + if (!node) continue + nodes[newId] = rewrite(cloneNodeWithRemap(node, { newId, idMap: nodeIdMap, classIdRemap })) + } + const roots = fragment.roots.map((id) => nodeIdMap.get(id)!).filter(Boolean) + reindexNodeParents(nodes) + dropDanglingRefs(nodes, vcIdMap, site, warnings) + const placedRoots = roots.filter((id) => nodes[id]) + + // Added component trees. + const components: VisualComponent[] = addedVcs.map((vc) => { + const idMap = vcNodeIdMaps.get(vc.id)! + const treeNodes: Record = {} + for (const [oldId, newId] of idMap) { + treeNodes[newId] = rewrite(cloneNodeWithRemap(vc.tree.nodes[oldId] as PageNode, { newId, idMap, classIdRemap })) + } + reindexNodeParents(treeNodes) + dropDanglingRefs(treeNodes, vcIdMap, site, warnings) + return { + ...structuredClone(vc), + id: vcIdMap.get(vc.id)!, + tree: { nodes: treeNodes, rootNodeId: idMap.get(vc.tree.rootNodeId)! }, + classIds: vc.classIds.flatMap((id) => { + const mapped = classIdRemap(id) + return mapped ? [mapped] : [] + }), + createdAt: Date.now(), + } + }) + + // ── Guards (same rules as inserting a saved layout) ──────────────────────── + if (hostTree && firstOutletId(nodes) !== null && treeHasOutlet(hostTree)) { + throw new FragmentInsertError('outlet-conflict', 'This fragment includes a content outlet and this document already has one.') + } + if (target.document?.kind === 'visualComponent') { + const roster = [...site.visualComponents, ...components] + for (const node of Object.values(nodes)) { + if (node.moduleId !== VC_REF || typeof node.props.componentId !== 'string') continue + if (wouldCreateCycle(roster, target.document.vcId, node.props.componentId)) { + throw new FragmentInsertError('component-cycle', 'This fragment contains a component that references the component being edited.') + } + } + } + + report.nodes = Object.keys(nodes).length + const media = referencedMedia(fragment, { + nodes: Object.values(nodes), + components, + rules: [...styleRules], + fontFamilies: tokens.fontFamilies, + tables, + }) + report.media.total = media.length + report.warnings = warnings + + return { + kind: fragment.kind, + ...(target.document && fragment.kind !== 'page' ? { document: target.document } : {}), + ...(page ? { page } : {}), + roots: placedRoots, + nodes, + styleRules, + customProperties, + conditions, + colorTokens: tokens.colorTokens, + scaleSteps: tokens.scaleSteps, + fontFamilies: tokens.fontFamilies, + fontTokens: tokens.fontTokens, + components, + tables, + media, + report, + } +} + +/** + * Walk component references from the placed nodes. A component the site + * already has (same name) is reused and NOT descended into: its nested + * components are the site's business. Only components reached through added + * ones are added, so reusing `Card` never drags in the fragment's `Badge`. + */ +function planComponents( + site: SiteDocument, + fragment: SiteFragment, + report: FragmentInsertReport, +): { vcIdMap: Map; addedVcs: VisualComponent[] } { + const siteVcBySlug = new Map(site.visualComponents.map((vc) => [vcSlugFromName(vc.name), vc])) + const fragmentVcs = new Map(fragment.components.map((vc) => [vc.id, vc])) + const vcIdMap = new Map() + const addedVcs: VisualComponent[] = [] + const queue: string[] = [] + const placed: Record = {} + for (const rootId of fragment.roots) { + for (const id of collectSubtreeIds(fragment.nodes, rootId)) placed[id] = fragment.nodes[id] + } + forEachVCRef(placed, ({ componentId }) => queue.push(componentId)) + while (queue.length > 0) { + const id = queue.shift()! + if (vcIdMap.has(id)) continue + const vc = fragmentVcs.get(id) + if (!vc) continue + const match = siteVcBySlug.get(vcSlugFromName(vc.name)) + if (match) { + vcIdMap.set(vc.id, match.id) + report.components.reused.push(match.name) + continue + } + vcIdMap.set(vc.id, nanoid()) + addedVcs.push(vc) + report.components.added.push(vc.name) + forEachVCRef(vc.tree.nodes, ({ componentId }) => queue.push(componentId)) + } + return { vcIdMap, addedVcs } +} + +function emptyReport(fragment: SiteFragment): FragmentInsertReport { + return { + kind: fragment.kind, + nodes: 0, + colors: { added: [], kept: [] }, + fonts: { tokensAdded: [], tokensKept: [], familiesAdded: [], familiesKept: [] }, + steps: { added: [] }, + customProperties: { added: [], kept: [] }, + rules: { added: [], reused: [], ambientAdded: [], ambientKept: [] }, + conditions: { added: [] }, + components: { added: [], reused: [] }, + tables: { created: [], reused: [], fieldsAdded: [], rows: 0 }, + media: { total: 0, uploaded: 0, reused: 0 }, + links: { remapped: [], unresolved: [] }, + outsideDependencies: fragment.outsideDependencies ?? [], + warnings: [], + } +} + +function resolveHostTree(target: FragmentInsertTarget, fragment: SiteFragment): NodeTree | null { + if (fragment.kind === 'page') return null + const document = target.document + if (!document) { + throw new FragmentInsertError('no-document', 'Open a page or component to insert this fragment into.') + } + if (document.kind === 'page') { + const page = target.site.pages.find((p) => p.id === document.pageId) + if (!page) throw new FragmentInsertError('document-not-found', `Page "${document.pageId}" not found`) + return page + } + const vc = target.site.visualComponents.find((v) => v.id === document.vcId) + if (!vc) throw new FragmentInsertError('document-not-found', `Component "${document.vcId}" not found`) + return vc.tree as NodeTree +} + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +function planRules( + site: SiteDocument, + fragment: SiteFragment, + usedClassIds: Set, + tokens: TokenPlan, + allocateOrder: () => number, + scopeNodeId: (id: string) => string | undefined, + report: FragmentInsertReport, + warnings: FragmentWarning[], +): { styleRules: StyleRule[]; classIdMap: Map } { + const frameworkByName = new Map() + for (const cls of Object.values(generateFrameworkUtilityClasses(tokens.nextFramework))) { + frameworkByName.set(cls.name, cls.id) + } + const classByName = new Map() + const ambientSelectors = new Set() + for (const rule of Object.values(site.styleRules)) { + if (rule.kind === 'ambient') ambientSelectors.add(rule.selector) + else if (!rule.scope && !rule.id.startsWith(FRAMEWORK_ID_PREFIX) && !classByName.has(rule.name)) { + classByName.set(rule.name, rule.id) + } + } + + const now = Date.now() + const classIdMap = new Map() + const styleRules: StyleRule[] = [] + const add = (rule: StyleRule, id: string, extra: Partial = {}) => { + const { origin: _origin, generated: _generated, ...rest } = structuredClone(rule) + styleRules.push({ ...rest, ...extra, id, order: allocateOrder(), createdAt: now, updatedAt: now }) + } + + // Ambient rules ride along only when they name a class that lands (or name + // no class at all, like an animation's @keyframes): a rule built on the + // fragment's copy of a component the site already has is not needed. + const usedNames = new Set( + fragment.styleRules.filter((rule) => usedClassIds.has(rule.id)).map((rule) => rule.name), + ) + const ambientNeeded = (rule: StyleRule) => { + const names = extractCssSelectorClasses(rule.selector).map((token) => token.name) + return names.length === 0 || names.some((name) => usedNames.has(name)) + } + + for (const rule of [...fragment.styleRules].sort((a, b) => a.order - b.order)) { + if (rule.kind === 'ambient' ? !ambientNeeded(rule) : !usedClassIds.has(rule.id)) continue + if (rule.id.startsWith(FRAMEWORK_ID_PREFIX)) { + const id = frameworkByName.get(rule.name) + if (id) classIdMap.set(rule.id, id) + else { + warnings.push({ + code: 'framework-class-dropped', + message: `The site's framework does not generate "${rule.name}", so it is left off.`, + subject: rule.name, + }) + } + continue + } + if (rule.scope?.type === 'node') { + const nodeId = scopeNodeId(rule.scope.nodeId) + if (!nodeId) continue + const id = nanoid() + classIdMap.set(rule.id, id) + add(rule, id, { scope: { ...rule.scope, nodeId } }) + continue + } + if (rule.kind === 'class') { + const existing = frameworkByName.get(rule.name) ?? classByName.get(rule.name) + if (existing) { + classIdMap.set(rule.id, existing) + report.rules.reused.push(rule.name) + continue + } + const id = nanoid() + classIdMap.set(rule.id, id) + classByName.set(rule.name, id) + add(rule, id) + report.rules.added.push(rule.name) + continue + } + if (ambientSelectors.has(rule.selector)) { + report.rules.ambientKept.push(rule.selector) + continue + } + ambientSelectors.add(rule.selector) + add(rule, nanoid()) + report.rules.ambientAdded.push(rule.selector) + } + return { styleRules, classIdMap } +} + +function warnMissingContexts( + site: SiteDocument, + conditionIds: string[], + rules: StyleRule[], + fragment: SiteFragment, + warnings: FragmentWarning[], +): void { + const known = new Set([...site.breakpoints.map((bp) => bp.id), ...conditionIds]) + const missing = new Set() + for (const rule of rules) { + for (const key of Object.keys(rule.contextStyles)) if (!known.has(key)) missing.add(key) + } + for (const node of Object.values(fragment.nodes)) { + for (const key of Object.keys(node.breakpointOverrides)) if (!known.has(key)) missing.add(key) + } + for (const key of missing) { + warnings.push({ + code: 'missing-context', + message: `Styles for "${key}" have no matching breakpoint or condition in this site, so they do not apply.`, + subject: key, + }) + } +} + +// --------------------------------------------------------------------------- +// Content rewrites +// --------------------------------------------------------------------------- + +function linkResolver( + site: SiteDocument, + fragment: SiteFragment, + newPageId: string | undefined, + report: FragmentInsertReport, +): (pageId: string) => string | null { + const slugBySourceId = new Map(fragment.links.map((l) => [l.pageId, l.slug])) + const seen = new Set() + return (pageId) => { + const slug = slugBySourceId.get(pageId) + // A page fragment's links to itself follow it to its new page. + let resolved: string | null = null + if (slug !== undefined && newPageId && slug === fragment.page?.slug) resolved = newPageId + else if (slug !== undefined) resolved = site.pages.find((p) => p.slug === slug)?.id ?? null + if (!seen.has(pageId)) { + seen.add(pageId) + const label = slug || pageId + if (resolved) report.links.remapped.push(label) + else report.links.unresolved.push(label) + } + return resolved + } +} + +function remapTableRefs(node: PageNode, tableIdMap: Map): PageNode { + if (tableIdMap.size === 0) return node + if (node.moduleId === 'base.loop') { + const filters = node.props.filters + if (filters && typeof filters === 'object' && !Array.isArray(filters)) { + const tableId = (filters as Record).tableId + const mapped = typeof tableId === 'string' ? tableIdMap.get(tableId) : undefined + if (mapped) return { ...node, props: { ...node.props, filters: { ...filters, tableId: mapped } } } + } + } + if (node.moduleId === 'base.form' && typeof node.props.targetTableId === 'string') { + const mapped = tableIdMap.get(node.props.targetTableId) + if (mapped) return { ...node, props: { ...node.props, targetTableId: mapped } } + } + return node +} + +/** + * Strip instances of components that neither travel in the fragment nor + * exist in the site (whole instance subtree), like a saved layout heals refs + * to deleted components. + */ +function dropDanglingRefs( + nodes: Record, + vcIdMap: Map, + site: SiteDocument, + warnings: FragmentWarning[], +): void { + const known = new Set([...vcIdMap.values(), ...site.visualComponents.map((vc) => vc.id)]) + const dangling: string[] = [] + for (const node of Object.values(nodes)) { + if (node.moduleId !== VC_REF) continue + const id = node.props.componentId + if (typeof id === 'string' && id && !known.has(id)) { + dangling.push(node.id) + warnings.push({ + code: 'component-ref-dropped', + message: `An instance of component "${id}" was left out: the fragment does not carry that component.`, + subject: id, + }) + } + } + if (dangling.length > 0) removeNodeSubtrees(nodes, dangling) +} diff --git a/src/core/fragments/planData.ts b/src/core/fragments/planData.ts new file mode 100644 index 000000000..38c46de7d --- /dev/null +++ b/src/core/fragments/planData.ts @@ -0,0 +1,157 @@ +/** + * Table and media merge for `planFragmentInsert`. + * + * Tables match by slug and the site's table wins: a fragment field the table + * lacks (by field id) is appended, nothing else changes, and the fragment's + * sample rows are NOT added. A table the site lacks is created with its + * schema and rows. Relation fields only survive when the table they point at + * travels in the same fragment; relation CELLS never travel (row ids are + * site-local), so they insert empty. + */ + +import type { DataField } from '@core/data/schemas' +import type { PageNode, StyleRule } from '@core/page-tree' +import type { VisualComponent } from '@core/visualComponents' +import { createPathFinder, forEachString, mediaIdsInCells, nodeContent, ruleContent } from './references' +import type { FragmentMedia, FragmentTable, FragmentTableRow, SiteFragment } from './schema' +import type { FragmentInsertReport, FragmentInsertTarget, PlannedFontFamily, PlannedTable } from './planTypes' +import type { FragmentWarning } from './warnings' + +export function planTables( + target: FragmentInsertTarget, + fragment: SiteFragment, + report: FragmentInsertReport, + warnings: FragmentWarning[], +): { tables: PlannedTable[]; tableIdMap: Map } { + const carriedIds = new Set(fragment.tables.map((t) => t.id)) + const tables: PlannedTable[] = [] + const tableIdMap = new Map() + + for (const table of fragment.tables) { + const fields = keepPortableFields(table, carriedIds, warnings) + const existing = target.tables.find((t) => t.slug === table.slug) + if (existing) { + const addFields: DataField[] = [] + for (const field of fields) { + const current = existing.fields.find((f) => f.id === field.id) + if (!current) { + addFields.push(field) + report.tables.fieldsAdded.push(`${table.slug}.${field.id}`) + } else if (current.type !== field.type) { + warnings.push({ + code: 'field-type-mismatch', + message: `"${table.slug}.${field.id}" is a ${current.type} field here and ${field.type} in the fragment; the site's field is kept.`, + subject: `${table.slug}.${field.id}`, + }) + } + } + tables.push({ + action: 'reuse', + fragmentTableId: table.id, + tableId: existing.id, + slug: table.slug, + existingFields: existing.fields, + addFields, + }) + tableIdMap.set(table.id, existing.id) + report.tables.reused.push(table.slug) + continue + } + + const rows = (table.rows ?? []).map((row) => portableRow(row, table.fields, fields)) + tables.push({ + action: 'create', + fragmentTableId: table.id, + slug: table.slug, + input: { + name: table.name, + slug: table.slug, + kind: table.kind, + singularLabel: table.singularLabel, + pluralLabel: table.pluralLabel, + routeBase: table.routeBase, + primaryFieldId: table.primaryFieldId, + fields, + }, + rows, + }) + report.tables.created.push(table.slug) + report.tables.rows += rows.length + } + return { tables, tableIdMap } +} + +function keepPortableFields( + table: FragmentTable, + carriedIds: Set, + warnings: FragmentWarning[], +): DataField[] { + return table.fields.filter((field) => { + if (field.type !== 'relation' || carriedIds.has(field.targetTableId)) return true + warnings.push({ + code: 'relation-field-dropped', + message: `"${table.slug}.${field.id}" relates to a table the fragment does not carry, so it is left out.`, + subject: `${table.slug}.${field.id}`, + }) + return false + }) +} + +function portableRow(row: FragmentTableRow, allFields: DataField[], kept: DataField[]): FragmentTableRow { + const keptIds = new Set(kept.map((f) => f.id)) + const cells: Record = {} + for (const [key, value] of Object.entries(row.cells)) { + const field = allFields.find((f) => f.id === key) + if (field && !keptIds.has(key)) continue + cells[key] = field?.type === 'relation' ? (field.allowMultiple ? [] : null) : structuredClone(value) + } + return { ...row, cells } +} + +/** + * The media the inserted content actually references: paths in placed nodes, + * added component trees and added rules; files of font families being + * installed; and media cells / inline paths of rows in created tables. + * Assets only rows of a REUSED table use are not needed and not uploaded. + */ +export function referencedMedia( + fragment: SiteFragment, + parts: { + nodes: PageNode[] + components: VisualComponent[] + rules: StyleRule[] + fontFamilies: PlannedFontFamily[] + tables: PlannedTable[] + }, +): FragmentMedia[] { + const byPath = new Map(fragment.media.map((m) => [m.path, m])) + const found = new Set() + const find = createPathFinder(byPath.keys()) + const scan = (text: string) => find(text, found) + for (const node of parts.nodes) for (const part of nodeContent(node)) forEachString(part, scan) + for (const vc of parts.components) { + for (const node of Object.values(vc.tree.nodes)) for (const part of nodeContent(node)) forEachString(part, scan) + } + for (const rule of parts.rules) for (const part of ruleContent(rule)) forEachString(part, scan) + + const ids = new Set() + for (const path of found) ids.add(byPath.get(path)!.id) + for (const family of parts.fontFamilies) { + if (family.entry.source !== 'custom') continue + for (const file of family.entry.files) { + if (file.mediaAssetId) ids.add(file.mediaAssetId) + const byFilePath = byPath.get(file.path) + if (byFilePath) ids.add(byFilePath.id) + } + } + for (const table of parts.tables) { + if (table.action !== 'create') continue + for (const row of table.rows) { + mediaIdsInCells(table.input.fields, row.cells, ids) + const rowPaths = new Set() + forEachString(row.cells, (text) => find(text, rowPaths)) + for (const path of rowPaths) ids.add(byPath.get(path)!.id) + } + } + return fragment.media.filter((m) => ids.has(m.id)) +} diff --git a/src/core/fragments/planTokens.ts b/src/core/fragments/planTokens.ts new file mode 100644 index 000000000..45fa9b238 --- /dev/null +++ b/src/core/fragments/planTokens.ts @@ -0,0 +1,263 @@ +/** + * Token merge for `planFragmentInsert`. The site's tokens always win: + * + * - colours match by normalised slug; a missing one is added with the + * fragment's FULL definition (generation flags included) so the variants + * the fragment reads exist; + * - type and space steps the site's group lacks are inserted into that + * group at their canonical position, keeping its base, ratio and sizes; + * - font tokens match by variable; a missing one comes with its family + * (an existing family of the same name is reused); + * - other custom properties the site does not define land in the + * engine-owned `:root` rule. + */ + +import { nanoid } from 'nanoid' +import type { SiteDocument, StyleRule } from '@core/page-tree' +import { + generateFrameworkColorVariableSets, + insertScaleStep, + normalizeFrameworkColorSlug, +} from '@core/framework' +import type { + FrameworkColorToken, + FrameworkSettings, + FrameworkSpacingGroup, + FrameworkTypographyGroup, +} from '@core/framework-schema' +import { normalizeFontTokenVariable, type FontToken } from '@core/fonts' +import { collectVarNames, forEachString, nodeContent, ruleContent } from './references' +import { indexSiteVariables, type ScaleFamily } from './siteVariables' +import type { FragmentScaleStep, SiteFragment } from './schema' +import type { + FragmentInsertReport, + PlannedCustomProperties, + PlannedFontFamily, + PlannedScaleSteps, +} from './planTypes' +import type { FragmentWarning } from './warnings' + +/** `origin.source` of the engine-owned `:root` rule that holds inserted custom properties. */ +export const FRAGMENT_PROPERTIES_ORIGIN = 'instatic:fragment-properties' + +export interface TokenPlan { + colorTokens: FrameworkColorToken[] + scaleSteps: PlannedScaleSteps[] + /** The site's framework settings as they will be after the insert. */ + nextFramework: FrameworkSettings | undefined + fontFamilies: PlannedFontFamily[] + /** fragment family id → site family id, for families the site already has. */ + reusedFamilyIds: Map + fontTokens: FontToken[] +} + +export function planTokens( + site: SiteDocument, + fragment: SiteFragment, + report: FragmentInsertReport, + warnings: FragmentWarning[], +): TokenPlan { + const now = Date.now() + const framework = site.settings.framework + + // ── Colours ──────────────────────────────────────────────────────────────── + const existingColors = framework?.colors.tokens ?? [] + const slugs = new Set(existingColors.map((t) => normalizeFrameworkColorSlug(t.slug))) + let colorOrder = existingColors.reduce((max, t) => Math.max(max, t.order), -1) + const colorTokens: FrameworkColorToken[] = [] + for (const token of fragment.tokens.colors) { + const slug = normalizeFrameworkColorSlug(token.slug) + if (slugs.has(slug)) { + report.colors.kept.push(slug) + continue + } + slugs.add(slug) + colorOrder += 1 + colorTokens.push({ ...structuredClone(token), id: nanoid(), slug, order: colorOrder, createdAt: now, updatedAt: now }) + report.colors.added.push(slug) + } + + // ── Scale steps ──────────────────────────────────────────────────────────── + const scaleSteps: PlannedScaleSteps[] = [] + const typography = framework?.typography ? structuredClone(framework.typography) : undefined + const spacing = framework?.spacing ? structuredClone(framework.spacing) : undefined + addSteps('typography', typography, fragment.tokens.typeSteps, scaleSteps, report, warnings) + addSteps('spacing', spacing, fragment.tokens.spaceSteps, scaleSteps, report, warnings) + + const nextFramework: FrameworkSettings | undefined = framework || colorTokens.length > 0 + ? { + ...(framework ?? { colors: { tokens: [] } }), + colors: { tokens: [...existingColors, ...colorTokens] }, + ...(typography ? { typography } : {}), + ...(spacing ? { spacing } : {}), + } + : undefined + + // ── Fonts ────────────────────────────────────────────────────────────────── + const families = site.settings.fonts?.items ?? [] + const fontFamilies: PlannedFontFamily[] = [] + const reusedFamilyIds = new Map() + for (const family of fragment.tokens.fontFamilies) { + const match = families.find((f) => f.family.toLowerCase() === family.family.toLowerCase()) + if (match) { + reusedFamilyIds.set(family.id, match.id) + report.fonts.familiesKept.push(family.family) + } else { + fontFamilies.push({ fragmentFamilyId: family.id, entry: structuredClone(family) }) + report.fonts.familiesAdded.push(family.family) + } + } + const existingTokens = site.settings.fonts?.tokens ?? [] + const variables = new Set(existingTokens.map((t) => normalizeFontTokenVariable(t.variable))) + let fontOrder = existingTokens.reduce((max, t) => Math.max(max, t.order), -1) + const fontTokens: FontToken[] = [] + for (const token of fragment.tokens.fontTokens) { + const variable = normalizeFontTokenVariable(token.variable) + if (variables.has(variable)) { + report.fonts.tokensKept.push(variable) + continue + } + variables.add(variable) + fontOrder += 1 + // A family being installed keeps its fragment id here; resolve swaps in + // the installed entry's id. + const familyId = token.familyId + ? reusedFamilyIds.get(token.familyId) + ?? (fontFamilies.some((f) => f.fragmentFamilyId === token.familyId) ? token.familyId : undefined) + : undefined + const { familyId: _sourceFamilyId, ...rest } = token + fontTokens.push({ ...rest, id: nanoid(), variable, ...(familyId ? { familyId } : {}), order: fontOrder, createdAt: now, updatedAt: now }) + report.fonts.tokensAdded.push(variable) + } + + return { colorTokens, scaleSteps, nextFramework, fontFamilies, reusedFamilyIds, fontTokens } +} + +function addSteps( + family: ScaleFamily, + settings: { groups: (FrameworkTypographyGroup | FrameworkSpacingGroup)[]; isDisabled?: boolean } | undefined, + wanted: FragmentScaleStep[], + out: PlannedScaleSteps[], + report: FragmentInsertReport, + warnings: FragmentWarning[], +): void { + const missingConventions = new Set() + for (const { convention, step } of wanted) { + const group = settings && !settings.isDisabled + ? settings.groups.find((g) => g.namingConvention === convention && !g.isDisabled) + : undefined + if (!group) { + if (!missingConventions.has(convention)) { + missingConventions.add(convention) + warnings.push({ + code: 'missing-scale-group', + message: `The site has no ${family} scale named "${convention}"; styles reading --${convention}-* fall back.`, + subject: convention, + }) + } + continue + } + if (group.mode === 'fluid_manual') { + if (!group.manualSizes?.some((size) => size.name === `${convention}-${step}`)) { + warnings.push({ + code: 'scale-step-not-added', + message: `The "${group.name}" scale uses manual sizes; add "${step}" there by hand.`, + subject: `${convention}-${step}`, + }) + } + continue + } + const next = insertScaleStep(group.steps, group.baseScaleIndex, step) + if (!next) { + warnings.push({ + code: 'scale-step-not-added', + message: `"${step}" has no canonical place in the "${group.name}" scale (${group.steps}).`, + subject: `${convention}-${step}`, + }) + continue + } + if (next.steps === group.steps) continue + group.steps = next.steps + group.baseScaleIndex = next.baseScaleIndex + const planned = out.find((p) => p.family === family && p.groupId === group.id) + if (planned) { + planned.steps = next.steps + planned.baseScaleIndex = next.baseScaleIndex + } else { + out.push({ family, groupId: group.id, steps: next.steps, baseScaleIndex: next.baseScaleIndex }) + } + report.steps.added.push({ family, convention, step }) + } +} + +/** + * The custom properties the site will still lack after the token merge, and + * the `:root` rule they go into. Also warns, once per variable, about colour + * variants the fragment reads that the site's version of the colour does not + * generate (a kept colour with shades turned off, say). + */ +export function planCustomProperties( + site: SiteDocument, + fragment: SiteFragment, + tokens: TokenPlan, + allocateOrder: () => number, + report: FragmentInsertReport, + warnings: FragmentWarning[], +): PlannedCustomProperties | null { + const after = indexSiteVariables({ + settings: { + ...site.settings, + framework: tokens.nextFramework, + fonts: { + items: site.settings.fonts?.items ?? [], + tokens: [...(site.settings.fonts?.tokens ?? []), ...tokens.fontTokens], + }, + }, + styleRules: site.styleRules, + }) + + // Colour variables the fragment's own definitions generate. + const fragmentColorVars = new Set( + generateFrameworkColorVariableSets({ tokens: fragment.tokens.colors }).light.map((v) => v.name), + ) + const used = new Set() + for (const rule of fragment.styleRules) for (const part of ruleContent(rule)) forEachString(part, (t) => collectVarNames(t, used)) + for (const node of Object.values(fragment.nodes)) for (const part of nodeContent(node)) forEachString(part, (t) => collectVarNames(t, used)) + for (const vc of fragment.components) { + for (const node of Object.values(vc.tree.nodes)) for (const part of nodeContent(node)) forEachString(part, (t) => collectVarNames(t, used)) + } + for (const value of Object.values(fragment.tokens.customProperties)) collectVarNames(value, used) + for (const name of used) { + if (fragmentColorVars.has(name) && !after.colorTokenIdByVar.has(name)) { + warnings.push({ + code: 'color-variant-missing', + message: `${name} is a colour variant the site's version of that colour does not generate.`, + subject: name, + }) + } + } + + const missing: Record = {} + for (const [name, value] of Object.entries(fragment.tokens.customProperties)) { + const defined = after.rootCustomProperties.has(name) + || after.colorTokenIdByVar.has(name) + || after.scaleStepByVar.has(name) + || after.fontTokenByVar.has(name) + if (defined) report.customProperties.kept.push(name) + else { + missing[name] = value + report.customProperties.added.push(name) + } + } + if (Object.keys(missing).length === 0) return null + + const owned = findFragmentPropertiesRule(site.styleRules) + if (owned) return { ruleId: owned.id, create: false, order: owned.order, properties: missing } + return { ruleId: nanoid(), create: true, order: allocateOrder(), properties: missing } +} + +export function findFragmentPropertiesRule(rules: Record): StyleRule | undefined { + return Object.values(rules).find( + (rule) => rule.kind === 'ambient' && rule.selector === ':root' && rule.origin?.source === FRAGMENT_PROPERTIES_ORIGIN, + ) +} diff --git a/src/core/fragments/planTypes.ts b/src/core/fragments/planTypes.ts new file mode 100644 index 000000000..20ac417e2 --- /dev/null +++ b/src/core/fragments/planTypes.ts @@ -0,0 +1,136 @@ +/** + * The insert plan and its report — what `planFragmentInsert` decides, what + * `commitFragmentPlan` executes, and what the user is shown. + */ + +import type { ConditionDef, PageNode, SiteDocument, StyleRule } from '@core/page-tree' +import type { FrameworkColorToken } from '@core/framework-schema' +import type { FontEntry, FontToken } from '@core/fonts' +import type { VisualComponent } from '@core/visualComponents' +import type { DataField, DataTable, DataTableKind } from '@core/data/schemas' +import type { + FragmentKind, + FragmentMedia, + FragmentOutsideDependency, + FragmentTableRow, +} from './schema' +import type { ScaleFamily } from './siteVariables' +import type { FragmentWarning } from './warnings' + +/** The document a section or component lands in. */ +export type FragmentInsertDocument = + | { kind: 'page'; pageId: string } + | { kind: 'visualComponent'; vcId: string } + +export interface FragmentInsertTarget { + site: SiteDocument + /** The target's data tables (the editor store holds none; read them from the CMS API). */ + tables: readonly DataTable[] + /** Where a section or component lands. A page fragment adds its own page. */ + document?: FragmentInsertDocument +} + +/** A fragment table that lands on a table the site already has (matched by slug). */ +export interface PlannedTableReuse { + action: 'reuse' + fragmentTableId: string + tableId: string + slug: string + /** The table's current fields; `addFields` are appended to them. */ + existingFields: DataField[] + addFields: DataField[] +} + +/** A fragment table the site lacks: created with its schema and rows. */ +export interface PlannedTableCreate { + action: 'create' + fragmentTableId: string + slug: string + input: { + name: string + slug: string + kind: Extract + singularLabel: string + pluralLabel: string + routeBase: string + primaryFieldId: string + fields: DataField[] + } + rows: FragmentTableRow[] +} + +export type PlannedTable = PlannedTableReuse | PlannedTableCreate + +/** A font library entry the site lacks; the commit installs or registers it. */ +export interface PlannedFontFamily { + /** The family's id inside the fragment — planned font tokens bind to it until installed. */ + fragmentFamilyId: string + entry: FontEntry +} + +export interface PlannedScaleSteps { + family: ScaleFamily + groupId: string + steps: string + baseScaleIndex: number +} + +/** + * Root custom properties the site lacks. They live in ONE engine-owned + * ambient `:root` rule (`origin.source === FRAGMENT_PROPERTIES_ORIGIN`): the + * first insert creates it, later inserts add to it. A user-authored `:root` + * rule is never edited. + */ +export interface PlannedCustomProperties { + ruleId: string + /** True when the rule does not exist yet. */ + create: boolean + order: number + properties: Record +} + +export interface FragmentInsertReport { + kind: FragmentKind + /** The page a page fragment adds, with its final (possibly suffixed) slug. */ + page?: { title: string; slug: string } + /** Nodes placed in the document (component trees not counted). */ + nodes: number + colors: { added: string[]; kept: string[] } + fonts: { tokensAdded: string[]; tokensKept: string[]; familiesAdded: string[]; familiesKept: string[] } + steps: { added: { family: ScaleFamily; convention: string; step: string }[] } + customProperties: { added: string[]; kept: string[] } + rules: { added: string[]; reused: string[]; ambientAdded: string[]; ambientKept: string[] } + conditions: { added: string[] } + components: { added: string[]; reused: string[] } + tables: { created: string[]; reused: string[]; fieldsAdded: string[]; rows: number } + media: { total: number; uploaded: number; reused: number } + links: { remapped: string[]; unresolved: string[] } + outsideDependencies: FragmentOutsideDependency[] + warnings: FragmentWarning[] +} + +/** + * Everything an insert will add, with every id already minted. Media paths, + * created-table ids and to-be-installed font family ids are still the + * fragment's; `resolveFragmentPlan` rewrites them once the server work has + * produced the real ones. + */ +export interface FragmentInsertPlan { + kind: FragmentKind + document?: FragmentInsertDocument + page?: { id: string; title: string; slug: string } + roots: string[] + nodes: Record + styleRules: StyleRule[] + customProperties: PlannedCustomProperties | null + conditions: ConditionDef[] + colorTokens: FrameworkColorToken[] + scaleSteps: PlannedScaleSteps[] + fontFamilies: PlannedFontFamily[] + fontTokens: FontToken[] + components: VisualComponent[] + tables: PlannedTable[] + /** Media the inserted content references (rows of reused tables excluded). */ + media: FragmentMedia[] + report: FragmentInsertReport +} diff --git a/src/core/fragments/references.ts b/src/core/fragments/references.ts index 1ad98e296..1ccfa3de1 100644 --- a/src/core/fragments/references.ts +++ b/src/core/fragments/references.ts @@ -11,6 +11,7 @@ */ import type { PageNode, StyleRule } from '@core/page-tree' +import type { DataField } from '@core/data/schemas' /** Visit every string inside a JSON-like value. */ export function forEachString(value: unknown, visit: (text: string) => void): void { @@ -155,3 +156,23 @@ export function createPathFinder(paths: Iterable): (text: string, into: for (const match of text.matchAll(pattern)) into.add(match[0]) } } + +/** Media ids a row's media cells hold (repeater items included). */ +export function mediaIdsInCells(fields: DataField[], cells: Record, into: Set): void { + for (const field of fields) { + const value = cells[field.id] + if (field.type === 'media') { + if (typeof value === 'string') into.add(value) + else if (Array.isArray(value)) for (const id of value) if (typeof id === 'string') into.add(id) + } else if (field.type === 'repeater' && Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === 'object' && 'cells' in item) { + const itemCells = (item as { cells: unknown }).cells + if (itemCells && typeof itemCells === 'object') { + mediaIdsInCells(field.fields, itemCells as Record, into) + } + } + } + } + } +} diff --git a/src/core/framework/scale.ts b/src/core/framework/scale.ts index fa64d2bdd..a45513932 100644 --- a/src/core/framework/scale.ts +++ b/src/core/framework/scale.ts @@ -266,3 +266,40 @@ export function prependStep(steps: string): string { const first = arr[0] ?? 'm' return [nextSizeBefore(first), ...arr].join(',') } + +/** + * Insert `step` into a comma-separated step list at its canonical position on + * the t-shirt-size ring (`3xs,2xs,xs,s,m,l,xl,2xl,…`), keeping the step that + * `baseScaleIndex` points at as the base: inserting before it shifts the index + * by one so the scale's base size, ratio and every existing step's value stay + * exactly as they were. + * + * Returns the list unchanged when it already holds `step`, and `null` when the + * position is undefined — `step` is not on the ring, or the list holds a + * custom step the ring cannot order against. + */ +export function insertScaleStep( + steps: string, + baseScaleIndex: number, + step: string, +): { steps: string; baseScaleIndex: number } | null { + const labels = steps.split(',').map((label) => label.trim()).filter(Boolean) + if (labels.includes(step)) return { steps: labels.join(','), baseScaleIndex } + const rank = SIZE_RING.indexOf(step) + if (rank === -1) return null + + let insertAt = labels.length + for (let index = 0; index < labels.length; index += 1) { + const labelRank = SIZE_RING.indexOf(labels[index]) + if (labelRank === -1) return null + if (labelRank > rank) { + insertAt = index + break + } + } + labels.splice(insertAt, 0, step) + return { + steps: labels.join(','), + baseScaleIndex: insertAt <= baseScaleIndex ? baseScaleIndex + 1 : baseScaleIndex, + } +} From 28cba8e493fb176d13a0c5c498f3411d61e61f5a Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:46:13 +0200 Subject: [PATCH 03/13] feat(fragments): commit fragment inserts in one undo step commitFragmentPlan runs the server work first (media reused by name and size or uploaded, fonts installed or registered, tables and rows created, missing fields appended), resolves the plan against the ids that produced, then hands it to the editor's new fragments slice, which applies it in one store transaction and regenerates framework utilities when the insert added a colour or a scale step. A failed upload or table write stops before the document is touched. --- .../editor-store/insertFragment.test.ts | 104 +++++++ src/__tests__/fragments/commit.test.ts | 249 +++++++++++++++ src/__tests__/fragments/fixtures.ts | 2 +- .../createFragmentInsertAdapter.ts | 76 +++++ .../pages/site/store/slices/fragmentsSlice.ts | 111 +++++++ src/admin/pages/site/store/store.ts | 2 + src/core/fragments/adapter.ts | 66 ++++ src/core/fragments/apply.ts | 160 ++++++++++ src/core/fragments/commit.ts | 288 ++++++++++++++++++ src/core/fragments/index.ts | 8 + src/core/fragments/plan.ts | 25 +- src/core/fragments/references.ts | 21 ++ src/core/fragments/resolve.ts | 81 +++++ 13 files changed, 1174 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/editor-store/insertFragment.test.ts create mode 100644 src/__tests__/fragments/commit.test.ts create mode 100644 src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts create mode 100644 src/admin/pages/site/store/slices/fragmentsSlice.ts create mode 100644 src/core/fragments/adapter.ts create mode 100644 src/core/fragments/apply.ts create mode 100644 src/core/fragments/commit.ts create mode 100644 src/core/fragments/resolve.ts diff --git a/src/__tests__/editor-store/insertFragment.test.ts b/src/__tests__/editor-store/insertFragment.test.ts new file mode 100644 index 000000000..cc67dd3c8 --- /dev/null +++ b/src/__tests__/editor-store/insertFragment.test.ts @@ -0,0 +1,104 @@ +/** + * 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 { + extractFragment, + fragmentSourceFromSite, + planFragmentInsert, + resolveFragmentPlan, + type SiteFragment, +} from '@core/fragments' +import { SOURCE_ROWS, SOURCE_TABLES, makeSourceSite, sourceMediaWithBytes } 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) + +async function sectionFragment(): Promise { + const source = fragmentSourceFromSite(makeSourceSite(), { tables: SOURCE_TABLES, rows: SOURCE_ROWS, media: sourceMediaWithBytes() }) + return (await extractFragment(source, { pageId: 'page-home', rootNodeIds: ['hero'] })).fragment +} + +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', async () => { + const site = useEditorStore.getState().createSite('Target') + const rootId = site.pages[0].rootNodeId + const resolved = resolvedPlanForStore(await sectionFragment()) + + 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('one undo removes the whole insert', async () => { + const site = useEditorStore.getState().createSite('Target') + const before = useEditorStore.getState().site! + const resolved = resolvedPlanForStore(await sectionFragment()) + 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', async () => { + useEditorStore.getState().createSite('Target') + const resolved = resolvedPlanForStore(await sectionFragment()) + expect(useEditorStore.getState().insertFragment({ ...resolved, document: { kind: 'page', pageId: 'elsewhere' } })).toBeNull() + }) + + it('a page fragment adds a page', async () => { + useEditorStore.getState().createSite('Target') + const source = fragmentSourceFromSite(makeSourceSite(), { tables: SOURCE_TABLES, rows: SOURCE_ROWS, media: sourceMediaWithBytes() }) + const { fragment } = await extractFragment(source, { 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..3dee1779b --- /dev/null +++ b/src/__tests__/fragments/commit.test.ts @@ -0,0 +1,249 @@ +/** + * 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, + extractFragment, + fragmentSourceFromSite, + 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 { + SOURCE_ROWS, + SOURCE_TABLES, + TARGET_TABLES, + makeSourceSite, + makeTargetSite, + mediaBytesFor, + sourceMediaWithBytes, +} from './fixtures' + +async function extracted(selection: Parameters[1]) { + return extractFragment( + fragmentSourceFromSite(makeSourceSite(), { tables: SOURCE_TABLES, rows: SOURCE_ROWS, media: sourceMediaWithBytes() }), + selection, + ) +} + +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 +} + +describe('commitFragmentPlan — into a populated site', () => { + it('lands the section with styles, tokens, media, tables and links resolved', async () => { + const { fragment } = await extracted({ pageId: 'page-home', rootNodeIds: ['hero'] }) + const site = makeTargetSite() + const plan = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, fragment) + const { adapter, server } = 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 === '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 extracted({ pageId: 'page-home', rootNodeIds: ['hero'] }) + const site = makeTargetSite() + const { adapter } = memoryAdapter(site, TARGET_TABLES, { pageId: 't-home', parentId: 't-body' }) + const first = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, 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 extracted({ pageId: 'page-home', rootNodeIds: ['hero'] }) + const site = makeTargetSite() + const before = structuredClone(site) + const plan = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, fragment) + const { adapter } = memoryAdapter(site, TARGET_TABLES, { pageId: 't-home', parentId: 't-body' }, 'upload') + 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 extracted({ pageId: 'page-home', rootNodeIds: ['hero'] }) + const site = makeTargetSite() + const before = structuredClone(site) + const plan = planFragmentInsert({ site, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, fragment) + const { adapter } = memoryAdapter(site, TARGET_TABLES, { pageId: 't-home', parentId: 't-body' }) + 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 extracted({ pageId: 'page-home', rootNodeIds: ['hero'] }) + 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 extracted({ 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/fixtures.ts b/src/__tests__/fragments/fixtures.ts index 06c8323ff..fd2e352ea 100644 --- a/src/__tests__/fragments/fixtures.ts +++ b/src/__tests__/fragments/fixtures.ts @@ -138,7 +138,7 @@ export function mediaBytesFor(id: string): Uint8Array { } export function sourceMediaWithBytes(): FragmentSourceMedia[] { - return SOURCE_MEDIA.map((m) => ({ ...m, readBytes: () => mediaBytesFor(m.id) })) + return SOURCE_MEDIA.map((m) => ({ ...m, sizeBytes: mediaBytesFor(m.id).byteLength, readBytes: () => mediaBytesFor(m.id) })) } export const SOURCE_TABLES: DataTable[] = [ diff --git a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts new file mode 100644 index 000000000..485375952 --- /dev/null +++ b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +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' + +export function createFragmentInsertAdapter(): 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 uploadCmsMediaAsset(file, { signal, altText }) + return { id: asset.id, url: asset.publicPath } + }, + + installGoogleFont: (request) => installCmsGoogleFont(request), + + registerCustomFont: (request) => registerCustomFont(request), + + async createTable(input) { + const table = await createCmsDataTable(input) + return { id: table.id } + }, + + async updateTableFields(tableId, fields) { + await updateCmsDataTable(tableId, { fields }) + }, + + async createRow(tableId, row) { + const created = await createCmsDataRow(tableId, { cells: row.cells }) + if (row.status === 'published') await 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/pages/site/store/slices/fragmentsSlice.ts b/src/admin/pages/site/store/slices/fragmentsSlice.ts new file mode 100644 index 000000000..4dffc65ff --- /dev/null +++ b/src/admin/pages/site/store/slices/fragmentsSlice.ts @@ -0,0 +1,111 @@ +/** + * 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 mirrors saved layouts: a section or component lands at the + * location resolved from the current selection (inside a container, else + * after it), in the document the plan was made for. + */ + +import { + applyResolvedFragment, + type FragmentApplyResult, + type FragmentInsertDocument, + type ResolvedFragmentPlan, +} from '@core/fragments' +import { resolveInsertLocation } from '@site/store/insertLocation' +import type { EditorStore, EditorStoreSliceCreator } from '@site/store/types' +import { reconcileFrameworkClasses } from './site/framework/reconcile' +import { buildSiteHelpers, resolveActiveTreeTarget } from './site/helpers' + +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 +} + +function sameDocument(a: FragmentInsertDocument | null, b: FragmentInsertDocument | undefined): boolean { + if (!a || !b) return false + if (a.kind === 'page') return b.kind === 'page' && a.pageId === b.pageId + return b.kind === 'visualComponent' && a.vcId === b.vcId +} + +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 (!sameDocument(fragmentInsertDocument(state), plan.document)) return null + const target = resolveActiveTreeTarget(state) + if (!target) return null + const location = resolveInsertLocation(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/store.ts b/src/admin/pages/site/store/store.ts index 6500fde13..7c21e6c96 100644 --- a/src/admin/pages/site/store/store.ts +++ b/src/admin/pages/site/store/store.ts @@ -17,6 +17,7 @@ import { createSitePanelSlice } from './slices/sitePanelSlice' import { createClipboardSlice } from './slices/clipboardSlice' import { createInlineEditSlice } from './slices/inlineEditSlice' import { createLayoutsSlice } from './slices/layoutsSlice' +import { createFragmentsSlice } from './slices/fragmentsSlice' import { initCollabBinding } from './slices/site/collabBinding' import { bindPluginRuntimeStoreApi } from '@core/plugins/runtime' import { useAdminUi } from '@admin/state/adminUi' @@ -75,6 +76,7 @@ export const useEditorStore = create()( ...createClipboardSlice(...args), ...createInlineEditSlice(...args), ...createLayoutsSlice(...args), + ...createFragmentsSlice(...args), }), { enableAutoFreeze: true }, ) diff --git a/src/core/fragments/adapter.ts b/src/core/fragments/adapter.ts new file mode 100644 index 000000000..3ba9032ba --- /dev/null +++ b/src/core/fragments/adapter.ts @@ -0,0 +1,66 @@ +/** + * FragmentInsertAdapter — the I/O `commitFragmentPlan` needs, as an interface. + * + * The engine stays headless; the editor implements this against the CMS API + * and its store (`src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts`), + * tests implement it in memory, and a future plugin API can implement it with + * its own media resolver. + * + * Interface only — no implementation here. + */ + +import type { DataField } from '@core/data/schemas' +import type { FontEntry } from '@core/fonts' +import type { FragmentMedia, FragmentTableRow } from './schema' +import type { PlannedTableCreate } from './planTypes' +import type { FragmentApplyResult, ResolvedFragmentPlan } from './resolve' + +export interface FragmentMediaRef { + /** Media library id (what media cells hold). */ + id: string + /** Public URL content references (what `src` and `url()` hold). */ + url: string +} + +export interface FragmentInsertAdapter { + /** + * An asset already in the site's library that is the same file, or null. + * The media library stores no content hash, so the editor matches by + * filename, byte size and MIME type. + */ + findMedia(media: FragmentMedia): Promise + + /** Upload bytes into the media library. Forward `signal` to the request. */ + uploadMedia(file: { + filename: string + mimeType: string + bytes: Uint8Array + altText?: string + signal?: AbortSignal + }): Promise + + /** Install a Google family the way the Typography panel does. */ + installGoogleFont(request: { family: string; variants: string[]; subsets: string[] }): Promise + + /** Register a custom family from uploaded media files. */ + registerCustomFont(request: { family: string; files: { mediaAssetId: string; variant: string }[] }): Promise + + /** Create a data table. Returns the server-minted id. */ + createTable(input: PlannedTableCreate['input']): Promise<{ id: string }> + + /** Replace a table's field list (used to append missing fields). */ + updateTableFields(tableId: string, fields: DataField[]): Promise + + /** + * Create one row (its slug comes from its `slug` cell, as for any row); + * a `published` row is published after it is created. + */ + createRow(tableId: string, row: Pick): Promise<{ id: string }> + + /** + * Apply the resolved plan in ONE store transaction, so one undo step reverts + * the whole insert. Synchronous work behind an async signature: everything + * the transaction needs is already resolved. + */ + commit(plan: ResolvedFragmentPlan): Promise +} diff --git a/src/core/fragments/apply.ts b/src/core/fragments/apply.ts new file mode 100644 index 000000000..c9ddeb789 --- /dev/null +++ b/src/core/fragments/apply.ts @@ -0,0 +1,160 @@ +/** + * applyResolvedFragment — write a resolved plan into a site document. + * + * Mechanical: every decision was made by the planner. Mutates `site` (and the + * placement tree) in place, so the editor runs it inside ONE Mutative recipe + * and the whole insert is one undo step; tests run it on plain objects. + * + * Framework utility classes are NOT regenerated here — that reconcile lives + * with the editor store. When `frameworkChanged` is true the caller must + * regenerate them in the same transaction (the planner already resolved the + * fragment's utility class ids against the regenerated set). + */ + +import { + addPage, + collectSubtreeIds, + reconcileSiteExplorerInPlace, + reindexNodeParents, + type NodeTree, + type PageNode, + type SiteDocument, + type StyleRule, +} from '@core/page-tree' +import { normalizeFrameworkColorSlug } from '@core/framework' +import { normalizeFontTokenVariable } from '@core/fonts' +import { applySlotSyncResult, syncSlotInstances } from '@core/visualComponents' +import { FRAGMENT_PROPERTIES_ORIGIN } from './planTokens' +import type { FragmentApplyResult, ResolvedFragmentPlan } from './resolve' + +/** Where a section or component lands: under `parentId`, at `index` (append when omitted). */ +export interface FragmentPlacement { + tree: NodeTree + parentId: string + index?: number +} + +export function applyResolvedFragment( + site: SiteDocument, + plan: ResolvedFragmentPlan, + placement: FragmentPlacement | null, +): FragmentApplyResult & { frameworkChanged: boolean } { + const now = Date.now() + + // ── Conditions, tokens, fonts ────────────────────────────────────────────── + if (plan.conditions.length > 0) { + site.conditions ??= [] + const ids = new Set(site.conditions.map((c) => c.id)) + for (const def of plan.conditions) if (!ids.has(def.id)) site.conditions.push(def) + } + + if (plan.colorTokens.length > 0) { + site.settings.framework ??= { colors: { tokens: [] } } + const tokens = site.settings.framework.colors.tokens + const slugs = new Set(tokens.map((t) => normalizeFrameworkColorSlug(t.slug))) + for (const token of plan.colorTokens) if (!slugs.has(token.slug)) tokens.push(token) + } + for (const update of plan.scaleSteps) { + const groups = update.family === 'typography' + ? site.settings.framework?.typography?.groups + : site.settings.framework?.spacing?.groups + const group = groups?.find((g) => g.id === update.groupId) + if (!group) continue + group.steps = update.steps + group.baseScaleIndex = update.baseScaleIndex + group.updatedAt = now + } + + if (plan.fontEntries.length > 0 || plan.fontTokens.length > 0) { + site.settings.fonts ??= { items: [] } + const fonts = site.settings.fonts + for (const entry of plan.fontEntries) { + if (!fonts.items.some((f) => f.id === entry.id)) fonts.items.push(entry) + } + if (plan.fontTokens.length > 0) { + fonts.tokens ??= [] + const variables = new Set(fonts.tokens.map((t) => normalizeFontTokenVariable(t.variable))) + for (const token of plan.fontTokens) if (!variables.has(token.variable)) fonts.tokens.push(token) + } + } + + // ── Rules ────────────────────────────────────────────────────────────────── + const custom = plan.customProperties + if (custom) { + const existing = site.styleRules[custom.ruleId] + if (existing) { + for (const [name, value] of Object.entries(custom.properties)) { + if (!(name in existing.styles)) existing.styles[name] = value + } + existing.updatedAt = now + } else { + const rule: StyleRule = { + id: custom.ruleId, + name: ':root', + kind: 'ambient', + selector: ':root', + order: custom.order, + description: 'Custom properties added by inserted fragments', + styles: { ...custom.properties }, + contextStyles: {}, + origin: { source: FRAGMENT_PROPERTIES_ORIGIN, ordinal: 0 }, + createdAt: now, + updatedAt: now, + } + site.styleRules[rule.id] = rule + } + } + for (const rule of plan.styleRules) site.styleRules[rule.id] = rule + + // ── Components ───────────────────────────────────────────────────────────── + for (const vc of plan.components) { + if (!site.visualComponents.some((existing) => existing.id === vc.id)) site.visualComponents.push(vc) + } + for (const vc of plan.components) { + const added = site.visualComponents.find((existing) => existing.id === vc.id) + if (added) { + const nodes = added.tree.nodes as Record + syncSlotInstancesIn(site, nodes, Object.keys(nodes)) + } + } + + // ── Nodes ────────────────────────────────────────────────────────────────── + let result: FragmentApplyResult + if (plan.page) { + const page = addPage(site, plan.page.title, plan.page.slug) + page.id = plan.page.id + page.nodes = { ...plan.nodes } + page.rootNodeId = plan.roots[0] + syncSlotInstancesIn(site, page.nodes, Object.keys(page.nodes)) + reindexNodeParents(page.nodes) + result = { rootIds: [page.rootNodeId], pageId: page.id } + } else { + if (!placement) throw new Error('[fragments] a section or component needs a placement') + const { tree, parentId } = placement + const parent = tree.nodes[parentId] + if (!parent) throw new Error(`[fragments] placement parent "${parentId}" not found`) + for (const [id, node] of Object.entries(plan.nodes)) tree.nodes[id] = node + const index = placement.index === undefined ? parent.children.length : Math.max(0, Math.min(placement.index, parent.children.length)) + parent.children.splice(index, 0, ...plan.roots) + syncSlotInstancesIn(site, tree.nodes, plan.roots.flatMap((rootId) => collectSubtreeIds(tree.nodes, rootId))) + reindexNodeParents(tree.nodes) + result = { rootIds: [...plan.roots] } + } + + if (plan.components.length > 0 || plan.page) reconcileSiteExplorerInPlace(site) + return { ...result, frameworkChanged: plan.colorTokens.length > 0 || plan.scaleSteps.length > 0 } +} + +/** + * Materialise slot instances for every component instance among `ids` + * against the component that now lives in the site (the site's own when it + * was reused by name), exactly as inserting the component by hand does. + */ +function syncSlotInstancesIn(site: SiteDocument, nodes: Record, ids: Iterable): void { + for (const id of [...ids]) { + const node = nodes[id] + if (node?.moduleId !== 'base.visual-component-ref') continue + const vc = site.visualComponents.find((v) => v.id === node.props.componentId) + if (vc) applySlotSyncResult(nodes, syncSlotInstances(node, vc, nodes), id) + } +} diff --git a/src/core/fragments/commit.ts b/src/core/fragments/commit.ts new file mode 100644 index 000000000..7bd72da18 --- /dev/null +++ b/src/core/fragments/commit.ts @@ -0,0 +1,288 @@ +/** + * commitFragmentPlan — carry out a plan. + * + * A. Media: reuse a library asset that is the same file, else upload. + * B. Fonts: install Google families, register custom ones from the uploads. + * C. Tables: create missing tables (and their rows), append missing fields. + * D. Resolve the plan against the ids A–C produced. + * E. ONE store transaction (`adapter.commit`): rules, conditions, tokens, + * fonts, components, and the nodes or the new page — one undo step. + * + * Failure: a media upload or table write that fails stops the insert BEFORE + * the transaction, so nothing touches the document; files and tables already + * created stay behind (the media library and Data workspace show them). A + * font that fails to install is a warning: its token falls back to its + * fallback stack. Cancellation (`signal`) stops at the next boundary and never + * after E has started. + */ + +import { isAbortError } from '@core/http' +import type { DataField } from '@core/data/schemas' +import type { FontEntry } from '@core/fonts' +import { createPathReplacer, mapStrings } from './references' +import { resolveFragmentPlan, type FragmentApplyResult } from './resolve' +import type { FragmentInsertAdapter, FragmentMediaRef } from './adapter' +import type { FragmentInsertPlan, FragmentInsertReport, PlannedFontFamily } from './planTypes' +import type { FragmentMedia } from './schema' +import type { FragmentWarning } from './warnings' + +export interface CommitFragmentPlanOptions { + /** The bytes of one media item (from the package, or fetched from its `src`). */ + readMediaBytes(media: FragmentMedia): Uint8Array | null | Promise + signal?: AbortSignal + /** Called as each piece of server work starts, for a progress line. */ + onProgress?(step: { phase: 'media' | 'fonts' | 'tables' | 'commit'; done: number; total: number; label?: string }): void +} + +export interface FragmentInsertResult extends FragmentApplyResult { + report: FragmentInsertReport +} + +export type FragmentCommitErrorStage = 'media' | 'tables' + +/** Server work failed before the transaction; the document is untouched. */ +export class FragmentCommitError extends Error { + readonly stage: FragmentCommitErrorStage + constructor(stage: FragmentCommitErrorStage, message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'FragmentCommitError' + this.stage = stage + } +} + +export async function commitFragmentPlan( + plan: FragmentInsertPlan, + adapter: FragmentInsertAdapter, + options: CommitFragmentPlanOptions, +): Promise { + const { signal } = options + const warnings: FragmentWarning[] = [] + + const media = await resolveMedia(plan.media, adapter, options) + const fontEntryByFamilyId = await installFonts(plan.fontFamilies, media.idById, adapter, options, warnings) + const tableIdMap = await writeTables(plan, media, adapter, options) + + const resolved = resolveFragmentPlan(plan, { + mediaUrlByPath: media.urlByPath, + tableIdMap, + fontEntryByFamilyId, + }) + + signal?.throwIfAborted() + options.onProgress?.({ phase: 'commit', done: 0, total: 1 }) + const applied = await adapter.commit(resolved) + + return { + ...applied, + report: { + ...plan.report, + media: { total: plan.media.length, uploaded: media.uploaded, reused: media.reused }, + warnings: [...plan.report.warnings, ...warnings], + }, + } +} + +// --------------------------------------------------------------------------- +// A. Media +// --------------------------------------------------------------------------- + +interface ResolvedMedia { + urlByPath: Map + idById: Map + uploaded: number + reused: number +} + +async function resolveMedia( + items: FragmentMedia[], + adapter: FragmentInsertAdapter, + options: CommitFragmentPlanOptions, +): Promise { + const out: ResolvedMedia = { urlByPath: new Map(), idById: new Map(), uploaded: 0, reused: 0 } + const byKey = new Map() + let done = 0 + for (const item of items) { + options.signal?.throwIfAborted() + options.onProgress?.({ phase: 'media', done, total: items.length, label: item.filename }) + let ref = item.key ? byKey.get(item.key) : undefined + if (!ref) { + try { + ref = await adapter.findMedia(item) ?? undefined + if (ref) out.reused += 1 + else { + const bytes = await options.readMediaBytes(item) + if (!bytes) throw new Error(`no bytes for ${item.src}`) + ref = await adapter.uploadMedia({ + filename: item.filename, + mimeType: item.mimeType, + bytes, + ...(item.alt ? { altText: item.alt } : {}), + signal: options.signal, + }) + out.uploaded += 1 + } + } catch (err) { + if (isAbortError(err)) throw err + throw new FragmentCommitError('media', `Could not add ${item.filename} to the media library: ${errorText(err)}`, { cause: err }) + } + if (item.key) byKey.set(item.key, ref) + } + out.urlByPath.set(item.path, ref.url) + out.idById.set(item.id, ref.id) + done += 1 + } + return out +} + +// --------------------------------------------------------------------------- +// B. Fonts +// --------------------------------------------------------------------------- + +async function installFonts( + families: PlannedFontFamily[], + mediaIdById: Map, + adapter: FragmentInsertAdapter, + options: CommitFragmentPlanOptions, + warnings: FragmentWarning[], +): Promise> { + const installed = new Map() + let done = 0 + for (const { fragmentFamilyId, entry } of families) { + options.signal?.throwIfAborted() + options.onProgress?.({ phase: 'fonts', done, total: families.length, label: entry.family }) + try { + if (entry.source === 'google') { + installed.set(fragmentFamilyId, await adapter.installGoogleFont({ + family: entry.family, + variants: entry.variants, + subsets: entry.subsets, + })) + } else { + const files = entry.files.flatMap((file) => { + const mediaAssetId = file.mediaAssetId ? mediaIdById.get(file.mediaAssetId) : undefined + return mediaAssetId ? [{ mediaAssetId, variant: file.variant }] : [] + }) + if (files.length === 0) throw new Error('none of its files reached the media library') + installed.set(fragmentFamilyId, await adapter.registerCustomFont({ family: entry.family, files })) + } + } catch (err) { + if (isAbortError(err)) throw err + warnings.push({ + code: 'font-install-failed', + message: `${entry.family} could not be installed (${errorText(err)}); text using it falls back.`, + subject: entry.family, + }) + } + done += 1 + } + return installed +} + +// --------------------------------------------------------------------------- +// C. Tables +// --------------------------------------------------------------------------- + +async function writeTables( + plan: FragmentInsertPlan, + media: ResolvedMedia, + adapter: FragmentInsertAdapter, + options: CommitFragmentPlanOptions, +): Promise> { + const tableIdMap = new Map() + for (const table of plan.tables) if (table.action === 'reuse') tableIdMap.set(table.fragmentTableId, table.tableId) + const replacePaths = createPathReplacer(media.urlByPath) + const total = plan.tables.length + let done = 0 + + try { + // Created tables first, relation fields to a table not created yet held + // back, so every relation can point at a real id. + const deferred: { tableId: string; fields: DataField[]; withRelations: DataField[] }[] = [] + for (const table of plan.tables) { + if (table.action !== 'create') continue + options.signal?.throwIfAborted() + options.onProgress?.({ phase: 'tables', done, total, label: table.slug }) + const ready = table.input.fields.filter((f) => f.type !== 'relation' || tableIdMap.has(f.targetTableId)) + const created = await adapter.createTable({ ...table.input, fields: ready.map((f) => withTableIds(f, tableIdMap)) }) + tableIdMap.set(table.fragmentTableId, created.id) + if (ready.length !== table.input.fields.length) { + deferred.push({ tableId: created.id, fields: ready, withRelations: table.input.fields }) + } + for (const row of table.rows) { + options.signal?.throwIfAborted() + await adapter.createRow(created.id, { + status: row.status, + cells: rewriteCells(table.input.fields, row.cells, media.idById, replacePaths), + }) + } + done += 1 + } + for (const item of deferred) { + await adapter.updateTableFields(item.tableId, item.withRelations.map((f) => withTableIds(f, tableIdMap))) + } + for (const table of plan.tables) { + if (table.action !== 'reuse' || table.addFields.length === 0) continue + options.signal?.throwIfAborted() + options.onProgress?.({ phase: 'tables', done, total, label: table.slug }) + await adapter.updateTableFields(table.tableId, [ + ...table.existingFields, + ...table.addFields.map((f) => withTableIds(f, tableIdMap)), + ]) + done += 1 + } + } catch (err) { + if (isAbortError(err)) throw err + throw new FragmentCommitError('tables', `Could not write the fragment's tables: ${errorText(err)}`, { cause: err }) + } + return tableIdMap +} + +function withTableIds(field: DataField, tableIdMap: Map): DataField { + if (field.type !== 'relation') return field + return { ...field, targetTableId: tableIdMap.get(field.targetTableId) ?? field.targetTableId } +} + +/** Media cells → new media ids; inline media paths in text → new URLs. */ +function rewriteCells( + fields: DataField[], + cells: Record, + mediaIdById: Map, + replacePaths: (text: string) => string, +): Record { + const out: Record = {} + for (const [key, value] of Object.entries(cells)) { + const field = fields.find((f) => f.id === key) + if (field && (field.type === 'media' || field.type === 'repeater')) { + out[key] = remapMediaIds(field, value, mediaIdById) + } else { + out[key] = mapStrings(value, replacePaths) + } + } + return out +} + +function remapMediaIds(field: DataField, value: unknown, mediaIdById: Map): unknown { + if (field.type === 'media') { + if (typeof value === 'string') return mediaIdById.get(value) ?? null + if (Array.isArray(value)) return value.flatMap((id) => (typeof id === 'string' && mediaIdById.has(id) ? [mediaIdById.get(id)!] : [])) + return value + } + if (field.type === 'repeater' && Array.isArray(value)) { + return value.map((item) => { + if (!item || typeof item !== 'object' || !('cells' in item)) return item + const cells = (item as { cells: unknown }).cells + if (!cells || typeof cells !== 'object') return item + const next: Record = {} + for (const [key, inner] of Object.entries(cells as Record)) { + const innerField = field.fields.find((f) => f.id === key) + next[key] = innerField ? remapMediaIds(innerField, inner, mediaIdById) : inner + } + return { ...(item as object), cells: next } + }) + } + return value +} + +function errorText(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err) +} diff --git a/src/core/fragments/index.ts b/src/core/fragments/index.ts index ca9e4ef38..2a4cf2a76 100644 --- a/src/core/fragments/index.ts +++ b/src/core/fragments/index.ts @@ -60,3 +60,11 @@ export type { PlannedTableCreate, PlannedTableReuse, } from './planTypes' + +export { resolveFragmentPlan } from './resolve' +export type { FragmentApplyResult, FragmentResolution, ResolvedFragmentPlan } from './resolve' +export { applyResolvedFragment } from './apply' +export type { FragmentPlacement } from './apply' +export type { FragmentInsertAdapter, FragmentMediaRef } from './adapter' +export { FragmentCommitError, commitFragmentPlan } from './commit' +export type { CommitFragmentPlanOptions, FragmentCommitErrorStage, FragmentInsertResult } from './commit' diff --git a/src/core/fragments/plan.ts b/src/core/fragments/plan.ts index e104731e3..292cca1fd 100644 --- a/src/core/fragments/plan.ts +++ b/src/core/fragments/plan.ts @@ -41,7 +41,7 @@ import { import { generateFrameworkUtilityClasses } from '@core/framework' import { firstOutletId, treeHasOutlet } from '@core/templates' import { forEachVCRef, vcSlugFromName, wouldCreateCycle, type VisualComponent } from '@core/visualComponents' -import { mapNodeStrings, rewritePageRefs } from './references' +import { mapNodeStrings, mapStrings, remapTableRefs, rewritePageRefs } from './references' import { planCustomProperties, planTokens, type TokenPlan } from './planTokens' import { planTables, referencedMedia } from './planData' import type { SiteFragment } from './schema' @@ -136,6 +136,12 @@ export function planFragmentInsert(target: FragmentInsertTarget, fragment: SiteF return next } + // Rows of created tables can hold links too (rich text). + for (const table of tables) { + if (table.action !== 'create') continue + table.rows = table.rows.map((row) => ({ ...row, cells: mapStrings(row.cells, (text) => rewritePageRefs(text, resolveLink)) })) + } + // Placed nodes. const nodes: Record = {} for (const [oldId, newId] of nodeIdMap) { @@ -434,23 +440,6 @@ function linkResolver( } } -function remapTableRefs(node: PageNode, tableIdMap: Map): PageNode { - if (tableIdMap.size === 0) return node - if (node.moduleId === 'base.loop') { - const filters = node.props.filters - if (filters && typeof filters === 'object' && !Array.isArray(filters)) { - const tableId = (filters as Record).tableId - const mapped = typeof tableId === 'string' ? tableIdMap.get(tableId) : undefined - if (mapped) return { ...node, props: { ...node.props, filters: { ...filters, tableId: mapped } } } - } - } - if (node.moduleId === 'base.form' && typeof node.props.targetTableId === 'string') { - const mapped = tableIdMap.get(node.props.targetTableId) - if (mapped) return { ...node, props: { ...node.props, targetTableId: mapped } } - } - return node -} - /** * Strip instances of components that neither travel in the fragment nor * exist in the site (whole instance subtree), like a saved layout heals refs diff --git a/src/core/fragments/references.ts b/src/core/fragments/references.ts index 1ccfa3de1..a3251ccb5 100644 --- a/src/core/fragments/references.ts +++ b/src/core/fragments/references.ts @@ -176,3 +176,24 @@ export function mediaIdsInCells(fields: DataField[], cells: Record): PageNode { + if (tableIdMap.size === 0) return node + if (node.moduleId === 'base.loop') { + const filters = node.props.filters + if (filters && typeof filters === 'object' && !Array.isArray(filters)) { + const tableId = (filters as Record).tableId + const mapped = typeof tableId === 'string' ? tableIdMap.get(tableId) : undefined + if (mapped) return { ...node, props: { ...node.props, filters: { ...filters, tableId: mapped } } } + } + } + if (node.moduleId === 'base.form' && typeof node.props.targetTableId === 'string') { + const mapped = tableIdMap.get(node.props.targetTableId) + if (mapped) return { ...node, props: { ...node.props, targetTableId: mapped } } + } + return node +} diff --git a/src/core/fragments/resolve.ts b/src/core/fragments/resolve.ts new file mode 100644 index 000000000..4aa699daa --- /dev/null +++ b/src/core/fragments/resolve.ts @@ -0,0 +1,81 @@ +/** + * resolveFragmentPlan — swap the fragment's placeholders for the real ids the + * server work produced: media paths for uploaded URLs, created-table ids for + * the fragment's table ids, installed font family ids for the fragment's. + * Pure; the result is what the single store transaction applies. + */ + +import type { FontEntry } from '@core/fonts' +import type { PageNode } from '@core/page-tree' +import { createPathReplacer, mapNodeStrings, mapRuleStrings, remapTableRefs } from './references' +import type { FragmentInsertPlan } from './planTypes' + +/** The plan with every reference resolved; what the store transaction applies. */ +export type ResolvedFragmentPlan = Omit & { + /** Font library entries installed or registered for this insert. */ + fontEntries: FontEntry[] +} + +/** What applying a resolved plan placed. */ +export interface FragmentApplyResult { + /** New root node ids, in order (a page fragment: the new page's body). */ + rootIds: string[] + /** The page a page fragment added. */ + pageId?: string +} + +export interface FragmentResolution { + /** Fragment media path → uploaded (or reused) URL. */ + mediaUrlByPath: ReadonlyMap + /** Fragment table id → site table id, for created tables. */ + tableIdMap: ReadonlyMap + /** Fragment font family id → installed entry. Failed installs are absent. */ + fontEntryByFamilyId: ReadonlyMap +} + +export function resolveFragmentPlan(plan: FragmentInsertPlan, resolution: FragmentResolution): ResolvedFragmentPlan { + const replacePaths = createPathReplacer(resolution.mediaUrlByPath) + const tableIdMap = new Map(resolution.tableIdMap) + const resolveNode = (node: TNode): TNode => + remapTableRefs(mapNodeStrings(node, replacePaths), tableIdMap) as TNode + + const nodes: Record = {} + for (const [id, node] of Object.entries(plan.nodes)) nodes[id] = resolveNode(node) + + const components = plan.components.map((vc) => { + const treeNodes: Record = {} + for (const [id, node] of Object.entries(vc.tree.nodes)) treeNodes[id] = resolveNode(node as PageNode) + return { ...vc, tree: { ...vc.tree, nodes: treeNodes } } + }) + + const fontTokens = plan.fontTokens.map((token) => { + if (!token.familyId) return token + const isFragmentFamily = plan.fontFamilies.some((f) => f.fragmentFamilyId === token.familyId) + if (!isFragmentFamily) return token + const installed = resolution.fontEntryByFamilyId.get(token.familyId) + if (installed) return { ...token, familyId: installed.id } + // The family did not install: the token falls back to its fallback stack. + const { familyId: _unresolved, ...rest } = token + return rest + }) + + const customProperties = plan.customProperties + ? { + ...plan.customProperties, + properties: Object.fromEntries( + Object.entries(plan.customProperties.properties).map(([name, value]) => [name, replacePaths(value)]), + ), + } + : null + + const { fontFamilies: _fontFamilies, tables: _tables, media: _media, ...rest } = plan + return { + ...rest, + nodes, + components, + styleRules: plan.styleRules.map((rule) => mapRuleStrings(rule, replacePaths)), + customProperties, + fontTokens, + fontEntries: [...resolution.fontEntryByFamilyId.values()], + } +} From 4426fda45be8b44de6feea8d5f98b57597a3d299 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:50:25 +0200 Subject: [PATCH 04/13] feat(editor): insert a fragment from a file through Spotlight "Insert fragment from file..." opens a dialog that reads a fragment package, plans the insert against the open document, and shows what it will add and what it keeps from the site before anything changes. Insert runs the commit and the dialog then lists what was added, including links that became "#", rules the fragment depended on through its old parents, and other notes. --- .../admin/insertFragmentModal.test.tsx | 103 ++++++++++ .../AdminCanvasEditorBody.tsx | 12 ++ .../modals/InsertFragment/FragmentReport.tsx | 120 +++++++++++ .../InsertFragmentModal.module.css | 98 +++++++++ .../InsertFragment/InsertFragmentModal.tsx | 193 ++++++++++++++++++ .../InsertFragment/fragmentInsertFlow.ts | 80 ++++++++ src/admin/modals/InsertFragment/index.ts | 1 + src/admin/spotlight/builtinCommands.ts | 2 + src/admin/spotlight/commands/fragments.ts | 37 ++++ 9 files changed, 646 insertions(+) create mode 100644 src/__tests__/admin/insertFragmentModal.test.tsx create mode 100644 src/admin/modals/InsertFragment/FragmentReport.tsx create mode 100644 src/admin/modals/InsertFragment/InsertFragmentModal.module.css create mode 100644 src/admin/modals/InsertFragment/InsertFragmentModal.tsx create mode 100644 src/admin/modals/InsertFragment/fragmentInsertFlow.ts create mode 100644 src/admin/modals/InsertFragment/index.ts create mode 100644 src/admin/spotlight/commands/fragments.ts diff --git a/src/__tests__/admin/insertFragmentModal.test.tsx b/src/__tests__/admin/insertFragmentModal.test.tsx new file mode 100644 index 000000000..9a9d1cdb7 --- /dev/null +++ b/src/__tests__/admin/insertFragmentModal.test.tsx @@ -0,0 +1,103 @@ +/** + * InsertFragmentModal — pick a package, review the plan, insert, read the report. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import React from 'react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { InsertFragmentModal } from '@admin/modals/InsertFragment' +import { getAllCommands } from '@admin/spotlight/builtinCommands' +import { useEditorStore } from '@site/store/store' +import { extractFragment, fragmentSourceFromSite, writeFragmentPackage } from '@core/fragments' +import type { StyleRule } from '@core/page-tree' +import { makeNode, makePage, makeSite } from '../fixtures' +import '@modules/base/index' + +afterEach(cleanup) + +const realFetch = globalThis.fetch + +function rule(id: string, name: string, styles: Record): 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/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..8ec1a3d2a --- /dev/null +++ b/src/admin/modals/InsertFragment/FragmentReport.tsx @@ -0,0 +1,120 @@ +/** + * 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 mediaLine = done + ? [`${report.media.uploaded} uploaded`, `${report.media.reused} already in the library`] + : report.media.total > 0 ? [`${report.media.total} file${report.media.total === 1 ? '' : 's'}`] : [] + 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..3c9df602c --- /dev/null +++ b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx @@ -0,0 +1,193 @@ +/** + * 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 { 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' }) + } +} + +async function insert( + loaded: LoadedFragment, + plan: FragmentInsertPlan, + controller: AbortController, + setStep: SetStep, +): Promise { + setStep({ kind: 'running', loaded, plan, line: 'Starting' }) + try { + const result = await runFragmentInsert(loaded, plan, 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)) { + 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) + + 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, 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 === 'page' ? 'page' : step.plan.kind === 'component' ? 'component' : 'section'} + {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/fragmentInsertFlow.ts b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts new file mode 100644 index 000000000..8daf8878d --- /dev/null +++ b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts @@ -0,0 +1,80 @@ +/** + * 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, + signal: AbortSignal, + onProgress: (line: string) => void, +): Promise { + return commitFragmentPlan(plan, createFragmentInsertAdapter(), { + 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/spotlight/builtinCommands.ts b/src/admin/spotlight/builtinCommands.ts index abe13eb69..2ab187bba 100644 --- a/src/admin/spotlight/builtinCommands.ts +++ b/src/admin/spotlight/builtinCommands.ts @@ -36,6 +36,7 @@ import { getUsersCommands } from './commands/users' import { getPreviewCommands } from './commands/preview' import { getAiAssistantCommands } from './commands/aiAssistant' import { getImportHtmlCommands } from './commands/importHtml' +import { getFragmentsCommands } from './commands/fragments' import { getSiteImportCommands } from './commands/siteImport' import { getSiteExportCommands } from './commands/siteExport' import { getBranchesCommands } from './commands/branches' @@ -83,6 +84,7 @@ export function getAllCommands(): Command[] { ...getSettingsCommands(), ...getPreviewCommands(), ...getImportHtmlCommands(), + ...getFragmentsCommands(), ...getSiteImportCommands(), ...getSiteExportCommands(), ...getBranchesCommands(), diff --git a/src/admin/spotlight/commands/fragments.ts b/src/admin/spotlight/commands/fragments.ts new file mode 100644 index 000000000..a0c354850 --- /dev/null +++ b/src/admin/spotlight/commands/fragments.ts @@ -0,0 +1,37 @@ +/** + * Insert fragment command — open the Insert fragment dialog, which inserts a + * section, page or component exported as a fragment package (`@core/fragments`). + * + * Site editor only: a fragment lands in the open document. Capability gate + * mirrors editor.save: any user who holds at least one site-write capability. + */ + +import type { Command } from '../types' + +/** Mirrors `SITE_WRITE_CAPABILITIES` in editor.ts. */ +const SITE_WRITE_CAPABILITIES = [ + 'site.structure.edit', + 'site.content.edit', + 'site.style.edit', +] as const + +export function getFragmentsCommands(): Command[] { + return [ + { + id: 'editor.insertFragment', + title: 'Insert fragment from file…', + subtitle: 'Insert a section, page or component with its styles, tokens and media', + group: 'editor', + iconName: 'layout-solid', + keywords: ['fragment', 'insert', 'section', 'import', 'package', 'zip', 'template', 'library'], + workspaces: ['site'], + capability: SITE_WRITE_CAPABILITIES, + run: async (ctx) => { + ctx.closeSpotlight() + // Lazy import keeps the editor store out of non-site bundles. + const { useEditorStore } = await import('@site/store/store') + useEditorStore.getState().openInsertFragmentModal() + }, + }, + ] +} From 0323c33e228d676a2f03bdd70c82e6ed772c77c9 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:59:19 +0200 Subject: [PATCH 05/13] fix(editor): run fragment table writes through the step-up prompt Creating a data table is a sensitive action, so the first real insert of a fragment with a loop or a form failed with step_up_required. Every server write of the insert now goes through runStepUp, which asks for the password and retries, and closing that prompt reads as a cancelled insert. The report also stops listing an empty media row. --- .../admin/insertFragmentModal.test.tsx | 3 ++- .../modals/InsertFragment/FragmentReport.tsx | 9 +++++--- .../InsertFragment/InsertFragmentModal.tsx | 15 ++++++++++--- .../createFragmentInsertAdapter.ts | 22 ++++++++++++------- .../InsertFragment/fragmentInsertFlow.ts | 3 ++- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/__tests__/admin/insertFragmentModal.test.tsx b/src/__tests__/admin/insertFragmentModal.test.tsx index 9a9d1cdb7..d5de47815 100644 --- a/src/__tests__/admin/insertFragmentModal.test.tsx +++ b/src/__tests__/admin/insertFragmentModal.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import React from 'react' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { InsertFragmentModal } from '@admin/modals/InsertFragment' +import { StepUpProvider } from '@admin/shared/StepUp' import { getAllCommands } from '@admin/spotlight/builtinCommands' import { useEditorStore } from '@site/store/store' import { extractFragment, fragmentSourceFromSite, writeFragmentPackage } from '@core/fragments' @@ -79,7 +80,7 @@ describe('Insert fragment', () => { }) it('reviews the plan, inserts on confirm, and reports what was added', async () => { - render() + render() const input = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(input, { target: { files: [await packageFile()] } }) diff --git a/src/admin/modals/InsertFragment/FragmentReport.tsx b/src/admin/modals/InsertFragment/FragmentReport.tsx index 8ec1a3d2a..9fd7303f9 100644 --- a/src/admin/modals/InsertFragment/FragmentReport.tsx +++ b/src/admin/modals/InsertFragment/FragmentReport.tsx @@ -22,9 +22,12 @@ function list(values: string[]): string { } function addedRows(report: FragmentInsertReport, done: boolean): ReportRow[] { - const mediaLine = done - ? [`${report.media.uploaded} uploaded`, `${report.media.reused} already in the library`] - : report.media.total > 0 ? [`${report.media.total} file${report.media.total === 1 ? '' : 's'}`] : [] + 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 }, diff --git a/src/admin/modals/InsertFragment/InsertFragmentModal.tsx b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx index 3c9df602c..9db71ca9f 100644 --- a/src/admin/modals/InsertFragment/InsertFragmentModal.tsx +++ b/src/admin/modals/InsertFragment/InsertFragmentModal.tsx @@ -20,6 +20,7 @@ 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' @@ -48,15 +49,22 @@ async function readAndPlan(file: File, setStep: SetStep): Promise { } } +/** The user closed the password prompt a table write asked for. */ +function isStepUpCancel(err: unknown): boolean { + const cause = err instanceof Error ? err.cause : undefined + return cause instanceof Error && cause.message === StepUpCancelledMessage +} + async function insert( loaded: LoadedFragment, plan: FragmentInsertPlan, + runStepUp: ReturnType['runStepUp'], controller: AbortController, setStep: SetStep, ): Promise { setStep({ kind: 'running', loaded, plan, line: 'Starting' }) try { - const result = await runFragmentInsert(loaded, plan, controller.signal, (line) => + const result = await runFragmentInsert(loaded, plan, runStepUp, controller.signal, (line) => setStep((current) => (current.kind === 'running' ? { ...current, line } : current)), ) setStep({ kind: 'done', loaded, result }) @@ -69,7 +77,7 @@ async function insert( location: 'site-editor', }) } catch (err) { - if (isAbortError(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) @@ -89,6 +97,7 @@ export function InsertFragmentModal() { 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') { @@ -107,7 +116,7 @@ export function InsertFragmentModal() { function handleInsert() { if (step.kind !== 'review') return controller.current = new AbortController() - void insert(step.loaded, step.plan, controller.current, setStep) + void insert(step.loaded, step.plan, runStepUp, controller.current, setStep) } function handleOpenPage(pageId: string) { diff --git a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts index 485375952..b7f5d26ab 100644 --- a/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts +++ b/src/admin/modals/InsertFragment/createFragmentInsertAdapter.ts @@ -10,6 +10,10 @@ * 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' @@ -23,7 +27,9 @@ import { } from '@core/persistence/cmsData' import { useEditorStore } from '@site/store/store' -export function createFragmentInsertAdapter(): FragmentInsertAdapter { +type RunStepUp = (action: () => Promise) => Promise + +export function createFragmentInsertAdapter(runStepUp: RunStepUp): FragmentInsertAdapter { let library: Promise | null = null return { @@ -42,26 +48,26 @@ export function createFragmentInsertAdapter(): FragmentInsertAdapter { 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 uploadCmsMediaAsset(file, { signal, altText }) + const asset = await runStepUp(() => uploadCmsMediaAsset(file, { signal, altText })) return { id: asset.id, url: asset.publicPath } }, - installGoogleFont: (request) => installCmsGoogleFont(request), + installGoogleFont: (request) => runStepUp(() => installCmsGoogleFont(request)), - registerCustomFont: (request) => registerCustomFont(request), + registerCustomFont: (request) => runStepUp(() => registerCustomFont(request)), async createTable(input) { - const table = await createCmsDataTable(input) + const table = await runStepUp(() => createCmsDataTable(input)) return { id: table.id } }, async updateTableFields(tableId, fields) { - await updateCmsDataTable(tableId, { fields }) + await runStepUp(() => updateCmsDataTable(tableId, { fields })) }, async createRow(tableId, row) { - const created = await createCmsDataRow(tableId, { cells: row.cells }) - if (row.status === 'published') await publishCmsDataRow(created.id) + const created = await runStepUp(() => createCmsDataRow(tableId, { cells: row.cells })) + if (row.status === 'published') await runStepUp(() => publishCmsDataRow(created.id)) return { id: created.id } }, diff --git a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts index 8daf8878d..f316a3a66 100644 --- a/src/admin/modals/InsertFragment/fragmentInsertFlow.ts +++ b/src/admin/modals/InsertFragment/fragmentInsertFlow.ts @@ -64,10 +64,11 @@ export async function planLoadedFragment(loaded: LoadedFragment): Promise(action: () => Promise) => Promise, signal: AbortSignal, onProgress: (line: string) => void, ): Promise { - return commitFragmentPlan(plan, createFragmentInsertAdapter(), { + return commitFragmentPlan(plan, createFragmentInsertAdapter(runStepUp), { readMediaBytes: loaded.readMedia, signal, onProgress: ({ phase, done, total, label }) => { From d458dcf0d4f1165f25382f35def1addd7db03e1d Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 17:06:56 +0200 Subject: [PATCH 06/13] fix(fragments): keep seeded font roles and skip families nothing uses New sites now start with font-heading, font-body and font-mono, so a fragment's token of the same name is kept and the family the fragment bound to it is no longer needed. The planner now installs a family only when an added token binds to it or a carried rule names it, so inserting into a new site never downloads a font or uploads a font file for a token the site keeps. --- .../editor-store/insertFragment.test.ts | 9 ++++ src/__tests__/fragments/plan.test.ts | 16 ++++++ src/core/fragments/planTokens.ts | 52 ++++++++++++++----- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/__tests__/editor-store/insertFragment.test.ts b/src/__tests__/editor-store/insertFragment.test.ts index cc67dd3c8..42f853e3e 100644 --- a/src/__tests__/editor-store/insertFragment.test.ts +++ b/src/__tests__/editor-store/insertFragment.test.ts @@ -72,6 +72,15 @@ describe('insertFragment', () => { expect(image.props.src).toBe('/uploads/copied-photo.jpg') }) + it("never duplicates the seeded font role tokens of a new site", async () => { + 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(await sectionFragment())) + const after = useEditorStore.getState().site!.settings.fonts?.tokens?.map((t) => t.variable) ?? [] + expect(after).toEqual(before) + }) + it('one undo removes the whole insert', async () => { const site = useEditorStore.getState().createSite('Target') const before = useEditorStore.getState().site! diff --git a/src/__tests__/fragments/plan.test.ts b/src/__tests__/fragments/plan.test.ts index c08b5b0c9..8fb51d5e6 100644 --- a/src/__tests__/fragments/plan.test.ts +++ b/src/__tests__/fragments/plan.test.ts @@ -13,6 +13,7 @@ import { 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 { @@ -149,6 +150,21 @@ describe('planFragmentInsert — tokens', () => { expect(plan.fontTokens[0].familyId).toBe(plan.fontFamilies[0].fragmentFamilyId) }) + it("keeps a new site's seeded font roles and installs nothing for them", async () => { + const target = makeTargetSite() + target.settings.fonts = { items: [], tokens: buildFontRoleTokens() } + const plan = planFragmentInsert( + { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, + await sectionFragment(), + ) + 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', async () => { const { plan } = await planIntoTarget() expect(plan.report.customProperties).toEqual({ added: ['--shadow'], kept: ['--radius'] }) diff --git a/src/core/fragments/planTokens.ts b/src/core/fragments/planTokens.ts index 45fa9b238..6dd9db854 100644 --- a/src/core/fragments/planTokens.ts +++ b/src/core/fragments/planTokens.ts @@ -94,10 +94,32 @@ export function planTokens( : undefined // ── Fonts ────────────────────────────────────────────────────────────────── + // Tokens first: a token the site already has (a seeded `font-heading` role, + // say) is kept, so the family the fragment bound to it is not needed. + const existingTokens = site.settings.fonts?.tokens ?? [] + const variables = new Set(existingTokens.map((t) => normalizeFontTokenVariable(t.variable))) + const addedTokens: FontToken[] = [] + for (const token of fragment.tokens.fontTokens) { + const variable = normalizeFontTokenVariable(token.variable) + if (variables.has(variable)) { + report.fonts.tokensKept.push(variable) + continue + } + variables.add(variable) + addedTokens.push({ ...token, variable }) + report.fonts.tokensAdded.push(variable) + } + + // Families: the ones an added token binds to, and the ones a carried rule + // names literally in `font-family`. An existing family of the same name is + // reused; the rest are installed. + const declared = declaredFontFamilies(fragment) const families = site.settings.fonts?.items ?? [] const fontFamilies: PlannedFontFamily[] = [] const reusedFamilyIds = new Map() for (const family of fragment.tokens.fontFamilies) { + const needed = addedTokens.some((t) => t.familyId === family.id) || declared.has(family.family.toLowerCase()) + if (!needed) continue const match = families.find((f) => f.family.toLowerCase() === family.family.toLowerCase()) if (match) { reusedFamilyIds.set(family.id, match.id) @@ -107,17 +129,9 @@ export function planTokens( report.fonts.familiesAdded.push(family.family) } } - const existingTokens = site.settings.fonts?.tokens ?? [] - const variables = new Set(existingTokens.map((t) => normalizeFontTokenVariable(t.variable))) + let fontOrder = existingTokens.reduce((max, t) => Math.max(max, t.order), -1) - const fontTokens: FontToken[] = [] - for (const token of fragment.tokens.fontTokens) { - const variable = normalizeFontTokenVariable(token.variable) - if (variables.has(variable)) { - report.fonts.tokensKept.push(variable) - continue - } - variables.add(variable) + const fontTokens: FontToken[] = addedTokens.map((token) => { fontOrder += 1 // A family being installed keeps its fragment id here; resolve swaps in // the installed entry's id. @@ -126,9 +140,8 @@ export function planTokens( ?? (fontFamilies.some((f) => f.fragmentFamilyId === token.familyId) ? token.familyId : undefined) : undefined const { familyId: _sourceFamilyId, ...rest } = token - fontTokens.push({ ...rest, id: nanoid(), variable, ...(familyId ? { familyId } : {}), order: fontOrder, createdAt: now, updatedAt: now }) - report.fonts.tokensAdded.push(variable) - } + return { ...rest, id: nanoid(), ...(familyId ? { familyId } : {}), order: fontOrder, createdAt: now, updatedAt: now } + }) return { colorTokens, scaleSteps, nextFramework, fontFamilies, reusedFamilyIds, fontTokens } } @@ -261,3 +274,16 @@ export function findFragmentPropertiesRule(rules: Record): St (rule) => rule.kind === 'ambient' && rule.selector === ':root' && rule.origin?.source === FRAGMENT_PROPERTIES_ORIGIN, ) } + +/** Lowercased family names the fragment's rules declare in `font-family`. */ +function declaredFontFamilies(fragment: SiteFragment): Set { + const names = new Set() + for (const rule of fragment.styleRules) { + for (const bag of [rule.styles, ...Object.values(rule.contextStyles)]) { + const value = bag.fontFamily + if (typeof value !== 'string') continue + for (const part of value.split(',')) names.add(part.trim().replace(/^["']|["']$/g, '').toLowerCase()) + } + } + return names +} From b6ee768f17dd54724fa072522a56ce416565336a Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 17:08:36 +0200 Subject: [PATCH 07/13] docs(fragments): document the fragment format, engine and insert command Adds the feature doc (format, closure, merge rules, commit, script usage) and links it from the docs that describe paste, saved layouts, Spotlight, HTML import, site import and site transfer. Also records the new headless gate, the shared style rule registry helpers, the fragments store slice, and refreshes the gate counts. --- docs/README.md | 2 + docs/architecture.md | 3 +- docs/editor.md | 8 +- docs/features/fragments.md | 195 +++++++++++++++++++++++++++ docs/features/html-import.md | 1 + docs/features/site-import.md | 1 + docs/features/site-transfer.md | 1 + docs/features/spotlight.md | 3 +- docs/reference/architecture-tests.md | 10 +- docs/reference/page-tree.md | 6 + 10 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 docs/features/fragments.md 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..12b4eec8b --- /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 where `resolveInsertLocation` puts 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` / `indexStyleRulesByName` +- [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..6eccc3268 100644 --- a/docs/reference/page-tree.md +++ b/docs/reference/page-tree.md @@ -178,6 +178,12 @@ All mutations live in `src/core/page-tree/mutations.ts`. They take a `NodeTree

Date: Tue, 13 Oct 2026 17:56:17 +0200 Subject: [PATCH 08/13] fix(editor): insert a fragment after the selected element, not inside it After an insert the new section stays selected, and the paste rule put the next fragment inside that container, so two sections inserted in a row ended up nested. Fragments now land as the next sibling of the selection, or at the end of the document. --- docs/features/fragments.md | 2 +- .../editor-store/insertFragment.test.ts | 14 ++++++++++++ src/admin/pages/site/store/insertLocation.ts | 22 +++++++++++++++++++ .../pages/site/store/slices/fragmentsSlice.ts | 12 +++++----- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/features/fragments.md b/docs/features/fragments.md index 12b4eec8b..6938a621a 100644 --- a/docs/features/fragments.md +++ b/docs/features/fragments.md @@ -148,7 +148,7 @@ 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 where `resolveInsertLocation` puts a paste, first root selected) or `mutateSite` (pages), plus `reconcileFrameworkClasses` when a colour or step was added. One undo step; the change syncs through collab like any edit. The plan's `document` must still be the active one. +- The editor's E is `insertFragment` (`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). --- diff --git a/src/__tests__/editor-store/insertFragment.test.ts b/src/__tests__/editor-store/insertFragment.test.ts index 42f853e3e..fa46d9163 100644 --- a/src/__tests__/editor-store/insertFragment.test.ts +++ b/src/__tests__/editor-store/insertFragment.test.ts @@ -72,6 +72,20 @@ describe('insertFragment', () => { expect(image.props.src).toBe('/uploads/copied-photo.jpg') }) + it('a second insert lands after the still-selected first section, not inside it', async () => { + const site = useEditorStore.getState().createSite('Target') + const rootId = site.pages[0].rootNodeId + + const first = useEditorStore.getState().insertFragment(resolvedPlanForStore(await sectionFragment())) + expect(useEditorStore.getState().selectedNodeId).toBe(first!.rootIds[0]) + const second = useEditorStore.getState().insertFragment(resolvedPlanForStore(await sectionFragment())) + + 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", async () => { useEditorStore.getState().createSite('Target') const before = useEditorStore.getState().site!.settings.fonts?.tokens?.map((t) => t.variable) ?? [] 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 index 4dffc65ff..5e79b462f 100644 --- a/src/admin/pages/site/store/slices/fragmentsSlice.ts +++ b/src/admin/pages/site/store/slices/fragmentsSlice.ts @@ -8,9 +8,11 @@ * framework utility classes are regenerated in the same recipe when the * insert added a colour or a scale step. * - * Placement mirrors saved layouts: a section or component lands at the - * location resolved from the current selection (inside a container, else - * after it), in the document the plan was made for. + * 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 { @@ -19,7 +21,7 @@ import { type FragmentInsertDocument, type ResolvedFragmentPlan, } from '@core/fragments' -import { resolveInsertLocation } from '@site/store/insertLocation' +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' @@ -89,7 +91,7 @@ export const createFragmentsSlice: EditorStoreSliceCreator = (se if (!sameDocument(fragmentInsertDocument(state), plan.document)) return null const target = resolveActiveTreeTarget(state) if (!target) return null - const location = resolveInsertLocation(target.tree, state.selectedNodeId ?? target.tree.rootNodeId) + const location = resolveSiblingInsertLocation(target.tree, state.selectedNodeId ?? target.tree.rootNodeId) if (!location) return null const accepted = mutateActiveTreeAndSite((tree, site) => { From f8d0c146ff1055c0c32bd77b8bab5b34c2851b16 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 18:58:55 +0200 Subject: [PATCH 09/13] refactor(fragments): reuse the core helpers the engine had copied The fragment engine carried its own copies of helpers Instatic already has: the root-scope selector test from the site importer, the repeater-aware media id walk from the data.rows loop source, the framework class id prefix, the condition registration the paste and site import paths both inline, the page ref builder, the subtree id map and the error message helper. Each copy was a second place to keep in step. The shared ones now live in core (isRootScopeSelector in page-tree, collectCellMediaIds in data/cells, addMissingConditions in page-tree, FRAMEWORK_ID_PREFIX exported from the framework) and every caller, old and new, goes through them. It also drops what nothing used: the extractor's mediaSrc option (no caller passes it), the plan's create flag on custom properties (apply looks the rule up itself), a second copy of the font-family scan, and the move of indexStyleRulesByName into core, which only the editor store needs. --- src/__tests__/fragments/plan.test.ts | 6 +-- .../pages/site/store/slices/site/helpers.ts | 13 ++--- .../site/store/slices/site/importLinking.ts | 13 +++++ .../site/store/slices/site/nodeActions.ts | 14 ++---- src/core/data/cells.ts | 23 +++++++++ src/core/fragments/apply.ts | 7 +-- src/core/fragments/commit.ts | 11 ++--- src/core/fragments/extract.ts | 48 ++++--------------- src/core/fragments/extractData.ts | 28 ++++------- src/core/fragments/extractStyles.ts | 2 +- src/core/fragments/extractTokens.ts | 16 ++----- src/core/fragments/plan.ts | 6 +-- src/core/fragments/planData.ts | 33 ++++--------- src/core/fragments/planTokens.ts | 38 ++++----------- src/core/fragments/planTypes.ts | 16 ++----- src/core/fragments/references.ts | 48 ++++++++----------- src/core/fragments/schema.ts | 20 ++++---- src/core/fragments/siteVariables.ts | 14 +----- src/core/framework/frameworkUsage.ts | 2 +- src/core/loops/sources/dataRowsMedia.ts | 21 +------- src/core/page-tree/condition.ts | 21 ++++++++ src/core/page-tree/index.ts | 12 ++++- src/core/page-tree/styleRule.ts | 12 +++++ src/core/page-tree/styleRuleRegistry.ts | 20 +------- src/core/siteImport/colorTokens.ts | 2 +- src/core/siteImport/fontTokens.ts | 2 +- src/core/siteImport/rootScope.ts | 16 ------- 27 files changed, 178 insertions(+), 286 deletions(-) delete mode 100644 src/core/siteImport/rootScope.ts diff --git a/src/__tests__/fragments/plan.test.ts b/src/__tests__/fragments/plan.test.ts index 8fb51d5e6..5fc0e2247 100644 --- a/src/__tests__/fragments/plan.test.ts +++ b/src/__tests__/fragments/plan.test.ts @@ -166,9 +166,9 @@ describe('planFragmentInsert — tokens', () => { }) it('puts root custom properties the site lacks into one engine-owned :root rule', async () => { - const { plan } = await planIntoTarget() + const { plan, target } = await planIntoTarget() expect(plan.report.customProperties).toEqual({ added: ['--shadow'], kept: ['--radius'] }) - expect(plan.customProperties?.create).toBe(true) + expect(target.styleRules[plan.customProperties!.ruleId]).toBeUndefined() expect(plan.customProperties?.properties).toEqual({ '--shadow': '0 0 4px var(--primary-50)' }) }) @@ -184,7 +184,7 @@ describe('planFragmentInsert — tokens', () => { { site: target, tables: TARGET_TABLES, document: { kind: 'page', pageId: 't-home' } }, await sectionFragment(), ) - expect(plan.customProperties).toEqual({ ruleId: 'owned', create: false, order: 50, properties: { '--shadow': '0 0 4px var(--primary-50)' } }) + expect(plan.customProperties).toEqual({ ruleId: 'owned', order: 50, properties: { '--shadow': '0 0 4px var(--primary-50)' } }) }) }) diff --git a/src/admin/pages/site/store/slices/site/helpers.ts b/src/admin/pages/site/store/slices/site/helpers.ts index 87f5388b1..fea9addb3 100644 --- a/src/admin/pages/site/store/slices/site/helpers.ts +++ b/src/admin/pages/site/store/slices/site/helpers.ts @@ -12,10 +12,10 @@ import { nanoid } from 'nanoid' import type { StoreApi } from 'zustand' import type { NodeTree, PageNode, StyleRule, SiteDocument } from '@core/page-tree' import { + addMissingConditions, addPage, createNode, createStyleRuleOrderAllocator, - indexStyleRulesByName, reconcileSiteExplorerInPlace, reindexNodeParents, type StyleRuleOrderAllocator, @@ -32,6 +32,7 @@ import { applyLocalSitePatches, notifyCollabBlocked } from './collabBinding' import type { EditorStore } from '@site/store/types' import { reconcileFrameworkClasses } from './framework/reconcile' import { + indexStyleRulesByName, linkImportedClassNames, findReimportedStyleRule, indexStyleRulesByOrigin, @@ -477,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 f0e00b2ab..c2b642f12 100644 --- a/src/admin/pages/site/store/slices/site/importLinking.ts +++ b/src/admin/pages/site/store/slices/site/importLinking.ts @@ -11,6 +11,19 @@ import { classKindSelector } from '@core/page-tree' import type { StyleRule, StyleRuleOrderAllocator, StyleRuleOrigin } from '@core/page-tree' import type { NewStyleRule } from '@core/siteImport' +/** + * Index a StyleRule registry by name → id. + * First id wins on duplicates (createClass enforces name uniqueness, so + * duplicates only occur in corrupted data — first-wins is a defensive tiebreak). + */ +export function indexStyleRulesByName(rules: Record): Map { + const byName = new Map() + for (const cls of Object.values(rules)) { + if (!byName.has(cls.name)) byName.set(cls.name, cls.id) + } + return byName +} + /** * The identity a re-import reconciles against: import origin (stylesheet + * ordinal) AND selector. Origin alone is not unique in the registry — a class diff --git a/src/admin/pages/site/store/slices/site/nodeActions.ts b/src/admin/pages/site/store/slices/site/nodeActions.ts index e173ef71f..1979763f2 100644 --- a/src/admin/pages/site/store/slices/site/nodeActions.ts +++ b/src/admin/pages/site/store/slices/site/nodeActions.ts @@ -30,8 +30,8 @@ import { wrapNode, wrapNodes, reindexNodeParents, + addMissingConditions, createStyleRuleOrderAllocator, - indexStyleRulesByName, } from '@core/page-tree' import type { NodeTree, PageNode, SiteDocument } from '@core/page-tree' import { subtreeHasOutlet, treeHasOutlet } from '@core/templates' @@ -39,7 +39,7 @@ import { wouldCreateCycle, syncSlotInstances, applySlotSyncResult } from '@core/ import { pushToast } from '@ui/components/Toast' import { depthInTree, resolveActiveTreeTarget } from './helpers' import { pruneCanvasSelectionDraft } from '../selectionSlice' -import { linkImportedClassNames, mergeImportedStyleRules } from './importLinking' +import { indexStyleRulesByName, linkImportedClassNames, mergeImportedStyleRules } from './importLinking' import type { SiteSlice, SiteSliceHelpers } from './types' type NodeActions = Pick< @@ -229,15 +229,7 @@ export function createNodeActions(helpers: SiteSliceHelpers): NodeActions { } // Register any reusable conditions (custom @media / @container / // @supports) the