@bygghammar/cms-platform 0.96.0 → 0.97.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Booking-off landing pages: pages that leave the website's menus and page
3
+ * lists while booking is hidden, but stay live and indexable for anyone who
4
+ * arrives from a search engine or a direct link.
5
+ *
6
+ * Four things are kept apart on purpose:
7
+ *
8
+ * - **Booking availability** — the site-wide switch (`booking`). Unchanged.
9
+ * - **Discovery** — whether the site's own menus, footer and page cards point
10
+ * at the page. A landing page is left out of them while booking is hidden,
11
+ * and only then.
12
+ * - **Indexability** — whether search engines may index it. Unchanged: a
13
+ * landing page keeps its canonical address, has no `noindex` and stays in
14
+ * the sitemap, so the result people find keeps working.
15
+ * - **Link-only and offline** — the permanent listing and publication states.
16
+ * Unchanged: link-only stays `noindex`, offline stays a 404.
17
+ *
18
+ * While booking is hidden a landing page shows a short notice in place of its
19
+ * booking actions — a heading, a message and optionally a link to somewhere
20
+ * that is not booking (a contact page, an interest form). The site-wide
21
+ * default can be replaced field by field for one page. The notice is plain
22
+ * text: no markup is stored or rendered.
23
+ *
24
+ * Client-safe: no server imports.
25
+ */
26
+ import { isBookingHref } from "./booking-links";
27
+ import type { LocalizedString } from "../i18n/types";
28
+
29
+ /** Longest values accepted, per locale. */
30
+ export const NOTICE_LIMITS = Object.freeze({
31
+ heading: 160,
32
+ body: 1200,
33
+ ctaLabel: 80,
34
+ href: 2048,
35
+ });
36
+
37
+ /** A link on the notice. Its target is never a booking site. */
38
+ export type NoticeCta = { label: LocalizedString; href: string };
39
+
40
+ /** The site-wide default notice, as stored. */
41
+ export type NoticeContent = {
42
+ heading: LocalizedString;
43
+ body: LocalizedString;
44
+ cta: NoticeCta | null;
45
+ };
46
+
47
+ /**
48
+ * One page's replacements for the default. A field that is absent uses the
49
+ * default; `cta: "none"` shows no link on this page even when the default has
50
+ * one.
51
+ */
52
+ export type PageNoticeOverride = {
53
+ heading?: LocalizedString;
54
+ body?: LocalizedString;
55
+ cta?: NoticeCta | "none";
56
+ };
57
+
58
+ /** The notice a page shows: every field chosen, still localized. */
59
+ export type ResolvedNotice = NoticeContent;
60
+
61
+ /**
62
+ * What a landing page says when nobody has written anything: neutral and
63
+ * true whatever the reason booking is hidden. Swedish is the source locale.
64
+ */
65
+ export const BUILT_IN_NOTICE: ResolvedNotice = Object.freeze({
66
+ heading: Object.freeze({
67
+ sv: "Bokning är inte tillgänglig just nu",
68
+ en: "Booking is not available right now",
69
+ }),
70
+ body: Object.freeze({
71
+ sv: "Informationen på den här sidan finns kvar. Bokningslänkarna visas igen när bokningen öppnar.",
72
+ en: "The information on this page is still here. Booking links will be shown again when booking opens.",
73
+ }),
74
+ cta: null,
75
+ }) as ResolvedNotice;
76
+
77
+ /* ------------------------------------------------------------------------ */
78
+ /* Links */
79
+ /* ------------------------------------------------------------------------ */
80
+
81
+ export type CtaHrefProblem =
82
+ | "empty"
83
+ | "too-long"
84
+ | "whitespace"
85
+ | "unsupported-scheme"
86
+ | "protocol-relative"
87
+ | "credentials"
88
+ | "invalid"
89
+ | "booking";
90
+
91
+ export type CtaHrefResult = { ok: true; href: string } | { ok: false; reason: CtaHrefProblem };
92
+
93
+ /**
94
+ * Check a notice link as staff typed it.
95
+ *
96
+ * Accepted: an address on this site (`/kontakt`, `/intresse?från=resa#form`)
97
+ * or an `https://` address with a host name. Refused: every other scheme
98
+ * (`javascript:`, `data:`, `http:`, `mailto:` …), a protocol-relative `//host`
99
+ * or a backslash (both leave the site in a browser), whitespace or control
100
+ * characters, a user name or password in the address — and any booking link,
101
+ * because the notice exists to say booking is not offered here.
102
+ */
103
+ export function parseNoticeCtaHref(raw: unknown): CtaHrefResult {
104
+ if (typeof raw !== "string") return { ok: false, reason: "empty" };
105
+ const value = raw.trim();
106
+ if (!value) return { ok: false, reason: "empty" };
107
+ if (value.length > NOTICE_LIMITS.href) return { ok: false, reason: "too-long" };
108
+ // eslint-disable-next-line no-control-regex
109
+ if (/[\s\u0000-\u001f\u007f]/.test(value)) return { ok: false, reason: "whitespace" };
110
+ if (value.includes("\\")) return { ok: false, reason: "protocol-relative" };
111
+ if (value.startsWith("//")) return { ok: false, reason: "protocol-relative" };
112
+ if (isBookingHref(value)) return { ok: false, reason: "booking" };
113
+ if (value.startsWith("/")) return { ok: true, href: value };
114
+ if (!/^https:\/\//i.test(value)) return { ok: false, reason: "unsupported-scheme" };
115
+ let url: URL;
116
+ try {
117
+ url = new URL(value);
118
+ } catch {
119
+ return { ok: false, reason: "invalid" };
120
+ }
121
+ if (url.protocol !== "https:" || !url.hostname) return { ok: false, reason: "invalid" };
122
+ if (url.username || url.password) return { ok: false, reason: "credentials" };
123
+ return { ok: true, href: value };
124
+ }
125
+
126
+ export function ctaHrefProblemMessage(reason: CtaHrefProblem): string {
127
+ switch (reason) {
128
+ case "empty":
129
+ return "Enter the address the link opens.";
130
+ case "too-long":
131
+ return `The address is longer than ${NOTICE_LIMITS.href} characters.`;
132
+ case "whitespace":
133
+ return "The address cannot contain spaces or line breaks.";
134
+ case "unsupported-scheme":
135
+ return "Use an address on this site (starting with /) or one starting with https://.";
136
+ case "protocol-relative":
137
+ return "Start an address on this site with a single / — // and backslashes lead off the site.";
138
+ case "credentials":
139
+ return "The address cannot contain a user name or password.";
140
+ case "invalid":
141
+ return "That is not a valid https:// address.";
142
+ case "booking":
143
+ return "The link cannot lead to booking: this notice is shown because booking is hidden.";
144
+ }
145
+ }
146
+
147
+ /* ------------------------------------------------------------------------ */
148
+ /* Stored values */
149
+ /* ------------------------------------------------------------------------ */
150
+
151
+ export class NoticeValidationError extends Error {
152
+ readonly code = "notice_invalid";
153
+ constructor(message: string) {
154
+ super(message);
155
+ this.name = "NoticeValidationError";
156
+ }
157
+ }
158
+
159
+ function isRecord(value: unknown): value is Record<string, unknown> {
160
+ return value !== null && typeof value === "object" && !Array.isArray(value);
161
+ }
162
+
163
+ /**
164
+ * A localized text with empty locales dropped. Returns null when nothing is
165
+ * left. Strict mode (saving) refuses anything over its limit or not a string;
166
+ * lenient mode (reading what is stored) skips it.
167
+ */
168
+ function localizedText(
169
+ raw: unknown,
170
+ limit: number,
171
+ field: string,
172
+ strict: boolean,
173
+ ): LocalizedString | null {
174
+ if (raw == null) return null;
175
+ if (!isRecord(raw)) {
176
+ if (strict) throw new NoticeValidationError(`${field} must be text per language.`);
177
+ return null;
178
+ }
179
+ const out: Record<string, string> = {};
180
+ for (const [locale, value] of Object.entries(raw)) {
181
+ if (!/^[a-z]{2}(-[a-z0-9]{2,8})?$/i.test(locale)) {
182
+ if (strict) throw new NoticeValidationError(`${field} has an unknown language "${locale}".`);
183
+ continue;
184
+ }
185
+ if (value == null) continue;
186
+ if (typeof value !== "string") {
187
+ if (strict) throw new NoticeValidationError(`${field} must be text.`);
188
+ continue;
189
+ }
190
+ // Plain text: line breaks are kept, other control characters are not.
191
+ // eslint-disable-next-line no-control-regex
192
+ const text = value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "").trim();
193
+ if (!text) continue;
194
+ if (text.length > limit) {
195
+ if (strict) {
196
+ throw new NoticeValidationError(`${field} (${locale.toUpperCase()}) is longer than ${limit} characters.`);
197
+ }
198
+ continue;
199
+ }
200
+ out[locale] = text;
201
+ }
202
+ return Object.keys(out).length > 0 ? (out as LocalizedString) : null;
203
+ }
204
+
205
+ function cta(raw: unknown, strict: boolean, field: string): NoticeCta | null {
206
+ if (raw == null) return null;
207
+ if (!isRecord(raw)) {
208
+ if (strict) throw new NoticeValidationError(`${field} must have a label and an address.`);
209
+ return null;
210
+ }
211
+ const label = localizedText(raw["label"], NOTICE_LIMITS.ctaLabel, `${field} label`, strict);
212
+ const href = parseNoticeCtaHref(raw["href"]);
213
+ if (!label && !href.ok && href.reason === "empty") return null;
214
+ if (!label) {
215
+ if (strict) throw new NoticeValidationError(`${field} needs a label.`);
216
+ return null;
217
+ }
218
+ if (!href.ok) {
219
+ if (strict) throw new NoticeValidationError(`${field}: ${ctaHrefProblemMessage(href.reason)}`);
220
+ return null;
221
+ }
222
+ return { label, href: href.href };
223
+ }
224
+
225
+ /**
226
+ * The default notice from staff input (strict) or from a stored row
227
+ * (lenient). Empty fields stay empty; {@link resolveNotice} falls back to the
228
+ * built-in copy for them.
229
+ */
230
+ export function normalizeNoticeContent(raw: unknown, strict: boolean): NoticeContent {
231
+ const value = isRecord(raw) ? raw : {};
232
+ if (strict && raw != null && !isRecord(raw)) throw new NoticeValidationError("The notice is not valid.");
233
+ return {
234
+ heading: localizedText(value["heading"], NOTICE_LIMITS.heading, "Heading", strict) ?? {},
235
+ body: localizedText(value["body"], NOTICE_LIMITS.body, "Message", strict) ?? {},
236
+ cta: cta(value["cta"], strict, "Link"),
237
+ };
238
+ }
239
+
240
+ /** One page's replacements, from staff input (strict) or a stored row (lenient). */
241
+ export function normalizePageNoticeOverride(raw: unknown, strict: boolean): PageNoticeOverride {
242
+ if (raw == null) return {};
243
+ if (!isRecord(raw)) {
244
+ if (strict) throw new NoticeValidationError("The page's own notice is not valid.");
245
+ return {};
246
+ }
247
+ const out: PageNoticeOverride = {};
248
+ const heading = localizedText(raw["heading"], NOTICE_LIMITS.heading, "Heading", strict);
249
+ if (heading) out.heading = heading;
250
+ const body = localizedText(raw["body"], NOTICE_LIMITS.body, "Message", strict);
251
+ if (body) out.body = body;
252
+ if (raw["cta"] === "none") out.cta = "none";
253
+ else {
254
+ const link = cta(raw["cta"], strict, "Link");
255
+ if (link) out.cta = link;
256
+ }
257
+ return out;
258
+ }
259
+
260
+ function hasText(value: LocalizedString | undefined | null): value is LocalizedString {
261
+ return Boolean(value && Object.values(value).some((text) => typeof text === "string" && text !== ""));
262
+ }
263
+
264
+ /**
265
+ * The notice one page shows: each field from the page's own replacement when
266
+ * it has one, otherwise from the site default, otherwise the built-in copy.
267
+ * Locale fallback within a field is the site's usual one, applied when it is
268
+ * rendered.
269
+ */
270
+ export function resolveNotice(
271
+ defaults: NoticeContent | null | undefined,
272
+ override: PageNoticeOverride | null | undefined,
273
+ ): ResolvedNotice {
274
+ const heading = hasText(override?.heading)
275
+ ? override!.heading
276
+ : hasText(defaults?.heading)
277
+ ? defaults!.heading
278
+ : BUILT_IN_NOTICE.heading;
279
+ // A page that replaces the heading but not the message keeps the default
280
+ // message only if the site wrote one; the built-in message goes with the
281
+ // built-in heading.
282
+ const body = hasText(override?.body)
283
+ ? override!.body
284
+ : hasText(defaults?.body)
285
+ ? defaults!.body
286
+ : hasText(override?.heading) || hasText(defaults?.heading)
287
+ ? {}
288
+ : BUILT_IN_NOTICE.body;
289
+ const link =
290
+ override?.cta === "none" ? null : override?.cta ? override.cta : (defaults?.cta ?? null);
291
+ return { heading, body, cta: link };
292
+ }
293
+
294
+ /** Which fields a page replaces, for the settings list. */
295
+ export function overriddenFields(override: PageNoticeOverride | null | undefined): string[] {
296
+ const out: string[] = [];
297
+ if (hasText(override?.heading)) out.push("heading");
298
+ if (hasText(override?.body)) out.push("message");
299
+ if (override?.cta === "none") out.push("no link");
300
+ else if (override?.cta) out.push("link");
301
+ return out;
302
+ }
303
+
304
+ /* ------------------------------------------------------------------------ */
305
+ /* Where a notice link leads, as the host's routes say */
306
+ /* ------------------------------------------------------------------------ */
307
+
308
+ /**
309
+ * What the host knows about its routes, for checking a notice link. The
310
+ * platform names no route: the host says which of its pages is the booking
311
+ * page (in every locale, slug and alias) and resolves an address to a page.
312
+ */
313
+ export type NoticeTargetRoutes = {
314
+ /** The normalized site path an address leads to, or null when it leaves the site. */
315
+ internalPath: (href: string) => string | null;
316
+ /** The page key served at a normalized path, or null. */
317
+ pageKeyAt: (path: string) => string | null;
318
+ /** Keys of the site's booking pages. */
319
+ bookingPageKeys: readonly string[];
320
+ /** Keys of the pages on the booking-off landing list. */
321
+ landingPageKeys: ReadonlySet<string>;
322
+ };
323
+
324
+ export const NOTICE_TARGET_PROBLEMS = Object.freeze({
325
+ booking: "The link leads to booking. The notice is shown because booking is hidden, so link somewhere else (a contact page or an interest form).",
326
+ hidden: "The link leads to a page that is hidden from the website while booking is hidden. Link somewhere that stays visible.",
327
+ });
328
+
329
+ /**
330
+ * Why a notice may not link to `href`, or null when it may: a booking site,
331
+ * the host's booking page in any spelling (query strings and fragments do not
332
+ * matter), or a page on the landing list — that page is hidden whenever the
333
+ * notice is shown.
334
+ */
335
+ export function noticeTargetProblem(href: string, routes: NoticeTargetRoutes): string | null {
336
+ if (isBookingHref(href)) return NOTICE_TARGET_PROBLEMS.booking;
337
+ const path = routes.internalPath(href);
338
+ if (path === null) return null;
339
+ const key = routes.pageKeyAt(path);
340
+ if (key === null) return null;
341
+ if (routes.bookingPageKeys.includes(key)) return NOTICE_TARGET_PROBLEMS.booking;
342
+ if (routes.landingPageKeys.has(key)) return NOTICE_TARGET_PROBLEMS.hidden;
343
+ return null;
344
+ }
345
+
346
+ /** Every link address a stored notice or a page's replacement holds. */
347
+ export function noticeLinkHrefs(notice: NoticeContent | PageNoticeOverride | null | undefined): string[] {
348
+ const cta = notice?.cta;
349
+ return cta && cta !== "none" && typeof cta.href === "string" ? [cta.href] : [];
350
+ }
351
+
352
+ /** The notice with its link removed when `blocked` says so; the rest is kept. */
353
+ export function withoutBlockedLink(
354
+ notice: ResolvedNotice | null,
355
+ blocked: (href: string) => boolean,
356
+ ): ResolvedNotice | null {
357
+ if (!notice?.cta || !blocked(notice.cta.href)) return notice;
358
+ return { ...notice, cta: null };
359
+ }
@@ -174,3 +174,27 @@ export function containsBookingTarget(value: unknown): boolean {
174
174
  export function withoutBookingPopups<T>(configs: readonly T[]): T[] {
175
175
  return configs.filter((config) => !containsBookingTarget(config));
176
176
  }
177
+
178
+ /**
179
+ * True when any string anywhere inside `value` is, as a whole, an address
180
+ * `leads` says yes to — a popup's link, a button's or an answer's `openUrl`,
181
+ * a form's success action. Text inside markup is not read.
182
+ */
183
+ export function containsLinkTo(value: unknown, leads: (href: string) => boolean): boolean {
184
+ if (typeof value === "string") return value.trim() !== "" && leads(value);
185
+ if (Array.isArray(value)) return value.some((item) => containsLinkTo(item, leads));
186
+ if (value !== null && typeof value === "object") {
187
+ return Object.values(value as Record<string, unknown>).some((item) => containsLinkTo(item, leads));
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Popups without the ones that lead where `leads` says — for example to a
194
+ * page hidden while booking is hidden. Withheld whole, for the reason
195
+ * {@link withoutBookingPopups} gives: a popup has one purpose, and taking only
196
+ * its button out would leave a promise with no way to act on it.
197
+ */
198
+ export function withoutPopupsLeadingTo<T>(configs: readonly T[], leads: (href: string) => boolean): T[] {
199
+ return configs.filter((config) => !containsLinkTo(config, leads));
200
+ }
@@ -17,6 +17,12 @@
17
17
  */
18
18
  import type { PublicationState } from "./page-publication";
19
19
  import { isPubliclyVisible } from "./page-publication";
20
+ import {
21
+ resolveNotice,
22
+ type NoticeContent,
23
+ type PageNoticeOverride,
24
+ type ResolvedNotice,
25
+ } from "./booking-landing";
20
26
 
21
27
  export const PAGE_LISTINGS = ["listed", "link-only"] as const;
22
28
  export type PageListing = (typeof PAGE_LISTINGS)[number];
@@ -132,6 +138,14 @@ export type PolicyPage = {
132
138
  listing: PageListing;
133
139
  /** Booking links in this page's own content stay while booking is off. */
134
140
  bookingException: boolean;
141
+ /**
142
+ * Chosen as a booking-off landing page: left out of the site's menus and
143
+ * page lists while booking is off, still live and indexable. Absent in
144
+ * snapshots made before the feature, which means no.
145
+ */
146
+ bookingLanding?: boolean;
147
+ /** This page's own replacements for the default booking-off notice. */
148
+ landingNotice?: PageNoticeOverride | null;
135
149
  };
136
150
 
137
151
  /**
@@ -147,6 +161,8 @@ export type PublicPolicySnapshot = {
147
161
  pages: PolicyPage[];
148
162
  /** The next instant any of this changes on its own (a publication boundary). */
149
163
  revalidateAt: number | null;
164
+ /** The site-wide booking-off notice, when staff wrote one. */
165
+ landingDefault?: NoticeContent | null;
150
166
  };
151
167
 
152
168
  export const UNKNOWN_PUBLIC_POLICY: PublicPolicySnapshot = Object.freeze({
@@ -193,7 +209,11 @@ export function pageBookingVisible(policy: PublicPolicySnapshot, key: string | n
193
209
 
194
210
  export type HiddenPageReason = "offline" | "link-only";
195
211
 
196
- /** Pages navigation must not point at, and why. */
212
+ /**
213
+ * Pages navigation must not point at, and why — the permanent states only.
214
+ * The sitemap leaves these out too, so booking-off landing pages are not
215
+ * here: see {@link bookingLandingPages}.
216
+ */
197
217
  export function unlistedPages(
198
218
  policy: PublicPolicySnapshot,
199
219
  ): Array<{ key: string; reason: HiddenPageReason }> {
@@ -204,3 +224,49 @@ export function unlistedPages(
204
224
  }
205
225
  return out;
206
226
  }
227
+
228
+ /* ------------------------------------------------------------------------ */
229
+ /* Booking-off landing pages */
230
+ /* ------------------------------------------------------------------------ */
231
+
232
+ /**
233
+ * A page stored both as a booking exception and as a landing page. Neither
234
+ * screen lets that happen, but rows written before the rule, by hand or by
235
+ * two editors at the same instant can. Such a page is treated as an
236
+ * exception only — exactly how it behaved before landing pages existed — and
237
+ * the settings screen says so.
238
+ */
239
+ export function isBookingConflict(page: PolicyPage): boolean {
240
+ return page.bookingException && page.bookingLanding === true;
241
+ }
242
+
243
+ /**
244
+ * Is this page a booking-off landing page right now? Only while booking is
245
+ * off, only for a live page read successfully, and never for a page that is
246
+ * also a booking exception (see {@link isBookingConflict}).
247
+ */
248
+ export function pageIsBookingLanding(policy: PublicPolicySnapshot, key: string): boolean {
249
+ if (!policy.known || policy.bookingEnabled) return false;
250
+ const page = policyPage(policy, key);
251
+ return page !== null && page.live && page.bookingLanding === true && !page.bookingException;
252
+ }
253
+
254
+ /**
255
+ * Pages to leave out of the site's menus, footer and page lists right now
256
+ * because booking is off. Not the sitemap, not robots: they stay indexable.
257
+ * Pages that are offline or link-only are not repeated here — those reasons
258
+ * come first and keep their own rules.
259
+ */
260
+ export function bookingLandingPages(policy: PublicPolicySnapshot): string[] {
261
+ return policy.pages
262
+ .filter(
263
+ (page) => page.listing !== "link-only" && pageIsBookingLanding(policy, page.key),
264
+ )
265
+ .map((page) => page.key);
266
+ }
267
+
268
+ /** The notice this page shows right now, or null when it shows none. */
269
+ export function bookingOffNoticeFor(policy: PublicPolicySnapshot, key: string): ResolvedNotice | null {
270
+ if (!pageIsBookingLanding(policy, key)) return null;
271
+ return resolveNotice(policy.landingDefault ?? null, policyPage(policy, key)?.landingNotice ?? null);
272
+ }
@@ -16,12 +16,21 @@ import type { HiddenPageReason } from "../cms/page-visibility";
16
16
  import { normalizeOrigin, normalizeRoutePath } from "../site/route-index";
17
17
  import type { NavigationItem } from "./index";
18
18
 
19
+ /**
20
+ * Why a page's addresses are hidden from navigation: its permanent state
21
+ * (offline, link-only), or `booking-off` — a booking-off landing page while
22
+ * booking is hidden, which stays indexable and in the sitemap.
23
+ */
24
+ export type HiddenPathReason = HiddenPageReason | "booking-off";
25
+
19
26
  /** Why a saved link is not shown. */
20
- export type HiddenLinkReason = HiddenPageReason | "booking" | "unknown";
27
+ export type HiddenLinkReason = HiddenPathReason | "booking" | "unknown";
21
28
 
22
29
  export const HIDDEN_LINK_REASONS: Record<HiddenLinkReason, string> = {
23
30
  offline: "Not shown on the site: the page it links to is offline.",
24
31
  "link-only": "Not shown on the site: the page it links to is link-only.",
32
+ "booking-off":
33
+ "Not shown while booking is hidden: the page it links to is hidden from the website until booking is shown again (it stays reachable and indexable).",
25
34
  booking: "Not shown on the site: booking is switched off in Site settings.",
26
35
  unknown: "Not shown right now: the site could not read which pages are public.",
27
36
  };
@@ -35,12 +44,21 @@ export type LinkVisibilityPolicy = {
35
44
  known: boolean;
36
45
  /** Booking links in site-wide chrome (menu, footer, header button). */
37
46
  bookingVisible: boolean;
38
- /** Normalized served paths of unlisted pages, every locale and alias. */
39
- hiddenPaths: Readonly<Record<string, HiddenPageReason>>;
47
+ /**
48
+ * Normalized served paths of unlisted pages, every locale and alias — and,
49
+ * while booking is off, of booking-off landing pages (`booking-off`).
50
+ */
51
+ hiddenPaths: Readonly<Record<string, HiddenPathReason>>;
40
52
  /** Origins that are this site, for absolute links written with the domain. */
41
53
  origins: readonly string[];
42
54
  /** Locale prefixes, so `/EN/...` and `/en/...` compare equal. */
43
55
  locales?: readonly string[];
56
+ /**
57
+ * Normalized addresses of the site's own booking page — every locale, slug
58
+ * and alias. Supplied by the host, which knows its routes; the platform
59
+ * names none. A booking-off notice never links there.
60
+ */
61
+ bookingPagePaths?: readonly string[];
44
62
  };
45
63
 
46
64
  /** No filtering at all: what screens without a public policy render with. */
@@ -93,6 +111,43 @@ export function linkHideReason(href: string, policy: LinkVisibilityPolicy): Hidd
93
111
  return policy.hiddenPaths[path] ?? null;
94
112
  }
95
113
 
114
+ /**
115
+ * Is this link a page card or list entry for a page that is hidden from the
116
+ * website right now because booking is off?
117
+ *
118
+ * For the structured page lists inside page content (link tiles, destination
119
+ * cards): they leave out booking-off landing pages, and only those. Their
120
+ * links to offline or link-only pages, and every link in running text, are
121
+ * left exactly as before.
122
+ */
123
+ export function isHiddenFromDiscovery(href: string | null | undefined, policy: LinkVisibilityPolicy): boolean {
124
+ if (!href) return false;
125
+ const path = internalPathOf(href, policy);
126
+ return path !== null && policy.hiddenPaths[path] === "booking-off";
127
+ }
128
+
129
+ /**
130
+ * Does this link lead to booking: a booking site, or the site's own booking
131
+ * page in any spelling the host named? Query strings and fragments do not
132
+ * change the answer.
133
+ */
134
+ export function isBookingDestination(href: string | null | undefined, policy: LinkVisibilityPolicy): boolean {
135
+ if (!href) return false;
136
+ if (isBookingHref(href)) return true;
137
+ const path = internalPathOf(href, policy);
138
+ return path !== null && (policy.bookingPagePaths ?? []).includes(path);
139
+ }
140
+
141
+ /**
142
+ * May a booking-off notice offer this link? Not when it leads to booking —
143
+ * the notice says booking is not offered here — and not when it leads to a
144
+ * page the same policy hides from the website.
145
+ */
146
+ export function isNoticeLinkAllowed(href: string | null | undefined, policy: LinkVisibilityPolicy): boolean {
147
+ if (!href) return false;
148
+ return !isBookingDestination(href, policy) && !isHiddenFromDiscovery(href, policy);
149
+ }
150
+
96
151
  /**
97
152
  * Navigation with hidden links removed, order preserved.
98
153
  *
@@ -221,12 +276,19 @@ export function savedLinkHrefs(section: "nav" | "footer", value: unknown): strin
221
276
  * page: that would publish the address of each link-only page in the source of
222
277
  * every other page. The chrome only needs answers for the links it has, and
223
278
  * those addresses are already in the content that is sent anyway.
279
+ *
280
+ * `booking-off` entries are kept whether linked from `hrefs` or not: those
281
+ * pages are public, indexable and in the sitemap, so naming them publishes
282
+ * nothing, and page cards in the content need them too.
224
283
  */
225
284
  export function restrictToLinks(
226
285
  policy: LinkVisibilityPolicy,
227
286
  hrefs: readonly string[],
228
287
  ): LinkVisibilityPolicy {
229
288
  const hiddenPaths: Record<string, LinkVisibilityPolicy["hiddenPaths"][string]> = {};
289
+ for (const [path, reason] of Object.entries(policy.hiddenPaths)) {
290
+ if (reason === "booking-off") hiddenPaths[path] = reason;
291
+ }
230
292
  for (const href of hrefs) {
231
293
  const path = internalPathOf(href, policy);
232
294
  if (path !== null && policy.hiddenPaths[path]) hiddenPaths[path] = policy.hiddenPaths[path]!;