@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.
Files changed (248) hide show
  1. package/AGENT-PATTERNS.md +111 -23
  2. package/README.md +67 -0
  3. package/dist/admin/content-form.d.ts.map +1 -1
  4. package/dist/admin/content-form.js +97 -10
  5. package/dist/admin/content-form.js.map +1 -1
  6. package/dist/admin/layout-editor/component-actions.d.ts +219 -22
  7. package/dist/admin/layout-editor/component-actions.d.ts.map +1 -1
  8. package/dist/admin/layout-editor/component-actions.js +356 -36
  9. package/dist/admin/layout-editor/component-actions.js.map +1 -1
  10. package/dist/admin/layout-editor/component-fork.d.ts +224 -0
  11. package/dist/admin/layout-editor/component-fork.d.ts.map +1 -0
  12. package/dist/admin/layout-editor/component-fork.js +342 -0
  13. package/dist/admin/layout-editor/component-fork.js.map +1 -0
  14. package/dist/admin/layout-editor/editor-canvas.d.ts +28 -7
  15. package/dist/admin/layout-editor/editor-canvas.d.ts.map +1 -1
  16. package/dist/admin/layout-editor/editor-canvas.js +145 -32
  17. package/dist/admin/layout-editor/editor-canvas.js.map +1 -1
  18. package/dist/admin/layout-editor/editor-dialog.d.ts +164 -0
  19. package/dist/admin/layout-editor/editor-dialog.d.ts.map +1 -0
  20. package/dist/admin/layout-editor/editor-dialog.js +195 -0
  21. package/dist/admin/layout-editor/editor-dialog.js.map +1 -0
  22. package/dist/admin/layout-editor/editor-state.d.ts +76 -0
  23. package/dist/admin/layout-editor/editor-state.d.ts.map +1 -1
  24. package/dist/admin/layout-editor/editor-state.js +47 -1
  25. package/dist/admin/layout-editor/editor-state.js.map +1 -1
  26. package/dist/admin/layout-editor/editor.css +366 -4
  27. package/dist/admin/layout-editor/index.d.ts +10 -1
  28. package/dist/admin/layout-editor/index.d.ts.map +1 -1
  29. package/dist/admin/layout-editor/index.js +11 -1
  30. package/dist/admin/layout-editor/index.js.map +1 -1
  31. package/dist/admin/layout-editor/inline-text.d.ts +56 -4
  32. package/dist/admin/layout-editor/inline-text.d.ts.map +1 -1
  33. package/dist/admin/layout-editor/inline-text.js +134 -11
  34. package/dist/admin/layout-editor/inline-text.js.map +1 -1
  35. package/dist/admin/layout-editor/layout-editor.d.ts +58 -1
  36. package/dist/admin/layout-editor/layout-editor.d.ts.map +1 -1
  37. package/dist/admin/layout-editor/layout-editor.js +160 -190
  38. package/dist/admin/layout-editor/layout-editor.js.map +1 -1
  39. package/dist/admin/layout-editor/linked-component.d.ts +39 -0
  40. package/dist/admin/layout-editor/linked-component.d.ts.map +1 -0
  41. package/dist/admin/layout-editor/linked-component.js +42 -0
  42. package/dist/admin/layout-editor/linked-component.js.map +1 -0
  43. package/dist/admin/layout-editor/module-overlay.d.ts +36 -14
  44. package/dist/admin/layout-editor/module-overlay.d.ts.map +1 -1
  45. package/dist/admin/layout-editor/module-overlay.js +233 -25
  46. package/dist/admin/layout-editor/module-overlay.js.map +1 -1
  47. package/dist/admin/layout-editor/node-lookup.d.ts +35 -0
  48. package/dist/admin/layout-editor/node-lookup.d.ts.map +1 -1
  49. package/dist/admin/layout-editor/node-lookup.js +91 -0
  50. package/dist/admin/layout-editor/node-lookup.js.map +1 -1
  51. package/dist/admin/layout-editor/overlay-shell.d.ts.map +1 -1
  52. package/dist/admin/layout-editor/overlay-shell.js +150 -32
  53. package/dist/admin/layout-editor/overlay-shell.js.map +1 -1
  54. package/dist/admin/layout-editor/props-panel.d.ts +54 -1
  55. package/dist/admin/layout-editor/props-panel.d.ts.map +1 -1
  56. package/dist/admin/layout-editor/props-panel.js +119 -10
  57. package/dist/admin/layout-editor/props-panel.js.map +1 -1
  58. package/dist/admin/layout-editor/section-overlay.d.ts +16 -1
  59. package/dist/admin/layout-editor/section-overlay.d.ts.map +1 -1
  60. package/dist/admin/layout-editor/section-overlay.js +21 -11
  61. package/dist/admin/layout-editor/section-overlay.js.map +1 -1
  62. package/dist/admin/layout-editor/section-template-picker.d.ts +27 -1
  63. package/dist/admin/layout-editor/section-template-picker.d.ts.map +1 -1
  64. package/dist/admin/layout-editor/section-template-picker.js +71 -17
  65. package/dist/admin/layout-editor/section-template-picker.js.map +1 -1
  66. package/dist/admin/layout-editor/section-variant-container-config.js +5 -5
  67. package/dist/admin/layout-editor/section-variant-container-config.js.map +1 -1
  68. package/dist/admin/layout-editor/use-chrome-flip.d.ts +111 -11
  69. package/dist/admin/layout-editor/use-chrome-flip.d.ts.map +1 -1
  70. package/dist/admin/layout-editor/use-chrome-flip.js +233 -33
  71. package/dist/admin/layout-editor/use-chrome-flip.js.map +1 -1
  72. package/dist/admin/layout-editor/use-component-connect.d.ts +61 -0
  73. package/dist/admin/layout-editor/use-component-connect.d.ts.map +1 -0
  74. package/dist/admin/layout-editor/use-component-connect.js +292 -0
  75. package/dist/admin/layout-editor/use-component-connect.js.map +1 -0
  76. package/dist/admin/layout-editor/use-component-fork.d.ts +123 -0
  77. package/dist/admin/layout-editor/use-component-fork.d.ts.map +1 -0
  78. package/dist/admin/layout-editor/use-component-fork.js +346 -0
  79. package/dist/admin/layout-editor/use-component-fork.js.map +1 -0
  80. package/dist/admin/layout-editor/use-component-library.d.ts +94 -0
  81. package/dist/admin/layout-editor/use-component-library.d.ts.map +1 -0
  82. package/dist/admin/layout-editor/use-component-library.js +55 -0
  83. package/dist/admin/layout-editor/use-component-library.js.map +1 -0
  84. package/dist/admin/registry.d.ts +11 -2
  85. package/dist/admin/registry.d.ts.map +1 -1
  86. package/dist/admin/registry.js +40 -4
  87. package/dist/admin/registry.js.map +1 -1
  88. package/dist/admin/server.d.ts +1 -0
  89. package/dist/admin/server.d.ts.map +1 -1
  90. package/dist/admin/server.js +5 -0
  91. package/dist/admin/server.js.map +1 -1
  92. package/dist/admin/styles/admin.css +57 -1
  93. package/dist/admin/surfaces/components/component-edit.d.ts +3 -0
  94. package/dist/admin/surfaces/components/component-edit.d.ts.map +1 -0
  95. package/dist/admin/surfaces/components/component-edit.js +235 -0
  96. package/dist/admin/surfaces/components/component-edit.js.map +1 -0
  97. package/dist/admin/surfaces/components/components-data.d.ts +153 -0
  98. package/dist/admin/surfaces/components/components-data.d.ts.map +1 -0
  99. package/dist/admin/surfaces/components/components-data.js +154 -0
  100. package/dist/admin/surfaces/components/components-data.js.map +1 -0
  101. package/dist/admin/surfaces/components/components-list.d.ts +3 -0
  102. package/dist/admin/surfaces/components/components-list.d.ts.map +1 -0
  103. package/dist/admin/surfaces/components/components-list.js +196 -0
  104. package/dist/admin/surfaces/components/components-list.js.map +1 -0
  105. package/dist/admin/surfaces/components/definition-canvas.d.ts +73 -0
  106. package/dist/admin/surfaces/components/definition-canvas.d.ts.map +1 -0
  107. package/dist/admin/surfaces/components/definition-canvas.js +207 -0
  108. package/dist/admin/surfaces/components/definition-canvas.js.map +1 -0
  109. package/dist/admin/surfaces/layouts/layout-editor-client.d.ts.map +1 -1
  110. package/dist/admin/surfaces/layouts/layout-editor-client.js +14 -2
  111. package/dist/admin/surfaces/layouts/layout-editor-client.js.map +1 -1
  112. package/dist/admin/surfaces/layouts/layouts-data.d.ts +3 -14
  113. package/dist/admin/surfaces/layouts/layouts-data.d.ts.map +1 -1
  114. package/dist/admin/surfaces/layouts/layouts-data.js +4 -7
  115. package/dist/admin/surfaces/layouts/layouts-data.js.map +1 -1
  116. package/dist/admin/surfaces/layouts/layouts-list.d.ts.map +1 -1
  117. package/dist/admin/surfaces/layouts/layouts-list.js +16 -22
  118. package/dist/admin/surfaces/layouts/layouts-list.js.map +1 -1
  119. package/dist/admin/surfaces/settings/admin-features.d.ts.map +1 -1
  120. package/dist/admin/surfaces/settings/admin-features.js +1 -0
  121. package/dist/admin/surfaces/settings/admin-features.js.map +1 -1
  122. package/dist/api/bulk-routes.d.ts.map +1 -1
  123. package/dist/api/bulk-routes.js +25 -0
  124. package/dist/api/bulk-routes.js.map +1 -1
  125. package/dist/api/changeset-routes.d.ts.map +1 -1
  126. package/dist/api/changeset-routes.js +17 -3
  127. package/dist/api/changeset-routes.js.map +1 -1
  128. package/dist/api/component-routes.d.ts.map +1 -1
  129. package/dist/api/component-routes.js +228 -8
  130. package/dist/api/component-routes.js.map +1 -1
  131. package/dist/api/content-routes.d.ts.map +1 -1
  132. package/dist/api/content-routes.js +64 -0
  133. package/dist/api/content-routes.js.map +1 -1
  134. package/dist/api/feed-routes.d.ts +6 -6
  135. package/dist/api/graphql/resolvers.d.ts +4 -4
  136. package/dist/api/layout-routes.d.ts.map +1 -1
  137. package/dist/api/layout-routes.js +4 -17
  138. package/dist/api/layout-routes.js.map +1 -1
  139. package/dist/api/layout-write-guard.d.ts +80 -0
  140. package/dist/api/layout-write-guard.d.ts.map +1 -0
  141. package/dist/api/layout-write-guard.js +88 -0
  142. package/dist/api/layout-write-guard.js.map +1 -0
  143. package/dist/api/section-template-routes.d.ts.map +1 -1
  144. package/dist/api/section-template-routes.js +11 -0
  145. package/dist/api/section-template-routes.js.map +1 -1
  146. package/dist/api/translation-routes.d.ts.map +1 -1
  147. package/dist/api/translation-routes.js +8 -0
  148. package/dist/api/translation-routes.js.map +1 -1
  149. package/dist/api/version-routes.d.ts.map +1 -1
  150. package/dist/api/version-routes.js +20 -8
  151. package/dist/api/version-routes.js.map +1 -1
  152. package/dist/api/workflow-routes.d.ts.map +1 -1
  153. package/dist/api/workflow-routes.js +53 -8
  154. package/dist/api/workflow-routes.js.map +1 -1
  155. package/dist/db/analytics-queries.d.ts +3 -3
  156. package/dist/db/apply-snapshot.d.ts +75 -8
  157. package/dist/db/apply-snapshot.d.ts.map +1 -1
  158. package/dist/db/apply-snapshot.js +104 -8
  159. package/dist/db/apply-snapshot.js.map +1 -1
  160. package/dist/db/audit-queries.d.ts +12 -12
  161. package/dist/db/category-queries.d.ts +3 -3
  162. package/dist/db/changeset-audit-queries.d.ts +4 -4
  163. package/dist/db/changeset-queries.d.ts +7 -7
  164. package/dist/db/comment-queries.d.ts +8 -8
  165. package/dist/db/comment-subscription-queries.d.ts +2 -2
  166. package/dist/db/component-instance-queries.d.ts +197 -0
  167. package/dist/db/component-instance-queries.d.ts.map +1 -0
  168. package/dist/db/component-instance-queries.js +405 -0
  169. package/dist/db/component-instance-queries.js.map +1 -0
  170. package/dist/db/content-queries.d.ts.map +1 -1
  171. package/dist/db/content-queries.js +39 -2
  172. package/dist/db/content-queries.js.map +1 -1
  173. package/dist/db/extension-queries.d.ts +7 -7
  174. package/dist/db/feed-queries.d.ts +3 -3
  175. package/dist/db/index.d.ts +1 -0
  176. package/dist/db/index.d.ts.map +1 -1
  177. package/dist/db/index.js +1 -0
  178. package/dist/db/index.js.map +1 -1
  179. package/dist/db/layout-queries.d.ts +13 -1
  180. package/dist/db/layout-queries.d.ts.map +1 -1
  181. package/dist/db/layout-queries.js +52 -7
  182. package/dist/db/layout-queries.js.map +1 -1
  183. package/dist/db/locale-queries.d.ts.map +1 -1
  184. package/dist/db/locale-queries.js +0 -0
  185. package/dist/db/locale-queries.js.map +1 -1
  186. package/dist/db/media-queries.d.ts +12 -12
  187. package/dist/db/revision-queries.d.ts +3 -3
  188. package/dist/db/site-admin-queries.d.ts +2 -2
  189. package/dist/db/site-membership-queries.d.ts +3 -3
  190. package/dist/db/site-plugin-queries.d.ts +2 -2
  191. package/dist/db/site-queries.d.ts +6 -6
  192. package/dist/db/version-queries.d.ts +12 -12
  193. package/dist/db/webhook-queries.d.ts +11 -11
  194. package/dist/db/workflow-queries.d.ts +6 -6
  195. package/dist/db/workflow-queries.d.ts.map +1 -1
  196. package/dist/db/workflow-queries.js +11 -2
  197. package/dist/db/workflow-queries.js.map +1 -1
  198. package/dist/layout/comments-module.d.ts.map +1 -1
  199. package/dist/layout/comments-module.js +6 -2
  200. package/dist/layout/comments-module.js.map +1 -1
  201. package/dist/layout/component-refs.d.ts +465 -0
  202. package/dist/layout/component-refs.d.ts.map +1 -0
  203. package/dist/layout/component-refs.js +848 -0
  204. package/dist/layout/component-refs.js.map +1 -0
  205. package/dist/layout/index.d.ts +2 -1
  206. package/dist/layout/index.d.ts.map +1 -1
  207. package/dist/layout/index.js +1 -0
  208. package/dist/layout/index.js.map +1 -1
  209. package/dist/layout/module-permissions.d.ts +49 -1
  210. package/dist/layout/module-permissions.d.ts.map +1 -1
  211. package/dist/layout/module-permissions.js +117 -6
  212. package/dist/layout/module-permissions.js.map +1 -1
  213. package/dist/layout/module-render-context.d.ts +32 -0
  214. package/dist/layout/module-render-context.d.ts.map +1 -1
  215. package/dist/layout/module-render-context.js +32 -1
  216. package/dist/layout/module-render-context.js.map +1 -1
  217. package/dist/layout/modules-extended.d.ts.map +1 -1
  218. package/dist/layout/modules-extended.js +21 -11
  219. package/dist/layout/modules-extended.js.map +1 -1
  220. package/dist/layout/modules.d.ts.map +1 -1
  221. package/dist/layout/modules.js +15 -10
  222. package/dist/layout/modules.js.map +1 -1
  223. package/dist/layout/renderer.d.ts +51 -4
  224. package/dist/layout/renderer.d.ts.map +1 -1
  225. package/dist/layout/renderer.js +36 -38
  226. package/dist/layout/renderer.js.map +1 -1
  227. package/dist/layout/types.d.ts +42 -0
  228. package/dist/layout/types.d.ts.map +1 -1
  229. package/dist/layout/types.js +54 -11
  230. package/dist/layout/types.js.map +1 -1
  231. package/dist/schema/base.d.ts +1 -1
  232. package/dist/schema/components.d.ts +150 -0
  233. package/dist/schema/components.d.ts.map +1 -1
  234. package/dist/schema/components.js +50 -4
  235. package/dist/schema/components.js.map +1 -1
  236. package/dist/scope/inline-editing.d.ts.map +1 -1
  237. package/dist/scope/inline-editing.js +10 -0
  238. package/dist/scope/inline-editing.js.map +1 -1
  239. package/dist/scope/sticky-offset.d.ts +185 -0
  240. package/dist/scope/sticky-offset.d.ts.map +1 -0
  241. package/dist/scope/sticky-offset.js +432 -0
  242. package/dist/scope/sticky-offset.js.map +1 -0
  243. package/dist/site/styles.css +41 -3
  244. package/dist/versioning/scheduler.d.ts +8 -0
  245. package/dist/versioning/scheduler.d.ts.map +1 -1
  246. package/dist/versioning/scheduler.js +21 -7
  247. package/dist/versioning/scheduler.js.map +1 -1
  248. 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