@drawnagency/primitives 0.1.55 → 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 (173) hide show
  1. package/dist/auth/cookies.d.ts.map +1 -1
  2. package/dist/auth/index.js +1 -1
  3. package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
  4. package/dist/chunk-NSCT3AMV.js +32 -0
  5. package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
  6. package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
  7. package/dist/chunk-TG43X7JO.js +123 -0
  8. package/dist/chunk-VKAGMEKE.js +90 -0
  9. package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
  10. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  11. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  12. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  13. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  14. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  15. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/SettingsForm.d.ts +5 -1
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  19. package/dist/components/primitives/IconPicker.d.ts +7 -1
  20. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  21. package/dist/components/sections/Container/Container.d.ts +20 -0
  22. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  23. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  24. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  25. package/dist/components/sections/Container/index.d.ts +11 -0
  26. package/dist/components/sections/Container/index.d.ts.map +1 -0
  27. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  28. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  29. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  30. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  31. package/dist/components/sections/IconList/index.d.ts +1 -0
  32. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  33. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  34. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  35. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  36. package/dist/components/sections/Media/index.d.ts.map +1 -0
  37. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  38. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  39. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  40. package/dist/components/sections/Spacer/index.d.ts +6 -0
  41. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  42. package/dist/components/sections/all-sections.d.ts +140 -0
  43. package/dist/components/sections/all-sections.d.ts.map +1 -0
  44. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  45. package/dist/components/sections/register.d.ts.map +1 -1
  46. package/dist/components/shared/Tabs.d.ts +24 -0
  47. package/dist/components/shared/Tabs.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts +2 -1
  49. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  50. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  51. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  52. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  53. package/dist/hooks/useBlockDnd.d.ts +48 -0
  54. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  55. package/dist/hooks/useEditorPublish.d.ts +2 -1
  56. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  57. package/dist/index.js +69 -48
  58. package/dist/lib/block-dnd.d.ts +42 -0
  59. package/dist/lib/block-dnd.d.ts.map +1 -0
  60. package/dist/lib/block-move.d.ts +31 -0
  61. package/dist/lib/block-move.d.ts.map +1 -0
  62. package/dist/lib/container-grid.d.ts +29 -0
  63. package/dist/lib/container-grid.d.ts.map +1 -0
  64. package/dist/lib/container-ops.d.ts +44 -0
  65. package/dist/lib/container-ops.d.ts.map +1 -0
  66. package/dist/lib/dexie.d.ts +12 -1
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +28 -3
  69. package/dist/lib/index.js +10 -7
  70. package/dist/lib/loader.d.ts.map +1 -1
  71. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  72. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  73. package/dist/lib/migrate-sections-transform.js +6 -0
  74. package/dist/lib/registry.d.ts +39 -2
  75. package/dist/lib/registry.d.ts.map +1 -1
  76. package/dist/lib/registry.js +26 -0
  77. package/dist/lib/sanitize.d.ts.map +1 -1
  78. package/dist/schemas/block.d.ts +20 -0
  79. package/dist/schemas/block.d.ts.map +1 -0
  80. package/dist/schemas/block.js +14 -0
  81. package/dist/schemas/index.js +10 -2
  82. package/dist/schemas/link.d.ts +7 -0
  83. package/dist/schemas/link.d.ts.map +1 -1
  84. package/dist/schemas/rich-text.d.ts +9 -0
  85. package/dist/schemas/rich-text.d.ts.map +1 -0
  86. package/dist/schemas/sections.d.ts +2 -0
  87. package/dist/schemas/sections.d.ts.map +1 -1
  88. package/dist/schemas/shared.d.ts +31 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/storage/index.d.ts +1 -0
  91. package/dist/storage/index.d.ts.map +1 -1
  92. package/dist/storage/types.d.ts +13 -1
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/package.json +13 -1
  95. package/src/auth/cookies.ts +6 -1
  96. package/src/components/brandguide/Colors.tsx +35 -33
  97. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  98. package/src/components/editor/ColSpanControl.tsx +56 -0
  99. package/src/components/editor/SectionWrapper.tsx +44 -20
  100. package/src/components/editor/SettingsForm.tsx +100 -73
  101. package/src/components/primitives/EditableGrid.tsx +40 -36
  102. package/src/components/primitives/IconPicker.tsx +116 -26
  103. package/src/components/sections/Container/Container.tsx +354 -0
  104. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  105. package/src/components/sections/Container/index.tsx +51 -0
  106. package/src/components/sections/IconList/IconList.tsx +113 -43
  107. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  108. package/src/components/sections/IconList/index.tsx +1 -1
  109. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  110. package/src/components/sections/Media/index.tsx +85 -0
  111. package/src/components/sections/Prose/index.tsx +1 -0
  112. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  113. package/src/components/sections/Spacer/index.tsx +18 -0
  114. package/src/components/sections/all-sections.ts +40 -0
  115. package/src/components/sections/register-schemas.ts +13 -18
  116. package/src/components/sections/register.ts +3 -17
  117. package/src/components/shared/Tabs.tsx +63 -0
  118. package/src/components/shell/EditorShell.tsx +147 -18
  119. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  120. package/src/components/shell/blockMoveDispatch.ts +40 -0
  121. package/src/hooks/useBlockDnd.ts +144 -0
  122. package/src/hooks/useEditorPublish.ts +17 -4
  123. package/src/lib/block-dnd.ts +58 -0
  124. package/src/lib/block-move.ts +236 -0
  125. package/src/lib/container-grid.ts +58 -0
  126. package/src/lib/container-ops.ts +159 -0
  127. package/src/lib/dexie.ts +47 -0
  128. package/src/lib/loader.ts +16 -4
  129. package/src/lib/migrate-sections-transform.ts +147 -0
  130. package/src/lib/registry.ts +48 -2
  131. package/src/lib/sanitize.ts +22 -1
  132. package/src/schemas/block.ts +40 -0
  133. package/src/schemas/link.ts +19 -1
  134. package/src/schemas/rich-text.ts +11 -0
  135. package/src/schemas/sections.ts +5 -1
  136. package/src/schemas/shared.ts +16 -0
  137. package/src/schemas/site-config.ts +3 -3
  138. package/src/storage/index.ts +1 -0
  139. package/src/storage/types.ts +17 -0
  140. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  141. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  142. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  143. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  144. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  145. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  146. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  147. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  148. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  149. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  150. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  151. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  152. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  153. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  154. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  155. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  156. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  157. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  158. package/src/components/brandguide/DoDontList.tsx +0 -67
  159. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  160. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  161. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  162. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  163. package/src/components/sections/DoDontList/index.tsx +0 -45
  164. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  165. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  166. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  167. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  168. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  169. package/src/components/sections/MediaGrid/index.tsx +0 -57
  170. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  171. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  172. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  173. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -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
