@drawnagency/primitives 0.1.56 → 0.1.57

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 (153) hide show
  1. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  2. package/dist/chunk-NSCT3AMV.js +32 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-PRKUXM7E.js} +23 -9
  4. package/dist/{chunk-7IAWF7LE.js → chunk-PYWS3MOJ.js} +12 -2
  5. package/dist/chunk-TG43X7JO.js +123 -0
  6. package/dist/chunk-VKAGMEKE.js +90 -0
  7. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  8. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  9. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  10. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  11. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  12. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  13. package/dist/components/editor/SettingsForm.d.ts +5 -1
  14. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  15. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  16. package/dist/components/primitives/IconPicker.d.ts +7 -1
  17. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  18. package/dist/components/sections/Container/Container.d.ts +20 -0
  19. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  20. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  21. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  22. package/dist/components/sections/Container/index.d.ts +11 -0
  23. package/dist/components/sections/Container/index.d.ts.map +1 -0
  24. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  25. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  26. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  27. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  28. package/dist/components/sections/IconList/index.d.ts +1 -0
  29. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  30. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  31. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  32. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  33. package/dist/components/sections/Media/index.d.ts.map +1 -0
  34. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  35. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  36. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  37. package/dist/components/sections/Spacer/index.d.ts +6 -0
  38. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  39. package/dist/components/sections/all-sections.d.ts +29 -103
  40. package/dist/components/sections/all-sections.d.ts.map +1 -1
  41. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  42. package/dist/components/shared/Tabs.d.ts +24 -0
  43. package/dist/components/shared/Tabs.d.ts.map +1 -0
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  46. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  47. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  48. package/dist/hooks/useBlockDnd.d.ts +48 -0
  49. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  50. package/dist/index.js +56 -48
  51. package/dist/lib/block-dnd.d.ts +42 -0
  52. package/dist/lib/block-dnd.d.ts.map +1 -0
  53. package/dist/lib/block-move.d.ts +31 -0
  54. package/dist/lib/block-move.d.ts.map +1 -0
  55. package/dist/lib/container-grid.d.ts +29 -0
  56. package/dist/lib/container-grid.d.ts.map +1 -0
  57. package/dist/lib/container-ops.d.ts +44 -0
  58. package/dist/lib/container-ops.d.ts.map +1 -0
  59. package/dist/lib/dexie.d.ts.map +1 -1
  60. package/dist/lib/dexie.js +13 -0
  61. package/dist/lib/index.js +10 -7
  62. package/dist/lib/loader.d.ts.map +1 -1
  63. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  64. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  65. package/dist/lib/migrate-sections-transform.js +6 -0
  66. package/dist/lib/registry.d.ts +39 -0
  67. package/dist/lib/registry.d.ts.map +1 -1
  68. package/dist/lib/registry.js +26 -0
  69. package/dist/schemas/block.d.ts +20 -0
  70. package/dist/schemas/block.d.ts.map +1 -0
  71. package/dist/schemas/block.js +14 -0
  72. package/dist/schemas/index.js +8 -2
  73. package/dist/schemas/link.d.ts +7 -0
  74. package/dist/schemas/link.d.ts.map +1 -1
  75. package/dist/schemas/rich-text.d.ts +9 -0
  76. package/dist/schemas/rich-text.d.ts.map +1 -0
  77. package/dist/schemas/sections.d.ts +2 -0
  78. package/dist/schemas/sections.d.ts.map +1 -1
  79. package/dist/schemas/shared.d.ts +30 -0
  80. package/dist/schemas/shared.d.ts.map +1 -1
  81. package/package.json +13 -1
  82. package/src/components/brandguide/Colors.tsx +35 -33
  83. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  84. package/src/components/editor/ColSpanControl.tsx +56 -0
  85. package/src/components/editor/SectionWrapper.tsx +44 -20
  86. package/src/components/editor/SettingsForm.tsx +100 -73
  87. package/src/components/primitives/EditableGrid.tsx +40 -36
  88. package/src/components/primitives/IconPicker.tsx +116 -26
  89. package/src/components/sections/Container/Container.tsx +354 -0
  90. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  91. package/src/components/sections/Container/index.tsx +51 -0
  92. package/src/components/sections/IconList/IconList.tsx +113 -43
  93. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  94. package/src/components/sections/IconList/index.tsx +1 -1
  95. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  96. package/src/components/sections/Media/index.tsx +85 -0
  97. package/src/components/sections/Prose/index.tsx +1 -0
  98. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  99. package/src/components/sections/Spacer/index.tsx +18 -0
  100. package/src/components/sections/all-sections.ts +10 -8
  101. package/src/components/sections/register-schemas.ts +5 -2
  102. package/src/components/shared/Tabs.tsx +63 -0
  103. package/src/components/shell/EditorShell.tsx +105 -13
  104. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  105. package/src/components/shell/blockMoveDispatch.ts +40 -0
  106. package/src/hooks/useBlockDnd.ts +144 -0
  107. package/src/lib/block-dnd.ts +58 -0
  108. package/src/lib/block-move.ts +236 -0
  109. package/src/lib/container-grid.ts +58 -0
  110. package/src/lib/container-ops.ts +159 -0
  111. package/src/lib/dexie.ts +22 -0
  112. package/src/lib/loader.ts +16 -4
  113. package/src/lib/migrate-sections-transform.ts +147 -0
  114. package/src/lib/registry.ts +48 -0
  115. package/src/schemas/block.ts +40 -0
  116. package/src/schemas/link.ts +19 -1
  117. package/src/schemas/rich-text.ts +11 -0
  118. package/src/schemas/sections.ts +5 -1
  119. package/src/schemas/shared.ts +6 -0
  120. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  121. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  122. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  123. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  124. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  125. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  126. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  127. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  128. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  129. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  130. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  131. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  132. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  133. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  134. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  135. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  136. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  137. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  138. package/src/components/brandguide/DoDontList.tsx +0 -67
  139. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  140. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  141. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  142. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  143. package/src/components/sections/DoDontList/index.tsx +0 -45
  144. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  145. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  146. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  147. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  148. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  149. package/src/components/sections/MediaGrid/index.tsx +0 -57
  150. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  151. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  152. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  153. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -0,0 +1,58 @@
