@drawnagency/primitives 0.1.50 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) 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/primitives/LinkPopover.d.ts.map +1 -1
  6. package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
  7. package/dist/components/shared/LinkField.d.ts.map +1 -1
  8. package/dist/components/shared/Navigation.d.ts.map +1 -1
  9. package/dist/components/shared/RadioGroup.d.ts +15 -0
  10. package/dist/components/shared/RadioGroup.d.ts.map +1 -0
  11. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  12. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/lib/index.js +2 -2
  15. package/dist/lib/links.d.ts +14 -3
  16. package/dist/lib/links.d.ts.map +1 -1
  17. package/dist/schemas/index.js +2 -2
  18. package/dist/schemas/site-config.d.ts +1 -0
  19. package/dist/schemas/site-config.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/components/editor/MoveSectionModal.tsx +10 -12
  22. package/src/components/editor/PagesModal.tsx +17 -9
  23. package/src/components/primitives/LinkPopover.tsx +99 -27
  24. package/src/components/primitives/tiptap-presets.ts +3 -1
  25. package/src/components/shared/LinkField.tsx +10 -7
  26. package/src/components/shared/Navigation.tsx +22 -7
  27. package/src/components/shared/RadioGroup.tsx +71 -0
  28. package/src/components/shell/EditorShell.tsx +7 -7
  29. package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
  30. package/src/hooks/useBuildStatus.ts +19 -4
  31. package/src/lib/links.ts +76 -9
  32. 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"}
@@ -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":"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.51",
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>
@@ -322,15 +322,23 @@ function PageRow({
322
322
  onChange={handleSlugChange}
323
323
  />
324
324
  )}
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
- )}
325
+ <div className="flex items-start justify-between gap-4">
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
+ )}
334
+ </div>
335
+ <Checkbox
336
+ label="Hide from Menu"
337
+ align="center"
338
+ checked={!page.showInNav}
339
+ onChange={(hidden) => onSetFields({ showInNav: !hidden })}
340
+ className="shrink-0"
341
+ />
334
342
  </div>
335
343
  <div className="flex items-center gap-2">
336
344
  <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
+ }
@@ -1466,13 +1466,6 @@ function EditorToolbar({
1466
1466
  </div>
1467
1467
  <div className="flex items-center justify-end gap-2">
1468
1468
  <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
1469
  <IconButton
1477
1470
  icon={<ListOrderedIcon size={16} />}
1478
1471
  label="Reorder sections"
@@ -1487,6 +1480,13 @@ function EditorToolbar({
1487
1480
  onClick={onMediaClick}
1488
1481
  className="border border-base-200 bg-base-accent"
1489
1482
  />
1483
+ <IconButton
1484
+ icon={<FilesIcon size={16} />}
1485
+ label="Pages"
1486
+ size="md"
1487
+ onClick={onPagesClick}
1488
+ className="border border-base-200 bg-base-accent"
1489
+ />
1490
1490
  <IconButton
1491
1491
  icon={<SettingsIcon size={16} />}
1492
1492
  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