@bygghammar/cms-platform 0.59.0 → 0.60.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 +16 -0
- package/package.json +3 -1
- package/src/backoffice/SitemapView.tsx +111 -0
- package/src/backoffice/routes/host.ts +1 -0
- package/src/backoffice/routes/index.tsx +76 -0
- package/src/config/package-owned-modules.ts +5 -0
- package/src/server/pages-core.ts +24 -0
- package/src/server/published-cache.ts +121 -0
- package/src/site/routes/public-page.tsx +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.60.0
|
|
4
|
+
|
|
5
|
+
- New backoffice screen `backoffice/SitemapView` plus the `SitemapRoute` route
|
|
6
|
+
and `paths.sitemap`: fetches the deployment's own `/sitemap.xml`,
|
|
7
|
+
per-locale `/sitemap/<locale>.xml` and `/robots.txt` and shows status,
|
|
8
|
+
entry counts and the served body, next to the read-only indexing status.
|
|
9
|
+
- New `server/published-cache`: a short-lived in-process cache for published
|
|
10
|
+
page reads, wired into `getPublishedPageCore` and dropped by every page and
|
|
11
|
+
global-section write in `server/pages-core`, so editor previews load faster
|
|
12
|
+
without ever serving replaced content.
|
|
13
|
+
|
|
14
|
+
## 0.59.1
|
|
15
|
+
|
|
16
|
+
- `site/routes/public-page`: path resolution uses the platform `notfound` kind
|
|
17
|
+
and the fallback screens are typed as function components.
|
|
18
|
+
|
|
3
19
|
## 0.59.0
|
|
4
20
|
|
|
5
21
|
- Added `site/routes/public-page`, `site/routes/preview-page` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
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 & robots.txt</h2>
|
|
47
|
+
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
48
|
+
Generated for every request from this deployment'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();
|
|
@@ -18,6 +18,11 @@ export type OwnedModule = {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
export const PACKAGE_OWNED_MODULES: OwnedModule[] = [
|
|
21
|
+
{
|
|
22
|
+
site: "src/routes/kontoret.admin.sitemap.tsx",
|
|
23
|
+
packageModule: "@bygghammar/cms-platform/backoffice/routes",
|
|
24
|
+
optional: true,
|
|
25
|
+
},
|
|
21
26
|
{ site: "src/lib/i18n/routes.ts", packageModule: "@bygghammar/cms-platform/site/routes" },
|
|
22
27
|
{
|
|
23
28
|
site: "src/lib/i18n/page-context.ts",
|
package/src/server/pages-core.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* site, so the engine lives here and the site keeps a thin route wrapper.
|
|
8
8
|
*/
|
|
9
9
|
import { notFound, redirect } from "@tanstack/react-router";
|
|
10
|
-
import type { ComponentType, ReactNode } from "react";
|
|
10
|
+
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
11
11
|
|
|
12
12
|
export type ResolvedPath =
|
|
13
13
|
| { kind: "page"; pageKey: string }
|
|
14
14
|
| { kind: "redirect"; to: string; status?: number }
|
|
15
|
-
| { kind: "
|
|
15
|
+
| { kind: "notfound" };
|
|
16
16
|
|
|
17
17
|
export type PublicPageHead = {
|
|
18
18
|
meta?: Array<Record<string, string>>;
|
|
@@ -50,9 +50,9 @@ type LoaderArgs = {
|
|
|
50
50
|
export type PublicPageRoute<Ctx> = {
|
|
51
51
|
loader: (args: LoaderArgs) => Promise<{ pageKey: string; ctx: Ctx }>;
|
|
52
52
|
head: (args: { loaderData?: { pageKey: string; ctx: Ctx } }) => PublicPageHead;
|
|
53
|
-
PublicPage:
|
|
54
|
-
ErrorScreen:
|
|
55
|
-
NotFoundScreen:
|
|
53
|
+
PublicPage: (props: { pageKey: string }) => ReactElement;
|
|
54
|
+
ErrorScreen: () => ReactElement;
|
|
55
|
+
NotFoundScreen: () => ReactElement;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): PublicPageRoute<Ctx> {
|