@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.
- package/CHANGELOG.md +121 -0
- package/docs/PAGES.md +45 -2
- package/package.json +3 -1
- package/src/admin/describe-log.ts +2 -0
- package/src/backoffice/BookingLandingSection.tsx +672 -0
- package/src/backoffice/BookingSettingsCard.tsx +34 -4
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/routes/host.ts +7 -0
- package/src/backoffice/routes/index.tsx +22 -3
- package/src/blocks/components/CountdownBlock.tsx +3 -1
- package/src/blocks/components/CtaBannerBlock.tsx +8 -2
- package/src/blocks/components/HeroFullBlock.tsx +7 -1
- package/src/blocks/components/IconGridBlock.tsx +5 -1
- package/src/blocks/components/InstagramBlock.tsx +5 -1
- package/src/blocks/components/InstagramTiles.tsx +3 -1
- package/src/blocks/components/LinkTilesBlock.tsx +8 -1
- package/src/blocks/components/MediaSplitBlock.tsx +8 -3
- package/src/blocks/components/PriceTableBlock.tsx +6 -5
- package/src/blocks/components/TripListBlock.tsx +5 -1
- package/src/cms/booking-landing.ts +359 -0
- package/src/cms/booking-links.ts +24 -0
- package/src/cms/page-visibility.ts +67 -1
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/navigation/visibility.ts +65 -3
- package/src/popups/runtime.tsx +75 -32
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +231 -5
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/schemas.ts +45 -0
- package/src/site/BookingOffNotice.tsx +73 -0
- package/src/site/PageRenderer.tsx +59 -3
- package/src/site/link-visibility.tsx +21 -2
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- 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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 += "<";
|
|
188
|
+
else if (c === ">") out += ">";
|
|
189
|
+
else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&";
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 (
|
|
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,
|
|
238
|
+
out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
|
|
201
239
|
continue;
|
|
202
240
|
}
|
|
203
|
-
out += `<${tag}${sanitizeAttrs(tag,
|
|
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(/ /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 =
|
|
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
|
-
/**
|
|
39
|
-
|
|
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]!;
|