@bygghammar/cms-platform 0.93.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +9 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/admin/module-inventory.ts +67 -0
  6. package/src/admin/registry-metadata.ts +256 -0
  7. package/src/admin/semver.ts +111 -0
  8. package/src/admin/version-core.ts +233 -25
  9. package/src/admin/version-report.ts +295 -0
  10. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  11. package/src/backoffice/NavigationView.tsx +38 -0
  12. package/src/backoffice/PageBuilderView.tsx +149 -3
  13. package/src/backoffice/PagesListView.tsx +173 -57
  14. package/src/backoffice/SiteSettingsView.tsx +4 -0
  15. package/src/backoffice/StatusBadge.tsx +38 -2
  16. package/src/backoffice/VersionView.tsx +747 -66
  17. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  18. package/src/backoffice/routes/host.ts +24 -0
  19. package/src/backoffice/routes/index.tsx +148 -9
  20. package/src/backoffice/routes/pages.tsx +13 -0
  21. package/src/backoffice/shell/use-resolved-config.tsx +30 -5
  22. package/src/cms/booking-links.ts +176 -0
  23. package/src/cms/page-publication.ts +11 -6
  24. package/src/cms/page-visibility.ts +206 -0
  25. package/src/config/modules.ts +11 -0
  26. package/src/html/sanitize-html.ts +1 -1
  27. package/src/navigation/visibility.ts +235 -0
  28. package/src/popups/package-name.ts +8 -0
  29. package/src/popups/runtime.tsx +155 -4
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/published-cache.ts +23 -3
  33. package/src/server/schemas.ts +17 -0
  34. package/src/site/PageRenderer.tsx +34 -5
  35. package/src/site/SiteFooter.tsx +11 -2
  36. package/src/site/SiteHeader.tsx +41 -21
  37. package/src/site/link-visibility.tsx +28 -0
  38. package/src/site/route-aliases.ts +74 -0
  39. package/src/site/routes/preview-page.tsx +39 -3
  40. package/src/site/routes/public-page.tsx +81 -9
