@bygghammar/cms-platform 0.95.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/docs/PAGES.md +45 -2
  3. package/package.json +3 -1
  4. package/src/admin/describe-log.ts +2 -0
  5. package/src/backoffice/BookingLandingSection.tsx +672 -0
  6. package/src/backoffice/BookingSettingsCard.tsx +34 -4
  7. package/src/backoffice/PageEditorView.tsx +47 -24
  8. package/src/backoffice/routes/host.ts +7 -0
  9. package/src/backoffice/routes/index.tsx +22 -3
  10. package/src/blocks/components/CountdownBlock.tsx +3 -1
  11. package/src/blocks/components/CtaBannerBlock.tsx +8 -2
  12. package/src/blocks/components/HeroFullBlock.tsx +7 -1
  13. package/src/blocks/components/IconGridBlock.tsx +5 -1
  14. package/src/blocks/components/InstagramBlock.tsx +5 -1
  15. package/src/blocks/components/InstagramTiles.tsx +3 -1
  16. package/src/blocks/components/LinkTilesBlock.tsx +8 -1
  17. package/src/blocks/components/MediaSplitBlock.tsx +8 -3
  18. package/src/blocks/components/PriceTableBlock.tsx +6 -5
  19. package/src/blocks/components/TripListBlock.tsx +5 -1
  20. package/src/cms/booking-landing.ts +359 -0
  21. package/src/cms/booking-links.ts +24 -0
  22. package/src/cms/page-visibility.ts +67 -1
  23. package/src/forms/form-audit.ts +22 -2
  24. package/src/html/html-tokenizer.ts +254 -0
  25. package/src/html/sanitize-html.ts +71 -36
  26. package/src/navigation/visibility.ts +65 -3
  27. package/src/popups/runtime.tsx +75 -32
  28. package/src/server/forms-core.ts +9 -0
  29. package/src/server/forms-delivery-core.ts +29 -22
  30. package/src/server/forms-submissions-core.ts +8 -5
  31. package/src/server/media-usage-core.ts +53 -13
  32. package/src/server/outbound-address.ts +131 -0
  33. package/src/server/outbound-http.ts +565 -0
  34. package/src/server/page-visibility-core.ts +231 -5
  35. package/src/server/popup-optin-webhook-core.ts +79 -140
  36. package/src/server/schemas.ts +45 -0
  37. package/src/site/BookingOffNotice.tsx +73 -0
  38. package/src/site/PageRenderer.tsx +59 -3
  39. package/src/site/link-visibility.tsx +21 -2
  40. package/src/site/preview-messaging.ts +232 -0
  41. package/src/site/routes/preview-page.tsx +37 -29
  42. package/src/util/redact-url.ts +18 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * An HTML tokenizer for the sanitizer, following the WHATWG tokenizer states
