@bygghammar/cms-platform 0.94.0 → 0.95.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 +74 -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/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/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- 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/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +39 -3
- package/src/site/routes/public-page.tsx +81 -9
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every address that reaches each page, the way the path resolver sees them.
|
|
3
|
+
*
|
|
4
|
+
* Hiding links to an unlisted page has to recognise all of its addresses — the
|
|
5
|
+
* Swedish slug, the English one under `/en`, a slug changed in the SEO screen,
|
|
6
|
+
* the bare `/<key>` every builder page answers to — and none that belong to a
|
|
7
|
+
* different page. This builds the same map `resolvePathCore` consults, in the
|
|
8
|
+
* same order of precedence:
|
|
9
|
+
*
|
|
10
|
+
* 1. slug overrides from `page_seo`;
|
|
11
|
+
* 2. the site's static page registry, for pages with no override;
|
|
12
|
+
* 3. the bare key, `/<key>` and `/<locale>/<key>`, for pages that exist, only
|
|
13
|
+
* where no slug already claims the address.
|
|
14
|
+
*
|
|
15
|
+
* Within one precedence level the first page (in key order) wins an address,
|
|
16
|
+
* matching the resolver's "first entered wins" for the rare collision instead
|
|
17
|
+
* of guessing differently.
|
|
18
|
+
*/
|
|
19
|
+
import { normalizeRoutePath, servedPath } from "./route-index";
|
|
20
|
+
|
|
21
|
+
/** Slug map of a page in a site's static registry (as `resolvePathCore` takes it). */
|
|
22
|
+
export type AliasSlugRegistry = Record<string, { slug: Record<string, string | undefined> }>;
|
|
23
|
+
|
|
24
|
+
export type RouteAliasInput = {
|
|
25
|
+
/** The site's static page registry. */
|
|
26
|
+
registry: AliasSlugRegistry;
|
|
27
|
+
/** `page_seo.slugs` per page key, for pages that have a row. */
|
|
28
|
+
overrides: ReadonlyMap<string, Record<string, string | undefined>>;
|
|
29
|
+
/** Keys of CMS pages that exist (not in the trash): their `/<key>` answers too. */
|
|
30
|
+
existingKeys: readonly string[];
|
|
31
|
+
locales: readonly string[];
|
|
32
|
+
defaultLocale: string;
|
|
33
|
+
/** The locale an unprefixed address resolves in on this request's domain. */
|
|
34
|
+
requestLocale: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const BARE_KEY = /^[a-z0-9_-]+$/;
|
|
38
|
+
|
|
39
|
+
/** Normalized served path → the page key the resolver would answer with. */
|
|
40
|
+
export function routeAliasMap(input: RouteAliasInput): Map<string, string> {
|
|
41
|
+
const policy = { locales: input.locales };
|
|
42
|
+
const prefixed = input.locales.filter((locale) => locale !== input.defaultLocale);
|
|
43
|
+
const map = new Map<string, string>();
|
|
44
|
+
const claim = (path: string, key: string) => {
|
|
45
|
+
const normalized = normalizeRoutePath(path, policy);
|
|
46
|
+
if (!map.has(normalized)) map.set(normalized, key);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const slugPaths = (key: string, slugs: Record<string, string | undefined>) => {
|
|
50
|
+
const pick = (locale: string) => slugs[locale] ?? slugs[input.defaultLocale];
|
|
51
|
+
// Unprefixed: the slug of the locale this domain resolves bare paths in.
|
|
52
|
+
const bare = pick(input.requestLocale);
|
|
53
|
+
if (bare !== undefined && bare !== null) claim(servedPath(bare, input.defaultLocale, input.defaultLocale), key);
|
|
54
|
+
// Prefixed: every other locale, each under its own prefix.
|
|
55
|
+
for (const locale of prefixed) {
|
|
56
|
+
const slug = pick(locale);
|
|
57
|
+
if (slug !== undefined && slug !== null) claim(servedPath(slug, locale, input.defaultLocale), key);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const key of [...input.overrides.keys()].sort()) {
|
|
62
|
+
slugPaths(key, input.overrides.get(key) ?? {});
|
|
63
|
+
}
|
|
64
|
+
for (const key of Object.keys(input.registry).sort()) {
|
|
65
|
+
if (input.overrides.has(key)) continue;
|
|
66
|
+
slugPaths(key, input.registry[key]!.slug);
|
|
67
|
+
}
|
|
68
|
+
for (const key of [...input.existingKeys].sort()) {
|
|
69
|
+
if (!BARE_KEY.test(key)) continue;
|
|
70
|
+
claim(`/${key}`, key);
|
|
71
|
+
for (const locale of input.locales) claim(`/${locale}/${key}`, key);
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
@@ -43,8 +43,42 @@ export type PreviewPageDeps = {
|
|
|
43
43
|
/** Outline colour used for block hover/selection. */
|
|
44
44
|
outlineColorVar?: string;
|
|
45
45
|
copy?: { draft?: string; published?: string; loading?: string };
|
|
46
|
+
/**
|
|
47
|
+
* What a visitor would get, for the standalone Preview: a hook returning a
|
|
48
|
+
* transform the site applies to the blocks it shows (booking links removed
|
|
49
|
+
* while booking is off, say). Never applied inside the builder's own canvas,
|
|
50
|
+
* which keeps every block and link editable.
|
|
51
|
+
*/
|
|
52
|
+
usePublicView?: () => (
|
|
53
|
+
pageKey: string,
|
|
54
|
+
snapshot: { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> },
|
|
55
|
+
) => { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> };
|
|
46
56
|
};
|
|
47
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Where a preview is being rendered.
|
|
60
|
+
*
|
|
61
|
+
* `canvas` is the builder's own editing surface, embedded in the builder; it
|
|
62
|
+
* shows every block as authored. `preview` is the page opened on its own and
|
|
63
|
+
* follows the public policy. The mode comes from how the page is actually
|
|
64
|
+
* displayed — never from a query parameter — and nothing about it grants
|
|
65
|
+
* access: unpublished content still only arrives from the editor's own
|
|
66
|
+
* browser or through staff-only reads.
|
|
67
|
+
*/
|
|
68
|
+
export type PreviewRenderMode = "canvas" | "preview";
|
|
69
|
+
|
|
70
|
+
export function previewRenderMode(
|
|
71
|
+
win: { parent: unknown } | undefined = typeof window === "undefined" ? undefined : window,
|
|
72
|
+
): PreviewRenderMode {
|
|
73
|
+
return win !== undefined && win.parent !== win ? "canvas" : "preview";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const asIs = (
|
|
77
|
+
_key: string,
|
|
78
|
+
snapshot: { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]> },
|
|
79
|
+
) => snapshot;
|
|
80
|
+
const useAsIs = () => asIs;
|
|
81
|
+
|
|
48
82
|
type DraftMsg = {
|
|
49
83
|
source: "lovable-editor";
|
|
50
84
|
type: "draft" | "scrollTo";
|
|
@@ -72,7 +106,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
72
106
|
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
|
73
107
|
const [ready, setReady] = useState(false);
|
|
74
108
|
const [isDraft, setIsDraft] = useState(false);
|
|
75
|
-
const embedded =
|
|
109
|
+
const embedded = previewRenderMode() === "canvas";
|
|
110
|
+
const publicView = (deps.usePublicView ?? useAsIs)();
|
|
111
|
+
const shown = embedded ? { blocks, globals } : publicView(key, { blocks, globals });
|
|
76
112
|
|
|
77
113
|
// Bootstrap: prefer a local unsaved draft, else the saved version.
|
|
78
114
|
useEffect(() => {
|
|
@@ -225,8 +261,8 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
|
|
|
225
261
|
: "pt-24 md:pt-32 has-[[data-site-hero]]:pt-0 md:has-[[data-site-hero]]:pt-0"}>
|
|
226
262
|
{ready ? (
|
|
227
263
|
<deps.DraftPageRenderer
|
|
228
|
-
blocks={blocks}
|
|
229
|
-
globals={globals}
|
|
264
|
+
blocks={shown.blocks}
|
|
265
|
+
globals={shown.globals}
|
|
230
266
|
edit
|
|
231
267
|
selectedId={selectedId}
|
|
232
268
|
/>
|
|
@@ -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 }) => (
|