@bygghammar/cms-platform 0.94.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +39 -3
- package/src/site/routes/public-page.tsx +81 -9
|
@@ -13,6 +13,7 @@ export function ConfirmDialog({
|
|
|
13
13
|
title,
|
|
14
14
|
description,
|
|
15
15
|
details,
|
|
16
|
+
consequences,
|
|
16
17
|
confirmLabel = "Confirm",
|
|
17
18
|
cancelLabel = "Cancel",
|
|
18
19
|
danger = false,
|
|
@@ -23,7 +24,13 @@ export function ConfirmDialog({
|
|
|
23
24
|
}: {
|
|
24
25
|
title: string;
|
|
25
26
|
description?: string;
|
|
27
|
+
/** Short items (names, paths), one line each; long ones are truncated with a tooltip. */
|
|
26
28
|
details?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* What confirming will do, in full sentences. Never truncated, and read out
|
|
31
|
+
* with the description, because a warning cut off at "…" is not a warning.
|
|
32
|
+
*/
|
|
33
|
+
consequences?: string[];
|
|
27
34
|
confirmLabel?: string;
|
|
28
35
|
cancelLabel?: string;
|
|
29
36
|
danger?: boolean;
|
|
@@ -82,7 +89,11 @@ export function ConfirmDialog({
|
|
|
82
89
|
role="dialog"
|
|
83
90
|
aria-modal="true"
|
|
84
91
|
aria-labelledby={`${id}-title`}
|
|
85
|
-
aria-describedby={
|
|
92
|
+
aria-describedby={
|
|
93
|
+
[description ? `${id}-description` : "", consequences?.length ? `${id}-consequences` : ""]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(" ") || undefined
|
|
96
|
+
}
|
|
86
97
|
className="w-full max-w-md border border-ink/15 bg-white"
|
|
87
98
|
onClick={(e) => e.stopPropagation()}
|
|
88
99
|
>
|
|
@@ -98,6 +109,13 @@ export function ConfirmDialog({
|
|
|
98
109
|
</p>
|
|
99
110
|
) : null}
|
|
100
111
|
{children}
|
|
112
|
+
{consequences?.length ? (
|
|
113
|
+
<ul id={`${id}-consequences`} className="list-disc space-y-1.5 pl-5 text-sm text-ink/80">
|
|
114
|
+
{consequences.map((line) => (
|
|
115
|
+
<li key={line}>{line}</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
) : null}
|
|
101
119
|
{details?.length ? (
|
|
102
120
|
<ul className="max-h-40 space-y-1 overflow-auto border border-ink/10 bg-ice/30 p-2 text-[0.72rem] text-ink/70">
|
|
103
121
|
{details.map((d) => (
|
|
@@ -10,6 +10,7 @@ import type { ReactNode } from "react";
|
|
|
10
10
|
|
|
11
11
|
import type { SiteConfig } from "../../config/site-config";
|
|
12
12
|
import type { SecretsLocation } from "../../server/provider-secrets";
|
|
13
|
+
import type { LinkVisibilityPolicy } from "../../navigation/visibility";
|
|
13
14
|
|
|
14
15
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
15
16
|
/** Media field component as the reviews editor consumes it. */
|
|
@@ -233,6 +234,11 @@ export type BackofficeHost = {
|
|
|
233
234
|
*/
|
|
234
235
|
setPageStatus?: HostServerFn;
|
|
235
236
|
duplicatePage?: HostServerFn;
|
|
237
|
+
/**
|
|
238
|
+
* Optional: make a page link-only or list it again (listing only, never a
|
|
239
|
+
* publish). Without it neither screen offers the action.
|
|
240
|
+
*/
|
|
241
|
+
setPageListing?: HostServerFn;
|
|
236
242
|
/**
|
|
237
243
|
* The site's pages, listed.
|
|
238
244
|
*
|
|
@@ -303,6 +309,24 @@ export type BackofficeHost = {
|
|
|
303
309
|
translateTarget?: string;
|
|
304
310
|
translate?: HostServerFn;
|
|
305
311
|
};
|
|
312
|
+
/**
|
|
313
|
+
* Site settings → Booking: the site-wide switch for booking links and the
|
|
314
|
+
* pages that keep theirs while it is off. Optional: without it the card is
|
|
315
|
+
* not shown.
|
|
316
|
+
*/
|
|
317
|
+
booking?: {
|
|
318
|
+
fns: {
|
|
319
|
+
getBookingSettings: HostServerFn;
|
|
320
|
+
setBookingEnabled: HostServerFn;
|
|
321
|
+
setBookingException: HostServerFn;
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* The public link policy as the site currently applies it, so the navigation
|
|
326
|
+
* editor can say why a saved item is not shown. A hook, so a site can read it
|
|
327
|
+
* however it serves it. Optional: without it the editor explains nothing.
|
|
328
|
+
*/
|
|
329
|
+
useLinkVisibility?: () => LinkVisibilityPolicy | undefined;
|
|
306
330
|
/** Browser auth adapters used by the password-reset screen. */
|
|
307
331
|
authAdapters?: {
|
|
308
332
|
/** Subscribes to the recovery session; returns an unsubscribe function. */
|
|
@@ -37,6 +37,7 @@ import { BackupView } from "../BackupView";
|
|
|
37
37
|
import { DomainsView } from "../DomainsView";
|
|
38
38
|
import { EmailSettingsView } from "../EmailSettingsView";
|
|
39
39
|
import { NavigationView } from "../NavigationView";
|
|
40
|
+
import { BookingSettingsCard, type BookingSettingsData } from "../BookingSettingsCard";
|
|
40
41
|
import { ReviewsView, type ReviewsSection } from "../ReviewsView";
|
|
41
42
|
import { ContactDetailsView } from "../ContactDetailsView";
|
|
42
43
|
import { EmailLogsView } from "../EmailLogsView";
|
|
@@ -224,6 +225,53 @@ export function VersionRoute() {
|
|
|
224
225
|
);
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
/** Stand-in for unregistered optional functions; the queries using it stay disabled. */
|
|
229
|
+
const notRegistered = async () => {
|
|
230
|
+
throw new Error("This site has not registered that backoffice function.");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Site settings → Booking, when the host registered the booking functions. */
|
|
234
|
+
function BookingSettingsSection() {
|
|
235
|
+
const host = backofficeHost();
|
|
236
|
+
const booking = host.booking;
|
|
237
|
+
const qc = useQueryClient();
|
|
238
|
+
const load = useServerFn(booking?.fns.getBookingSettings ?? notRegistered);
|
|
239
|
+
const setEnabled = useServerFn(booking?.fns.setBookingEnabled ?? notRegistered);
|
|
240
|
+
const setException = useServerFn(booking?.fns.setBookingException ?? notRegistered);
|
|
241
|
+
const listPages = useServerFn(host.pages?.fns.listPages ?? notRegistered);
|
|
242
|
+
const q = useQuery({
|
|
243
|
+
queryKey: ["admin", "booking-settings"],
|
|
244
|
+
queryFn: () => load(),
|
|
245
|
+
enabled: Boolean(booking),
|
|
246
|
+
});
|
|
247
|
+
const pagesQ = useQuery({
|
|
248
|
+
queryKey: ["cms", "pages-list"],
|
|
249
|
+
queryFn: () => listPages(),
|
|
250
|
+
enabled: Boolean(booking && host.pages?.fns.listPages),
|
|
251
|
+
});
|
|
252
|
+
const resolveUrl = host.pages?.usePageUrl?.();
|
|
253
|
+
if (!booking) return null;
|
|
254
|
+
return (
|
|
255
|
+
<BookingSettingsCard
|
|
256
|
+
deps={{
|
|
257
|
+
settings: q.data as BookingSettingsData | undefined,
|
|
258
|
+
error: (q.error as Error | null) ?? null,
|
|
259
|
+
pages: (pagesQ.data as Array<{ id: string; key: string; name: string }> | undefined)?.map(
|
|
260
|
+
(page) => ({ id: page.id, key: page.key, name: page.name }),
|
|
261
|
+
),
|
|
262
|
+
pathFor: (key) =>
|
|
263
|
+
resolveUrl ? resolveUrl(key, host.siteConfig.defaultLocale).path : `/${key}`,
|
|
264
|
+
setEnabled: (input) => setEnabled({ data: input } as never),
|
|
265
|
+
setException: (input) => setException({ data: input } as never),
|
|
266
|
+
refresh: () => {
|
|
267
|
+
void q.refetch();
|
|
268
|
+
void qc.invalidateQueries({ queryKey: ["admin", "link-visibility"] });
|
|
269
|
+
},
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
227
275
|
/** Site settings screen (code defaults plus database overrides). */
|
|
228
276
|
export function SiteSettingsRoute() {
|
|
229
277
|
const host = backofficeHost();
|
|
@@ -259,6 +307,7 @@ export function SiteSettingsRoute() {
|
|
|
259
307
|
window.location.href = contactPath;
|
|
260
308
|
},
|
|
261
309
|
MediaField: host.components.MediaField,
|
|
310
|
+
bookingSection: <BookingSettingsSection />,
|
|
262
311
|
}}
|
|
263
312
|
/>
|
|
264
313
|
);
|
|
@@ -715,10 +764,12 @@ export function NavigationRoute() {
|
|
|
715
764
|
queryFn: () => loadSettings(),
|
|
716
765
|
});
|
|
717
766
|
const settingOverrides = (settingsQ.data?.overrides ?? {}) as SiteSettingOverrides;
|
|
767
|
+
const linkPolicy = (host.useLinkVisibility ?? noLinkVisibility)();
|
|
718
768
|
|
|
719
769
|
return (
|
|
720
770
|
<NavigationView
|
|
721
771
|
deps={{
|
|
772
|
+
linkPolicy,
|
|
722
773
|
overrides,
|
|
723
774
|
site,
|
|
724
775
|
defaults: host.content.defaults,
|
|
@@ -749,6 +800,9 @@ export function NavigationRoute() {
|
|
|
749
800
|
*/
|
|
750
801
|
const noEffectiveAdminHost = async () => null;
|
|
751
802
|
|
|
803
|
+
/** A host without a link policy: the navigation editor explains nothing. */
|
|
804
|
+
const noLinkVisibility = () => undefined;
|
|
805
|
+
|
|
752
806
|
/** Per-locale domain mapping. */
|
|
753
807
|
function DomainsRouteInner() {
|
|
754
808
|
const host = backofficeHost();
|
|
@@ -21,6 +21,11 @@ import { useResolvedSiteConfig } from "../shell/use-resolved-config";
|
|
|
21
21
|
import { ResetPasswordView } from "../ResetPasswordView";
|
|
22
22
|
import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
|
|
23
23
|
|
|
24
|
+
/** Stand-in for unregistered optional functions; never called (the action is not offered). */
|
|
25
|
+
const notRegistered = async () => {
|
|
26
|
+
throw new Error("This site has not registered that page function.");
|
|
27
|
+
};
|
|
28
|
+
|
|
24
29
|
const notify = {
|
|
25
30
|
success: (m: string) => toast.success(m),
|
|
26
31
|
error: (m: string) => toast.error(m),
|
|
@@ -47,6 +52,8 @@ export function PageBuilderRoute() {
|
|
|
47
52
|
const listPubs = useServerFn(pages.fns.listPagePublications);
|
|
48
53
|
const cancelPub = useServerFn(pages.fns.cancelPagePublication);
|
|
49
54
|
const rename = useServerFn(pages.fns.renamePage);
|
|
55
|
+
const setStatus = useServerFn(pages.fns.setPageStatus ?? notRegistered);
|
|
56
|
+
const setListing = useServerFn(pages.fns.setPageListing ?? notRegistered);
|
|
50
57
|
|
|
51
58
|
const pageQ = useQuery({
|
|
52
59
|
queryKey: ["cms", "page-edit", key],
|
|
@@ -127,6 +134,12 @@ export function PageBuilderRoute() {
|
|
|
127
134
|
restoreVersion: (versionId, editSessionId) =>
|
|
128
135
|
restore({ data: { versionId, editSessionId } }),
|
|
129
136
|
rename: (input) => rename({ data: input }),
|
|
137
|
+
setPageStatus: pages.fns.setPageStatus
|
|
138
|
+
? (input) => setStatus({ data: input } as never)
|
|
139
|
+
: undefined,
|
|
140
|
+
setPageListing: pages.fns.setPageListing
|
|
141
|
+
? (input) => setListing({ data: input } as never)
|
|
142
|
+
: undefined,
|
|
130
143
|
refetchPage: () => void pageQ.refetch(),
|
|
131
144
|
refetchVersions: () => void versionsQ.refetch(),
|
|
132
145
|
refetchPublications: () => void pubsQ.refetch(),
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking links: which addresses count as one, and how they are taken out of
|
|
3
|
+
* public content while a site has booking switched off.
|
|
4
|
+
*
|
|
5
|
+
* A booking link is an `https:` address whose host starts with `booking` —
|
|
6
|
+
* `https://booking.voyara.se/…`, `https://booking.example.com`. That is the
|
|
7
|
+
* whole rule. The address is read the way a browser reads it (character
|
|
8
|
+
* references decoded, tabs and line breaks dropped, the ends trimmed, a
|
|
9
|
+
* protocol-relative `//booking…` taken as `https:`), and only the scheme and
|
|
10
|
+
* host decide. A link to another site whose query string happens to contain a
|
|
11
|
+
* booking address is not a booking link, and neither is plain text that
|
|
12
|
+
* mentions one.
|
|
13
|
+
*
|
|
14
|
+
* Removal is applied to a copy of the content on its way out of the server,
|
|
15
|
+
* never to what is stored: switching booking back on restores every link
|
|
16
|
+
* exactly as it was written.
|
|
17
|
+
*/
|
|
18
|
+
import { decodeAttr, sanitizeHtml } from "../html/sanitize-html";
|
|
19
|
+
|
|
20
|
+
/** A browser drops these anywhere inside a URL before parsing it. */
|
|
21
|
+
const DROPPED_INSIDE = /[\t\n\r]/g;
|
|
22
|
+
/** And trims C0 controls and spaces from both ends. */
|
|
23
|
+
const TRIMMED_ENDS = /^[\u0000- ]+|[\u0000- ]+$/g;
|
|
24
|
+
|
|
25
|
+
/** True when `raw` is an address that leads to a booking site. */
|
|
26
|
+
export function isBookingHref(raw: unknown): boolean {
|
|
27
|
+
if (typeof raw !== "string" || raw === "") return false;
|
|
28
|
+
let value = decodeAttr(raw).replace(DROPPED_INSIDE, "").replace(TRIMMED_ENDS, "");
|
|
29
|
+
if (!value) return false;
|
|
30
|
+
if (value.startsWith("//")) value = `https:${value}`;
|
|
31
|
+
let url: URL;
|
|
32
|
+
try {
|
|
33
|
+
// No base: a relative address ("/boka", "boka") is never a booking site.
|
|
34
|
+
url = new URL(value);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return url.protocol === "https:" && url.hostname.startsWith("booking");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every tag, as the sanitizer reads them (quoted `>` included). */
|
|
42
|
+
const TAG = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
|
43
|
+
const HREF = /(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
|
|
44
|
+
|
|
45
|
+
/** Placeholder for a removed link while emptied containers are collapsed. */
|
|
46
|
+
const REMOVED = "\u0000booking-link\u0000";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Containers that existed only to hold a removed link and are dropped with it:
|
|
50
|
+
* a paragraph, list item or heading that contained the "Boka" button and
|
|
51
|
+
* nothing else would otherwise leave an empty line behind.
|
|
52
|
+
*/
|
|
53
|
+
const COLLAPSIBLE =
|
|
54
|
+
"p|li|ul|ol|div|span|strong|b|em|i|u|s|small|mark|h1|h2|h3|h4|h5|h6|figure|figcaption|blockquote";
|
|
55
|
+
const FILLER = `(?:\\s| |\\u00a0|<br\\s*\\/?>|${REMOVED})`;
|
|
56
|
+
const EMPTIED = new RegExp(
|
|
57
|
+
`<(${COLLAPSIBLE})(?:\\s[^>]*)?>${FILLER}*${REMOVED}${FILLER}*<\\/\\1>`,
|
|
58
|
+
"gi",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
function anchorLeadsToBooking(attributes: string): boolean {
|
|
62
|
+
const match = HREF.exec(attributes);
|
|
63
|
+
if (!match) return false;
|
|
64
|
+
return isBookingHref(match[1] ?? match[2] ?? match[3] ?? "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same HTML with every booking link removed, link text included.
|
|
69
|
+
*
|
|
70
|
+
* Everything else — the prose around the link, other links, images, styling —
|
|
71
|
+
* is left exactly as it was. Input with no booking link comes back unchanged
|
|
72
|
+
* (the same string); input with one comes back sanitized, which is what every
|
|
73
|
+
* renderer does to it anyway.
|
|
74
|
+
*/
|
|
75
|
+
export function stripBookingFromHtml(html: string): string {
|
|
76
|
+
if (!/<\s*a\b/i.test(html)) return html;
|
|
77
|
+
const clean = sanitizeHtml(html);
|
|
78
|
+
let out = "";
|
|
79
|
+
let last = 0;
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let removed = false;
|
|
82
|
+
TAG.lastIndex = 0;
|
|
83
|
+
let match: RegExpExecArray | null;
|
|
84
|
+
while ((match = TAG.exec(clean))) {
|
|
85
|
+
const closing = Boolean(match[1]);
|
|
86
|
+
const tag = match[2]!.toLowerCase();
|
|
87
|
+
if (depth > 0) {
|
|
88
|
+
if (tag === "a") depth += closing ? -1 : 1;
|
|
89
|
+
if (depth === 0) {
|
|
90
|
+
out += REMOVED;
|
|
91
|
+
last = match.index + match[0].length;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!closing && tag === "a" && anchorLeadsToBooking(match[3] ?? "")) {
|
|
96
|
+
out += clean.slice(last, match.index);
|
|
97
|
+
depth = 1;
|
|
98
|
+
removed = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!removed) return html;
|
|
102
|
+
out += depth > 0 ? REMOVED : clean.slice(last);
|
|
103
|
+
let previous: string;
|
|
104
|
+
do {
|
|
105
|
+
previous = out;
|
|
106
|
+
out = out.replace(EMPTIED, REMOVED);
|
|
107
|
+
} while (out !== previous);
|
|
108
|
+
return out.split(REMOVED).join("");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A copy of any JSON-shaped value with booking links taken out.
|
|
113
|
+
*
|
|
114
|
+
* A string that *is* a booking address becomes empty — every block treats an
|
|
115
|
+
* empty link as "no button", so a hero keeps its picture and heading and loses
|
|
116
|
+
* only the button — and booking links inside HTML strings are removed with
|
|
117
|
+
* {@link stripBookingFromHtml}. Objects and arrays are rebuilt only where
|
|
118
|
+
* something changed, so untouched content keeps its identity.
|
|
119
|
+
*/
|
|
120
|
+
export function stripBookingDeep<T>(value: T): T {
|
|
121
|
+
return strip(value) as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function strip(value: unknown): unknown {
|
|
125
|
+
if (typeof value === "string") {
|
|
126
|
+
if (isBookingHref(value)) return "";
|
|
127
|
+
return value.includes("<") ? stripBookingFromHtml(value) : value;
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
let changed = false;
|
|
131
|
+
const next = value.map((item) => {
|
|
132
|
+
const out = strip(item);
|
|
133
|
+
if (out !== item) changed = true;
|
|
134
|
+
return out;
|
|
135
|
+
});
|
|
136
|
+
return changed ? next : value;
|
|
137
|
+
}
|
|
138
|
+
if (value !== null && typeof value === "object") {
|
|
139
|
+
let changed = false;
|
|
140
|
+
const next: Record<string, unknown> = {};
|
|
141
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
142
|
+
const out = strip(item);
|
|
143
|
+
if (out !== item) changed = true;
|
|
144
|
+
next[key] = out;
|
|
145
|
+
}
|
|
146
|
+
return changed ? next : value;
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True when any string anywhere inside `value` leads to a booking site. */
|
|
152
|
+
export function containsBookingTarget(value: unknown): boolean {
|
|
153
|
+
if (typeof value === "string") {
|
|
154
|
+
if (isBookingHref(value)) return true;
|
|
155
|
+
return value.includes("<") && stripBookingFromHtml(value) !== value;
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(value)) return value.some(containsBookingTarget);
|
|
158
|
+
if (value !== null && typeof value === "object") {
|
|
159
|
+
return Object.values(value as Record<string, unknown>).some(containsBookingTarget);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Popups to show while booking is off: every popup that links to a booking
|
|
166
|
+
* site is withheld whole.
|
|
167
|
+
*
|
|
168
|
+
* A popup is one short message with one purpose. Taking only the button out of
|
|
169
|
+
* one that says "Book your cabin now" leaves a promise with no way to act on
|
|
170
|
+
* it, and an answer or a form that ends by opening the booking site cannot be
|
|
171
|
+
* removed without changing what the popup does — so the popup is simply not
|
|
172
|
+
* offered until booking is back.
|
|
173
|
+
*/
|
|
174
|
+
export function withoutBookingPopups<T>(configs: readonly T[]): T[] {
|
|
175
|
+
return configs.filter((config) => !containsBookingTarget(config));
|
|
176
|
+
}
|
|
@@ -155,21 +155,26 @@ export function isPubliclyVisible(state: PublicationState): boolean {
|
|
|
155
155
|
return state === "published" || state === "ending";
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/*
|
|
159
|
+
* "Offline" rather than "Hidden": a page can also be link-only — online, but
|
|
160
|
+
* left out of menus and the sitemap — and two different states must not share
|
|
161
|
+
* the word "hidden".
|
|
162
|
+
*/
|
|
158
163
|
export const PUBLICATION_STATE_LABELS: Record<PublicationState, string> = {
|
|
159
|
-
draft: "
|
|
164
|
+
draft: "Offline",
|
|
160
165
|
scheduled: "Scheduled to publish",
|
|
161
166
|
published: "Published",
|
|
162
|
-
ending: "Scheduled to
|
|
163
|
-
expired: "
|
|
167
|
+
ending: "Scheduled to go offline",
|
|
168
|
+
expired: "Offline after expiry",
|
|
164
169
|
};
|
|
165
170
|
|
|
166
171
|
/** Longer wording for tooltips and screen-reader descriptions. */
|
|
167
172
|
export const PUBLICATION_STATE_DESCRIPTIONS: Record<PublicationState, string> = {
|
|
168
|
-
draft: "Not
|
|
173
|
+
draft: "Not on the public website; visitors get “page not found”. Staff can still edit and preview it.",
|
|
169
174
|
scheduled: "Not visible yet. It appears by itself when its publication window opens.",
|
|
170
175
|
published: "Visible on the public website right now.",
|
|
171
|
-
ending: "Visible now, and scheduled to
|
|
172
|
-
expired: "
|
|
176
|
+
ending: "Visible now, and scheduled to go offline when its window closes.",
|
|
177
|
+
expired: "Offline — its publication window has closed.",
|
|
173
178
|
};
|
|
174
179
|
|
|
175
180
|
/**
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page listing and the public policy built from it.
|
|
3
|
+
*
|
|
4
|
+
* Two independent properties decide how a page appears to visitors:
|
|
5
|
+
*
|
|
6
|
+
* - **Publication** — can a visitor open it at all? Publication windows answer
|
|
7
|
+
* that (`cms/page-publication`); Unpublish closes them.
|
|
8
|
+
* - **Listing** — is it advertised? A `listed` page appears in menus, footers
|
|
9
|
+
* and the sitemap like any other. A `link-only` page is just as reachable,
|
|
10
|
+
* but only for someone who has its address: it is left out of navigation and
|
|
11
|
+
* the sitemap and asks search engines not to index it.
|
|
12
|
+
*
|
|
13
|
+
* Listing never publishes, unpublishes, replaces or reschedules anything, and
|
|
14
|
+
* nothing that publishes changes listing. It is not access control either:
|
|
15
|
+
* a link-only address can be shared, guessed or found, and `noindex` is a
|
|
16
|
+
* request to crawlers, not a removal from results that already exist.
|
|
17
|
+
*/
|
|
18
|
+
import type { PublicationState } from "./page-publication";
|
|
19
|
+
import { isPubliclyVisible } from "./page-publication";
|
|
20
|
+
|
|
21
|
+
export const PAGE_LISTINGS = ["listed", "link-only"] as const;
|
|
22
|
+
export type PageListing = (typeof PAGE_LISTINGS)[number];
|
|
23
|
+
|
|
24
|
+
/** Pages are listed unless somebody decided otherwise. */
|
|
25
|
+
export const DEFAULT_PAGE_LISTING: PageListing = "listed";
|
|
26
|
+
|
|
27
|
+
export function normalizePageListing(raw: unknown): PageListing {
|
|
28
|
+
return raw === "link-only" ? "link-only" : "listed";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The three answers the screens lead with, in a visitor's terms. */
|
|
32
|
+
export type PageVisibility = "published" | "link-only" | "offline";
|
|
33
|
+
|
|
34
|
+
export const PAGE_VISIBILITY_LABELS: Record<PageVisibility, string> = {
|
|
35
|
+
published: "Published",
|
|
36
|
+
"link-only": "Link only",
|
|
37
|
+
offline: "Offline",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PAGE_VISIBILITY_DESCRIPTIONS: Record<PageVisibility, string> = {
|
|
41
|
+
published: "Visible on the public website and listed in menus, footer and sitemap.",
|
|
42
|
+
"link-only":
|
|
43
|
+
"Visible to anyone who has the exact address, but left out of menus, footer and sitemap, " +
|
|
44
|
+
"and search engines are asked not to index it. It is not private: the address can be shared.",
|
|
45
|
+
offline: "Not on the public website. Visitors get “page not found”.",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The headline answer for one page, from both properties.
|
|
50
|
+
*
|
|
51
|
+
* Only the headline: schedules and editorial status keep their own badges,
|
|
52
|
+
* because "offline" says nothing about a window that opens tomorrow.
|
|
53
|
+
*/
|
|
54
|
+
export function pageVisibility(publication: PublicationState, listing: PageListing): PageVisibility {
|
|
55
|
+
if (!isPubliclyVisible(publication)) return "offline";
|
|
56
|
+
return listing === "link-only" ? "link-only" : "published";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ------------------------------------------------------------------------ */
|
|
60
|
+
/* Confirmation copy */
|
|
61
|
+
/* ------------------------------------------------------------------------ */
|
|
62
|
+
|
|
63
|
+
export type VisibilityConfirmation = {
|
|
64
|
+
title: string;
|
|
65
|
+
body: string[];
|
|
66
|
+
confirmLabel: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** What Make link-only does to this page, said before it happens. */
|
|
70
|
+
export function linkOnlyConfirmation(page: {
|
|
71
|
+
name: string;
|
|
72
|
+
path: string;
|
|
73
|
+
live: boolean;
|
|
74
|
+
}): VisibilityConfirmation {
|
|
75
|
+
return {
|
|
76
|
+
title: `Make “${page.name}” link-only?`,
|
|
77
|
+
body: [
|
|
78
|
+
page.live
|
|
79
|
+
? `${page.path} stays online for anyone who has the address.`
|
|
80
|
+
: `${page.path} is offline now; once published it will only be reachable by its address.`,
|
|
81
|
+
"It disappears from the menu, the footer and the sitemap, and search engines are asked not to index it.",
|
|
82
|
+
"This is not a password: anyone with the link can open it, and it can be shared.",
|
|
83
|
+
],
|
|
84
|
+
confirmLabel: "Make link-only",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What Show on website does: listing only, never a publish. */
|
|
89
|
+
export function showOnWebsiteConfirmation(page: {
|
|
90
|
+
name: string;
|
|
91
|
+
path: string;
|
|
92
|
+
live: boolean;
|
|
93
|
+
}): VisibilityConfirmation {
|
|
94
|
+
return {
|
|
95
|
+
title: `Show “${page.name}” on the website?`,
|
|
96
|
+
body: [
|
|
97
|
+
"Menu and footer links to it reappear, it is added to the sitemap, and search engines may index it.",
|
|
98
|
+
page.live
|
|
99
|
+
? `${page.path} is already online; nothing about its content changes.`
|
|
100
|
+
: `${page.path} is offline: this does not publish it. It is listed again once it is published.`,
|
|
101
|
+
],
|
|
102
|
+
confirmLabel: "Show on website",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The extra sentences Unpublish adds to the existing schedule-aware warning. */
|
|
107
|
+
export function unpublishConsequences(page: { path: string; listing: PageListing }): string[] {
|
|
108
|
+
const lines = [
|
|
109
|
+
`Visitors to ${page.path} will get “page not found”.`,
|
|
110
|
+
"Menu and footer links to it are hidden until it is published again; the menu itself is not changed.",
|
|
111
|
+
];
|
|
112
|
+
if (page.listing === "link-only") {
|
|
113
|
+
lines.push("It stays link-only, so publishing it again will not list it.");
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Why a protected page offers neither action. */
|
|
119
|
+
export const PROTECTED_PAGE_REASON =
|
|
120
|
+
"This page is protected: the start page and the contact page always stay published and listed.";
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------------ */
|
|
123
|
+
/* Public policy */
|
|
124
|
+
/* ------------------------------------------------------------------------ */
|
|
125
|
+
|
|
126
|
+
/** One page, as public rendering needs to know it. */
|
|
127
|
+
export type PolicyPage = {
|
|
128
|
+
id: string;
|
|
129
|
+
key: string;
|
|
130
|
+
/** Can a visitor open it right now (publication windows, trash excluded)? */
|
|
131
|
+
live: boolean;
|
|
132
|
+
listing: PageListing;
|
|
133
|
+
/** Booking links in this page's own content stay while booking is off. */
|
|
134
|
+
bookingException: boolean;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Everything public rendering decides from, read once on the server.
|
|
139
|
+
*
|
|
140
|
+
* `known: false` is a read that failed. It is never a default: every question
|
|
141
|
+
* below then gets its restrictive answer — booking hidden, nothing indexable,
|
|
142
|
+
* no page reported offline (a failed read is not proof a page is gone).
|
|
143
|
+
*/
|
|
144
|
+
export type PublicPolicySnapshot = {
|
|
145
|
+
known: boolean;
|
|
146
|
+
bookingEnabled: boolean;
|
|
147
|
+
pages: PolicyPage[];
|
|
148
|
+
/** The next instant any of this changes on its own (a publication boundary). */
|
|
149
|
+
revalidateAt: number | null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const UNKNOWN_PUBLIC_POLICY: PublicPolicySnapshot = Object.freeze({
|
|
153
|
+
known: false,
|
|
154
|
+
bookingEnabled: false,
|
|
155
|
+
pages: [],
|
|
156
|
+
revalidateAt: null,
|
|
157
|
+
}) as PublicPolicySnapshot;
|
|
158
|
+
|
|
159
|
+
export function policyPage(policy: PublicPolicySnapshot, key: string): PolicyPage | null {
|
|
160
|
+
return policy.pages.find((page) => page.key === key) ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** May search engines index this page? Only a live, listed page, read successfully. */
|
|
164
|
+
export function pageIsIndexable(policy: PublicPolicySnapshot, key: string): boolean {
|
|
165
|
+
const page = policyPage(policy, key);
|
|
166
|
+
return policy.known && page !== null && page.live && page.listing === "listed";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Is this page positively known to be offline?
|
|
171
|
+
*
|
|
172
|
+
* Only a successful read that found the page and found it not live says so.
|
|
173
|
+
* An unknown page (no CMS row) or a failed read is not offline — the caller
|
|
174
|
+
* renders what it always rendered, or reports the error.
|
|
175
|
+
*/
|
|
176
|
+
export function pageIsKnownOffline(policy: PublicPolicySnapshot, key: string): boolean {
|
|
177
|
+
if (!policy.known) return false;
|
|
178
|
+
const page = policyPage(policy, key);
|
|
179
|
+
return page !== null && !page.live;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Booking in site-wide chrome: header button, menu, footer, popups. */
|
|
183
|
+
export function chromeBookingVisible(policy: PublicPolicySnapshot): boolean {
|
|
184
|
+
return policy.known && policy.bookingEnabled;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Booking in one page's own content: on when booking is on, or the page is an exception. */
|
|
188
|
+
export function pageBookingVisible(policy: PublicPolicySnapshot, key: string | null): boolean {
|
|
189
|
+
if (!policy.known) return false;
|
|
190
|
+
if (policy.bookingEnabled) return true;
|
|
191
|
+
return key !== null && policyPage(policy, key)?.bookingException === true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type HiddenPageReason = "offline" | "link-only";
|
|
195
|
+
|
|
196
|
+
/** Pages navigation must not point at, and why. */
|
|
197
|
+
export function unlistedPages(
|
|
198
|
+
policy: PublicPolicySnapshot,
|
|
199
|
+
): Array<{ key: string; reason: HiddenPageReason }> {
|
|
200
|
+
const out: Array<{ key: string; reason: HiddenPageReason }> = [];
|
|
201
|
+
for (const page of policy.pages) {
|
|
202
|
+
if (!page.live) out.push({ key: page.key, reason: "offline" });
|
|
203
|
+
else if (page.listing === "link-only") out.push({ key: page.key, reason: "link-only" });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
@@ -114,7 +114,7 @@ const NAMED_REFERENCES: Record<string, string> = {
|
|
|
114
114
|
* was checked: `javascript:` is judged as `javascript:`, and an
|
|
115
115
|
* undecoded `javascript:` never becomes one.
|
|
116
116
|
*/
|
|
117
|
-
function decodeAttr(value: string): string {
|
|
117
|
+
export function decodeAttr(value: string): string {
|
|
118
118
|
return value.replace(
|
|
119
119
|
/&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
|
|
120
120
|
(reference, decimal?: string, hex?: string, name?: string) => {
|