@@ -40,6 +40,12 @@ interface ContentCacheRow {
40
40
  index: SiteIndex;
41
41
  siteConfig: Record<string, unknown>;
42
42
  updatedAt: string;
43
+ // Draft-vs-published diff state, cached alongside the content so a cache hit
44
+ // (same sha) restores the editor's "changed since published" indicators
45
+ // without re-deriving them from GitHub. Optional for backward compatibility.
46
+ savedBranchSha?: string | null;
47
+ changedSectionIds?: string[];
48
+ mainIndex?: SiteIndex | null;
43
49
  }
44
50
 
45
51
  interface MediaManifestRow {
@@ -136,6 +142,28 @@ class EditorDatabase extends Dexie {
136
142
  await tx.table("siteIndex").clear();
137
143
  await tx.table("contentCache").clear();
138
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
+ });
139
167
  }
140
168
  }
141
169
 
@@ -389,6 +417,7 @@ export async function cacheContent(
389
417
  sections: LoadedSection[],
390
418
  index: SiteIndex,
391
419
  siteConfig: Record<string, unknown>,
420
+ diff?: { savedBranchSha: string | null; changedSectionIds: string[]; mainIndex: SiteIndex | null },
392
421
  ): Promise<void> {
393
422
  const now = new Date().toISOString();
394
423
  await getDb().contentCache.put({
@@ -398,6 +427,9 @@ export async function cacheContent(
398
427
  index,
399
428
  siteConfig,
400
429
  updatedAt: now,
430
+ savedBranchSha: diff?.savedBranchSha ?? null,
431
+ changedSectionIds: diff?.changedSectionIds ?? [],
432
+ mainIndex: diff?.mainIndex ?? null,
401
433
  });
402
434
  }
403
435
 
@@ -406,6 +438,9 @@ export async function getCachedContent(): Promise<{
406
438
  sections: LoadedSection[];
407
439
  index: SiteIndex;
408
440
  siteConfig: Record<string, unknown>;
441
+ savedBranchSha: string | null;
442
+ changedSectionIds: string[];
443
+ mainIndex: SiteIndex | null;
409
444
  } | null> {
410
445
  const row = await getDb().contentCache.get("current");
411
446
  if (!row) return null;
@@ -414,9 +449,21 @@ export async function getCachedContent(): Promise<{
414
449
  sections: row.sections,
415
450
  index: row.index,
416
451
  siteConfig: row.siteConfig,
452
+ savedBranchSha: row.savedBranchSha ?? null,
453
+ changedSectionIds: row.changedSectionIds ?? [],
454
+ // No stored mainIndex (e.g. legacy or post-publish cache) means "no draft",
455
+ // so the published index equals the current index.
456
+ mainIndex: row.mainIndex ?? row.index,
417
457
  };
418
458
  }
419
459
 
460
+ /** Invalidate only the cached content snapshot (e.g. after a publish removes the
461
+ * draft), forcing the next editor load to refetch rather than serve stale diff
462
+ * state. Narrower than discardSavedChanges, which also clears index/meta/config. */
463
+ export async function clearContentCache(): Promise<void> {
464
+ await getDb().contentCache.clear();
465
+ }
466
+
420
467
  export async function persistMediaManifest(manifest: MediaManifest): Promise<void> {
421
468
  const now = new Date().toISOString();
422
469
  await getDb().mediaManifest.put({ key: "current", manifest, updatedAt: now });
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
+ }
@@ -33,6 +33,11 @@ export type SettingsFieldDef =
33
33
  default: string;
34
34
  target?: "content" | "options";
35
35
  coerce?: "number";
36
+ /** When set, an empty-string ("") selection is normalized to `undefined`
37
+ * before the content/options split, so an optional-enum field clears cleanly
38
+ * (e.g. Media `dodont`, whose enum is strictly ["do","dont"]). Opt-in so other
39
+ * selects keep their "" value. */
40
+ emptyIsUndefined?: boolean;
36
41
  options: { label: string; value: string }[];
37
42
  }
