@bygghammar/cms-platform 0.59.1 → 0.60.1

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.60.1
4
+
5
+ - Drift manifest no longer lists site backoffice route files: sites reach the
6
+ package routes through their own re-export module, so the manifest entry was
7
+ wrong by construction.
8
+
9
+ ## 0.60.0
10
+
11
+ - New backoffice screen `backoffice/SitemapView` plus the `SitemapRoute` route
12
+ and `paths.sitemap`: fetches the deployment's own `/sitemap.xml`,
13
+ per-locale `/sitemap/<locale>.xml` and `/robots.txt` and shows status,
14
+ entry counts and the served body, next to the read-only indexing status.
15
+ - New `server/published-cache`: a short-lived in-process cache for published
16
+ page reads, wired into `getPublishedPageCore` and dropped by every page and
17
+ global-section write in `server/pages-core`, so editor previews load faster
18
+ without ever serving replaced content.
19
+
3
20
  ## 0.59.1
4
21
 
5
22
  - `site/routes/public-page`: path resolution uses the platform `notfound` kind
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.59.1",
3
+ "version": "0.60.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -120,6 +120,7 @@
120
120
  "./config/site-config": "./src/config/site-config.ts",
121
121
  "./server/site-settings-core": "./src/server/site-settings-core.ts",
122
122
  "./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx",
123
+ "./backoffice/SitemapView": "./src/backoffice/SitemapView.tsx",
123
124
  "./backoffice/UsersView": "./src/backoffice/UsersView.tsx",
124
125
  "./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
125
126
  "./backoffice/AuditView": "./src/backoffice/AuditView.tsx",
@@ -149,6 +150,7 @@
149
150
  "./server/media-library-core": "./src/server/media-library-core.ts",
150
151
  "./server/admin-core": "./src/server/admin-core.ts",
151
152
  "./server/pages-core": "./src/server/pages-core.ts",
153
+ "./server/published-cache": "./src/server/published-cache.ts",
152
154
  "./server/resolve-globals": "./src/server/resolve-globals.ts",
153
155
  "./server/content-sections-core": "./src/server/content-sections-core.ts",
154
156
  "./server/translate-db-core": "./src/server/translate-db-core.ts",
@@ -0,0 +1,111 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { IndexingStatusCard } from "./IndexingStatusCard";
4
+ import type { IndexingPolicy } from "../site/indexing";
5
+
6
+ /** One fetched file: what was requested and what the site actually answered. */
7
+ export type SitemapCheck = {
8
+ label: string;
9
+ path: string;
10
+ status: number | null;
11
+ contentType: string | null;
12
+ /** Entry count for XML, line count for robots.txt. Null when unknown. */
13
+ count: number | null;
14
+ body: string;
15
+ error?: string;
16
+ };
17
+
18
+ /**
19
+ * Search-engine files screen.
20
+ *
21
+ * `robots.txt` and the sitemaps are generated at request time by the platform's
22
+ * own route handlers, so the only meaningful question a backoffice can answer is
23
+ * whether they are actually being served from this deployment, with this
24
+ * deployment's origin and locale rules. That is a verification, not a setting —
25
+ * so this screen fetches the live files and shows exactly what a crawler gets.
26
+ */
27
+ export function SitemapView({
28
+ origin,
29
+ locales,
30
+ indexing,
31
+ checks,
32
+ loading,
33
+ onRefresh,
34
+ }: {
35
+ origin: string;
36
+ locales: readonly string[];
37
+ indexing?: IndexingPolicy;
38
+ checks: SitemapCheck[];
39
+ loading?: boolean;
40
+ onRefresh: () => void;
41
+ }): ReactNode {
42
+ return (
43
+ <div className="p-8 md:p-12 w-full space-y-8">
44
+ <div className="flex flex-wrap items-end justify-between gap-4">
45
+ <div>
46
+ <h2 className="font-serif text-xl text-ink">Sitemap &amp; robots.txt</h2>
47
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
48
+ Generated for every request from this deployment&apos;s origin and locales — never a
49
+ static file that can go stale. Public origin <code>{origin}</code>, locales{" "}
50
+ <code>{locales.join(", ")}</code>.
51
+ </p>
52
+ </div>
53
+ <button
54
+ type="button"
55
+ onClick={onRefresh}
56
+ className="border border-ink/20 px-4 py-2 text-sm text-ink hover:border-sea"
57
+ >
58
+ {loading ? "Checking…" : "Check again"}
59
+ </button>
60
+ </div>
61
+
62
+ <IndexingStatusCard indexing={indexing} />
63
+
64
+ <div className="space-y-6">
65
+ {checks.map((c) => {
66
+ const ok = c.status === 200 && !c.error;
67
+ return (
68
+ <div key={c.path} className="border border-ink/10 bg-white">
69
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 px-4 py-3">
70
+ <div>
71
+ <div className="text-sm text-ink">{c.label}</div>
72
+ <a
73
+ href={c.path}
74
+ target="_blank"
75
+ rel="noreferrer"
76
+ className="text-xs text-sea underline"
77
+ >
78
+ {c.path}
79
+ </a>
80
+ </div>
81
+ <div className="flex items-center gap-3 text-xs">
82
+ {c.count !== null && (
83
+ <span className="text-ink/50">
84
+ {c.count} {c.path.endsWith(".xml") ? "entries" : "lines"}
85
+ </span>
86
+ )}
87
+ <span className="text-ink/40">{c.contentType ?? "—"}</span>
88
+ <span
89
+ className={
90
+ ok
91
+ ? "bg-sea/10 px-2 py-1 uppercase tracking-wider text-sea"
92
+ : "bg-red-50 px-2 py-1 uppercase tracking-wider text-red-600"
93
+ }
94
+ >
95
+ {c.error ? "Failed" : `HTTP ${c.status ?? "?"}`}
96
+ </span>
97
+ </div>
98
+ </div>
99
+ <pre className="max-h-64 overflow-auto px-4 py-3 text-[11px] leading-relaxed text-ink/70">
100
+ {c.error ?? c.body.slice(0, 4000) ?? ""}
101
+ </pre>
102
+ </div>
103
+ );
104
+ })}
105
+ {!checks.length && !loading && (
106
+ <p className="text-sm text-ink/50">Nothing checked yet.</p>
107
+ )}
108
+ </div>
109
+ </div>
110
+ );
111
+ }
@@ -282,6 +282,7 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
282
282
  contact: `${base}/admin/contact`,
