@bygghammar/cms-platform 0.57.1 → 0.59.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 +20 -0
- package/package.json +11 -3
- package/src/admin/version-core.ts +76 -0
- package/src/config/package-owned-modules.ts +75 -0
- package/src/site/font-bundle.ts +43 -0
- package/src/site/page-context.ts +87 -0
- package/src/site/public-origin.ts +47 -0
- package/src/site/routes/preview-page.tsx +240 -0
- package/src/site/routes/public-page.tsx +114 -0
- package/src/site/routes/section-preview.tsx +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.59.0
|
|
4
|
+
|
|
5
|
+
- Added `site/routes/public-page`, `site/routes/preview-page` and
|
|
6
|
+
`site/routes/section-preview`: the catch-all public page route engine, the
|
|
7
|
+
page-builder preview (draft store, editor handshake, block selection) and the
|
|
8
|
+
standalone content-section preview are now package-owned. Sites keep thin
|
|
9
|
+
route wrappers supplying data sources, chrome and copy.
|
|
10
|
+
- Drift guard manifest now covers the three route wrappers.
|
|
11
|
+
|
|
12
|
+
## 0.58.0
|
|
13
|
+
|
|
14
|
+
- Utilities sweep: the public-page context loader (`site/page-context`), the
|
|
15
|
+
deployment origin resolver (`site/public-origin`), the self-hosted font bundle
|
|
16
|
+
(`site/font-bundle`) and the version screen's build/release metadata
|
|
17
|
+
(`admin/version-core`) are now package-owned factories. Sites pass their own
|
|
18
|
+
data sources, compiled origin and font assets.
|
|
19
|
+
- New drift guard: `config/package-owned-modules` lists every module a site must
|
|
20
|
+
keep as a thin wrapper, so a site test fails the build if platform behaviour is
|
|
21
|
+
reimplemented locally.
|
|
22
|
+
|
|
3
23
|
## 0.57.0
|
|
4
24
|
|
|
5
25
|
- Server-function inputs are now platform contracts: `server/schemas` ships the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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": [
|
|
@@ -172,6 +172,11 @@
|
|
|
172
172
|
"./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
|
|
173
173
|
"./backoffice/routes/pages": "./src/backoffice/routes/pages.tsx",
|
|
174
174
|
"./config/define-site": "./src/config/define-site.ts",
|
|
175
|
+
"./site/page-context": "./src/site/page-context.ts",
|
|
176
|
+
"./site/public-origin": "./src/site/public-origin.ts",
|
|
177
|
+
"./site/font-bundle": "./src/site/font-bundle.ts",
|
|
178
|
+
"./admin/version-core": "./src/admin/version-core.ts",
|
|
179
|
+
"./config/package-owned-modules": "./src/config/package-owned-modules.ts",
|
|
175
180
|
"./server/schema-drift": "./src/server/schema-drift.ts",
|
|
176
181
|
"./server/media-focus-core": "./src/server/media-focus-core.ts",
|
|
177
182
|
"./server/media-meta-core": "./src/server/media-meta-core.ts",
|
|
@@ -207,7 +212,10 @@
|
|
|
207
212
|
"./site/SiteHeader": "./src/site/SiteHeader.tsx",
|
|
208
213
|
"./site/SiteFooter": "./src/site/SiteFooter.tsx",
|
|
209
214
|
"./server/schemas": "./src/server/schemas.ts",
|
|
210
|
-
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts"
|
|
215
|
+
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts",
|
|
216
|
+
"./site/routes/public-page": "./src/site/routes/public-page.tsx",
|
|
217
|
+
"./site/routes/preview-page": "./src/site/routes/preview-page.tsx",
|
|
218
|
+
"./site/routes/section-preview": "./src/site/routes/section-preview.tsx"
|
|
211
219
|
},
|
|
212
220
|
"dependencies": {
|
|
213
221
|
"fflate": "^0.8.3"
|
|
@@ -285,4 +293,4 @@
|
|
|
285
293
|
"cms-platform-migrations": "scripts/sync-migrations.mjs",
|
|
286
294
|
"create-cms-site": "./scripts/create-cms-site.mjs"
|
|
287
295
|
}
|
|
288
|
-
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build metadata and platform release history for the version screen.
|
|
3
|
+
*
|
|
4
|
+
* The build constants are injected by the site's Vite config (they must be, the
|
|
5
|
+
* values only exist at build time); reading them defensively and fetching the
|
|
6
|
+
* package's published releases is identical everywhere, so it lives here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type BuildInfo = {
|
|
10
|
+
appVersion: string;
|
|
11
|
+
buildTime: string;
|
|
12
|
+
platformVersion: string;
|
|
13
|
+
platformRange: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PlatformRelease = {
|
|
17
|
+
version: string;
|
|
18
|
+
publishedAt: string | null;
|
|
19
|
+
current: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Raw build constants as provided by the host build. */
|
|
23
|
+
export type BuildConstants = {
|
|
24
|
+
appVersion?: unknown;
|
|
25
|
+
buildTime?: unknown;
|
|
26
|
+
platformVersion?: unknown;
|
|
27
|
+
platformRange?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const text = (value: unknown, fallback: string): string =>
|
|
31
|
+
typeof value === "string" && value ? value : fallback;
|
|
32
|
+
|
|
33
|
+
/** Normalize the injected build constants into a complete `BuildInfo`. */
|
|
34
|
+
export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
|
|
35
|
+
return {
|
|
36
|
+
appVersion: text(constants.appVersion, "0.0.0"),
|
|
37
|
+
buildTime: text(constants.buildTime, new Date().toISOString()),
|
|
38
|
+
platformVersion: text(constants.platformVersion, "unknown"),
|
|
39
|
+
platformRange: text(constants.platformRange, "unknown"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The npm package name of the shared CMS platform. */
|
|
44
|
+
export const PLATFORM_PACKAGE = "@bygghammar/cms-platform";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The most recent published platform releases, newest first.
|
|
48
|
+
*
|
|
49
|
+
* Network failures are not an error worth surfacing on an information screen —
|
|
50
|
+
* the screen still shows the running version from the build constants.
|
|
51
|
+
*/
|
|
52
|
+
export async function fetchPlatformReleases(
|
|
53
|
+
currentVersion: string,
|
|
54
|
+
limit = 3,
|
|
55
|
+
): Promise<PlatformRelease[]> {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
`https://registry.npmjs.org/${PLATFORM_PACKAGE.replace("/", "%2F")}`,
|
|
59
|
+
{ headers: { accept: "application/json" } },
|
|
60
|
+
);
|
|
61
|
+
if (!res.ok) return [];
|
|
62
|
+
const json = (await res.json()) as { time?: Record<string, string> };
|
|
63
|
+
const time = json.time ?? {};
|
|
64
|
+
return Object.entries(time)
|
|
65
|
+
.filter(([v]) => v !== "created" && v !== "modified")
|
|
66
|
+
.map(([version, publishedAt]) => ({
|
|
67
|
+
version,
|
|
68
|
+
publishedAt,
|
|
69
|
+
current: version === currentVersion,
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
|
72
|
+
.slice(0, limit);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift guard manifest.
|
|
3
|
+
*
|
|
4
|
+
* Each entry names a module a site is expected to keep as a thin wrapper, and
|
|
5
|
+
* the package export it must delegate to. A site test walks this manifest and
|
|
6
|
+
* fails the build when a local file stops importing the package module — the
|
|
7
|
+
* failure mode this whole extraction exists to prevent, where a site quietly
|
|
8
|
+
* reimplements platform behaviour and then drifts away from every other site.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type OwnedModule = {
|
|
12
|
+
/** Path relative to the site's project root. */
|
|
13
|
+
site: string;
|
|
14
|
+
/** Package specifier the site file must reference. */
|
|
15
|
+
packageModule: string;
|
|
16
|
+
/** Whether the site is allowed not to have the file at all. */
|
|
17
|
+
optional?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const PACKAGE_OWNED_MODULES: OwnedModule[] = [
|
|
21
|
+
{ site: "src/lib/i18n/routes.ts", packageModule: "@bygghammar/cms-platform/site/routes" },
|
|
22
|
+
{
|
|
23
|
+
site: "src/lib/i18n/page-context.ts",
|
|
24
|
+
packageModule: "@bygghammar/cms-platform/site/page-context",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
site: "src/lib/site/public-origin.ts",
|
|
28
|
+
packageModule: "@bygghammar/cms-platform/site/public-origin",
|
|
29
|
+
},
|
|
30
|
+
{ site: "src/lib/site/site-url.ts", packageModule: "@bygghammar/cms-platform/site/site-url" },
|
|
31
|
+
{ site: "src/lib/fonts/bundle.ts", packageModule: "@bygghammar/cms-platform/site/font-bundle" },
|
|
32
|
+
{
|
|
33
|
+
site: "src/lib/admin/version.server.ts",
|
|
34
|
+
packageModule: "@bygghammar/cms-platform/admin/version-core",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
site: "src/lib/backup/backup.server.ts",
|
|
38
|
+
packageModule: "@bygghammar/cms-platform/server/backup-core",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
site: "src/components/site/SiteHeader.tsx",
|
|
42
|
+
packageModule: "@bygghammar/cms-platform/site/SiteHeader",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
site: "src/components/site/SiteFooter.tsx",
|
|
46
|
+
packageModule: "@bygghammar/cms-platform/site/SiteFooter",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
site: "src/components/site/PageRenderer.tsx",
|
|
50
|
+
packageModule: "@bygghammar/cms-platform/site/PageRenderer",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
site: "src/components/site/blocks/FormBlock.tsx",
|
|
54
|
+
packageModule: "@bygghammar/cms-platform/blocks/components/FormBlock",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
site: "src/components/site/blocks/CourseListBlock.tsx",
|
|
58
|
+
packageModule: "@bygghammar/cms-platform/blocks/components/TripListBlock",
|
|
59
|
+
optional: true,
|
|
60
|
+
},
|
|
61
|
+
{ site: "src/lib/blocks/registry.tsx", packageModule: "@bygghammar/cms-platform/blocks/registry" },
|
|
62
|
+
{ site: "src/lib/site/definition.ts", packageModule: "@bygghammar/cms-platform/config/define-site" },
|
|
63
|
+
{
|
|
64
|
+
site: "src/routes/$.tsx",
|
|
65
|
+
packageModule: "@bygghammar/cms-platform/site/routes/public-page",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
site: "src/routes/preview.$key.tsx",
|
|
69
|
+
packageModule: "@bygghammar/cms-platform/site/routes/preview-page",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
site: "src/routes/preview.section.$key.tsx",
|
|
73
|
+
packageModule: "@bygghammar/cms-platform/site/routes/section-preview",
|
|
74
|
+
},
|
|
75
|
+
];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted WOFF2 payloads, bundled into the server build.
|
|
3
|
+
*
|
|
4
|
+
* Fonts deliberately do NOT live in `public/`: files served by the framework's
|
|
5
|
+
* static-asset handler bypass every application layer, so they come back without
|
|
6
|
+
* `Cache-Control` and without `Expires`. Serving them from a real route puts them
|
|
7
|
+
* back under the deployment's caching policy in both runtimes.
|
|
8
|
+
*
|
|
9
|
+
* The site imports its own `.woff2?inline` assets (they are brand assets) and
|
|
10
|
+
* hands the resulting data URIs to this factory; decoding and caching are shared.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type FontBundle = {
|
|
14
|
+
/** Every filename this bundle can serve. */
|
|
15
|
+
filenames: string[];
|
|
16
|
+
/** Font bytes, decoded once per process. `undefined` for unknown names. */
|
|
17
|
+
fontBytes: (name: string) => Uint8Array | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fromDataUri(uri: string): Uint8Array {
|
|
21
|
+
const base64 = uri.slice(uri.indexOf(",") + 1);
|
|
22
|
+
const binary = atob(base64);
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build a font bundle from a `filename -> data URI` map. */
|
|
29
|
+
export function createFontBundle(dataUris: Record<string, string>): FontBundle {
|
|
30
|
+
const decoded = new Map<string, Uint8Array>();
|
|
31
|
+
return {
|
|
32
|
+
filenames: Object.keys(dataUris),
|
|
33
|
+
fontBytes(name: string): Uint8Array | undefined {
|
|
34
|
+
const cached = decoded.get(name);
|
|
35
|
+
if (cached) return cached;
|
|
36
|
+
const uri = dataUris[name];
|
|
37
|
+
if (!uri) return undefined;
|
|
38
|
+
const bytes = fromDataUri(uri);
|
|
39
|
+
decoded.set(name, bytes);
|
|
40
|
+
return bytes;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-page context: the single server round-trip every public route needs.
|
|
3
|
+
*
|
|
4
|
+
* A public page needs four facts before it can render its `<head>`: the CMS SEO
|
|
5
|
+
* overrides, the locale implied by the request domain, the request origin, and
|
|
6
|
+
* the locale/domain map used for cross-domain hreflang. Fetching them one by one
|
|
7
|
+
* per route is what made this glue site-local; the engine is identical for every
|
|
8
|
+
* site, so it lives here and the site supplies only its data sources.
|
|
9
|
+
*/
|
|
10
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
11
|
+
|
|
12
|
+
import type { Locale } from "../i18n/locales";
|
|
13
|
+
import type { PageHead, PageSeoOverrides, SiteRoutes } from "./routes";
|
|
14
|
+
|
|
15
|
+
/** One `locale -> domain` mapping row, as stored by the domains module. */
|
|
16
|
+
export type PageContextDomain = { locale: string; domain: string };
|
|
17
|
+
|
|
18
|
+
export type PageContext<Row = unknown> = {
|
|
19
|
+
seoRows: Row[];
|
|
20
|
+
locale: Locale;
|
|
21
|
+
origin: string;
|
|
22
|
+
domains: PageContextDomain[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PageContextDeps<Row> = {
|
|
26
|
+
/** Raw `page_seo` rows for this site. */
|
|
27
|
+
getSeoRows: () => Promise<Row[]>;
|
|
28
|
+
/** Locale + origin derived from the incoming request. */
|
|
29
|
+
getRequestLocale: () => Promise<{ locale: Locale; origin: string }>;
|
|
30
|
+
/** Configured locale domains. */
|
|
31
|
+
getLocaleDomains: () => Promise<PageContextDomain[]>;
|
|
32
|
+
/** Convert stored rows into the routing engine's override shape. */
|
|
33
|
+
rowsToOverrides: (rows: Row[] | undefined) => PageSeoOverrides;
|
|
34
|
+
/** The site's routing engine (from `createSiteRoutes`). */
|
|
35
|
+
routes: Pick<SiteRoutes, "resolveRoute" | "pageHeadMeta">;
|
|
36
|
+
/** Locale used when nothing else is known. */
|
|
37
|
+
defaultLocale: Locale;
|
|
38
|
+
/** Query key; defaults to `["page-context"]`. */
|
|
39
|
+
queryKey?: readonly unknown[];
|
|
40
|
+
/** Cache lifetime in ms; defaults to one minute. */
|
|
41
|
+
staleTime?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SitePageContext<Row> = {
|
|
45
|
+
pageContextQueryOptions: ReturnType<typeof queryOptions<PageContext<Row>>>;
|
|
46
|
+
buildPageHead: (pageKey: string, context: PageContext<Row> | undefined) => PageHead;
|
|
47
|
+
canonicalPathFor: (pageKey: string, context: PageContext<Row> | undefined) => string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Build the page-context loader + head helpers for one site. */
|
|
51
|
+
export function createPageContext<Row>(deps: PageContextDeps<Row>): SitePageContext<Row> {
|
|
52
|
+
async function fetchPageContext(): Promise<PageContext<Row>> {
|
|
53
|
+
const [seoRows, localeInfo, domains] = await Promise.all([
|
|
54
|
+
deps.getSeoRows(),
|
|
55
|
+
deps.getRequestLocale(),
|
|
56
|
+
deps.getLocaleDomains(),
|
|
57
|
+
]);
|
|
58
|
+
return {
|
|
59
|
+
seoRows,
|
|
60
|
+
locale: localeInfo.locale,
|
|
61
|
+
origin: localeInfo.origin,
|
|
62
|
+
domains,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const options = queryOptions<PageContext<Row>>({
|
|
67
|
+
queryKey: deps.queryKey ?? ["page-context"],
|
|
68
|
+
queryFn: fetchPageContext,
|
|
69
|
+
staleTime: deps.staleTime ?? 60_000,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function buildPageHead(pageKey: string, context: PageContext<Row> | undefined): PageHead {
|
|
73
|
+
const locale = context?.locale ?? deps.defaultLocale;
|
|
74
|
+
const origin = context?.origin ?? "";
|
|
75
|
+
const overrides = deps.rowsToOverrides(context?.seoRows);
|
|
76
|
+
const domains = context?.domains ?? [];
|
|
77
|
+
return deps.routes.pageHeadMeta(pageKey, locale, origin, overrides, domains);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function canonicalPathFor(pageKey: string, context: PageContext<Row> | undefined): string {
|
|
81
|
+
const locale = context?.locale ?? deps.defaultLocale;
|
|
82
|
+
const overrides = deps.rowsToOverrides(context?.seoRows);
|
|
83
|
+
return deps.routes.resolveRoute(pageKey, locale, overrides);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { pageContextQueryOptions: options, buildPageHead, canonicalPathFor };
|
|
87
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical public origin for a deployment.
|
|
3
|
+
*
|
|
4
|
+
* The origin is a deployment fact, not editable content: preview, staging and
|
|
5
|
+
* production are different hosts. It is read from the environment, with the
|
|
6
|
+
* origin compiled into the site's source as the fallback, and is deliberately
|
|
7
|
+
* never stored as a database override — canonical URLs, sitemaps and auth
|
|
8
|
+
* redirects must be right before any database read happens.
|
|
9
|
+
*/
|
|
10
|
+
import { originIsFromEnvironment, resolveSiteOrigin } from "../config/site-config";
|
|
11
|
+
|
|
12
|
+
export type PublicOriginDeps = {
|
|
13
|
+
/** Origin compiled into the build; used whenever no environment value is set. */
|
|
14
|
+
codeOrigin: string;
|
|
15
|
+
/** Reads the configured origin from the environment, or `undefined`. */
|
|
16
|
+
readEnvOrigin: () => string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PublicOrigin = {
|
|
20
|
+
/** The origin this deployment should advertise. Always returns something. */
|
|
21
|
+
publicOrigin: () => string;
|
|
22
|
+
/**
|
|
23
|
+
* The origin this deployment was EXPLICITLY configured with, or `null`.
|
|
24
|
+
*
|
|
25
|
+
* Distinct from `publicOrigin()`: a deployment with no configuration is not
|
|
26
|
+
* the live site, it is a deployment whose configuration is missing. Answering
|
|
27
|
+
* that from a constant would make every unconfigured build claim to be
|
|
28
|
+
* production — the one answer that must never be reached by default.
|
|
29
|
+
*/
|
|
30
|
+
configuredPublicOrigin: () => string | null;
|
|
31
|
+
/** Whether the running origin came from the environment rather than code. */
|
|
32
|
+
publicOriginFromEnv: () => boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Build the origin accessors for one deployment. */
|
|
36
|
+
export function createPublicOrigin(deps: PublicOriginDeps): PublicOrigin {
|
|
37
|
+
return {
|
|
38
|
+
publicOrigin: () => resolveSiteOrigin(deps.codeOrigin, deps.readEnvOrigin()),
|
|
39
|
+
configuredPublicOrigin: () => {
|
|
40
|
+
const value = deps.readEnvOrigin();
|
|
41
|
+
if (typeof value !== "string") return null;
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return trimmed ? trimmed : null;
|
|
44
|
+
},
|
|
45
|
+
publicOriginFromEnv: () => originIsFromEnvironment(deps.codeOrigin, deps.readEnvOrigin()),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-builder preview route (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The preview renders either an unsaved draft (from the local draft store or a
|
|
5
|
+
* postMessage stream from the builder) or the published version of a page, and
|
|
6
|
+
* talks back to the editor: ready handshake, block selection, scroll-to-block.
|
|
7
|
+
* All of that is platform behaviour; a site supplies its data sources, chrome
|
|
8
|
+
* and draft store only.
|
|
9
|
+
*/
|
|
10
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useEffect, useState, type ComponentType } from "react";
|
|
12
|
+
|
|
13
|
+
import { LocaleProvider, isLocale, DEFAULT_LOCALE, type Locale } from "../../i18n";
|
|
14
|
+
import type { BlockInstance } from "../../blocks/types";
|
|
15
|
+
|
|
16
|
+
export type PreviewDraftSnapshot = {
|
|
17
|
+
blocks: BlockInstance[];
|
|
18
|
+
globals?: Record<string, BlockInstance[]>;
|
|
19
|
+
locale?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PreviewPageDeps = {
|
|
23
|
+
/** Published blocks + globals for a page key. */
|
|
24
|
+
getPublishedPage: (key: string) => Promise<PreviewDraftSnapshot | null | undefined>;
|
|
25
|
+
/** Saved blocks for a global section key (preview keys prefixed `gs:`). */
|
|
26
|
+
getGlobalSection: (key: string) => Promise<{ blocks: BlockInstance[] } | null | undefined>;
|
|
27
|
+
/** Local draft store written by the builder. */
|
|
28
|
+
readDraft: (key: string) => PreviewDraftSnapshot | null;
|
|
29
|
+
subscribeDraft: (key: string, cb: (snap: PreviewDraftSnapshot) => void) => () => void;
|
|
30
|
+
/** In-memory renderer for a block tree. */
|
|
31
|
+
DraftPageRenderer: ComponentType<{
|
|
32
|
+
blocks: BlockInstance[];
|
|
33
|
+
globals: Record<string, BlockInstance[]>;
|
|
34
|
+
edit?: boolean;
|
|
35
|
+
selectedId?: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
Header: ComponentType;
|
|
38
|
+
Footer: ComponentType;
|
|
39
|
+
/** Query key holding shared content sections, patched from inspector edits. */
|
|
40
|
+
sharedContentQueryKey: readonly unknown[];
|
|
41
|
+
/** Page key rendered without the header offset (usually the home page). */
|
|
42
|
+
fullBleedKey?: string;
|
|
43
|
+
/** Outline colour used for block hover/selection. */
|
|
44
|
+
outlineColorVar?: string;
|
|
45
|
+
copy?: { draft?: string; published?: string; loading?: string };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type DraftMsg = {
|
|
49
|
+
source: "lovable-editor";
|
|
50
|
+
type: "draft" | "scrollTo";
|
|
51
|
+
blocks: BlockInstance[];
|
|
52
|
+
globals: Record<string, BlockInstance[]>;
|
|
53
|
+
selectedId?: string | null;
|
|
54
|
+
blockId?: string;
|
|
55
|
+
locale?: string;
|
|
56
|
+
sharedContent?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
|
|
60
|
+
const copy = {
|
|
61
|
+
draft: deps.copy?.draft ?? "Draft preview — unsaved changes",
|
|
62
|
+
published: deps.copy?.published ?? "Preview — published version",
|
|
63
|
+
loading: deps.copy?.loading ?? "Loading preview…",
|
|
64
|
+
};
|
|
65
|
+
const outline = deps.outlineColorVar ?? "var(--primary, #2ac4ea)";
|
|
66
|
+
|
|
67
|
+
return function PreviewPage({ pageKey: key }) {
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
const [blocks, setBlocks] = useState<BlockInstance[]>([]);
|
|
70
|
+
const [globals, setGlobals] = useState<Record<string, BlockInstance[]>>({});
|
|
71
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
72
|
+
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
|
73
|
+
const [ready, setReady] = useState(false);
|
|
74
|
+
const [isDraft, setIsDraft] = useState(false);
|
|
75
|
+
const embedded = typeof window !== "undefined" && window.parent !== window;
|
|
76
|
+
|
|
77
|
+
// Bootstrap: prefer a local unsaved draft, else the saved version.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let alive = true;
|
|
80
|
+
const local = deps.readDraft(key);
|
|
81
|
+
if (local) {
|
|
82
|
+
setBlocks(local.blocks);
|
|
83
|
+
setGlobals(local.globals ?? {});
|
|
84
|
+
if (local.locale && isLocale(local.locale)) setLocale(local.locale);
|
|
85
|
+
setIsDraft(true);
|
|
86
|
+
setReady(true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (key.startsWith("gs:")) {
|
|
90
|
+
deps
|
|
91
|
+
.getGlobalSection(key.slice(3))
|
|
92
|
+
.then((res) => {
|
|
93
|
+
if (!alive || !res) return;
|
|
94
|
+
setBlocks((prev) => (prev.length ? prev : res.blocks));
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {})
|
|
97
|
+
.finally(() => alive && setReady(true));
|
|
98
|
+
return () => {
|
|
99
|
+
alive = false;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
deps
|
|
103
|
+
.getPublishedPage(key)
|
|
104
|
+
.then((res) => {
|
|
105
|
+
if (!alive || !res) return;
|
|
106
|
+
setBlocks((prev) => (prev.length ? prev : res.blocks));
|
|
107
|
+
setGlobals((prev) => (Object.keys(prev).length ? prev : (res.globals ?? {})));
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {})
|
|
110
|
+
.finally(() => alive && setReady(true));
|
|
111
|
+
return () => {
|
|
112
|
+
alive = false;
|
|
113
|
+
};
|
|
114
|
+
}, [key]);
|
|
115
|
+
|
|
116
|
+
// Live sync with the builder in another tab.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (embedded) return;
|
|
119
|
+
return deps.subscribeDraft(key, (snap) => {
|
|
120
|
+
setBlocks(snap.blocks);
|
|
121
|
+
setGlobals(snap.globals ?? {});
|
|
122
|
+
if (snap.locale && isLocale(snap.locale)) setLocale(snap.locale);
|
|
123
|
+
setIsDraft(true);
|
|
124
|
+
setReady(true);
|
|
125
|
+
});
|
|
126
|
+
}, [key, embedded]);
|
|
127
|
+
|
|
128
|
+
// Locale from the URL (?locale=en) wins for standalone tabs.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (typeof window === "undefined") return;
|
|
131
|
+
const q = new URLSearchParams(window.location.search).get("locale");
|
|
132
|
+
if (q && isLocale(q)) setLocale(q);
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Ready handshake plus draft/scroll messages from the editor.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const send = (msg: unknown) =>
|
|
138
|
+
window.parent && window.parent !== window && window.parent.postMessage(msg, "*");
|
|
139
|
+
send({ source: "lovable-preview", type: "ready" });
|
|
140
|
+
const onMsg = (ev: MessageEvent) => {
|
|
141
|
+
const data = ev.data as DraftMsg | undefined;
|
|
142
|
+
if (!data || data.source !== "lovable-editor") return;
|
|
143
|
+
if (data.type === "scrollTo") {
|
|
144
|
+
const id = data.blockId;
|
|
145
|
+
if (!id) return;
|
|
146
|
+
requestAnimationFrame(() => {
|
|
147
|
+
document
|
|
148
|
+
.querySelector(`[data-block-id="${CSS.escape(id)}"]`)
|
|
149
|
+
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (data.type !== "draft") return;
|
|
154
|
+
if (data.sharedContent && Object.keys(data.sharedContent).length) {
|
|
155
|
+
queryClient.setQueryData(
|
|
156
|
+
deps.sharedContentQueryKey,
|
|
157
|
+
(prev: Record<string, unknown> | undefined) => ({
|
|
158
|
+
...(prev ?? {}),
|
|
159
|
+
...data.sharedContent,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
setBlocks(data.blocks);
|
|
164
|
+
setGlobals(data.globals);
|
|
165
|
+
setSelectedId(data.selectedId ?? null);
|
|
166
|
+
if (data.locale && isLocale(data.locale)) setLocale(data.locale);
|
|
167
|
+
setIsDraft(true);
|
|
168
|
+
setReady(true);
|
|
169
|
+
};
|
|
170
|
+
window.addEventListener("message", onMsg);
|
|
171
|
+
return () => window.removeEventListener("message", onMsg);
|
|
172
|
+
}, [queryClient]);
|
|
173
|
+
|
|
174
|
+
// Intercept clicks and report the outermost block to the editor.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const onClick = (e: MouseEvent) => {
|
|
177
|
+
if (window.parent === window) return;
|
|
178
|
+
const target = e.target as HTMLElement | null;
|
|
179
|
+
let el = target?.closest("[data-block-id]") as HTMLElement | null;
|
|
180
|
+
if (!el) return;
|
|
181
|
+
let outer = el.parentElement?.closest("[data-block-id]") as HTMLElement | null;
|
|
182
|
+
while (outer) {
|
|
183
|
+
el = outer;
|
|
184
|
+
outer = el.parentElement?.closest("[data-block-id]") as HTMLElement | null;
|
|
185
|
+
}
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
window.parent.postMessage(
|
|
189
|
+
{
|
|
190
|
+
source: "lovable-preview",
|
|
191
|
+
type: "select",
|
|
192
|
+
blockId: el.getAttribute("data-block-id"),
|
|
193
|
+
},
|
|
194
|
+
"*",
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener("click", onClick, true);
|
|
198
|
+
return () => document.removeEventListener("click", onClick, true);
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<LocaleProvider locale={locale}>
|
|
203
|
+
<style>{`
|
|
204
|
+
.lovable-block { position: relative; }
|
|
205
|
+
.lovable-block::after {
|
|
206
|
+
content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
207
|
+
outline: 2px dashed transparent; outline-offset: -2px;
|
|
208
|
+
transition: outline-color .15s ease; z-index: 40;
|
|
209
|
+
}
|
|
210
|
+
.lovable-block:hover::after { outline-color: ${outline}; }
|
|
211
|
+
.lovable-block[data-selected="true"]::after {
|
|
212
|
+
outline: 3px solid ${outline}; outline-offset: -3px;
|
|
213
|
+
}
|
|
214
|
+
a, button { cursor: pointer; }
|
|
215
|
+
`}</style>
|
|
216
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
217
|
+
{!embedded && (
|
|
218
|
+
<div className="fixed bottom-4 left-4 z-[100] border border-ink/15 bg-white/95 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] text-ink/70 shadow-lg">
|
|
219
|
+
{isDraft ? copy.draft : copy.published}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
<deps.Header />
|
|
223
|
+
<div className={key === (deps.fullBleedKey ?? "home") ? "" : "pt-24 md:pt-32"}>
|
|
224
|
+
{ready ? (
|
|
225
|
+
<deps.DraftPageRenderer
|
|
226
|
+
blocks={blocks}
|
|
227
|
+
globals={globals}
|
|
228
|
+
edit
|
|
229
|
+
selectedId={selectedId}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<div className="p-12 text-ink/50 text-sm">{copy.loading}</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
<deps.Footer />
|
|
236
|
+
</main>
|
|
237
|
+
</LocaleProvider>
|
|
238
|
+
);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public catch-all page route (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Every site resolves an incoming path to a CMS page key, honours stored
|
|
5
|
+
* redirects, warms the page + page-context queries and renders the page between
|
|
6
|
+
* the site chrome. Only the data sources, chrome components and copy differ per
|
|
7
|
+
* site, so the engine lives here and the site keeps a thin route wrapper.
|
|
8
|
+
*/
|
|
9
|
+
import { notFound, redirect } from "@tanstack/react-router";
|
|
10
|
+
import type { ComponentType, ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
export type ResolvedPath =
|
|
13
|
+
| { kind: "page"; pageKey: string }
|
|
14
|
+
| { kind: "redirect"; to: string; status?: number }
|
|
15
|
+
| { kind: "none" };
|
|
16
|
+
|
|
17
|
+
export type PublicPageHead = {
|
|
18
|
+
meta?: Array<Record<string, string>>;
|
|
19
|
+
links?: Array<Record<string, string>>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type QueryLike = { queryKey: readonly unknown[] };
|
|
23
|
+
|
|
24
|
+
export type PublicPageRouteDeps<Ctx> = {
|
|
25
|
+
/** Resolve a request path to a page key, a redirect, or nothing. */
|
|
26
|
+
resolvePath: (path: string) => Promise<ResolvedPath>;
|
|
27
|
+
/** Page-context query (SEO rows, locale, origin, domains). */
|
|
28
|
+
pageContextQueryOptions: QueryLike;
|
|
29
|
+
/** Published-page query for one page key. */
|
|
30
|
+
pageQueryOptions: (pageKey: string) => QueryLike;
|
|
31
|
+
/** Head builder from `createPageContext`. */
|
|
32
|
+
buildPageHead: (pageKey: string, context: Ctx | undefined) => PublicPageHead;
|
|
33
|
+
/** Site chrome. */
|
|
34
|
+
Header: ComponentType;
|
|
35
|
+
Footer: ComponentType;
|
|
36
|
+
/** Published-page renderer. */
|
|
37
|
+
PageRenderer: ComponentType<{ pageKey: string }>;
|
|
38
|
+
/** Localized-free copy for the fallback screens (already resolved strings). */
|
|
39
|
+
copy: { notFoundTitle: string; errorText: string };
|
|
40
|
+
/** Padding applied above the page content; defaults to the header offset. */
|
|
41
|
+
contentClassName?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type EnsureQueryData = <T extends QueryLike>(options: T) => Promise<unknown>;
|
|
45
|
+
type LoaderArgs = {
|
|
46
|
+
context: { queryClient: { ensureQueryData: EnsureQueryData } };
|
|
47
|
+
params: { _splat?: string };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PublicPageRoute<Ctx> = {
|
|
51
|
+
loader: (args: LoaderArgs) => Promise<{ pageKey: string; ctx: Ctx }>;
|
|
52
|
+
head: (args: { loaderData?: { pageKey: string; ctx: Ctx } }) => PublicPageHead;
|
|
53
|
+
PublicPage: ComponentType<{ pageKey: string }>;
|
|
54
|
+
ErrorScreen: ComponentType;
|
|
55
|
+
NotFoundScreen: ComponentType;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): PublicPageRoute<Ctx> {
|
|
59
|
+
const Shell = ({ children }: { children: ReactNode }) => (
|
|
60
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
61
|
+
<deps.Header />
|
|
62
|
+
{children}
|
|
63
|
+
<deps.Footer />
|
|
64
|
+
</main>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
async loader({ context, params }) {
|
|
69
|
+
const path = `/${params._splat ?? ""}`;
|
|
70
|
+
const res = await deps.resolvePath(path);
|
|
71
|
+
if (res.kind === "redirect") {
|
|
72
|
+
throw redirect({ href: res.to, statusCode: res.status === 302 ? 302 : 301 });
|
|
73
|
+
}
|
|
74
|
+
if (res.kind !== "page") throw notFound();
|
|
75
|
+
|
|
76
|
+
const [ctx] = await Promise.all([
|
|
77
|
+
context.queryClient.ensureQueryData(deps.pageContextQueryOptions),
|
|
78
|
+
context.queryClient.ensureQueryData(deps.pageQueryOptions(res.pageKey)),
|
|
79
|
+
]);
|
|
80
|
+
return { pageKey: res.pageKey, ctx: ctx as Ctx };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
head({ loaderData }) {
|
|
84
|
+
if (!loaderData) {
|
|
85
|
+
return {
|
|
86
|
+
meta: [{ title: deps.copy.notFoundTitle }, { name: "robots", content: "noindex" }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return deps.buildPageHead(loaderData.pageKey, loaderData.ctx);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
PublicPage: ({ pageKey }) => (
|
|
93
|
+
<Shell>
|
|
94
|
+
<div className={deps.contentClassName ?? "pt-24 md:pt-32"}>
|
|
95
|
+
<deps.PageRenderer pageKey={pageKey} />
|
|
96
|
+
</div>
|
|
97
|
+
</Shell>
|
|
98
|
+
),
|
|
99
|
+
|
|
100
|
+
ErrorScreen: () => (
|
|
101
|
+
<Shell>
|
|
102
|
+
<div className="pt-40 pb-32 text-center text-ink/70">{deps.copy.errorText}</div>
|
|
103
|
+
</Shell>
|
|
104
|
+
),
|
|
105
|
+
|
|
106
|
+
NotFoundScreen: () => (
|
|
107
|
+
<Shell>
|
|
108
|
+
<div className="pt-40 pb-32 text-center">
|
|
109
|
+
<h1 className="font-serif text-4xl text-ink">{deps.copy.notFoundTitle}</h1>
|
|
110
|
+
</div>
|
|
111
|
+
</Shell>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single content-section preview (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The backoffice thumbnail lightbox opens one section standalone so an editor
|
|
5
|
+
* sees the real site component rather than a screenshot. The section map is the
|
|
6
|
+
* only site-specific part.
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentType } from "react";
|
|
9
|
+
|
|
10
|
+
export type SectionPreviewDeps = {
|
|
11
|
+
/** Section key -> real site component. Missing keys render the fallback. */
|
|
12
|
+
sections: Record<string, ComponentType>;
|
|
13
|
+
copy?: { title?: string; body?: string };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createSectionPreview(
|
|
17
|
+
deps: SectionPreviewDeps,
|
|
18
|
+
): ComponentType<{ sectionKey: string }> {
|
|
19
|
+
const title = deps.copy?.title ?? "No visual preview";
|
|
20
|
+
const body =
|
|
21
|
+
deps.copy?.body ?? "This section holds settings and text used elsewhere on the site.";
|
|
22
|
+
|
|
23
|
+
return function SectionPreview({ sectionKey }) {
|
|
24
|
+
const Cmp = deps.sections[sectionKey];
|
|
25
|
+
return (
|
|
26
|
+
<main className="min-h-screen bg-background text-foreground overflow-x-hidden">
|
|
27
|
+
{Cmp ? (
|
|
28
|
+
<Cmp />
|
|
29
|
+
) : (
|
|
30
|
+
<div className="flex min-h-screen items-center justify-center p-10 text-center">
|
|
31
|
+
<div>
|
|
32
|
+
<div className="font-serif text-2xl text-ink">{title}</div>
|
|
33
|
+
<p className="mt-2 text-sm text-ink/60">{body}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
}
|