38
43
  | {
@@ -79,6 +84,8 @@ export interface WrapperProps {
79
84
  dirty?: boolean;
80
85
  index: number;
81
86
  isLast: boolean;
87
+ containerId: string;
88
+ isContainerBlock: boolean;
82
89
  definition: SectionDefinition;
83
90
  options?: Record<string, unknown>;
84
91
  audiences: Audience[];
@@ -90,6 +97,7 @@ export interface WrapperProps {
90
97
  onRequestInsert?: (index: number) => void;
91
98
  onDelete?: () => void;
92
99
  onMoveSection?: () => void;
100
+ onAddChild?: () => void;
93
101
  mainStatus?: string | null;
94
102
  contentDiffersFromMain?: boolean;
95
103
  isLocalOnly?: boolean;
@@ -105,13 +113,25 @@ export interface SectionDefinition<T = unknown> {
105
113
  schema: ZodType<T>;
106
114
  component: ComponentType<SectionProps<T>>;
107
115
  defaults: () => T;
108
- wrapper?: ComponentType<WrapperProps>;
109
116
  settings?: SettingsSchema;
110
117
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
118
  settingsForm?: ComponentType<any>;
112
119
  getLabel?(content: T): string;
113
120
  getThumbnails?(content: T): Thumbnail[];
114
121
  navRole?: "h1" | "h2" | "h3";
122
+ /** content-relative field paths that hold HTML rich text (for the sanitizer). */
123
+ richTextFields?: readonly string[];
124
+ /**
125
+ * Option keys (under this type's `options`) that a parent container may set as a
126
+ * default for all children via `content.childDefaults`. v1: typed primitives only.
127
+ */
128
+ inheritableSettings?: readonly string[];
129
+ /**
130
+ * Optional grouping of `settings` fields into labeled tabs for the settings modal;
131
+ * presentation-only — does not change content/options routing; omitted fields fall
132
+ * into a trailing 'Other' tab.
133
+ */
134
+ settingsTabs?: { label: string; fields: string[] }[];
115
135
  }
116
136
 
117
137
  // --- defineSection ---
@@ -123,13 +143,20 @@ type DefineSectionInput<S extends ZodType> = {
123
143
  schema: S;
124
144
  component: ComponentType<SectionProps<z.infer<S>>>;
125
145
  defaults: () => z.infer<S>;
126
- wrapper?: ComponentType<WrapperProps>;
127
146
  settings?: SettingsSchema;
128
147
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
148
  settingsForm?: ComponentType<any>;
130
149
  getLabel?(content: z.infer<S>): string;
131
150
  getThumbnails?(content: z.infer<S>): Thumbnail[];
132
151
  navRole?: "h1" | "h2" | "h3";
152
+ richTextFields?: readonly string[];
153
+ inheritableSettings?: readonly string[];
154
+ /**
155
+ * Optional grouping of `settings` fields into labeled tabs for the settings modal;
156
+ * presentation-only — does not change content/options routing; omitted fields fall
157
+ * into a trailing 'Other' tab.
158
+ */
159
+ settingsTabs?: { label: string; fields: string[] }[];
133
160
  };
134
161
 
135
162
  export function defineSection<S extends ZodType>(
@@ -148,6 +175,8 @@ export interface SectionRegistry {
148
175
  getSchema(type: string): ZodType | undefined;
149
176
  getAllSections(): SectionDefinition[];
150
177
  getAllSchemas(): ZodType[];
178
+ registerRichText(type: string, fields: readonly string[]): void;
179
+ getRichTextFields(type: string): readonly string[];
151
180
  clearRegistry(): void;
152
181
  }
153
182
 
@@ -155,14 +184,22 @@ export function createRegistry(): SectionRegistry {
155
184
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
185
  const sections = new Map<string, SectionDefinition<any>>();
157
186
  const schemas = new Map<string, ZodType>();
187
+ const richText = new Map<string, readonly string[]>();
158
188
 
159
189
  return {
160
190
  registerSection(def) {
161
191
  sections.set(def.type, def);
192
+ if (def.richTextFields) richText.set(def.type, def.richTextFields);
162
193
  },
163
194
  registerSchema(type, schema) {
164
195
  schemas.set(type, schema);
165
196
  },
197
+ registerRichText(type, fields) {
198
+ richText.set(type, fields);
199
+ },
200
+ getRichTextFields(type) {
201
+ return richText.get(type) ?? [];
202
+ },
166
203
  getSection(type) {
167
204
  return sections.get(type);
168
205
  },
@@ -182,6 +219,7 @@ export function createRegistry(): SectionRegistry {
182
219
  clearRegistry() {
183
220
  sections.clear();
184
221
  schemas.clear();
222
+ richText.clear();
185
223
  },
186
224
  };
187
225
  }
@@ -204,6 +242,14 @@ export function registerSchema(type: string, schema: ZodType): void {
204
242
  defaultRegistry.registerSchema(type, schema);
205
243
  }
206
244
 
245
+ export function registerRichText(type: string, fields: readonly string[]): void {
246
+ defaultRegistry.registerRichText(type, fields);
247
+ }
248
+
249
+ export function getRichTextFields(type: string): readonly string[] {
250
+ return defaultRegistry.getRichTextFields(type);
251
+ }
252
+
207
253
  export function getSection(type: string): SectionDefinition | undefined {
208
254
  return defaultRegistry.getSection(type);
209
255
  }
@@ -9,6 +9,14 @@ if (typeof window !== "undefined") {
9
9
  });
10
10
  }
11
11
 
12
+ // Memoize sanitized output per input string. In the editor, every keystroke
13
+ // re-renders all sections, re-running DOMPurify (CPU-heavy HTML parsing) on
14
+ // every inactive rich-text block whose HTML hasn't changed. Caching by input
15
+ // value makes those repeats free. Bounded with FIFO eviction so it can't grow
16
+ // without limit across a long editing session.
17
+ const sanitizeCache = new Map<string, string>();
18
+ const SANITIZE_CACHE_LIMIT = 500;
19
+
12
20
  /**
13
21
  * Synchronous sanitizer — returns sanitized HTML if DOMPurify has loaded,
14
22
  * otherwise returns raw HTML. Call `ensureSanitizer()` during component mount
@@ -16,7 +24,20 @@ if (typeof window !== "undefined") {
16
24
  */
17
25
  export function sanitizeHtml(html: string): string {
18
26
  if (!html) return "";
19
- return purifier ? purifier(html) : html;
27
+ // Don't cache the pre-DOMPurify passthrough — once the purifier loads, the
28
+ // same input must produce sanitized output.
29
+ if (!purifier) return html;
30
+
31
+ const cached = sanitizeCache.get(html);
32
+ if (cached !== undefined) return cached;
33
+
34
+ const clean = purifier(html);
35
+ if (sanitizeCache.size >= SANITIZE_CACHE_LIMIT) {
36
+ const oldest = sanitizeCache.keys().next().value;
37
+ if (oldest !== undefined) sanitizeCache.delete(oldest);
38
+ }
39
+ sanitizeCache.set(html, clean);
40
+ return clean;
20
41
  }
21
42
 
22
43
  /**
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * A block's participation in its parent container's layout — distinct from its
5
+ * content. Optional, and absent at the page root (the root is an implicit single
6
+ * column). Intentionally NOT strict so future keys (rowSpan, alignSelf) are
7
+ * forward-compatible.
8
+ */
9
+ export const LayoutEnvelopeSchema = z.object({
10
+ colSpan: z.number().int().min(1).optional(),
11
+ });
12
+
13
+ export type LayoutEnvelope = z.infer<typeof LayoutEnvelopeSchema>;
14
+
15
+ /** v1 nesting cap: a top-level container (depth 2) may hold leaves but not containers. */
16
+ export const MAX_BLOCK_DEPTH = 2;
17
+
18
+ /** Content-agnostic: any block whose `content.children` is an array is a container. */
19
+ export function getBlockChildren(block: unknown): unknown[] {
20
+ if (typeof block !== "object" || block === null) return [];
21
+ const content = (block as { content?: unknown }).content;
22
+ if (typeof content !== "object" || content === null) return [];
23
+ const children = (content as { children?: unknown }).children;
24
+ return Array.isArray(children) ? children : [];
25
+ }
26
+
27
+ /** Depth of a block tree: a leaf is 1; a container is 1 + max(child depths). */
28
+ export function blockDepth(block: unknown): number {
29
+ const children = getBlockChildren(block);
30
+ if (children.length === 0) return 1;
31
+ return 1 + Math.max(...children.map(blockDepth));
32
+ }
33
+
34
+ /** Throws if the block tree is deeper than `max`. */
35
+ export function assertMaxDepth(block: unknown, max: number = MAX_BLOCK_DEPTH): void {
36
+ const depth = blockDepth(block);
37
+ if (depth > max) {
38
+ throw new Error(`Block tree depth ${depth} exceeds MAX_BLOCK_DEPTH (${max})`);
39
+ }
40
+ }
@@ -2,8 +2,26 @@ import { z } from "zod";
2
2
 
3
3
  export const LinkTargetSchema = z.enum(["_self", "_blank"]);
4
4
 
5
+ /**
6
+ * Block dangerous href schemes (javascript:, data:, vbscript:) and protocol-
7
+ * relative URLs at the schema boundary. Allows empty (unset), relative paths /
8
+ * fragments / queries, and http(s)/mailto absolute URLs. Links are not walked by
9
+ * the recursive sanitizer, so this is the only XSS guard for stored link hrefs.
10
+ */
11
+ export function isSafeHref(href: string): boolean {
12
+ // Browsers strip leading ASCII whitespace and embedded \t \r \n before
13
+ // interpreting the scheme, so normalize the same way before checking.
14
+ const normalized = href.replace(/[\t\r\n]/g, "").trimStart();
15
+ if (normalized === "") return true;
16
+ if (normalized.startsWith("//")) return false; // protocol-relative
17
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(normalized);
18
+ if (!scheme) return true; // no scheme → relative path / fragment / query
19
+ const s = scheme[1].toLowerCase();
20
+ return s === "http" || s === "https" || s === "mailto";
21
+ }
22
+
5
23
  export const LinkValueSchema = z.discriminatedUnion("kind", [
6
- z.object({ kind: z.literal("external"), href: z.string(), target: LinkTargetSchema }),
24
+ z.object({ kind: z.literal("external"), href: z.string().refine(isSafeHref, "Unsafe href scheme"), target: LinkTargetSchema }),
7
25
  z.object({
8
26
  kind: z.literal("internal"),
9
27
  pageId: z.string(),