3
+ * that matter for a fragment of body content. No DOM is needed, so it runs the
4
+ * same on the server and in the browser.
5
+ *
6
+ * It splits markup into text, start tags and end tags, the way a browser
7
+ * would. Comments, DOCTYPEs, CDATA sections and processing instructions are
8
+ * consumed and produce nothing. A tag that runs into the end of the input is
9
+ * dropped, as a browser drops it. The content of raw-text and RCDATA elements
10
+ * (`<script>`, `<style>`, `<textarea>`, …) is returned as one text token, as
11
+ * the browser's tree builder makes the tokenizer read it.
12
+ *
13
+ * Values are returned exactly as written — text and attribute values are not
14
+ * decoded here. The sanitizer re-serializes everything it keeps, so nothing
15
+ * from the input reaches its output without being re-encoded.
16
+ */
17
+
18
+ export type HtmlToken =
19
+ | { kind: "text"; raw: string }
20
+ | {
21
+ kind: "start";
22
+ name: string;
23
+ /** In source order; a repeated name keeps its first value, as browsers do. */
24
+ attrs: Array<{ name: string; raw: string }>;
25
+ selfClosing: boolean;
26
+ }
27
+ | { kind: "end"; name: string };
28
+
29
+ /** Elements whose content the browser reads as text up to their own end tag. */
30
+ const RAW_TEXT = new Set([
31
+ "script",
32
+ "style",
33
+ "xmp",
34
+ "iframe",
35
+ "noembed",
36
+ "noframes",
37
+ "noscript",
38
+ "title",
39
+ "textarea",
40
+ ]);
41
+
42
+ const isWhitespace = (c: string) =>
43
+ c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
44
+ const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
45
+
46
+ /** Lower-cases ASCII only, as the HTML tokenizer does for names. */
47
+ function asciiLower(value: string): string {
48
+ return value.replace(/[A-Z]+/g, (m) => m.toLowerCase());
49
+ }
50
+
51
+ export function tokenizeHtml(input: string): HtmlToken[] {
52
+ const html = input.replace(/\r\n?/g, "\n").replace(/\u0000/g, "�");
53
+ const tokens: HtmlToken[] = [];
54
+ const n = html.length;
55
+ let lower: string | undefined;
56
+ let i = 0;
57
+ let textStart = 0;
58
+
59
+ const flushText = (end: number) => {
60
+ if (end > textStart) tokens.push({ kind: "text", raw: html.slice(textStart, end) });
61
+ };
62
+
63
+ /** Skips a bogus comment: everything up to and including the next `>`. */
64
+ const skipBogusComment = (from: number): number => {
65
+ const end = html.indexOf(">", from);
66
+ return end < 0 ? n : end + 1;
67
+ };
68
+
69
+ /** Skips a comment whose `<!--` ends at `from`. */
70
+ const skipComment = (from: number): number => {
71
+ if (html.startsWith(">", from)) return from + 1; // <!-->
72
+ if (html.startsWith("->", from)) return from + 2; // <!--->
73
+ let j = from;
74
+ while (j < n) {
75
+ if (html.startsWith("-->", j)) return j + 3;
76
+ if (html.startsWith("--!>", j)) return j + 4;
77
+ j++;
78
+ }
79
+ return n;
80
+ };
81
+
82
+ /**
83
+ * Reads a tag whose name starts at `from`. Returns the token and the index
84
+ * after the tag, or null when the input ends inside it.
85
+ */
86
+ const readTag = (
87
+ from: number,
88
+ closing: boolean,
89
+ ): { token: HtmlToken; next: number } | null => {
90
+ let j = from;
91
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">") j++;
92
+ const name = asciiLower(html.slice(from, j));
93
+ const attrs: Array<{ name: string; raw: string }> = [];
94
+ const seen = new Set<string>();
95
+ let selfClosing = false;
96
+
97
+ for (;;) {
98
+ // Before attribute name.
99
+ while (j < n && (isWhitespace(html[j]!) || html[j] === "/")) {
100
+ if (html[j] === "/") {
101
+ // Self-closing start tag state.
102
+ if (html[j + 1] === ">") {
103
+ selfClosing = true;
104
+ j++;
105
+ break;
106
+ }
107
+ }
108
+ j++;
109
+ }
110
+ if (j >= n) return null;
111
+ if (html[j] === ">") {
112
+ j++;
113
+ break;
114
+ }
115
+ // Attribute name: a leading `=` is part of the name.
116
+ const nameStart = j;
117
+ j++;
118
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") {
119
+ j++;
120
+ }
121
+ const attrName = asciiLower(html.slice(nameStart, j));
122
+ // After attribute name.
123
+ while (j < n && isWhitespace(html[j]!)) j++;
124
+ let raw = "";
125
+ if (j < n && html[j] === "=") {
126
+ j++;
127
+ while (j < n && isWhitespace(html[j]!)) j++;
128
+ if (j >= n) return null;
129
+ const quote = html[j];
130
+ if (quote === '"' || quote === "'") {
131
+ const end = html.indexOf(quote, j + 1);
132
+ if (end < 0) return null;
133
+ raw = html.slice(j + 1, end);
134
+ j = end + 1;
135
+ } else if (html[j] !== ">") {
136
+ const start = j;
137
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== ">") j++;
138
+ raw = html.slice(start, j);
139
+ }
140
+ }
141
+ if (j >= n) return null;
142
+ if (!seen.has(attrName)) {
143
+ seen.add(attrName);
144
+ attrs.push({ name: attrName, raw });
145
+ }
146
+ }
147
+
148
+ const token: HtmlToken = closing
149
+ ? { kind: "end", name }
150
+ : { kind: "start", name, attrs, selfClosing };
151
+ return { token, next: j };
152
+ };
153
+
154
+ while (i < n) {
155
+ const lt = html.indexOf("<", i);
156
+ if (lt < 0) break;
157
+ const c = html[lt + 1] ?? "";
158
+
159
+ if (isAlpha(c)) {
160
+ const tag = readTag(lt + 1, false);
161
+ flushText(lt);
162
+ if (!tag) {
163
+ // The input ends inside the tag: the browser drops it.
164
+ i = textStart = n;
165
+ break;
166
+ }
167
+ tokens.push(tag.token);
168
+ i = textStart = tag.next;
169
+ const name = tag.token.kind === "start" ? tag.token.name : "";
170
+ if (name === "plaintext") {
171
+ flushText(n);
172
+ textStart = n;
173
+ i = n;
174
+ break;
175
+ }
176
+ if (RAW_TEXT.has(name)) {
177
+ // Read the content as text, up to `</name` followed by a delimiter.
178
+ // ASCII-only lower-casing keeps every index the same as in `html`.
179
+ lower ??= asciiLower(html);
180
+ let j = i;
181
+ let end = -1;
182
+ while ((j = lower.indexOf(`</${name}`, j)) >= 0) {
183
+ const after = html[j + 2 + name.length] ?? "";
184
+ if (after === "" || isWhitespace(after) || after === "/" || after === ">") {
185
+ end = j;
186
+ break;
187
+ }
188
+ j += 2 + name.length;
189
+ }
190
+ if (end < 0) {
191
+ if (i < n) tokens.push({ kind: "text", raw: html.slice(i) });
192
+ i = textStart = n;
193
+ break;
194
+ }
195
+ if (end > i) tokens.push({ kind: "text", raw: html.slice(i, end) });
196
+ const close = readTag(end + 2, true);
197
+ tokens.push({ kind: "end", name });
198
+ i = textStart = close ? close.next : n;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (c === "/") {
204
+ const d = html[lt + 2] ?? "";
205
+ if (isAlpha(d)) {
206
+ const tag = readTag(lt + 2, true);
207
+ flushText(lt);
208
+ if (!tag) {
209
+ i = textStart = n;
210
+ break;
211
+ }
212
+ tokens.push(tag.token);
213
+ i = textStart = tag.next;
214
+ continue;
215
+ }
216
+ if (d === ">") {
217
+ flushText(lt);
218
+ i = textStart = lt + 3;
219
+ continue;
220
+ }
221
+ if (d === "") {
222
+ // `</` at the end of the input is text.
223
+ i = n;
224
+ break;
225
+ }
226
+ flushText(lt);
227
+ i = textStart = skipBogusComment(lt + 2);
228
+ continue;
229
+ }
230
+
231
+ if (c === "!") {
232
+ flushText(lt);
233
+ if (html.startsWith("--", lt + 2)) {
234
+ i = textStart = skipComment(lt + 4);
235
+ } else {
236
+ // DOCTYPE, CDATA and anything else after `<!` end at the next `>`.
237
+ i = textStart = skipBogusComment(lt + 2);
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (c === "?") {
243
+ flushText(lt);
244
+ i = textStart = skipBogusComment(lt + 1);
245
+ continue;
246
+ }
247
+
248
+ // A `<` that starts nothing is text.
249
+ i = lt + 1;
250
+ }
251
+
252
+ flushText(n);
253
+ return tokens;
254
+ }
@@ -5,7 +5,16 @@
5
5
  * only staff can author it, the output is sanitized on every render so a
6
6
  * pasted `<script>`, inline event handler or `javascript:` URL can never end
7
7
  * up in a page.
8
+ *
9
+ * The input is tokenized the way a browser tokenizes it (`html-tokenizer`),
10
+ * and the output is written from scratch: allowed elements with allowed,
11
+ * re-encoded attributes, and text with every `<` and `>` escaped. Nothing from
12
+ * the input is ever copied through as written, so markup the sanitizer does
13
+ * not recognise — an unterminated tag, a stray `<` — reaches the page as
14
+ * visible text and can never be completed into an element by the markup that
15
+ * follows it.
8
16
  */
17
+ import { tokenizeHtml } from "./html-tokenizer";
9
18
 
10
19
  const ALLOWED_TAGS = new Set([
11
20
  "p", "br", "hr", "span", "div",
@@ -19,9 +28,13 @@ const ALLOWED_TAGS = new Set([
19
28
 
20
29
  /** Tags whose entire contents are dropped. */
21
30
  const DROP_WITH_CONTENT = new Set([
22
- "script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math",
31
+ "script", "style", "iframe", "object", "noscript", "template", "svg", "math",
32
+ "textarea", "title", "xmp", "noembed", "noframes", "plaintext",
23
33
  ]);
24
34
 
35
+ /** Dropped elements that can contain themselves, so their end tags are counted. */
36
+ const NESTING = new Set(["svg", "math", "object", "template"]);
37
+
25
38
  const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
26
39
 
27
40
  const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
@@ -58,10 +71,13 @@ const ALLOWED_CSS = new Set([
58
71
 
59
72
  const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
60
73
 
61
- function safeUrl(raw: string): string | null {
74
+ function safeUrl(raw: string, attribute: string): string | null {
62
75
  const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
63
76
  if (!value) return null;
64
- if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) return value;
77
+ // An inline image is an image source, never a link target.
78
+ if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) {
79
+ return attribute === "src" ? value : null;
80
+ }
65
81
  if (!SAFE_URL.test(value)) return null;
66
82
  if (/^\s*javascript:/i.test(value)) return null;
67
83
  return value;
@@ -127,21 +143,17 @@ export function decodeAttr(value: string): string {
127
143
  );
128
144
  }
129
145
 
130
- const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
131
-
132
- function sanitizeAttrs(tag: string, raw: string): string {
133
- const allowed = TAG_ATTRS[tag];
146
+ function sanitizeAttrs(tag: string, attrs: ReadonlyArray<{ name: string; raw: string }>): string {
147
+ const allowed = Object.prototype.hasOwnProperty.call(TAG_ATTRS, tag) ? TAG_ATTRS[tag] : undefined;
134
148
  const parts: string[] = [];
135
- let m: RegExpExecArray | null;
136
- ATTR_RE.lastIndex = 0;
137
- while ((m = ATTR_RE.exec(raw))) {
138
- const name = m[1].toLowerCase();
139
- let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
149
+ for (const attr of attrs) {
150
+ const name = attr.name;
151
+ let value = decodeAttr(attr.raw);
140
152
  if (name.startsWith("on")) continue;
141
153
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
142
154
  if (name === "href" || name === "src" || name === "srcset") {
143
155
  if (name === "srcset") continue;
144
- const url = safeUrl(value);
156
+ const url = safeUrl(value, name);
145
157
  if (!url) continue;
146
158
  value = url;
147
159
  }
@@ -164,30 +176,56 @@ function sanitizeAttrs(tag: string, raw: string): string {
164
176
  return parts.length ? " " + parts.join(" ") : "";
165
177
  }
166
178
 
179
+ /** A character reference as written in text: kept, since it can only decode to text. */
180
+ const CHAR_REF = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{0,31});/;
181
+
182
+ /** Text as written, made safe to emit: no `<` or `>`, and every `&` a reference. */
183
+ function escapeText(raw: string): string {
184
+ let out = "";
185
+ for (let i = 0; i < raw.length; i++) {
186
+ const c = raw[i]!;
187
+ if (c === "<") out += "&lt;";
188
+ else if (c === ">") out += "&gt;";
189
+ else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&amp;";
190
+ else out += c;
191
+ }
192
+ return out;
193
+ }
194
+
167
195
  /** Sanitize an HTML fragment down to the editor's allowlist. */
168
196
  export function sanitizeHtml(input: string | undefined | null): string {
169
197
  if (!input) return "";
170
- // Remove comments and any element whose content must go with it.
171
- let html = input.replace(/<!--[\s\S]*?-->/g, "");
172
- for (const tag of DROP_WITH_CONTENT) {
173
- html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
174
- html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
175
- }
176
-
177
198
  const open: string[] = [];
178
199
  let out = "";
179
- let last = 0;
180
- const tagRe = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
181
- let m: RegExpExecArray | null;
182
-
183
- while ((m = tagRe.exec(html))) {
184
- out += html.slice(last, m.index);
185
- last = m.index + m[0].length;
186
- const closing = Boolean(m[1]);
187
- const tag = m[2].toLowerCase();
200
+ /** The dropped element being skipped, and how deep inside it we are. */
201
+ let skipping: { tag: string; depth: number } | null = null;
202
+
203
+ for (const token of tokenizeHtml(input)) {
204
+ if (skipping) {
205
+ if (token.kind === "start" && token.name === skipping.tag && NESTING.has(token.name)) {
206
+ if (!token.selfClosing) skipping.depth++;
207
+ } else if (token.kind === "end" && token.name === skipping.tag) {
208
+ if (--skipping.depth === 0) skipping = null;
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (token.kind === "text") {
214
+ out += escapeText(token.raw);
215
+ continue;
216
+ }
217
+
218
+ const tag = token.name;
219
+ if (token.kind === "start" && DROP_WITH_CONTENT.has(tag)) {
220
+ // A self-closing <svg/> or <math/> is empty; every other one has content.
221
+ if (!(token.selfClosing && (tag === "svg" || tag === "math"))) {
222
+ skipping = { tag, depth: 1 };
223
+ }
224
+ continue;
225
+ }
188
226
  if (!ALLOWED_TAGS.has(tag)) continue;
189
227
 
190
- if (closing) {
228
+ if (token.kind === "end") {
191
229
  if (VOID_TAGS.has(tag)) continue;
192
230
  const at = open.lastIndexOf(tag);
193
231
  if (at < 0) continue;
@@ -197,22 +235,19 @@ export function sanitizeHtml(input: string | undefined | null): string {
197
235
  }
198
236
 
199
237
  if (VOID_TAGS.has(tag)) {
200
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")} />`;
238
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
201
239
  continue;
202
240
  }
203
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")}>`;
241
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)}>`;
204
242
  open.push(tag);
205
243
  }
206
- out += html.slice(last);
207
244
  while (open.length) out += `</${open.pop()}>`;
208
245
  return out;
209
246
  }
210
247
 
211
248
  /** Plain-text preview of rich text (used for thumbnails and summaries). */
212
249
  export function htmlToText(input: string | undefined | null): string {
213
- return sanitizeHtml(input)
214
- .replace(/<[^>]+>/g, " ")
215
- .replace(/&nbsp;/g, " ")
250
+ return decodeAttr(sanitizeHtml(input).replace(/<[^>]+>/g, " "))
216
251
  .replace(/\s+/g, " ")
217
252
  .trim();
218
253
  }
@@ -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]!;