@drawnagency/primitives 0.1.56 → 0.1.58

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 (165) hide show
  1. package/dist/adapter-HH47ZPGM.js +1779 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-AN62WPW7.js} +22 -163
  4. package/dist/chunk-ESE5UBQI.js +73 -0
  5. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  6. package/dist/chunk-JSBRDJBE.js +30 -0
  7. package/dist/chunk-NSCT3AMV.js +32 -0
  8. package/dist/chunk-RFZNNCAS.js +160 -0
  9. package/dist/chunk-TG43X7JO.js +123 -0
  10. package/dist/{chunk-7IAWF7LE.js → chunk-V7JN2DDU.js} +2 -19
  11. package/dist/chunk-VKAGMEKE.js +90 -0
  12. package/dist/chunk-ZU2MKPTG.js +29 -0
  13. package/dist/closest-edge-EBOXL3YW.js +72 -0
  14. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  16. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  17. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  18. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/SettingsForm.d.ts +5 -1
  21. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  22. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  23. package/dist/components/primitives/IconPicker.d.ts +7 -1
  24. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  25. package/dist/components/sections/Container/Container.d.ts +20 -0
  26. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  28. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  29. package/dist/components/sections/Container/index.d.ts +11 -0
  30. package/dist/components/sections/Container/index.d.ts.map +1 -0
  31. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  32. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  33. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  34. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  35. package/dist/components/sections/IconList/index.d.ts +1 -0
  36. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  37. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  38. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  39. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  40. package/dist/components/sections/Media/index.d.ts.map +1 -0
  41. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  42. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  43. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  44. package/dist/components/sections/Spacer/index.d.ts +6 -0
  45. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  46. package/dist/components/sections/all-sections.d.ts +29 -103
  47. package/dist/components/sections/all-sections.d.ts.map +1 -1
  48. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  49. package/dist/components/sections/register-schemas.js +4094 -0
  50. package/dist/components/shared/Tabs.d.ts +24 -0
  51. package/dist/components/shared/Tabs.d.ts.map +1 -0
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  54. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  55. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  56. package/dist/hooks/useBlockDnd.d.ts +48 -0
  57. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  58. package/dist/index.js +69 -56
  59. package/dist/lib/block-dnd.d.ts +42 -0
  60. package/dist/lib/block-dnd.d.ts.map +1 -0
  61. package/dist/lib/block-move.d.ts +31 -0
  62. package/dist/lib/block-move.d.ts.map +1 -0
  63. package/dist/lib/container-grid.d.ts +29 -0
  64. package/dist/lib/container-grid.d.ts.map +1 -0
  65. package/dist/lib/container-ops.d.ts +44 -0
  66. package/dist/lib/container-ops.d.ts.map +1 -0
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +15 -0
  69. package/dist/lib/env.js +1 -0
  70. package/dist/lib/index.js +19 -13
  71. package/dist/lib/loader.d.ts.map +1 -1
  72. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  73. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  74. package/dist/lib/migrate-sections-transform.js +7 -0
  75. package/dist/lib/registry.d.ts +39 -0
  76. package/dist/lib/registry.d.ts.map +1 -1
  77. package/dist/lib/registry.js +27 -0
  78. package/dist/media/index.js +1 -0
  79. package/dist/schemas/auth.js +1 -0
  80. package/dist/schemas/block.d.ts +20 -0
  81. package/dist/schemas/block.d.ts.map +1 -0
  82. package/dist/schemas/block.js +15 -0
  83. package/dist/schemas/index.js +13 -4
  84. package/dist/schemas/link.d.ts +7 -0
  85. package/dist/schemas/link.d.ts.map +1 -1
  86. package/dist/schemas/rich-text.d.ts +9 -0
  87. package/dist/schemas/rich-text.d.ts.map +1 -0
  88. package/dist/schemas/sections.d.ts +2 -0
  89. package/dist/schemas/sections.d.ts.map +1 -1
  90. package/dist/schemas/shared.d.ts +30 -0
  91. package/dist/schemas/shared.d.ts.map +1 -1
  92. package/dist/types/database.js +2 -0
  93. package/package.json +17 -1
  94. package/src/components/brandguide/Colors.tsx +35 -33
  95. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  96. package/src/components/editor/ColSpanControl.tsx +56 -0
  97. package/src/components/editor/SectionWrapper.tsx +44 -20
  98. package/src/components/editor/SettingsForm.tsx +100 -73
  99. package/src/components/primitives/EditableGrid.tsx +40 -36
  100. package/src/components/primitives/IconPicker.tsx +116 -26
  101. package/src/components/sections/Container/Container.tsx +354 -0
  102. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  103. package/src/components/sections/Container/index.tsx +51 -0
  104. package/src/components/sections/IconList/IconList.tsx +113 -43
  105. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  106. package/src/components/sections/IconList/index.tsx +1 -1
  107. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  108. package/src/components/sections/Media/index.tsx +85 -0
  109. package/src/components/sections/Prose/index.tsx +1 -0
  110. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  111. package/src/components/sections/Spacer/index.tsx +18 -0
  112. package/src/components/sections/all-sections.ts +10 -8
  113. package/src/components/sections/register-schemas.ts +5 -2
  114. package/src/components/shared/Tabs.tsx +63 -0
  115. package/src/components/shell/EditorShell.tsx +105 -13
  116. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  117. package/src/components/shell/blockMoveDispatch.ts +40 -0
  118. package/src/hooks/useBlockDnd.ts +144 -0
  119. package/src/lib/block-dnd.ts +58 -0
  120. package/src/lib/block-move.ts +236 -0
  121. package/src/lib/container-grid.ts +58 -0
  122. package/src/lib/container-ops.ts +159 -0
  123. package/src/lib/dexie.ts +22 -0
  124. package/src/lib/loader.ts +16 -4
  125. package/src/lib/migrate-sections-transform.ts +147 -0
  126. package/src/lib/registry.ts +48 -0
  127. package/src/schemas/block.ts +40 -0
  128. package/src/schemas/link.ts +19 -1
  129. package/src/schemas/rich-text.ts +11 -0
  130. package/src/schemas/sections.ts +5 -1
  131. package/src/schemas/shared.ts +6 -0
  132. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  133. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  134. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  135. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  136. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  137. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  138. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  139. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  140. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  141. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  142. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  143. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  144. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  145. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  146. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  147. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  148. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  149. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  150. package/src/components/brandguide/DoDontList.tsx +0 -67
  151. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  152. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  153. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  154. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  155. package/src/components/sections/DoDontList/index.tsx +0 -45
  156. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  157. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  158. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  159. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  160. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  161. package/src/components/sections/MediaGrid/index.tsx +0 -57
  162. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  163. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  164. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  165. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -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
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pure, browser-safe transform that rewrites the three legacy "layout-as-content"
3
+ * section types into the recursive `container` block. Used by BOTH the loader
4
+ * fallback (mergeSiteContent) and the codemod (scripts/migrate-sections.ts), so
5
+ * it must stay free of Node APIs and registry/schema imports.
6
+ *
7
+ * Structural only — it never sanitizes. Output is emitted in schema-normal form
8
+ * (every defaulted key explicit, only known ref fields carried) so that
9
+ * `transform(raw)` deep-equals `safeParse(transform(raw))` (convergence).
10
+ */
11
+
12
+ type Obj = Record<string, unknown>;
13
+
14
+ const RETIRED = new Set(["split_content", "media_grid", "do_dont_grid", "do_dont"]);
15
+
16
+ export function upgradeLegacySection(raw: unknown): unknown {
17
+ if (!raw || typeof raw !== "object") return raw;
18
+ const section = raw as Obj;
19
+ if (!RETIRED.has(section.type as string)) return raw; // same reference → idempotent
20
+ if (section.type === "split_content") return upgradeSplitContent(section);
21
+ if (section.type === "do_dont") return upgradeDoDont(section);
22
+ return upgradeGrid(section); // media_grid | do_dont_grid (per-item type drives the mapping)
23
+ }
24
+
25
+ /** Carry only known BaseMediaRef (+ video) fields; drops render artifacts (src/srcset/alt) and any stray keys. */
26
+ function buildRef(item: Obj, forceImage: boolean): Obj {
27
+ const type = forceImage ? "image" : (item.type as string);
28
+ const ref: Obj = { type, imageId: item.imageId ?? "" };
29
+ if (item.caption !== undefined) ref.caption = item.caption;
30
+ if (item.background !== undefined) ref.background = item.background;
31
+ if (item.invertFrom !== undefined) ref.invertFrom = item.invertFrom;
32
+ if (item.border !== undefined) ref.border = item.border;
33
+ if (item.objectFit !== undefined) ref.objectFit = item.objectFit;
34
+ if (type === "video") {
35
+ if (item.poster !== undefined) ref.poster = item.poster;
36
+ if (item.autoplay !== undefined) ref.autoplay = item.autoplay;
37
+ if (item.loop !== undefined) ref.loop = item.loop;
38
+ if (item.muted !== undefined) ref.muted = item.muted;
39
+ }
40
+ return ref;
41
+ }
42
+
43
+ function mapRefItem(item: Obj, parentId: string, index: number): Obj {
44
+ const content: Obj = {};
45
+ if (item.type === "linkedImage") {
46
+ content.ref = buildRef(item, true);
47
+ content.link = { kind: "external", href: item.href ?? "", target: item.target === "_blank" ? "_blank" : "_self" };
48
+ } else if (item.type === "doDontImage") {
49
+ content.ref = buildRef(item, true);
50
+ if (item.doDont !== undefined) content.dodont = item.doDont;
51
+ } else {
52
+ content.ref = buildRef(item, false); // image | video
53
+ }
54
+ return { id: `${parentId}__media_${index}`, type: "media", content, options: {} };
55
+ }
56
+
57
+ function upgradeGrid(section: Obj): Obj {
58
+ const id = section.id as string;
59
+ const content = (section.content ?? {}) as Obj;
60
+ const options = (section.options ?? {}) as Obj;
61
+ const media = Array.isArray(content.media) ? (content.media as Obj[]) : [];
62
+
63
+ const childDefaults: Obj = {};
64
+ if (options.square !== undefined) childDefaults.square = options.square;
65
+ if (options.showCaptions !== undefined) childDefaults.showCaption = options.showCaptions; // RENAME plural→singular
66
+ if (options.border !== undefined) childDefaults.border = options.border;
67
+ if (options.crop) childDefaults.objectFit = "cover"; // crop:true → 'cover'; else omit (→ contain)
68
+
69
+ const out: Obj = {
70
+ id,
71
+ type: "container",
72
+ content: {
73
+ columns: content.columns,
74
+ flow: "row",
75
+ children: media.map((item, i) => mapRefItem(item, id, i)),
76
+ },
77
+ };
78
+ if (Object.keys(childDefaults).length > 0) (out.content as Obj).childDefaults = childDefaults;
79
+ if (section.layout !== undefined) out.layout = section.layout;
80
+ return out;
81
+ }
82
+
83
+ function upgradeSplitContent(section: Obj): Obj {
84
+ const id = section.id as string;
85
+ const content = (section.content ?? {}) as Obj;
86
+ const options = (section.options ?? {}) as Obj;
87
+
88
+ const mediaChild: Obj = {
89
+ id: `${id}__media_0`,
90
+ type: "media",
91
+ content: { ref: { type: "image", imageId: content.imageId ?? "" } },
92
+ options: options.border !== undefined ? { border: options.border } : {},
93
+ };
94
+ const proseChild: Obj = {
95
+ id: `${id}__prose_1`,
96
+ type: "prose",
97
+ content: { body: content.body ?? "" },
98
+ };
99
+ const children = options.imagePosition === "right" ? [proseChild, mediaChild] : [mediaChild, proseChild];
100
+
101
+ const out: Obj = { id, type: "container", content: { columns: 2, flow: "row", children } };
102
+ if (section.layout !== undefined) out.layout = section.layout;
103
+ return out;
104
+ }
105
+
106
+ /** Carry showLabel/stackText onto each icon_list child; omit the options key entirely when neither is set. */
107
+ function dodontChildOptions(options: Obj): Obj | undefined {
108
+ const out: Obj = {};
109
+ if (options.showLabel !== undefined) out.showLabel = options.showLabel;
110
+ if (options.stackText !== undefined) out.stackText = options.stackText;
111
+ return Object.keys(out).length > 0 ? out : undefined;
112
+ }
113
+
114
+ /** doItems/dontItems → two tagged icon_list children. dodont forces the glyph, so per-item icon is dropped. */
115
+ function dodontChild(items: Obj[], parentId: string, index: number, tag: "do" | "dont", options: Obj | undefined): Obj {
116
+ const child: Obj = {
117
+ id: `${parentId}__icon_list_${index}`,
118
+ type: "icon_list",
119
+ content: { items: items.map((it) => ({ label: it.label ?? "", text: it.text ?? "", dodont: tag })) },
120
+ };
121
+ if (options !== undefined) child.options = options;
122
+ return child;
123
+ }
124
+
125
+ function upgradeDoDont(section: Obj): Obj {
126
+ const id = section.id as string;
127
+ const content = (section.content ?? {}) as Obj;
128
+ const options = (section.options ?? {}) as Obj;
129
+ const doItems = Array.isArray(content.doItems) ? (content.doItems as Obj[]) : [];
130
+ const dontItems = Array.isArray(content.dontItems) ? (content.dontItems as Obj[]) : [];
131
+
132
+ const childOptions = dodontChildOptions(options);
133
+ const out: Obj = {
134
+ id,
135
+ type: "container",
136
+ content: {
137
+ columns: 2,
138
+ flow: "row",
139
+ children: [
140
+ dodontChild(doItems, id, 0, "do", childOptions),
141
+ dodontChild(dontItems, id, 1, "dont", childOptions),
142
+ ],
143
+ },
144
+ };
145
+ if (section.layout !== undefined) out.layout = section.layout;
146
+ return out;
147
+ }