283
283
  tracking: `${base}/admin/tracking`,
284
284
  redirects: `${base}/admin/redirects`,
285
+ sitemap: `${base}/admin/sitemap`,
285
286
  navigation: `${base}/admin/navigation`,
286
287
  version: `${base}/admin/version`,
287
288
  backup: `${base}/admin/backup`,
@@ -21,6 +21,7 @@ import {
21
21
  Shield,
22
22
  Signpost,
23
23
  SlidersHorizontal,
24
+ Globe,
24
25
  Users,
25
26
  } from "lucide-react";
26
27
  import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -39,6 +40,7 @@ import { FormSettingsView } from "../FormSettingsView";
39
40
  import { LogsView } from "../LogsView";
40
41
  import { RedirectsView } from "../RedirectsView";
41
42
  import { RolesView } from "../RolesView";
43
+ import { SitemapView, type SitemapCheck } from "../SitemapView";
42
44
  import { SiteSettingsView } from "../SiteSettingsView";
43
45
  import { TrackingView } from "../TrackingView";
44
46
  import { UsersView } from "../UsersView";
@@ -372,6 +374,12 @@ export function AdminOverviewRoute() {
372
374
  body: "Old URLs pointing to new ones, created automatically on slug changes.",
373
375
  icon: Signpost,
374
376
  },
377
+ {
378
+ to: paths.sitemap,
379
+ title: "Sitemap & robots.txt",
380
+ body: "Verify the generated sitemap index, per-locale sitemaps and robots.txt served by this deployment.",
381
+ icon: Globe,
382
+ },
375
383
  {
376
384
  to: paths.navigation,
377
385
  title: "Navigation & footer",
@@ -415,6 +423,74 @@ export function AdminOverviewRoute() {
415
423
  );
416
424
  }
417
425
 
