@bygghammar/cms-platform 0.93.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +9 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/admin/module-inventory.ts +67 -0
  6. package/src/admin/registry-metadata.ts +256 -0
  7. package/src/admin/semver.ts +111 -0
  8. package/src/admin/version-core.ts +233 -25
  9. package/src/admin/version-report.ts +295 -0
  10. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  11. package/src/backoffice/NavigationView.tsx +38 -0
  12. package/src/backoffice/PageBuilderView.tsx +149 -3
  13. package/src/backoffice/PagesListView.tsx +173 -57
  14. package/src/backoffice/SiteSettingsView.tsx +4 -0
  15. package/src/backoffice/StatusBadge.tsx +38 -2
  16. package/src/backoffice/VersionView.tsx +747 -66
  17. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  18. package/src/backoffice/routes/host.ts +24 -0
  19. package/src/backoffice/routes/index.tsx +148 -9
  20. package/src/backoffice/routes/pages.tsx +13 -0
  21. package/src/backoffice/shell/use-resolved-config.tsx +30 -5
  22. package/src/cms/booking-links.ts +176 -0
  23. package/src/cms/page-publication.ts +11 -6
  24. package/src/cms/page-visibility.ts +206 -0
  25. package/src/config/modules.ts +11 -0
  26. package/src/html/sanitize-html.ts +1 -1
  27. package/src/navigation/visibility.ts +235 -0
  28. package/src/popups/package-name.ts +8 -0
  29. package/src/popups/runtime.tsx +155 -4
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/published-cache.ts +23 -3
  33. package/src/server/schemas.ts +17 -0
  34. package/src/site/PageRenderer.tsx +34 -5
  35. package/src/site/SiteFooter.tsx +11 -2
  36. package/src/site/SiteHeader.tsx +41 -21
  37. package/src/site/link-visibility.tsx +28 -0
  38. package/src/site/route-aliases.ts +74 -0
  39. package/src/site/routes/preview-page.tsx +39 -3
  40. 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 = typeof window !== "undefined" && window.parent !== window;
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
- /** 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 }) => (