@@ -13,6 +13,7 @@ export function ConfirmDialog({
13
13
  title,
14
14
  description,
15
15
  details,
16
+ consequences,
16
17
  confirmLabel = "Confirm",
17
18
  cancelLabel = "Cancel",
18
19
  danger = false,
@@ -23,7 +24,13 @@ export function ConfirmDialog({
23
24
  }: {
24
25
  title: string;
25
26
  description?: string;
27
+ /** Short items (names, paths), one line each; long ones are truncated with a tooltip. */
26
28
  details?: string[];
29
+ /**
30
+ * What confirming will do, in full sentences. Never truncated, and read out
31
+ * with the description, because a warning cut off at "…" is not a warning.
32
+ */
33
+ consequences?: string[];
27
34
  confirmLabel?: string;
28
35
  cancelLabel?: string;
29
36
  danger?: boolean;
@@ -82,7 +89,11 @@ export function ConfirmDialog({
82
89
  role="dialog"
83
90
  aria-modal="true"
84
91
  aria-labelledby={`${id}-title`}
85
- aria-describedby={description ? `${id}-description` : undefined}
92
+ aria-describedby={
93
+ [description ? `${id}-description` : "", consequences?.length ? `${id}-consequences` : ""]
94
+ .filter(Boolean)
95
+ .join(" ") || undefined
96
+ }
86
97
  className="w-full max-w-md border border-ink/15 bg-white"
87
98
  onClick={(e) => e.stopPropagation()}
88
99
  >
@@ -98,6 +109,13 @@ export function ConfirmDialog({
98
109
  </p>
99
110
  ) : null}
100
111
  {children}
112
+ {consequences?.length ? (
113
+ <ul id={`${id}-consequences`} className="list-disc space-y-1.5 pl-5 text-sm text-ink/80">
114
+ {consequences.map((line) => (
115
+ <li key={line}>{line}</li>
116
+ ))}
117
+ </ul>
118
+ ) : null}
101
119
  {details?.length ? (
102
120
  <ul className="max-h-40 space-y-1 overflow-auto border border-ink/10 bg-ice/30 p-2 text-[0.72rem] text-ink/70">
103
121
  {details.map((d) => (
@@ -10,6 +10,7 @@ import type { ReactNode } from "react";
10
10
 
11
11
  import type { SiteConfig } from "../../config/site-config";
12
12
  import type { SecretsLocation } from "../../server/provider-secrets";
13
+ import type { LinkVisibilityPolicy } from "../../navigation/visibility";
13
14
 
14
15
  /* eslint-disable @typescript-eslint/no-explicit-any */
15
16
  /** Media field component as the reviews editor consumes it. */
@@ -233,6 +234,11 @@ export type BackofficeHost = {
233
234
  */
234
235
  setPageStatus?: HostServerFn;
235
236
  duplicatePage?: HostServerFn;
237
+ /**
238
+ * Optional: make a page link-only or list it again (listing only, never a
239
+ * publish). Without it neither screen offers the action.
240
+ */
241
+ setPageListing?: HostServerFn;
236
242
  /**
237
243
  * The site's pages, listed.
238
244
  *
@@ -303,6 +309,24 @@ export type BackofficeHost = {
303
309
  translateTarget?: string;
304
310
  translate?: HostServerFn;
305
311
  };
312
+ /**
313
+ * Site settings → Booking: the site-wide switch for booking links and the
314
+ * pages that keep theirs while it is off. Optional: without it the card is
315
+ * not shown.
316
+ */
317
+ booking?: {
318
+ fns: {
319
+ getBookingSettings: HostServerFn;
320
+ setBookingEnabled: HostServerFn;
321
+ setBookingException: HostServerFn;
322
+ };
323
+ };
324
+ /**
325
+ * The public link policy as the site currently applies it, so the navigation
326
+ * editor can say why a saved item is not shown. A hook, so a site can read it
327
+ * however it serves it. Optional: without it the editor explains nothing.
328
+ */
329
+ useLinkVisibility?: () => LinkVisibilityPolicy | undefined;
306
330
  /** Browser auth adapters used by the password-reset screen. */
307
331
  authAdapters?: {
308
332
  /** Subscribes to the recovery session; returns an unsubscribe function. */
@@ -26,7 +26,7 @@ import {
26
26
  Users,
27
27
  } from "lucide-react";
28
28
  import { useQuery, useQueryClient } from "@tanstack/react-query";
29
- import { useCallback, useRef } from "react";
29
+ import { useCallback, useRef, useState } from "react";
30
30
  import { useServerFn } from "@tanstack/react-start";
31
31
 
32
32
  import { MISSING_CREATED_ID } from "../../tracking/injections";
@@ -37,6 +37,7 @@ import { BackupView } from "../BackupView";
37
37
  import { DomainsView } from "../DomainsView";
38
38
  import { EmailSettingsView } from "../EmailSettingsView";
39
39
  import { NavigationView } from "../NavigationView";
40
+ import { BookingSettingsCard, type BookingSettingsData } from "../BookingSettingsCard";
40
41
  import { ReviewsView, type ReviewsSection } from "../ReviewsView";
41
42
  import { ContactDetailsView } from "../ContactDetailsView";
42
43
  import { EmailLogsView } from "../EmailLogsView";
@@ -48,13 +49,16 @@ import { SitemapView, type SitemapCheck } from "../SitemapView";
48
49
  import { SiteSettingsView } from "../SiteSettingsView";
49
50
  import { TrackingView, type TrackingDeps } from "../TrackingView";
50
51
  import { UsersView } from "../UsersView";
51
- import { VersionView } from "../VersionView";
52
+ import { VersionView, type PlatformBuildInfo, type PlatformRelease } from "../VersionView";
53
+ import { moduleInventory, type ModuleSettingsRead } from "../../admin/module-inventory";
54
+ import type { VersionReport } from "../../admin/version-report";
52
55
  import { parseEffectiveAdminHost } from "../../site/locale-domains";
53
56
  import {
54
57
  useFeatureEnabled,
55
58
  useFeatureGate,
56
59
  useModuleGate,
57
60
  useMyRoles,
61
+ useResolvedSiteConfig,
58
62
  } from "../shell/use-resolved-config";
59
63
  import { backofficeHost, backofficePaths } from "./host";
60
64
 
@@ -127,14 +131,143 @@ export function UsersRoute() {
127
131
  );
128
132
  }
129
133
 
130
- /** CMS build and platform version screen. */
134
+ type SystemVersionPayload = {
135
+ build?: PlatformBuildInfo;
136
+ releases?: PlatformRelease[];
137
+ /** Present from hosts that build it with `buildVersionReport`. */
138
+ report?: VersionReport;
139
+ };
140
+
141
+ const SYSTEM_VERSION_QUERY_KEY = ["admin", "system-version"] as const;
142
+
143
+ /**
144
+ * Release, package and module screen.
145
+ *
146
+ * Read-only: it shows the module switches, it never changes them. The version
147
+ * read is not polled — no refetch on focus, a five-minute stale time — and a
148
+ * manual check goes through the server, which applies its own minimum
149
+ * interval before asking the registry again.
150
+ */
131
151
  export function VersionRoute() {
132
- const { fns } = backofficeHost();
133
- const fetchVersion = useServerFn(fns.getSystemVersion);
134
- const q = useQuery({ queryKey: ["admin", "system-version"], queryFn: () => fetchVersion() });
152
+ const host = backofficeHost();
153
+ const paths = backofficePaths(host);
154
+ const fetchVersion = useServerFn(host.fns.getSystemVersion);
155
+ const queryClient = useQueryClient();
156
+ const q = useQuery({
157
+ queryKey: SYSTEM_VERSION_QUERY_KEY,
158
+ queryFn: () => fetchVersion({ data: { refresh: false } }) as Promise<SystemVersionPayload>,
159
+ staleTime: 5 * 60_000,
160
+ refetchOnWindowFocus: false,
161
+ retry: 1,
162
+ });
163
+ const refreshing = useRef(false);
164
+ const [isRefreshing, setRefreshing] = useState(false);
165
+ // The `dataUpdatedAt` of the answer that was on screen when a "Check again"
166
+ // failed. The failure notice shows while that same answer is still shown, so
167
+ // any later successful answer (a retry or a re-read) clears it.
168
+ const [refreshFailedFor, setRefreshFailedFor] = useState<number | null>(null);
169
+ const refresh = useCallback(async () => {
170
+ if (refreshing.current) return;
171
+ refreshing.current = true;
172
+ setRefreshing(true);
173
+ try {
174
+ const next = (await fetchVersion({ data: { refresh: true } })) as SystemVersionPayload;
175
+ queryClient.setQueryData(SYSTEM_VERSION_QUERY_KEY, next);
176
+ setRefreshFailedFor(null);
177
+ } catch {
178
+ // Keep the last answer on screen, but say the check failed. The error
179
+ // itself (an expired session, a network failure) is not shown.
180
+ setRefreshFailedFor(queryClient.getQueryState(SYSTEM_VERSION_QUERY_KEY)?.dataUpdatedAt ?? 0);
181
+ } finally {
182
+ refreshing.current = false;
183
+ setRefreshing(false);
184
+ }
185
+ }, [fetchVersion, queryClient]);
186
+
187
+ const settings = useResolvedSiteConfig();
188
+ const settingsRead: ModuleSettingsRead =
189
+ settings.status === "loading"
190
+ ? { state: "loading" }
191
+ : settings.status === "error"
192
+ ? { state: "error" }
193
+ : { state: "ready", features: settings.config.features, stale: settings.status === "stale" };
194
+ const report = q.data?.report && q.data.report.schemaVersion === 1 ? q.data.report : null;
195
+ const cmsVersion =
196
+ report?.packages.find((p) => p.role === "cms")?.installed ?? q.data?.build?.platformVersion ?? null;
197
+ // The latest request failed while an earlier answer is still on screen:
198
+ // a failed "Check again", or a failed re-read of cached data.
199
+ const latestCheckFailed =
200
+ q.data !== undefined &&
201
+ ((refreshFailedFor !== null && refreshFailedFor === q.dataUpdatedAt) || q.isError);
202
+
135
203
  return (
136
204
  <VersionView
137
- deps={{ build: q.data?.build, releases: q.data?.releases ?? [], loading: q.isLoading }}
205
+ deps={{
206
+ build: q.data?.build,
207
+ releases: q.data?.releases ?? [],
208
+ loading: q.isLoading,
209
+ error: q.isError && !q.data,
210
+ latestCheckFailed,
211
+ onRetry: () => void q.refetch(),
212
+ report,
213
+ modules: moduleInventory(settingsRead, report?.packages, q.isLoading ? null : cmsVersion),
214
+ onRefresh: report ? () => void refresh() : undefined,
215
+ refreshing: isRefreshing,
216
+ siteName: host.siteConfig.name,
217
+ settingsHref: paths.settings,
218
+ renderLink: ({ href, className, children }) => (
219
+ <Link to={href} className={className}>
220
+ {children}
221
+ </Link>
222
+ ),
223
+ }}
224
+ />
225
+ );
226
+ }
227
+
228
+ /** Stand-in for unregistered optional functions; the queries using it stay disabled. */
229
+ const notRegistered = async () => {
230
+ throw new Error("This site has not registered that backoffice function.");
231
+ };
232
+
233
+ /** Site settings → Booking, when the host registered the booking functions. */
234
+ function BookingSettingsSection() {
235
+ const host = backofficeHost();
236
+ const booking = host.booking;
237
+ const qc = useQueryClient();
238
+ const load = useServerFn(booking?.fns.getBookingSettings ?? notRegistered);
239
+ const setEnabled = useServerFn(booking?.fns.setBookingEnabled ?? notRegistered);
240
+ const setException = useServerFn(booking?.fns.setBookingException ?? notRegistered);
241
+ const listPages = useServerFn(host.pages?.fns.listPages ?? notRegistered);
242
+ const q = useQuery({
243
+ queryKey: ["admin", "booking-settings"],
244
+ queryFn: () => load(),
245
+ enabled: Boolean(booking),
246
+ });
247
+ const pagesQ = useQuery({
248
+ queryKey: ["cms", "pages-list"],
249
+ queryFn: () => listPages(),
250
+ enabled: Boolean(booking && host.pages?.fns.listPages),
251
+ });
252
+ const resolveUrl = host.pages?.usePageUrl?.();
253
+ if (!booking) return null;
254
+ return (
255
+ <BookingSettingsCard
256
+ deps={{
257
+ settings: q.data as BookingSettingsData | undefined,
258
+ error: (q.error as Error | null) ?? null,
259
+ pages: (pagesQ.data as Array<{ id: string; key: string; name: string }> | undefined)?.map(
260
+ (page) => ({ id: page.id, key: page.key, name: page.name }),
261
+ ),
262
+ pathFor: (key) =>
263
+ resolveUrl ? resolveUrl(key, host.siteConfig.defaultLocale).path : `/${key}`,
264
+ setEnabled: (input) => setEnabled({ data: input } as never),
265
+ setException: (input) => setException({ data: input } as never),
266
+ refresh: () => {
267
+ void q.refetch();
268
+ void qc.invalidateQueries({ queryKey: ["admin", "link-visibility"] });
269
+ },
270
+ }}
138
271
  />
139
272
  );
140
273
  }
@@ -174,6 +307,7 @@ export function SiteSettingsRoute() {
174
307
  window.location.href = contactPath;
175
308
  },
176
309
  MediaField: host.components.MediaField,
310
+ bookingSection: <BookingSettingsSection />,
177
311
  }}
178
312
  />
179
313
  );
@@ -466,8 +600,8 @@ export function AdminOverviewRoute() {
466
600
  },
467
601
  {
468
602
  to: paths.version,
469
- title: "CMS & platform version",
470
- body: "Current CMS build, shared platform package version and release history with dates.",
603
+ title: "Version & modules",
604
+ body: "The live site release, installed CMS and module packages, module status and available updates.",
471
605
  icon: Package,
472
606
  },
473
607
  ...(isSuper && host.siteConfig.features.backup
@@ -630,10 +764,12 @@ export function NavigationRoute() {
630
764
  queryFn: () => loadSettings(),
631
765
  });
632
766
  const settingOverrides = (settingsQ.data?.overrides ?? {}) as SiteSettingOverrides;
767
+ const linkPolicy = (host.useLinkVisibility ?? noLinkVisibility)();
633
768
 
634
769
  return (
635
770
  <NavigationView
636
771
  deps={{
772
+ linkPolicy,
637
773
  overrides,
638
774
  site,
639
775
  defaults: host.content.defaults,
@@ -664,6 +800,9 @@ export function NavigationRoute() {
664
800
  */
665
801
  const noEffectiveAdminHost = async () => null;
666
802
 
803
+ /** A host without a link policy: the navigation editor explains nothing. */
804
+ const noLinkVisibility = () => undefined;
805
+
667
806
  /** Per-locale domain mapping. */
668
807
  function DomainsRouteInner() {
669
808
  const host = backofficeHost();
@@ -21,6 +21,11 @@ import { useResolvedSiteConfig } from "../shell/use-resolved-config";
21
21
  import { ResetPasswordView } from "../ResetPasswordView";
22
22
  import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
23
23
 
24
+ /** Stand-in for unregistered optional functions; never called (the action is not offered). */
25
+ const notRegistered = async () => {
26
+ throw new Error("This site has not registered that page function.");
27
+ };
28
+
24
29
  const notify = {
25
30
  success: (m: string) => toast.success(m),
26
31
  error: (m: string) => toast.error(m),
@@ -47,6 +52,8 @@ export function PageBuilderRoute() {
47
52
  const listPubs = useServerFn(pages.fns.listPagePublications);
48
53
  const cancelPub = useServerFn(pages.fns.cancelPagePublication);
49
54
  const rename = useServerFn(pages.fns.renamePage);
55
+ const setStatus = useServerFn(pages.fns.setPageStatus ?? notRegistered);
56
+ const setListing = useServerFn(pages.fns.setPageListing ?? notRegistered);
50
57
 
51
58
  const pageQ = useQuery({
52
59
  queryKey: ["cms", "page-edit", key],
@@ -127,6 +134,12 @@ export function PageBuilderRoute() {
127
134
  restoreVersion: (versionId, editSessionId) =>
128
135
  restore({ data: { versionId, editSessionId } }),
129
136
  rename: (input) => rename({ data: input }),
137
+ setPageStatus: pages.fns.setPageStatus
138
+ ? (input) => setStatus({ data: input } as never)
139
+ : undefined,
140
+ setPageListing: pages.fns.setPageListing
141
+ ? (input) => setListing({ data: input } as never)
142
+ : undefined,
130
143
  refetchPage: () => void pageQ.refetch(),
131
144
  refetchVersions: () => void versionsQ.refetch(),
132
145
  refetchPublications: () => void pubsQ.refetch(),
@@ -21,11 +21,27 @@ import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from
21
21
  /** Query key shared with the site-settings screen. */
22
22
  export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
23
23
 
24
+ /**
25
+ * Where the resolved configuration came from:
26
+ *
27
+ * - `loading`: no answer yet; `config` is the compiled code config.
28
+ * - `error`: the read failed (or answered without settings) and no earlier
29
+ * answer is held; `config` is the compiled code config, not the live one.
30
+ * - `ready`: `config` includes the saved overrides.
31
+ * - `stale`: a re-read failed; `config` is from the last successful read.
32
+ */
33
+ export type SiteSettingsStatus = "loading" | "error" | "ready" | "stale";
34
+
24
35
  /**
25
36
  * Code config merged with the stored overrides. While loading, the code config
26
- * is returned so the shell renders without a flash of empty navigation.
37
+ * is returned so the shell renders without a flash of empty navigation; a
38
+ * caller that must not present that fallback as the live answer reads `status`.
27
39
  */
28
- export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolean } {
40
+ export function useResolvedSiteConfig(): {
41
+ config: SiteConfig;
42
+ isLoading: boolean;
43
+ status: SiteSettingsStatus;
44
+ } {
29
45
  const host = backofficeHost();
30
46
  const load = useServerFn(host.fns.getSiteSettings);
31
47
  const q = useQuery({
@@ -34,13 +50,22 @@ export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolea
34
50
  staleTime: 30_000,
35
51
  });
36
52
 
37
- const resolved =
53
+ const fromSettings =
38
54
  (q.data?.config as SiteConfig | undefined) ??
39
55
  (q.data?.overrides
40
56
  ? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
41
- : host.siteConfig);
57
+ : undefined);
58
+ const resolved = fromSettings ?? host.siteConfig;
59
+
60
+ const status: SiteSettingsStatus = q.isLoading
61
+ ? "loading"
62
+ : !fromSettings
63
+ ? "error"
64
+ : q.isError
65
+ ? "stale"
66
+ : "ready";
42
67
 
43
- return { config: resolved, isLoading: q.isLoading };
68
+ return { config: resolved, isLoading: q.isLoading, status };
44
69
  }
45
70
 
46
71
  /** Sidebar model built from the live (override-aware) configuration. */
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Booking links: which addresses count as one, and how they are taken out of
3
+ * public content while a site has booking switched off.
4
+ *
5
+ * A booking link is an `https:` address whose host starts with `booking` —
6
+ * `https://booking.voyara.se/…`, `https://booking.example.com`. That is the
7
+ * whole rule. The address is read the way a browser reads it (character
8
+ * references decoded, tabs and line breaks dropped, the ends trimmed, a
9
+ * protocol-relative `//booking…` taken as `https:`), and only the scheme and
10
+ * host decide. A link to another site whose query string happens to contain a
11
+ * booking address is not a booking link, and neither is plain text that
12
+ * mentions one.
13
+ *
14
+ * Removal is applied to a copy of the content on its way out of the server,
15
+ * never to what is stored: switching booking back on restores every link
16
+ * exactly as it was written.
17
+ */
18
+ import { decodeAttr, sanitizeHtml } from "../html/sanitize-html";
19
+
20
+ /** A browser drops these anywhere inside a URL before parsing it. */
21
+ const DROPPED_INSIDE = /[\t\n\r]/g;
22
+ /** And trims C0 controls and spaces from both ends. */
23
+ const TRIMMED_ENDS = /^[\u0000- ]+|[\u0000- ]+$/g;
24
+
25
+ /** True when `raw` is an address that leads to a booking site. */
26
+ export function isBookingHref(raw: unknown): boolean {
27
+ if (typeof raw !== "string" || raw === "") return false;
28
+ let value = decodeAttr(raw).replace(DROPPED_INSIDE, "").replace(TRIMMED_ENDS, "");
29
+ if (!value) return false;
30
+ if (value.startsWith("//")) value = `https:${value}`;
31
+ let url: URL;
32
+ try {
33
+ // No base: a relative address ("/boka", "boka") is never a booking site.
34
+ url = new URL(value);
35
+ } catch {
36
+ return false;
37
+ }
38
+ return url.protocol === "https:" && url.hostname.startsWith("booking");
39
+ }
40
+
41
+ /** Every tag, as the sanitizer reads them (quoted `>` included). */
42
+ const TAG = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
43
+ const HREF = /(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
44
+
45
+ /** Placeholder for a removed link while emptied containers are collapsed. */
46
+ const REMOVED = "\u0000booking-link\u0000";
47
+
48
+ /**
49
+ * Containers that existed only to hold a removed link and are dropped with it:
50
+ * a paragraph, list item or heading that contained the "Boka" button and
51
+ * nothing else would otherwise leave an empty line behind.
52
+ */
53
+ const COLLAPSIBLE =
54
+ "p|li|ul|ol|div|span|strong|b|em|i|u|s|small|mark|h1|h2|h3|h4|h5|h6|figure|figcaption|blockquote";
55
+ const FILLER = `(?:\\s|&nbsp;|\\u00a0|<br\\s*\\/?>|${REMOVED})`;
56
+ const EMPTIED = new RegExp(
57
+ `<(${COLLAPSIBLE})(?:\\s[^>]*)?>${FILLER}*${REMOVED}${FILLER}*<\\/\\1>`,
58
+ "gi",
59
+ );
60
+
61
+ function anchorLeadsToBooking(attributes: string): boolean {
62
+ const match = HREF.exec(attributes);
63
+ if (!match) return false;
64
+ return isBookingHref(match[1] ?? match[2] ?? match[3] ?? "");
65
+ }
66
+
67
+ /**
68
+ * The same HTML with every booking link removed, link text included.
69
+ *
70
+ * Everything else — the prose around the link, other links, images, styling —
71
+ * is left exactly as it was. Input with no booking link comes back unchanged
72
+ * (the same string); input with one comes back sanitized, which is what every
73
+ * renderer does to it anyway.
74
+ */
75
+ export function stripBookingFromHtml(html: string): string {
76
+ if (!/<\s*a\b/i.test(html)) return html;
77
+ const clean = sanitizeHtml(html);
78
+ let out = "";
79
+ let last = 0;
80
+ let depth = 0;
81
+ let removed = false;
82
+ TAG.lastIndex = 0;
83
+ let match: RegExpExecArray | null;
84
+ while ((match = TAG.exec(clean))) {
85
+ const closing = Boolean(match[1]);
86
+ const tag = match[2]!.toLowerCase();
87
+ if (depth > 0) {
88
+ if (tag === "a") depth += closing ? -1 : 1;
89
+ if (depth === 0) {
90
+ out += REMOVED;
91
+ last = match.index + match[0].length;
92
+ }
93
+ continue;
94
+ }
95
+ if (!closing && tag === "a" && anchorLeadsToBooking(match[3] ?? "")) {
96
+ out += clean.slice(last, match.index);
97
+ depth = 1;
98
+ removed = true;
99
+ }
100
+ }
101
+ if (!removed) return html;
102
+ out += depth > 0 ? REMOVED : clean.slice(last);
103
+ let previous: string;
104
+ do {
105
+ previous = out;
106
+ out = out.replace(EMPTIED, REMOVED);
107
+ } while (out !== previous);
108
+ return out.split(REMOVED).join("");
109
+ }
110
+
111
+ /**
112
+ * A copy of any JSON-shaped value with booking links taken out.
113
+ *
114
+ * A string that *is* a booking address becomes empty — every block treats an
115
+ * empty link as "no button", so a hero keeps its picture and heading and loses
116
+ * only the button — and booking links inside HTML strings are removed with
117
+ * {@link stripBookingFromHtml}. Objects and arrays are rebuilt only where
118
+ * something changed, so untouched content keeps its identity.
119
+ */
120
+ export function stripBookingDeep<T>(value: T): T {
121
+ return strip(value) as T;
122
+ }
123
+
124
+ function strip(value: unknown): unknown {
125
+ if (typeof value === "string") {
126
+ if (isBookingHref(value)) return "";
127
+ return value.includes("<") ? stripBookingFromHtml(value) : value;
128
+ }
129
+ if (Array.isArray(value)) {
130
+ let changed = false;
131
+ const next = value.map((item) => {
132
+ const out = strip(item);
133
+ if (out !== item) changed = true;
134
+ return out;
135
+ });
136
+ return changed ? next : value;
137
+ }
138
+ if (value !== null && typeof value === "object") {
139
+ let changed = false;
140
+ const next: Record<string, unknown> = {};
141
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
142
+ const out = strip(item);
143
+ if (out !== item) changed = true;
144
+ next[key] = out;
145
+ }
146
+ return changed ? next : value;
147
+ }
148
+ return value;
149
+ }
150
+
151
+ /** True when any string anywhere inside `value` leads to a booking site. */
152
+ export function containsBookingTarget(value: unknown): boolean {
153
+ if (typeof value === "string") {
154
+ if (isBookingHref(value)) return true;
155
+ return value.includes("<") && stripBookingFromHtml(value) !== value;
156
+ }
157
+ if (Array.isArray(value)) return value.some(containsBookingTarget);
158
+ if (value !== null && typeof value === "object") {
159
+ return Object.values(value as Record<string, unknown>).some(containsBookingTarget);
160
+ }
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Popups to show while booking is off: every popup that links to a booking
166
+ * site is withheld whole.
167
+ *
168
+ * A popup is one short message with one purpose. Taking only the button out of
169
+ * one that says "Book your cabin now" leaves a promise with no way to act on
170
+ * it, and an answer or a form that ends by opening the booking site cannot be
171
+ * removed without changing what the popup does — so the popup is simply not
172
+ * offered until booking is back.
173
+ */
174
+ export function withoutBookingPopups<T>(configs: readonly T[]): T[] {
175
+ return configs.filter((config) => !containsBookingTarget(config));
176
+ }
@@ -155,21 +155,26 @@ export function isPubliclyVisible(state: PublicationState): boolean {
155
155
  return state === "published" || state === "ending";
156
156
  }
157
157
 
158
+ /*
159
+ * "Offline" rather than "Hidden": a page can also be link-only — online, but
160
+ * left out of menus and the sitemap — and two different states must not share
161
+ * the word "hidden".
162
+ */
158
163
  export const PUBLICATION_STATE_LABELS: Record<PublicationState, string> = {
159
- draft: "Hidden",
164
+ draft: "Offline",
160
165
  scheduled: "Scheduled to publish",
161
166
  published: "Published",
162
- ending: "Scheduled to hide",
163
- expired: "Hidden after expiry",
167
+ ending: "Scheduled to go offline",
168
+ expired: "Offline after expiry",
164
169
  };
165
170
 
166
171
  /** Longer wording for tooltips and screen-reader descriptions. */
167
172
  export const PUBLICATION_STATE_DESCRIPTIONS: Record<PublicationState, string> = {
168
- draft: "Not visible on the public website. Staff can still edit and preview it.",
173
+ draft: "Not on the public website; visitors get “page not found”. Staff can still edit and preview it.",
169
174
  scheduled: "Not visible yet. It appears by itself when its publication window opens.",
170
175
  published: "Visible on the public website right now.",
171
- ending: "Visible now, and scheduled to disappear when its window closes.",
172
- expired: "Not visible any more — its publication window has closed.",
176
+ ending: "Visible now, and scheduled to go offline when its window closes.",
177
+ expired: "Offline — its publication window has closed.",
173
178
  };
174
179
 
175
180
  /**