@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,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
|
+
}
|
package/src/cms/booking-links.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/src/forms/form-audit.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Every backoffice change to a form (settings + fields) is turned into a list
|
|
3
3
|
// of short change descriptions so the Activity view answers who/what/when.
|
|
4
4
|
|
|
5
|
+
import { redactUrl } from "../util/redact-url";
|
|
6
|
+
|
|
5
7
|
export type FormAuditChange = {
|
|
6
8
|
/** Machine scope: "form" | "settings" | "field" */
|
|
7
9
|
scope: "form" | "settings" | "field";
|
|
@@ -26,6 +28,24 @@ export type FormSnapshotInput = {
|
|
|
26
28
|
|
|
27
29
|
const MAX_LEN = 160;
|
|
28
30
|
|
|
31
|
+
/** Delivery settings whose value is a credential: the audit says it changed, never what to. */
|
|
32
|
+
const SECRET_SETTING = /secret|token|password|apikey|api_key/i;
|
|
33
|
+
|
|
34
|
+
/** An address as the audit shows it: its origin only — keys travel in paths and query strings. */
|
|
35
|
+
function addressOnly(v: unknown): unknown {
|
|
36
|
+
if (typeof v !== "string" || v.trim() === "") return v;
|
|
37
|
+
const redacted = redactUrl(v.trim());
|
|
38
|
+
return redacted === "(invalid address)" ? "(not a valid address)" : redacted;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A delivery setting's value as the audit may record it. */
|
|
42
|
+
function auditable(group: string, key: string, v: unknown): unknown {
|
|
43
|
+
if (group !== "private_settings") return v;
|
|
44
|
+
if (SECRET_SETTING.test(key)) return v == null || v === "" ? v : "(hidden)";
|
|
45
|
+
if (/url$/i.test(key)) return addressOnly(v);
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
function short(v: unknown): string {
|
|
30
50
|
if (v == null) return "—";
|
|
31
51
|
if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
|
|
@@ -143,8 +163,8 @@ export function diffForms(
|
|
|
143
163
|
out.push({
|
|
144
164
|
scope: "settings",
|
|
145
165
|
label: `${label}: ${humanKey(k)}`,
|
|
146
|
-
from: short(a[k]),
|
|
147
|
-
to: short(b[k]),
|
|
166
|
+
from: short(auditable(group, k, a[k])),
|
|
167
|
+
to: short(auditable(group, k, b[k])),
|
|
148
168
|
});
|
|
149
169
|
}
|
|
150
170
|
}
|