1
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
2
+
3
+ /** The page root behaves like a single-column container (spec §3). */
4
+ export const ROOT_CONTAINER_ID = "__root__";
5
+
6
+ export interface BlockDragData {
7
+ dragType: "block";
8
+ blockId: string;
9
+ /** Container the block currently lives in: ROOT_CONTAINER_ID or a container section id. */
10
+ containerId: string;
11
+ index: number;
12
+ /** True if the dragged block is itself a container (used for the depth guard). */
13
+ isContainer: boolean;
14
+ [key: string | symbol]: unknown;
15
+ }
16
+
17
+ export interface BlockDropData {
18
+ dropContainerId: string;
19
+ index: number;
20
+ /** When set, a column-targeted drop (ghost/spacer cell): place at this 1-based grid column. */
21
+ toColumn?: number;
22
+ [key: string | symbol]: unknown;
23
+ }
24
+
25
+ export function buildBlockDragData(args: {
26
+ blockId: string;
27
+ containerId: string;
28
+ index: number;
29
+ isContainer: boolean;
30
+ }): BlockDragData {
31
+ return { dragType: "block", ...args };
32
+ }
33
+
34
+ export function buildBlockDropData(args: { dropContainerId: string; index: number; toColumn?: number }): BlockDropData {
35
+ return { ...args };
36
+ }
37
+
38
+ export function isBlockDragData(data: Record<string | symbol, unknown>): data is BlockDragData {
39
+ return data.dragType === "block";
40
+ }
41
+
42
+ /**
43
+ * May `source` drop into `dropContainerId`?
44
+ * - A container block may only land at the page root (v1 one-level nesting → no
45
+ * container-in-container; MAX_BLOCK_DEPTH=2).
46
+ * - Nothing may drop onto itself.
47
+ */
48
+ export function canDropBlock(source: BlockDragData, dropContainerId: string): boolean {
49
+ if (source.blockId === dropContainerId) return false;
50
+ if (source.isContainer && dropContainerId !== ROOT_CONTAINER_ID) return false;
51
+ return true;
52
+ }
53
+
54
+ /** Closest edge → insertion index (before = index, after = index + 1). */
55
+ export function resolveDropIndex(targetIndex: number, edge: Edge | null): number {
56
+ if (edge === "left" || edge === "top") return targetIndex;
57
+ return targetIndex + 1; // right / bottom / null
58
+ }
@@ -0,0 +1,236 @@
1
+ import type { LoadedSection } from "./loader";
2
+ import type { Section } from "../schemas/sections";
3
+ import type { SiteIndex, SectionMeta } from "../schemas/site-config";
4
+ import { ROOT_CONTAINER_ID, canDropBlock } from "./block-dnd";
5
+ import { getContainerChildren, reorderChild, insertChildAt, removeChildAt, placeBlockAtColumn, trimTrailingSpacers } from "./container-ops";
6
+ import { reorderSectionInPage, addSectionToPage, removeSectionFromPages } from "./pages";
7
+
8
+ export interface BlockMoveState {
9
+ sections: LoadedSection[];
10
+ index: SiteIndex;
11
+ rootPageId: string;
12
+ }
13
+
14
+ export interface BlockMove {
15
+ blockId: string;
16
+ fromContainerId: string;
17
+ fromIndex: number;
18
+ toContainerId: string;
19
+ /** Raw insertion index in the ORIGINAL (pre-removal) array, 0..len — as resolveDropIndex returns. applyBlockMove shifts it for same-container moves. */
20
+ toIndex: number;
21
+ /** When set, place at this 1-based grid column (ghost/spacer drop) instead of toIndex. */
22
+ toColumn?: number;
23
+ }
24
+
25
+ export interface BlockMoveEffects {
26
+ markDirtySectionIds: string[];
27
+ indexDirty: boolean;
28
+ deleteSectionIds: string[];
29
+ createSectionIds: string[];
30
+ }
31
+
32
+ export interface BlockMoveResult {
33
+ sections: LoadedSection[];
34
+ index: SiteIndex;
35
+ effects: BlockMoveEffects;
36
+ changed: boolean;
37
+ }
38
+
39
+ function defaultNewId(): string {
40
+ return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
41
+ ? crypto.randomUUID()
42
+ : `block-${Math.random().toString(36).slice(2)}`;
43
+ }
44
+
45
+ const DEFAULT_CHILD_META = (type: string): SectionMeta => ({ type, status: "draft", access: [] });
46
+
47
+ function isContainerSection(section: Section): boolean {
48
+ return Array.isArray((section.content as { children?: unknown })?.children);
49
+ }
50
+
51
+ function mapContainerSection(sections: LoadedSection[], id: string, fn: (s: Section) => Section): LoadedSection[] {
52
+ return sections.map((l) => (l.section.id === id ? { ...l, section: fn(l.section) } : l));
53
+ }
54
+
55
+ function noop(state: BlockMoveState): BlockMoveResult {
56
+ return {
57
+ sections: state.sections,
58
+ index: state.index,
59
+ effects: { markDirtySectionIds: [], indexDirty: false, deleteSectionIds: [], createSectionIds: [] },
60
+ changed: false,
61
+ };
62
+ }
63
+
64
+ export function applyBlockMove(
65
+ state: BlockMoveState,
66
+ move: BlockMove,
67
+ newId: () => string = defaultNewId,
68
+ ): BlockMoveResult {
69
+ const { sections, index, rootPageId } = state;
70
+ const { blockId, fromContainerId, fromIndex, toContainerId, toIndex } = move;
71
+
72
+ if (move.toColumn != null) return applyColumnPlacement(state, move, newId);
73
+
74
+ const draggedSection = findBlock(sections, fromContainerId, fromIndex, blockId);
75
+ if (!draggedSection) return noop(state);
76
+ const dragData = {
77
+ dragType: "block" as const,
78
+ blockId,
79
+ containerId: fromContainerId,
80
+ index: fromIndex,
81
+ isContainer: isContainerSection(draggedSection),
82
+ };
83
+ if (!canDropBlock(dragData, toContainerId)) return noop(state);
84
+
85
+ if (fromContainerId === toContainerId) {
86
+ // toIndex is the raw insertion index in the original (pre-removal) array.
87
+ // Both reorderSectionInPage and reorderChild use splice(from,1)+splice(to,0,moved),
88
+ // so we must convert to the post-removal position for same-container moves.
89
+ const adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex;
90
+ if (fromContainerId === ROOT_CONTAINER_ID) {
91
+ const nextIndex = reorderSectionInPage(index, rootPageId, fromIndex, adjusted);
92
+ if (nextIndex === index) return noop(state);
93
+ return {
94
+ sections,
95
+ index: nextIndex,
96
+ effects: { markDirtySectionIds: [], indexDirty: true, deleteSectionIds: [], createSectionIds: [] },
97
+ changed: true,
98
+ };
99
+ }
100
+ const nextSections = mapContainerSection(sections, fromContainerId, (c) => reorderChild(c, fromIndex, adjusted));
101
+ return {
102
+ sections: nextSections,
103
+ index,
104
+ effects: { markDirtySectionIds: [fromContainerId], indexDirty: false, deleteSectionIds: [], createSectionIds: [] },
105
+ changed: true,
106
+ };
107
+ }
108
+
109
+ const markDirty = new Set<string>();
110
+ const deleteSectionIds: string[] = [];
111
+ const createSectionIds: string[] = [];
112
+ let indexDirty = false;
113
+ let nextSections = sections;
114
+ let nextIndex = index;
115
+
116
+ let moving: Section;
117
+ if (fromContainerId === ROOT_CONTAINER_ID) {
118
+ moving = draggedSection;
119
+ nextSections = nextSections.filter((l) => l.section.id !== blockId);
120
+ nextIndex = removeSectionFromPages(nextIndex, blockId);
121
+ deleteSectionIds.push(blockId);
122
+ indexDirty = true;
123
+ } else {
124
+ const src = nextSections.find((l) => l.section.id === fromContainerId);
125
+ if (!src) return noop(state);
126
+ const { container, removed } = removeChildAt(src.section, fromIndex);
127
+ if (!removed) return noop(state);
128
+ moving = removed;
129
+ nextSections = mapContainerSection(nextSections, fromContainerId, () => container);
130
+ markDirty.add(fromContainerId);
131
+ }
132
+
133
+ if (toContainerId === ROOT_CONTAINER_ID) {
134
+ const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
135
+ const meta = DEFAULT_CHILD_META(rootBlock.type);
136
+ nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta, toIndex);
137
+ nextSections = [...nextSections, { section: rootBlock as Section, meta }];
138
+ indexDirty = true;
139
+ createSectionIds.push(rootBlock.id);
140
+ markDirty.add(rootBlock.id);
141
+ } else {
142
+ const dest = nextSections.find((l) => l.section.id === toContainerId);
143
+ if (!dest) return noop(state);
144
+ nextSections = mapContainerSection(nextSections, toContainerId, (c) => insertChildAt(c, moving, toIndex));
145
+ markDirty.add(toContainerId);
146
+ }
147
+
148
+ return {
149
+ sections: nextSections,
150
+ index: nextIndex,
151
+ effects: { markDirtySectionIds: [...markDirty], indexDirty, deleteSectionIds, createSectionIds },
152
+ changed: true,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Column-targeted placement (a ghost/spacer drop). Removes the dragged block from its source
158
+ * (root or a container), then places it at 1-based column `toColumn` of the destination
159
+ * container — padding intervening empty columns with spacers, or replacing a spacer already at
160
+ * that column. Root is never a column target (it has no columns).
161
+ */
162
+ function applyColumnPlacement(
163
+ state: BlockMoveState,
164
+ move: BlockMove,
165
+ newId: () => string,
166
+ ): BlockMoveResult {
167
+ const { sections, index } = state;
168
+ const { blockId, fromContainerId, fromIndex, toContainerId } = move;
169
+ const toColumn = move.toColumn!;
170
+
171
+ if (toContainerId === ROOT_CONTAINER_ID) return noop(state);
172
+
173
+ const draggedSection = findBlock(sections, fromContainerId, fromIndex, blockId);
174
+ if (!draggedSection) return noop(state);
175
+ const dragData = {
176
+ dragType: "block" as const,
177
+ blockId,
178
+ containerId: fromContainerId,
179
+ index: fromIndex,
180
+ isContainer: isContainerSection(draggedSection),
181
+ };
182
+ if (!canDropBlock(dragData, toContainerId)) return noop(state);
183
+
184
+ const markDirty = new Set<string>();
185
+ const deleteSectionIds: string[] = [];
186
+ let indexDirty = false;
187
+ let nextSections = sections;
188
+ let nextIndex = index;
189
+ let moving: Section;
190
+
191
+ if (fromContainerId === ROOT_CONTAINER_ID) {
192
+ moving = draggedSection;
193
+ nextSections = nextSections.filter((l) => l.section.id !== blockId);
194
+ nextIndex = removeSectionFromPages(nextIndex, blockId);
195
+ deleteSectionIds.push(blockId);
196
+ indexDirty = true;
197
+ } else {
198
+ const src = nextSections.find((l) => l.section.id === fromContainerId);
199
+ if (!src) return noop(state);
200
+ const { container, removed } = removeChildAt(src.section, fromIndex);
201
+ if (!removed) return noop(state);
202
+ moving = removed;
203
+ nextSections = mapContainerSection(nextSections, fromContainerId, () => trimTrailingSpacers(container));
204
+ markDirty.add(fromContainerId);
205
+ }
206
+
207
+ const dest = nextSections.find((l) => l.section.id === toContainerId);
208
+ if (!dest) return noop(state);
209
+ const columns = ((dest.section.content as { columns?: number })?.columns) ?? 1;
210
+ nextSections = mapContainerSection(nextSections, toContainerId, (c) =>
211
+ placeBlockAtColumn(c, moving, toColumn, columns, newId),
212
+ );
213
+ markDirty.add(toContainerId);
214
+
215
+ return {
216
+ sections: nextSections,
217
+ index: nextIndex,
218
+ effects: { markDirtySectionIds: [...markDirty], indexDirty, deleteSectionIds, createSectionIds: [] },
219
+ changed: true,
220
+ };
221
+ }
222
+
223
+ function findBlock(
224
+ sections: LoadedSection[],
225
+ containerId: string,
226
+ index: number,
227
+ blockId: string,
228
+ ): Section | null {
229
+ if (containerId === ROOT_CONTAINER_ID) {
230
+ return sections.find((l) => l.section.id === blockId)?.section ?? null;
231
+ }
232
+ const host = sections.find((l) => l.section.id === containerId);
233
+ if (!host) return null;
234
+ const children = getContainerChildren(host.section);
235
+ return children[index]?.id === blockId ? children[index] : (children.find((c) => c.id === blockId) ?? null);
236
+ }
@@ -0,0 +1,58 @@
1
+ /** Max columns a container exposes in v1 (covers halves/thirds/sixths via spans). */
2
+ export const MAX_CONTAINER_COLUMNS = 6;
3
+
4
+ /**
5
+ * Column count -> grid-cols classes that collapse by the CONTAINER's own width
6
+ * (Tailwind 4 container-query `@` variants), not the viewport. Full static
7
+ * strings so Tailwind's source scan generates them (no interpolation/purge gap).
8
+ */
9
+ export const containerGridClass: Record<number, string> = {
10
+ 1: "grid-cols-1",
11
+ 2: "grid-cols-1 @sm:grid-cols-2",
12
+ 3: "grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3",
13
+ 4: "grid-cols-1 @xs:grid-cols-2 @lg:grid-cols-4",
14
+ 5: "grid-cols-1 @xs:grid-cols-2 @md:grid-cols-3 @xl:grid-cols-5",
15
+ 6: "grid-cols-1 @xs:grid-cols-2 @md:grid-cols-3 @2xl:grid-cols-6",
16
+ };
17
+
18
+ /**
19
+ * Per-child colSpan -> col-span classes, keyed [columns][span]. A child's span
20
+ * must clamp to the columns AVAILABLE at each container-query breakpoint, and the
21
+ * collapse curve differs per column count — so the class depends on BOTH the
22
+ * parent's column count and the child's span, not span alone. At every breakpoint
23
+ * the emitted span = min(span, columns-at-that-breakpoint), guaranteeing a span
24
+ * never exceeds the tracks that exist (no phantom column). Span 1 needs no class.
25
+ * Full static strings so Tailwind's source scan generates them.
26
+ */
27
+ export const colSpanClass: Record<number, Record<number, string>> = {
28
+ 1: { 1: "" },
29
+ 2: { 1: "", 2: "col-span-1 @sm:col-span-2" },
30
+ 3: { 1: "", 2: "col-span-1 @sm:col-span-2", 3: "col-span-1 @sm:col-span-2 @lg:col-span-3" },
31
+ 4: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @lg:col-span-3", 4: "col-span-1 @xs:col-span-2 @lg:col-span-4" },
32
+ 5: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @md:col-span-3", 4: "col-span-1 @xs:col-span-2 @md:col-span-3 @xl:col-span-4", 5: "col-span-1 @xs:col-span-2 @md:col-span-3 @xl:col-span-5" },
33
+ 6: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @md:col-span-3", 4: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-4", 5: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-5", 6: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-6" },
34
+ };
35
+
36
+ /** Gap token -> Tailwind gap class. Retained as a utility; the container now uses a
37
+ * fixed gap (the Gap setting was removed), but the map stays for general use. */
38
+ export const gapClass: Record<string, string> = {
39
+ none: "gap-0",
40
+ sm: "gap-2",
41
+ md: "gap-4",
42
+ lg: "gap-8",
43
+ };
44
+
45
+ /**
46
+ * Per-column-count container-query classes that HIDE a spacer cell once the container
47
+ * collapses to a single column (mobile). Mirrors `containerGridClass`'s collapse curve:
48
+ * hidden at base width, shown at the first breakpoint where the grid has ≥2 columns.
49
+ * Static strings so Tailwind's source scan generates them.
50
+ */
51
+ export const spacerHiddenClass: Record<number, string> = {
52
+ 1: "hidden",
53
+ 2: "hidden @sm:block",
54
+ 3: "hidden @sm:block",
55
+ 4: "hidden @xs:block",
56
+ 5: "hidden @xs:block",
57
+ 6: "hidden @xs:block",
58
+ };
@@ -0,0 +1,159 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Section } from "../schemas/sections";
3
+ import { getAllSections } from "./registry";
4
+
5
+ interface ContainerContent {
6
+ columns: number;
7
+ gap: string;
8
+ flow: string;
9
+ childDefaults?: Record<string, unknown>;
10
+ children: Section[];
11
+ }
12
+
13
+ /** A container is any block whose `content.children` is an array. */
14
+ export function getContainerChildren(block: Section): Section[] {
15
+ const content = block.content as Partial<ContainerContent> | undefined;
16
+ return Array.isArray(content?.children) ? (content!.children as Section[]) : [];
17
+ }
18
+
19
+ function withChildren(container: Section, children: Section[]): Section {
20
+ return { ...container, content: { ...(container.content as object), children } };
21
+ }
22
+
23
+ function containerColumns(container: Section): number {
24
+ const n = (container.content as Partial<ContainerContent>)?.columns;
25
+ return typeof n === "number" && n >= 1 ? n : 1;
26
+ }
27
+
28
+ export function reorderChild(container: Section, from: number, to: number): Section {
29
+ const children = getContainerChildren(container);
30
+ if (from < 0 || from >= children.length || to < 0 || to >= children.length) return container;
31
+ const next = [...children];
32
+ const [moved] = next.splice(from, 1);
33
+ next.splice(to, 0, moved);
34
+ return withChildren(container, next);
35
+ }
36
+
37
+ /**
38
+ * Block types insertable as a container CHILD — excludes `container` itself
39
+ * (v1 enforces one level of container nesting). Shared so the depth guard has a
40
+ * single, testable source of truth (the container toolbar's "+" picker uses it).
41
+ */
42
+ export function childInsertableTypes(): { type: string; label: string; icon: ReactNode }[] {
43
+ return getAllSections()
44
+ .filter((d) => d.type !== "container" && d.type !== "spacer")
45
+ .map((d) => ({ type: d.type, label: d.label, icon: d.icon }));
46
+ }
47
+
48
+ export function insertChildAt(container: Section, child: Section, index: number): Section {
49
+ const children = getContainerChildren(container);
50
+ const at = Math.max(0, Math.min(index, children.length));
51
+ const next = [...children];
52
+ next.splice(at, 0, child);
53
+ return withChildren(container, next);
54
+ }
55
+
56
+ export function removeChildAt(
57
+ container: Section,
58
+ index: number,
59
+ ): { container: Section; removed: Section | null } {
60
+ const children = getContainerChildren(container);
61
+ if (index < 0 || index >= children.length) return { container, removed: null };
62
+ const next = [...children];
63
+ const [removed] = next.splice(index, 1);
64
+ return { container: withChildren(container, next), removed };
65
+ }
66
+
67
+ export function duplicateChildAt(
68
+ container: Section,
69
+ index: number,
70
+ newId: () => string,
71
+ ): Section {
72
+ const children = getContainerChildren(container);
73
+ if (index < 0 || index >= children.length) return container;
74
+ const clone = { ...(JSON.parse(JSON.stringify(children[index])) as Section), id: newId() };
75
+ const next = [...children];
76
+ next.splice(index + 1, 0, clone);
77
+ return withChildren(container, next);
78
+ }
79
+
80
+ /** Set a child's colSpan, clamped to the container's columns. Span 1 removes `layout`. */
81
+ export function setChildColSpan(container: Section, index: number, span: number): Section {
82
+ const children = getContainerChildren(container);
83
+ if (index < 0 || index >= children.length) return container;
84
+ const clamped = Math.max(1, Math.min(span, containerColumns(container)));
85
+ const next = children.map((child, i) => {
86
+ if (i !== index) return child;
87
+ if (clamped <= 1) {
88
+ const { layout: _drop, ...rest } = child;
89
+ return rest as Section;
90
+ }
91
+ return { ...child, layout: { ...child.layout, colSpan: clamped } };
92
+ });
93
+ return withChildren(container, next);
94
+ }
95
+
96
+ /** Effective grid tracks a child occupies (spacer = 1; real child = clamped colSpan). */
97
+ function effectiveSpan(child: Section, columns: number): number {
98
+ return isSpacer(child) ? 1 : Math.min(child.layout?.colSpan ?? 1, columns);
99
+ }
100
+
101
+ /** A spacer is the empty gap-filler block. */
102
+ export function isSpacer(child: Section): boolean {
103
+ return child.type === "spacer";
104
+ }
105
+
106
+ /** Mint a fresh empty spacer block. */
107
+ export function makeSpacer(newId: () => string): Section {
108
+ return { id: newId(), type: "spacer", content: {} };
109
+ }
110
+
111
+ /** Grid tracks occupied by a container's children (real child = clamped colSpan, spacer = 1). */
112
+ export function occupiedColumns(children: Section[], columns: number): number {
113
+ return children.reduce((sum, c) => sum + effectiveSpan(c, columns), 0);
114
+ }
115
+
116
+ /** Remove spacer children after the last non-spacer child (a trailing gap is meaningless). */
117
+ export function trimTrailingSpacers(container: Section): Section {
118
+ const children = getContainerChildren(container);
119
+ let end = children.length;
120
+ while (end > 0 && isSpacer(children[end - 1])) end--;
121
+ if (end === children.length) return container;
122
+ return withChildren(container, children.slice(0, end));
123
+ }
124
+
125
+ /**
126
+ * Place `block` at 1-based grid column `K` of `container`. If a spacer already holds column
127
+ * `K`, replace it; otherwise pad intervening empty columns with spacers and append the block.
128
+ * Trailing spacers are trimmed. `columns` clamps colSpan accounting; `newId` mints spacer ids.
129
+ *
130
+ * Precondition: `K` addresses an EMPTY column — a trailing empty column or one held by a
131
+ * spacer. The editor's drop targets (ghost/spacer cells) only ever address empty columns, so
132
+ * callers never target a column consumed by a real block. Defensive: out-of-range `K`
133
+ * (`K < 1 || K > columns`) or a spacer `block` returns the container unchanged. A `block` with
134
+ * `colSpan > 1` dropped onto a single spacer keeps its span; CSS grid reflows it (v1).
135
+ */
136
+ export function placeBlockAtColumn(
137
+ container: Section,
138
+ block: Section,
139
+ K: number,
140
+ columns: number,
141
+ newId: () => string,
142
+ ): Section {
143
+ if (K < 1 || K > columns) return container;
144
+ if (isSpacer(block)) return container;
145
+ const children = getContainerChildren(container);
146
+ let col = 0;
147
+ for (let i = 0; i < children.length; i++) {
148
+ const span = effectiveSpan(children[i], columns);
149
+ if (isSpacer(children[i]) && K >= col + 1 && K <= col + span) {
150
+ const next = [...children];
151
+ next.splice(i, 1, block);
152
+ return trimTrailingSpacers(withChildren(container, next));
153
+ }
154
+ col += span;
155
+ }
156
+ const pad = Math.max(0, K - 1 - col);
157
+ const spacers = Array.from({ length: pad }, () => makeSpacer(newId));
158
+ return trimTrailingSpacers(withChildren(container, [...children, ...spacers, block]));
159
+ }
package/src/lib/dexie.ts CHANGED
@@ -142,6 +142,28 @@ class EditorDatabase extends Dexie {
142
142
  await tx.table("siteIndex").clear();
143
143
  await tx.table("contentCache").clear();
144
144
  });
