@drawnagency/primitives 0.1.49 → 0.1.50

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 (69) hide show
  1. package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
  2. package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
  3. package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
  4. package/dist/components/editor/MoveSectionModal.d.ts +12 -0
  5. package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
  6. package/dist/components/editor/PagesModal.d.ts +18 -0
  7. package/dist/components/editor/PagesModal.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  11. package/dist/components/sections/Button/CTAButton.d.ts +3 -3
  12. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  13. package/dist/components/sections/Button/index.d.ts +10 -2
  14. package/dist/components/sections/Button/index.d.ts.map +1 -1
  15. package/dist/components/shared/Input.d.ts +1 -0
  16. package/dist/components/shared/Input.d.ts.map +1 -1
  17. package/dist/components/shared/LinkField.d.ts +9 -0
  18. package/dist/components/shared/LinkField.d.ts.map +1 -0
  19. package/dist/components/shared/Navigation.d.ts +4 -3
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shared/PagesContext.d.ts +13 -0
  22. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  23. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  24. package/dist/index.js +17 -3
  25. package/dist/lib/dexie.d.ts.map +1 -1
  26. package/dist/lib/dexie.js +16 -3
  27. package/dist/lib/events.d.ts +3 -1
  28. package/dist/lib/events.d.ts.map +1 -1
  29. package/dist/lib/index.js +2 -2
  30. package/dist/lib/links.d.ts +14 -0
  31. package/dist/lib/links.d.ts.map +1 -0
  32. package/dist/lib/loader.d.ts +2 -2
  33. package/dist/lib/loader.d.ts.map +1 -1
  34. package/dist/lib/nav.d.ts +23 -0
  35. package/dist/lib/nav.d.ts.map +1 -1
  36. package/dist/lib/pages.d.ts +31 -0
  37. package/dist/lib/pages.d.ts.map +1 -0
  38. package/dist/lib/registry.d.ts +7 -0
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/schemas/index.d.ts +1 -0
  41. package/dist/schemas/index.d.ts.map +1 -1
  42. package/dist/schemas/index.js +17 -3
  43. package/dist/schemas/link.d.ts +24 -0
  44. package/dist/schemas/link.d.ts.map +1 -0
  45. package/dist/schemas/site-config.d.ts +128 -3
  46. package/dist/schemas/site-config.d.ts.map +1 -1
  47. package/package.json +5 -1
  48. package/src/components/editor/MoveSectionModal.tsx +38 -0
  49. package/src/components/editor/PagesModal.tsx +392 -0
  50. package/src/components/editor/SectionWrapper.tsx +13 -0
  51. package/src/components/editor/SettingsForm.tsx +12 -0
  52. package/src/components/sections/Button/CTAButton.tsx +10 -11
  53. package/src/components/sections/Button/index.tsx +4 -9
  54. package/src/components/shared/Input.tsx +14 -3
  55. package/src/components/shared/LinkField.tsx +87 -0
  56. package/src/components/shared/Navigation.tsx +131 -136
  57. package/src/components/shared/PagesContext.tsx +12 -0
  58. package/src/components/shell/EditorShell.tsx +273 -78
  59. package/src/hooks/useEditorPublish.ts +1 -1
  60. package/src/lib/dexie.ts +18 -5
  61. package/src/lib/events.ts +3 -1
  62. package/src/lib/links.ts +41 -0
  63. package/src/lib/loader.ts +5 -4
  64. package/src/lib/nav.ts +59 -0
  65. package/src/lib/pages.ts +209 -0
  66. package/src/lib/registry.ts +8 -0
  67. package/src/schemas/index.ts +1 -0
  68. package/src/schemas/link.ts +17 -0
  69. package/src/schemas/site-config.ts +113 -11
package/src/lib/events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { NavItem } from "./nav";
1
+ import type { NavItem, SiteNav } from "./nav";
2
2
 
