modern-cms 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. package/template/packages/shared/vitest.config.ts +8 -0
@@ -0,0 +1,303 @@
1
+ import { nanoid } from "nanoid";
2
+ import { getComponentDefinition } from "./components.js";
3
+ import type { PageNode } from "./schema/pageNode.js";
4
+
5
+ export function createNode(type: string, id?: string): PageNode {
6
+ const def = getComponentDefinition(type);
7
+ if (!def) throw new Error(`Unknown component type: ${type}`);
8
+ const node: PageNode = {
9
+ id: id ?? nanoid(10),
10
+ type,
11
+ props: structuredClone(def.defaultProps),
12
+ style: structuredClone(def.defaultStyle),
13
+ children: [],
14
+ };
15
+ // A Container's children are one "column" node per column — each independently
16
+ // droppable — rather than a single flat list. Seed them up front so a freshly
17
+ // dropped Container is immediately usable.
18
+ if (type === "container") {
19
+ const count = Math.max(1, Number(node.props.columns) || 1);
20
+ node.children = Array.from({ length: count }, () => createNode("column"));
21
+ }
22
+ // A Header's zone count is adjustable (like a Container's columns) but starts with
23
+ // the common 3-zone layout — logo start, links centered, end free for a CTA — so a
24
+ // freshly dropped Header is immediately usable, not N empty boxes.
25
+ if (type === "header") {
26
+ node.children = buildHeaderZones();
27
+ }
28
+ // A Grid's children are one "gridCell" node per row×column slot — seed them up
29
+ // front (same reasoning as Container above) so a freshly dropped Grid shows its
30
+ // full rows×columns layout immediately instead of one empty box.
31
+ if (type === "grid") {
32
+ const rows = Math.max(1, Number(node.props.rows) || 1);
33
+ const columns = Math.max(1, Number(node.props.columns) || 1);
34
+ node.children = Array.from({ length: rows * columns }, () => createNode("gridCell"));
35
+ }
36
+ // A fresh Countdown should point at a real future moment, not a frozen placeholder
37
+ // date baked into the registry — default to 30 days out.
38
+ if (type === "countdown") {
39
+ const target = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
40
+ node.props.targetDate = target.toISOString().slice(0, 16);
41
+ }
42
+ return node;
43
+ }
44
+
45
+ function buildHeaderZones(): PageNode[] {
46
+ const start = createNode("navZone");
47
+ start.children = [createNode("logo")];
48
+
49
+ const middle = createNode("navZone");
50
+ middle.children = [createNode("navLinks")];
51
+
52
+ const end = createNode("navZone");
53
+
54
+ return assignZonePositions([start, middle, end]);
55
+ }
56
+
57
+ /** Zone 0 hugs the start, the last zone hugs the end, everything between shares and
58
+ * centers the remaining space — this holds for any zone count, not just 3. */
59
+ function assignZonePositions(zones: PageNode[]): PageNode[] {
60
+ const last = zones.length - 1;
61
+ return zones.map((zone, i) => ({
62
+ ...zone,
63
+ props: { ...zone.props, zone: i === 0 ? "start" : i === last ? "end" : "middle" },
64
+ }));
65
+ }
66
+
67
+ /**
68
+ * Upgrades a legacy Header (saved before zones existed, so it has no children) to the
69
+ * zone-based layout. No-op if it already has zones — never overwrites existing content.
70
+ */
71
+ export function ensureHeaderZones(node: PageNode): PageNode {
72
+ if (node.type !== "header" || node.children.length > 0) return node;
73
+ return { ...node, children: buildHeaderZones() };
74
+ }
75
+
76
+ /**
77
+ * Updates a Header's zone count, growing its children (one "navZone" per zone) as
78
+ * needed and recomputing each zone's start/middle/end position. Never removes existing
79
+ * zones on shrink — a "hidden" zone still holds content the user may not want deleted —
80
+ * so the visible zone count can temporarily exceed `count` until the user removes one.
81
+ */
82
+ export function setHeaderZoneCount(node: PageNode, count: number): PageNode {
83
+ const clamped = Math.max(1, Math.min(6, count));
84
+ const children = [...node.children];
85
+ while (children.length < clamped) children.push(createNode("navZone"));
86
+ return { ...node, props: { ...node.props, zoneCount: clamped }, children: assignZonePositions(children) };
87
+ }
88
+
89
+ /**
90
+ * Updates a Container's column count, growing its children (one "column" node per
91
+ * column) as needed. Never removes existing columns on shrink — even an emptied
92
+ * column may hold content the user doesn't want silently deleted — so the visible
93
+ * column count can temporarily exceed `count` until the user deletes one manually.
94
+ */
95
+ export function setContainerColumnCount(node: PageNode, count: number): PageNode {
96
+ const clamped = Math.max(1, Math.min(4, count));
97
+ const children = [...node.children];
98
+ while (children.length < clamped) children.push(createNode("column"));
99
+ return { ...node, props: { ...node.props, columns: clamped }, children };
100
+ }
101
+
102
+ /**
103
+ * Updates a Grid's row/column counts, growing its cells (one "gridCell" node per
104
+ * row×column slot) as needed. A cell spanning multiple tracks (style.gridColumnSpan/
105
+ * gridRowSpan) visually consumes more of the grid than one slot — the browser reflows
106
+ * later cells to fit, same as any CSS Grid auto-placement — so the *slot* count here is
107
+ * a floor, not a guarantee of exactly rows×columns visible cells. Like
108
+ * setContainerColumnCount, shrinking never deletes existing cells or their content.
109
+ */
110
+ export function setGridDimensions(node: PageNode, rows: number, columns: number): PageNode {
111
+ const clampedRows = Math.max(1, Math.min(12, rows));
112
+ const clampedColumns = Math.max(1, Math.min(12, columns));
113
+ const total = clampedRows * clampedColumns;
114
+ const children = [...node.children];
115
+ while (children.length < total) children.push(createNode("gridCell"));
116
+ return { ...node, props: { ...node.props, rows: clampedRows, columns: clampedColumns }, children };
117
+ }
118
+
119
+ /**
120
+ * Sets one column or row track's explicit size on a Grid. Unlike Container (where each
121
+ * Column owns its own width via style.columnWidth), a Grid's tracks are a property of
122
+ * the Grid itself — every cell in the same column/row shares that one track size, same
123
+ * as a spreadsheet column/row — so this lives on the Grid, not the cell.
124
+ *
125
+ * Only correctly targets cells that don't span multiple tracks: a spanning cell shifts
126
+ * later cells' effective row/column via the browser's own grid auto-placement, which
127
+ * this doesn't attempt to replicate. Combining custom track sizes with spanning cells
128
+ * on the same grid is a rare-enough combination that this approximation is an
129
+ * acceptable trade-off over the complexity of a full auto-placement simulation.
130
+ */
131
+ export function setGridTrackSize(
132
+ node: PageNode,
133
+ axis: "column" | "row",
134
+ index: number,
135
+ size: string
136
+ ): PageNode {
137
+ const count = Math.max(1, Number(node.props[axis === "column" ? "columns" : "rows"]) || 1);
138
+ const key = axis === "column" ? "columnTracks" : "rowTracks";
139
+ const defaultTrack = axis === "column" ? "minmax(0, 1fr)" : "minmax(80px, auto)";
140
+ const existing = Array.isArray(node.props[key]) ? [...(node.props[key] as string[])] : [];
141
+ while (existing.length < count) existing.push(defaultTrack);
142
+ const clampedIndex = Math.max(0, Math.min(count - 1, index));
143
+ existing[clampedIndex] = size;
144
+ return { ...node, props: { ...node.props, [key]: existing.slice(0, count) } };
145
+ }
146
+
147
+ export function createEmptyPage(): PageNode {
148
+ return {
149
+ id: "root",
150
+ type: "root",
151
+ props: {},
152
+ style: {},
153
+ children: [],
154
+ };
155
+ }
156
+
157
+ export function findNode(root: PageNode, id: string): PageNode | null {
158
+ if (root.id === id) return root;
159
+ for (const child of root.children) {
160
+ const found = findNode(child, id);
161
+ if (found) return found;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export function findParent(root: PageNode, childId: string): { parent: PageNode; index: number } | null {
167
+ for (let i = 0; i < root.children.length; i++) {
168
+ if (root.children[i].id === childId) return { parent: root, index: i };
169
+ const found = findParent(root.children[i], childId);
170
+ if (found) return found;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Returns the chain of nodes from (but not including) the root down to `id`, inclusive
177
+ * of `id` itself. Used to let the Inspector offer "select parent" breadcrumbs — once a
178
+ * container/column is filled with content that content covers its entire clickable
179
+ * area, so clicking never bubbles up far enough to reselect the container itself.
180
+ */
181
+ export function findAncestorChain(root: PageNode, id: string): PageNode[] {
182
+ function walk(current: PageNode, path: PageNode[]): PageNode[] | null {
183
+ if (current.id === id) return [...path, current];
184
+ for (const child of current.children) {
185
+ const found = walk(child, [...path, current]);
186
+ if (found) return found;
187
+ }
188
+ return null;
189
+ }
190
+ const result = walk(root, []);
191
+ if (!result) return [];
192
+ // Drop the synthetic page root — it isn't a selectable/editable node.
193
+ return result[0]?.type === "root" ? result.slice(1) : result;
194
+ }
195
+
196
+ /** Returns a new tree with `node` inserted as a child of `parentId` at `index`. */
197
+ export function insertNode(root: PageNode, parentId: string, node: PageNode, index?: number): PageNode {
198
+ function recur(current: PageNode): PageNode {
199
+ if (current.id === parentId) {
200
+ const children = [...current.children];
201
+ const at = index === undefined ? children.length : Math.max(0, Math.min(index, children.length));
202
+ children.splice(at, 0, node);
203
+ return { ...current, children };
204
+ }
205
+ return { ...current, children: current.children.map(recur) };
206
+ }
207
+ return recur(root);
208
+ }
209
+
210
+ /** Returns a new tree with the node matching `id` removed. */
211
+ export function removeNode(root: PageNode, id: string): PageNode {
212
+ function recur(current: PageNode): PageNode {
213
+ return {
214
+ ...current,
215
+ children: current.children.filter((c) => c.id !== id).map(recur),
216
+ };
217
+ }
218
+ return recur(root);
219
+ }
220
+
221
+ /** Returns a new tree with the node matching `id` updated via `updater`. */
222
+ export function updateNode(root: PageNode, id: string, updater: (node: PageNode) => PageNode): PageNode {
223
+ function recur(current: PageNode): PageNode {
224
+ if (current.id === id) return updater(current);
225
+ return { ...current, children: current.children.map(recur) };
226
+ }
227
+ return recur(root);
228
+ }
229
+
230
+ /** Moves an existing node (by id) to be a child of `newParentId` at `index`. */
231
+ export function moveNode(root: PageNode, nodeId: string, newParentId: string, index?: number): PageNode {
232
+ const node = findNode(root, nodeId);
233
+ if (!node) return root;
234
+ const withoutNode = removeNode(root, nodeId);
235
+ return insertNode(withoutNode, newParentId, node, index);
236
+ }
237
+
238
+ export function duplicateNode(root: PageNode, id: string): PageNode {
239
+ const node = findNode(root, id);
240
+ const parentInfo = findParent(root, id);
241
+ if (!node || !parentInfo) return root;
242
+ const clone = cloneWithNewIds(node);
243
+ return insertNode(root, parentInfo.parent.id, clone, parentInfo.index + 1);
244
+ }
245
+
246
+ /** True if `id` refers to `node` itself or any node within its subtree. */
247
+ export function isNodeOrDescendant(node: PageNode, id: string): boolean {
248
+ if (node.id === id) return true;
249
+ return node.children.some((c) => isNodeOrDescendant(c, id));
250
+ }
251
+
252
+ /** True if any node in the subtree (including `node` itself) has the given type.
253
+ * Used to decide whether a global section actually replaces per-page chrome: a global
254
+ * "header" tree that contains a real Header component supersedes page-level headers,
255
+ * while one that's only decoration (a utility bar, an announcement strip) does not. */
256
+ export function treeContainsType(node: PageNode, type: string): boolean {
257
+ if (node.type === type) return true;
258
+ return node.children.some((c) => treeContainsType(c, type));
259
+ }
260
+
261
+ /** Deep-copy a subtree with fresh ids throughout — used by duplicate, and by
262
+ * inserting a Saved Block (each insertion must be an independent copy, not share ids
263
+ * with the stored template or with other insertions). */
264
+ export function cloneWithNewIds(node: PageNode): PageNode {
265
+ return {
266
+ ...node,
267
+ id: nanoid(10),
268
+ props: structuredClone(node.props),
269
+ style: structuredClone(node.style),
270
+ children: node.children.map(cloneWithNewIds),
271
+ };
272
+ }
273
+
274
+ const HTML_TAG_RE = /<[^>]*>/g;
275
+
276
+ /** Pulls every string value out of one props object (recursing into nested arrays/
277
+ * objects — card lists, testimonial arrays, etc.) with HTML tags stripped, so rich text
278
+ * fields contribute their visible words rather than markup. */
279
+ function extractPropStrings(value: unknown, out: string[]): void {
280
+ if (typeof value === "string") {
281
+ const text = value.replace(HTML_TAG_RE, " ").trim();
282
+ if (text) out.push(text);
283
+ } else if (Array.isArray(value)) {
284
+ for (const item of value) extractPropStrings(item, out);
285
+ } else if (value && typeof value === "object") {
286
+ for (const v of Object.values(value)) extractPropStrings(v, out);
287
+ }
288
+ }
289
+
290
+ /** Flattens a page/collection-item tree into one plain-text blob for full-text search —
291
+ * every string prop value (headings, body copy, button labels, card/testimonial/FAQ
292
+ * entries...) across every node, HTML-stripped and whitespace-joined. Generic over
293
+ * component type by design: new block types need no changes here since it just walks
294
+ * whatever props they declare, the same tree-walking style as treeContainsType. */
295
+ export function extractSearchText(node: PageNode): string {
296
+ const out: string[] = [];
297
+ function walk(current: PageNode) {
298
+ extractPropStrings(current.props, out);
299
+ current.children.forEach(walk);
300
+ }
301
+ walk(node);
302
+ return out.join(" ").slice(0, 20000);
303
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["src/**/*.test.ts"],
7
+ },
8
+ });