@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.
Files changed (35) hide show
  1. package/dist/{chunk-ONBJG426.js → chunk-24SUF2BC.js} +3 -0
  2. package/dist/{chunk-DLXIYIG2.js → chunk-KDGYHU36.js} +1 -1
  3. package/dist/{chunk-ICRCH3GI.js → chunk-PUNXQK4M.js} +1 -1
  4. package/dist/components/editor/MoveSectionModal.d.ts.map +1 -1
  5. package/dist/components/editor/PagesModal.d.ts +0 -2
  6. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  7. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  8. package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
  9. package/dist/components/shared/LinkField.d.ts.map +1 -1
  10. package/dist/components/shared/Navigation.d.ts.map +1 -1
  11. package/dist/components/shared/RadioGroup.d.ts +15 -0
  12. package/dist/components/shared/RadioGroup.d.ts.map +1 -0
  13. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  14. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  15. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/lib/index.js +2 -2
  18. package/dist/lib/links.d.ts +14 -3
  19. package/dist/lib/links.d.ts.map +1 -1
  20. package/dist/schemas/index.js +2 -2
  21. package/dist/schemas/site-config.d.ts +1 -0
  22. package/dist/schemas/site-config.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/components/editor/MoveSectionModal.tsx +10 -12
  25. package/src/components/editor/PagesModal.tsx +23 -23
  26. package/src/components/primitives/LinkPopover.tsx +99 -27
  27. package/src/components/primitives/tiptap-presets.ts +3 -1
  28. package/src/components/shared/LinkField.tsx +10 -7
  29. package/src/components/shared/Navigation.tsx +22 -7
  30. package/src/components/shared/RadioGroup.tsx +71 -0
  31. package/src/components/shell/EditorShell.tsx +7 -11
  32. package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
  33. package/src/hooks/useBuildStatus.ts +19 -4
  34. package/src/lib/links.ts +76 -9
  35. 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
 
@@ -3,7 +3,7 @@ import {
3
3
  getAllSchemas,
4
4
  getSection,
5
5
  getSectionSchema
6
- } from "./chunk-ONBJG426.js";
6
+ } from "./chunk-24SUF2BC.js";
7
7
  import {
8
8
  safeNextPath
9
9
  } from "./chunk-S2L3BPLS.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  HexColorSchema
3
- } from "./chunk-ONBJG426.js";
3
+ } from "./chunk-24SUF2BC.js";
4
4
 
5
5
  // src/schemas/audience.ts
6
6
  import { z } from "zod";
@@ -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,2CAyBjF"}
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;IAC1C,0EAA0E;IAC1E,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,2CA4EhD"}
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;AAG3C,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,gBAAgB,2CAmGhE"}
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;AA+C/C,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,MAAM,CAAC;AAE1C,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,UAAU,EAAE,UAAU,CAAmB,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,2CAoE1D"}
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":"AAGA,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,2CAwN/G"}
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,2CA+2BP"}
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":"AAQA,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;AAQD,wBAAgB,mBAAmB,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,KAAK,2CA2ElE"}
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;AAMD,wBAAgB,cAAc,IAAI,iBAAiB,CAgIlD"}
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-ICRCH3GI.js";
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-DLXIYIG2.js";
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-ONBJG426.js";
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-DLXIYIG2.js";
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-ONBJG426.js";
32
+ } from "../chunk-24SUF2BC.js";
33
33
  import "../chunk-S2L3BPLS.js";
34
34
  import {
35
35
  env
@@ -6,9 +6,20 @@ export declare function resolveLinkHref(link: LinkValue, index: SiteIndex, headi
6
6
  target: string;
7
7
  };
8
8
  /**
9
- * Rewrite any internal LinkValue embedded in a section's content into a
10
- * resolved external link so the zero-JS viewer renders a plain <a>. Currently
11
- * the only link-bearing field is the button section's `content.link`.
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
@@ -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,CAQT"}
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"}
@@ -6,7 +6,7 @@ import {
6
6
  LinkValueSchema,
7
7
  MediaGridOptionsSchema,
8
8
  slugifyAudienceName
9
- } from "../chunk-ICRCH3GI.js";
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-ONBJG426.js";
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;;;;;;;;;;;;;;;;;;;;iBAe3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -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 { FormLabel } from "../shared/FormLabel";
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
- <div>
22
- <FormLabel>Position</FormLabel>
23
- <div className="flex gap-4">
24
- <label className="flex items-center gap-1.5 text-sm">
25
- <input type="radio" name="position" checked={position === "top"} onChange={() => setPosition("top")} /> Top
26
- </label>
27
- <label className="flex items-center gap-1.5 text-sm">
28
- <input type="radio" name="position" checked={position === "bottom"} onChange={() => setPosition("bottom")} /> Bottom
29
- </label>
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, onNavigate, onSetHome, onSetArchived, onSetFields, onSetAudience, onRequestDelete,
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; onNavigate: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => 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
- <button
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 transition-colors group-hover:text-primary", !page.title && "italic text-base-contrast/60")}>
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 transition-colors group-hover:text-primary">{page.isHome ? "/" : `/${page.slug}`}</div>
295
- </button>
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
- <Checkbox label="Show in nav" align="center" checked={page.showInNav} onChange={(v) => onSetFields({ showInNav: v })} />
326
- <div>
327
- <label className="mb-1 block text-sm text-base-contrast">Audience</label>
328
- <AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
329
- {page.access.length === 0 && (
330
- <p className="mt-1 text-xs text-base-contrast/70">
331
- Viewers see this page; sections still apply their own audience rules.
332
- </p>
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 { useEffect, useRef, useState } from "react";
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 [url, setUrl] = useState<string>(existingAttrs.href ?? "");
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 (url.trim()) {
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
- <input
56
- ref={inputRef}
57
- type="url"
58
- value={url}
59
- onChange={(e) => setUrl(e.target.value)}
60
- onKeyDown={handleKeyDown}
61
- placeholder="https://example.com"
62
- 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"
63
- />
64
- <label className="flex cursor-pointer items-center gap-2 text-sm text-base-contrast">
65
- <input
66
- type="checkbox"
67
- checked={openInNewTab}
68
- onChange={(e) => setOpenInNewTab(e.target.checked)}
69
- className="accent-primary"
70
- />
71
- Open in new tab
72
- </label>
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
- Link.configure({ openOnClick: false }),
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: value.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
- <Select
80
- label="Opens in"
81
- value={value.target}
82
- options={TARGET_OPTIONS}
83
- onChange={(t) => onChange({ ...value, target: t as "_self" | "_blank" })}
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 > 0 && (
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
- <p className="px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">{label}</p>
171
- <ul className="space-y-1">{pages.map(renderPage)}</ul>
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(0);
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
- * 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`.
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
- 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 } },
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