3
3
  export interface TypedEvent<T> {
4
4
  dispatch(detail: T): void;
@@ -22,3 +22,5 @@ export const editModeEvent = createEvent<{ isEditMode: boolean }>("editmodechang
22
22
  export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
23
23
  export const darkModeEvent = createEvent<string>("sitedarkmode");
24
24
  export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
25
+ export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
26
+ export const pageSelectEvent = createEvent<string>("pageselect");
@@ -0,0 +1,41 @@
1
+ import type { SiteIndex } from "../schemas/site-config";
2
+ import type { Section } from "../schemas/sections";
3
+ import type { LinkValue } from "../schemas/link";
4
+ import { pageById } from "./pages";
5
+ import { toSectionId } from "./nav";
6
+
7
+ export function resolveLinkHref(
8
+ link: LinkValue,
9
+ index: SiteIndex,
10
+ headingById: Record<string, string | undefined>,
11
+ ): { href: string; target: string } {
12
+ if (link.kind === "external") return { href: link.href, target: link.target };
13
+
14
+ const page = pageById(index, link.pageId);
15
+ if (!page) return { href: "", target: link.target };
16
+ let href = page.slug === "" ? "/" : `/${page.slug}`;
17
+ if (link.anchorSectionId) {
18
+ const heading = headingById[link.anchorSectionId];
19
+ if (heading) href += `#${toSectionId(heading)}`;
20
+ }
21
+ return { href, target: link.target };
22
+ }
23
+
24
+ /**
25
+ * Rewrite any internal LinkValue embedded in a section's content into a
26
+ * resolved external link so the zero-JS viewer renders a plain <a>. Currently
27
+ * the only link-bearing field is the button section's `content.link`.
28
+ */
29
+ export function resolveInternalLinks(
30
+ section: Section,
31
+ index: SiteIndex,
32
+ headingById: Record<string, string | undefined>,
33
+ ): Section {
34
+ const content = section.content as { link?: LinkValue } | undefined;
35
+ if (!content?.link || content.link.kind !== "internal") return section;
36
+ const resolved = resolveLinkHref(content.link, index, headingById);
37
+ return {
38
+ ...section,
39
+ content: { ...content, link: { kind: "external", href: resolved.href, target: resolved.target } },
40
+ };
41
+ }
package/src/lib/loader.ts CHANGED
@@ -15,7 +15,7 @@ export interface SiteContent {
15
15
  /**
16
16
  * Merge a validated index with raw section file contents.
17
17
  * Validates each section file against its Zod schema.
18
- * Returns sections in the order specified by index.order.
18
+ * Returns sections in the concatenated order of every page's order.
19
19
  */
20
20
  export function mergeSiteContent(
21
21
  index: SiteIndex,
@@ -26,7 +26,8 @@ export function mergeSiteContent(
26
26
  const canValidate = getAllSchemas().length >= 2;
27
27
  const schema = canValidate ? getSectionSchema() : null;
28
28
 
29
- for (const id of index.order) {
29
+ const orderedIds = index.pages.flatMap((p) => p.order);
30
+ for (const id of orderedIds) {
30
31
  const raw = sectionFiles[id];
31
32
  if (!raw) {
32
33
  console.warn(`Section file missing for id: ${id}, skipping`);
@@ -53,7 +54,7 @@ export function mergeSiteContent(
53
54
  /**
54
55
  * Build a SiteContent from a Vite `import.meta.glob` result.
55
56
  * Callers glob the section JSON files at build time, pass the parsed index, and
56
- * receive sections merged in index.order.
57
+ * receive sections merged across every page's order.
57
58
  */
58
59
  export function loadStaticSiteContent(
59
60
  index: SiteIndex,
@@ -81,7 +82,7 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
81
82
  const index = IndexSchema.parse(indexRaw);
82
83
 
83
84
  const sectionFiles: Record<string, unknown> = {};
84
- for (const id of index.order) {
85
+ for (const id of index.pages.flatMap((p: SiteIndex["pages"][number]) => p.order)) {
85
86
  const sectionPath = path.join(contentDir, "sections", `${id}.json`);
86
87
  sectionFiles[id] = JSON.parse(await fs.readFile(sectionPath, "utf-8"));
87
88
  }
package/src/lib/nav.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
+ import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
4
+ import type { SiteIndex } from "../schemas/site-config";
3
5
 
4
6
  export interface NavItem {
5
7
  href: string;
@@ -81,3 +83,60 @@ export function generateNavLinks(
81
83
 
82
84
  return nav;
83
85
  }
86
+
87
+ export interface PageNavItem {
88
+ id: string;
89
+ title: string;
90
+ href: string;
91
+ slug: string;
92
+ isActive: boolean;
93
+ headings: NavItem[];
94
+ }
95
+
96
+ export interface SiteNav {
97
+ pages: PageNavItem[];
98
+ /** Live pages with showInNav off — editor-only group, like the archive. */
99
+ hidden: PageNavItem[];
100
+ archive: PageNavItem[];
101
+ }
102
+
103
+ function pageHref(slug: string): string {
104
+ return slug === "" ? "/" : `/${slug}`;
105
+ }
106
+
107
+ export function generateSiteNav(opts: {
108
+ index: SiteIndex;
109
+ activeSections: LoadedSection[];
110
+ activePageId: string | null;
111
+ mode: "viewer" | "editor";
112
+ audience: string | null;
113
+ registry?: SectionRegistry;
114
+ }): SiteNav {
115
+ const { index, activeSections, activePageId, mode, audience, registry } = opts;
116
+ const active = activePageId ?? homePage(index).id;
117
+
118
+ const toNavItem = (p: (typeof index.pages)[number]): PageNavItem => ({
119
+ id: p.id,
120
+ title: pageDisplayTitle(p.title),
121
+ href: pageHref(p.slug),
122
+ slug: p.slug,
123
+ isActive: p.id === active,
124
+ headings: p.id === active ? generateNavLinks(activeSections, registry) : [],
125
+ });
126
+
127
+ if (mode === "editor") {
128
+ return {
129
+ pages: index.pages.filter((p) => p.status === "live" && p.showInNav).map(toNavItem),
130
+ hidden: index.pages.filter((p) => p.status === "live" && !p.showInNav).map(toNavItem),
131
+ archive: index.pages.filter((p) => p.status === "archived").map(toNavItem),
132
+ };
133
+ }
134
+
135
+ return {
136
+ pages: index.pages
137
+ .filter((p) => p.status === "live" && p.showInNav && audiencePasses(p.access, audience))
138
+ .map(toNavItem),
139
+ hidden: [],
140
+ archive: [],
141
+ };
142
+ }
@@ -0,0 +1,209 @@
1
+ import type { Page, SiteIndex, SectionMeta } from "../schemas/site-config";
2
+ import { RESERVED_SLUGS } from "../schemas/site-config";
3
+
4
+ /**
5
+ * Display fallback for blank page titles. addPage creates pages with an empty
6
+ * title (the editor prompts for one), and closing the modal can leave it blank
7
+ * — every UI that renders a page title must go through this.
8
+ */
9
+ export function pageDisplayTitle(title: string): string {
10
+ return title || "Untitled";
11
+ }
12
+
13
+ export function slugifyPageSlug(input: string): string {
14
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
+ }
16
+
17
+ export function uniquePageSlug(base: string, existing: string[]): string {
18
+ const taken = new Set(existing);
19
+ const reserved = new Set<string>(RESERVED_SLUGS);
20
+ let candidate = base || "page";
21
+ if (!taken.has(candidate) && !reserved.has(candidate)) return candidate;
22
+ let n = 2;
23
+ while (taken.has(`${candidate}-${n}`) || reserved.has(`${candidate}-${n}`)) n++;
24
+ return `${candidate}-${n}`;
25
+ }
26
+
27
+ export function pageById(index: SiteIndex, id: string): Page | undefined {
28
+ return index.pages.find((p) => p.id === id);
29
+ }
30
+
31
+ export function homePage(index: SiteIndex): Page {
32
+ return index.pages.find((p) => p.isHome) ?? index.pages[0];
33
+ }
34
+
35
+ export function pageBySlug(index: SiteIndex, slug: string): Page | undefined {
36
+ const normalized = slug.replace(/^\/+|\/+$/g, "");
37
+ if (normalized === "") return homePage(index);
38
+ return index.pages.find((p) => p.slug === normalized);
39
+ }
40
+
41
+ export function audiencePasses(access: string[], audience: string | null): boolean {
42
+ if (access.length === 0) return true;
43
+ if (!audience) return true;
44
+ return access.includes(audience);
45
+ }
46
+
47
+ function mapPages(index: SiteIndex, fn: (p: Page) => Page): SiteIndex {
48
+ return { ...index, pages: index.pages.map(fn) };
49
+ }
50
+
51
+ export function addPage(index: SiteIndex, id: string): SiteIndex {
52
+ // Title starts blank (the editor prompts for it); the slug must stay valid and
53
+ // unique in the stored index, so it gets a placeholder until the title is typed.
54
+ const slug = uniquePageSlug("new-page", index.pages.map((p) => p.slug));
55
+ const page: Page = {
56
+ id, title: "", slug, isHome: false, showInNav: true, status: "live", access: [], order: [],
57
+ };
58
+ return { ...index, pages: [...index.pages, page] };
59
+ }
60
+
61
+ export function reorderPages(index: SiteIndex, fromIndex: number, toIndex: number): SiteIndex {
62
+ const len = index.pages.length;
63
+ if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return index;
64
+ const pages = [...index.pages];
65
+ const [moved] = pages.splice(fromIndex, 1);
66
+ pages.splice(toIndex, 0, moved);
67
+ return { ...index, pages };
68
+ }
69
+
70
+ export function setHomePage(index: SiteIndex, pageId: string): SiteIndex {
71
+ const current = homePage(index);
72
+ if (current.id === pageId) return index;
73
+ if (!pageById(index, pageId)) return index;
74
+ const existingSlugs = index.pages.map((p) => p.slug);
75
+ const demotedSlug = uniquePageSlug(slugifyPageSlug(current.title) || "home", existingSlugs.filter((s) => s !== ""));
76
+ return mapPages(index, (p) => {
77
+ if (p.id === pageId) return { ...p, isHome: true, slug: "" };
78
+ if (p.id === current.id) return { ...p, isHome: false, slug: demotedSlug };
79
+ return { ...p, isHome: false };
80
+ });
81
+ }
82
+
83
+ export function setPageArchived(index: SiteIndex, pageId: string, archived: boolean): SiteIndex {
84
+ return mapPages(index, (p) => {
85
+ if (p.id !== pageId) return p;
86
+ if (p.isHome && archived) return p;
87
+ return { ...p, status: archived ? "archived" : "live" };
88
+ });
89
+ }
90
+
91
+ export function setPageFields(
92
+ index: SiteIndex,
93
+ pageId: string,
94
+ patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>,
95
+ ): SiteIndex {
96
+ return mapPages(index, (p) => (p.id === pageId ? { ...p, ...patch } : p));
97
+ }
98
+
99
+ export function setPageAudience(
100
+ index: SiteIndex,
101
+ pageId: string,
102
+ access: string[],
103
+ ): { index: SiteIndex; resetSectionIds: string[] } {
104
+ const page = pageById(index, pageId);
105
+ if (!page) return { index, resetSectionIds: [] };
106
+ const removed = page.access.filter((a) => !access.includes(a));
107
+ const nextPages = index.pages.map((p) => (p.id === pageId ? { ...p, access } : p));
108
+
109
+ const resetSectionIds: string[] = [];
110
+ const nextSections: Record<string, SectionMeta> = { ...index.sections };
111
+ if (removed.length > 0) {
112
+ for (const sectionId of page.order) {
113
+ const meta = nextSections[sectionId];
114
+ if (meta && meta.access.some((a) => removed.includes(a))) {
115
+ nextSections[sectionId] = { ...meta, access: [] };
116
+ resetSectionIds.push(sectionId);
117
+ }
118
+ }
119
+ }
120
+ return { index: { ...index, pages: nextPages, sections: nextSections }, resetSectionIds };
121
+ }
122
+
123
+ export function deletePage(
124
+ index: SiteIndex,
125
+ pageId: string,
126
+ ): { index: SiteIndex; removedSectionIds: string[] } {
127
+ const page = pageById(index, pageId);
128
+ if (!page || page.isHome) return { index, removedSectionIds: [] };
129
+ const removedSectionIds = [...page.order];
130
+ const nextSections: Record<string, SectionMeta> = { ...index.sections };
131
+ for (const id of removedSectionIds) delete nextSections[id];
132
+ return {
133
+ index: { ...index, pages: index.pages.filter((p) => p.id !== pageId), sections: nextSections },
134
+ removedSectionIds,
135
+ };
136
+ }
137
+
138
+ export function addSectionToPage(
139
+ index: SiteIndex,
140
+ pageId: string,
141
+ sectionId: string,
142
+ meta: SectionMeta,
143
+ insertIndex: number,
144
+ ): SiteIndex {
145
+ const pages = index.pages.map((p) => {
146
+ if (p.id !== pageId) return p;
147
+ const order = [...p.order];
148
+ order.splice(insertIndex, 0, sectionId);
149
+ return { ...p, order };
150
+ });
151
+ return { ...index, pages, sections: { ...index.sections, [sectionId]: meta } };
152
+ }
153
+
154
+ export function removeSectionFromPages(index: SiteIndex, sectionId: string): SiteIndex {
155
+ const pages = index.pages.map((p) =>
156
+ p.order.includes(sectionId) ? { ...p, order: p.order.filter((id) => id !== sectionId) } : p,
157
+ );
158
+ const sections = { ...index.sections };
159
+ delete sections[sectionId];
160
+ return { ...index, pages, sections };
161
+ }
162
+
163
+ export function reorderSectionInPage(
164
+ index: SiteIndex,
165
+ pageId: string,
166
+ fromIndex: number,
167
+ toIndex: number,
168
+ ): SiteIndex {
169
+ const pages = index.pages.map((p) => {
170
+ if (p.id !== pageId) return p;
171
+ const len = p.order.length;
172
+ if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return p;
173
+ const order = [...p.order];
174
+ const [moved] = order.splice(fromIndex, 1);
175
+ order.splice(toIndex, 0, moved);
176
+ return { ...p, order };
177
+ });
178
+ return { ...index, pages };
179
+ }
180
+
181
+ export function moveSection(
182
+ index: SiteIndex,
183
+ sectionId: string,
184
+ destPageId: string,
185
+ position: "top" | "bottom",
186
+ ): SiteIndex {
187
+ const dest = pageById(index, destPageId);
188
+ if (!dest) return index;
189
+ if (!index.sections[sectionId]) return index;
190
+ const pages = index.pages.map((p) => {
191
+ if (p.order.includes(sectionId)) p = { ...p, order: p.order.filter((id) => id !== sectionId) };
192
+ if (p.id === destPageId) {
193
+ const order = [...p.order];
194
+ if (position === "top") order.unshift(sectionId);
195
+ else order.push(sectionId);
196
+ p = { ...p, order };
197
+ }
198
+ return p;
199
+ });
200
+
201
+ // Cascade: if the destination declares audiences and the section carries any
202
+ // audience not allowed there, reset it to "No Audience".
203
+ let sections = index.sections;
204
+ const meta = index.sections[sectionId];
205
+ if (meta && dest.access.length > 0 && meta.access.some((a) => !dest.access.includes(a))) {
206
+ sections = { ...index.sections, [sectionId]: { ...meta, access: [] } };
207
+ }
208
+ return { ...index, pages, sections };
209
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ZodType, z } from "zod";
2
2
  import type { ComponentType, ReactNode } from "react";
3
3
  import type { Audience } from "../schemas/auth";
4
+ import type { LinkValue } from "../schemas/link";
4
5
 
5
6
  // --- Settings field types ---
6
7
 
@@ -42,6 +43,12 @@ export type SettingsFieldDef =
42
43
  min: number;
43
44
  max: number;
44
45
  step?: number;
46
+ }
47
+ | {
48
+ type: "link";
49
+ label: string;
50
+ default: LinkValue;
51
+ target?: "content" | "options";
45
52
  };
46
53
 
47
54
  export type SettingsSchema = Record<string, SettingsFieldDef>;
@@ -82,6 +89,7 @@ export interface WrapperProps {
82
89
  onReorder?: (fromIndex: number, toIndex: number) => void;
83
90
  onRequestInsert?: (index: number) => void;
84
91
  onDelete?: () => void;
92
+ onMoveSection?: () => void;
85
93
  mainStatus?: string | null;
86
94
  contentDiffersFromMain?: boolean;
87
95
  isLocalOnly?: boolean;
@@ -5,3 +5,4 @@ export * from "./media";
5
5
  export * from "./audience";
6
6
  export * from "./media-grid-options";
7
7
  export * from "./auth";
8
+ export * from "./link";
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+
3
+ export const LinkTargetSchema = z.enum(["_self", "_blank"]);
4
+
5
+ export const LinkValueSchema = z.discriminatedUnion("kind", [
6
+ z.object({ kind: z.literal("external"), href: z.string(), target: LinkTargetSchema }),
7
+ z.object({
8
+ kind: z.literal("internal"),
9
+ pageId: z.string(),
10
+ anchorSectionId: z.string().nullable().optional(),
11
+ target: LinkTargetSchema,
12
+ }),
13
+ ]);
14
+
15
+ export type LinkValue = z.infer<typeof LinkValueSchema>;
16
+
17
+ export const DEFAULT_LINK: LinkValue = { kind: "external", href: "", target: "_self" };
@@ -14,21 +14,123 @@ export const SectionMetaSchema = z.object({
14
14
 
15
15
  export type SectionMeta = z.infer<typeof SectionMetaSchema>;
16
16
 
17
- export const IndexSchema = z.object({
18
- siteId: z.string(),
17
+ // Page slugs that would shadow a platform route. KEEP IN SYNC with the root and
18
+ // /edit/* route patterns injected in packages/core/src/integration.ts — if a new
19
+ // root-level route is added there, add its first segment here (the test in Step 1
20
+ // guards the known set). This is the single source of truth for reserved slugs;
21
+ // the schema, uniquePageSlug, and /api/save all import it.
22
+ export const RESERVED_SLUGS = ["edit", "api", "login", "set-password", "404"] as const;
23
+
24
+ export const PageStatusSchema = z.enum(["live", "archived"]);
25
+
26
+ export const PageSchema = z.object({
27
+ id: z.string().min(1),
28
+ title: z.string(),
29
+ slug: z.string(),
30
+ isHome: z.boolean(),
31
+ showInNav: z.boolean(),
32
+ status: PageStatusSchema,
33
+ access: z.array(z.string()),
19
34
  order: z.array(z.string()),
35
+ });
36
+
37
+ export type Page = z.infer<typeof PageSchema>;
38
+
39
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
40
+
41
+ // Raw input: accepts legacy (order[]) OR canonical (pages[]).
42
+ const RawIndexSchema = z.object({
43
+ siteId: z.string(),
44
+ order: z.array(z.string()).optional(),
45
+ pages: z.array(PageSchema).optional(),
20
46
  sections: z.record(z.string(), SectionMetaSchema),
21
47
  lastModified: z.string().nullable().optional(),
22
- }).refine(
23
- (data) => {
24
- const orderSet = new Set(data.order);
25
- return data.order.every((id) => id in data.sections) &&
26
- Object.keys(data.sections).every((key) => orderSet.has(key));
27
- },
28
- { message: "Every id in order must have a sections entry and vice versa" }
29
- );
48
+ });
49
+
50
+ function normalizeRawIndex(data: z.infer<typeof RawIndexSchema>): z.input<typeof CanonicalIndexSchema> {
51
+ if (data.pages && data.pages.length > 0) {
52
+ return {
53
+ siteId: data.siteId,
54
+ pages: data.pages,
55
+ sections: data.sections,
56
+ lastModified: data.lastModified ?? null,
57
+ };
58
+ }
59
+ const order = data.order ?? Object.keys(data.sections);
60
+ return {
61
+ siteId: data.siteId,
62
+ pages: [
63
+ {
64
+ id: "home",
65
+ title: "Home",
66
+ slug: "",
67
+ isHome: true,
68
+ showInNav: true,
69
+ status: "live" as const,
70
+ access: [],
71
+ order,
72
+ },
73
+ ],
74
+ sections: data.sections,
75
+ lastModified: data.lastModified ?? null,
76
+ };
77
+ }
78
+
79
+ const CanonicalIndexSchema = z
80
+ .object({
81
+ siteId: z.string(),
82
+ pages: z.array(PageSchema),
83
+ sections: z.record(z.string(), SectionMetaSchema),
84
+ lastModified: z.string().nullable().optional(),
85
+ })
86
+ .superRefine((data, ctx) => {
87
+ const homes = data.pages.filter((p) => p.isHome);
88
+ if (homes.length !== 1) {
89
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Exactly one page must be the home page" });
90
+ }
91
+ if (homes[0]?.status === "archived") {
92
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "The home page cannot be archived" });
93
+ }
94
+ if (homes[0] && homes[0].slug !== "") {
95
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "The home page slug must be empty" });
96
+ }
97
+
98
+ const seenSlugs = new Set<string>();
99
+ for (const p of data.pages) {
100
+ if (!p.isHome) {
101
+ if (!SLUG_RE.test(p.slug)) {
102
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid slug "${p.slug}"` });
103
+ }
104
+ if ((RESERVED_SLUGS as readonly string[]).includes(p.slug)) {
105
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Slug "${p.slug}" is reserved` });
106
+ }
107
+ }
108
+ if (seenSlugs.has(p.slug)) {
109
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate slug "${p.slug}"` });
110
+ }
111
+ seenSlugs.add(p.slug);
112
+ }
113
+
114
+ const fromPages: string[] = [];
115
+ for (const p of data.pages) fromPages.push(...p.order);
116
+ const fromPagesSet = new Set(fromPages);
117
+ if (fromPages.length !== fromPagesSet.size) {
118
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A section id appears in more than one page" });
119
+ }
120
+ const sectionKeys = Object.keys(data.sections);
121
+ if (fromPagesSet.size !== sectionKeys.length || !sectionKeys.every((k) => fromPagesSet.has(k))) {
122
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Page section orders must partition sections{} exactly" });
123
+ }
124
+ });
125
+
126
+ export const IndexSchema = RawIndexSchema.transform(normalizeRawIndex).pipe(CanonicalIndexSchema);
127
+
128
+ export type SiteIndex = z.infer<typeof CanonicalIndexSchema>;
30
129
 
31
- export type SiteIndex = z.infer<typeof IndexSchema>;
130
+ /** Parse + normalize any raw index (legacy or canonical) to the canonical shape. */
131
+ export function normalizeSiteIndex(raw: unknown): SiteIndex {
132
+ return IndexSchema.parse(raw);
133
+ }
32
134
 
33
135
  export const SiteConfigSchema = z.object({
34
136
  siteName: z.string().default("Brand Portal"),