ferst-core 0.4.7 → 0.4.9
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/cms/theme-preview.js +108 -0
- package/layouts/Base.astro +9 -0
- package/lib/theme-preview.ts +91 -0
- package/package.json +2 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS design-tokens live preview (PDLC Phase 1 / WS4) — SPIKE artifact.
|
|
3
|
+
*
|
|
4
|
+
* Registers a Sveltia/Decap preview template for the `theme` file collection that
|
|
5
|
+
* embeds an iframe of the live site (?preview=1) and pushes the editor's CURRENT
|
|
6
|
+
* (unsaved) token values to it over postMessage — same-origin, since /admin and
|
|
7
|
+
* the site share the client domain. Re-themes live via ferst-core's site-side
|
|
8
|
+
* listener (lib/theme-preview.ts). Save is the CMS's existing git-proxy write.
|
|
9
|
+
*
|
|
10
|
+
* STATUS: unverified against the deployed Sveltia (pre-1.0; docs warn of Decap
|
|
11
|
+
* preview-template compat gaps). It is fully GUARDED — if the API isn't present or
|
|
12
|
+
* differs, it logs once and no-ops, so it can never break the CMS. The portal
|
|
13
|
+
* Theme Studio is the guaranteed token surface until the dev spike confirms this.
|
|
14
|
+
*
|
|
15
|
+
* Include from a client's public/admin/index.html AFTER the Sveltia bundle:
|
|
16
|
+
* <script src="/ferst/theme-preview.js"></script> (copied from ferst-core/cms)
|
|
17
|
+
*/
|
|
18
|
+
(function () {
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
var MSG = { theme: 'ferst:preview:theme', ready: 'ferst:preview:ready' };
|
|
22
|
+
// Match the CMS theme collection's fields (config.yml `theme` file).
|
|
23
|
+
var FIELDS = ['brand', 'ink', 'surface', 'corners', 'elevation', 'stroke', 'fields', 'darkDepth', 'darkChroma'];
|
|
24
|
+
|
|
25
|
+
function log(msg) {
|
|
26
|
+
try {
|
|
27
|
+
console.info('[ferst theme-preview] ' + msg);
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read the live entry into a themeSettings-shaped draft (Immutable Map API).
|
|
32
|
+
function draftFromEntry(entry) {
|
|
33
|
+
var get = function (k) {
|
|
34
|
+
try {
|
|
35
|
+
return entry && entry.getIn ? entry.getIn(['data', k]) : undefined;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var fonts = get('fonts') || {};
|
|
41
|
+
var ov = get('darkOverrides') || {};
|
|
42
|
+
var pick = function (o, k) {
|
|
43
|
+
return o && typeof o.get === 'function' ? o.get(k) : o ? o[k] : '';
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
brand: get('brand') || '',
|
|
47
|
+
ink: get('ink') || '',
|
|
48
|
+
surface: get('surface') || '',
|
|
49
|
+
fonts: { heading: pick(fonts, 'heading') || '', body: pick(fonts, 'body') || '', display: pick(fonts, 'display') || '' },
|
|
50
|
+
corners: get('corners') || 'rounded',
|
|
51
|
+
elevation: get('elevation') || 'raised',
|
|
52
|
+
stroke: get('stroke') || 'on',
|
|
53
|
+
fields: get('fields') || 'boxed',
|
|
54
|
+
darkDepth: typeof get('darkDepth') === 'number' ? get('darkDepth') : 0.21,
|
|
55
|
+
darkChroma: typeof get('darkChroma') === 'number' ? get('darkChroma') : 0.05,
|
|
56
|
+
darkOverrides: { ground: pick(ov, 'ground') || '', text: pick(ov, 'text') || '', accent: pick(ov, 'accent') || '' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function register(CMS, React) {
|
|
61
|
+
var Preview = function (props) {
|
|
62
|
+
// Keep one iframe of the live site in the preview pane; push the draft on
|
|
63
|
+
// every render (Sveltia re-renders as the editor types) and on ready.
|
|
64
|
+
var doc = props.document || document;
|
|
65
|
+
var frame = doc.getElementById('ferst-preview-frame');
|
|
66
|
+
if (!frame) {
|
|
67
|
+
frame = doc.createElement('iframe');
|
|
68
|
+
frame.id = 'ferst-preview-frame';
|
|
69
|
+
frame.src = '/?preview=1';
|
|
70
|
+
frame.style.cssText = 'width:100%;height:100%;min-height:80vh;border:0';
|
|
71
|
+
(doc.body || doc.documentElement).appendChild(frame);
|
|
72
|
+
(props.window || window).addEventListener('message', function (e) {
|
|
73
|
+
if (e && e.data && e.data.type === MSG.ready) postDraft();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function postDraft() {
|
|
77
|
+
try {
|
|
78
|
+
frame.contentWindow.postMessage({ type: MSG.theme, theme: draftFromEntry(props.entry) }, window.location.origin);
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
postDraft();
|
|
82
|
+
// Render nothing of our own into React's tree — we own the iframe directly.
|
|
83
|
+
return React && React.createElement ? React.createElement('div', { style: { display: 'none' } }) : null;
|
|
84
|
+
};
|
|
85
|
+
CMS.registerPreviewTemplate('theme', Preview);
|
|
86
|
+
log('registered live preview for the theme collection');
|
|
87
|
+
void FIELDS; // documents the fields this mirrors
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var tries = 0;
|
|
91
|
+
(function wait() {
|
|
92
|
+
var CMS = window.CMS;
|
|
93
|
+
var React = window.React || window.h || (CMS && CMS.React);
|
|
94
|
+
if (CMS && typeof CMS.registerPreviewTemplate === 'function') {
|
|
95
|
+
try {
|
|
96
|
+
register(CMS, React);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log('registerPreviewTemplate failed — in-pane preview disabled (use the portal Theme Studio). ' + (err && err.message));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (tries++ < 40) {
|
|
103
|
+
setTimeout(wait, 250); // CMS loads async as an ES module
|
|
104
|
+
} else {
|
|
105
|
+
log('CMS.registerPreviewTemplate not found — in-pane preview unavailable in this CMS build.');
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
})();
|
package/layouts/Base.astro
CHANGED
|
@@ -171,6 +171,15 @@ const metaDescription = description ?? identity.description;
|
|
|
171
171
|
ferstMarkExternalLinks();
|
|
172
172
|
document.addEventListener('astro:page-load', ferstMarkExternalLinks);
|
|
173
173
|
</script>
|
|
174
|
+
<script>
|
|
175
|
+
// Live theme preview: only when this page is FRAMED with ?preview=1 (the
|
|
176
|
+
// portal Theme Studio or the CMS design-tokens pane), listen for draft theme
|
|
177
|
+
// settings over postMessage and re-theme live. Dynamically imported so the
|
|
178
|
+
// code never ships to normal visitors. Inert otherwise.
|
|
179
|
+
if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
|
|
180
|
+
import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
174
183
|
</head>
|
|
175
184
|
<body>
|
|
176
185
|
<SiteHeader
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live theme preview (client-side). Lets an external editing surface — the portal
|
|
3
|
+
* Theme Studio, or the CMS design-tokens pane — push DRAFT theme settings into a
|
|
4
|
+
* loaded page over `postMessage`, so the whole site re-themes instantly with no
|
|
5
|
+
* rebuild. This is the "instant" preview lane of the Change-Request loop (see
|
|
6
|
+
* myai4-webdev/docs/PDLC-PREVIEW-ARCHITECTURE.md).
|
|
7
|
+
*
|
|
8
|
+
* Robust for ANY site: it re-runs the core's own `buildThemeStyle(draft)` in the
|
|
9
|
+
* browser and swaps a dedicated `<style id="ferst-preview">` — the same output the
|
|
10
|
+
* server emits at build time — instead of only setting inline `--brand/--ink/
|
|
11
|
+
* --surface` (which re-derive nothing on a not-yet-themed site). Injected late in
|
|
12
|
+
* <head>, it out-specifies the server theme block (`:root:root`), so the draft wins.
|
|
13
|
+
*
|
|
14
|
+
* Import ONLY from a component <script> (touches window/document). Gated by the
|
|
15
|
+
* caller to preview mode so it never runs for normal visitors.
|
|
16
|
+
*/
|
|
17
|
+
import { buildThemeStyle } from './themeTokens';
|
|
18
|
+
import type { ThemeSettings } from '../content/schemas';
|
|
19
|
+
|
|
20
|
+
/** postMessage protocol shared with the portal / CMS preview surfaces. */
|
|
21
|
+
export const PREVIEW_MESSAGES = {
|
|
22
|
+
/** surface → page: apply this draft theme */
|
|
23
|
+
theme: 'ferst:preview:theme',
|
|
24
|
+
/** surface → page: switch light/dark */
|
|
25
|
+
mode: 'ferst:preview:mode',
|
|
26
|
+
/** page → surface: listener is live, send the current draft */
|
|
27
|
+
ready: 'ferst:preview:ready',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const STYLE_ID = 'ferst-preview';
|
|
31
|
+
|
|
32
|
+
/** Origins allowed to drive the preview (besides same-origin, i.e. the CMS pane).
|
|
33
|
+
* The Ferst portal — both its custom domains and the bare Cloudflare Pages URLs
|
|
34
|
+
* it's actually served from (dev has no custom domain today). */
|
|
35
|
+
const PORTAL_ORIGINS = [
|
|
36
|
+
'https://portal.ferst.co.uk',
|
|
37
|
+
'https://portal-dev.ferst.co.uk',
|
|
38
|
+
'https://myai4-portal.pages.dev',
|
|
39
|
+
'https://myai4-portal-dev.pages.dev',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** True for same-origin (CMS design-tokens pane) or a known Ferst portal origin. */
|
|
43
|
+
export function isAllowedPreviewOrigin(origin: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
if (origin === window.location.origin) return true;
|
|
46
|
+
} catch {
|
|
47
|
+
/* no window (SSR/tests) — fall through to the allowlist */
|
|
48
|
+
}
|
|
49
|
+
return PORTAL_ORIGINS.includes(origin);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Re-theme the current document from a draft, via the core's own derivation. */
|
|
53
|
+
export function applyPreviewTheme(theme: ThemeSettings): void {
|
|
54
|
+
const { css, field } = buildThemeStyle(theme);
|
|
55
|
+
const head = document.head;
|
|
56
|
+
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
|
57
|
+
if (!style) {
|
|
58
|
+
style = document.createElement('style');
|
|
59
|
+
style.id = STYLE_ID;
|
|
60
|
+
head.appendChild(style);
|
|
61
|
+
} else if (style !== head.lastElementChild) {
|
|
62
|
+
head.appendChild(style); // keep it last so it always wins over the server block
|
|
63
|
+
}
|
|
64
|
+
style.textContent = css;
|
|
65
|
+
if (field) document.documentElement.dataset.field = field;
|
|
66
|
+
else delete document.documentElement.dataset.field;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Wire the page to receive drafts from an allowed surface, and announce readiness. */
|
|
70
|
+
export function initThemePreviewListener(): void {
|
|
71
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
72
|
+
if (!isAllowedPreviewOrigin(event.origin)) return;
|
|
73
|
+
const data = event.data as { type?: string; theme?: ThemeSettings; mode?: string } | null;
|
|
74
|
+
if (!data || typeof data !== 'object') return;
|
|
75
|
+
if (data.type === PREVIEW_MESSAGES.theme && data.theme) {
|
|
76
|
+
try {
|
|
77
|
+
applyPreviewTheme(data.theme);
|
|
78
|
+
} catch {
|
|
79
|
+
/* a malformed draft must never break the preview page */
|
|
80
|
+
}
|
|
81
|
+
} else if (data.type === PREVIEW_MESSAGES.mode) {
|
|
82
|
+
document.documentElement.dataset.theme = data.mode === 'dark' ? 'dark' : 'light';
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// Tell whoever framed us that we're listening, so it can push the current draft.
|
|
86
|
+
try {
|
|
87
|
+
window.parent?.postMessage({ type: PREVIEW_MESSAGES.ready }, '*');
|
|
88
|
+
} catch {
|
|
89
|
+
/* no parent / blocked — the surface can still push on its own timer */
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"files": [
|
|
34
34
|
"bin",
|
|
35
|
+
"cms",
|
|
35
36
|
"components",
|
|
36
37
|
"layouts",
|
|
37
38
|
"content",
|