426
+ /**
427
+ * Sitemap and robots.txt verification screen.
428
+ *
429
+ * Both files are generated by the platform's own route handlers, so the screen
430
+ * fetches them from this deployment and shows what a crawler actually receives.
431
+ * Nothing here is editable: origin and locales come from configuration, and the
432
+ * indexing decision is an environment fact.
433
+ */
434
+ export function SitemapRoute() {
435
+ const host = backofficeHost();
436
+ const fetchSettings = useServerFn(host.fns.getSiteSettings);
437
+ const settingsQ = useQuery({
438
+ queryKey: ["admin", "site-settings"],
439
+ queryFn: () => fetchSettings(),
440
+ });
441
+
442
+ const paths = [
443
+ { label: "Sitemap index", path: "/sitemap.xml" },
444
+ ...host.locales.map((l) => ({ label: `Sitemap — ${l}`, path: `/sitemap/${l}.xml` })),
445
+ { label: "robots.txt", path: "/robots.txt" },
446
+ ];
447
+
448
+ const checksQ = useQuery({
449
+ queryKey: ["admin", "sitemap-checks", host.locales.join(",")],
450
+ queryFn: async (): Promise<SitemapCheck[]> =>
451
+ Promise.all(
452
+ paths.map(async ({ label, path }) => {
453
+ try {
454
+ const res = await fetch(path, { headers: { Accept: "*/*" }, cache: "no-store" });
455
+ const body = await res.text();
456
+ const count = path.endsWith(".xml")
457
+ ? (body.match(/<(?:url|sitemap)>/g) ?? []).length
458
+ : body.split("\n").filter((l) => l.trim()).length;
459
+ return {
460
+ label,
461
+ path,
462
+ status: res.status,
463
+ contentType: res.headers.get("content-type"),
464
+ count,
465
+ body,
466
+ };
467
+ } catch (err) {
468
+ return {
469
+ label,
470
+ path,
471
+ status: null,
472
+ contentType: null,
473
+ count: null,
474
+ body: "",
475
+ error: err instanceof Error ? err.message : "Request failed",
476
+ };
477
+ }
478
+ }),
479
+ ),
480
+ });
481
+
482
+ return (
483
+ <SitemapView
484
+ origin={(settingsQ.data as { config?: { origin?: string } } | undefined)?.config?.origin ?? host.siteConfig.origin}
485
+ locales={host.locales}
486
+ indexing={(settingsQ.data as { indexing?: never } | undefined)?.indexing}
487
+ checks={checksQ.data ?? []}
488
+ loading={checksQ.isFetching}
489
+ onRefresh={() => void checksQ.refetch()}
490
+ />
491
+ );
492
+ }
493
+
418
494
  /** Email sending settings plus provider credentials. */
