@bygghammar/cms-platform 0.94.0 → 0.96.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 +146 -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/PageEditorView.tsx +47 -24
- 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/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +72 -37
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- 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 +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/popup-optin-webhook-core.ts +79 -140
- 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/preview-messaging.ts +232 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +75 -31
- package/src/site/routes/public-page.tsx +81 -9
- package/src/util/redact-url.ts +18 -0
package/src/server/schemas.ts
CHANGED
|
@@ -131,6 +131,23 @@ export const pageSchemas = {
|
|
|
131
131
|
status: z.enum(["draft", "published"]),
|
|
132
132
|
editSessionId: editSessionId.optional(),
|
|
133
133
|
}),
|
|
134
|
+
/** Listing only: never publishes, unpublishes or reschedules. */
|
|
135
|
+
setPageListing: z.object({
|
|
136
|
+
pageId: uuid,
|
|
137
|
+
listing: z.enum(["listed", "link-only"]),
|
|
138
|
+
/** The version the editor was shown; null when nothing was stored yet. */
|
|
139
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
140
|
+
editSessionId: editSessionId.optional(),
|
|
141
|
+
}),
|
|
142
|
+
setBookingEnabled: z.object({
|
|
143
|
+
enabled: z.boolean(),
|
|
144
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
145
|
+
}),
|
|
146
|
+
setBookingException: z.object({
|
|
147
|
+
pageId: uuid,
|
|
148
|
+
exception: z.boolean(),
|
|
149
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
150
|
+
}),
|
|
134
151
|
duplicatePage: z.object({
|
|
135
152
|
pageId: uuid,
|
|
136
153
|
name: z.string().min(1).max(120),
|
|
@@ -177,14 +177,24 @@ export function createPageQueryOptions(
|
|
|
177
177
|
fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
|
|
178
178
|
defaultStaleTimeMs: number = PAGE_STALE_TIME_MS,
|
|
179
179
|
) {
|
|
180
|
-
|
|
180
|
+
/*
|
|
181
|
+
* `variant` partitions the cache by whatever the site's public policy says
|
|
182
|
+
* about the page (booking shown or not, say). The server stays the
|
|
183
|
+
* authority on what it returns; the partition only stops an answer fetched
|
|
184
|
+
* under one policy being reused under another, so switching booking back
|
|
185
|
+
* on is not answered from a copy fetched while it was off.
|
|
186
|
+
*/
|
|
187
|
+
return (key: string, variant?: string) =>
|
|
181
188
|
queryOptions({
|
|
182
|
-
queryKey: ["cms", "page", key],
|
|
189
|
+
queryKey: variant ? ["cms", "page", key, variant] : ["cms", "page", key],
|
|
183
190
|
queryFn: () => fetchPublishedPage(key),
|
|
184
191
|
staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
|
|
185
192
|
});
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
/** A rendering-time view of a fetched page, e.g. with links the policy forbids removed. */
|
|
196
|
+
export type PageTransform = (page: RenderedPage) => RenderedPage;
|
|
197
|
+
|
|
188
198
|
/**
|
|
189
199
|
* Build the published-page renderer for a site.
|
|
190
200
|
* `fetchPublishedPage` is the site's `getPublishedPage` server function.
|
|
@@ -195,9 +205,28 @@ export function createPageRenderer(
|
|
|
195
205
|
) {
|
|
196
206
|
const pageQueryOptions = createPageQueryOptions(fetchPublishedPage);
|
|
197
207
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
/*
|
|
209
|
+
* `transform` is applied to whatever the cache holds, on every render: a
|
|
210
|
+
* page cached before the policy changed, or one still being refetched in
|
|
211
|
+
* the background, is shown as the current policy says, never as it was
|
|
212
|
+
* fetched. The cached original is not changed, so a later policy can show
|
|
213
|
+
* it again. Pass a stable function, or none.
|
|
214
|
+
*/
|
|
215
|
+
function PageRenderer({
|
|
216
|
+
pageKey,
|
|
217
|
+
variant,
|
|
218
|
+
transform,
|
|
219
|
+
}: {
|
|
220
|
+
pageKey: string;
|
|
221
|
+
variant?: string;
|
|
222
|
+
transform?: PageTransform;
|
|
223
|
+
}) {
|
|
224
|
+
const { data } = useSuspenseQuery(pageQueryOptions(pageKey, variant));
|
|
225
|
+
const fetched = data as RenderedPage | null;
|
|
226
|
+
const page = useMemo(
|
|
227
|
+
() => (fetched && transform ? transform(fetched) : fetched),
|
|
228
|
+
[fetched, transform],
|
|
229
|
+
);
|
|
201
230
|
if (!page) return null;
|
|
202
231
|
return (
|
|
203
232
|
<DraftPageRendererView blocks={page.blocks} globals={page.globals} registry={registry} />
|
package/src/site/SiteFooter.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import type { SiteTypography } from "../config/typography";
|
|
|
12
12
|
import { useT } from "../i18n";
|
|
13
13
|
import type { LocalizedString } from "../i18n/types";
|
|
14
14
|
import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
|
|
15
|
+
import { filterFooterColumnsForPolicy } from "../navigation/visibility";
|
|
16
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
15
17
|
|
|
16
18
|
export type SiteFooterCompany = {
|
|
17
19
|
legalName: string;
|
|
@@ -81,12 +83,19 @@ export function SiteFooter({
|
|
|
81
83
|
// boundary rather than on the next full page load.
|
|
82
84
|
const now = useScheduleClock(footerSchedules(columns));
|
|
83
85
|
const footerFont = slotFont(typography, "footer");
|
|
84
|
-
const
|
|
86
|
+
const linkPolicy = useLinkVisibility();
|
|
87
|
+
// Scheduled-out links first, then links the site must not offer right now
|
|
88
|
+
// (an offline or link-only page, booking while it is off). A column left with
|
|
89
|
+
// nothing to link to is not rendered as an empty heading.
|
|
90
|
+
const scheduled = columns
|
|
85
91
|
.filter((column) => isVisibleAt(column.schedule, now))
|
|
86
92
|
.map((column) => ({
|
|
87
93
|
...column,
|
|
88
|
-
links: (column.links ?? []).filter(
|
|
94
|
+
links: (column.links ?? []).filter(
|
|
95
|
+
(link) => isVisibleAt(link.schedule, now) && (link.href ?? "").trim() !== "",
|
|
96
|
+
),
|
|
89
97
|
}));
|
|
98
|
+
const visibleColumns = filterFooterColumnsForPolicy(scheduled, linkPolicy);
|
|
90
99
|
|
|
91
100
|
const jsonLd = {
|
|
92
101
|
"@context": "https://schema.org",
|
package/src/site/SiteHeader.tsx
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
type NavigationItem,
|
|
22
22
|
} from "../navigation";
|
|
23
23
|
import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
|
|
24
|
+
import { filterNavigationForPolicy, linkHideReason } from "../navigation/visibility";
|
|
25
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
24
26
|
|
|
25
27
|
export type SiteHeaderProps = {
|
|
26
28
|
/**
|
|
@@ -39,8 +41,13 @@ export type SiteHeaderProps = {
|
|
|
39
41
|
logoAlt?: string;
|
|
40
42
|
/** Set false when the logo artwork is already dark. */
|
|
41
43
|
invertLogoOnLightSurface?: boolean;
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Primary call to action shown on desktop and at the end of the mobile panel.
|
|
46
|
+
* `null` shows none. With `booking: true` it is a booking action, hidden
|
|
47
|
+
* while the site's booking is switched off; either way it is hidden when the
|
|
48
|
+
* page it leads to is offline or link-only (see `LinkVisibilityProvider`).
|
|
49
|
+
*/
|
|
50
|
+
primaryCta: { href: string; label: LocalizedString; booking?: boolean } | null;
|
|
44
51
|
/** Mobile menu toggle labels. */
|
|
45
52
|
menuLabels: { open: LocalizedString; close: LocalizedString };
|
|
46
53
|
/** Optional backoffice shortcut, rendered only when `signedIn` is true. */
|
|
@@ -252,8 +259,17 @@ export function SiteHeader({
|
|
|
252
259
|
// A page left open past a scheduled publish/hide time re-renders on the
|
|
253
260
|
// boundary instead of showing what was true when it loaded.
|
|
254
261
|
const now = useScheduleClock(navigationSchedules(nav));
|
|
262
|
+
// Links to offline or link-only pages, and booking links while booking is
|
|
263
|
+
// off, are left out here; the saved menu itself is untouched.
|
|
264
|
+
const linkPolicy = useLinkVisibility();
|
|
255
265
|
// Half-finished entries are the editor's business, not a visitor's.
|
|
256
|
-
const items = renderableNavigation(nav, locale, now);
|
|
266
|
+
const items = renderableNavigation(filterNavigationForPolicy(nav, linkPolicy), locale, now);
|
|
267
|
+
const cta =
|
|
268
|
+
primaryCta &&
|
|
269
|
+
!(primaryCta.booking && !linkPolicy.bookingVisible) &&
|
|
270
|
+
linkHideReason(primaryCta.href, linkPolicy) === null
|
|
271
|
+
? primaryCta
|
|
272
|
+
: null;
|
|
257
273
|
const menuFont = slotFont(typography, "menu");
|
|
258
274
|
|
|
259
275
|
return (
|
|
@@ -297,17 +313,19 @@ export function SiteHeader({
|
|
|
297
313
|
{backoffice.label}
|
|
298
314
|
</Link>
|
|
299
315
|
)}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
{cta ? (
|
|
317
|
+
<Link
|
|
318
|
+
to={cta.href}
|
|
319
|
+
className={[
|
|
320
|
+
"inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
|
|
321
|
+
!lightText
|
|
322
|
+
? "border-foreground text-foreground hover:bg-foreground hover:text-background"
|
|
323
|
+
: "border-paper text-paper hover:bg-paper hover:text-ink",
|
|
324
|
+
].join(" ")}
|
|
325
|
+
>
|
|
326
|
+
{t(cta.label)}
|
|
327
|
+
</Link>
|
|
328
|
+
) : null}
|
|
311
329
|
</div>
|
|
312
330
|
|
|
313
331
|
<button
|
|
@@ -395,13 +413,15 @@ export function SiteHeader({
|
|
|
395
413
|
{backoffice.label}
|
|
396
414
|
</Link>
|
|
397
415
|
)}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
416
|
+
{cta ? (
|
|
417
|
+
<Link
|
|
418
|
+
to={cta.href}
|
|
419
|
+
className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
|
|
420
|
+
onClick={() => setOpen(false)}
|
|
421
|
+
>
|
|
422
|
+
{t(cta.label)}
|
|
423
|
+
</Link>
|
|
424
|
+
) : null}
|
|
405
425
|
</nav>
|
|
406
426
|
</div>
|
|
407
427
|
)}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public link policy, made available to the site chrome.
|
|
3
|
+
*
|
|
4
|
+
* A site resolves the policy on the server (which pages are offline or
|
|
5
|
+
* link-only, whether booking is on) and provides it here; the shared header and
|
|
6
|
+
* footer read it to leave out links a visitor must not be offered. Without a
|
|
7
|
+
* provider nothing is filtered, which is what the backoffice and any screen
|
|
8
|
+
* that renders the chrome outside the public site get.
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
import { OPEN_LINK_POLICY, type LinkVisibilityPolicy } from "../navigation/visibility";
|
|
13
|
+
|
|
14
|
+
const LinkVisibilityContext = createContext<LinkVisibilityPolicy>(OPEN_LINK_POLICY);
|
|
15
|
+
|
|
16
|
+
export function LinkVisibilityProvider({
|
|
17
|
+
policy,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
policy: LinkVisibilityPolicy;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return <LinkVisibilityContext.Provider value={policy}>{children}</LinkVisibilityContext.Provider>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useLinkVisibility(): LinkVisibilityPolicy {
|
|
27
|
+
return useContext(LinkVisibilityContext);
|
|
28
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page builder's channel to its preview canvas.
|
|
3
|
+
*
|
|
4
|
+
* The builder renders the page being edited in an iframe (the preview route)
|
|
5
|
+
* and streams unsaved drafts into it; the canvas answers with a ready signal
|
|
6
|
+
* and block selections. The preview route is public, so its page can be
|
|
7
|
+
* opened or framed by anyone — a message that only *says* it comes from the
|
|
8
|
+
* builder proves nothing. Both ends therefore check who is talking:
|
|
9
|
+
*
|
|
10
|
+
* - the canvas accepts the builder's messages only from its own parent frame
|
|
11
|
+
* (`event.source`), only from an expected origin (its own, plus any origin a
|
|
12
|
+
* site explicitly names), and only in one builder session, in order;
|
|
13
|
+
* - the builder accepts the canvas's messages only from its own iframe's
|
|
14
|
+
* window and the iframe's origin;
|
|
15
|
+
* - nothing is ever posted to `"*"`: every message names the origin that may
|
|
16
|
+
* receive it, so unsaved drafts never reach a page that navigated elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* Malformed, oversized, out-of-order and repeated messages are ignored.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const EDITOR_SOURCE = "lovable-editor";
|
|
22
|
+
export const PREVIEW_SOURCE = "lovable-preview";
|
|
23
|
+
|
|
24
|
+
/** Largest draft accepted, measured as JSON. A whole page with its globals is far smaller. */
|
|
25
|
+
export const PREVIEW_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
const MAX_ID = 200;
|
|
28
|
+
const KEYS_THAT_ARE_NOT_DATA = new Set(["__proto__", "constructor", "prototype"]);
|
|
29
|
+
|
|
30
|
+
export type EditorDraftMessage = {
|
|
31
|
+
source: typeof EDITOR_SOURCE;
|
|
32
|
+
session: string;
|
|
33
|
+
seq: number;
|
|
34
|
+
type: "draft";
|
|
35
|
+
blocks: unknown[];
|
|
36
|
+
globals: Record<string, unknown[]>;
|
|
37
|
+
selectedId: string | null;
|
|
38
|
+
locale?: string;
|
|
39
|
+
sharedContent?: Record<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type EditorScrollMessage = {
|
|
43
|
+
source: typeof EDITOR_SOURCE;
|
|
44
|
+
session: string;
|
|
45
|
+
seq: number;
|
|
46
|
+
type: "scrollTo";
|
|
47
|
+
blockId: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type EditorMessage = EditorDraftMessage | EditorScrollMessage;
|
|
51
|
+
|
|
52
|
+
export type PreviewMessage =
|
|
53
|
+
| { source: typeof PREVIEW_SOURCE; type: "ready" }
|
|
54
|
+
| { source: typeof PREVIEW_SOURCE; type: "select"; blockId: string };
|
|
55
|
+
|
|
56
|
+
/** The parts of a `MessageEvent` the guards read. */
|
|
57
|
+
export type IncomingMessage = { data: unknown; origin: string; source: unknown };
|
|
58
|
+
|
|
59
|
+
/** A serialized origin (`https://host[:port]`), or null for anything else. */
|
|
60
|
+
export function normalizeOrigin(value: string | null | undefined): string | null {
|
|
61
|
+
if (!value) return null;
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(value);
|
|
64
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
65
|
+
if (url.username || url.password) return null;
|
|
66
|
+
return url.origin;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The origins the canvas trusts: its own first, then any the site names explicitly. */
|
|
73
|
+
export function trustedEditorOrigins(own: string, extra: readonly string[] = []): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
for (const value of [own, ...extra]) {
|
|
76
|
+
const origin = normalizeOrigin(value);
|
|
77
|
+
if (origin && !out.includes(origin)) out.push(origin);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The origin of the page the builder frames, resolved against the builder's address. */
|
|
83
|
+
export function previewOriginOf(previewUrl: string, base: string): string | null {
|
|
84
|
+
try {
|
|
85
|
+
return normalizeOrigin(new URL(previewUrl, base).href);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A fresh, unguessable builder session id. */
|
|
92
|
+
export function newPreviewSession(): string {
|
|
93
|
+
const bytes = new Uint8Array(16);
|
|
94
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
95
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
100
|
+
const proto = Object.getPrototypeOf(value) as unknown;
|
|
101
|
+
return proto === Object.prototype || proto === null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasOnlyDataKeys(value: Record<string, unknown>): boolean {
|
|
105
|
+
return Object.keys(value).every((key) => !KEYS_THAT_ARE_NOT_DATA.has(key));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isId = (value: unknown): value is string =>
|
|
109
|
+
typeof value === "string" && value.length > 0 && value.length <= MAX_ID;
|
|
110
|
+
|
|
111
|
+
function sizeWithin(value: unknown, maxBytes: number): boolean {
|
|
112
|
+
try {
|
|
113
|
+
const json = JSON.stringify(value);
|
|
114
|
+
return typeof json === "string" && json.length <= maxBytes;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The builder's message, checked for shape; null when it is not one. */
|
|
121
|
+
export function parseEditorMessage(
|
|
122
|
+
data: unknown,
|
|
123
|
+
maxBytes = PREVIEW_MESSAGE_MAX_BYTES,
|
|
124
|
+
): EditorMessage | null {
|
|
125
|
+
if (!isPlainObject(data) || data["source"] !== EDITOR_SOURCE) return null;
|
|
126
|
+
const session = data["session"];
|
|
127
|
+
const seq = data["seq"];
|
|
128
|
+
if (typeof session !== "string" || session.length < 16 || session.length > 128) return null;
|
|
129
|
+
if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return null;
|
|
130
|
+
if (!sizeWithin(data, maxBytes)) return null;
|
|
131
|
+
|
|
132
|
+
if (data["type"] === "scrollTo") {
|
|
133
|
+
if (!isId(data["blockId"])) return null;
|
|
134
|
+
return { source: EDITOR_SOURCE, session, seq, type: "scrollTo", blockId: data["blockId"] };
|
|
135
|
+
}
|
|
136
|
+
if (data["type"] !== "draft") return null;
|
|
137
|
+
|
|
138
|
+
const blocks = data["blocks"];
|
|
139
|
+
const globals = data["globals"];
|
|
140
|
+
const selectedId = data["selectedId"] ?? null;
|
|
141
|
+
const locale = data["locale"];
|
|
142
|
+
const sharedContent = data["sharedContent"];
|
|
143
|
+
if (!Array.isArray(blocks)) return null;
|
|
144
|
+
if (!isPlainObject(globals) || !hasOnlyDataKeys(globals)) return null;
|
|
145
|
+
if (!Object.values(globals).every((value) => Array.isArray(value))) return null;
|
|
146
|
+
if (selectedId !== null && !isId(selectedId)) return null;
|
|
147
|
+
if (locale !== undefined && (typeof locale !== "string" || locale.length > 16)) return null;
|
|
148
|
+
if (sharedContent !== undefined && (!isPlainObject(sharedContent) || !hasOnlyDataKeys(sharedContent))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
source: EDITOR_SOURCE,
|
|
153
|
+
session,
|
|
154
|
+
seq,
|
|
155
|
+
type: "draft",
|
|
156
|
+
blocks,
|
|
157
|
+
globals: globals as Record<string, unknown[]>,
|
|
158
|
+
selectedId,
|
|
159
|
+
...(locale !== undefined ? { locale: locale as string } : {}),
|
|
160
|
+
...(sharedContent !== undefined ? { sharedContent } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The canvas's side: accepts the builder's messages from its parent frame and
|
|
166
|
+
* a trusted origin, in one session and in order.
|
|
167
|
+
*/
|
|
168
|
+
export function createEditorMessageGuard(options: {
|
|
169
|
+
trustedOrigins: readonly string[];
|
|
170
|
+
/** The window that framed the canvas (`window.parent`). */
|
|
171
|
+
parent: () => unknown;
|
|
172
|
+
/** The canvas's own window, to tell "not framed" apart. */
|
|
173
|
+
self: () => unknown;
|
|
174
|
+
maxBytes?: number;
|
|
175
|
+
}) {
|
|
176
|
+
const trusted = new Set(options.trustedOrigins);
|
|
177
|
+
let session: string | null = null;
|
|
178
|
+
let lastSeq = -1;
|
|
179
|
+
let origin: string | null = null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
accept(event: IncomingMessage): EditorMessage | null {
|
|
183
|
+
const parent = options.parent();
|
|
184
|
+
if (!parent || parent === options.self()) return null;
|
|
185
|
+
if (event.source !== parent) return null;
|
|
186
|
+
if (!trusted.has(event.origin)) return null;
|
|
187
|
+
if (origin !== null && event.origin !== origin) return null;
|
|
188
|
+
const message = parseEditorMessage(event.data, options.maxBytes);
|
|
189
|
+
if (!message) return null;
|
|
190
|
+
if (session !== null && message.session !== session) return null;
|
|
191
|
+
if (message.seq <= lastSeq) return null;
|
|
192
|
+
session = message.session;
|
|
193
|
+
origin = event.origin;
|
|
194
|
+
lastSeq = message.seq;
|
|
195
|
+
return message;
|
|
196
|
+
},
|
|
197
|
+
/** Where replies may go: the builder's verified origin, or every trusted one before it spoke. */
|
|
198
|
+
replyOrigins(): string[] {
|
|
199
|
+
return origin !== null ? [origin] : [...trusted];
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The canvas's message, checked for shape; null when it is not one. */
|
|
205
|
+
export function parsePreviewMessage(data: unknown): PreviewMessage | null {
|
|
206
|
+
if (!isPlainObject(data) || data["source"] !== PREVIEW_SOURCE) return null;
|
|
207
|
+
if (data["type"] === "ready") return { source: PREVIEW_SOURCE, type: "ready" };
|
|
208
|
+
if (data["type"] === "select" && isId(data["blockId"])) {
|
|
209
|
+
return { source: PREVIEW_SOURCE, type: "select", blockId: data["blockId"] };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The builder's side: accepts only its own iframe's messages, from the iframe's origin. */
|
|
215
|
+
export function acceptPreviewMessage(
|
|
216
|
+
event: IncomingMessage,
|
|
217
|
+
options: { frame: unknown; previewOrigin: string | null },
|
|
218
|
+
): PreviewMessage | null {
|
|
219
|
+
if (!options.frame || event.source !== options.frame) return null;
|
|
220
|
+
if (!options.previewOrigin || event.origin !== options.previewOrigin) return null;
|
|
221
|
+
return parsePreviewMessage(event.data);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Posts to each origin in turn; a window at any other origin receives nothing. */
|
|
225
|
+
export function postToOrigins(
|
|
226
|
+
target: { postMessage: (message: unknown, targetOrigin: string) => void } | null | undefined,
|
|
227
|
+
message: unknown,
|
|
228
|
+
origins: readonly string[],
|
|
229
|
+
): void {
|
|
230
|
+
if (!target) return;
|
|
231
|
+
for (const origin of origins) target.postMessage(message, origin);
|
|
232
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every address that reaches each page, the way the path resolver sees them.
|
|
3
|
+
*
|
|
4
|
+
* Hiding links to an unlisted page has to recognise all of its addresses — the
|
|
5
|
+
* Swedish slug, the English one under `/en`, a slug changed in the SEO screen,
|
|
6
|
+
* the bare `/<key>` every builder page answers to — and none that belong to a
|
|
7
|
+
* different page. This builds the same map `resolvePathCore` consults, in the
|
|
8
|
+
* same order of precedence:
|
|
9
|
+
*
|
|
10
|
+
* 1. slug overrides from `page_seo`;
|
|
11
|
+
* 2. the site's static page registry, for pages with no override;
|
|
12
|
+
* 3. the bare key, `/<key>` and `/<locale>/<key>`, for pages that exist, only
|
|
13
|
+
* where no slug already claims the address.
|
|
14
|
+
*
|
|
15
|
+
* Within one precedence level the first page (in key order) wins an address,
|
|
16
|
+
* matching the resolver's "first entered wins" for the rare collision instead
|
|
17
|
+
* of guessing differently.
|
|
18
|
+
*/
|
|
19
|
+
import { normalizeRoutePath, servedPath } from "./route-index";
|
|
20
|
+
|
|
21
|
+
/** Slug map of a page in a site's static registry (as `resolvePathCore` takes it). */
|
|
22
|
+
export type AliasSlugRegistry = Record<string, { slug: Record<string, string | undefined> }>;
|
|
23
|
+
|
|
24
|
+
export type RouteAliasInput = {
|
|
25
|
+
/** The site's static page registry. */
|
|
26
|
+
registry: AliasSlugRegistry;
|
|
27
|
+
/** `page_seo.slugs` per page key, for pages that have a row. */
|
|
28
|
+
overrides: ReadonlyMap<string, Record<string, string | undefined>>;
|
|
29
|
+
/** Keys of CMS pages that exist (not in the trash): their `/<key>` answers too. */
|
|
30
|
+
existingKeys: readonly string[];
|
|
31
|
+
locales: readonly string[];
|
|
32
|
+
defaultLocale: string;
|
|
33
|
+
/** The locale an unprefixed address resolves in on this request's domain. */
|
|
34
|
+
requestLocale: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const BARE_KEY = /^[a-z0-9_-]+$/;
|
|
38
|
+
|
|
39
|
+
/** Normalized served path → the page key the resolver would answer with. */
|
|
40
|
+
export function routeAliasMap(input: RouteAliasInput): Map<string, string> {
|
|
41
|
+
const policy = { locales: input.locales };
|
|
42
|
+
const prefixed = input.locales.filter((locale) => locale !== input.defaultLocale);
|
|
43
|
+
const map = new Map<string, string>();
|
|
44
|
+
const claim = (path: string, key: string) => {
|
|
45
|
+
const normalized = normalizeRoutePath(path, policy);
|
|
46
|
+
if (!map.has(normalized)) map.set(normalized, key);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const slugPaths = (key: string, slugs: Record<string, string | undefined>) => {
|
|
50
|
+
const pick = (locale: string) => slugs[locale] ?? slugs[input.defaultLocale];
|
|
51
|
+
// Unprefixed: the slug of the locale this domain resolves bare paths in.
|
|
52
|
+
const bare = pick(input.requestLocale);
|
|
53
|
+
if (bare !== undefined && bare !== null) claim(servedPath(bare, input.defaultLocale, input.defaultLocale), key);
|
|
54
|
+
// Prefixed: every other locale, each under its own prefix.
|
|
55
|
+
for (const locale of prefixed) {
|
|
56
|
+
const slug = pick(locale);
|
|
57
|
+
if (slug !== undefined && slug !== null) claim(servedPath(slug, locale, input.defaultLocale), key);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const key of [...input.overrides.keys()].sort()) {
|
|
62
|
+
slugPaths(key, input.overrides.get(key) ?? {});
|
|
63
|
+
}
|
|
64
|
+
for (const key of Object.keys(input.registry).sort()) {
|
|
65
|
+
if (input.overrides.has(key)) continue;
|
|
66
|
+
slugPaths(key, input.registry[key]!.slug);
|
|
67
|
+
}
|
|
68
|
+
for (const key of [...input.existingKeys].sort()) {
|
|
69
|
+
if (!BARE_KEY.test(key)) continue;
|
|
70
|
+
claim(`/${key}`, key);
|
|
71
|
+
for (const locale of input.locales) claim(`/${locale}/${key}`, key);
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|