@bygghammar/cms-platform 0.94.0 → 0.95.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 +74 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +39 -3
- package/src/site/routes/public-page.tsx +81 -9
package/src/server/pages-core.ts
CHANGED
|
@@ -37,6 +37,13 @@ import {
|
|
|
37
37
|
type PublicationWindow,
|
|
38
38
|
} from "../cms/page-publication";
|
|
39
39
|
import { resolveGlobalsDeep } from "./resolve-globals";
|
|
40
|
+
import { normalizePageListing, type PageListing } from "../cms/page-visibility";
|
|
41
|
+
import {
|
|
42
|
+
assertNotProtected,
|
|
43
|
+
PAGE_LISTING_PREFIX,
|
|
44
|
+
readVisibilitySettingsCore,
|
|
45
|
+
type VersionToken,
|
|
46
|
+
} from "./page-visibility-core";
|
|
40
47
|
import { CONTRACT_V2_MIGRATIONS, CONTRACT_V3_MIGRATIONS } from "../platform/contract";
|
|
41
48
|
import { digestJson, type DigestValue } from "../platform/digest";
|
|
42
49
|
|
|
@@ -50,8 +57,48 @@ export type PagesDeps = {
|
|
|
50
57
|
context: PagesContext;
|
|
51
58
|
/** Privileged client; only used to resolve legacy version authors. */
|
|
52
59
|
admin?: () => Promise<any>;
|
|
60
|
+
/**
|
|
61
|
+
* Keys of pages that must always stay published and listed (for Voyara the
|
|
62
|
+
* start page and the contact page). The cores refuse to unpublish, end-date,
|
|
63
|
+
* trash or unlist them. Supplied by the host's server code, never the browser.
|
|
64
|
+
*/
|
|
65
|
+
protectedPageKeys?: readonly string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Listing fields the list and the builder show next to publication. */
|
|
69
|
+
export type PageListingFields = {
|
|
70
|
+
listing: PageListing;
|
|
71
|
+
/** Version token to send back with a listing change (null: never changed). */
|
|
72
|
+
listing_version: VersionToken;
|
|
73
|
+
/** Protected pages offer neither Unpublish nor Make link-only. */
|
|
74
|
+
protected: boolean;
|
|
53
75
|
};
|
|
54
76
|
|
|
77
|
+
function listingFieldsFor(
|
|
78
|
+
pageId: string,
|
|
79
|
+
key: string,
|
|
80
|
+
settings: Awaited<ReturnType<typeof readVisibilitySettingsCore>>,
|
|
81
|
+
protectedPageKeys: readonly string[] | undefined,
|
|
82
|
+
): PageListingFields {
|
|
83
|
+
const row = settings.listings.get(pageId.toLowerCase());
|
|
84
|
+
return {
|
|
85
|
+
listing: normalizePageListing(row?.listing),
|
|
86
|
+
listing_version: row?.version ?? null,
|
|
87
|
+
protected: (protectedPageKeys ?? []).includes(key),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Name and key of one page, for the protected-page rule. */
|
|
92
|
+
async function pageIdentity(client: any, pageId: string): Promise<{ key: string; name: string } | null> {
|
|
93
|
+
const { data, error } = await client
|
|
94
|
+
.from("pages")
|
|
95
|
+
.select("key,name")
|
|
96
|
+
.eq("id", pageId)
|
|
97
|
+
.maybeSingle();
|
|
98
|
+
if (error) throw new Error(error.message);
|
|
99
|
+
return (data as { key: string; name: string } | null) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
55
102
|
export type ResolvedPage = {
|
|
56
103
|
key: string;
|
|
57
104
|
name: string;
|
|
@@ -304,9 +351,10 @@ export type PageListRow = {
|
|
|
304
351
|
status: string;
|
|
305
352
|
updated_at: string;
|
|
306
353
|
current_version_id: string | null;
|
|
307
|
-
} & PublicationSummaryFields
|
|
354
|
+
} & PublicationSummaryFields &
|
|
355
|
+
PageListingFields;
|
|
308
356
|
|
|
309
|
-
export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow[]> {
|
|
357
|
+
export async function listPagesCore({ context, protectedPageKeys }: PagesDeps): Promise<PageListRow[]> {
|
|
310
358
|
await assertStaff(context);
|
|
311
359
|
const { data, error } = await context.supabase
|
|
312
360
|
.from("pages")
|
|
@@ -315,7 +363,9 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
|
|
|
315
363
|
.order("is_system", { ascending: false })
|
|
316
364
|
.order("name");
|
|
317
365
|
if (error) throw new Error(error.message);
|
|
318
|
-
const rows = (data ?? []) as Array<
|
|
366
|
+
const rows = (data ?? []) as Array<
|
|
367
|
+
Omit<PageListRow, keyof PublicationSummaryFields | keyof PageListingFields>
|
|
368
|
+
>;
|
|
319
369
|
if (rows.length === 0) return [];
|
|
320
370
|
|
|
321
371
|
/*
|
|
@@ -341,11 +391,17 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
|
|
|
341
391
|
byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
|
|
342
392
|
}
|
|
343
393
|
|
|
394
|
+
// Listing is its own decision, stored apart from publication; read once.
|
|
395
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
344
396
|
const now = Date.now();
|
|
345
|
-
return rows.map((row) => ({
|
|
397
|
+
return rows.map((row) => ({
|
|
398
|
+
...row,
|
|
399
|
+
...summarize(byPage.get(row.id) ?? [], now),
|
|
400
|
+
...listingFieldsFor(row.id, row.key, settings, protectedPageKeys),
|
|
401
|
+
}));
|
|
346
402
|
}
|
|
347
403
|
|
|
348
|
-
export async function getPageForEditCore({ context }: PagesDeps, key: string) {
|
|
404
|
+
export async function getPageForEditCore({ context, protectedPageKeys }: PagesDeps, key: string) {
|
|
349
405
|
await assertStaff(context);
|
|
350
406
|
const { data: page, error } = await context.supabase
|
|
351
407
|
.from("pages")
|
|
@@ -373,7 +429,9 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
|
|
|
373
429
|
* the public routes use.
|
|
374
430
|
*/
|
|
375
431
|
const publication = await publicationOf(context.supabase, page.id);
|
|
376
|
-
|
|
432
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
433
|
+
const listing = listingFieldsFor(page.id, page.key, settings, protectedPageKeys);
|
|
434
|
+
return { page: { ...page, ...publication, ...listing }, blocks };
|
|
377
435
|
}
|
|
378
436
|
|
|
379
437
|
/**
|
|
@@ -649,7 +707,7 @@ export async function createPageCore({ context }: PagesDeps, input: { key: strin
|
|
|
649
707
|
* or a schedule somebody forgot about.
|
|
650
708
|
*/
|
|
651
709
|
export async function setPageStatusCore(
|
|
652
|
-
{ context }: PagesDeps,
|
|
710
|
+
{ context, protectedPageKeys }: PagesDeps,
|
|
653
711
|
input: { pageId: string; status: PageStatus; editSessionId?: string },
|
|
654
712
|
) {
|
|
655
713
|
await assertStaff(context);
|
|
@@ -670,6 +728,7 @@ export async function setPageStatusCore(
|
|
|
670
728
|
if (status === "published" && !page.current_version_id) {
|
|
671
729
|
throw new Error("This page has no saved content yet, so there is nothing to publish.");
|
|
672
730
|
}
|
|
731
|
+
if (status === "draft") assertNotProtected(protectedPageKeys, page, "unpublished");
|
|
673
732
|
|
|
674
733
|
invalidatePublishedCache();
|
|
675
734
|
|
|
@@ -774,6 +833,11 @@ export async function duplicatePageCore(
|
|
|
774
833
|
blocks = duplicateBlockTree(blocksOf(version.blocks));
|
|
775
834
|
}
|
|
776
835
|
|
|
836
|
+
// Before the copy exists: what this duplication means for its listing, fixed
|
|
837
|
+
// now and kept with the idempotency key, so a retry completes this decision
|
|
838
|
+
// rather than taking a new one from the source as it is by then.
|
|
839
|
+
const intent = await recordDuplicateIntent(context, idempotencyKey, input.pageId);
|
|
840
|
+
|
|
777
841
|
invalidatePublishedCache();
|
|
778
842
|
const copy = await publicationRpc(
|
|
779
843
|
context.supabase,
|
|
@@ -797,6 +861,7 @@ export async function duplicatePageCore(
|
|
|
797
861
|
|
|
798
862
|
const id = typeof copy?.pageId === "string" ? copy.pageId : "";
|
|
799
863
|
if (!id) throw new Error("The database made no copy of this page.");
|
|
864
|
+
await completeDuplicateListing(context, intent, id);
|
|
800
865
|
return {
|
|
801
866
|
id,
|
|
802
867
|
key: String(copy.key ?? key),
|
|
@@ -846,7 +911,7 @@ export async function renamePageCore(
|
|
|
846
911
|
* publishing action nobody confirmed.
|
|
847
912
|
*/
|
|
848
913
|
export async function deletePageCore(
|
|
849
|
-
{ context }: PagesDeps,
|
|
914
|
+
{ context, protectedPageKeys }: PagesDeps,
|
|
850
915
|
pageId: string,
|
|
851
916
|
editSessionId?: string,
|
|
852
917
|
) {
|
|
@@ -856,6 +921,8 @@ export async function deletePageCore(
|
|
|
856
921
|
resourceId: pageId,
|
|
857
922
|
sessionId: editSessionId,
|
|
858
923
|
});
|
|
924
|
+
const identity = await pageIdentity(context.supabase, pageId);
|
|
925
|
+
if (identity) assertNotProtected(protectedPageKeys, identity, "moved to the trash");
|
|
859
926
|
invalidatePublishedCache();
|
|
860
927
|
await publicationRpc(
|
|
861
928
|
context.supabase,
|
|
@@ -956,6 +1023,163 @@ export async function purgePageCore({ context }: PagesDeps, pageId: string) {
|
|
|
956
1023
|
return { ok: true as const };
|
|
957
1024
|
}
|
|
958
1025
|
|
|
1026
|
+
/** Where a duplication's listing decision is kept until it has been applied. */
|
|
1027
|
+
const DUPLICATE_INTENT_PREFIX = "duplicate.intent.";
|
|
1028
|
+
|
|
1029
|
+
type DuplicateIntent = {
|
|
1030
|
+
key: string;
|
|
1031
|
+
/** What the copy's listing is to be: the source's, as it was when the row was written. */
|
|
1032
|
+
listing: PageListing;
|
|
1033
|
+
/** When the database wrote the row (`site_settings.created_at`, its own clock). */
|
|
1034
|
+
recordedAt: string;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* A copy of a link-only page starts link-only too.
|
|
1039
|
+
*
|
|
1040
|
+
* The copy is a draft, so nothing is public yet; but the moment somebody
|
|
1041
|
+
* publishes it, a listed copy would put the address the original kept out of
|
|
1042
|
+
* menus and the sitemap straight into them. Booking exceptions are never
|
|
1043
|
+
* copied: the safe default for a new page is booking hidden while booking is
|
|
1044
|
+
* off.
|
|
1045
|
+
*
|
|
1046
|
+
* The copy is made in one transaction and listed in a second write, so the
|
|
1047
|
+
* listing can fail after the copy exists, and a retry with the same
|
|
1048
|
+
* idempotency key is how a caller finishes it. A retry must finish *that*
|
|
1049
|
+
* decision and never take a new one: if the source has been listed or
|
|
1050
|
+
* unlisted since, a call that read the source again would change a copy
|
|
1051
|
+
* nobody asked to change. So the decision is written down before the copy is
|
|
1052
|
+
* made — a `site_settings` row keyed by the idempotency key, durable across
|
|
1053
|
+
* processes, no migration — and applied after it.
|
|
1054
|
+
*
|
|
1055
|
+
* Which row is that decision is settled by the database's own record, not by
|
|
1056
|
+
* which call wrote it, whether a cleanup succeeded, or how many calls are in
|
|
1057
|
+
* flight: the decision is the row written **before the copy existed**. The
|
|
1058
|
+
* row's `created_at` and the copy's `pages.created_at` are both the
|
|
1059
|
+
* database's `now()`, and the row is always committed before the call that
|
|
1060
|
+
* makes the copy starts. A row written by a call that arrived after the copy
|
|
1061
|
+
* was made — a replay of a finished duplication, alone or overlapping
|
|
1062
|
+
* another, or one whose cleanup failed — is only an observation of the source
|
|
1063
|
+
* as it is today, and is never applied.
|
|
1064
|
+
*
|
|
1065
|
+
* The row is inserted only if absent (`ON CONFLICT DO NOTHING`), so the first
|
|
1066
|
+
* attempt's decision is the one that stands, and its `created_at` never moves.
|
|
1067
|
+
* The table is publicly readable, so the row holds the decision only, as the
|
|
1068
|
+
* page's own listing row would.
|
|
1069
|
+
*/
|
|
1070
|
+
async function recordDuplicateIntent(
|
|
1071
|
+
context: PagesContext,
|
|
1072
|
+
idempotencyKey: string,
|
|
1073
|
+
sourceId: string,
|
|
1074
|
+
): Promise<DuplicateIntent | null> {
|
|
1075
|
+
const key = `${DUPLICATE_INTENT_PREFIX}${idempotencyKey.toLowerCase()}`;
|
|
1076
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
1077
|
+
const listing = normalizePageListing(settings.listings.get(sourceId.toLowerCase())?.listing);
|
|
1078
|
+
const { error } = await context.supabase
|
|
1079
|
+
.from("site_settings")
|
|
1080
|
+
.upsert(
|
|
1081
|
+
{ key, value: { listing }, updated_by: context.userId },
|
|
1082
|
+
{ onConflict: "key", ignoreDuplicates: true },
|
|
1083
|
+
)
|
|
1084
|
+
.select("key");
|
|
1085
|
+
if (error) throw new Error(`Could not prepare the copy: ${error.message}. Nothing was copied.`);
|
|
1086
|
+
// Whoever wrote it, the row now in the database is the one that counts.
|
|
1087
|
+
const { data: row, error: readError } = await context.supabase
|
|
1088
|
+
.from("site_settings")
|
|
1089
|
+
.select("value, created_at")
|
|
1090
|
+
.eq("key", key)
|
|
1091
|
+
.maybeSingle();
|
|
1092
|
+
if (readError) throw new Error(`Could not prepare the copy: ${readError.message}. Nothing was copied.`);
|
|
1093
|
+
// Gone again: a call that finished this duplication has applied and removed it.
|
|
1094
|
+
if (!row) return null;
|
|
1095
|
+
const value = (row.value ?? {}) as { listing?: unknown };
|
|
1096
|
+
return { key, listing: normalizePageListing(value.listing), recordedAt: String(row.created_at) };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Apply the duplication's recorded listing decision to the copy, if it is the
|
|
1101
|
+
* original decision (see {@link recordDuplicateIntent}).
|
|
1102
|
+
*
|
|
1103
|
+
* Only ever inserts a missing listing row, so a listing chosen for the copy
|
|
1104
|
+
* since (a row already present, listed or link-only) is never overwritten,
|
|
1105
|
+
* and applying the same decision twice is harmless.
|
|
1106
|
+
*/
|
|
1107
|
+
async function completeDuplicateListing(
|
|
1108
|
+
context: PagesContext,
|
|
1109
|
+
intent: DuplicateIntent | null,
|
|
1110
|
+
copyId: string,
|
|
1111
|
+
): Promise<void> {
|
|
1112
|
+
if (!intent) return;
|
|
1113
|
+
const { data: copy, error } = await context.supabase
|
|
1114
|
+
.from("pages")
|
|
1115
|
+
.select("created_at")
|
|
1116
|
+
.eq("id", copyId)
|
|
1117
|
+
.maybeSingle();
|
|
1118
|
+
const recorded = timestampMicros(intent.recordedAt);
|
|
1119
|
+
const created = timestampMicros(copy?.created_at);
|
|
1120
|
+
if (error || recorded === null || created === null) {
|
|
1121
|
+
// Undecidable now. The decision stays where it is, for a retry to settle.
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
"The copy was made as a draft, but whether it is listed like the original could not be settled" +
|
|
1124
|
+
(error ? `: ${error.message}` : "") +
|
|
1125
|
+
". Retrying the same duplication (same idempotency key) completes it without a second copy; " +
|
|
1126
|
+
"otherwise check the copy's listing before publishing it.",
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
if (recorded < created && intent.listing === "link-only") {
|
|
1130
|
+
const { error: writeError } = await context.supabase
|
|
1131
|
+
.from("site_settings")
|
|
1132
|
+
.upsert(
|
|
1133
|
+
{
|
|
1134
|
+
key: `${PAGE_LISTING_PREFIX}${copyId.toLowerCase()}`,
|
|
1135
|
+
value: { listing: "link-only" },
|
|
1136
|
+
updated_by: context.userId,
|
|
1137
|
+
},
|
|
1138
|
+
{ onConflict: "key", ignoreDuplicates: true },
|
|
1139
|
+
)
|
|
1140
|
+
.select("updated_at");
|
|
1141
|
+
if (writeError) {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
`The copy was made as a draft, but it could not be marked link-only like the original: ${writeError.message}. ` +
|
|
1144
|
+
"Retrying the same duplication (same idempotency key) completes it without a second copy; " +
|
|
1145
|
+
"otherwise set the copy to link-only before publishing it.",
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
invalidatePublishedCache();
|
|
1149
|
+
}
|
|
1150
|
+
await forgetDuplicateIntent(context, intent);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Remove the decision row once it has been dealt with. Nothing depends on this
|
|
1155
|
+
* succeeding, so a failure is not an error: an original decision left behind
|
|
1156
|
+
* only ever inserts what is already there, and a row written after the copy
|
|
1157
|
+
* existed is never applied. Whatever row holds the key by now was written
|
|
1158
|
+
* after the copy existed, so removing it loses no pending decision.
|
|
1159
|
+
*/
|
|
1160
|
+
async function forgetDuplicateIntent(context: PagesContext, intent: DuplicateIntent): Promise<void> {
|
|
1161
|
+
await context.supabase.from("site_settings").delete().eq("key", intent.key);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* A `timestamptz` as PostgREST returns it (`2026-10-16T11:03:36.123456+00:00`),
|
|
1166
|
+
* in whole microseconds since the epoch, so two instants in the same
|
|
1167
|
+
* millisecond still compare correctly. Null when it is not one.
|
|
1168
|
+
*/
|
|
1169
|
+
function timestampMicros(value: unknown): number | null {
|
|
1170
|
+
if (typeof value !== "string") return null;
|
|
1171
|
+
const match = /^(\d{4}-\d\d-\d\d)[T ](\d\d:\d\d:\d\d)(?:\.(\d{1,6})\d*)?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(
|
|
1172
|
+
value.trim(),
|
|
1173
|
+
);
|
|
1174
|
+
if (!match) return null;
|
|
1175
|
+
const offset = match[4]!;
|
|
1176
|
+
const zone =
|
|
1177
|
+
offset === "Z" ? "Z" : offset.length === 3 ? `${offset}:00` : offset.replace(/^([+-]\d\d)(\d\d)$/, "$1:$2");
|
|
1178
|
+
const seconds = Date.parse(`${match[1]}T${match[2]}${zone}`);
|
|
1179
|
+
if (!Number.isFinite(seconds)) return null;
|
|
1180
|
+
return seconds * 1000 + Number((match[3] ?? "").padEnd(6, "0"));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
959
1183
|
/* ---------- Global blocks ---------- */
|
|
960
1184
|
|
|
961
1185
|
/** A global block holds exactly one block and may not reference other globals. */
|
|
@@ -1236,7 +1460,7 @@ function scheduleInstant(value: unknown, field: string): number {
|
|
|
1236
1460
|
* and the page it promised to reveal stayed hidden for good.
|
|
1237
1461
|
*/
|
|
1238
1462
|
export async function schedulePageVersionCore(
|
|
1239
|
-
{ context }: PagesDeps,
|
|
1463
|
+
{ context, protectedPageKeys }: PagesDeps,
|
|
1240
1464
|
input: {
|
|
1241
1465
|
versionId: string;
|
|
1242
1466
|
publishAt: string;
|
|
@@ -1271,6 +1495,11 @@ export async function schedulePageVersionCore(
|
|
|
1271
1495
|
resourceId: v.page_id,
|
|
1272
1496
|
sessionId: input.editSessionId,
|
|
1273
1497
|
});
|
|
1498
|
+
// An end date is an unpublish on a timer, which a protected page refuses too.
|
|
1499
|
+
if (unpublishAt !== null) {
|
|
1500
|
+
const identity = await pageIdentity(context.supabase, v.page_id);
|
|
1501
|
+
if (identity) assertNotProtected(protectedPageKeys, identity, "given an unpublish time");
|
|
1502
|
+
}
|
|
1274
1503
|
invalidatePublishedCache();
|
|
1275
1504
|
const scheduled = await publicationRpc(
|
|
1276
1505
|
context.supabase,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* tests. A cold process simply has an empty cache.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
export type PublishedCacheScope = "page" | "global" | "custom";
|
|
16
|
+
export type PublishedCacheScope = "page" | "global" | "policy" | "custom";
|
|
17
17
|
|
|
18
18
|
type Entry = { value: unknown; expiresAt: number };
|
|
19
19
|
|
|
@@ -22,6 +22,16 @@ const store = new Map<string, Entry>();
|
|
|
22
22
|
/** How long an entry may be served before it is re-read. */
|
|
23
23
|
let ttlMs = 20_000;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Bumped by every invalidation. A load records the generation it started in
|
|
27
|
+
* and may only store its answer if no invalidation happened meanwhile: a read
|
|
28
|
+
* that began before a write and finished after it holds the old state, and
|
|
29
|
+
* putting that back into the cache would undo the invalidation the write just
|
|
30
|
+
* made — every request after the write would be served what the write
|
|
31
|
+
* replaced.
|
|
32
|
+
*/
|
|
33
|
+
let generation = 0;
|
|
34
|
+
|
|
25
35
|
/** Cheap counters so the backoffice can show whether the cache is working. */
|
|
26
36
|
let hits = 0;
|
|
27
37
|
let misses = 0;
|
|
@@ -83,6 +93,10 @@ export function publishedCacheSet(
|
|
|
83
93
|
*/
|
|
84
94
|
export function invalidatePublishedCache(scope?: PublishedCacheScope, key?: string): void {
|
|
85
95
|
invalidations += 1;
|
|
96
|
+
// Any invalidation, however narrow, retires every load already in flight:
|
|
97
|
+
// it may only cost a fill that was going to be correct, never let a stale
|
|
98
|
+
// one through.
|
|
99
|
+
generation += 1;
|
|
86
100
|
if (!scope) {
|
|
87
101
|
store.clear();
|
|
88
102
|
return;
|
|
@@ -116,9 +130,14 @@ export async function withPublishedCache<T>(
|
|
|
116
130
|
return cached;
|
|
117
131
|
}
|
|
118
132
|
misses += 1;
|
|
133
|
+
const startedIn = generation;
|
|
119
134
|
const value = await load();
|
|
120
|
-
// `undefined` is indistinguishable from "absent", so it is never stored.
|
|
121
|
-
|
|
135
|
+
// `undefined` is indistinguishable from "absent", so it is never stored. A
|
|
136
|
+
// value whose read began before an invalidation is returned to its caller —
|
|
137
|
+
// it is what that caller asked for — but never stored for anyone else.
|
|
138
|
+
if (value !== undefined && startedIn === generation) {
|
|
139
|
+
publishedCacheSet(scope, key, value, boundaryOf?.(value));
|
|
140
|
+
}
|
|
122
141
|
return value;
|
|
123
142
|
}
|
|
124
143
|
|
|
@@ -136,6 +155,7 @@ export function publishedCacheStats(): {
|
|
|
136
155
|
/** Resets counters and contents. Tests use this between cases. */
|
|
137
156
|
export function resetPublishedCache(): void {
|
|
138
157
|
store.clear();
|
|
158
|
+
generation += 1;
|
|
139
159
|
hits = 0;
|
|
140
160
|
misses = 0;
|
|
141
161
|
invalidations = 0;
|
package/src/server/schemas.ts
CHANGED
|
@@ -131,6 +131,23 @@ export const pageSchemas = {
|
|
|
131
131
|
status: z.enum(["draft", "published"]),
|
|
132
132
|
editSessionId: editSessionId.optional(),
|
|
133
133
|
}),
|
|
134
|
+
/** Listing only: never publishes, unpublishes or reschedules. */
|
|
135
|
+
setPageListing: z.object({
|
|
136
|
+
pageId: uuid,
|
|
137
|
+
listing: z.enum(["listed", "link-only"]),
|
|
138
|
+
/** The version the editor was shown; null when nothing was stored yet. */
|
|
139
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
140
|
+
editSessionId: editSessionId.optional(),
|
|
141
|
+
}),
|
|
142
|
+
setBookingEnabled: z.object({
|
|
143
|
+
enabled: z.boolean(),
|
|
144
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
145
|
+
}),
|
|
146
|
+
setBookingException: z.object({
|
|
147
|
+
pageId: uuid,
|
|
148
|
+
exception: z.boolean(),
|
|
149
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
150
|
+
}),
|
|
134
151
|
duplicatePage: z.object({
|
|
135
152
|
pageId: uuid,
|
|
136
153
|
name: z.string().min(1).max(120),
|
|
@@ -177,14 +177,24 @@ export function createPageQueryOptions(
|
|
|
177
177
|
fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
|
|
178
178
|
defaultStaleTimeMs: number = PAGE_STALE_TIME_MS,
|
|
179
179
|
) {
|
|
180
|
-
|
|
180
|
+
/*
|
|
181
|
+
* `variant` partitions the cache by whatever the site's public policy says
|
|
182
|
+
* about the page (booking shown or not, say). The server stays the
|
|
183
|
+
* authority on what it returns; the partition only stops an answer fetched
|
|
184
|
+
* under one policy being reused under another, so switching booking back
|
|
185
|
+
* on is not answered from a copy fetched while it was off.
|
|
186
|
+
*/
|
|
187
|
+
return (key: string, variant?: string) =>
|
|
181
188
|
queryOptions({
|
|
182
|
-
queryKey: ["cms", "page", key],
|
|
189
|
+
queryKey: variant ? ["cms", "page", key, variant] : ["cms", "page", key],
|
|
183
190
|
queryFn: () => fetchPublishedPage(key),
|
|
184
191
|
staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
|
|
185
192
|
});
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
/** A rendering-time view of a fetched page, e.g. with links the policy forbids removed. */
|
|
196
|
+
export type PageTransform = (page: RenderedPage) => RenderedPage;
|
|
197
|
+
|
|
188
198
|
/**
|
|
189
199
|
* Build the published-page renderer for a site.
|
|
190
200
|
* `fetchPublishedPage` is the site's `getPublishedPage` server function.
|
|
@@ -195,9 +205,28 @@ export function createPageRenderer(
|
|
|
195
205
|
) {
|
|
196
206
|
const pageQueryOptions = createPageQueryOptions(fetchPublishedPage);
|
|
197
207
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
/*
|
|
209
|
+
* `transform` is applied to whatever the cache holds, on every render: a
|
|
210
|
+
* page cached before the policy changed, or one still being refetched in
|
|
211
|
+
* the background, is shown as the current policy says, never as it was
|
|
212
|
+
* fetched. The cached original is not changed, so a later policy can show
|
|
213
|
+
* it again. Pass a stable function, or none.
|
|
214
|
+
*/
|
|
215
|
+
function PageRenderer({
|
|
216
|
+
pageKey,
|
|
217
|
+
variant,
|
|
218
|
+
transform,
|
|
219
|
+
}: {
|
|
220
|
+
pageKey: string;
|
|
221
|
+
variant?: string;
|
|
222
|
+
transform?: PageTransform;
|
|
223
|
+
}) {
|
|
224
|
+
const { data } = useSuspenseQuery(pageQueryOptions(pageKey, variant));
|
|
225
|
+
const fetched = data as RenderedPage | null;
|
|
226
|
+
const page = useMemo(
|
|
227
|
+
() => (fetched && transform ? transform(fetched) : fetched),
|
|
228
|
+
[fetched, transform],
|
|
229
|
+
);
|
|
201
230
|
if (!page) return null;
|
|
202
231
|
return (
|
|
203
232
|
<DraftPageRendererView blocks={page.blocks} globals={page.globals} registry={registry} />
|
package/src/site/SiteFooter.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import type { SiteTypography } from "../config/typography";
|
|
|
12
12
|
import { useT } from "../i18n";
|
|
13
13
|
import type { LocalizedString } from "../i18n/types";
|
|
14
14
|
import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
|
|
15
|
+
import { filterFooterColumnsForPolicy } from "../navigation/visibility";
|
|
16
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
15
17
|
|
|
16
18
|
export type SiteFooterCompany = {
|
|
17
19
|
legalName: string;
|
|
@@ -81,12 +83,19 @@ export function SiteFooter({
|
|
|
81
83
|
// boundary rather than on the next full page load.
|
|
82
84
|
const now = useScheduleClock(footerSchedules(columns));
|
|
83
85
|
const footerFont = slotFont(typography, "footer");
|
|
84
|
-
const
|
|
86
|
+
const linkPolicy = useLinkVisibility();
|
|
87
|
+
// Scheduled-out links first, then links the site must not offer right now
|
|
88
|
+
// (an offline or link-only page, booking while it is off). A column left with
|
|
89
|
+
// nothing to link to is not rendered as an empty heading.
|
|
90
|
+
const scheduled = columns
|
|
85
91
|
.filter((column) => isVisibleAt(column.schedule, now))
|
|
86
92
|
.map((column) => ({
|
|
87
93
|
...column,
|
|
88
|
-
links: (column.links ?? []).filter(
|
|
94
|
+
links: (column.links ?? []).filter(
|
|
95
|
+
(link) => isVisibleAt(link.schedule, now) && (link.href ?? "").trim() !== "",
|
|
96
|
+
),
|
|
89
97
|
}));
|
|
98
|
+
const visibleColumns = filterFooterColumnsForPolicy(scheduled, linkPolicy);
|
|
90
99
|
|
|
91
100
|
const jsonLd = {
|
|
92
101
|
"@context": "https://schema.org",
|
package/src/site/SiteHeader.tsx
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
type NavigationItem,
|
|
22
22
|
} from "../navigation";
|
|
23
23
|
import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
|
|
24
|
+
import { filterNavigationForPolicy, linkHideReason } from "../navigation/visibility";
|
|
25
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
24
26
|
|
|
25
27
|
export type SiteHeaderProps = {
|
|
26
28
|
/**
|
|
@@ -39,8 +41,13 @@ export type SiteHeaderProps = {
|
|
|
39
41
|
logoAlt?: string;
|
|
40
42
|
/** Set false when the logo artwork is already dark. */
|
|
41
43
|
invertLogoOnLightSurface?: boolean;
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Primary call to action shown on desktop and at the end of the mobile panel.
|
|
46
|
+
* `null` shows none. With `booking: true` it is a booking action, hidden
|
|
47
|
+
* while the site's booking is switched off; either way it is hidden when the
|
|
48
|
+
* page it leads to is offline or link-only (see `LinkVisibilityProvider`).
|
|
49
|
+
*/
|
|
50
|
+
primaryCta: { href: string; label: LocalizedString; booking?: boolean } | null;
|
|
44
51
|
/** Mobile menu toggle labels. */
|
|
45
52
|
menuLabels: { open: LocalizedString; close: LocalizedString };
|
|
46
53
|
/** Optional backoffice shortcut, rendered only when `signedIn` is true. */
|
|
@@ -252,8 +259,17 @@ export function SiteHeader({
|
|
|
252
259
|
// A page left open past a scheduled publish/hide time re-renders on the
|
|
253
260
|
// boundary instead of showing what was true when it loaded.
|
|
254
261
|
const now = useScheduleClock(navigationSchedules(nav));
|
|
262
|
+
// Links to offline or link-only pages, and booking links while booking is
|
|
263
|
+
// off, are left out here; the saved menu itself is untouched.
|
|
264
|
+
const linkPolicy = useLinkVisibility();
|
|
255
265
|
// Half-finished entries are the editor's business, not a visitor's.
|
|
256
|
-
const items = renderableNavigation(nav, locale, now);
|
|
266
|
+
const items = renderableNavigation(filterNavigationForPolicy(nav, linkPolicy), locale, now);
|
|
267
|
+
const cta =
|
|
268
|
+
primaryCta &&
|
|
269
|
+
!(primaryCta.booking && !linkPolicy.bookingVisible) &&
|
|
270
|
+
linkHideReason(primaryCta.href, linkPolicy) === null
|
|
271
|
+
? primaryCta
|
|
272
|
+
: null;
|
|
257
273
|
const menuFont = slotFont(typography, "menu");
|
|
258
274
|
|
|
259
275
|
return (
|
|
@@ -297,17 +313,19 @@ export function SiteHeader({
|
|
|
297
313
|
{backoffice.label}
|
|
298
314
|
</Link>
|
|
299
315
|
)}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
{cta ? (
|
|
317
|
+
<Link
|
|
318
|
+
to={cta.href}
|
|
319
|
+
className={[
|
|
320
|
+
"inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
|
|
321
|
+
!lightText
|
|
322
|
+
? "border-foreground text-foreground hover:bg-foreground hover:text-background"
|
|
323
|
+
: "border-paper text-paper hover:bg-paper hover:text-ink",
|
|
324
|
+
].join(" ")}
|
|
325
|
+
>
|
|
326
|
+
{t(cta.label)}
|
|
327
|
+
</Link>
|
|
328
|
+
) : null}
|
|
311
329
|
</div>
|
|
312
330
|
|
|
313
331
|
<button
|
|
@@ -395,13 +413,15 @@ export function SiteHeader({
|
|
|
395
413
|
{backoffice.label}
|
|
396
414
|
</Link>
|
|
397
415
|
)}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
416
|
+
{cta ? (
|
|
417
|
+
<Link
|
|
418
|
+
to={cta.href}
|
|
419
|
+
className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
|
|
420
|
+
onClick={() => setOpen(false)}
|
|
421
|
+
>
|
|
422
|
+
{t(cta.label)}
|
|
423
|
+
</Link>
|
|
424
|
+
) : null}
|
|
405
425
|
</nav>
|
|
406
426
|
</div>
|
|
407
427
|
)}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public link policy, made available to the site chrome.
|
|
3
|
+
*
|
|
4
|
+
* A site resolves the policy on the server (which pages are offline or
|
|
5
|
+
* link-only, whether booking is on) and provides it here; the shared header and
|
|
6
|
+
* footer read it to leave out links a visitor must not be offered. Without a
|
|
7
|
+
* provider nothing is filtered, which is what the backoffice and any screen
|
|
8
|
+
* that renders the chrome outside the public site get.
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
import { OPEN_LINK_POLICY, type LinkVisibilityPolicy } from "../navigation/visibility";
|
|
13
|
+
|
|
14
|
+
const LinkVisibilityContext = createContext<LinkVisibilityPolicy>(OPEN_LINK_POLICY);
|
|
15
|
+
|
|
16
|
+
export function LinkVisibilityProvider({
|
|
17
|
+
policy,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
policy: LinkVisibilityPolicy;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return <LinkVisibilityContext.Provider value={policy}>{children}</LinkVisibilityContext.Provider>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useLinkVisibility(): LinkVisibilityPolicy {
|
|
27
|
+
return useContext(LinkVisibilityContext);
|
|
28
|
+
}
|