@drawnagency/primitives 0.1.49 → 0.1.51
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/dist/{chunk-BJ6FYGYP.js → chunk-24SUF2BC.js} +98 -9
- package/dist/{chunk-P3HO76OS.js → chunk-KDGYHU36.js} +6 -3
- package/dist/{chunk-5XYUO4HP.js → chunk-PUNXQK4M.js} +19 -2
- package/dist/components/editor/MoveSectionModal.d.ts +12 -0
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +18 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
- package/dist/components/sections/Button/CTAButton.d.ts +3 -3
- package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
- package/dist/components/sections/Button/index.d.ts +10 -2
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/shared/Input.d.ts +1 -0
- package/dist/components/shared/Input.d.ts.map +1 -1
- package/dist/components/shared/LinkField.d.ts +9 -0
- package/dist/components/shared/LinkField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts +4 -3
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/PagesContext.d.ts +13 -0
- package/dist/components/shared/PagesContext.d.ts.map +1 -0
- package/dist/components/shared/RadioGroup.d.ts +15 -0
- package/dist/components/shared/RadioGroup.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +16 -3
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +25 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav.d.ts +23 -0
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +31 -0
- package/dist/lib/pages.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +17 -3
- package/dist/schemas/link.d.ts +24 -0
- package/dist/schemas/link.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +129 -3
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/MoveSectionModal.tsx +36 -0
- package/src/components/editor/PagesModal.tsx +400 -0
- package/src/components/editor/SectionWrapper.tsx +13 -0
- package/src/components/editor/SettingsForm.tsx +12 -0
- package/src/components/primitives/LinkPopover.tsx +99 -27
- package/src/components/primitives/tiptap-presets.ts +3 -1
- package/src/components/sections/Button/CTAButton.tsx +10 -11
- package/src/components/sections/Button/index.tsx +4 -9
- package/src/components/shared/Input.tsx +14 -3
- package/src/components/shared/LinkField.tsx +90 -0
- package/src/components/shared/Navigation.tsx +147 -137
- package/src/components/shared/PagesContext.tsx +12 -0
- package/src/components/shared/RadioGroup.tsx +71 -0
- package/src/components/shell/EditorShell.tsx +273 -78
- package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
- package/src/hooks/useBuildStatus.ts +19 -4
- package/src/hooks/useEditorPublish.ts +1 -1
- package/src/lib/dexie.ts +18 -5
- package/src/lib/events.ts +3 -1
- package/src/lib/links.ts +108 -0
- package/src/lib/loader.ts +5 -4
- package/src/lib/nav.ts +59 -0
- package/src/lib/pages.ts +209 -0
- package/src/lib/registry.ts +8 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/link.ts +17 -0
- package/src/schemas/site-config.ts +119 -11
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Button } from "../shared/Button";
|
|
1
3
|
import { Checkbox } from "../shared/Checkbox";
|
|
2
4
|
import { ColorPicker } from "../shared/ColorPicker";
|
|
3
5
|
import { FontPicker } from "../shared/FontPicker";
|
|
@@ -19,11 +21,32 @@ const darkModeOptions = [
|
|
|
19
21
|
{ label: "Optional (viewer toggle)", value: "optional" },
|
|
20
22
|
];
|
|
21
23
|
|
|
24
|
+
const FAVICON_TYPES = ["image/svg+xml", "image/png"];
|
|
25
|
+
const FAVICON_MAX_BYTES = 100 * 1024;
|
|
26
|
+
|
|
22
27
|
export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
28
|
+
const [faviconError, setFaviconError] = useState<string | null>(null);
|
|
29
|
+
|
|
23
30
|
function update(patch: Partial<SiteConfig>) {
|
|
24
31
|
onChange({ ...siteConfig, ...patch });
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
function handleFaviconFile(file: File | undefined) {
|
|
35
|
+
if (!file) return;
|
|
36
|
+
if (!FAVICON_TYPES.includes(file.type)) {
|
|
37
|
+
setFaviconError("Favicon must be an SVG or PNG.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (file.size > FAVICON_MAX_BYTES) {
|
|
41
|
+
setFaviconError("Favicon must be 100KB or smaller.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setFaviconError(null);
|
|
45
|
+
const reader = new FileReader();
|
|
46
|
+
reader.onload = () => update({ favicon: reader.result as string });
|
|
47
|
+
reader.readAsDataURL(file);
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
function handleColorChange(color: string) {
|
|
28
51
|
const contrast = deriveContrast(color);
|
|
29
52
|
onChange({ ...siteConfig, primaryColor: color, primaryContrast: contrast });
|
|
@@ -56,6 +79,48 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
|
56
79
|
</div>
|
|
57
80
|
</div>
|
|
58
81
|
|
|
82
|
+
<div>
|
|
83
|
+
<FormLabel>Favicon</FormLabel>
|
|
84
|
+
<div className="flex items-center gap-3">
|
|
85
|
+
{siteConfig.favicon && (
|
|
86
|
+
<img
|
|
87
|
+
src={siteConfig.favicon}
|
|
88
|
+
alt="Current favicon"
|
|
89
|
+
className="h-8 w-8 rounded border border-base-200 bg-base-accent object-contain p-1"
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
<label className="inline-flex cursor-pointer items-center rounded border border-base-200 px-3 py-1.5 text-xs font-medium text-base-contrast transition-colors hover:bg-base-accent">
|
|
93
|
+
{siteConfig.favicon ? "Replace" : "Upload"}
|
|
94
|
+
<input
|
|
95
|
+
type="file"
|
|
96
|
+
accept="image/svg+xml,image/png,.svg,.png"
|
|
97
|
+
aria-label="Upload favicon"
|
|
98
|
+
className="sr-only"
|
|
99
|
+
onChange={(e) => {
|
|
100
|
+
handleFaviconFile(e.target.files?.[0]);
|
|
101
|
+
e.target.value = "";
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
{siteConfig.favicon && (
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
tone="destructive"
|
|
109
|
+
size="sm"
|
|
110
|
+
aria-label="Remove favicon"
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setFaviconError(null);
|
|
113
|
+
update({ favicon: null });
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
Remove
|
|
117
|
+
</Button>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
<p className="mt-1 text-xs text-base-contrast/70">SVG or PNG, up to 100KB. Shown in browser tabs.</p>
|
|
121
|
+
{faviconError && <p className="mt-1 text-xs text-red-600">{faviconError}</p>}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
59
124
|
<Select
|
|
60
125
|
label="Dark mode"
|
|
61
126
|
value={siteConfig.darkMode}
|
|
@@ -19,6 +19,10 @@ interface BuildStatusResult {
|
|
|
19
19
|
const POLL_INTERVAL = 5000;
|
|
20
20
|
const AUTO_CLEAR_DELAY = 10000;
|
|
21
21
|
const FADE_DURATION = 1000;
|
|
22
|
+
// A "building" row older than this on initial load is a leftover from a publish
|
|
23
|
+
// whose deploy was never confirmed (e.g. superseded by a later build) — show
|
|
24
|
+
// idle rather than a phantom in-progress publish.
|
|
25
|
+
const STALE_BUILD_MS = 15 * 60 * 1000;
|
|
22
26
|
|
|
23
27
|
export function useBuildStatus(): BuildStatusResult {
|
|
24
28
|
const [state, setState] = useState<BuildState>("idle");
|
|
@@ -34,9 +38,9 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
34
38
|
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
|
35
39
|
}, []);
|
|
36
40
|
|
|
37
|
-
const startTimer = useCallback(() => {
|
|
41
|
+
const startTimer = useCallback((initialSeconds = 0) => {
|
|
38
42
|
stopTimer();
|
|
39
|
-
setElapsedSeconds(
|
|
43
|
+
setElapsedSeconds(initialSeconds);
|
|
40
44
|
timerRef.current = setInterval(() => setElapsedSeconds((s) => s + 1), 1000);
|
|
41
45
|
}, [stopTimer]);
|
|
42
46
|
|
|
@@ -114,11 +118,22 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
114
118
|
async function check() {
|
|
115
119
|
const data = await fetchStatus();
|
|
116
120
|
if (cancelled) return;
|
|
117
|
-
handleStatusUpdate(data, true);
|
|
118
121
|
|
|
119
122
|
if (data && data.state === "building") {
|
|
123
|
+
const ageMs = Date.now() - Date.parse(data.updatedAt);
|
|
124
|
+
if (Number.isFinite(ageMs) && ageMs > STALE_BUILD_MS) {
|
|
125
|
+
setState("idle");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
setState("building");
|
|
129
|
+
// Seed the timer from when the build actually started so the display
|
|
130
|
+
// counts real elapsed time instead of sitting frozen at 0:00.
|
|
131
|
+
startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
|
|
120
132
|
startPolling();
|
|
133
|
+
return;
|
|
121
134
|
}
|
|
135
|
+
|
|
136
|
+
handleStatusUpdate(data, true);
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
check();
|
|
@@ -131,7 +146,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
131
146
|
if (clearRef.current) clearTimeout(clearRef.current);
|
|
132
147
|
if (fadeRef.current) clearTimeout(fadeRef.current);
|
|
133
148
|
};
|
|
134
|
-
}, [fetchStatus, handleStatusUpdate, startPolling, stopPolling, stopTimer]);
|
|
149
|
+
}, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
|
|
135
150
|
|
|
136
151
|
const startTracking = useCallback(() => {
|
|
137
152
|
if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
|
|
@@ -158,7 +158,7 @@ export function useEditorPublish({
|
|
|
158
158
|
}
|
|
159
159
|
siteIndex = {
|
|
160
160
|
...siteIndex,
|
|
161
|
-
|
|
161
|
+
pages: siteIndex.pages.map((p) => ({ ...p, order: p.order.filter((id) => !deleteSet.has(id)) })),
|
|
162
162
|
sections: filteredSections,
|
|
163
163
|
};
|
|
164
164
|
}
|
package/src/lib/dexie.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Dexie from "dexie";
|
|
2
2
|
import type { SectionContent } from "../schemas/sections";
|
|
3
|
-
import type { SiteIndex, SectionMeta, SiteConfig } from "../schemas/site-config";
|
|
3
|
+
import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-config";
|
|
4
4
|
import type { LoadedSection } from "./loader";
|
|
5
5
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ interface SectionRow {
|
|
|
13
13
|
|
|
14
14
|
interface SiteIndexRow {
|
|
15
15
|
key: string;
|
|
16
|
-
|
|
16
|
+
pages: Page[];
|
|
17
17
|
sections: Record<string, SectionMeta>;
|
|
18
18
|
deletedSections: string[];
|
|
19
19
|
updatedAt: string;
|
|
@@ -123,6 +123,19 @@ class EditorDatabase extends Dexie {
|
|
|
123
123
|
}).upgrade((tx) => {
|
|
124
124
|
return tx.table("pendingMedia").clear();
|
|
125
125
|
});
|
|
126
|
+
this.version(7).stores({
|
|
127
|
+
sections: "sectionId",
|
|
128
|
+
siteIndex: "key",
|
|
129
|
+
meta: "key",
|
|
130
|
+
siteConfig: "key",
|
|
131
|
+
contentCache: "key",
|
|
132
|
+
mediaManifest: "key",
|
|
133
|
+
pendingMedia: "id",
|
|
134
|
+
pendingMediaDeletions: "id",
|
|
135
|
+
}).upgrade(async (tx) => {
|
|
136
|
+
await tx.table("siteIndex").clear();
|
|
137
|
+
await tx.table("contentCache").clear();
|
|
138
|
+
});
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
|
|
@@ -173,7 +186,7 @@ export async function restoreLocalChanges(): Promise<{
|
|
|
173
186
|
const siteId = metaRow?.siteId ?? "";
|
|
174
187
|
return {
|
|
175
188
|
sections,
|
|
176
|
-
siteIndex: { siteId,
|
|
189
|
+
siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections },
|
|
177
190
|
siteConfig: configRow?.config,
|
|
178
191
|
deletedSections: indexRow.deletedSections ?? [],
|
|
179
192
|
};
|
|
@@ -200,7 +213,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
|
|
|
200
213
|
await database.transaction("rw", [database.siteIndex, database.meta], async () => {
|
|
201
214
|
await database.siteIndex.put({
|
|
202
215
|
key: "current",
|
|
203
|
-
|
|
216
|
+
pages: index.pages,
|
|
204
217
|
sections: index.sections,
|
|
205
218
|
deletedSections,
|
|
206
219
|
updatedAt: now,
|
|
@@ -323,7 +336,7 @@ export async function persistAll(
|
|
|
323
336
|
if (siteIndex) {
|
|
324
337
|
await database.siteIndex.put({
|
|
325
338
|
key: "current",
|
|
326
|
-
|
|
339
|
+
pages: siteIndex.pages,
|
|
327
340
|
sections: siteIndex.sections,
|
|
328
341
|
deletedSections: [],
|
|
329
342
|
updatedAt: now,
|
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");
|
package/src/lib/links.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
* Stable internal-link href stored inside rich text (TipTap link marks):
|
|
26
|
+
* `page://{pageId}` or `page://{pageId}#{anchorSectionId}`. Page ids survive
|
|
27
|
+
* slug renames; the SSR pass rewrites these to real routes for the viewer.
|
|
28
|
+
*/
|
|
29
|
+
export function internalHref(pageId: string, anchorSectionId?: string | null): string {
|
|
30
|
+
return `page://${pageId}${anchorSectionId ? `#${anchorSectionId}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseInternalHref(
|
|
34
|
+
href: string,
|
|
35
|
+
): { pageId: string; anchorSectionId: string | null } | null {
|
|
36
|
+
const m = /^page:\/\/([^#]+)(?:#(.+))?$/.exec(href);
|
|
37
|
+
return m ? { pageId: m[1], anchorSectionId: m[2] ?? null } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveHtmlString(
|
|
41
|
+
html: string,
|
|
42
|
+
index: SiteIndex,
|
|
43
|
+
headingById: Record<string, string | undefined>,
|
|
44
|
+
): string {
|
|
45
|
+
return html.replace(/href="page:\/\/([^"]*)"/g, (_match, ref: string) => {
|
|
46
|
+
const hash = ref.indexOf("#");
|
|
47
|
+
const pageId = hash === -1 ? ref : ref.slice(0, hash);
|
|
48
|
+
const anchorSectionId = hash === -1 ? null : ref.slice(hash + 1) || null;
|
|
49
|
+
const { href } = resolveLinkHref(
|
|
50
|
+
{ kind: "internal", pageId, anchorSectionId, target: "_self" },
|
|
51
|
+
index,
|
|
52
|
+
headingById,
|
|
53
|
+
);
|
|
54
|
+
return `href="${href}"`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isInternalLinkValue(value: Record<string, unknown>): value is LinkValue & { kind: "internal" } {
|
|
59
|
+
return value.kind === "internal" && typeof value.pageId === "string";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Rewrite any internal link embedded in a section's content into a resolved
|
|
64
|
+
* plain href so the zero-JS viewer renders a normal <a>. Covers structured
|
|
65
|
+
* LinkValue fields (button) and `page://` hrefs inside rich text HTML (prose,
|
|
66
|
+
* list items — any string field).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveInternalLinks(
|
|
69
|
+
section: Section,
|
|
70
|
+
index: SiteIndex,
|
|
71
|
+
headingById: Record<string, string | undefined>,
|
|
72
|
+
): Section {
|
|
73
|
+
// Copy-on-write: untouched subtrees keep their original references so the
|
|
74
|
+
// per-request SSR pass allocates nothing for the common no-links case.
|
|
75
|
+
const walk = (value: unknown): unknown => {
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
return value.includes("page://") ? resolveHtmlString(value, index, headingById) : value;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
let changed = false;
|
|
81
|
+
const next = value.map((v) => {
|
|
82
|
+
const w = walk(v);
|
|
83
|
+
if (w !== v) changed = true;
|
|
84
|
+
return w;
|
|
85
|
+
});
|
|
86
|
+
return changed ? next : value;
|
|
87
|
+
}
|
|
88
|
+
if (value !== null && typeof value === "object") {
|
|
89
|
+
const obj = value as Record<string, unknown>;
|
|
90
|
+
if (isInternalLinkValue(obj)) {
|
|
91
|
+
const resolved = resolveLinkHref(obj, index, headingById);
|
|
92
|
+
return { kind: "external", href: resolved.href, target: resolved.target };
|
|
93
|
+
}
|
|
94
|
+
let changed = false;
|
|
95
|
+
const result: Record<string, unknown> = {};
|
|
96
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
97
|
+
const w = walk(v);
|
|
98
|
+
result[key] = w;
|
|
99
|
+
if (w !== v) changed = true;
|
|
100
|
+
}
|
|
101
|
+
return changed ? result : value;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const content = walk(section.content);
|
|
107
|
+
return content === section.content ? section : ({ ...section, content } as Section);
|
|
108
|
+
}
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/lib/pages.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -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;
|
package/src/schemas/index.ts
CHANGED