@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
@@ -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,18 +49,47 @@ 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[];
58
+ /**
59
+ * What a visitor would get, for the standalone Preview: a hook returning a
60
+ * transform the site applies to the blocks it shows (booking links removed
61
+ * while booking is off, say). Never applied inside the builder's own canvas,
62
+ * which keeps every block and link editable.
63
+ */
64
+ usePublicView?: () => (
65
+ pageKey: string,
66
+ snapshot: { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> },
67
+ ) => { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> };
46
68
  };
47
69
 
48
- type DraftMsg = {
49
- source: "lovable-editor";
50
- type: "draft" | "scrollTo";
51
- blocks: BlockInstance[];
52
- globals: Record<string, BlockInstance[]>;
53
- selectedId?: string | null;
54
- blockId?: string;
55
- locale?: string;
56
- sharedContent?: Record<string, unknown>;
57
- };
70
+ /**
71
+ * Where a preview is being rendered.
72
+ *
73
+ * `canvas` is the builder's own editing surface, embedded in the builder; it
74
+ * shows every block as authored. `preview` is the page opened on its own and
75
+ * follows the public policy. The mode comes from how the page is actually
76
+ * displayed — never from a query parameter — and nothing about it grants
77
+ * access: unpublished content still only arrives from the editor's own
78
+ * browser or through staff-only reads.
79
+ */
80
+ export type PreviewRenderMode = "canvas" | "preview";
81
+
82
+ export function previewRenderMode(
83
+ win: { parent: unknown } | undefined = typeof window === "undefined" ? undefined : window,
84
+ ): PreviewRenderMode {
85
+ return win !== undefined && win.parent !== win ? "canvas" : "preview";
86
+ }
87
+
88
+ const asIs = (
89
+ _key: string,
90
+ snapshot: { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> },
91
+ ) => snapshot;
92
+ const useAsIs = () => asIs;
58
93
 
59
94
  export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
60
95
  const copy = {
@@ -72,7 +107,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
72
107
  const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
73
108
  const [ready, setReady] = useState(false);
74
109
  const [isDraft, setIsDraft] = useState(false);
75
- const embedded = typeof window !== "undefined" && window.parent !== window;
110
+ const embedded = previewRenderMode() === "canvas";
111
+ const publicView = (deps.usePublicView ?? useAsIs)();
112
+ const shown = embedded ? { blocks, globals } : publicView(key, { blocks, globals });
76
113
 
77
114
  // Bootstrap: prefer a local unsaved draft, else the saved version.
78
115
  useEffect(() => {
@@ -132,14 +169,23 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
132
169
  if (q && isLocale(q)) setLocale(q);
133
170
  }, []);
134
171
 
135
- // 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[]>(() => []);
136
176
  useEffect(() => {
137
- const send = (msg: unknown) =>
138
- window.parent && window.parent !== window && window.parent.postMessage(msg, "*");
139
- 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
+ }
140
186
  const onMsg = (ev: MessageEvent) => {
141
- const data = ev.data as DraftMsg | undefined;
142
- if (!data || data.source !== "lovable-editor") return;
187
+ const data = guard.accept(ev);
188
+ if (!data) return;
143
189
  if (data.type === "scrollTo") {
144
190
  const id = data.blockId;
145
191
  if (!id) return;
@@ -150,7 +196,6 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
150
196
  });
151
197
  return;
152
198
  }
153
- if (data.type !== "draft") return;
154
199
  if (data.sharedContent && Object.keys(data.sharedContent).length) {
155
200
  queryClient.setQueryData(
156
201
  deps.sharedContentQueryKey,
@@ -160,9 +205,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
160
205
  }),
161
206
  );
162
207
  }
163
- setBlocks(data.blocks);
164
- setGlobals(data.globals);
165
- setSelectedId(data.selectedId ?? null);
208
+ setBlocks(data.blocks as BlockInstance[]);
209
+ setGlobals(data.globals as Record<string, BlockInstance[]>);
210
+ setSelectedId(data.selectedId);
166
211
  if (data.locale && isLocale(data.locale)) setLocale(data.locale);
167
212
  setIsDraft(true);
