@drawnagency/primitives 0.1.50 → 0.1.52
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-ONBJG426.js → chunk-24SUF2BC.js} +3 -0
- package/dist/{chunk-DLXIYIG2.js → chunk-KDGYHU36.js} +1 -1
- package/dist/{chunk-ICRCH3GI.js → chunk-PUNXQK4M.js} +1 -1
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts +0 -2
- package/dist/components/editor/PagesModal.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/shared/LinkField.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- 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 +3 -3
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +14 -3
- package/dist/lib/links.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/MoveSectionModal.tsx +10 -12
- package/src/components/editor/PagesModal.tsx +23 -23
- package/src/components/primitives/LinkPopover.tsx +99 -27
- package/src/components/primitives/tiptap-presets.ts +3 -1
- package/src/components/shared/LinkField.tsx +10 -7
- package/src/components/shared/Navigation.tsx +22 -7
- package/src/components/shared/RadioGroup.tsx +71 -0
- package/src/components/shell/EditorShell.tsx +7 -11
- package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
- package/src/hooks/useBuildStatus.ts +19 -4
- package/src/lib/links.ts +76 -9
- package/src/schemas/site-config.ts +6 -0
|
@@ -266,6 +266,9 @@ var SiteConfigSchema = z3.object({
|
|
|
266
266
|
uppercaseSubheadings: z3.boolean().default(true),
|
|
267
267
|
uppercaseNavHeadings: z3.boolean().default(true),
|
|
268
268
|
googleFontsUrl: z3.string().refine((url) => url.startsWith("https://fonts.googleapis.com/"), "Must be a Google Fonts URL").nullable().default(null),
|
|
269
|
+
// Favicon stored as a data URL (SVG/PNG, small) so it travels with
|
|
270
|
+
// site-config.json — no media manifest or CDN round-trip needed.
|
|
271
|
+
favicon: z3.string().refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL").nullable().default(null),
|
|
269
272
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({}))
|
|
270
273
|
});
|
|
271
274
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MoveSectionModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/MoveSectionModal.tsx"],"names":[],"mappings":"AAKA,UAAU,KAAK;IACb,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,GAAG,QAAQ,KAAK,IAAI,CAAC;IACjE,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"MoveSectionModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/MoveSectionModal.tsx"],"names":[],"mappings":"AAKA,UAAU,KAAK;IACb,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,GAAG,QAAQ,KAAK,IAAI,CAAC;IACjE,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuBjF"}
|
|
@@ -11,8 +11,6 @@ export interface PagesModalProps {
|
|
|
11
11
|
onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
12
12
|
onSetAudience: (pageId: string, access: string[]) => void;
|
|
13
13
|
onConfirmDelete: (pageId: string) => void;
|
|
14
|
-
/** Navigate the editor to this page (the shell also closes the modal). */
|
|
15
|
-
onNavigate: (pageId: string) => void;
|
|
16
14
|
}
|
|
17
15
|
export declare function PagesModal(props: PagesModalProps): import("react/jsx-runtime").JSX.Element;
|
|
18
16
|
//# sourceMappingURL=PagesModal.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PagesModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/PagesModal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAUjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD,+EAA+E;IAC/E,SAAS,EAAE,MAAM,MAAM,CAAC;IACxB,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3D,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC,KAAK,IAAI,CAAC;IAClG,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"PagesModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/PagesModal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAUjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD,+EAA+E;IAC/E,SAAS,EAAE,MAAM,MAAM,CAAC;IACxB,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3D,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC,KAAK,IAAI,CAAC;IAClG,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,2CA2EhD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LinkPopover.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/LinkPopover.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"LinkPopover.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/LinkPopover.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAM3C,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAKD,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,gBAAgB,2CAqKhE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tiptap-presets.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/tiptap-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"tiptap-presets.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/tiptap-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAiD/C,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,MAAM,CAAC;AAE1C,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,UAAU,EAAE,UAAU,CAAmB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LinkField.d.ts","sourceRoot":"","sources":["../../../src/components/shared/LinkField.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAGpD,UAAU,KAAK;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAOD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"LinkField.d.ts","sourceRoot":"","sources":["../../../src/components/shared/LinkField.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAGpD,UAAU,KAAK;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAOD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuE1D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Navigation.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Navigation.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAW,MAAM,eAAe,CAAC;AAOtD,UAAU,KAAK;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;IACxC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAG3B,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,EAAE,KAAK,2CAsO/G"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface RadioGroupOption<T extends string = string> {
|
|
2
|
+
label: string;
|
|
3
|
+
value: T;
|
|
4
|
+
}
|
|
5
|
+
interface RadioGroupProps<T extends string = string> {
|
|
6
|
+
label: string;
|
|
7
|
+
options: RadioGroupOption<T>[];
|
|
8
|
+
value: T;
|
|
9
|
+
onChange: (value: T) => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function RadioGroup<T extends string = string>({ label, options, value, onChange, disabled, className, }: RadioGroupProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=RadioGroup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../../src/components/shared/RadioGroup.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,CAAC,CAAC;CACV;AAED,UAAU,eAAe,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/B,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EACpD,KAAK,EACL,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,GACV,EAAE,eAAe,CAAC,CAAC,CAAC,2CA6CpB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAgEjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,GACZ,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAgEjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,GACZ,EAAE,KAAK,2CA22BP"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SiteSettingsDisplay.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsDisplay.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SiteSettingsDisplay.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsDisplay.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,UAAU,KAAK;IACb,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;CACxC;AAWD,wBAAgB,mBAAmB,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuIlE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useBuildStatus.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuildStatus.ts"],"names":[],"mappings":"AAEA,KAAK,UAAU,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AASrE,UAAU,iBAAiB;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;
|
|
1
|
+
{"version":3,"file":"useBuildStatus.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuildStatus.ts"],"names":[],"mappings":"AAEA,KAAK,UAAU,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AASrE,UAAU,iBAAiB;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAUD,wBAAgB,cAAc,IAAI,iBAAiB,CA2IlD"}
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
LinkValueSchema,
|
|
7
7
|
MediaGridOptionsSchema,
|
|
8
8
|
slugifyAudienceName
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-PUNXQK4M.js";
|
|
10
10
|
import {
|
|
11
11
|
AudienceSchema,
|
|
12
12
|
RoleSchema,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
safeRedirect,
|
|
34
34
|
sanitizeHtml,
|
|
35
35
|
toSectionId
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-KDGYHU36.js";
|
|
37
37
|
import {
|
|
38
38
|
ColorItemSchema,
|
|
39
39
|
ColorSpaceSchema,
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
normalizeSiteIndex,
|
|
59
59
|
registerSchema,
|
|
60
60
|
registerSection
|
|
61
|
-
} from "./chunk-
|
|
61
|
+
} from "./chunk-24SUF2BC.js";
|
|
62
62
|
import {
|
|
63
63
|
AUDIENCE_COOKIE,
|
|
64
64
|
LastOwnerError,
|
package/dist/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
safeRedirect,
|
|
19
19
|
sanitizeHtml,
|
|
20
20
|
toSectionId
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-KDGYHU36.js";
|
|
22
22
|
import {
|
|
23
23
|
clearRegistry,
|
|
24
24
|
createRegistry,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
getSection,
|
|
30
30
|
registerSchema,
|
|
31
31
|
registerSection
|
|
32
|
-
} from "../chunk-
|
|
32
|
+
} from "../chunk-24SUF2BC.js";
|
|
33
33
|
import "../chunk-S2L3BPLS.js";
|
|
34
34
|
import {
|
|
35
35
|
env
|
package/dist/lib/links.d.ts
CHANGED
|
@@ -6,9 +6,20 @@ export declare function resolveLinkHref(link: LinkValue, index: SiteIndex, headi
|
|
|
6
6
|
target: string;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
9
|
+
* Stable internal-link href stored inside rich text (TipTap link marks):
|
|
10
|
+
* `page://{pageId}` or `page://{pageId}#{anchorSectionId}`. Page ids survive
|
|
11
|
+
* slug renames; the SSR pass rewrites these to real routes for the viewer.
|
|
12
|
+
*/
|
|
13
|
+
export declare function internalHref(pageId: string, anchorSectionId?: string | null): string;
|
|
14
|
+
export declare function parseInternalHref(href: string): {
|
|
15
|
+
pageId: string;
|
|
16
|
+
anchorSectionId: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite any internal link embedded in a section's content into a resolved
|
|
20
|
+
* plain href so the zero-JS viewer renders a normal <a>. Covers structured
|
|
21
|
+
* LinkValue fields (button) and `page://` hrefs inside rich text HTML (prose,
|
|
22
|
+
* list items — any string field).
|
|
12
23
|
*/
|
|
13
24
|
export declare function resolveInternalLinks(section: Section, index: SiteIndex, headingById: Record<string, string | undefined>): Section;
|
|
14
25
|
//# sourceMappingURL=links.d.ts.map
|
package/dist/lib/links.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/lib/links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAWlC;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C,OAAO,
|
|
1
|
+
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/lib/links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAWlC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAEpF;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,GACX;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAG3D;AAwBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C,OAAO,CAoCT"}
|
package/dist/schemas/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
LinkValueSchema,
|
|
7
7
|
MediaGridOptionsSchema,
|
|
8
8
|
slugifyAudienceName
|
|
9
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-PUNXQK4M.js";
|
|
10
10
|
import {
|
|
11
11
|
AudienceSchema,
|
|
12
12
|
RoleSchema,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
getSectionContentSchema,
|
|
29
29
|
getSectionSchema,
|
|
30
30
|
normalizeSiteIndex
|
|
31
|
-
} from "../chunk-
|
|
31
|
+
} from "../chunk-24SUF2BC.js";
|
|
32
32
|
import {
|
|
33
33
|
ImageManifestSchema,
|
|
34
34
|
MediaConfigSchema,
|
|
@@ -165,6 +165,7 @@ export declare const SiteConfigSchema: z.ZodObject<{
|
|
|
165
165
|
uppercaseSubheadings: z.ZodDefault<z.ZodBoolean>;
|
|
166
166
|
uppercaseNavHeadings: z.ZodDefault<z.ZodBoolean>;
|
|
167
167
|
googleFontsUrl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
168
|
+
favicon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
168
169
|
media: z.ZodDefault<z.ZodObject<{
|
|
169
170
|
sizes: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
170
171
|
maxFileSize: z.ZodDefault<z.ZodNumber>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAED,eAAO,MAAM,gBAAgB
|
|
1
|
+
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;iBAqB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Select } from "../shared/Select";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
-
import {
|
|
4
|
+
import { RadioGroup } from "../shared/RadioGroup";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
pages: { id: string; title: string }[];
|
|
@@ -18,17 +18,15 @@ export function MoveSectionModal({ pages, currentPageId, onMove, onCancel }: Pro
|
|
|
18
18
|
return (
|
|
19
19
|
<div className="flex flex-col gap-4">
|
|
20
20
|
<Select label="Destination page" value={dest} options={options} onChange={setDest} />
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
21
|
+
<RadioGroup
|
|
22
|
+
label="Position"
|
|
23
|
+
options={[
|
|
24
|
+
{ label: "Top", value: "top" },
|
|
25
|
+
{ label: "Bottom", value: "bottom" },
|
|
26
|
+
]}
|
|
27
|
+
value={position}
|
|
28
|
+
onChange={setPosition}
|
|
29
|
+
/>
|
|
32
30
|
<div className="flex justify-end gap-3">
|
|
33
31
|
<Button variant="secondary" size="md" onClick={onCancel}>Cancel</Button>
|
|
34
32
|
<Button variant="primary" size="md" disabled={!dest} onClick={() => onMove(dest, position)}>Move</Button>
|
|
@@ -22,8 +22,6 @@ export interface PagesModalProps {
|
|
|
22
22
|
onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
23
23
|
onSetAudience: (pageId: string, access: string[]) => void;
|
|
24
24
|
onConfirmDelete: (pageId: string) => void;
|
|
25
|
-
/** Navigate the editor to this page (the shell also closes the modal). */
|
|
26
|
-
onNavigate: (pageId: string) => void;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
export function PagesModal(props: PagesModalProps) {
|
|
@@ -61,7 +59,6 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
61
59
|
dragIndex={dragIndex}
|
|
62
60
|
onReorder={props.onReorder}
|
|
63
61
|
onToggleExpand={() => toggleExpand(page.id)}
|
|
64
|
-
onNavigate={() => props.onNavigate(page.id)}
|
|
65
62
|
onSetHome={() => props.onSetHome(page.id)}
|
|
66
63
|
onSetArchived={(v) => {
|
|
67
64
|
// Archiving/restoring remounts the row in its new group, so end the
|
|
@@ -105,12 +102,12 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
105
102
|
}
|
|
106
103
|
|
|
107
104
|
function PageRow({
|
|
108
|
-
page, audiences, expanded, isNew, otherSlugs, dragIndex, onReorder, onToggleExpand,
|
|
105
|
+
page, audiences, expanded, isNew, otherSlugs, dragIndex, onReorder, onToggleExpand, onSetHome, onSetArchived, onSetFields, onSetAudience, onRequestDelete,
|
|
109
106
|
}: {
|
|
110
107
|
page: Page; audiences: Audience[]; expanded: boolean;
|
|
111
108
|
isNew: boolean; otherSlugs: string[];
|
|
112
109
|
dragIndex?: number; onReorder: (fromIndex: number, toIndex: number) => void;
|
|
113
|
-
onToggleExpand: () => void;
|
|
110
|
+
onToggleExpand: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => void;
|
|
114
111
|
onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
115
112
|
onSetAudience: (access: string[]) => void; onRequestDelete: () => void;
|
|
116
113
|
}) {
|
|
@@ -277,22 +274,17 @@ function PageRow({
|
|
|
277
274
|
<DragHandle ref={handleRef} />
|
|
278
275
|
</div>
|
|
279
276
|
)}
|
|
280
|
-
<
|
|
281
|
-
type="button"
|
|
282
|
-
aria-label={`Go to ${pageDisplayTitle(page.title)}`}
|
|
283
|
-
onClick={onNavigate}
|
|
284
|
-
className="group min-w-0 flex-1 cursor-pointer text-left"
|
|
285
|
-
>
|
|
277
|
+
<div className="min-w-0 flex-1">
|
|
286
278
|
<div className="flex items-center gap-2">
|
|
287
|
-
<span className={cn("truncate text-sm font-medium text-base-contrast
|
|
279
|
+
<span className={cn("truncate text-sm font-medium text-base-contrast", !page.title && "italic text-base-contrast/60")}>
|
|
288
280
|
{pageDisplayTitle(page.title)}
|
|
289
281
|
</span>
|
|
290
282
|
{page.isHome && <span className="rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-contrast">Home page</span>}
|
|
291
283
|
{!page.showInNav && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Hidden</span>}
|
|
292
284
|
{page.status === "archived" && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Archived</span>}
|
|
293
285
|
</div>
|
|
294
|
-
<div className="text-xs text-base-contrast/70
|
|
295
|
-
</
|
|
286
|
+
<div className="text-xs text-base-contrast/70">{page.isHome ? "/" : `/${page.slug}`}</div>
|
|
287
|
+
</div>
|
|
296
288
|
<button
|
|
297
289
|
type="button"
|
|
298
290
|
aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
|
|
@@ -322,15 +314,23 @@ function PageRow({
|
|
|
322
314
|
onChange={handleSlugChange}
|
|
323
315
|
/>
|
|
324
316
|
)}
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
317
|
+
<div className="flex items-start justify-between gap-4">
|
|
318
|
+
<div>
|
|
319
|
+
<label className="mb-1 block text-sm text-base-contrast">Audience</label>
|
|
320
|
+
<AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
|
|
321
|
+
{page.access.length === 0 && (
|
|
322
|
+
<p className="mt-1 text-xs text-base-contrast/70">
|
|
323
|
+
Viewers see this page; sections still apply their own audience rules.
|
|
324
|
+
</p>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
<Checkbox
|
|
328
|
+
label="Hide from Menu"
|
|
329
|
+
align="center"
|
|
330
|
+
checked={!page.showInNav}
|
|
331
|
+
onChange={(hidden) => onSetFields({ showInNav: !hidden })}
|
|
332
|
+
className="shrink-0"
|
|
333
|
+
/>
|
|
334
334
|
</div>
|
|
335
335
|
<div className="flex items-center gap-2">
|
|
336
336
|
<Button
|
|
@@ -1,27 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import type { Editor } from "@tiptap/core";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
+
import { usePages } from "../shared/PagesContext";
|
|
5
|
+
import { internalHref, parseInternalHref } from "../../lib/links";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
4
7
|
|
|
5
8
|
interface LinkPopoverProps {
|
|
6
9
|
editor: Editor;
|
|
7
10
|
onClose: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
const selectClasses =
|
|
14
|
+
"w-full rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:ring-1 focus:ring-primary";
|
|
15
|
+
|
|
10
16
|
export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
17
|
+
const { pages, getPageHeadings } = usePages();
|
|
11
18
|
const existingAttrs = editor.getAttributes("link");
|
|
12
|
-
const
|
|
19
|
+
const existingInternal = parseInternalHref(existingAttrs.href ?? "");
|
|
20
|
+
|
|
21
|
+
const [mode, setMode] = useState<"external" | "internal">(existingInternal ? "internal" : "external");
|
|
22
|
+
const [url, setUrl] = useState<string>(existingInternal ? "" : existingAttrs.href ?? "");
|
|
23
|
+
const [pageId, setPageId] = useState<string>(existingInternal?.pageId ?? "");
|
|
24
|
+
const [anchorId, setAnchorId] = useState<string>(existingInternal?.anchorSectionId ?? "");
|
|
13
25
|
const [openInNewTab, setOpenInNewTab] = useState<boolean>(
|
|
14
26
|
existingAttrs.target === "_blank"
|
|
15
27
|
);
|
|
16
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
17
28
|
const hasLink = Boolean(existingAttrs.href);
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
inputRef.current?.focus();
|
|
21
|
-
}, []);
|
|
29
|
+
const headings = pageId ? getPageHeadings(pageId) : [];
|
|
22
30
|
|
|
23
31
|
const handleApply = () => {
|
|
24
|
-
if (
|
|
32
|
+
if (mode === "internal") {
|
|
33
|
+
if (pageId) {
|
|
34
|
+
editor
|
|
35
|
+
.chain()
|
|
36
|
+
.focus()
|
|
37
|
+
.extendMarkRange("link")
|
|
38
|
+
.setLink({ href: internalHref(pageId, anchorId || null), target: null })
|
|
39
|
+
.run();
|
|
40
|
+
}
|
|
41
|
+
} else if (url.trim()) {
|
|
25
42
|
editor
|
|
26
43
|
.chain()
|
|
27
44
|
.focus()
|
|
@@ -49,27 +66,82 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
|
49
66
|
|
|
50
67
|
return (
|
|
51
68
|
<div
|
|
52
|
-
className="flex flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
69
|
+
className="flex w-64 flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
53
70
|
onMouseDown={(e) => e.stopPropagation()}
|
|
71
|
+
onKeyDown={handleKeyDown}
|
|
54
72
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
<div className="flex gap-2" role="tablist">
|
|
74
|
+
{(["external", "internal"] as const).map((kind) => (
|
|
75
|
+
<button
|
|
76
|
+
key={kind}
|
|
77
|
+
type="button"
|
|
78
|
+
role="tab"
|
|
79
|
+
aria-selected={mode === kind}
|
|
80
|
+
onClick={() => setMode(kind)}
|
|
81
|
+
className={cn(
|
|
82
|
+
"cursor-pointer rounded border px-2 py-0.5 text-xs",
|
|
83
|
+
mode === kind
|
|
84
|
+
? "border-primary bg-primary text-primary-contrast"
|
|
85
|
+
: "border-base-200 text-base-contrast",
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{kind === "external" ? "External URL" : "Internal page"}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{mode === "external" ? (
|
|
94
|
+
<>
|
|
95
|
+
<input
|
|
96
|
+
autoFocus
|
|
97
|
+
type="url"
|
|
98
|
+
value={url}
|
|
99
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
100
|
+
placeholder="https://example.com"
|
|
101
|
+
className="rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast placeholder:text-base-contrast/40 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
102
|
+
/>
|
|
103
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm text-base-contrast">
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
checked={openInNewTab}
|
|
107
|
+
onChange={(e) => setOpenInNewTab(e.target.checked)}
|
|
108
|
+
className="accent-primary"
|
|
109
|
+
/>
|
|
110
|
+
Open in new tab
|
|
111
|
+
</label>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<select
|
|
116
|
+
autoFocus
|
|
117
|
+
aria-label="Page"
|
|
118
|
+
value={pageId}
|
|
119
|
+
onChange={(e) => {
|
|
120
|
+
setPageId(e.target.value);
|
|
121
|
+
setAnchorId("");
|
|
122
|
+
}}
|
|
123
|
+
className={selectClasses}
|
|
124
|
+
>
|
|
125
|
+
<option value="">Select a page…</option>
|
|
126
|
+
{pages.map((p) => (
|
|
127
|
+
<option key={p.id} value={p.id}>{p.title}</option>
|
|
128
|
+
))}
|
|
129
|
+
</select>
|
|
130
|
+
<select
|
|
131
|
+
aria-label="Section"
|
|
132
|
+
value={anchorId}
|
|
133
|
+
disabled={!pageId}
|
|
134
|
+
onChange={(e) => setAnchorId(e.target.value)}
|
|
135
|
+
className={cn(selectClasses, !pageId && "cursor-not-allowed opacity-50")}
|
|
136
|
+
>
|
|
137
|
+
<option value="">None (top of page)</option>
|
|
138
|
+
{headings.map((h) => (
|
|
139
|
+
<option key={h.id} value={h.id}>{h.label}</option>
|
|
140
|
+
))}
|
|
141
|
+
</select>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
73
145
|
<div className="flex gap-2">
|
|
74
146
|
<Button
|
|
75
147
|
variant="brand"
|
|
@@ -42,7 +42,9 @@ const rich: Extensions = [
|
|
|
42
42
|
}),
|
|
43
43
|
CustomParagraph,
|
|
44
44
|
Underline,
|
|
45
|
-
|
|
45
|
+
// `page` protocol: stable internal-page hrefs (page://{pageId}#{sectionId})
|
|
46
|
+
// resolved to real routes at SSR — see lib/links.ts.
|
|
47
|
+
Link.configure({ openOnClick: false, protocols: ["page"] }),
|
|
46
48
|
];
|
|
47
49
|
|
|
48
50
|
export type PresetName = "basic" | "rich";
|
|
@@ -22,9 +22,10 @@ export function LinkField({ label, value, onChange }: Props) {
|
|
|
22
22
|
|
|
23
23
|
function setMode(kind: "external" | "internal") {
|
|
24
24
|
if (kind === value.kind) return;
|
|
25
|
+
// Internal links always open in the same tab — no target choice offered.
|
|
25
26
|
onChange(kind === "external"
|
|
26
27
|
? { kind: "external", href: "", target: value.target }
|
|
27
|
-
: { kind: "internal", pageId: "", anchorSectionId: null, target:
|
|
28
|
+
: { kind: "internal", pageId: "", anchorSectionId: null, target: "_self" });
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const pageId = isInternal ? value.pageId : "";
|
|
@@ -76,12 +77,14 @@ export function LinkField({ label, value, onChange }: Props) {
|
|
|
76
77
|
</>
|
|
77
78
|
)}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
{value.kind === "external" && (
|
|
81
|
+
<Select
|
|
82
|
+
label="Opens in"
|
|
83
|
+
value={value.target}
|
|
84
|
+
options={TARGET_OPTIONS}
|
|
85
|
+
onChange={(t) => onChange({ ...value, target: t as "_self" | "_blank" })}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
85
88
|
</div>
|
|
86
89
|
);
|
|
87
90
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
2
3
|
import { cn } from "../../lib/cn";
|
|
3
4
|
import { Toggle } from "./Toggle";
|
|
4
5
|
import type { SiteNav, NavItem } from "../../lib/nav";
|
|
@@ -25,6 +26,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
25
26
|
const [isDark, setIsDark] = useState(false);
|
|
26
27
|
const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
|
|
27
28
|
const [showHistory, setShowHistory] = useState(false);
|
|
29
|
+
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
28
30
|
const historyButtonRef = useRef<HTMLButtonElement>(null);
|
|
29
31
|
|
|
30
32
|
useEffect(() => {
|
|
@@ -164,13 +166,26 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
164
166
|
</li>
|
|
165
167
|
);
|
|
166
168
|
|
|
167
|
-
const renderPageGroup = (label: string, pages: SiteNav["pages"]) =>
|
|
168
|
-
pages.length
|
|
169
|
+
const renderPageGroup = (label: string, pages: SiteNav["pages"]) => {
|
|
170
|
+
if (pages.length === 0) return null;
|
|
171
|
+
// Closed by default; auto-open when it holds the active page so the
|
|
172
|
+
// editor's current location (and its heading tree) stays visible.
|
|
173
|
+
const isOpen = openGroups[label] ?? pages.some((p) => p.isActive);
|
|
174
|
+
return (
|
|
169
175
|
<div className="mx-4 mt-2 border-t border-base-200 pt-2">
|
|
170
|
-
<
|
|
171
|
-
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
aria-expanded={isOpen}
|
|
179
|
+
onClick={() => setOpenGroups((g) => ({ ...g, [label]: !isOpen }))}
|
|
180
|
+
className="flex w-full cursor-pointer items-center justify-between rounded px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70 transition-colors hover:text-primary"
|
|
181
|
+
>
|
|
182
|
+
<span>{`${label} (${pages.length})`}</span>
|
|
183
|
+
<ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
|
|
184
|
+
</button>
|
|
185
|
+
{isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
|
|
172
186
|
</div>
|
|
173
187
|
);
|
|
188
|
+
};
|
|
174
189
|
|
|
175
190
|
return (
|
|
176
191
|
<>
|
|
@@ -199,13 +214,13 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
199
214
|
{siteNav.pages.map(renderPage)}
|
|
200
215
|
</ul>
|
|
201
216
|
|
|
202
|
-
{renderPageGroup("Hidden from Menu", siteNav.hidden)}
|
|
203
|
-
{renderPageGroup("Archive", siteNav.archive)}
|
|
217
|
+
{isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
|
|
218
|
+
{isEditMode && renderPageGroup("Archive", siteNav.archive)}
|
|
204
219
|
|
|
205
220
|
<div className="mt-auto">
|
|
206
221
|
{currentDarkMode === "optional" && (
|
|
207
222
|
<div className="mx-4 border-t border-base-200 py-4">
|
|
208
|
-
<div className="flex items-center gap-2 text-sm text-base-contrast-light">
|
|
223
|
+
<div className="flex items-center justify-center gap-2 text-sm text-base-contrast-light">
|
|
209
224
|
<span className={cn(!isDark && "text-base-contrast")}>Light</span>
|
|
210
225
|
<Toggle checked={isDark} onChange={handleThemeToggle} label="Toggle dark mode" />
|
|
211
226
|
<span className={cn(isDark && "text-base-contrast")}>Dark</span>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { FormLabel } from "./FormLabel";
|
|
4
|
+
|
|
5
|
+
export interface RadioGroupOption<T extends string = string> {
|
|
6
|
+
label: string;
|
|
7
|
+
value: T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface RadioGroupProps<T extends string = string> {
|
|
11
|
+
label: string;
|
|
12
|
+
options: RadioGroupOption<T>[];
|
|
13
|
+
value: T;
|
|
14
|
+
onChange: (value: T) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RadioGroup<T extends string = string>({
|
|
20
|
+
label,
|
|
21
|
+
options,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
disabled,
|
|
25
|
+
className,
|
|
26
|
+
}: RadioGroupProps<T>) {
|
|
27
|
+
const name = useId();
|
|
28
|
+
return (
|
|
29
|
+
<div className={className} role="radiogroup" aria-label={label}>
|
|
30
|
+
<FormLabel>{label}</FormLabel>
|
|
31
|
+
<div className="flex gap-4">
|
|
32
|
+
{options.map((option) => {
|
|
33
|
+
const checked = value === option.value;
|
|
34
|
+
return (
|
|
35
|
+
<label
|
|
36
|
+
key={option.value}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex cursor-pointer items-center gap-2 select-none hover:opacity-80",
|
|
39
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
|
|
43
|
+
<input
|
|
44
|
+
type="radio"
|
|
45
|
+
name={name}
|
|
46
|
+
checked={checked}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
onChange={() => {
|
|
49
|
+
if (!disabled) onChange(option.value);
|
|
50
|
+
}}
|
|
51
|
+
className="sr-only"
|
|
52
|
+
/>
|
|
53
|
+
<span
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
className={cn(
|
|
56
|
+
"flex h-5 w-5 items-center justify-center rounded-full border transition-colors",
|
|
57
|
+
checked ? "border-primary" : "border-base-300 bg-base hover:border-primary",
|
|
58
|
+
disabled && "pointer-events-none",
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{checked && <span className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
|
62
|
+
</span>
|
|
63
|
+
</span>
|
|
64
|
+
<span className="text-sm font-medium text-base-contrast">{option.label}</span>
|
|
65
|
+
</label>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -948,10 +948,6 @@ export default function EditorShell({
|
|
|
948
948
|
onSetFields={handleSetPageFields}
|
|
949
949
|
onSetAudience={handleSetPageAudience}
|
|
950
950
|
onConfirmDelete={handleDeletePage}
|
|
951
|
-
onNavigate={(id) => {
|
|
952
|
-
goToPage(id);
|
|
953
|
-
setShowPagesModal(false);
|
|
954
|
-
}}
|
|
955
951
|
/>
|
|
956
952
|
</EditorModal>
|
|
957
953
|
<EditorModal isOpen={movingSectionId !== null} onClose={() => setMovingSectionId(null)} title="Move to page">
|
|
@@ -1466,13 +1462,6 @@ function EditorToolbar({
|
|
|
1466
1462
|
</div>
|
|
1467
1463
|
<div className="flex items-center justify-end gap-2">
|
|
1468
1464
|
<ProcessingIndicator items={processingItems} />
|
|
1469
|
-
<IconButton
|
|
1470
|
-
icon={<FilesIcon size={16} />}
|
|
1471
|
-
label="Pages"
|
|
1472
|
-
size="md"
|
|
1473
|
-
onClick={onPagesClick}
|
|
1474
|
-
className="border border-base-200 bg-base-accent"
|
|
1475
|
-
/>
|
|
1476
1465
|
<IconButton
|
|
1477
1466
|
icon={<ListOrderedIcon size={16} />}
|
|
1478
1467
|
label="Reorder sections"
|
|
@@ -1487,6 +1476,13 @@ function EditorToolbar({
|
|
|
1487
1476
|
onClick={onMediaClick}
|
|
1488
1477
|
className="border border-base-200 bg-base-accent"
|
|
1489
1478
|
/>
|
|
1479
|
+
<IconButton
|
|
1480
|
+
icon={<FilesIcon size={16} />}
|
|
1481
|
+
label="Pages"
|
|
1482
|
+
size="md"
|
|
1483
|
+
onClick={onPagesClick}
|
|
1484
|
+
className="border border-base-200 bg-base-accent"
|
|
1485
|
+
/>
|
|
1490
1486
|
<IconButton
|
|
1491
1487
|
icon={<SettingsIcon size={16} />}
|
|
1492
1488
|
label="Site settings"
|
|
@@ -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; }
|
package/src/lib/links.ts
CHANGED
|
@@ -22,20 +22,87 @@ export function resolveLinkHref(
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the
|
|
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).
|
|
28
67
|
*/
|
|
29
68
|
export function resolveInternalLinks(
|
|
30
69
|
section: Section,
|
|
31
70
|
index: SiteIndex,
|
|
32
71
|
headingById: Record<string, string | undefined>,
|
|
33
72
|
): Section {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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;
|
|
40
104
|
};
|
|
105
|
+
|
|
106
|
+
const content = walk(section.content);
|
|
107
|
+
return content === section.content ? section : ({ ...section, content } as Section);
|
|
41
108
|
}
|
|
@@ -146,6 +146,12 @@ export const SiteConfigSchema = z.object({
|
|
|
146
146
|
.refine(url => url.startsWith("https://fonts.googleapis.com/"), "Must be a Google Fonts URL")
|
|
147
147
|
.nullable()
|
|
148
148
|
.default(null),
|
|
149
|
+
// Favicon stored as a data URL (SVG/PNG, small) so it travels with
|
|
150
|
+
// site-config.json — no media manifest or CDN round-trip needed.
|
|
151
|
+
favicon: z.string()
|
|
152
|
+
.refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL")
|
|
153
|
+
.nullable()
|
|
154
|
+
.default(null),
|
|
149
155
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
|
|
150
156
|
});
|
|
151
157
|
|