@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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. */
|
package/src/server/schemas.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
/**
|
|
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:
|
|
184
|
+
staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
|
|
152
185
|
});
|
|
153
186
|
}
|
|
154
187
|
|
package/src/site/SiteFooter.tsx
CHANGED
|
@@ -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:
|
|
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
|
-
{
|
|
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">
|
package/src/site/SiteHeader.tsx
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|