@kywi-software/core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-PATTERNS.md +111 -23
- package/README.md +67 -0
- package/dist/admin/content-form.d.ts.map +1 -1
- package/dist/admin/content-form.js +97 -10
- package/dist/admin/content-form.js.map +1 -1
- package/dist/admin/layout-editor/component-actions.d.ts +219 -22
- package/dist/admin/layout-editor/component-actions.d.ts.map +1 -1
- package/dist/admin/layout-editor/component-actions.js +356 -36
- package/dist/admin/layout-editor/component-actions.js.map +1 -1
- package/dist/admin/layout-editor/component-fork.d.ts +224 -0
- package/dist/admin/layout-editor/component-fork.d.ts.map +1 -0
- package/dist/admin/layout-editor/component-fork.js +342 -0
- package/dist/admin/layout-editor/component-fork.js.map +1 -0
- package/dist/admin/layout-editor/editor-canvas.d.ts +28 -7
- package/dist/admin/layout-editor/editor-canvas.d.ts.map +1 -1
- package/dist/admin/layout-editor/editor-canvas.js +145 -32
- package/dist/admin/layout-editor/editor-canvas.js.map +1 -1
- package/dist/admin/layout-editor/editor-dialog.d.ts +164 -0
- package/dist/admin/layout-editor/editor-dialog.d.ts.map +1 -0
- package/dist/admin/layout-editor/editor-dialog.js +195 -0
- package/dist/admin/layout-editor/editor-dialog.js.map +1 -0
- package/dist/admin/layout-editor/editor-state.d.ts +76 -0
- package/dist/admin/layout-editor/editor-state.d.ts.map +1 -1
- package/dist/admin/layout-editor/editor-state.js +47 -1
- package/dist/admin/layout-editor/editor-state.js.map +1 -1
- package/dist/admin/layout-editor/editor.css +366 -4
- package/dist/admin/layout-editor/index.d.ts +10 -1
- package/dist/admin/layout-editor/index.d.ts.map +1 -1
- package/dist/admin/layout-editor/index.js +11 -1
- package/dist/admin/layout-editor/index.js.map +1 -1
- package/dist/admin/layout-editor/inline-text.d.ts +56 -4
- package/dist/admin/layout-editor/inline-text.d.ts.map +1 -1
- package/dist/admin/layout-editor/inline-text.js +134 -11
- package/dist/admin/layout-editor/inline-text.js.map +1 -1
- package/dist/admin/layout-editor/layout-editor.d.ts +58 -1
- package/dist/admin/layout-editor/layout-editor.d.ts.map +1 -1
- package/dist/admin/layout-editor/layout-editor.js +160 -190
- package/dist/admin/layout-editor/layout-editor.js.map +1 -1
- package/dist/admin/layout-editor/linked-component.d.ts +39 -0
- package/dist/admin/layout-editor/linked-component.d.ts.map +1 -0
- package/dist/admin/layout-editor/linked-component.js +42 -0
- package/dist/admin/layout-editor/linked-component.js.map +1 -0
- package/dist/admin/layout-editor/module-overlay.d.ts +36 -14
- package/dist/admin/layout-editor/module-overlay.d.ts.map +1 -1
- package/dist/admin/layout-editor/module-overlay.js +233 -25
- package/dist/admin/layout-editor/module-overlay.js.map +1 -1
- package/dist/admin/layout-editor/node-lookup.d.ts +35 -0
- package/dist/admin/layout-editor/node-lookup.d.ts.map +1 -1
- package/dist/admin/layout-editor/node-lookup.js +91 -0
- package/dist/admin/layout-editor/node-lookup.js.map +1 -1
- package/dist/admin/layout-editor/overlay-shell.d.ts.map +1 -1
- package/dist/admin/layout-editor/overlay-shell.js +150 -32
- package/dist/admin/layout-editor/overlay-shell.js.map +1 -1
- package/dist/admin/layout-editor/props-panel.d.ts +54 -1
- package/dist/admin/layout-editor/props-panel.d.ts.map +1 -1
- package/dist/admin/layout-editor/props-panel.js +119 -10
- package/dist/admin/layout-editor/props-panel.js.map +1 -1
- package/dist/admin/layout-editor/section-overlay.d.ts +16 -1
- package/dist/admin/layout-editor/section-overlay.d.ts.map +1 -1
- package/dist/admin/layout-editor/section-overlay.js +21 -11
- package/dist/admin/layout-editor/section-overlay.js.map +1 -1
- package/dist/admin/layout-editor/section-template-picker.d.ts +27 -1
- package/dist/admin/layout-editor/section-template-picker.d.ts.map +1 -1
- package/dist/admin/layout-editor/section-template-picker.js +71 -17
- package/dist/admin/layout-editor/section-template-picker.js.map +1 -1
- package/dist/admin/layout-editor/section-variant-container-config.js +5 -5
- package/dist/admin/layout-editor/section-variant-container-config.js.map +1 -1
- package/dist/admin/layout-editor/use-chrome-flip.d.ts +111 -11
- package/dist/admin/layout-editor/use-chrome-flip.d.ts.map +1 -1
- package/dist/admin/layout-editor/use-chrome-flip.js +233 -33
- package/dist/admin/layout-editor/use-chrome-flip.js.map +1 -1
- package/dist/admin/layout-editor/use-component-connect.d.ts +61 -0
- package/dist/admin/layout-editor/use-component-connect.d.ts.map +1 -0
- package/dist/admin/layout-editor/use-component-connect.js +292 -0
- package/dist/admin/layout-editor/use-component-connect.js.map +1 -0
- package/dist/admin/layout-editor/use-component-fork.d.ts +123 -0
- package/dist/admin/layout-editor/use-component-fork.d.ts.map +1 -0
- package/dist/admin/layout-editor/use-component-fork.js +346 -0
- package/dist/admin/layout-editor/use-component-fork.js.map +1 -0
- package/dist/admin/layout-editor/use-component-library.d.ts +94 -0
- package/dist/admin/layout-editor/use-component-library.d.ts.map +1 -0
- package/dist/admin/layout-editor/use-component-library.js +55 -0
- package/dist/admin/layout-editor/use-component-library.js.map +1 -0
- package/dist/admin/registry.d.ts +11 -2
- package/dist/admin/registry.d.ts.map +1 -1
- package/dist/admin/registry.js +40 -4
- package/dist/admin/registry.js.map +1 -1
- package/dist/admin/server.d.ts +1 -0
- package/dist/admin/server.d.ts.map +1 -1
- package/dist/admin/server.js +5 -0
- package/dist/admin/server.js.map +1 -1
- package/dist/admin/styles/admin.css +57 -1
- package/dist/admin/surfaces/components/component-edit.d.ts +3 -0
- package/dist/admin/surfaces/components/component-edit.d.ts.map +1 -0
- package/dist/admin/surfaces/components/component-edit.js +235 -0
- package/dist/admin/surfaces/components/component-edit.js.map +1 -0
- package/dist/admin/surfaces/components/components-data.d.ts +153 -0
- package/dist/admin/surfaces/components/components-data.d.ts.map +1 -0
- package/dist/admin/surfaces/components/components-data.js +154 -0
- package/dist/admin/surfaces/components/components-data.js.map +1 -0
- package/dist/admin/surfaces/components/components-list.d.ts +3 -0
- package/dist/admin/surfaces/components/components-list.d.ts.map +1 -0
- package/dist/admin/surfaces/components/components-list.js +196 -0
- package/dist/admin/surfaces/components/components-list.js.map +1 -0
- package/dist/admin/surfaces/components/definition-canvas.d.ts +73 -0
- package/dist/admin/surfaces/components/definition-canvas.d.ts.map +1 -0
- package/dist/admin/surfaces/components/definition-canvas.js +207 -0
- package/dist/admin/surfaces/components/definition-canvas.js.map +1 -0
- package/dist/admin/surfaces/layouts/layout-editor-client.d.ts.map +1 -1
- package/dist/admin/surfaces/layouts/layout-editor-client.js +14 -2
- package/dist/admin/surfaces/layouts/layout-editor-client.js.map +1 -1
- package/dist/admin/surfaces/layouts/layouts-data.d.ts +3 -14
- package/dist/admin/surfaces/layouts/layouts-data.d.ts.map +1 -1
- package/dist/admin/surfaces/layouts/layouts-data.js +4 -7
- package/dist/admin/surfaces/layouts/layouts-data.js.map +1 -1
- package/dist/admin/surfaces/layouts/layouts-list.d.ts.map +1 -1
- package/dist/admin/surfaces/layouts/layouts-list.js +16 -22
- package/dist/admin/surfaces/layouts/layouts-list.js.map +1 -1
- package/dist/admin/surfaces/settings/admin-features.d.ts.map +1 -1
- package/dist/admin/surfaces/settings/admin-features.js +1 -0
- package/dist/admin/surfaces/settings/admin-features.js.map +1 -1
- package/dist/api/bulk-routes.d.ts.map +1 -1
- package/dist/api/bulk-routes.js +25 -0
- package/dist/api/bulk-routes.js.map +1 -1
- package/dist/api/changeset-routes.d.ts.map +1 -1
- package/dist/api/changeset-routes.js +17 -3
- package/dist/api/changeset-routes.js.map +1 -1
- package/dist/api/component-routes.d.ts.map +1 -1
- package/dist/api/component-routes.js +228 -8
- package/dist/api/component-routes.js.map +1 -1
- package/dist/api/content-routes.d.ts.map +1 -1
- package/dist/api/content-routes.js +64 -0
- package/dist/api/content-routes.js.map +1 -1
- package/dist/api/feed-routes.d.ts +6 -6
- package/dist/api/graphql/resolvers.d.ts +4 -4
- package/dist/api/layout-routes.d.ts.map +1 -1
- package/dist/api/layout-routes.js +4 -17
- package/dist/api/layout-routes.js.map +1 -1
- package/dist/api/layout-write-guard.d.ts +80 -0
- package/dist/api/layout-write-guard.d.ts.map +1 -0
- package/dist/api/layout-write-guard.js +88 -0
- package/dist/api/layout-write-guard.js.map +1 -0
- package/dist/api/section-template-routes.d.ts.map +1 -1
- package/dist/api/section-template-routes.js +11 -0
- package/dist/api/section-template-routes.js.map +1 -1
- package/dist/api/translation-routes.d.ts.map +1 -1
- package/dist/api/translation-routes.js +8 -0
- package/dist/api/translation-routes.js.map +1 -1
- package/dist/api/version-routes.d.ts.map +1 -1
- package/dist/api/version-routes.js +20 -8
- package/dist/api/version-routes.js.map +1 -1
- package/dist/api/workflow-routes.d.ts.map +1 -1
- package/dist/api/workflow-routes.js +53 -8
- package/dist/api/workflow-routes.js.map +1 -1
- package/dist/db/analytics-queries.d.ts +3 -3
- package/dist/db/apply-snapshot.d.ts +75 -8
- package/dist/db/apply-snapshot.d.ts.map +1 -1
- package/dist/db/apply-snapshot.js +104 -8
- package/dist/db/apply-snapshot.js.map +1 -1
- package/dist/db/audit-queries.d.ts +12 -12
- package/dist/db/category-queries.d.ts +3 -3
- package/dist/db/changeset-audit-queries.d.ts +4 -4
- package/dist/db/changeset-queries.d.ts +7 -7
- package/dist/db/comment-queries.d.ts +8 -8
- package/dist/db/comment-subscription-queries.d.ts +2 -2
- package/dist/db/component-instance-queries.d.ts +197 -0
- package/dist/db/component-instance-queries.d.ts.map +1 -0
- package/dist/db/component-instance-queries.js +405 -0
- package/dist/db/component-instance-queries.js.map +1 -0
- package/dist/db/content-queries.d.ts.map +1 -1
- package/dist/db/content-queries.js +39 -2
- package/dist/db/content-queries.js.map +1 -1
- package/dist/db/extension-queries.d.ts +7 -7
- package/dist/db/feed-queries.d.ts +3 -3
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/layout-queries.d.ts +13 -1
- package/dist/db/layout-queries.d.ts.map +1 -1
- package/dist/db/layout-queries.js +52 -7
- package/dist/db/layout-queries.js.map +1 -1
- package/dist/db/locale-queries.d.ts.map +1 -1
- package/dist/db/locale-queries.js +0 -0
- package/dist/db/locale-queries.js.map +1 -1
- package/dist/db/media-queries.d.ts +12 -12
- package/dist/db/revision-queries.d.ts +3 -3
- package/dist/db/site-admin-queries.d.ts +2 -2
- package/dist/db/site-membership-queries.d.ts +3 -3
- package/dist/db/site-plugin-queries.d.ts +2 -2
- package/dist/db/site-queries.d.ts +6 -6
- package/dist/db/version-queries.d.ts +12 -12
- package/dist/db/webhook-queries.d.ts +11 -11
- package/dist/db/workflow-queries.d.ts +6 -6
- package/dist/db/workflow-queries.d.ts.map +1 -1
- package/dist/db/workflow-queries.js +11 -2
- package/dist/db/workflow-queries.js.map +1 -1
- package/dist/layout/comments-module.d.ts.map +1 -1
- package/dist/layout/comments-module.js +6 -2
- package/dist/layout/comments-module.js.map +1 -1
- package/dist/layout/component-refs.d.ts +465 -0
- package/dist/layout/component-refs.d.ts.map +1 -0
- package/dist/layout/component-refs.js +848 -0
- package/dist/layout/component-refs.js.map +1 -0
- package/dist/layout/index.d.ts +2 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +1 -0
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/module-permissions.d.ts +49 -1
- package/dist/layout/module-permissions.d.ts.map +1 -1
- package/dist/layout/module-permissions.js +117 -6
- package/dist/layout/module-permissions.js.map +1 -1
- package/dist/layout/module-render-context.d.ts +32 -0
- package/dist/layout/module-render-context.d.ts.map +1 -1
- package/dist/layout/module-render-context.js +32 -1
- package/dist/layout/module-render-context.js.map +1 -1
- package/dist/layout/modules-extended.d.ts.map +1 -1
- package/dist/layout/modules-extended.js +21 -11
- package/dist/layout/modules-extended.js.map +1 -1
- package/dist/layout/modules.d.ts.map +1 -1
- package/dist/layout/modules.js +15 -10
- package/dist/layout/modules.js.map +1 -1
- package/dist/layout/renderer.d.ts +51 -4
- package/dist/layout/renderer.d.ts.map +1 -1
- package/dist/layout/renderer.js +36 -38
- package/dist/layout/renderer.js.map +1 -1
- package/dist/layout/types.d.ts +42 -0
- package/dist/layout/types.d.ts.map +1 -1
- package/dist/layout/types.js +54 -11
- package/dist/layout/types.js.map +1 -1
- package/dist/schema/base.d.ts +1 -1
- package/dist/schema/components.d.ts +150 -0
- package/dist/schema/components.d.ts.map +1 -1
- package/dist/schema/components.js +50 -4
- package/dist/schema/components.js.map +1 -1
- package/dist/scope/inline-editing.d.ts.map +1 -1
- package/dist/scope/inline-editing.js +10 -0
- package/dist/scope/inline-editing.js.map +1 -1
- package/dist/scope/sticky-offset.d.ts +185 -0
- package/dist/scope/sticky-offset.d.ts.map +1 -0
- package/dist/scope/sticky-offset.js +432 -0
- package/dist/scope/sticky-offset.js.map +1 -0
- package/dist/site/styles.css +41 -3
- package/dist/versioning/scheduler.d.ts +8 -0
- package/dist/versioning/scheduler.d.ts.map +1 -1
- package/dist/versioning/scheduler.js +21 -7
- package/dist/versioning/scheduler.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* component-refs.ts
|
|
3
|
+
* The pure layer under linked components (kywi-cms#147): finding the
|
|
4
|
+
* `componentId` links inside a layout document, diffing two documents' links,
|
|
5
|
+
* and materialising a link into an independent copy ("detach").
|
|
6
|
+
*
|
|
7
|
+
* Everything here is a pure function over plain layout data — no database, no
|
|
8
|
+
* React — so the instance index (`db/component-instance-queries.ts`), the
|
|
9
|
+
* detach-all endpoint, and the editor can all share one definition of "where
|
|
10
|
+
* are the links" and "what does detaching produce".
|
|
11
|
+
*
|
|
12
|
+
* Keep this module dependency-free beyond `./types.js`.
|
|
13
|
+
*/
|
|
14
|
+
import { validateLayoutSection, validateVariantContainer } from './types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Identity of a placement: the same component in the same node is one instance.
|
|
17
|
+
*
|
|
18
|
+
* The separator is US (unit separator, U+001F) — unusable inside a real id, so
|
|
19
|
+
* two different pairs can never collide on one key. It is deliberately NOT NUL:
|
|
20
|
+
* a NUL byte anywhere in the first 8 KiB makes git classify the whole source
|
|
21
|
+
* file as binary, which silently turns every diff and review of this file into
|
|
22
|
+
* "Bin 15229 bytes".
|
|
23
|
+
*/
|
|
24
|
+
export function componentRefKey(ref) {
|
|
25
|
+
return `${ref.componentId}${ref.nodeId}`;
|
|
26
|
+
}
|
|
27
|
+
function readComponentId(node) {
|
|
28
|
+
const value = node?.componentId;
|
|
29
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Record a placement once — first occurrence, i.e. document order, wins. */
|
|
32
|
+
function record(out, instance) {
|
|
33
|
+
const key = componentRefKey(instance);
|
|
34
|
+
if (!out.has(key))
|
|
35
|
+
out.set(key, instance);
|
|
36
|
+
}
|
|
37
|
+
function collectFromColumns(columns, out) {
|
|
38
|
+
for (const column of columns ?? []) {
|
|
39
|
+
for (const node of column.nodes ?? []) {
|
|
40
|
+
const componentId = readComponentId(node);
|
|
41
|
+
if (componentId) {
|
|
42
|
+
record(out, { componentId, nodeId: node.id, kind: 'module', node });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function collectFromSection(section, out) {
|
|
48
|
+
const componentId = readComponentId(section);
|
|
49
|
+
if (componentId) {
|
|
50
|
+
record(out, { componentId, nodeId: section.id, kind: 'section', node: section });
|
|
51
|
+
// A connected section renders its component's columns wholesale; the inline
|
|
52
|
+
// `columns` it still carries are only the fallback copy for an unresolvable
|
|
53
|
+
// link. Descending into them would count links the page never renders and
|
|
54
|
+
// double-count the ones the component definition owns — so stop here. What
|
|
55
|
+
// the definition contains is the component's business, not this page's.
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
collectFromColumns(section.columns, out);
|
|
59
|
+
}
|
|
60
|
+
function collectFromRegionNode(node, out) {
|
|
61
|
+
if (node.type === 'section') {
|
|
62
|
+
collectFromSection(node, out);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (node.type === 'variantContainer') {
|
|
66
|
+
const componentId = readComponentId(node);
|
|
67
|
+
if (componentId) {
|
|
68
|
+
record(out, { componentId, nodeId: node.id, kind: 'variantContainer', node });
|
|
69
|
+
return; // same wholesale-resolution reasoning as a connected section
|
|
70
|
+
}
|
|
71
|
+
for (const section of node.defaultSections ?? [])
|
|
72
|
+
collectFromSection(section, out);
|
|
73
|
+
for (const variant of node.variants ?? []) {
|
|
74
|
+
for (const section of variant.sections ?? [])
|
|
75
|
+
collectFromSection(section, out);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function collectFromRegions(regions, out) {
|
|
80
|
+
for (const nodes of Object.values(regions ?? {})) {
|
|
81
|
+
for (const node of nodes ?? [])
|
|
82
|
+
collectFromRegionNode(node, out);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every linked-component placement in a layout document, in document order,
|
|
87
|
+
* deduplicated by (componentId, nodeId).
|
|
88
|
+
*
|
|
89
|
+
* Page variants are walked because their regions really do render (the
|
|
90
|
+
* personalization pass swaps them in). A/B experiment variants are walked too,
|
|
91
|
+
* even though today's renderer never renders `abExperiments[]` regions — a link
|
|
92
|
+
* stored there is still a link the delete gate must see and detach-all must
|
|
93
|
+
* rewrite, otherwise deleting the component would leave a dangling id behind in
|
|
94
|
+
* data the renderer may well start honouring. Counting them can only ever
|
|
95
|
+
* over-report, never miss.
|
|
96
|
+
*
|
|
97
|
+
* Deduplication matters in both: a variant is often a near-copy of the default
|
|
98
|
+
* regions and may legitimately repeat a node id.
|
|
99
|
+
*/
|
|
100
|
+
export function collectComponentRefs(layout) {
|
|
101
|
+
return collectComponentInstances(layout).map(({ componentId, nodeId, kind }) => ({
|
|
102
|
+
componentId,
|
|
103
|
+
nodeId,
|
|
104
|
+
kind,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* {@link collectComponentRefs}, with the node each link sits on.
|
|
109
|
+
*
|
|
110
|
+
* THE traversal — `collectComponentRefs` is now a projection of this one, so
|
|
111
|
+
* "where are the links" and "what is in them" cannot drift apart. The nodes are
|
|
112
|
+
* the document's own objects, not copies: callers that intend to keep or rewrite
|
|
113
|
+
* one deep-copy it themselves (`detachedCopy` in the editor, jsonb serialisation
|
|
114
|
+
* on the server), exactly as they already did for the refs.
|
|
115
|
+
*/
|
|
116
|
+
export function collectComponentInstances(layout) {
|
|
117
|
+
const out = new Map();
|
|
118
|
+
if (!layout)
|
|
119
|
+
return [];
|
|
120
|
+
collectFromRegions(layout.regions, out);
|
|
121
|
+
for (const variant of layout.pageVariants ?? [])
|
|
122
|
+
collectFromRegions(variant.regions, out);
|
|
123
|
+
for (const experiment of layout.abExperiments ?? []) {
|
|
124
|
+
for (const variant of experiment.variants ?? [])
|
|
125
|
+
collectFromRegions(variant.regions, out);
|
|
126
|
+
}
|
|
127
|
+
return [...out.values()];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Which placements appeared and which disappeared between two documents.
|
|
131
|
+
*
|
|
132
|
+
* Identity is (componentId, nodeId), so moving a linked node to another column,
|
|
133
|
+
* region, or variant is not a change — but re-keying it (paste, duplicate) is a
|
|
134
|
+
* remove plus an add, which is exactly right: it is a different placement.
|
|
135
|
+
*/
|
|
136
|
+
export function diffComponentRefs(before, after) {
|
|
137
|
+
const beforeRefs = new Map(collectComponentRefs(before).map(r => [componentRefKey(r), r]));
|
|
138
|
+
const afterRefs = new Map(collectComponentRefs(after).map(r => [componentRefKey(r), r]));
|
|
139
|
+
const added = [];
|
|
140
|
+
const removed = [];
|
|
141
|
+
for (const [key, ref] of afterRefs)
|
|
142
|
+
if (!beforeRefs.has(key))
|
|
143
|
+
added.push(ref);
|
|
144
|
+
for (const [key, ref] of beforeRefs)
|
|
145
|
+
if (!afterRefs.has(key))
|
|
146
|
+
removed.push(ref);
|
|
147
|
+
return { added, removed };
|
|
148
|
+
}
|
|
149
|
+
// ── Re-keying a resolved definition ──────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* Re-derive a resolved definition's ids from the id of the node it is rendered
|
|
152
|
+
* into, so the same component placed twice on a page never emits duplicate ids.
|
|
153
|
+
*
|
|
154
|
+
* This is the canonical implementation of the scheme the public renderer
|
|
155
|
+
* already applies to connected sections (`${localId}:${storedId}`), lifted out
|
|
156
|
+
* of `renderer.tsx` so the server-side detach produces *byte-identical* ids to
|
|
157
|
+
* what the page was already rendering — `renderer.tsx` imports this one.
|
|
158
|
+
*/
|
|
159
|
+
export function rekeyResolvedSection(resolved, localId) {
|
|
160
|
+
const scope = (storedId) => `${localId}:${storedId}`;
|
|
161
|
+
return {
|
|
162
|
+
...resolved,
|
|
163
|
+
id: localId,
|
|
164
|
+
columns: (resolved.columns ?? []).map(col => ({
|
|
165
|
+
...col,
|
|
166
|
+
id: scope(col.id),
|
|
167
|
+
nodes: (col.nodes ?? []).map(node => ({
|
|
168
|
+
...node,
|
|
169
|
+
id: scope(node.id),
|
|
170
|
+
instanceId: scope(node.instanceId),
|
|
171
|
+
})),
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The variant-container equivalent of {@link rekeyResolvedSection}: the local
|
|
177
|
+
* container id is kept (stable keys, `data-*` hooks, style scoping) and every
|
|
178
|
+
* id underneath — variant arms and their sections' columns/modules — is
|
|
179
|
+
* re-derived from it.
|
|
180
|
+
*
|
|
181
|
+
* THE SCOPING SCHEME, and why the arm id is part of it:
|
|
182
|
+
*
|
|
183
|
+
* - a Default-arm section becomes `${containerId}:${sectionId}`;
|
|
184
|
+
* - a variant-arm section becomes `${containerId}:${armId}:${sectionId}`.
|
|
185
|
+
*
|
|
186
|
+
* A container's arms are near-copies of each other by construction — an A/B
|
|
187
|
+
* container is usually "the same section, two headlines" — so the same stored
|
|
188
|
+
* section id in the Default arm and in arm B is the NORMAL case, not a
|
|
189
|
+
* pathological one. Scoping both by the container id alone gave them (and every
|
|
190
|
+
* column/module beneath them) identical ids: duplicate React keys, a
|
|
191
|
+
* `[data-section-id]` responsive-style selector matching two sections, and a
|
|
192
|
+
* `data-instance-id` that no longer identifies one module. Folding the arm id in
|
|
193
|
+
* makes ids unique document-wide.
|
|
194
|
+
*
|
|
195
|
+
* These ids are a COMPATIBILITY SURFACE: detaching persists them into the page's
|
|
196
|
+
* stored layout, so changing the scheme re-keys previously detached containers.
|
|
197
|
+
* That is safe within a document (responsive selectors are re-derived from the
|
|
198
|
+
* same ids in the same save) but not for anything outside it that pinned a
|
|
199
|
+
* section id — analytics selectors, hand-written CSS. Changing it again means a
|
|
200
|
+
* migration, so it changes only for a bug of this class.
|
|
201
|
+
*/
|
|
202
|
+
export function rekeyResolvedVariantContainer(resolved, localId) {
|
|
203
|
+
const scope = (storedId) => `${localId}:${storedId}`;
|
|
204
|
+
const rekeySections = (sections, armScope) => (sections ?? []).map(section => rekeyResolvedSection(section, armScope(section.id)));
|
|
205
|
+
return {
|
|
206
|
+
...resolved,
|
|
207
|
+
id: localId,
|
|
208
|
+
defaultSections: rekeySections(resolved.defaultSections, scope),
|
|
209
|
+
variants: (resolved.variants ?? []).map((variant) => ({
|
|
210
|
+
...variant,
|
|
211
|
+
id: scope(variant.id),
|
|
212
|
+
sections: rekeySections(variant.sections, storedId => `${localId}:${variant.id}:${storedId}`),
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Is this a *clean* linked instance — one that carries no local content of its
|
|
218
|
+
* own and therefore renders its component's definition strictly (kywi-cms#147)?
|
|
219
|
+
*
|
|
220
|
+
* The connect flows write a node whose only prop is the `componentId` echo (see
|
|
221
|
+
* `buildConnectedModuleNode`) and whose style map is empty. Such a node has
|
|
222
|
+
* nothing to say about how it renders, so the definition speaks for it alone —
|
|
223
|
+
* which is what "strict link" means and what makes "edit the instance → save to
|
|
224
|
+
* all" coherent (there is no per-instance state to lose).
|
|
225
|
+
*
|
|
226
|
+
* A node with ANY other local prop, or any local style, is a LEGACY override
|
|
227
|
+
* node from before strict links (kywi-cms#46 allowed per-instance overrides) and
|
|
228
|
+
* keeps the old merge, so upgrading the engine never silently changes what an
|
|
229
|
+
* existing page renders. The distinction is deliberately structural rather than
|
|
230
|
+
* a stored flag: it needs no migration and it reads the same in the editor, the
|
|
231
|
+
* renderer and the detach path.
|
|
232
|
+
*/
|
|
233
|
+
export function isCleanComponentInstance(node) {
|
|
234
|
+
if (Object.keys(node.style ?? {}).length > 0)
|
|
235
|
+
return false;
|
|
236
|
+
return Object.keys(node.props ?? {}).every(key => key === 'componentId');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The props/style a connected module node renders with: the definition's alone
|
|
240
|
+
* for a clean instance, definition-then-node for a legacy override node (see
|
|
241
|
+
* {@link isCleanComponentInstance}).
|
|
242
|
+
*
|
|
243
|
+
* Note what the clean case drops: the `componentId` echo that
|
|
244
|
+
* `buildConnectedModuleNode` writes into `props`. It is a link marker, not
|
|
245
|
+
* content, and forwarding it to the module as a prop was only ever an artefact
|
|
246
|
+
* of the merge.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveModulePlacement(node, resolved) {
|
|
249
|
+
if (isCleanComponentInstance(node)) {
|
|
250
|
+
return { props: { ...resolved.props }, style: { ...resolved.style } };
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
props: { ...resolved.props, ...node.props },
|
|
254
|
+
style: { ...resolved.style, ...node.style },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function withoutComponentId(node) {
|
|
258
|
+
const { componentId: _dropped, ...rest } = node;
|
|
259
|
+
return rest;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Drop every `componentId` inside a resolved SECTION definition — the section
|
|
263
|
+
* itself and each module node in its columns.
|
|
264
|
+
*
|
|
265
|
+
* v1 refuses to store a definition that contains a link at all
|
|
266
|
+
* ({@link validateComponentDefinition}), so on any definition written through
|
|
267
|
+
* the API this is a no-op. It is here for the ones that aren't: a row created
|
|
268
|
+
* before the refusal, a hand-written INSERT, a restored dump.
|
|
269
|
+
*
|
|
270
|
+
* Stripping is the render-faithful choice, not a destructive one. A nested link
|
|
271
|
+
* is not resolved TODAY either — the host prefetch only fetches the links the
|
|
272
|
+
* document itself holds (a definition's interior is the component's business),
|
|
273
|
+
* so the renderer's resolver misses and the node renders its own inline props.
|
|
274
|
+
* Dropping the id produces exactly that, and it guarantees that neither detach
|
|
275
|
+
* nor pre-resolution can ever PROMOTE a dormant nested link into a live one by
|
|
276
|
+
* lifting it into a page where the next prefetch would find it.
|
|
277
|
+
*/
|
|
278
|
+
function stripLinksInSection(section) {
|
|
279
|
+
return withoutComponentId({
|
|
280
|
+
...section,
|
|
281
|
+
columns: (section.columns ?? []).map(column => ({
|
|
282
|
+
...column,
|
|
283
|
+
nodes: (column.nodes ?? []).map(node => withoutComponentId(node)),
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** {@link stripLinksInSection} for every section a container holds. */
|
|
288
|
+
function stripLinksInVariantContainer(container) {
|
|
289
|
+
return withoutComponentId({
|
|
290
|
+
...container,
|
|
291
|
+
defaultSections: (container.defaultSections ?? []).map(stripLinksInSection),
|
|
292
|
+
variants: (container.variants ?? []).map((variant) => ({
|
|
293
|
+
...variant,
|
|
294
|
+
sections: (variant.sections ?? []).map(stripLinksInSection),
|
|
295
|
+
})),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* What a connected section renders: the resolved definition, re-keyed off the
|
|
300
|
+
* placement's own id. The placement's inline `columns`/`style` are only the
|
|
301
|
+
* fallback copy for an unresolvable link, so nothing of the placement survives
|
|
302
|
+
* but its id — v1 sections are an all-or-nothing reference.
|
|
303
|
+
*
|
|
304
|
+
* Every link inside the result is dropped ({@link stripLinksInSection}),
|
|
305
|
+
* including one on the definition's own root. The renderer stamps
|
|
306
|
+
* `data-component-id` from the PLACEMENT, so a definition cannot smuggle a
|
|
307
|
+
* second link in through here.
|
|
308
|
+
*/
|
|
309
|
+
export function applySectionPlacement(resolved, placement) {
|
|
310
|
+
return stripLinksInSection(rekeyResolvedSection(resolved, placement.id));
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The variant-container counterpart of {@link applySectionPlacement}, with one
|
|
314
|
+
* addition: the placement's own experiment binding wins over the definition's.
|
|
315
|
+
*
|
|
316
|
+
* `experimentId`/`winnerId` identify THIS page's experiment run — which
|
|
317
|
+
* experiment the arms report to and which arm has been locked in as the winner —
|
|
318
|
+
* not the saved component's. Everything selection-related therefore reads off
|
|
319
|
+
* the returned container: the arms come from the definition, the experiment they
|
|
320
|
+
* belong to comes from the placement. A placement that names neither inherits
|
|
321
|
+
* the definition's, so a component saved with its experiment binding intact
|
|
322
|
+
* keeps working when placed as-is.
|
|
323
|
+
*/
|
|
324
|
+
export function applyVariantContainerPlacement(resolved, placement) {
|
|
325
|
+
const materialised = rekeyResolvedVariantContainer(resolved, placement.id);
|
|
326
|
+
return stripLinksInVariantContainer({
|
|
327
|
+
...materialised,
|
|
328
|
+
...(placement.experimentId !== undefined ? { experimentId: placement.experimentId } : {}),
|
|
329
|
+
...(placement.winnerId !== undefined ? { winnerId: placement.winnerId } : {}),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// ── Un-applying a placement (the inverse) ────────────────────────────────────
|
|
333
|
+
//
|
|
334
|
+
// THE PAIR THAT MAKES "UPDATE ALL" NON-DESTRUCTIVE (kywi-cms#147 T4 review, C1).
|
|
335
|
+
//
|
|
336
|
+
// A linked section/container placement stores an inline fallback copy, and the
|
|
337
|
+
// editor edits THAT copy. Turning an edited copy back into a definition therefore
|
|
338
|
+
// has to undo whatever `apply*Placement` did on the way in — otherwise the
|
|
339
|
+
// placement's scoping travels into the definition, and every later refresh scopes
|
|
340
|
+
// it again (`P:P:col`, `P:P:P:col`, …). So `apply` and `unapply` are written as an
|
|
341
|
+
// explicit pair, with a round-trip test pinning `unapply(apply(D, P)) ≡ D`.
|
|
342
|
+
//
|
|
343
|
+
// What `unapply` cannot restore, and why that is fine: `apply` also STRIPS links
|
|
344
|
+
// inside the definition ({@link stripLinksInSection}). v1 refuses to store a
|
|
345
|
+
// definition that contains a link at all, so on every definition written through
|
|
346
|
+
// the API stripping is a no-op and there is nothing to restore.
|
|
347
|
+
/** Remove one `${scope}:` prefix, if this id carries it. */
|
|
348
|
+
function unscope(id, scope) {
|
|
349
|
+
const prefix = `${scope}:`;
|
|
350
|
+
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The inverse of {@link rekeyResolvedSection}: strip the scope that section's own
|
|
354
|
+
* id imposed on its columns and modules, and give the result `rootId`.
|
|
355
|
+
*
|
|
356
|
+
* The scope is read off `resolved.id` rather than passed in, because that is
|
|
357
|
+
* exactly what `rekeyResolvedSection` used as the `localId` — for a top-level
|
|
358
|
+
* placement the placement's id, for a container's arm section the arm-scoped id it
|
|
359
|
+
* was rekeyed to. Ids that do NOT carry the prefix are left alone: a module the
|
|
360
|
+
* author ADDED to a linked instance has a freshly generated id and no scope, and
|
|
361
|
+
* must keep it.
|
|
362
|
+
*/
|
|
363
|
+
export function unrekeyResolvedSection(resolved, rootId) {
|
|
364
|
+
const scope = resolved.id;
|
|
365
|
+
return {
|
|
366
|
+
...resolved,
|
|
367
|
+
id: rootId,
|
|
368
|
+
columns: (resolved.columns ?? []).map(col => ({
|
|
369
|
+
...col,
|
|
370
|
+
id: unscope(col.id, scope),
|
|
371
|
+
nodes: (col.nodes ?? []).map(node => ({
|
|
372
|
+
...node,
|
|
373
|
+
id: unscope(node.id, scope),
|
|
374
|
+
instanceId: unscope(node.instanceId, scope),
|
|
375
|
+
})),
|
|
376
|
+
})),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* What this placement would be as a DEFINITION: the inverse of
|
|
381
|
+
* {@link applySectionPlacement}.
|
|
382
|
+
*
|
|
383
|
+
* `definitionId` is the id the stored definition already carries, so a definition
|
|
384
|
+
* rewritten from a placement keeps its own root id rather than adopting the
|
|
385
|
+
* page's. Nothing reads a section definition's root id (the placement's always
|
|
386
|
+
* wins at render — see `applySectionPlacement`), but keeping it stable means two
|
|
387
|
+
* successive update-alls produce byte-identical rows.
|
|
388
|
+
*/
|
|
389
|
+
export function unapplySectionPlacement(placement, definitionId) {
|
|
390
|
+
return withoutComponentId(unrekeyResolvedSection(placement, definitionId ?? placement.id));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The container counterpart of {@link unapplySectionPlacement}, mirroring
|
|
394
|
+
* {@link rekeyResolvedVariantContainer}'s arm-aware scheme in reverse:
|
|
395
|
+
* `${containerId}:${armId}:${sectionId}` unwinds one level at a time.
|
|
396
|
+
*
|
|
397
|
+
* The placement's `experimentId`/`winnerId` are DROPPED, not inverted: they are
|
|
398
|
+
* this page's experiment run, and `applyVariantContainerPlacement` lets them win
|
|
399
|
+
* over the definition's precisely so a component never carries one page's run.
|
|
400
|
+
* Restoring the definition's own binding is the caller's business (it holds the
|
|
401
|
+
* stored row); this returns a container with no binding at all.
|
|
402
|
+
*/
|
|
403
|
+
export function unapplyVariantContainerPlacement(placement, definitionId) {
|
|
404
|
+
const scope = placement.id;
|
|
405
|
+
const { experimentId: _thisPagesRun, winnerId: _thisPagesWinner, ...rest } = placement;
|
|
406
|
+
return withoutComponentId({
|
|
407
|
+
...rest,
|
|
408
|
+
id: definitionId ?? placement.id,
|
|
409
|
+
defaultSections: (placement.defaultSections ?? []).map(section => unrekeyResolvedSection(section, unscope(section.id, scope))),
|
|
410
|
+
variants: (placement.variants ?? []).map((variant) => {
|
|
411
|
+
const armId = unscope(variant.id, scope);
|
|
412
|
+
return {
|
|
413
|
+
...variant,
|
|
414
|
+
id: armId,
|
|
415
|
+
sections: (variant.sections ?? []).map(section => unrekeyResolvedSection(section, unscope(section.id, `${scope}:${armId}`))),
|
|
416
|
+
};
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function isPlainObject(value) {
|
|
421
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Is this stored `definition` a legal one for a component of this type?
|
|
425
|
+
*
|
|
426
|
+
* A component definition is not just a blob: detaching materialises it straight
|
|
427
|
+
* into a page's layout, so a definition that a layout could never hold is a
|
|
428
|
+
* loaded gun. A section definition whose columns don't sum to 12, for instance,
|
|
429
|
+
* gets copied into every page that links the component the moment anyone
|
|
430
|
+
* detaches — and every subsequent layout save of those pages is then rejected by
|
|
431
|
+
* the very same rule, leaving them un-editable with no way back through the UI.
|
|
432
|
+
*
|
|
433
|
+
* So the rules here are deliberately the *same* rules the layout write path
|
|
434
|
+
* applies, reached through the same functions:
|
|
435
|
+
* - `module` → a resolvable `moduleType`, plus `props`/`style` object maps
|
|
436
|
+
* (the shape `ComponentResolver` and `detachModuleNode` read);
|
|
437
|
+
* - `section` → {@link validateLayoutSection};
|
|
438
|
+
* - `variantContainer` → {@link validateVariantContainer}.
|
|
439
|
+
*
|
|
440
|
+
* Called on component create and on every definition-bearing update, which is
|
|
441
|
+
* the only pair of doors a definition can come in through.
|
|
442
|
+
*/
|
|
443
|
+
export function validateComponentDefinition(component) {
|
|
444
|
+
const { type, definition } = component;
|
|
445
|
+
if (type === 'module') {
|
|
446
|
+
// Without a moduleType the renderer resolves this component to nothing —
|
|
447
|
+
// every instance would silently render its own (usually empty) props. It is
|
|
448
|
+
// not a usable component, so it is not a creatable one.
|
|
449
|
+
if (!component.moduleType) {
|
|
450
|
+
return { success: false, error: "A 'module' component requires a moduleType" };
|
|
451
|
+
}
|
|
452
|
+
if (!isPlainObject(definition)) {
|
|
453
|
+
return { success: false, error: "A 'module' definition must be an object with props/style" };
|
|
454
|
+
}
|
|
455
|
+
if (definition['props'] !== undefined && !isPlainObject(definition['props'])) {
|
|
456
|
+
return { success: false, error: "A 'module' definition's props must be an object" };
|
|
457
|
+
}
|
|
458
|
+
const style = definition['style'];
|
|
459
|
+
if (style !== undefined) {
|
|
460
|
+
if (!isPlainObject(style)) {
|
|
461
|
+
return { success: false, error: "A 'module' definition's style must be an object" };
|
|
462
|
+
}
|
|
463
|
+
for (const [key, value] of Object.entries(style)) {
|
|
464
|
+
if (typeof value !== 'string') {
|
|
465
|
+
return { success: false, error: `A 'module' definition's style.${key} must be a string` };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { success: true };
|
|
470
|
+
}
|
|
471
|
+
if (type === 'section') {
|
|
472
|
+
const result = validateLayoutSection(definition);
|
|
473
|
+
if (!result.success)
|
|
474
|
+
return { success: false, error: result.error };
|
|
475
|
+
return rejectNestedComponentLinks(result.data);
|
|
476
|
+
}
|
|
477
|
+
if (type === 'variantContainer') {
|
|
478
|
+
const result = validateVariantContainer(definition);
|
|
479
|
+
if (!result.success)
|
|
480
|
+
return { success: false, error: result.error };
|
|
481
|
+
return rejectNestedComponentLinks(result.data);
|
|
482
|
+
}
|
|
483
|
+
return { success: false, error: `Unknown component type '${type}'` };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* v1 REFUSAL: a component definition may not itself contain a link to another
|
|
487
|
+
* component (kywi-cms#147).
|
|
488
|
+
*
|
|
489
|
+
* A depth-2 link is not a small extra case, it is a different feature. The whole
|
|
490
|
+
* engine rests on "what a placement renders IS what detaching bakes in"
|
|
491
|
+
* (`applySectionPlacement` is called by the renderer and by the detach path
|
|
492
|
+
* alike). A nested link breaks that equality: the host prefetch only fetches the
|
|
493
|
+
* links the DOCUMENT holds, so an inner link renders its inline fallback while
|
|
494
|
+
* the delete gate, the instance index and the usage counts — all of which read
|
|
495
|
+
* `collectComponentRefs`, which deliberately stops at a connected node — never
|
|
496
|
+
* see it at all. A component could then be deleted while a page still renders
|
|
497
|
+
* it, one level down.
|
|
498
|
+
*
|
|
499
|
+
* Refusing at the only two doors a definition can come in through (component
|
|
500
|
+
* POST/PUT) makes the whole class impossible, INCLUDING cycles: a cycle needs at
|
|
501
|
+
* least one component whose definition names another component, so if no stored
|
|
502
|
+
* definition may name any component, no cycle can be constructed — not a
|
|
503
|
+
* self-reference, not a mutual pair, not a longer ring. There is no depth limit
|
|
504
|
+
* to tune and no cycle detector to get wrong.
|
|
505
|
+
*
|
|
506
|
+
* The flows that WANT nesting materialise instead of linking: "save as
|
|
507
|
+
* component" flattens any links inside the selection before storing it, so the
|
|
508
|
+
* definition is a self-contained copy. Belt and braces, the placement helpers
|
|
509
|
+
* strip any link they find in a definition anyway ({@link stripLinksInSection}),
|
|
510
|
+
* so a row that predates this rule still cannot promote one.
|
|
511
|
+
*/
|
|
512
|
+
function rejectNestedComponentLinks(definition) {
|
|
513
|
+
const refs = collectComponentRefs({ regions: { definition: [definition] } });
|
|
514
|
+
const nested = refs[0];
|
|
515
|
+
if (!nested)
|
|
516
|
+
return { success: true };
|
|
517
|
+
return {
|
|
518
|
+
success: false,
|
|
519
|
+
error: `A component definition may not link to another component: ` +
|
|
520
|
+
`node '${nested.nodeId}' links to component '${nested.componentId}'. ` +
|
|
521
|
+
`Materialise (detach) it before saving this component.`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Project a `kywi_components` row into a {@link ComponentDetachSource}.
|
|
526
|
+
* Takes the structural shape rather than the row type so it stays usable from
|
|
527
|
+
* the editor (which holds the same fields over the wire).
|
|
528
|
+
*/
|
|
529
|
+
export function componentDetachSource(component) {
|
|
530
|
+
if (!component)
|
|
531
|
+
return { kind: 'unresolvable' };
|
|
532
|
+
const def = (component.definition ?? {});
|
|
533
|
+
if (component.type === 'module') {
|
|
534
|
+
// A module component with no `moduleType` is unresolvable, not "resolvable
|
|
535
|
+
// with the node's own type". The renderer's resolver returns null for such
|
|
536
|
+
// a row, so the page renders the node's OWN props and never merges the
|
|
537
|
+
// definition's — materialising them here would bake in content no visitor
|
|
538
|
+
// ever saw. Unresolvable makes detach drop the link and leave the node
|
|
539
|
+
// exactly as it renders today.
|
|
540
|
+
if (!component.moduleType)
|
|
541
|
+
return { kind: 'unresolvable' };
|
|
542
|
+
return {
|
|
543
|
+
kind: 'module',
|
|
544
|
+
moduleType: component.moduleType,
|
|
545
|
+
props: (def['props'] ?? {}),
|
|
546
|
+
style: (def['style'] ?? {}),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (component.type === 'section') {
|
|
550
|
+
if (def['type'] !== 'section')
|
|
551
|
+
return { kind: 'unresolvable' };
|
|
552
|
+
return { kind: 'section', section: def };
|
|
553
|
+
}
|
|
554
|
+
if (component.type === 'variantContainer') {
|
|
555
|
+
if (def['type'] !== 'variantContainer')
|
|
556
|
+
return { kind: 'unresolvable' };
|
|
557
|
+
return { kind: 'variantContainer', container: def };
|
|
558
|
+
}
|
|
559
|
+
return { kind: 'unresolvable' };
|
|
560
|
+
}
|
|
561
|
+
function applyLink(materialised, original, link) {
|
|
562
|
+
const componentId = original.componentId;
|
|
563
|
+
if (link === 'drop' || !componentId)
|
|
564
|
+
return withoutComponentId(materialised);
|
|
565
|
+
return { ...materialised, componentId };
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Materialise a connected module node: the definition's props/style baked in,
|
|
569
|
+
* the module's `type` set to the component's `moduleType`.
|
|
570
|
+
*
|
|
571
|
+
* What it renders through the link is decided by {@link resolveModulePlacement}
|
|
572
|
+
* — the same call the renderer makes — so what the page rendered before is
|
|
573
|
+
* exactly what it renders after. Ids are preserved: this is the same placement,
|
|
574
|
+
* not a new one.
|
|
575
|
+
*/
|
|
576
|
+
export function materializeModuleNode(node, source, link = 'drop') {
|
|
577
|
+
if (source.kind !== 'module')
|
|
578
|
+
return applyLink(node, node, link);
|
|
579
|
+
return applyLink({ ...node, type: source.moduleType, ...resolveModulePlacement(node, source) }, node, link);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Materialise a connected section: exactly what the renderer draws for this
|
|
583
|
+
* placement ({@link applySectionPlacement}). An unresolvable source degrades to
|
|
584
|
+
* keeping the section's own inline fallback copy — again matching what the
|
|
585
|
+
* renderer shows.
|
|
586
|
+
*/
|
|
587
|
+
export function materializeSectionNode(section, source, link = 'drop') {
|
|
588
|
+
if (source.kind !== 'section')
|
|
589
|
+
return applyLink(section, section, link);
|
|
590
|
+
return applyLink(applySectionPlacement(source.section, section), section, link);
|
|
591
|
+
}
|
|
592
|
+
/** The variant-container equivalent of {@link materializeSectionNode}. */
|
|
593
|
+
export function materializeVariantContainerNode(container, source, link = 'drop') {
|
|
594
|
+
if (source.kind !== 'variantContainer')
|
|
595
|
+
return applyLink(container, container, link);
|
|
596
|
+
return applyLink(applyVariantContainerPlacement(source.container, container), container, link);
|
|
597
|
+
}
|
|
598
|
+
/** Materialise a connected module node into an INDEPENDENT one (link dropped). */
|
|
599
|
+
export function detachModuleNode(node, source) {
|
|
600
|
+
return materializeModuleNode(node, source, 'drop');
|
|
601
|
+
}
|
|
602
|
+
/** Materialise a connected section into an INDEPENDENT one (link dropped). */
|
|
603
|
+
export function detachSectionNode(section, source) {
|
|
604
|
+
return materializeSectionNode(section, source, 'drop');
|
|
605
|
+
}
|
|
606
|
+
/** Materialise a connected container into an INDEPENDENT one (link dropped). */
|
|
607
|
+
export function detachVariantContainerNode(container, source) {
|
|
608
|
+
return materializeVariantContainerNode(container, source, 'drop');
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* THE traversal for "visit every linked placement in a document".
|
|
612
|
+
*
|
|
613
|
+
* It knows the two rules that make a placement a placement, and it knows them
|
|
614
|
+
* once: a connected section/container resolves WHOLESALE, so its inline
|
|
615
|
+
* columns/arms are fallback copy the renderer never shows and must not be
|
|
616
|
+
* descended into; and links live in page variants and A/B experiment regions as
|
|
617
|
+
* well as the default regions.
|
|
618
|
+
*
|
|
619
|
+
* Both whole-document rewrites — detach ({@link detachComponentFromLayout}) and
|
|
620
|
+
* pre-resolution ({@link resolveComponentPlacements}) — run through here, so
|
|
621
|
+
* neither can develop its own idea of where the links are. It mirrors
|
|
622
|
+
* {@link collectComponentRefs}, which answers the same question read-only.
|
|
623
|
+
*/
|
|
624
|
+
function mapLinkedPlacements(layout, mapper) {
|
|
625
|
+
let changed = 0;
|
|
626
|
+
const mapSection = (section) => {
|
|
627
|
+
const sectionLink = readComponentId(section);
|
|
628
|
+
if (sectionLink) {
|
|
629
|
+
// A connected section's inline columns are fallback copy the renderer
|
|
630
|
+
// never shows, so nothing below it is a placement of this page's.
|
|
631
|
+
if (!mapper.matches(sectionLink, 'section'))
|
|
632
|
+
return section;
|
|
633
|
+
changed++;
|
|
634
|
+
return mapper.section(section, sectionLink);
|
|
635
|
+
}
|
|
636
|
+
let columnsChanged = false;
|
|
637
|
+
const columns = (section.columns ?? []).map(col => {
|
|
638
|
+
let nodesChanged = false;
|
|
639
|
+
const nodes = (col.nodes ?? []).map(node => {
|
|
640
|
+
const link = readComponentId(node);
|
|
641
|
+
if (!link || !mapper.matches(link, 'module'))
|
|
642
|
+
return node;
|
|
643
|
+
changed++;
|
|
644
|
+
nodesChanged = true;
|
|
645
|
+
return mapper.module(node, link);
|
|
646
|
+
});
|
|
647
|
+
if (!nodesChanged)
|
|
648
|
+
return col;
|
|
649
|
+
columnsChanged = true;
|
|
650
|
+
return { ...col, nodes };
|
|
651
|
+
});
|
|
652
|
+
return columnsChanged ? { ...section, columns } : section;
|
|
653
|
+
};
|
|
654
|
+
const mapRegionNode = (node) => {
|
|
655
|
+
if (node.type === 'section')
|
|
656
|
+
return mapSection(node);
|
|
657
|
+
if (node.type !== 'variantContainer')
|
|
658
|
+
return node;
|
|
659
|
+
const containerLink = readComponentId(node);
|
|
660
|
+
if (containerLink) {
|
|
661
|
+
if (!mapper.matches(containerLink, 'variantContainer'))
|
|
662
|
+
return node;
|
|
663
|
+
changed++;
|
|
664
|
+
return mapper.variantContainer(node, containerLink);
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
...node,
|
|
668
|
+
defaultSections: (node.defaultSections ?? []).map(mapSection),
|
|
669
|
+
variants: (node.variants ?? []).map(variant => ({
|
|
670
|
+
...variant,
|
|
671
|
+
sections: (variant.sections ?? []).map(mapSection),
|
|
672
|
+
})),
|
|
673
|
+
};
|
|
674
|
+
};
|
|
675
|
+
const mapRegions = (regions) => Object.fromEntries(Object.entries(regions ?? {}).map(([name, nodes]) => [name, (nodes ?? []).map(mapRegionNode)]));
|
|
676
|
+
const next = {
|
|
677
|
+
...layout,
|
|
678
|
+
regions: mapRegions(layout.regions),
|
|
679
|
+
...(layout.pageVariants
|
|
680
|
+
? { pageVariants: layout.pageVariants.map(v => ({ ...v, regions: mapRegions(v.regions) })) }
|
|
681
|
+
: {}),
|
|
682
|
+
...(layout.abExperiments
|
|
683
|
+
? {
|
|
684
|
+
abExperiments: layout.abExperiments.map(exp => ({
|
|
685
|
+
...exp,
|
|
686
|
+
variants: exp.variants.map(v => ({ ...v, regions: mapRegions(v.regions) })),
|
|
687
|
+
})),
|
|
688
|
+
}
|
|
689
|
+
: {}),
|
|
690
|
+
};
|
|
691
|
+
return changed === 0 ? { layout, changed: 0 } : { layout: next, changed };
|
|
692
|
+
}
|
|
693
|
+
export function rewriteComponentInstances(layout, rewrite) {
|
|
694
|
+
const { layout: next } = mapLinkedPlacements(layout, {
|
|
695
|
+
matches: (_componentId, kind) => rewrite[kind] !== undefined,
|
|
696
|
+
module: (node, componentId) => rewrite.module?.(node, { componentId, nodeId: node.id, kind: 'module' }) ?? node,
|
|
697
|
+
section: (section, componentId) => rewrite.section?.(section, { componentId, nodeId: section.id, kind: 'section' }) ?? section,
|
|
698
|
+
variantContainer: (container, componentId) => rewrite.variantContainer?.(container, {
|
|
699
|
+
componentId,
|
|
700
|
+
nodeId: container.id,
|
|
701
|
+
kind: 'variantContainer',
|
|
702
|
+
}) ?? container,
|
|
703
|
+
});
|
|
704
|
+
return next;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Replace every placement of `componentId` in a layout document with an
|
|
708
|
+
* independent copy of the component's definition. Returns a new document; the
|
|
709
|
+
* input is not mutated. `detached` is 0 when the document held no link, in
|
|
710
|
+
* which case `layout` is the input document unchanged.
|
|
711
|
+
*/
|
|
712
|
+
export function detachComponentFromLayout(layout, componentId, source) {
|
|
713
|
+
const { layout: next, changed } = mapLinkedPlacements(layout, {
|
|
714
|
+
matches: id => id === componentId,
|
|
715
|
+
module: node => materializeModuleNode(node, source, 'drop'),
|
|
716
|
+
section: section => materializeSectionNode(section, source, 'drop'),
|
|
717
|
+
variantContainer: container => materializeVariantContainerNode(container, source, 'drop'),
|
|
718
|
+
});
|
|
719
|
+
return { layout: next, detached: changed };
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Resolve every connected placement in a layout document AHEAD of rendering,
|
|
723
|
+
* producing a document that already contains what each placement draws.
|
|
724
|
+
*
|
|
725
|
+
* WHY THIS EXISTS. The renderer's resolver props (`componentResolver` and
|
|
726
|
+
* friends) are FUNCTIONS, and `renderer.tsx` is a `'use client'` module. A host
|
|
727
|
+
* that resolves components in an async Server Component — which is every real
|
|
728
|
+
* host, because resolution is a database read — cannot pass them: React's flight
|
|
729
|
+
* serializer rejects a function prop with "Functions cannot be passed directly
|
|
730
|
+
* to Client Components", and the page 500s. That was true of module components
|
|
731
|
+
* (kywi-cms#46) and section components (#69) from the day they shipped; nothing
|
|
732
|
+
* caught it because no database in the wild had a `componentId` in a layout, and
|
|
733
|
+
* every test rendered through `renderToString` with no RSC boundary in sight.
|
|
734
|
+
*
|
|
735
|
+
* So resolution moves to where the data already is: the server, before render.
|
|
736
|
+
* The host fetches the definitions it needs, calls this, and hands `KywiLayout`
|
|
737
|
+
* a plain serializable document. No functions cross the boundary.
|
|
738
|
+
*
|
|
739
|
+
* WHAT IT PRODUCES. Materialisation is the same code detaching uses, with the
|
|
740
|
+
* link KEPT rather than dropped — pre-resolution ≡ detach output ≡ what the
|
|
741
|
+
* renderer was supposed to show. Keeping the link is what preserves everything
|
|
742
|
+
* built on it: `data-component-id` in the public DOM, the editor's
|
|
743
|
+
* linked-instance affordance, and the fact that saving the page writes a link
|
|
744
|
+
* back rather than a baked-in copy.
|
|
745
|
+
*
|
|
746
|
+
* Selection is unaffected and needs no ordering care: a pre-resolved variant
|
|
747
|
+
* container is an ordinary container holding the component's arms, so audience
|
|
748
|
+
* matching and A/B assignment run on it exactly as on an inline one — which is
|
|
749
|
+
* also why a pre-resolved page ships the same bytes to the visitor that a native
|
|
750
|
+
* one does (all arms in the document), no more and no less.
|
|
751
|
+
*
|
|
752
|
+
* The operation is idempotent: every placement is re-materialised from the
|
|
753
|
+
* DEFINITION, not from whatever the node currently holds, so running it twice
|
|
754
|
+
* (or on a document that already came back from the editor) yields the same
|
|
755
|
+
* document.
|
|
756
|
+
*
|
|
757
|
+
* ⚠ A PRE-RESOLVED DOCUMENT IS A RENDER ARTEFACT — NEVER FEED IT TO AN EDITOR.
|
|
758
|
+
* Saving one would write the definition's props into every placement, and a
|
|
759
|
+
* clean instance (props empty but for the `componentId` echo) would come back as
|
|
760
|
+
* a LEGACY override node: still linked, but no longer following its component.
|
|
761
|
+
* Hosts must hand the editor / front-edit overlay the STORED layout, which is
|
|
762
|
+
* what both of Kywi's own hosts do — the same rule feed and nav hydration
|
|
763
|
+
* already live by.
|
|
764
|
+
*/
|
|
765
|
+
export function resolveComponentPlacements(layout, lookup) {
|
|
766
|
+
if (!layout)
|
|
767
|
+
return { regions: {} };
|
|
768
|
+
const sourceOf = (componentId, kind) => {
|
|
769
|
+
const source = lookup(componentId);
|
|
770
|
+
if (!source || source.kind !== kind)
|
|
771
|
+
return null;
|
|
772
|
+
return source;
|
|
773
|
+
};
|
|
774
|
+
const { layout: next } = mapLinkedPlacements(layout, {
|
|
775
|
+
matches: (componentId, kind) => sourceOf(componentId, kind) !== null,
|
|
776
|
+
module: (node, id) => materializeModuleNode(node, sourceOf(id, 'module'), 'keep'),
|
|
777
|
+
section: (section, id) => materializeSectionNode(section, sourceOf(id, 'section'), 'keep'),
|
|
778
|
+
variantContainer: (container, id) => materializeVariantContainerNode(container, sourceOf(id, 'variantContainer'), 'keep'),
|
|
779
|
+
});
|
|
780
|
+
return next;
|
|
781
|
+
}
|
|
782
|
+
// ── Editor-side copy refresh ─────────────────────────────────────────────────
|
|
783
|
+
/**
|
|
784
|
+
* Bring a document's linked SECTION and CONTAINER fallback copies back in line
|
|
785
|
+
* with the definitions they point at (kywi-cms#147 T4 review, C1).
|
|
786
|
+
*
|
|
787
|
+
* THE BUG THIS EXISTS FOR. A linked section/container stores an inline copy of its
|
|
788
|
+
* definition, and the editor edits that copy — nothing refreshed it when the
|
|
789
|
+
* definition changed elsewhere. "Save and update all instances" then built the new
|
|
790
|
+
* definition out of the page's subtree, so an update-all from a page whose copy was
|
|
791
|
+
* stale REVERTED every newer edit of that component, everywhere, published pages
|
|
792
|
+
* included, without saying a word. Two placements on one page were enough: update
|
|
793
|
+
* from the first, then from the second, and the first edit was gone.
|
|
794
|
+
*
|
|
795
|
+
* So the copy is refreshed BEFORE it can be edited, which makes the baseline the
|
|
796
|
+
* definition and makes update-all a true inverse by construction (see
|
|
797
|
+
* {@link unapplySectionPlacement}). It is also simply what the editor should be
|
|
798
|
+
* showing: the stale copy was on screen.
|
|
799
|
+
*
|
|
800
|
+
* WHAT IT DELIBERATELY DOES NOT TOUCH. Module placements. A clean module instance
|
|
801
|
+
* carries no copy to refresh (the rail reads what it renders through the library),
|
|
802
|
+
* and materialising one would bake the definition's props onto the node — turning a
|
|
803
|
+
* clean instance into a LEGACY override that no longer follows its component, which
|
|
804
|
+
* is precisely the hazard behind "⚠ a pre-resolved document must never reach an
|
|
805
|
+
* editor" on {@link resolveComponentPlacements}. Nothing here writes a node an
|
|
806
|
+
* editor may not save: refreshing a section/container's fallback copy is the same
|
|
807
|
+
* write the connect flows make.
|
|
808
|
+
*
|
|
809
|
+
* ONE CONSEQUENCE TO KNOW ABOUT: IDS. A refreshed copy is the definition re-keyed
|
|
810
|
+
* off the placement (`${placementId}:${definitionId}` — {@link rekeyResolvedSection}),
|
|
811
|
+
* which is what the RENDERER already emits for that placement, so the editor and the
|
|
812
|
+
* public DOM now agree. But a copy stored before this existed carries the ids its
|
|
813
|
+
* clone was given, so the first save after upgrading re-writes the ids inside every
|
|
814
|
+
* linked section/container placement. Nothing inside Kywi minds — responsive style
|
|
815
|
+
* selectors and `data-*` hooks are all re-derived from the same document in the same
|
|
816
|
+
* pass, and the placement's OWN id (the one the instance index and usage counts are
|
|
817
|
+
* keyed on) never changes — but this is the compatibility surface documented at
|
|
818
|
+
* {@link rekeyResolvedVariantContainer}, so hand-written CSS or an analytics selector
|
|
819
|
+
* pinned to an id INSIDE a linked instance sees a new value. That is the cost of the
|
|
820
|
+
* editor and the renderer finally holding the same document; the alternative, keeping
|
|
821
|
+
* the stored ids by matching two trees positionally, cannot survive an edit that
|
|
822
|
+
* restructured one of them.
|
|
823
|
+
*
|
|
824
|
+
* Returns the INPUT document when nothing changed, so a caller can dispatch only
|
|
825
|
+
* on a real difference and never manufacture dirty state out of a no-op.
|
|
826
|
+
*/
|
|
827
|
+
export function refreshComponentCopies(layout, lookup) {
|
|
828
|
+
const sourceOf = (componentId, kind) => {
|
|
829
|
+
const source = lookup(componentId);
|
|
830
|
+
if (!source || source.kind !== kind)
|
|
831
|
+
return null;
|
|
832
|
+
return source;
|
|
833
|
+
};
|
|
834
|
+
const { layout: next } = mapLinkedPlacements(layout, {
|
|
835
|
+
// Modules are left alone on purpose — see the note above.
|
|
836
|
+
matches: (componentId, kind) => kind !== 'module' && sourceOf(componentId, kind) !== null,
|
|
837
|
+
module: node => node,
|
|
838
|
+
section: (section, id) => materializeSectionNode(section, sourceOf(id, 'section'), 'keep'),
|
|
839
|
+
variantContainer: (container, id) => materializeVariantContainerNode(container, sourceOf(id, 'variantContainer'), 'keep'),
|
|
840
|
+
});
|
|
841
|
+
if (next === layout)
|
|
842
|
+
return layout;
|
|
843
|
+
// `mapLinkedPlacements` counts placements it VISITED, not ones it altered, so a
|
|
844
|
+
// document whose copies were already current comes back as a fresh object with
|
|
845
|
+
// identical content. Callers key dirty-state on identity, so compare the content.
|
|
846
|
+
return JSON.stringify(next) === JSON.stringify(layout) ? layout : next;
|
|
847
|
+
}
|
|
848
|
+
//# sourceMappingURL=component-refs.js.map
|