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.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- 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
|
+
}
|