@bygghammar/cms-platform 0.93.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 +141 -0
- package/docs/PAGES.md +72 -4
- package/package.json +9 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -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/VersionView.tsx +747 -66
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +148 -9
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- 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/config/modules.ts +11 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/package-name.ts +8 -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
|
@@ -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
|
+
}
|
package/src/config/modules.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
MessageSquareQuote,
|
|
24
24
|
} from "lucide-react";
|
|
25
25
|
|
|
26
|
+
import { POPUP_PACKAGE } from "../popups/package-name";
|
|
27
|
+
|
|
26
28
|
/**
|
|
27
29
|
* Every module a site can switch on.
|
|
28
30
|
*
|
|
@@ -90,6 +92,14 @@ export type CmsModuleDefinition = {
|
|
|
90
92
|
* enabled, so a disabled module mounts nothing.
|
|
91
93
|
*/
|
|
92
94
|
readonly hasPublicRuntime?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* A separately published first-party package the module's runtime is built
|
|
97
|
+
* on, beyond the CMS package itself. The version screen reports its installed
|
|
98
|
+
* and published versions; every other module ships inside the CMS package and
|
|
99
|
+
* updates with it. Naming a package here also allows the server to look it up
|
|
100
|
+
* in the public registry, so only first-party packages belong here.
|
|
101
|
+
*/
|
|
102
|
+
readonly package?: `@bygghammar/${string}`;
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
/**
|
|
@@ -145,6 +155,7 @@ export const CMS_MODULES = [
|
|
|
145
155
|
nav: { path: "/popups", label: "Popups", icon: MessageSquare },
|
|
146
156
|
requiresRole: "staff",
|
|
147
157
|
hasPublicRuntime: true,
|
|
158
|
+
package: POPUP_PACKAGE,
|
|
148
159
|
},
|
|
149
160
|
] as const satisfies readonly CmsModuleDefinition[];
|
|
150
161
|
|
|
@@ -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) => {
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which saved menu and footer links a visitor is shown right now.
|
|
3
|
+
*
|
|
4
|
+
* Saved navigation is never edited by a page changing state: an item pointing
|
|
5
|
+
* at a page that is offline or link-only is simply not rendered, and comes back
|
|
6
|
+
* by itself when the page is published or listed again. The same goes for
|
|
7
|
+
* booking links while booking is switched off.
|
|
8
|
+
*
|
|
9
|
+
* Only links positively known to point at such a page are hidden. External
|
|
10
|
+
* links, `#fragment` links, `mailto:`/`tel:` and every internal address the CMS
|
|
11
|
+
* does not know about are left alone — an address missing from the CMS list is
|
|
12
|
+
* a route the site serves some other way, not an offline page.
|
|
13
|
+
*/
|
|
14
|
+
import { isBookingHref } from "../cms/booking-links";
|
|
15
|
+
import type { HiddenPageReason } from "../cms/page-visibility";
|
|
16
|
+
import { normalizeOrigin, normalizeRoutePath } from "../site/route-index";
|
|
17
|
+
import type { NavigationItem } from "./index";
|
|
18
|
+
|
|
19
|
+
/** Why a saved link is not shown. */
|
|
20
|
+
export type HiddenLinkReason = HiddenPageReason | "booking" | "unknown";
|
|
21
|
+
|
|
22
|
+
export const HIDDEN_LINK_REASONS: Record<HiddenLinkReason, string> = {
|
|
23
|
+
offline: "Not shown on the site: the page it links to is offline.",
|
|
24
|
+
"link-only": "Not shown on the site: the page it links to is link-only.",
|
|
25
|
+
booking: "Not shown on the site: booking is switched off in Site settings.",
|
|
26
|
+
unknown: "Not shown right now: the site could not read which pages are public.",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the chrome needs to decide, in a serializable shape a server can hand
|
|
31
|
+
* to the browser with the page.
|
|
32
|
+
*/
|
|
33
|
+
export type LinkVisibilityPolicy = {
|
|
34
|
+
/** False when the server could not read the policy: see {@link linkHideReason}. */
|
|
35
|
+
known: boolean;
|
|
36
|
+
/** Booking links in site-wide chrome (menu, footer, header button). */
|
|
37
|
+
bookingVisible: boolean;
|
|
38
|
+
/** Normalized served paths of unlisted pages, every locale and alias. */
|
|
39
|
+
hiddenPaths: Readonly<Record<string, HiddenPageReason>>;
|
|
40
|
+
/** Origins that are this site, for absolute links written with the domain. */
|
|
41
|
+
origins: readonly string[];
|
|
42
|
+
/** Locale prefixes, so `/EN/...` and `/en/...` compare equal. */
|
|
43
|
+
locales?: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** No filtering at all: what screens without a public policy render with. */
|
|
47
|
+
export const OPEN_LINK_POLICY: LinkVisibilityPolicy = Object.freeze({
|
|
48
|
+
known: true,
|
|
49
|
+
bookingVisible: true,
|
|
50
|
+
hiddenPaths: Object.freeze({}),
|
|
51
|
+
origins: Object.freeze([]) as readonly string[],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The site path an address leads to, or null when it leads off the site (or
|
|
56
|
+
* is not a navigable path at all).
|
|
57
|
+
*/
|
|
58
|
+
export function internalPathOf(
|
|
59
|
+
href: string,
|
|
60
|
+
policy: Pick<LinkVisibilityPolicy, "origins" | "locales">,
|
|
61
|
+
): string | null {
|
|
62
|
+
const value = href.trim();
|
|
63
|
+
if (!value || value.startsWith("#")) return null;
|
|
64
|
+
const pathPolicy = { locales: policy.locales ?? [] };
|
|
65
|
+
if (value.startsWith("/") && !value.startsWith("//")) {
|
|
66
|
+
return normalizeRoutePath(value, pathPolicy);
|
|
67
|
+
}
|
|
68
|
+
const absolute = value.startsWith("//") ? `https:${value}` : value;
|
|
69
|
+
if (!/^https?:\/\//i.test(absolute)) return null;
|
|
70
|
+
let url: URL;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(absolute);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const own = new Set(policy.origins.map((origin) => normalizeOrigin(origin)).filter(Boolean));
|
|
77
|
+
if (!own.has(url.origin)) return null;
|
|
78
|
+
return normalizeRoutePath(url.pathname, pathPolicy);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Why this saved link is hidden, or null when it is shown. */
|
|
82
|
+
export function linkHideReason(href: string, policy: LinkVisibilityPolicy): HiddenLinkReason | null {
|
|
83
|
+
if (!policy.bookingVisible && isBookingHref(href)) return "booking";
|
|
84
|
+
const path = internalPathOf(href, policy);
|
|
85
|
+
if (path === null) return null;
|
|
86
|
+
/*
|
|
87
|
+
* A policy that could not be read hides every internal page link except the
|
|
88
|
+
* front page, rather than showing links to pages that may be offline or
|
|
89
|
+
* link-only. The pages themselves come from the same database, so a read
|
|
90
|
+
* that failed here is rarely the only thing failing.
|
|
91
|
+
*/
|
|
92
|
+
if (!policy.known) return path === "/" ? null : "unknown";
|
|
93
|
+
return policy.hiddenPaths[path] ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Navigation with hidden links removed, order preserved.
|
|
98
|
+
*
|
|
99
|
+
* A hidden entry is removed with its submenu. A submenu whose entries are all
|
|
100
|
+
* hidden is removed and its parent stays an ordinary link — never an empty
|
|
101
|
+
* dropdown.
|
|
102
|
+
*/
|
|
103
|
+
export function filterNavigationForPolicy(
|
|
104
|
+
items: readonly NavigationItem[] | undefined,
|
|
105
|
+
policy: LinkVisibilityPolicy,
|
|
106
|
+
): NavigationItem[] {
|
|
107
|
+
const out: NavigationItem[] = [];
|
|
108
|
+
for (const item of items ?? []) {
|
|
109
|
+
if (linkHideReason(item.href ?? "", policy) !== null) continue;
|
|
110
|
+
if (!item.children || item.children.length === 0) {
|
|
111
|
+
out.push(item);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const children = filterNavigationForPolicy(item.children, policy);
|
|
115
|
+
out.push(children.length > 0 ? { ...item, children } : { ...item, children: undefined });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A footer column as the visibility filter needs to see it. */
|
|
121
|
+
type FooterColumnLike = { links: ReadonlyArray<{ href: string }> };
|
|
122
|
+
|
|
123
|
+
/** Footer columns with hidden links removed; a column left with no links is dropped. */
|
|
124
|
+
export function filterFooterColumnsForPolicy<C extends FooterColumnLike>(
|
|
125
|
+
columns: readonly C[] | undefined,
|
|
126
|
+
policy: LinkVisibilityPolicy,
|
|
127
|
+
): C[] {
|
|
128
|
+
const out: C[] = [];
|
|
129
|
+
for (const column of columns ?? []) {
|
|
130
|
+
const links = column.links.filter((link) => linkHideReason(link.href ?? "", policy) === null);
|
|
131
|
+
if (links.length === 0 && column.links.length > 0) continue;
|
|
132
|
+
out.push(links.length === column.links.length ? column : { ...column, links });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A saved menu or footer link that is not shown, with the reason. */
|
|
138
|
+
export type HiddenSavedLink = { label: string; href: string; reason: HiddenLinkReason };
|
|
139
|
+
|
|
140
|
+
function labelText(label: unknown): string {
|
|
141
|
+
if (typeof label === "string") return label;
|
|
142
|
+
if (label && typeof label === "object") {
|
|
143
|
+
const first = Object.values(label as Record<string, unknown>).find(
|
|
144
|
+
(value) => typeof value === "string" && value.trim() !== "",
|
|
145
|
+
);
|
|
146
|
+
if (typeof first === "string") return first;
|
|
147
|
+
}
|
|
148
|
+
return "(no label)";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every saved link in a menu (`nav`, an item list) or footer (`footer`, with
|
|
153
|
+
* `columns`) that the site is not showing right now, and why. For the editor,
|
|
154
|
+
* which explains rather than deletes: the saved item stays exactly as it is.
|
|
155
|
+
*/
|
|
156
|
+
export function hiddenSavedLinks(
|
|
157
|
+
section: "nav" | "footer",
|
|
158
|
+
value: unknown,
|
|
159
|
+
policy: LinkVisibilityPolicy,
|
|
160
|
+
): HiddenSavedLink[] {
|
|
161
|
+
const out: HiddenSavedLink[] = [];
|
|
162
|
+
const visit = (label: unknown, href: unknown) => {
|
|
163
|
+
if (typeof href !== "string" || href.trim() === "") return;
|
|
164
|
+
const reason = linkHideReason(href, policy);
|
|
165
|
+
if (reason) out.push({ label: labelText(label), href, reason });
|
|
166
|
+
};
|
|
167
|
+
if (section === "nav") {
|
|
168
|
+
const walk = (items: unknown) => {
|
|
169
|
+
if (!Array.isArray(items)) return;
|
|
170
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
171
|
+
visit(item?.["label"], item?.["href"]);
|
|
172
|
+
walk(item?.["children"]);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
walk(value);
|
|
176
|
+
} else {
|
|
177
|
+
const columns = (value as { columns?: unknown } | null)?.columns;
|
|
178
|
+
if (Array.isArray(columns)) {
|
|
179
|
+
for (const column of columns as Array<{ links?: unknown }>) {
|
|
180
|
+
if (!Array.isArray(column?.links)) continue;
|
|
181
|
+
for (const link of column.links as Array<Record<string, unknown>>) {
|
|
182
|
+
visit(link?.["label"], link?.["href"]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Every `href` in a menu (items with children) or a footer (`{ columns: [{ links }] }`). */
|
|
191
|
+
export function savedLinkHrefs(section: "nav" | "footer", value: unknown): string[] {
|
|
192
|
+
const out: string[] = [];
|
|
193
|
+
const add = (href: unknown) => {
|
|
194
|
+
if (typeof href === "string" && href.trim() !== "") out.push(href);
|
|
195
|
+
};
|
|
196
|
+
if (section === "nav") {
|
|
197
|
+
const walk = (items: unknown) => {
|
|
198
|
+
if (!Array.isArray(items)) return;
|
|
199
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
200
|
+
add(item?.["href"]);
|
|
201
|
+
walk(item?.["children"]);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
walk(value);
|
|
205
|
+
} else {
|
|
206
|
+
const columns = (value as { columns?: unknown } | null)?.columns;
|
|
207
|
+
if (Array.isArray(columns)) {
|
|
208
|
+
for (const column of columns as Array<{ links?: unknown }>) {
|
|
209
|
+
if (!Array.isArray(column?.links)) continue;
|
|
210
|
+
for (const link of column.links as Array<Record<string, unknown>>) add(link?.["href"]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The policy cut down to the addresses actually linked from `hrefs`.
|
|
219
|
+
*
|
|
220
|
+
* A policy sent to the browser with the page must not list every unlisted
|
|
221
|
+
* page: that would publish the address of each link-only page in the source of
|
|
222
|
+
* every other page. The chrome only needs answers for the links it has, and
|
|
223
|
+
* those addresses are already in the content that is sent anyway.
|
|
224
|
+
*/
|
|
225
|
+
export function restrictToLinks(
|
|
226
|
+
policy: LinkVisibilityPolicy,
|
|
227
|
+
hrefs: readonly string[],
|
|
228
|
+
): LinkVisibilityPolicy {
|
|
229
|
+
const hiddenPaths: Record<string, LinkVisibilityPolicy["hiddenPaths"][string]> = {};
|
|
230
|
+
for (const href of hrefs) {
|
|
231
|
+
const path = internalPathOf(href, policy);
|
|
232
|
+
if (path !== null && policy.hiddenPaths[path]) hiddenPaths[path] = policy.hiddenPaths[path]!;
|
|
233
|
+
}
|
|
234
|
+
return { ...policy, hiddenPaths };
|
|
235
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The separately published package the Popups module is built on.
|
|
3
|
+
*
|
|
4
|
+
* Named here, inside the module's own directory, so the module table can
|
|
5
|
+
* declare it without the rest of the CMS mentioning the popup package. This
|
|
6
|
+
* file holds only the name: importing it loads nothing from the package.
|
|
7
|
+
*/
|
|
8
|
+
export const POPUP_PACKAGE = "@bygghammar/lightweight-popup" as const;
|
package/src/popups/runtime.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* design tokens — so neither the popup package nor the site has to know about
|
|
11
11
|
* the other.
|
|
12
12
|
*/
|
|
13
|
-
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
14
14
|
import type { ReactNode } from "react";
|
|
15
15
|
import { useRouterState } from "@tanstack/react-router";
|
|
16
16
|
import {
|
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
import { ManagedForm } from "../forms/FormRenderer";
|
|
26
26
|
import { onFormSubmissionSuccess } from "../forms/success-listeners";
|
|
27
27
|
import { siteConfig } from "../config/site-config";
|
|
28
|
+
import { containsBookingTarget, isBookingHref, stripBookingDeep } from "../cms/booking-links";
|
|
29
|
+
import { useLinkVisibility } from "../site/link-visibility";
|
|
28
30
|
import type { PopupPublicHost } from "./host";
|
|
29
31
|
import { popupPublicHost } from "./host";
|
|
30
32
|
|
|
@@ -95,6 +97,63 @@ export function popupExcludedPaths(
|
|
|
95
97
|
export function PopupModuleRuntime(): ReactNode {
|
|
96
98
|
const path = useRouterState({ select: (state) => state.location.pathname });
|
|
97
99
|
const host = popupPublicHost();
|
|
100
|
+
/*
|
|
101
|
+
* The runtime keeps the popups it was handed until its `configs` adapter
|
|
102
|
+
* changes, and keeps everything else — the popup on screen, the form in it,
|
|
103
|
+
* the visitor's counts, its timers and its analytics queue. It is never
|
|
104
|
+
* remounted, which would throw away a newsletter form a visitor is typing in.
|
|
105
|
+
*
|
|
106
|
+
* What it is handed is decided here, in render, from what this module last
|
|
107
|
+
* had from the site's repository and the policy now. So a change of policy
|
|
108
|
+
* restricts what the runtime holds at once: before the refresh it also
|
|
109
|
+
* starts has answered, and whatever becomes of it — a slow or failed read
|
|
110
|
+
* changes nothing that booking-off takes away. A read only ever brings what
|
|
111
|
+
* it found through the same rules, and only the latest read counts, so one
|
|
112
|
+
* begun under the old policy cannot bring booking back. Without a policy
|
|
113
|
+
* provider booking is visible and nothing changes.
|
|
114
|
+
*/
|
|
115
|
+
const bookingVisible = useLinkVisibility().bookingVisible;
|
|
116
|
+
const [loaded, setLoaded] = useState<readonly PopupConfig[] | null>(null);
|
|
117
|
+
const offered = useRef<Offered>(NOTHING_OFFERED);
|
|
118
|
+
const offer = useMemo(
|
|
119
|
+
() => popupsToOffer(loaded ?? [], offered.current, bookingVisible),
|
|
120
|
+
// `offered` is what the runtime was last handed, read for identity only.
|
|
121
|
+
[loaded, bookingVisible],
|
|
122
|
+
);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
offered.current = offer;
|
|
125
|
+
}, [offer]);
|
|
126
|
+
|
|
127
|
+
// The repository is asked again when the host or the policy changes — and
|
|
128
|
+
// only once the runtime asks for its popups, so never where it does not run.
|
|
129
|
+
const askedFor = useRef<{ configs: PopupPublicHost["configs"]; bookingVisible: boolean } | null>(null);
|
|
130
|
+
const latestRead = useRef(0);
|
|
131
|
+
const configs = useMemo<RuntimeAdapters["configs"]>(
|
|
132
|
+
() => ({
|
|
133
|
+
listActive: async () => {
|
|
134
|
+
const asked = askedFor.current;
|
|
135
|
+
if (asked === null || asked.configs !== host.configs || asked.bookingVisible !== bookingVisible) {
|
|
136
|
+
askedFor.current = { configs: host.configs, bookingVisible };
|
|
137
|
+
const read = ++latestRead.current;
|
|
138
|
+
Promise.resolve()
|
|
139
|
+
.then(() => host.configs.listActive())
|
|
140
|
+
.then(
|
|
141
|
+
(answer) => {
|
|
142
|
+
if (read !== latestRead.current) return;
|
|
143
|
+
setLoaded((previous) =>
|
|
144
|
+
previous !== null && JSON.stringify(previous) === JSON.stringify(answer) ? previous : answer,
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
// A failed read changes nothing: what the runtime holds already
|
|
148
|
+
// follows the policy, and stays as it is until a read succeeds.
|
|
149
|
+
() => undefined,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return offer.list;
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
[host.configs, bookingVisible, offer],
|
|
156
|
+
);
|
|
98
157
|
|
|
99
158
|
// The argument type comes from the port, not a copy of it: a package that
|
|
100
159
|
// stopped supplying `popupKey` would fail to compile here rather than
|
|
@@ -105,14 +164,15 @@ export function PopupModuleRuntime(): ReactNode {
|
|
|
105
164
|
),
|
|
106
165
|
[],
|
|
107
166
|
);
|
|
167
|
+
const forms = useMemo(() => ({ renderForm }), [renderForm]);
|
|
108
168
|
|
|
109
169
|
const adapters = useMemo<RuntimeAdapters>(
|
|
110
170
|
() => ({
|
|
111
|
-
configs
|
|
171
|
+
configs,
|
|
112
172
|
analytics: host.analytics,
|
|
113
|
-
forms
|
|
173
|
+
forms,
|
|
114
174
|
}),
|
|
115
|
-
[host,
|
|
175
|
+
[configs, host, forms],
|
|
116
176
|
);
|
|
117
177
|
|
|
118
178
|
return (
|
|
@@ -124,5 +184,96 @@ export function PopupModuleRuntime(): ReactNode {
|
|
|
124
184
|
);
|
|
125
185
|
}
|
|
126
186
|
|
|
187
|
+
type PopupConfig = Awaited<ReturnType<RuntimeAdapters["configs"]["listActive"]>>[number];
|
|
188
|
+
type ContentBlock = PopupConfig["steps"][number]["blocks"][number];
|
|
189
|
+
type PopupButton = Extract<ContentBlock, { type: "buttons" }>["buttons"][number];
|
|
190
|
+
type BlockAction = PopupButton["action"];
|
|
191
|
+
|
|
192
|
+
/** The list last handed to the runtime, and each popup's content, by key. */
|
|
193
|
+
type Offered = { list: readonly PopupConfig[]; content: ReadonlyMap<string, string> };
|
|
194
|
+
const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What the runtime is handed from a load, given what it already holds.
|
|
198
|
+
*
|
|
199
|
+
* - While booking is hidden, a popup that leads to booking is not offered: it
|
|
200
|
+
* is handed over retired (see {@link retire}).
|
|
201
|
+
* - A popup the runtime already holds is never dropped, even when the load no
|
|
202
|
+
* longer has it (a server that withholds booking popups, a popup paused or
|
|
203
|
+
* deleted since). The runtime tracks the open popup by key, so dropping the
|
|
204
|
+
* open one would leave it unclosable and stop every other popup for the
|
|
205
|
+
* rest of the visit. It stays, retired.
|
|
206
|
+
* - A popup whose content has not changed is handed over as the very object
|
|
207
|
+
* the runtime already has. The popup on screen then re-renders nothing:
|
|
208
|
+
* its focus trap is not re-installed (that would pull focus out of the
|
|
209
|
+
* field being typed in), and a list with nothing new is the same list, so
|
|
210
|
+
* the runtime's schedule is not even restarted.
|
|
211
|
+
*/
|
|
212
|
+
function popupsToOffer(
|
|
213
|
+
loaded: readonly PopupConfig[],
|
|
214
|
+
previous: Offered,
|
|
215
|
+
bookingVisible: boolean,
|
|
216
|
+
): Offered {
|
|
217
|
+
const held = new Map(previous.list.map((popup) => [popup.key, popup]));
|
|
218
|
+
const content = new Map<string, string>();
|
|
219
|
+
const list: PopupConfig[] = [];
|
|
220
|
+
const offer = (popup: PopupConfig) => {
|
|
221
|
+
const text = JSON.stringify(popup);
|
|
222
|
+
const same = held.get(popup.key);
|
|
223
|
+
content.set(popup.key, text);
|
|
224
|
+
list.push(same !== undefined && previous.content.get(popup.key) === text ? same : popup);
|
|
225
|
+
};
|
|
226
|
+
for (const popup of loaded) {
|
|
227
|
+
offer(bookingVisible || !containsBookingTarget(popup) ? popup : retire(popup, true));
|
|
228
|
+
}
|
|
229
|
+
for (const popup of previous.list) {
|
|
230
|
+
if (!content.has(popup.key)) offer(retire(popup, !bookingVisible));
|
|
231
|
+
}
|
|
232
|
+
const unchanged =
|
|
233
|
+
list.length === previous.list.length && list.every((popup, index) => popup === previous.list[index]);
|
|
234
|
+
return { list: unchanged ? previous.list : list, content };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A popup the runtime keeps but never offers again.
|
|
239
|
+
*
|
|
240
|
+
* Paused, which the runtime neither shows nor reports as suppressed. With
|
|
241
|
+
* `hideBooking`, everything in it that leads to booking is taken out too —
|
|
242
|
+
* links, buttons, an answer or a form that would open the booking site now
|
|
243
|
+
* simply closes — so if it is the one on screen, the visitor keeps what they
|
|
244
|
+
* were reading, cannot reach booking from it, and closes it the usual way.
|
|
245
|
+
* The stored popup is not touched; this is the copy the visitor sees.
|
|
246
|
+
*/
|
|
247
|
+
function retire(popup: PopupConfig, hideBooking: boolean): PopupConfig {
|
|
248
|
+
const paused: PopupConfig = { ...popup, status: "paused" };
|
|
249
|
+
if (!hideBooking) return paused;
|
|
250
|
+
return stripBookingDeep({
|
|
251
|
+
...paused,
|
|
252
|
+
steps: paused.steps.map((step) => ({ ...step, blocks: step.blocks.flatMap(withoutBookingTargets) })),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function withoutBookingTargets(block: ContentBlock): ContentBlock[] {
|
|
257
|
+
switch (block.type) {
|
|
258
|
+
case "link":
|
|
259
|
+
return isBookingHref(block.href) ? [] : [block];
|
|
260
|
+
case "buttons": {
|
|
261
|
+
const buttons = block.buttons.filter((button) => !opensBooking(button.action));
|
|
262
|
+
return buttons.length === 0 ? [] : [{ ...block, buttons }];
|
|
263
|
+
}
|
|
264
|
+
case "choice":
|
|
265
|
+
return [{ ...block, yes: closingInstead(block.yes), no: closingInstead(block.no) }];
|
|
266
|
+
case "form":
|
|
267
|
+
return [opensBooking(block.onSuccess) ? { ...block, onSuccess: { kind: "close" } } : block];
|
|
268
|
+
default:
|
|
269
|
+
return [block];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const opensBooking = (action: BlockAction) => action.kind === "openUrl" && isBookingHref(action.href);
|
|
274
|
+
|
|
275
|
+
const closingInstead = (button: PopupButton): PopupButton =>
|
|
276
|
+
opensBooking(button.action) ? { ...button, action: { kind: "close" } } : button;
|
|
277
|
+
|
|
127
278
|
// Default export so the module host can load this file lazily.
|
|
128
279
|
export default PopupModuleRuntime;
|