@koehler8/cms 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -220,9 +220,13 @@ function scrollTargetWithOffset(target) {
220
220
  padding: 14px 24px;
221
221
  border: none;
222
222
  border-radius: var(--brand-button-radius, 14px);
223
+ /* The default 3-stop gradient previously failed contrast at the mid stop
224
+ (#0a0a0d on #9a2eff = 3.96:1). Mid stop darkened to #7e23d5 → 5.34:1
225
+ with the dark text. Sites overriding --brand-primary-cta-gradient
226
+ are responsible for verifying contrast against --brand-primary-cta-text. */
223
227
  background: var(
224
228
  --brand-primary-cta-gradient,
225
- linear-gradient(135deg, #ff2d86 0%, #9a2eff 55%, #27f3ff 100%)
229
+ linear-gradient(135deg, #ff2d86 0%, #7e23d5 55%, #2bb8d9 100%)
226
230
  );
227
231
  color: var(--brand-primary-cta-text, #0a0a0d);
228
232
  font-size: clamp(0.95rem, 2.5vw, 1.05rem);
@@ -260,4 +264,22 @@ function scrollTargetWithOffset(target) {
260
264
  transform: translateY(30%);
261
265
  opacity: 0;
262
266
  }
267
+
268
+ @media (prefers-reduced-motion: reduce) {
269
+ .sticky-cta__button,
270
+ .slide-up-enter-active,
271
+ .slide-up-leave-active {
272
+ transition: none;
273
+ }
274
+
275
+ .sticky-cta__button:hover,
276
+ .sticky-cta__button:active {
277
+ transform: none;
278
+ }
279
+
280
+ .slide-up-enter-from,
281
+ .slide-up-leave-to {
282
+ transform: none;
283
+ }
284
+ }
263
285
  </style>
@@ -337,7 +337,9 @@ const buildSocialAriaLabel = (memberName, social) => {
337
337
  font-size: 0.72rem;
338
338
  letter-spacing: 0.18em;
339
339
  text-transform: uppercase;
340
- color: var(--ui-text-muted, var(--brand-border-highlight, rgba(79, 108, 240, 0.8)));
340
+ /* AA-safe muted text on the white card. Don't fall through to a brand
341
+ color rgba — it composites below 4.5:1. */
342
+ color: var(--team-role-color, var(--ui-text-muted, #54627b));
341
343
  }
342
344
 
343
345
  .team-card__bio {
@@ -1,5 +1,6 @@
1
1
  import { computed } from 'vue';
2
2
  import { extensionComponentDefinitionsBySource, extensionComponentSources, getExtensionContentDefaults } from '../extensions/extensionLoader.js';
3
+ import { getSiteComponent } from '../utils/componentRegistry.js';
3
4
  import { APP_VERSION } from '../utils/appInfo.js';
4
5
  import { satisfiesMinVersion } from '../utils/semver.js';
5
6
  import { validateRequiredContentPaths } from '../utils/contentRequirements.js';
@@ -8,6 +9,8 @@ import { mergeConfigTrees } from '../utils/loadConfig.js';
8
9
  const extensionDefinitionsBySource = extensionComponentDefinitionsBySource || {};
9
10
  const extensionSourcesByName = extensionComponentSources || {};
10
11
 
12
+ const SITE_SOURCE = 'site';
13
+
11
14
  const normalizePageKey = (value) => (value || '').toString().trim().toLowerCase();
12
15
  const normalizeSourceKey = (value) => (value || '').toString().trim().toLowerCase();
13
16
 
@@ -49,12 +52,57 @@ export function useComponentResolver({ componentKeys, pageContent, currentPage,
49
52
  name = entry;
50
53
  }
51
54
 
55
+ // Source-qualified string syntax ("site:Name", "slug:Name") — same
56
+ // meaning as a { name, source } object entry. Component names can
57
+ // never contain a colon (^[A-Z][A-Za-z0-9]+$), so the split is safe.
58
+ if (!source && name.includes(':')) {
59
+ const splitAt = name.indexOf(':');
60
+ source = name.slice(0, splitAt);
61
+ name = name.slice(splitAt + 1);
62
+ }
63
+
52
64
  if (!enabled) return null;
53
65
 
66
+ let normalizedSource = normalizeSourceKey(source) || null;
67
+
68
+ // ---- Site-local components ---------------------------------
69
+ // Pages can reference Vue files dropped into `site/components/`
70
+ // by basename (e.g. site/components/PropertyCard.vue → "PropertyCard").
71
+ // Resolution priority: site > extension > bundled. Use `site:Name`
72
+ // to disambiguate when both site and extension/bundled define a
73
+ // component with the same name.
74
+ if (normalizedSource === SITE_SOURCE) {
75
+ const siteComp = getSiteComponent(name);
76
+ if (!siteComp) {
77
+ reportExtensionIssue(
78
+ `Component "site:${name}" is not registered. Add a Vue file to site/components/${name}.vue.`,
79
+ { component: name, requestedSource: SITE_SOURCE },
80
+ );
81
+ return null;
82
+ }
83
+ return {
84
+ key: `site-${name}-${index}`,
85
+ component: siteComp,
86
+ configKey: configKey || null,
87
+ props: {},
88
+ };
89
+ }
90
+ if (!normalizedSource) {
91
+ const siteComp = getSiteComponent(name);
92
+ if (siteComp) {
93
+ return {
94
+ key: `site-${name}-${index}`,
95
+ component: siteComp,
96
+ configKey: configKey || null,
97
+ props: {},
98
+ };
99
+ }
100
+ }
101
+ // -------------------------------------------------------------
102
+
54
103
  const availableSources = Array.isArray(extensionSourcesByName[name])
55
104
  ? extensionSourcesByName[name]
56
105
  : null;
57
- let normalizedSource = normalizeSourceKey(source) || null;
58
106
 
59
107
  if (availableSources && availableSources.length) {
60
108
  if (!normalizedSource) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Reactive gate state for draft pages.
3
+ *
4
+ * - During SSR (vite-ssg pre-render), isUnlocked is always false so the
5
+ * pre-rendered HTML on disk contains only the gate component, not the
6
+ * page body. Crawlers and `curl` see a noindex page with no content.
7
+ * - In the browser, isUnlocked hydrates from sessionStorage so the gate
8
+ * doesn't re-prompt while the user iterates between draft pages within
9
+ * one tab. Closing the tab re-engages the gate (sessionStorage scope).
10
+ * - Password verification is hash-based: the Vite plugin replaces
11
+ * site.draftPassword with site.draftPasswordHash (SHA-256 hex) at build
12
+ * time so the plaintext never lands in the bundle. attemptUnlock hashes
13
+ * the user's input with the same algorithm and compares.
14
+ * - Empty/missing hash is a deliberate fail-safe state: the gate still
15
+ * appears, but accepts any input including empty. This makes the gate
16
+ * visible even when the author hasn't picked a password yet — see
17
+ * hasPassword in the return value.
18
+ */
19
+
20
+ import { computed, ref } from 'vue';
21
+ import { isPathDraft, getDraftPasswordHash } from '../utils/draftMode.js';
22
+ import { sha256Hex } from '../utils/sha256.js';
23
+
24
+ const SESSION_KEY = 'cms_draft_unlocked';
25
+
26
+ function readUnlockedFromSession() {
27
+ if (typeof window === 'undefined') return false;
28
+ try {
29
+ return window.sessionStorage.getItem(SESSION_KEY) === '1';
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function persistUnlocked() {
36
+ if (typeof window === 'undefined') return;
37
+ try {
38
+ window.sessionStorage.setItem(SESSION_KEY, '1');
39
+ } catch {
40
+ /* sessionStorage may be blocked in private mode; gate falls back to
41
+ in-memory unlock for the lifetime of the page. */
42
+ }
43
+ }
44
+
45
+ export function useDraftGate({ siteData, currentPage }) {
46
+ const isDraft = computed(() => {
47
+ const page = currentPage.value || {};
48
+ return isPathDraft(siteData.value, page.path, page);
49
+ });
50
+
51
+ const expectedHash = computed(() => getDraftPasswordHash(siteData.value));
52
+ const hasPassword = computed(() => expectedHash.value.length > 0);
53
+
54
+ const isUnlocked = ref(import.meta.env.SSR ? false : readUnlockedFromSession());
55
+ const errorMessage = ref('');
56
+
57
+ async function attemptUnlock(input) {
58
+ const hash = expectedHash.value;
59
+ if (!hash) {
60
+ isUnlocked.value = true;
61
+ errorMessage.value = '';
62
+ persistUnlocked();
63
+ return true;
64
+ }
65
+ const provided = typeof input === 'string' ? input : '';
66
+ let providedHash = '';
67
+ try {
68
+ providedHash = await sha256Hex(provided);
69
+ } catch {
70
+ // crypto.subtle only exists in secure contexts — on plain http://
71
+ // (LAN preview, staging by IP) hashing itself fails. Say that,
72
+ // instead of telling the user their correct password is wrong.
73
+ errorMessage.value = 'Unlocking requires a secure connection — reload this page over HTTPS (or localhost) and try again.';
74
+ return false;
75
+ }
76
+ if (providedHash && providedHash === hash) {
77
+ isUnlocked.value = true;
78
+ errorMessage.value = '';
79
+ persistUnlocked();
80
+ return true;
81
+ }
82
+ errorMessage.value = 'Incorrect password.';
83
+ return false;
84
+ }
85
+
86
+ return {
87
+ isDraft,
88
+ isUnlocked,
89
+ hasPassword,
90
+ errorMessage,
91
+ attemptUnlock,
92
+ };
93
+ }
@@ -0,0 +1,85 @@
1
+ import { nextTick, onBeforeUnmount, unref, watch } from 'vue';
2
+
3
+ // Selector shared by every modal in the framework. Kept intentionally simple:
4
+ // visible-but-disabled edge cases are handled by the browser refusing focus.
5
+ export const FOCUSABLE_SELECTOR =
6
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
7
+
8
+ /**
9
+ * Keyboard focus containment for modal dialogs (WCAG 2.4.3 Focus Order).
10
+ *
11
+ * While `active` is truthy:
12
+ * - Tab / Shift+Tab wrap within `containerRef`.
13
+ * - Escape calls `options.onEscape` when provided (omit it for gates that
14
+ * must not be dismissible, e.g. the draft password gate).
15
+ * On activation, focus moves to `options.initialFocusRef` (or the container);
16
+ * on deactivation, focus returns to the element focused before.
17
+ *
18
+ * `active` may be a ref or a getter. SSR-safe: does nothing on the server.
19
+ */
20
+ export function useFocusTrap(containerRef, active, options = {}) {
21
+ const { initialFocusRef = null, onEscape = null } = options;
22
+
23
+ let previousFocused = null;
24
+
25
+ const isActive = () => Boolean(typeof active === 'function' ? active() : unref(active));
26
+
27
+ const handleKeydown = (event) => {
28
+ if (!isActive()) return;
29
+ if (event.key === 'Escape' || event.key === 'Esc') {
30
+ if (typeof onEscape === 'function') {
31
+ event.preventDefault();
32
+ onEscape();
33
+ }
34
+ return;
35
+ }
36
+ if (event.key !== 'Tab') return;
37
+ const container = unref(containerRef);
38
+ if (!container) return;
39
+ const focusable = container.querySelectorAll(FOCUSABLE_SELECTOR);
40
+ if (!focusable.length) {
41
+ event.preventDefault();
42
+ return;
43
+ }
44
+ const first = focusable[0];
45
+ const last = focusable[focusable.length - 1];
46
+ if (event.shiftKey && document.activeElement === first) {
47
+ event.preventDefault();
48
+ last.focus();
49
+ } else if (!event.shiftKey && document.activeElement === last) {
50
+ event.preventDefault();
51
+ first.focus();
52
+ }
53
+ };
54
+
55
+ const activate = () => {
56
+ previousFocused =
57
+ typeof HTMLElement !== 'undefined' && document.activeElement instanceof HTMLElement
58
+ ? document.activeElement
59
+ : null;
60
+ nextTick(() => {
61
+ const target = unref(initialFocusRef) || unref(containerRef);
62
+ if (target && typeof target.focus === 'function') target.focus();
63
+ });
64
+ document.addEventListener('keydown', handleKeydown);
65
+ };
66
+
67
+ const deactivate = () => {
68
+ document.removeEventListener('keydown', handleKeydown);
69
+ if (previousFocused && typeof previousFocused.focus === 'function') {
70
+ previousFocused.focus();
71
+ }
72
+ previousFocused = null;
73
+ };
74
+
75
+ if (!import.meta.env.SSR) {
76
+ watch(isActive, (open, wasOpen) => {
77
+ if (open && !wasOpen) activate();
78
+ else if (!open && wasOpen) deactivate();
79
+ }, { immediate: true });
80
+
81
+ onBeforeUnmount(() => {
82
+ document.removeEventListener('keydown', handleKeydown);
83
+ });
84
+ }
85
+ }
@@ -1,5 +1,7 @@
1
1
  import { onServerPrefetch, ref, watch } from 'vue';
2
- import { loadConfigData, mergeConfigTrees } from '../utils/loadConfig.js';
2
+ import { loadConfigData, mergeConfigTrees, peekConfigSync } from '../utils/loadConfig.js';
3
+ import { readStoredLocale } from '../utils/webStorage.js';
4
+ import { isNotFoundPage, NOT_FOUND_PAGE_ID } from '../utils/notFound.js';
3
5
 
4
6
  function normalizePath(value) {
5
7
  if (!value || typeof value !== 'string') return '/';
@@ -26,6 +28,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
26
28
  components: [],
27
29
  content: {},
28
30
  meta: {},
31
+ draft: undefined,
29
32
  });
30
33
  const componentKeys = ref([]);
31
34
  const isLoading = ref(false);
@@ -42,13 +45,22 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
42
45
  const resolveById = (id) => {
43
46
  const data = pages[id];
44
47
  if (!data) return null;
45
- return {
48
+ const resolved = {
46
49
  id,
47
50
  path: data.path ?? '/',
48
51
  components: Array.isArray(data.components) ? [...data.components] : [],
49
52
  content: data.content || {},
50
53
  meta: data.meta || {},
54
+ draft: data.draft,
55
+ jsonld: data.jsonld,
51
56
  };
57
+ // A site-authored pages/404.json resolves like any other route (it has a
58
+ // real path so the plugin can pre-render it), so the flag has to be set
59
+ // here rather than only on the not-found fallback below. Otherwise a
60
+ // direct hit on /404 yields a page usePageMeta treats as ordinary —
61
+ // canonical emitted, noindex omitted — and sitemapGenerator lists it.
62
+ if (isNotFoundPage(id, resolved.path)) resolved.isNotFound = true;
63
+ return resolved;
52
64
  };
53
65
 
54
66
  const currentPageId = typeof pageId === 'function' ? pageId() : pageId;
@@ -63,15 +75,35 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
63
75
  const requestedPath = normalizePath(currentPagePath);
64
76
  for (const [id, data] of entries) {
65
77
  if (normalizePath(data.path) === requestedPath) {
66
- return {
67
- id,
68
- path: data.path ?? '/',
69
- components: Array.isArray(data.components) ? [...data.components] : [],
70
- content: data.content || {},
71
- meta: data.meta || {},
72
- };
78
+ return resolveById(id);
73
79
  }
74
80
  }
81
+
82
+ // No page matches the requested path. If it's anything other than
83
+ // the home path "/" — or the explicit /404 SSG route — treat it as
84
+ // a not-found page (instead of silently returning home content with
85
+ // HTTP 200, which is what the framework used to do).
86
+ //
87
+ // Sites can override the rendered 404 by authoring pages/404.json
88
+ // with their own components[]; otherwise the synthesized sentinel
89
+ // renders the bundled NotFound component.
90
+ if (requestedPath !== '/') {
91
+ const customNotFound = resolveById(NOT_FOUND_PAGE_ID);
92
+ if (customNotFound) {
93
+ // resolveById already flags this; kept explicit so the guarantee is
94
+ // visible at the call site that depends on it.
95
+ return { ...customNotFound, isNotFound: true };
96
+ }
97
+ return {
98
+ id: '__not_found__',
99
+ path: requestedPath,
100
+ components: [{ name: 'NotFound', enabled: true }],
101
+ content: {},
102
+ meta: {},
103
+ draft: undefined,
104
+ isNotFound: true,
105
+ };
106
+ }
75
107
  }
76
108
 
77
109
  const homeFallback = resolveById('home');
@@ -85,13 +117,47 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
85
117
  components: Array.isArray(firstData.components) ? [...firstData.components] : [],
86
118
  content: firstData.content || {},
87
119
  meta: firstData.meta || {},
120
+ draft: firstData.draft,
121
+ jsonld: firstData.jsonld,
88
122
  };
89
123
  }
90
124
 
91
125
  return null;
92
126
  }
93
127
 
94
- async function syncPage() {
128
+ // Synchronously apply a resolved config to the reactive refs. Kept pure and
129
+ // synchronous so it can run during the first client render (matching the
130
+ // prerendered DOM) as well as after an async load. Throws if the config has
131
+ // no selectable page, so callers can fall back.
132
+ function applyConfig(config) {
133
+ siteData.value = config;
134
+ const selectedPage = selectPage(config);
135
+
136
+ if (!selectedPage) {
137
+ throw new Error('No pages defined in configuration.');
138
+ }
139
+
140
+ const sharedContent = config?.shared?.content;
141
+ const mergedContent = mergeWithSharedContent(sharedContent, selectedPage.content);
142
+ currentPage.value = {
143
+ ...selectedPage,
144
+ content: mergedContent,
145
+ };
146
+ pageContent.value = mergedContent;
147
+
148
+ if (typeof onPageLoaded === 'function') {
149
+ onPageLoaded({ config, page: selectedPage, content: mergedContent });
150
+ }
151
+
152
+ componentKeys.value = Array.isArray(selectedPage.components)
153
+ ? selectedPage.components.map((entry) => (entry && typeof entry === 'object' ? { ...entry } : entry))
154
+ : [];
155
+ }
156
+
157
+ // soft: when true, keep the currently-rendered components visible while the
158
+ // new config loads (no blank-out). Used by the post-hydration locale
159
+ // reconcile so a saved-locale switch doesn't reintroduce a flash.
160
+ async function syncPage({ soft = false } = {}) {
95
161
  const requestId = ++activeRequest;
96
162
  const currentLocale = typeof locale === 'function' ? locale() : locale;
97
163
  const localeKey = (currentLocale || '').toString().toLowerCase() || 'default';
@@ -100,7 +166,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
100
166
 
101
167
  if (requestId === activeRequest) {
102
168
  isLoading.value = true;
103
- if (shouldReload) {
169
+ if (shouldReload && !soft) {
104
170
  componentKeys.value = [];
105
171
  }
106
172
  loadError.value = null;
@@ -114,34 +180,20 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
114
180
 
115
181
  if (requestId !== activeRequest) return;
116
182
 
117
- siteData.value = cachedConfig;
118
- const selectedPage = selectPage(cachedConfig);
119
-
120
- if (!selectedPage) {
121
- throw new Error('No pages defined in configuration.');
122
- }
123
-
124
- const sharedContent = cachedConfig?.shared?.content;
125
- const mergedContent = mergeWithSharedContent(sharedContent, selectedPage.content);
126
- currentPage.value = {
127
- ...selectedPage,
128
- content: mergedContent,
129
- };
130
- pageContent.value = mergedContent;
131
-
132
- if (typeof onPageLoaded === 'function') {
133
- onPageLoaded({ config: cachedConfig, page: selectedPage, content: mergedContent });
134
- }
135
-
136
- componentKeys.value = Array.isArray(selectedPage.components)
137
- ? selectedPage.components.map((entry) => (entry && typeof entry === 'object' ? { ...entry } : entry))
138
- : [];
183
+ applyConfig(cachedConfig);
139
184
  } catch (error) {
140
185
  if (requestId !== activeRequest) return;
141
- if (import.meta.env.DEV) {
186
+ if (import.meta.env.SSR) {
187
+ // Machine-grepable marker: cms-ssg-build scans captured child output
188
+ // for this exact string and FAILS the build. A page whose config
189
+ // didn't load during pre-render must never ship silently as chrome
190
+ // around an empty <main> — the empty-root detector can't see it,
191
+ // because the shell (skip link, header, footer) still renders.
192
+ console.error(`[cms-ssg] PAGE CONFIG LOAD FAILED locale=${localeKey}`, error);
193
+ } else if (import.meta.env.DEV) {
142
194
  console.error('[PageRenderer] Failed to load page configuration', error);
143
195
  }
144
- currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {} };
196
+ currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {}, draft: undefined };
145
197
  pageContent.value = {};
146
198
  if (typeof onPageLoaded === 'function') {
147
199
  onPageLoaded({ config: null, page: null, content: {} });
@@ -155,11 +207,59 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
155
207
  }
156
208
  }
157
209
 
210
+ // Client-only: populate refs synchronously from the config the server pass
211
+ // already resolved + serialized (primed into a sync cache by main.js before
212
+ // mount). This makes the first client render reproduce the prerendered DOM
213
+ // instead of an empty <main> while the async loader runs — the fix for the
214
+ // paint -> blank -> paint white-flash. Returns false on a cache miss, so the
215
+ // caller falls back to the async path (previous behavior).
216
+ function hydrateFromSyncCache() {
217
+ const currentLocale = typeof locale === 'function' ? locale() : locale;
218
+ const localeKey = (currentLocale || '').toString().toLowerCase() || 'default';
219
+ const localeForConfig = localeKey === 'default' ? undefined : localeKey;
220
+
221
+ const primed = peekConfigSync(localeForConfig);
222
+ if (!primed) return false;
223
+
224
+ try {
225
+ applyConfig(primed);
226
+ } catch {
227
+ return false;
228
+ }
229
+ cachedConfig = primed;
230
+ lastLocaleKey = localeKey;
231
+
232
+ // Decide whether a soft (no blank-out) background reload of the FULL config
233
+ // is needed after this first synchronous paint. Two independent triggers,
234
+ // both satisfied by the same single reload:
235
+ //
236
+ // 1. Partial embed: the server trimmed the embedded config to just this
237
+ // page (site.trimInitialState), so the primed config can't resolve
238
+ // OTHER pages for in-SPA navigation. Load the full config now; the
239
+ // current page re-applies identically, so there's no visible change.
240
+ // 2. Saved-locale switch: the server has no localStorage, so a base-locale
241
+ // route rendered the base locale. If a returning visitor stored a
242
+ // different locale, reconcile to it. (loadConfigData reads localStorage
243
+ // when no explicit locale is passed, so the same reload resolves it.)
244
+ let needsFullReload = Boolean(primed && primed.pagesPartial);
245
+
246
+ if (localeForConfig === undefined) {
247
+ const stored = (readStoredLocale() || '').trim().toLowerCase();
248
+ if (stored) needsFullReload = true;
249
+ }
250
+
251
+ if (needsFullReload) {
252
+ lastLocaleKey = null; // force syncPage to reload (localStorage-resolved for base routes)
253
+ syncPage({ soft: true });
254
+ }
255
+ return true;
256
+ }
257
+
158
258
  if (import.meta.env.SSR) {
159
259
  onServerPrefetch(async () => {
160
260
  await syncPage();
161
261
  });
162
- } else {
262
+ } else if (!hydrateFromSyncCache()) {
163
263
  syncPage();
164
264
  }
165
265