145
+ // v8: the legacy→container migration (Plan 5 retirement of split_content/
146
+ // media_grid/do_dont_grid/do_dont). The editor's loadContent prefers the
147
+ // cached content when its SHA matches, and that path does NOT re-run the
148
+ // loader fallback — so a cache populated before the package gained
149
+ // `container` support holds raw legacy sections that the retired registry
150
+ // can't render ("Unknown section type: media_grid"). Drop the cached
151
+ // baseline so the editor re-fetches through /api/content →
152
+ // mergeSiteContent, which upgrades legacy sections to containers.
153
+ // One-time; re-fetched content is native.
154
+ this.version(8).stores({
155
+ sections: "sectionId",
156
+ siteIndex: "key",
157
+ meta: "key",
158
+ siteConfig: "key",
159
+ contentCache: "key",
160
+ mediaManifest: "key",
161
+ pendingMedia: "id",
162
+ pendingMediaDeletions: "id",
163
+ }).upgrade(async (tx) => {
164
+ await tx.table("siteIndex").clear();
165
+ await tx.table("contentCache").clear();
166
+ });
145
167
  }
146
168
  }
147
169
 
package/src/lib/loader.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { getSectionSchema, type Section } from "../schemas/sections";
2
2
  import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
3
  import { getAllSchemas } from "./registry";
