@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0

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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -52,14 +52,26 @@ export function publishedCacheGet<T>(scope: PublishedCacheScope, key: string): T
52
52
  return entry.value as T;
53
53
  }
54
54
 
55
- /** Stores an entry. */
55
+ /**
56
+ * Stores an entry.
57
+ *
58
+ * `notAfter` is an instant the caller knows this value stops being true at — a
59
+ * scheduled publication, a window about to close. The entry expires then if
60
+ * that comes first, so a cache can shorten a value's life but never extend it
61
+ * past the moment the answer changes. An instant already in the past means the
62
+ * value is stale on arrival and is not stored at all.
63
+ */
56
64
  export function publishedCacheSet(
57
65
  scope: PublishedCacheScope,
58
66
  key: string,
59
67
  value: unknown,
68
+ notAfter?: number | null,
60
69
  ): void {
61
70
  if (ttlMs === 0) return;
62
- store.set(cacheKey(scope, key), { value, expiresAt: Date.now() + ttlMs });
71
+ const now = Date.now();
72
+ const expiresAt = notAfter == null ? now + ttlMs : Math.min(now + ttlMs, notAfter);
73
+ if (expiresAt <= now) return;
74
+ store.set(cacheKey(scope, key), { value, expiresAt });
63
75
  }
64
76
 