419
495
  export function EmailSettingsRoute() {
420
496
  const host = backofficeHost();
@@ -12,6 +12,10 @@ import { diffBlocks, summarizeChanges } from "../cms/version-diff";
12
12
  import type { VersionChange } from "../cms/version-diff";
13
13
  import { actorEmail, assertStaff, assertSuperadmin } from "./guards";
14
14
  import type { PlatformAuthContext } from "./guards";
15
+ import {
16
+ invalidatePublishedCache,
17
+ withPublishedCache,
18
+ } from "./published-cache";
15
19
  import { resolveGlobalsDeep } from "./resolve-globals";
16
20
 
17
21
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -46,6 +50,12 @@ export async function getPublishedPageCore(
46
50
  client: any,
47
51
  key: string,
48
52
  ): Promise<ResolvedPage | null> {
53
+ // Cached per key and dropped by every page/global write below, so a preview
54
+ // reload costs one database round trip instead of several.
55
+ return withPublishedCache("page", key, () => readPublishedPage(client, key));
56
+ }
57
+
58
+ async function readPublishedPage(client: any, key: string): Promise<ResolvedPage | null> {
49
59
  const { data: page } = await client
50
60
  .from("pages")
51
61
  .select("id,key,name,current_version_id")
@@ -128,6 +138,7 @@ export async function savePageVersionCore(
128
138
  input: { pageId: string; blocks: BlockInstance[]; publish?: boolean; note?: string },
129
139
  ) {
130
140
  await assertStaff(context);
141
+ invalidatePublishedCache();
131
142
  const { data: last } = await context.supabase
132
143
  .from("page_versions")
133
144
  .select("version_no")
@@ -243,6 +254,8 @@ export async function restorePageVersionCore(deps: PagesDeps, versionId: string)
243
254
 
244
255
  export async function createPageCore({ context }: PagesDeps, input: { key: string; name: string }) {
245
256
  await assertStaff(context);
257
+ invalidatePublishedCache();
258
+ invalidatePublishedCache();
246
259
  const { data: page, error } = await context.supabase
247
260
  .from("pages")
248
261
  .insert({ key: input.key, name: input.name, is_system: false, status: "draft" })
@@ -265,6 +278,7 @@ export async function renamePageCore(
265
278
  input: { pageId: string; name: string },
266
279
  ) {
267
280
  await assertStaff(context);
281
+ invalidatePublishedCache();
268
282
  const { error } = await context.supabase
269
283
  .from("pages")
270
284
  .update({ name: input.name })
@@ -283,6 +297,7 @@ export async function renamePageCore(
283
297
  /** Soft delete: moves the page to the trash bin. Never destroys data. */
284
298
  export async function deletePageCore({ context }: PagesDeps, pageId: string) {
285
299
  await assertStaff(context);
300
+ invalidatePublishedCache();
286
301
  const email = actorEmail(context);
287
302
  const { data: page } = await context.supabase
288
303
  .from("pages")
@@ -342,6 +357,7 @@ export async function listDeletedPagesCore({
342
357
 
343
358
  export async function restorePageCore({ context }: PagesDeps, pageId: string) {
344
359
  await assertStaff(context);
360
+ invalidatePublishedCache();
345
361
  const { error } = await context.supabase
346
362
  .from("pages")
347
363
  .update({ deleted_at: null, deleted_by: null, deleted_by_email: null })
@@ -361,6 +377,7 @@ export async function restorePageCore({ context }: PagesDeps, pageId: string) {
361
377
  /** Permanent delete from the trash bin — superadmin only. */
362
378
  export async function purgePageCore({ context }: PagesDeps, pageId: string) {
363
379
  await assertStaff(context);
380
+ invalidatePublishedCache();
364
381
  await assertSuperadmin(context);
365
382
  const { data: page } = await context.supabase
366
383
  .from("pages")
@@ -455,6 +472,7 @@ export async function createGlobalSectionCore(
455
472
  input: { key: string; name: string; blocks?: BlockInstance[]; kind?: "block" | "section" },
456
473
  ) {
457
474
  await assertStaff(context);
475
+ invalidatePublishedCache();
458
476
  const kind = input.kind ?? "section";
459
477
  if (kind === "block") assertSingleBlock(input.blocks ?? []);
460
478
  const { data: gs, error } = await context.supabase
@@ -487,6 +505,7 @@ export async function setGlobalSectionKindCore(
487
505
  input: { sectionId: string; kind: "block" | "section" },
488
506
  ) {
489
507
  await assertStaff(context);
508
+ invalidatePublishedCache();
490
509
  if (input.kind === "block") {
491
510
  const { data: gs } = await context.supabase
492
511
  .from("global_sections")
@@ -515,6 +534,7 @@ export async function saveGlobalSectionVersionCore(
515
534
  input: { sectionId: string; blocks: BlockInstance[]; note?: string },
516
535
  ) {
517
536
  await assertStaff(context);
537
+ invalidatePublishedCache();
518
538
  const { data: meta } = await context.supabase
519
539
  .from("global_sections")
520
540
  .select("kind")
@@ -553,6 +573,7 @@ export async function renameGlobalSectionCore(
553
573
  input: { sectionId: string; name: string },
554
574
  ) {
555
575
  await assertStaff(context);
576
+ invalidatePublishedCache();
556
577
  const { error } = await context.supabase
557
578
  .from("global_sections")
558
579
  .update({ name: input.name })
@@ -563,6 +584,7 @@ export async function renameGlobalSectionCore(
563
584
 
564
585
  export async function deleteGlobalSectionCore({ context }: PagesDeps, sectionId: string) {
565
586
  await assertStaff(context);
587
+ invalidatePublishedCache();
566
588
  const { error } = await context.supabase
567
589
  .from("global_sections")
568
590
  .delete()
@@ -596,6 +618,7 @@ export async function schedulePageVersionCore(
596
618
  input: { versionId: string; publishAt: string; unpublishAt?: string | null; note?: string },
597
619
  ) {
598
620
  await assertStaff(context);
621
+ invalidatePublishedCache();
599
622
  const { data: v, error: vErr } = await context.supabase
600
623
  .from("page_versions")
601
624
  .select("page_id")
@@ -649,6 +672,7 @@ export async function listPagePublicationsCore(
649
672
 
650
673
  export async function cancelPagePublicationCore({ context }: PagesDeps, publicationId: string) {
651
674
  await assertStaff(context);
675
+ invalidatePublishedCache();
652
676
  const { error } = await context.supabase
653
677
  .from("page_publications")
654
678
  .update({ is_active: false })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Server-side cache for published page content.
3
+ *
4
+ * The page builder's preview route re-reads the published page and every global
5
+ * section it references on each load, which is several round trips to the
6
+ * database for content that only changes when an editor saves. This module
7
+ * keeps those reads in the server process for a short time and — the part that
8
+ * matters — drops them the moment any page or global section is written, so a
9
+ * preview never shows content that has already been replaced.
10
+ *
11
+ * Deliberately a plain in-process map: it must be free of infrastructure so it
12
+ * works identically in the Worker runtime, the self-hosted Node server and
13
+ * tests. A cold process simply has an empty cache.
14
+ */
15
+
16
+ export type PublishedCacheScope = "page" | "global" | "custom";
17
+
18
+ type Entry = { value: unknown; expiresAt: number };
19
+
20
+ const store = new Map<string, Entry>();
21
+
22
+ /** How long an entry may be served before it is re-read. */
23
+ let ttlMs = 20_000;
24
+
25
+ /** Cheap counters so the backoffice can show whether the cache is working. */
26
+ let hits = 0;
27
+ let misses = 0;
28
+ let invalidations = 0;
29
+
30
+ const cacheKey = (scope: PublishedCacheScope, key: string) => `${scope}:${key}`;
31
+
32
+ /** Sets the entry lifetime. `0` disables caching entirely. */
33
+ export function setPublishedCacheTtl(ms: number): void {
34
+ ttlMs = Math.max(0, ms);
35
+ if (ttlMs === 0) store.clear();
36
+ }
37
+
38
+ /** Current entry lifetime in milliseconds. */
39
+ export function publishedCacheTtl(): number {
40
+ return ttlMs;
41
+ }
42
+
43
+ /** Reads a live entry, or `undefined` when absent or expired. */
44
+ export function publishedCacheGet<T>(scope: PublishedCacheScope, key: string): T | undefined {
45
+ if (ttlMs === 0) return undefined;
46
+ const entry = store.get(cacheKey(scope, key));
47
+ if (!entry) return undefined;
48
+ if (entry.expiresAt <= Date.now()) {
49
+ store.delete(cacheKey(scope, key));
50
+ return undefined;
51
+ }
52
+ return entry.value as T;
53
+ }
54
+
55
+ /** Stores an entry. */
56
+ export function publishedCacheSet(
57
+ scope: PublishedCacheScope,
58
+ key: string,
59
+ value: unknown,
60
+ ): void {
61
+ if (ttlMs === 0) return;
62
+ store.set(cacheKey(scope, key), { value, expiresAt: Date.now() + ttlMs });
63
+ }
64
+
65
+ /**
66
+ * Drops cached content.
67
+ *
68
+ * With no arguments every scope is cleared, which is what a write should do:
69
+ * a saved page can change any global section's resolved output, and clearing
70
+ * everything can only cost one extra read.
71
+ */
72
+ export function invalidatePublishedCache(scope?: PublishedCacheScope, key?: string): void {
73
+ invalidations += 1;
74
+ if (!scope) {
75
+ store.clear();
76
+ return;
77
+ }
78
+ if (key) {
79
+ store.delete(cacheKey(scope, key));
80
+ return;
81
+ }
82
+ const prefix = `${scope}:`;
83
+ for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
84
+ }
85
+
86
+ /** Runs `load` only when the entry is missing or expired. */
87
+ export async function withPublishedCache<T>(
88
+ scope: PublishedCacheScope,
89
+ key: string,
90
+ load: () => Promise<T>,
91
+ ): Promise<T> {
92
+ const cached = publishedCacheGet<T>(scope, key);
93
+ if (cached !== undefined) {
94
+ hits += 1;
95
+ return cached;
96
+ }
97
+ misses += 1;
98
+ const value = await load();
99
+ // `undefined` is indistinguishable from "absent", so it is never stored.
100
+ if (value !== undefined) publishedCacheSet(scope, key, value);
101
+ return value;
102
+ }
103
+
104
+ /** Counters and size, for the backoffice diagnostics screen. */
105
+ export function publishedCacheStats(): {
106
+ entries: number;
107
+ hits: number;
108
+ misses: number;
109
+ invalidations: number;
110
+ ttlMs: number;
111
+ } {
112
+ return { entries: store.size, hits, misses, invalidations, ttlMs };
113
+ }
114
+
115
+ /** Resets counters and contents. Tests use this between cases. */
116
+ export function resetPublishedCache(): void {
117
+ store.clear();
118
+ hits = 0;
119
+ misses = 0;
120
+ invalidations = 0;
121
+ }