4
+ import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
5
+ import { upgradeLegacySection } from "./migrate-sections-transform";
4
6
 
5
7
  export interface LoadedSection {
6
8
  section: Section;
@@ -28,11 +30,19 @@ export function mergeSiteContent(
28
30
 
29
31
  const orderedIds = index.pages.flatMap((p) => p.order);
30
32
  for (const id of orderedIds) {
31
- const raw = sectionFiles[id];
32
- if (!raw) {
33
+ const rawFile = sectionFiles[id];
34
+ if (!rawFile) {
33
35
  console.warn(`Section file missing for id: ${id}, skipping`);
34
36
  continue;
35
37
  }
38
+ // Transitional upgrade: rewrite legacy-typed JSON to a container BEFORE
39
+ // validation, so retired/legacy files render on every load path. Returns the
40
+ // same reference for non-legacy input (idempotent). We normalize the emitted
41
+ // section's meta.type when an upgrade happens (no mutation of the caller's index).
42
+ const raw = upgradeLegacySection(rawFile);
43
+ const baseMeta = index.sections[id];
44
+ const meta = raw !== rawFile ? { ...baseMeta, type: "container" } : baseMeta;
45
+
36
46
  if (canValidate && schema) {
37
47
  const result = schema.safeParse(raw);
38
48
  if (!result.success) {
@@ -40,10 +50,12 @@ export function mergeSiteContent(
40
50
  console.warn(`Skipping section "${id}" (type: ${type}): invalid schema`);
41
51
  continue;
42
52
  }
43
- const meta = index.sections[id];
53
+ if (blockDepth(result.data) > MAX_BLOCK_DEPTH) {
54
+ console.warn(`Skipping section "${id}": block tree depth exceeds ${MAX_BLOCK_DEPTH}`);
55
+ continue;
56
+ }
44
57
  sections.push({ section: result.data, meta });
45
58
  } else {
46
- const meta = index.sections[id];
47
59
  sections.push({ section: raw as Section, meta });
48
60
  }
49
61
  }