@bygghammar/cms-platform 0.95.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/docs/PAGES.md +45 -2
- package/package.json +3 -1
- package/src/admin/describe-log.ts +2 -0
- package/src/backoffice/BookingLandingSection.tsx +672 -0
- package/src/backoffice/BookingSettingsCard.tsx +34 -4
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/routes/host.ts +7 -0
- package/src/backoffice/routes/index.tsx +22 -3
- package/src/blocks/components/CountdownBlock.tsx +3 -1
- package/src/blocks/components/CtaBannerBlock.tsx +8 -2
- package/src/blocks/components/HeroFullBlock.tsx +7 -1
- package/src/blocks/components/IconGridBlock.tsx +5 -1
- package/src/blocks/components/InstagramBlock.tsx +5 -1
- package/src/blocks/components/InstagramTiles.tsx +3 -1
- package/src/blocks/components/LinkTilesBlock.tsx +8 -1
- package/src/blocks/components/MediaSplitBlock.tsx +8 -3
- package/src/blocks/components/PriceTableBlock.tsx +6 -5
- package/src/blocks/components/TripListBlock.tsx +5 -1
- package/src/cms/booking-landing.ts +359 -0
- package/src/cms/booking-links.ts +24 -0
- package/src/cms/page-visibility.ts +67 -1
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/navigation/visibility.ts +65 -3
- package/src/popups/runtime.tsx +75 -32
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +231 -5
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/schemas.ts +45 -0
- package/src/site/BookingOffNotice.tsx +73 -0
- package/src/site/PageRenderer.tsx +59 -3
- package/src/site/link-visibility.tsx +21 -2
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- package/src/util/redact-url.ts +18 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notice a booking-off landing page shows while booking is hidden.
|
|
3
|
+
*
|
|
4
|
+
* The page keeps its own content, title, H1 and address; this only says,
|
|
5
|
+
* honestly, that booking is not offered here right now, and offers an
|
|
6
|
+
* optional link somewhere that is not booking. A titled region with an `h2`,
|
|
7
|
+
* so it is found by heading and landmark navigation without interrupting a
|
|
8
|
+
* screen reader the way an alert would. Plain text only: the message is
|
|
9
|
+
* rendered as text with its line breaks, never as markup.
|
|
10
|
+
*
|
|
11
|
+
* Placed after a leading hero, or first. First, it can follow the fixed
|
|
12
|
+
* header directly (a route with no top padding and no hero): it then keeps
|
|
13
|
+
* the same offset the site's padded routes use, instead of sitting under the
|
|
14
|
+
* header. Anywhere else the rule does not match.
|
|
15
|
+
*
|
|
16
|
+
* Its link is offered only if the current policy allows it: never to booking
|
|
17
|
+
* (a booking site, or the host's own booking page in any spelling) and never
|
|
18
|
+
* to a page hidden while booking is hidden. The host's server already drops
|
|
19
|
+
* such a link before the notice is sent; this is the same rule where it is
|
|
20
|
+
* rendered, so a policy that changed since cannot bring one back.
|
|
21
|
+
*/
|
|
22
|
+
import { useId } from "react";
|
|
23
|
+
|
|
24
|
+
import { useT } from "../i18n";
|
|
25
|
+
import type { ResolvedNotice } from "../cms/booking-landing";
|
|
26
|
+
import { isNoticeLinkAllowed } from "../navigation/visibility";
|
|
27
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
28
|
+
|
|
29
|
+
export function BookingOffNotice({ notice }: { notice: ResolvedNotice | null | undefined }) {
|
|
30
|
+
const { t } = useT();
|
|
31
|
+
const id = useId();
|
|
32
|
+
const policy = useLinkVisibility();
|
|
33
|
+
if (!notice) return null;
|
|
34
|
+
const heading = t(notice.heading);
|
|
35
|
+
const body = t(notice.body);
|
|
36
|
+
const cta = notice.cta && isNoticeLinkAllowed(notice.cta.href, policy) ? notice.cta : null;
|
|
37
|
+
const label = cta ? t(cta.label) : "";
|
|
38
|
+
if (!heading && !body) return null;
|
|
39
|
+
const external = cta ? /^https:\/\//i.test(cta.href) : false;
|
|
40
|
+
return (
|
|
41
|
+
<section
|
|
42
|
+
aria-labelledby={heading ? `${id}-title` : undefined}
|
|
43
|
+
aria-label={heading ? undefined : body}
|
|
44
|
+
data-booking-off-notice=""
|
|
45
|
+
className="container-editorial py-8 md:py-10 [header+&]:pt-24 md:[header+&]:pt-32"
|
|
46
|
+
>
|
|
47
|
+
<div className="max-w-3xl border-l-4 border-ink/70 bg-paper px-5 py-5 md:px-7 md:py-6">
|
|
48
|
+
{heading ? (
|
|
49
|
+
<h2
|
|
50
|
+
id={`${id}-title`}
|
|
51
|
+
className="font-serif text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.15] text-ink [overflow-wrap:anywhere]"
|
|
52
|
+
>
|
|
53
|
+
{heading}
|
|
54
|
+
</h2>
|
|
55
|
+
) : null}
|
|
56
|
+
{body ? (
|
|
57
|
+
<p className="mt-3 whitespace-pre-line text-base leading-relaxed text-ink/80 [overflow-wrap:anywhere]">
|
|
58
|
+
{body}
|
|
59
|
+
</p>
|
|
60
|
+
) : null}
|
|
61
|
+
{cta && label ? (
|
|
62
|
+
<a
|
|
63
|
+
href={cta.href}
|
|
64
|
+
{...(external ? { rel: "noopener" } : {})}
|
|
65
|
+
className="mt-5 inline-flex min-h-11 max-w-full items-center justify-center bg-ink px-6 py-3 text-center text-xs uppercase tracking-[0.22em] text-paper hover:bg-ink/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [overflow-wrap:anywhere]"
|
|
66
|
+
>
|
|
67
|
+
{label}
|
|
68
|
+
</a>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* a published page or an unsaved draft. The registry is passed in, so a site
|
|
6
6
|
* only supplies its own block definitions.
|
|
7
7
|
*/
|
|
8
|
-
import { useMemo } from "react";
|
|
8
|
+
import { Fragment, useMemo, type ReactNode } from "react";
|
|
9
9
|
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
|
10
10
|
|
|
11
11
|
import { publishedPageStaleTime } from "../cms/page-publication";
|
|
@@ -116,6 +116,32 @@ function RenderBlock({
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Block types that open a page full-bleed, under the header. */
|
|
120
|
+
export const DEFAULT_HERO_BLOCK_TYPES: readonly string[] = Object.freeze(["heroFull"]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Where a notice goes among a page's top-level blocks: right after a leading
|
|
124
|
+
* hero (which sits under the header and must stay first), otherwise first. A
|
|
125
|
+
* global block counts by its own first visible block.
|
|
126
|
+
*/
|
|
127
|
+
export function noticeIndex(
|
|
128
|
+
blocks: readonly BlockInstance[],
|
|
129
|
+
globals: Record<string, BlockInstance[]>,
|
|
130
|
+
heroTypes: readonly string[] = DEFAULT_HERO_BLOCK_TYPES,
|
|
131
|
+
): number {
|
|
132
|
+
const leadOf = (block: BlockInstance): BlockInstance | null => {
|
|
133
|
+
if (block.visible === false) return null;
|
|
134
|
+
if (block.type !== "globalRef") return block;
|
|
135
|
+
const key = String((block.props as BlockProps | undefined)?.sectionKey ?? "");
|
|
136
|
+
return (globals[key] ?? []).find((inner) => inner.visible !== false) ?? null;
|
|
137
|
+
};
|
|
138
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
139
|
+
const lead = leadOf(blocks[i]!);
|
|
140
|
+
if (lead) return heroTypes.includes(lead.type) ? i + 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
/** Render an in-memory block tree — used by the builder preview and drafts. */
|
|
120
146
|
export function DraftPageRendererView({
|
|
121
147
|
blocks,
|
|
@@ -123,12 +149,15 @@ export function DraftPageRendererView({
|
|
|
123
149
|
registry,
|
|
124
150
|
edit,
|
|
125
151
|
selectedId,
|
|
152
|
+
insert,
|
|
126
153
|
}: {
|
|
127
154
|
blocks: BlockInstance[];
|
|
128
155
|
globals: Record<string, BlockInstance[]>;
|
|
129
156
|
registry: BlockRegistry;
|
|
130
157
|
edit?: boolean;
|
|
131
158
|
selectedId?: string | null;
|
|
159
|
+
/** Something that is not a block, rendered among the top-level blocks at `at`. */
|
|
160
|
+
insert?: { at: number; node: ReactNode };
|
|
132
161
|
}) {
|
|
133
162
|
// Container blocks (`layout`) render their children through this, so nesting
|
|
134
163
|
// uses exactly the same code path — and the same globals, selection
|
|
@@ -154,9 +183,17 @@ export function DraftPageRendererView({
|
|
|
154
183
|
// Every page render gets its own priority scope, so the first first-screen
|
|
155
184
|
// image loads eagerly and the rest stay lazy. In the builder (`edit`) the
|
|
156
185
|
// editor jumps around the page, so no image is treated as the hero.
|
|
186
|
+
const rendered = children.render(blocks);
|
|
187
|
+
if (insert) {
|
|
188
|
+
rendered.splice(
|
|
189
|
+
Math.max(0, Math.min(insert.at, rendered.length)),
|
|
190
|
+
0,
|
|
191
|
+
<Fragment key="__inserted">{insert.node}</Fragment>,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
157
194
|
return (
|
|
158
195
|
<PriorityMediaProvider enabled={!edit}>
|
|
159
|
-
<BlockChildrenProvider value={children}>{
|
|
196
|
+
<BlockChildrenProvider value={children}>{rendered}</BlockChildrenProvider>
|
|
160
197
|
</PriorityMediaProvider>
|
|
161
198
|
);
|
|
162
199
|
}
|
|
@@ -212,14 +249,24 @@ export function createPageRenderer(
|
|
|
212
249
|
* fetched. The cached original is not changed, so a later policy can show
|
|
213
250
|
* it again. Pass a stable function, or none.
|
|
214
251
|
*/
|
|
252
|
+
/*
|
|
253
|
+
* `notice` is shown among the page's blocks — after a leading hero (one of
|
|
254
|
+
* `heroBlockTypes`), otherwise first — without changing the page itself: a
|
|
255
|
+
* booking-off landing page's notice, say. It is decided by the caller from
|
|
256
|
+
* the current policy, like `transform`.
|
|
257
|
+
*/
|
|
215
258
|
function PageRenderer({
|
|
216
259
|
pageKey,
|
|
217
260
|
variant,
|
|
218
261
|
transform,
|
|
262
|
+
notice,
|
|
263
|
+
heroBlockTypes,
|
|
219
264
|
}: {
|
|
220
265
|
pageKey: string;
|
|
221
266
|
variant?: string;
|
|
222
267
|
transform?: PageTransform;
|
|
268
|
+
notice?: ReactNode;
|
|
269
|
+
heroBlockTypes?: readonly string[];
|
|
223
270
|
}) {
|
|
224
271
|
const { data } = useSuspenseQuery(pageQueryOptions(pageKey, variant));
|
|
225
272
|
const fetched = data as RenderedPage | null;
|
|
@@ -228,8 +275,17 @@ export function createPageRenderer(
|
|
|
228
275
|
[fetched, transform],
|
|
229
276
|
);
|
|
230
277
|
if (!page) return null;
|
|
278
|
+
const insert =
|
|
279
|
+
notice != null && notice !== false
|
|
280
|
+
? { at: noticeIndex(page.blocks, page.globals, heroBlockTypes), node: notice }
|
|
281
|
+
: undefined;
|
|
231
282
|
return (
|
|
232
|
-
<DraftPageRendererView
|
|
283
|
+
<DraftPageRendererView
|
|
284
|
+
blocks={page.blocks}
|
|
285
|
+
globals={page.globals}
|
|
286
|
+
registry={registry}
|
|
287
|
+
insert={insert}
|
|
288
|
+
/>
|
|
233
289
|
);
|
|
234
290
|
}
|
|
235
291
|
|
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* provider nothing is filtered, which is what the backoffice and any screen
|
|
8
8
|
* that renders the chrome outside the public site get.
|
|
9
9
|
*/
|
|
10
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
10
|
+
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
OPEN_LINK_POLICY,
|
|
14
|
+
isHiddenFromDiscovery,
|
|
15
|
+
type LinkVisibilityPolicy,
|
|
16
|
+
} from "../navigation/visibility";
|
|
13
17
|
|
|
14
18
|
const LinkVisibilityContext = createContext<LinkVisibilityPolicy>(OPEN_LINK_POLICY);
|
|
15
19
|
|
|
@@ -26,3 +30,18 @@ export function LinkVisibilityProvider({
|
|
|
26
30
|
export function useLinkVisibility(): LinkVisibilityPolicy {
|
|
27
31
|
return useContext(LinkVisibilityContext);
|
|
28
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The one rule every structured link on a page follows — buttons, cards,
|
|
36
|
+
* tiles, slide links: may it be offered right now? False only for a link to a
|
|
37
|
+
* page hidden from the website while booking is hidden (see
|
|
38
|
+
* `isHiddenFromDiscovery`); external links, anchors, `mailto:`/`tel:` and
|
|
39
|
+
* every other page are always offered. Read from the policy the server
|
|
40
|
+
* rendered with and every client navigation refreshes, so the server's HTML
|
|
41
|
+
* and the browser agree. Without a provider (the builder, the backoffice)
|
|
42
|
+
* everything is offered. Running text is not filtered: see docs/PAGES.md.
|
|
43
|
+
*/
|
|
44
|
+
export function useDiscoverableLink(): (href: string | null | undefined) => boolean {
|
|
45
|
+
const policy = useLinkVisibility();
|
|
46
|
+
return useCallback((href) => !isHiddenFromDiscovery(href, policy), [policy]);
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
* and draft store only.
|
|
9
9
|
*/
|
|
10
10
|
import { useQueryClient } from "@tanstack/react-query";
|
|
11
|
-
import { useEffect, useState, type ComponentType } from "react";
|
|
11
|
+
import { useEffect, useRef, useState, type ComponentType } from "react";
|
|
12
12
|
|
|
13
13
|
import { LocaleProvider, isLocale, DEFAULT_LOCALE, type Locale } from "../../i18n";
|
|
14
14
|
import type { BlockInstance } from "../../blocks/types";
|
|
15
|
+
import {
|
|
16
|
+
createEditorMessageGuard,
|
|
17
|
+
postToOrigins,
|
|
18
|
+
PREVIEW_SOURCE,
|
|
19
|
+
trustedEditorOrigins,
|
|
20
|
+
} from "../preview-messaging";
|
|
15
21
|
|
|
16
22
|
export type PreviewDraftSnapshot = {
|
|
17
23
|
blocks: BlockInstance[];
|
|
@@ -43,6 +49,12 @@ export type PreviewPageDeps = {
|
|
|
43
49
|
/** Outline colour used for block hover/selection. */
|
|
44
50
|
outlineColorVar?: string;
|
|
45
51
|
copy?: { draft?: string; published?: string; loading?: string };
|
|
52
|
+
/**
|
|
53
|
+
* Origins other than the preview's own that may drive the canvas — only
|
|
54
|
+
* when the builder is served from a different origin than the site. The
|
|
55
|
+
* canvas otherwise listens only to a builder on its own origin.
|
|
56
|
+
*/
|
|
57
|
+
trustedEditorOrigins?: readonly string[];
|
|
46
58
|
/**
|
|
47
59
|
* What a visitor would get, for the standalone Preview: a hook returning a
|
|
48
60
|
* transform the site applies to the blocks it shows (booking links removed
|
|
@@ -79,17 +91,6 @@ const asIs = (
|
|
|
79
91
|
) => snapshot;
|
|
80
92
|
const useAsIs = () => asIs;
|
|
81
93
|
|
|
82
|
-
type DraftMsg = {
|
|
83
|
-
source: "lovable-editor";
|
|
84
|
-
type: "draft" | "scrollTo";
|
|
85
|
-
blocks: BlockInstance[];
|
|
86
|
-
globals: Record<string, BlockInstance[]>;
|
|
87
|
-
selectedId?: string | null;
|
|
88
|
-
blockId?: string;
|
|
89
|
-
locale?: string;
|
|
90
|
-
sharedContent?: Record<string, unknown>;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
94
|
export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
|
|
94
95
|
const copy = {
|
|
95
96
|
draft: deps.copy?.draft ?? "Draft preview — unsaved changes",
|
|
@@ -168,14 +169,23 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
168
169
|
if (q && isLocale(q)) setLocale(q);
|
|
169
170
|
}, []);
|
|
170
171
|
|
|
171
|
-
// Ready handshake plus draft/scroll messages from the editor
|
|
172
|
+
// Ready handshake plus draft/scroll messages from the editor: only from
|
|
173
|
+
// the frame that embeds this canvas, on a trusted origin, in order (see
|
|
174
|
+
// preview-messaging). Replies name that origin; nothing goes to "*".
|
|
175
|
+
const replyOrigins = useRef<() => string[]>(() => []);
|
|
172
176
|
useEffect(() => {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
177
|
+
const guard = createEditorMessageGuard({
|
|
178
|
+
trustedOrigins: trustedEditorOrigins(window.location.origin, deps.trustedEditorOrigins),
|
|
179
|
+
parent: () => window.parent,
|
|
180
|
+
self: () => window,
|
|
181
|
+
});
|
|
182
|
+
replyOrigins.current = () => guard.replyOrigins();
|
|
183
|
+
if (window.parent !== window) {
|
|
184
|
+
postToOrigins(window.parent, { source: PREVIEW_SOURCE, type: "ready" }, guard.replyOrigins());
|
|
185
|
+
}
|
|
176
186
|
const onMsg = (ev: MessageEvent) => {
|
|
177
|
-
const data = ev
|
|
178
|
-
if (!data
|
|
187
|
+
const data = guard.accept(ev);
|
|
188
|
+
if (!data) return;
|
|
179
189
|
if (data.type === "scrollTo") {
|
|
180
190
|
const id = data.blockId;
|
|
181
191
|
if (!id) return;
|
|
@@ -186,7 +196,6 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
186
196
|
});
|
|
187
197
|
return;
|
|
188
198
|
}
|
|
189
|
-
if (data.type !== "draft") return;
|
|
190
199
|
if (data.sharedContent && Object.keys(data.sharedContent).length) {
|
|
191
200
|
queryClient.setQueryData(
|
|
192
201
|
deps.sharedContentQueryKey,
|
|
@@ -196,9 +205,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
196
205
|
}),
|
|
197
206
|
);
|
|
198
207
|
}
|
|
199
|
-
setBlocks(data.blocks);
|
|
200
|
-
setGlobals(data.globals);
|
|
201
|
-
setSelectedId(data.selectedId
|
|
208
|
+
setBlocks(data.blocks as BlockInstance[]);
|
|
209
|
+
setGlobals(data.globals as Record<string, BlockInstance[]>);
|
|
210
|
+
setSelectedId(data.selectedId);
|
|
202
211
|
if (data.locale && isLocale(data.locale)) setLocale(data.locale);
|
|
203
212
|
setIsDraft(true);
|
|
204
213
|
setReady(true);
|
|
@@ -221,13 +230,12 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
221
230
|
}
|
|
222
231
|
e.preventDefault();
|
|
223
232
|
e.stopPropagation();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"*",
|
|
233
|
+
const blockId = el.getAttribute("data-block-id");
|
|
234
|
+
if (!blockId) return;
|
|
235
|
+
postToOrigins(
|
|
236
|
+
window.parent,
|
|
237
|
+
{ source: PREVIEW_SOURCE, type: "select", blockId },
|
|
238
|
+
replyOrigins.current(),
|
|
231
239
|
);
|
|
232
240
|
};
|
|
233
241
|
document.addEventListener("click", onClick, true);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An address as it may be stored or shown: its origin, and never its
|
|
3
|
+
* credentials, path, query string or fragment.
|
|
4
|
+
*
|
|
5
|
+
* Receivers put keys in paths (`/services/T…/B…/<key>`) as readily as in query
|
|
6
|
+
* strings, so whatever follows the origin is replaced by a fixed marker rather
|
|
7
|
+
* than trimmed. Pure, so audit code in the browser can use it too.
|
|
8
|
+
*/
|
|
9
|
+
export function redactUrl(raw: string | URL): string {
|
|
10
|
+
try {
|
|
11
|
+
const url = typeof raw === "string" ? new URL(raw) : raw;
|
|
12
|
+
if (url.origin === "null") return "(invalid address)";
|
|
13
|
+
const rest = url.pathname !== "/" || url.search !== "" || url.hash !== "";
|
|
14
|
+
return rest ? `${url.origin}/[redacted]` : url.origin;
|
|
15
|
+
} catch {
|
|
16
|
+
return "(invalid address)";
|
|
17
|
+
}
|
|
18
|
+
}
|