@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
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 =
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
142
|
-
if (!data
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
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: {
|
|
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<
|
|
52
|
-
head: (args: { loaderData?:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|