@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/docs/PAGES.md +45 -2
  3. package/package.json +3 -1
  4. package/src/admin/describe-log.ts +2 -0
  5. package/src/backoffice/BookingLandingSection.tsx +672 -0
  6. package/src/backoffice/BookingSettingsCard.tsx +34 -4
  7. package/src/backoffice/PageEditorView.tsx +47 -24
  8. package/src/backoffice/routes/host.ts +7 -0
  9. package/src/backoffice/routes/index.tsx +22 -3
  10. package/src/blocks/components/CountdownBlock.tsx +3 -1
  11. package/src/blocks/components/CtaBannerBlock.tsx +8 -2
  12. package/src/blocks/components/HeroFullBlock.tsx +7 -1
  13. package/src/blocks/components/IconGridBlock.tsx +5 -1
  14. package/src/blocks/components/InstagramBlock.tsx +5 -1
  15. package/src/blocks/components/InstagramTiles.tsx +3 -1
  16. package/src/blocks/components/LinkTilesBlock.tsx +8 -1
  17. package/src/blocks/components/MediaSplitBlock.tsx +8 -3
  18. package/src/blocks/components/PriceTableBlock.tsx +6 -5
  19. package/src/blocks/components/TripListBlock.tsx +5 -1
  20. package/src/cms/booking-landing.ts +359 -0
  21. package/src/cms/booking-links.ts +24 -0
  22. package/src/cms/page-visibility.ts +67 -1
  23. package/src/forms/form-audit.ts +22 -2
  24. package/src/html/html-tokenizer.ts +254 -0
  25. package/src/html/sanitize-html.ts +71 -36
  26. package/src/navigation/visibility.ts +65 -3
  27. package/src/popups/runtime.tsx +75 -32
  28. package/src/server/forms-core.ts +9 -0
  29. package/src/server/forms-delivery-core.ts +29 -22
  30. package/src/server/forms-submissions-core.ts +8 -5
  31. package/src/server/media-usage-core.ts +53 -13
  32. package/src/server/outbound-address.ts +131 -0
  33. package/src/server/outbound-http.ts +565 -0
  34. package/src/server/page-visibility-core.ts +231 -5
  35. package/src/server/popup-optin-webhook-core.ts +79 -140
  36. package/src/server/schemas.ts +45 -0
  37. package/src/site/BookingOffNotice.tsx +73 -0
  38. package/src/site/PageRenderer.tsx +59 -3
  39. package/src/site/link-visibility.tsx +21 -2
  40. package/src/site/preview-messaging.ts +232 -0
  41. package/src/site/routes/preview-page.tsx +37 -29
  42. 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}>{children.render(blocks)}</BlockChildrenProvider>
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 blocks={page.blocks} globals={page.globals} registry={registry} />
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 { OPEN_LINK_POLICY, type LinkVisibilityPolicy } from "../navigation/visibility";
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 send = (msg: unknown) =>
174
- window.parent && window.parent !== window && window.parent.postMessage(msg, "*");
175
- send({ source: "lovable-preview", type: "ready" });
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.data as DraftMsg | undefined;
178
- if (!data || data.source !== "lovable-editor") return;
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 ?? null);
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
- window.parent.postMessage(
225
- {
226
- source: "lovable-preview",
227
- type: "select",
228
- blockId: el.getAttribute("data-block-id"),
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
+ }