65
77
  /**
@@ -83,11 +95,20 @@ export function invalidatePublishedCache(scope?: PublishedCacheScope, key?: stri
83
95
  for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
84
96
  }
85
97
 
86
- /** Runs `load` only when the entry is missing or expired. */
98
+ /**
99
+ * Runs `load` only when the entry is missing or expired.
100
+ *
101
+ * `boundaryOf` reads the instant the loaded value stops being true at, when the
102
+ * caller knows one. Content that is going to change on a schedule is the case
103
+ * this exists for: a page cached twenty seconds before it goes live would be
104
+ * served as missing for twenty seconds after it went live, and no amount of
105
+ * invalidation on write helps, because nobody writes anything at that moment.
106
+ */
87
107
  export async function withPublishedCache<T>(
88
108
  scope: PublishedCacheScope,
89
109
  key: string,
90
110
  load: () => Promise<T>,
111
+ boundaryOf?: (value: T) => number | null | undefined,
91
112
  ): Promise<T> {
92
113
  const cached = publishedCacheGet<T>(scope, key);
93
114
  if (cached !== undefined) {
@@ -97,7 +118,7 @@ export async function withPublishedCache<T>(
97
118
  misses += 1;
98
119
  const value = await load();
99
120
  // `undefined` is indistinguishable from "absent", so it is never stored.
100
- if (value !== undefined) publishedCacheSet(scope, key, value);
121
+ if (value !== undefined) publishedCacheSet(scope, key, value, boundaryOf?.(value));
101
122
  return value;
102
123
  }
103
124
 
@@ -5,6 +5,7 @@
5
5
  * passes, so it works with an authenticated client in the backoffice and with a
6
6
  * privileged client during public rendering.
7
7
  */
8
+ import { flattenBlockTree } from "../blocks/block-tree";
8
9
  import type { BlockInstance, BlockProps } from "../blocks/types";
9
10
 
10
11
  /** Minimal read surface this helper needs from a Supabase-style client. */
@@ -16,8 +17,12 @@ export type GlobalsReadClient = {
16
17
  };
17
18
  };
18
19
 
20
+ /*
21
+ * Flattened, not root-only: a global reference nested in a layout column is
22
+ * still a reference, and missing it renders the global as empty on the page.
23
+ */
19
24
  function refKeysOf(blocks: BlockInstance[]): string[] {
20
- return blocks
25
+ return flattenBlockTree(blocks)
21
26
  .map((b) => {
22
27
  const props = b.props as (BlockProps & { expiredSectionKey?: string }) | undefined;
23
28
  if (b.type === "globalRef") return String(props?.sectionKey ?? "");
@@ -64,6 +64,10 @@ export const SCHEMA_MIGRATIONS: readonly string[] = [
64
64
  "20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql",
65
65
  "20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql",
66
66
  "20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql",
67
+ "20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql",
68
+ "20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql",
69
+ "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
70
+ "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
67
71
  ];
68
72
 
69
73
  /** Schema baseline version = the newest canonical migration. */
@@ -126,6 +126,17 @@ export const pageSchemas = {
126
126
  name: z.string().min(1).max(120),
127
127
  editSessionId: editSessionId.optional(),
128
128
  }),
129
+ setPageStatus: z.object({
130
+ pageId: uuid,
131
+ status: z.enum(["draft", "published"]),
132
+ editSessionId: editSessionId.optional(),
133
+ }),
134
+ duplicatePage: z.object({
135
+ pageId: uuid,
136
+ name: z.string().min(1).max(120),
137
+ /** Optional explicit key; the slug is derived from the name otherwise. */
138
+ key: cmsKey.optional(),
139
+ }),
129
140
  schedulePageVersion: z.object({
130
141
  versionId: uuid,
131
142
  publishAt: z.string().datetime(),
@@ -202,6 +213,10 @@ export const mediaSchemas = {
202
213
  .min(1)
203
214
  .max(50),
204
215
  upsert: z.boolean().optional(),
216
+ /** Destination folder; empty/absent means the automatic `YYYY-MM` folder. */
217
+ folder: z.string().max(200).optional(),
218
+ /** Site timezone for the automatic folder's month boundary. */
219
+ timezone: z.string().max(64).optional(),
205
220
  }),
206
221
  reviewImage: z.object({
207
222
  slug: z.string().trim().min(1).max(80),
@@ -5,10 +5,13 @@
5
5
  * a published page or an unsaved draft. The registry is passed in, so a site
6
6
  * only supplies its own block definitions.
7
7
  */
8
+ import { useMemo } from "react";
8
9
  import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
9
10
 
11
+ import { publishedPageStaleTime } from "../cms/page-publication";
10
12
  import { PriorityMediaProvider } from "../media/priority";
11
13
  import { CountdownReplacementProvider } from "../blocks/countdown-replacement";
14
+ import { BlockChildrenProvider } from "../blocks/block-children";
12
15
  import type { BlockDef } from "../blocks/registry";
13
16
  import type { BlockInstance, BlockProps } from "../blocks/types";
14
17
 
@@ -17,6 +20,12 @@ export type BlockRegistry = Record<string, BlockDef>;
17
20
  export type RenderedPage = {
18
21
  blocks: BlockInstance[];
19
22
  globals: Record<string, BlockInstance[]>;
23
+ /**
24
+ * The next instant this page's public answer changes, when the server knows
25
+ * of one — a version scheduled to replace this one, or the end of the window
26
+ * showing it. Caching stops there.
27
+ */
28
+ revalidateAt?: string | null;
20
29
  };
21
30
 
22
31
  function RenderBlock({
@@ -121,34 +130,58 @@ export function DraftPageRendererView({
121
130
  edit?: boolean;
122
131
  selectedId?: string | null;
123
132
  }) {
133
+ // Container blocks (`layout`) render their children through this, so nesting
134
+ // uses exactly the same code path — and the same globals, selection
135
+ // highlighting and priority scope — as the top level.
136
+ const children = useMemo(
137
+ () => ({
138
+ edit,
139
+ render: (list: BlockInstance[]) =>
140
+ list.map((b) => (
141
+ <RenderBlock
142
+ key={b.id}
143
+ block={b}
144
+ globals={globals}
145
+ registry={registry}
146
+ edit={edit}
147
+ selectedId={selectedId}
148
+ />
149
+ )),
150
+ }),
151
+ [globals, registry, edit, selectedId],
152
+ );
153
+
124
154
  // Every page render gets its own priority scope, so the first first-screen
125
155
  // image loads eagerly and the rest stay lazy. In the builder (`edit`) the
126
156
  // editor jumps around the page, so no image is treated as the hero.
127
157
  return (
128
158
  <PriorityMediaProvider enabled={!edit}>
129
- {blocks.map((b) => (
130
- <RenderBlock
131
- key={b.id}
132
- block={b}
133
- globals={globals}
134
- registry={registry}
135
- edit={edit}
136
- selectedId={selectedId}
137
- />
138
- ))}
159
+ <BlockChildrenProvider value={children}>{children.render(blocks)}</BlockChildrenProvider>
139
160
  </PriorityMediaProvider>
140
161
  );
141
162
  }
142
163
 
143
- /** Query options for one published page, keyed by page key. */
164
+ /** How long a fetched page may be reused when nothing is scheduled. */
165
+ export const PAGE_STALE_TIME_MS = 30_000;
166
+
167
+ /**
168
+ * Query options for one published page, keyed by page key.
169
+ *
170
+ * The freshness window is a ceiling, not a promise: it is cut short by whatever
171
+ * the server said would change next. Half a minute is a sensible default for a
172
+ * page nobody has scheduled anything for, and exactly the wrong answer for one
173
+ * going live in ten seconds — it would keep showing the visitor a 404 for
174
+ * twenty seconds after publication, with no write anywhere to invalidate on.
175
+ */
144
176
  export function createPageQueryOptions(
145
177
  fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
178
+ defaultStaleTimeMs: number = PAGE_STALE_TIME_MS,
146
179
  ) {
147
180
  return (key: string) =>
148
181
  queryOptions({
149
182
  queryKey: ["cms", "page", key],
150
183
  queryFn: () => fetchPublishedPage(key),
151
- staleTime: 30_000,
184
+ staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
152
185
  });
153
186
  }
154
187
 
@@ -6,8 +6,12 @@
6
6
  */
7
7
  import { Link } from "@tanstack/react-router";
8
8
 
9
+ import { useScheduleClock } from "../cms/use-schedule-clock";
10
+ import { isVisibleAt, type VisibilitySchedule } from "../cms/visibility-schedule";
11
+ import type { SiteTypography } from "../config/typography";
9
12
  import { useT } from "../i18n";
10
13
  import type { LocalizedString } from "../i18n/types";
14
+ import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
11
15
 
12
16
  export type SiteFooterCompany = {
13
17
  legalName: string;
@@ -21,11 +25,31 @@ export type SiteFooterCompany = {
21
25
  phone: string;
22
26
  };
23
27
 
28
+ /** A footer link, optionally only shown inside a scheduled window. */
29
+ export type SiteFooterLink = {
30
+ label: LocalizedString;
31
+ href: string;
32
+ /** Absent means "always visible", so existing footers are unchanged. */
33
+ schedule?: VisibilitySchedule;
34
+ };
35
+
24
36
  export type SiteFooterColumn = {
25
37
  title: LocalizedString;
26
- links: Array<{ label: LocalizedString; href: string }>;
38
+ links: SiteFooterLink[];
39
+ /** Hides the whole column, links included, outside its window. */
40
+ schedule?: VisibilitySchedule;
27
41
  };
28
42
 
43
+ /** Every schedule in a footer, for the re-render clock. */
44
+ function footerSchedules(columns: readonly SiteFooterColumn[]): VisibilitySchedule[] {
45
+ const out: VisibilitySchedule[] = [];
46
+ for (const column of columns) {
47
+ if (column.schedule) out.push(column.schedule);
48
+ for (const link of column.links ?? []) if (link.schedule) out.push(link.schedule);
49
+ }
50
+ return out;
51
+ }
52
+
29
53
  export type SiteFooterProps = {
30
54
  brandShort: LocalizedString;
31
55
  brandName: LocalizedString;
@@ -35,6 +59,11 @@ export type SiteFooterProps = {
35
59
  company: SiteFooterCompany;
36
60
  /** Label prefixing the organisation number in the address block. */
37
61
  orgNumberLabel?: string;
62
+ /**
63
+ * Site typography. Only the `footer` slot is applied here, and only when it
64
+ * names a Google family; otherwise the footer keeps its theme fonts exactly.
65
+ */
66
+ typography?: SiteTypography;
38
67
  };
39
68
 
40
69
  export function SiteFooter({
@@ -45,8 +74,19 @@ export function SiteFooter({
45
74
  columns,
46
75
  company,
47
76
  orgNumberLabel = "Org.nr",
77
+ typography,
48
78
  }: SiteFooterProps) {
49
79
  const { t } = useT();
80
+ // Same clock as the menu: a scheduled link appears or disappears on its
81
+ // boundary rather than on the next full page load.
82
+ const now = useScheduleClock(footerSchedules(columns));
83
+ const footerFont = slotFont(typography, "footer");
84
+ const visibleColumns = columns
85
+ .filter((column) => isVisibleAt(column.schedule, now))
86
+ .map((column) => ({
87
+ ...column,
88
+ links: (column.links ?? []).filter((link) => isVisibleAt(link.schedule, now)),
89
+ }));
50
90
 
51
91
  const jsonLd = {
52
92
  "@context": "https://schema.org",
@@ -66,7 +106,8 @@ export function SiteFooter({
66
106
  };
67
107
 
68
108
  return (
69
- <footer className="bg-ink text-paper">
109
+ <footer className="bg-ink text-paper" style={fontStyle(footerFont)}>
110
+ <SiteFontLinks requests={typographyFontRequests(typography)} />
70
111
  <div className="container-editorial py-20 md:py-28">
71
112
  <div className="grid gap-12 md:grid-cols-12">
72
113
  <div className="md:col-span-4">
@@ -76,7 +117,7 @@ export function SiteFooter({
76
117
  </p>
77
118
  </div>
78
119
  <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
79
- {columns.map((col, i) => (
120
+ {visibleColumns.map((col, i) => (
80
121
  <div key={i} className="min-w-0">
81
122
  <div className="eyebrow text-paper/80">{t(col.title)}</div>
82
123
  <ul className="mt-4 space-y-2">
@@ -10,9 +10,17 @@ import { Link, useRouterState } from "@tanstack/react-router";
10
10
  import { useEffect, useRef, useState } from "react";
11
11
  import { ChevronDown, Menu, X } from "lucide-react";
12
12
 
13
+ import { useScheduleClock } from "../cms/use-schedule-clock";
14
+ import type { SiteTypography } from "../config/typography";
13
15
  import { useT } from "../i18n";
14
16
  import type { LocalizedString } from "../i18n/types";
15
- import { navigationPanelId, renderableNavigation, type NavigationItem } from "../navigation";
17
+ import {
18
+ navigationPanelId,
19
+ navigationSchedules,
20
+ renderableNavigation,
21
+ type NavigationItem,
22
+ } from "../navigation";
23
+ import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
16
24
 
17
25
  export type SiteHeaderProps = {
18
26
  /**
@@ -42,6 +50,12 @@ export type SiteHeaderProps = {
42
50
  * un-scrolled header uses light text. Defaults to the home route.
43
51
  */
44
52
  lightTextPaths?: readonly string[];
53
+ /**
54
+ * Site typography. Only the `menu` slot is read here, and only when it names
55
+ * a Google family — otherwise the header renders no font-family at all and
56
+ * keeps inheriting the theme font exactly as before.
57
+ */
58
+ typography?: SiteTypography;
45
59
  };
46
60
 
47
61
  function NavEntry({ item, lightText }: { item: NavigationItem; lightText: boolean }) {
@@ -179,6 +193,7 @@ export function SiteHeader({
179
193
  menuLabels,
180
194
  backoffice,
181
195
  lightTextPaths = ["/"],
196
+ typography,
182
197
  }: SiteHeaderProps) {
183
198
  const { t, locale } = useT();
184
199
  const pathname = useRouterState({ select: (r) => r.location.pathname });
@@ -234,8 +249,12 @@ export function SiteHeader({
234
249
  const logo = onLightSurface && logoDarkSrc ? logoDarkSrc : logoSrc;
235
250
  const invertLogo = onLightSurface && !logoDarkSrc && invertLogoOnLightSurface;
236
251
  const showBackoffice = Boolean(backoffice?.signedIn);
252
+ // A page left open past a scheduled publish/hide time re-renders on the
253
+ // boundary instead of showing what was true when it loaded.
254
+ const now = useScheduleClock(navigationSchedules(nav));
237
255
  // Half-finished entries are the editor's business, not a visitor's.
238
- const items = renderableNavigation(nav, locale);
256
+ const items = renderableNavigation(nav, locale, now);
257
+ const menuFont = slotFont(typography, "menu");
239
258
 
240
259
  return (
241
260
  <header
@@ -243,7 +262,9 @@ export function SiteHeader({
243
262
  "fixed inset-x-0 top-0 z-50 transition-all duration-500",
244
263
  scrolled ? "backdrop-blur-md bg-background/70 border-b border-border/50" : "bg-transparent",
245
264
  ].join(" ")}
265
+ style={fontStyle(menuFont)}
246
266
  >
267
+ <SiteFontLinks requests={typographyFontRequests(typography)} />
247
268
  <div className="container-editorial flex h-16 items-center justify-between gap-4 md:h-20">
248
269
  <Link to="/" className="shrink-0 inline-flex items-center" aria-label={name}>
249
270
  <img
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Google Fonts support, without an API key and without a runtime catalog fetch.
3
+ *
4
+ * The picker is backed by the static list below rather than the Google Fonts
5
+ * Developer API: an API key would have to reach the browser to query it, and a
6
+ * catalog request on every admin render is a lot of bytes for a select box.
7
+ * Any family Google serves can still be typed in by hand — the list is a
8
+ * shortcut, not a whitelist.
9
+ *
10
+ * Self-hosted WOFF2 fonts (`../site/font-bundle`) are untouched by this module.
11
+ * A site using them simply leaves typography on the theme default.
12
+ */
13
+
14
+ import { DEFAULT_FONT_WEIGHTS, sanitizeFontFamily } from "../config/typography";
15
+
16
+ /**
17
+ * Families offered in the picker. Deliberately a short, curated list of
18
+ * well-supported text faces rather than the full 1500-family catalog.
19
+ */
20
+ export const GOOGLE_FONT_CATALOG = [
21
+ "Abril Fatface",
22
+ "Alegreya",
23
+ "Archivo",
24
+ "Arvo",
25
+ "Barlow",
26
+ "Bitter",
27
+ "Cabin",
28
+ "Cardo",
29
+ "Cormorant Garamond",
30
+ "Crimson Text",
31
+ "DM Sans",
32
+ "DM Serif Display",
33
+ "DM Serif Text",
34
+ "EB Garamond",
35
+ "Fira Sans",
36
+ "Fraunces",
37
+ "Heebo",
38
+ "IBM Plex Sans",
39
+ "IBM Plex Serif",
40
+ "Inter",
41
+ "Josefin Sans",
42
+ "Karla",
43
+ "Lato",
44
+ "Libre Baskerville",
45
+ "Libre Franklin",
46
+ "Lora",
47
+ "Manrope",
48
+ "Merriweather",
49
+ "Montserrat",
50
+ "Mulish",
51
+ "Newsreader",
52
+ "Noto Sans",
53
+ "Noto Serif",
54
+ "Nunito",
55
+ "Nunito Sans",
56
+ "Old Standard TT",
57
+ "Open Sans",
58
+ "Oswald",
59
+ "Outfit",
60
+ "Playfair Display",
61
+ "Plus Jakarta Sans",
62
+ "Poppins",
63
+ "PT Sans",
64
+ "PT Serif",
65
+ "Public Sans",
66
+ "Quicksand",
67
+ "Raleway",
68
+ "Roboto",
69
+ "Roboto Serif",
70
+ "Roboto Slab",
71
+ "Rubik",
72
+ "Sora",
73
+ "Source Sans 3",
74
+ "Source Serif 4",
75
+ "Space Grotesk",
76
+ "Spectral",
77
+ "Syne",
78
+ "Tenor Sans",
79
+ "Urbanist",
80
+ "Work Sans",
81
+ ] as const;
82
+
83
+ /**
84
+ * Families that do not ship the usual range of weights.
85
+ *
86
+ * The picker offers 400 and 700 by default, and `css2` answers a request for a
87
+ * weight a family does not have with an error rather than a stylesheet — so
88
+ * asking DM Serif Display for 700 loses the font entirely, including the 400 it
89
+ * does have. Only families that are actually restricted are listed; anything
90
+ * absent is treated as unrestricted, which is the safe default for a family
91
+ * typed in by hand.
92
+ *
93
+ * These are all single-weight display faces: Google publishes one upright (and
94
+ * for some an italic), and no bold at all.
95
+ */
96
+ export const GOOGLE_FONT_WEIGHTS: Readonly<Record<string, readonly number[]>> = {
97
+ "Abril Fatface": [400],
98
+ "DM Serif Display": [400],
99
+ "DM Serif Text": [400],
100
+ "Tenor Sans": [400],
101
+ };
102
+
103
+ /**
104
+ * The weights a family actually has, or `null` when nothing is known about it.
105
+ *
106
+ * `null` means "do not narrow anything" — the catalog above is a list of known
107
+ * exceptions, not a whitelist, and a hand-typed family must keep working.
108
+ */
109
+ export function supportedFontWeights(family: string): readonly number[] | null {
110
+ return GOOGLE_FONT_WEIGHTS[sanitizeFontFamily(family)] ?? null;
111
+ }
112
+
113
+ /**
114
+ * The weights to actually request for a family.
115
+ *
116
+ * Unsupported weights are mapped to the nearest one the family has rather than
117
+ * dropped, because dropping every requested weight would drop the family: an
118
+ * editor who asks a 400-only face for bold should get its 400, not lose the
119
+ * font. De-duplicated, so 400 and 700 of a single-weight family cost one style.
120
+ */
121
+ export function usableFontWeights(family: string, requested: readonly number[]): number[] {
122
+ const supported = supportedFontWeights(family);
123
+ if (!supported || supported.length === 0) return [...new Set(requested)].sort((a, b) => a - b);
124
+ const nearest = (want: number) =>
125
+ supported.reduce((best, w) => (Math.abs(w - want) < Math.abs(best - want) ? w : best));
126
+ return [...new Set(requested.map(nearest))].sort((a, b) => a - b);
127
+ }
128
+
129
+ /** Ranked catalog search for the picker: prefix matches first, then contains. */
130
+ export function searchGoogleFonts(query: string, limit = 12): string[] {
131
+ const q = query.trim().toLowerCase();
132
+ if (!q) return GOOGLE_FONT_CATALOG.slice(0, limit);
133
+ const starts: string[] = [];
134
+ const contains: string[] = [];
135
+ for (const family of GOOGLE_FONT_CATALOG) {
136
+ const lower = family.toLowerCase();
137
+ if (lower.startsWith(q)) starts.push(family);
138
+ else if (lower.includes(q)) contains.push(family);
139
+ }
140
+ return [...starts, ...contains].slice(0, limit);
141
+ }
142
+
143
+ export type FontRequest = { family: string; weights?: readonly number[] };
144
+
145
+ /**
146
+ * One `css2` stylesheet URL for every family requested, or `null` when nothing
147
+ * needs loading. Families are merged and de-duplicated so asking for the same
148
+ * font in the menu and the footer costs a single request.
149
+ */
150
+ export function googleFontsCssUrl(requests: readonly FontRequest[]): string | null {
151
+ const merged = new Map<string, Set<number>>();
152
+ for (const request of requests) {
153
+ const family = sanitizeFontFamily(request.family);
154
+ if (!family) continue;
155
+ const weights = merged.get(family) ?? new Set<number>();
156
+ for (const weight of request.weights?.length ? request.weights : DEFAULT_FONT_WEIGHTS) {
157
+ if (Number.isFinite(weight)) weights.add(weight);
158
+ }
159
+ merged.set(family, weights);
160
+ }
161
+ if (merged.size === 0) return null;
162
+
163
+ const families = [...merged.entries()]
164
+ .sort(([a], [b]) => a.localeCompare(b))
165
+ .map(([family, weights]) => {
166
+ // Narrowed here rather than at the call sites: this is the one place every
167
+ // stylesheet URL is built, so a weight a family does not have cannot reach
168
+ // Google however it was configured — picker, hand-typed name, or settings
169
+ // stored before the family's limits were known.
170
+ const list = usableFontWeights(family, [...weights]);
171
+ return `family=${encodeURIComponent(family).replace(/%20/g, "+")}:wght@${list.join(";")}`;
172
+ });
173
+ return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`;
174
+ }
175
+
176
+ export type HeadLink = Record<string, string>;
177
+
178
+ /**
179
+ * `<link>` descriptors for a site's `head()`: the two preconnects Google
180
+ * recommends plus the stylesheet. Empty when no Google font is configured, so
181
+ * a site that never picks one ships no extra requests at all.
182
+ */
183
+ export function googleFontsHeadLinks(requests: readonly FontRequest[]): HeadLink[] {
184
+ const href = googleFontsCssUrl(requests);
185
+ if (!href) return [];
186
+ return [
187
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
188
+ { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
189
+ { rel: "stylesheet", href },
190
+ ];
191
+ }