168
213
  setReady(true);
@@ -185,13 +230,12 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
185
230
  }
186
231
  e.preventDefault();
187
232
  e.stopPropagation();
188
- window.parent.postMessage(
189
- {
190
- source: "lovable-preview",
191
- type: "select",
192
- blockId: el.getAttribute("data-block-id"),
193
- },
194
- "*",
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(),
195
239
  );
196
240
  };
197
241
  document.addEventListener("click", onClick, true);
@@ -225,8 +269,8 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
225
269
  : "pt-24 md:pt-32 has-[[data-site-hero]]:pt-0 md:has-[[data-site-hero]]:pt-0"}>
226
270
  {ready ? (
227
271
  <deps.DraftPageRenderer
228
- blocks={blocks}
229
- globals={globals}
272
+ blocks={shown.blocks}
273
+ globals={shown.globals}
230
274
  edit
231
275
  selectedId={selectedId}
232
276
  />
@@ -26,8 +26,12 @@ export type PublicPageRouteDeps<Ctx> = {
26
26
  resolvePath: (path: string) => Promise<ResolvedPath>;
27
27
  /** Page-context query (SEO rows, locale, origin, domains). */
28
28
  pageContextQueryOptions: QueryLike;
29
- /** Published-page query for one page key. */
30
- pageQueryOptions: (pageKey: string) => QueryLike;
29
+ /**
30
+ * Published-page query for one page key. With `pagePolicy` configured it is
31
+ * also handed the page's current policy, so a site can keep pages fetched
32
+ * under different policies in different cache entries.
33
+ */
34
+ pageQueryOptions: (pageKey: string, pagePolicy?: unknown) => QueryLike;
31
35
  /** Head builder from `createPageContext`. */
32
36
  buildPageHead: (pageKey: string, context: Ctx | undefined) => PublicPageHead;
33
37
  /** Site chrome. */
@@ -39,17 +43,60 @@ export type PublicPageRouteDeps<Ctx> = {
39
43
  copy: { notFoundTitle: string; errorText: string };
40
44
  /** Padding applied above the page content; defaults to the header offset. */
41
45
  contentClassName?: string;
46
+ /**
47
+ * This page's public policy, read on the server: a query resolving to at
48
+ * least `{ indexable: boolean }`. A page that is reachable but not listed
49
+ * (link-only), or whose policy could not be read, is rendered with
50
+ * `noindex`. The answer is handed to the page as `pagePolicy` in the loader
51
+ * data, so the page can apply the rest of it (booking, say).
52
+ *
53
+ * Per page on purpose: a site-wide list would put the address of every
54
+ * link-only page into the source of every page. Optional: without it every
55
+ * page found is indexable as before. Reachability itself stays with
56
+ * `pageQueryOptions`, which answers nothing for a page that is not public.
57
+ */
58
+ pagePolicy?: (pageKey: string) => QueryLike;
59
+ };
60
+
61
+ /** The part of a page policy the route itself reads. */
62
+ export type RoutePagePolicy = {
63
+ /** False: render with `noindex`. */
64
+ indexable?: boolean;
65
+ /** True: the page exists and is positively not public now. Answer 404 whatever the cache holds. */
66
+ offline?: boolean;
67
+ /** True: the page is public now. A cached "not found" is re-read rather than trusted. */
68
+ live?: boolean;
42
69
  };
43
70
 
44
71
  type EnsureQueryData = <T extends QueryLike>(options: T) => Promise<unknown>;
45
72
  type LoaderArgs = {
46
- context: { queryClient: { ensureQueryData: EnsureQueryData } };
73
+ context: {
74
+ queryClient: {
75
+ ensureQueryData: EnsureQueryData;
76
+ /** Honours the query's freshness, so client navigation re-reads a stale policy. */
77
+ fetchQuery?: EnsureQueryData;
78
+ };
79
+ };
47
80
  params: { _splat?: string };
48
81
  };
49
82
 
83
+ /** What the route hands its head and component. */
84
+ export type PublicPageLoaderData<Ctx> = { pageKey: string; ctx: Ctx; pagePolicy?: unknown };
85
+
86
+ /** A head with its robots directive replaced by `noindex` — never weakened, only added. */
87
+ export function withNoindex(head: PublicPageHead): PublicPageHead {
88
+ return {
89
+ ...head,
90
+ meta: [
91
+ ...(head.meta ?? []).filter((tag) => tag["name"] !== "robots"),
92
+ { name: "robots", content: "noindex" },
93
+ ],
94
+ };
95
+ }
96
+
50
97
  export type PublicPageRoute<Ctx> = {
51
- loader: (args: LoaderArgs) => Promise<{ pageKey: string; ctx: Ctx }>;
52
- head: (args: { loaderData?: { pageKey: string; ctx: Ctx } }) => PublicPageHead;
98
+ loader: (args: LoaderArgs) => Promise<PublicPageLoaderData<Ctx>>;
99
+ head: (args: { loaderData?: PublicPageLoaderData<Ctx> }) => PublicPageHead;
53
100
  PublicPage: (props: { pageKey: string }) => ReactElement;
54
101
  ErrorScreen: () => ReactElement;
55
102
  NotFoundScreen: () => ReactElement;
@@ -73,10 +120,32 @@ export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): Publ
73
120
  }
74
121
  if (res.kind !== "page") throw notFound();
75
122
 
76
- const [ctx, page] = await Promise.all([
123
+ const fetchQuery = context.queryClient.fetchQuery?.bind(context.queryClient);
124
+ const readPolicy = deps.pagePolicy
125
+ ? (fetchQuery ?? context.queryClient.ensureQueryData.bind(context.queryClient))(
126
+ deps.pagePolicy(res.pageKey),
127
+ )
128
+ : Promise.resolve(undefined);
129
+ // The policy first: it decides whether a cached page may be used at all,
130
+ // and which cache entry to read.
131
+ const [ctx, pagePolicy] = await Promise.all([
77
132
  context.queryClient.ensureQueryData(deps.pageContextQueryOptions),
78
- context.queryClient.ensureQueryData(deps.pageQueryOptions(res.pageKey)),
133
+ readPolicy,
79
134
  ]);
135
+ const policy = pagePolicy as RoutePagePolicy | undefined;
136
+ /*
137
+ * A page the current policy reports offline answers 404 even when the
138
+ * browser still holds it from a visit made while it was published. A
139
+ * warm query must not keep an unpublished page on the screen.
140
+ */
141
+ if (policy?.offline === true) throw notFound();
142
+ const pageOptions = deps.pageQueryOptions(res.pageKey, pagePolicy);
143
+ let page = await context.queryClient.ensureQueryData(pageOptions);
144
+ // And the reverse: a "not found" cached before the page went live is
145
+ // read again rather than trusted.
146
+ if (page == null && policy?.live === true && fetchQuery) {
147
+ page = await fetchQuery({ ...pageOptions, staleTime: 0 } as QueryLike);
148
+ }
80
149
 
81
150
  /*
82
151
  * `resolvePath` only knows the path map, which lists drafts too. The
@@ -88,7 +157,7 @@ export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): Publ
88
157
  * path.
89
158
  */
90
159
  if (page == null) throw notFound();
91
- return { pageKey: res.pageKey, ctx: ctx as Ctx };
160
+ return { pageKey: res.pageKey, ctx: ctx as Ctx, pagePolicy };
92
161
  },
93
162
 
94
163
  head({ loaderData }) {
@@ -97,7 +166,10 @@ export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): Publ
97
166
  meta: [{ title: deps.copy.notFoundTitle }, { name: "robots", content: "noindex" }],
98
167
  };
99
168
  }
100
- return deps.buildPageHead(loaderData.pageKey, loaderData.ctx);
169
+ const head = deps.buildPageHead(loaderData.pageKey, loaderData.ctx);
170
+ // Link-only (or a policy that could not be read): reachable, not indexable.
171
+ const policy = loaderData.pagePolicy as RoutePagePolicy | undefined;
172
+ return policy?.indexable === false ? withNoindex(head) : head;
101
173
  },
102
174
 
103
175
  PublicPage: ({ pageKey }) => (
@@ -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
+ }