@koehler8/cms 1.0.0-beta.9 → 1.0.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 (110) hide show
  1. package/CHANGELOG.md +1491 -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 +184 -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 +126 -28
  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/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. 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,6 @@
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';
3
4
 
4
5
  function normalizePath(value) {
5
6
  if (!value || typeof value !== 'string') return '/';
@@ -26,6 +27,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
26
27
  components: [],
27
28
  content: {},
28
29
  meta: {},
30
+ draft: undefined,
29
31
  });
30
32
  const componentKeys = ref([]);
31
33
  const isLoading = ref(false);
@@ -48,6 +50,8 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
48
50
  components: Array.isArray(data.components) ? [...data.components] : [],
49
51
  content: data.content || {},
50
52
  meta: data.meta || {},
53
+ draft: data.draft,
54
+ jsonld: data.jsonld,
51
55
  };
52
56
  };
53
57
 
@@ -69,9 +73,35 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
69
73
  components: Array.isArray(data.components) ? [...data.components] : [],
70
74
  content: data.content || {},
71
75
  meta: data.meta || {},
76
+ draft: data.draft,
77
+ jsonld: data.jsonld,
72
78
  };
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('404');
92
+ if (customNotFound) {
93
+ return { ...customNotFound, isNotFound: true };
94
+ }
95
+ return {
96
+ id: '__not_found__',
97
+ path: requestedPath,
98
+ components: [{ name: 'NotFound', enabled: true }],
99
+ content: {},
100
+ meta: {},
101
+ draft: undefined,
102
+ isNotFound: true,
103
+ };
104
+ }
75
105
  }
76
106
 
77
107
  const homeFallback = resolveById('home');
@@ -85,13 +115,47 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
85
115
  components: Array.isArray(firstData.components) ? [...firstData.components] : [],
86
116
  content: firstData.content || {},
87
117
  meta: firstData.meta || {},
118
+ draft: firstData.draft,
119
+ jsonld: firstData.jsonld,
88
120
  };
89
121
  }
90
122
 
91
123
  return null;
92
124
  }
93
125
 
94
- async function syncPage() {
126
+ // Synchronously apply a resolved config to the reactive refs. Kept pure and
127
+ // synchronous so it can run during the first client render (matching the
128
+ // prerendered DOM) as well as after an async load. Throws if the config has
129
+ // no selectable page, so callers can fall back.
130
+ function applyConfig(config) {
131
+ siteData.value = config;
132
+ const selectedPage = selectPage(config);
133
+
134
+ if (!selectedPage) {
135
+ throw new Error('No pages defined in configuration.');
136
+ }
137
+
138
+ const sharedContent = config?.shared?.content;
139
+ const mergedContent = mergeWithSharedContent(sharedContent, selectedPage.content);
140
+ currentPage.value = {
141
+ ...selectedPage,
142
+ content: mergedContent,
143
+ };
144
+ pageContent.value = mergedContent;
145
+
146
+ if (typeof onPageLoaded === 'function') {
147
+ onPageLoaded({ config, page: selectedPage, content: mergedContent });
148
+ }
149
+
150
+ componentKeys.value = Array.isArray(selectedPage.components)
151
+ ? selectedPage.components.map((entry) => (entry && typeof entry === 'object' ? { ...entry } : entry))
152
+ : [];
153
+ }
154
+
155
+ // soft: when true, keep the currently-rendered components visible while the
156
+ // new config loads (no blank-out). Used by the post-hydration locale
157
+ // reconcile so a saved-locale switch doesn't reintroduce a flash.
158
+ async function syncPage({ soft = false } = {}) {
95
159
  const requestId = ++activeRequest;
96
160
  const currentLocale = typeof locale === 'function' ? locale() : locale;
97
161
  const localeKey = (currentLocale || '').toString().toLowerCase() || 'default';
@@ -100,7 +164,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
100
164
 
101
165
  if (requestId === activeRequest) {
102
166
  isLoading.value = true;
103
- if (shouldReload) {
167
+ if (shouldReload && !soft) {
104
168
  componentKeys.value = [];
105
169
  }
106
170
  loadError.value = null;
@@ -114,34 +178,20 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
114
178
 
115
179
  if (requestId !== activeRequest) return;
116
180
 
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
- : [];
181
+ applyConfig(cachedConfig);
139
182
  } catch (error) {
140
183
  if (requestId !== activeRequest) return;
141
- if (import.meta.env.DEV) {
184
+ if (import.meta.env.SSR) {
185
+ // Machine-grepable marker: cms-ssg-build scans captured child output
186
+ // for this exact string and FAILS the build. A page whose config
187
+ // didn't load during pre-render must never ship silently as chrome
188
+ // around an empty <main> — the empty-root detector can't see it,
189
+ // because the shell (skip link, header, footer) still renders.
190
+ console.error(`[cms-ssg] PAGE CONFIG LOAD FAILED locale=${localeKey}`, error);
191
+ } else if (import.meta.env.DEV) {
142
192
  console.error('[PageRenderer] Failed to load page configuration', error);
143
193
  }
144
- currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {} };
194
+ currentPage.value = { id: '', path: '/', components: [], content: {}, meta: {}, draft: undefined };
145
195
  pageContent.value = {};
146
196
  if (typeof onPageLoaded === 'function') {
147
197
  onPageLoaded({ config: null, page: null, content: {} });
@@ -155,11 +205,59 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
155
205
  }
156
206
  }
157
207
 
208
+ // Client-only: populate refs synchronously from the config the server pass
209
+ // already resolved + serialized (primed into a sync cache by main.js before
210
+ // mount). This makes the first client render reproduce the prerendered DOM
211
+ // instead of an empty <main> while the async loader runs — the fix for the
212
+ // paint -> blank -> paint white-flash. Returns false on a cache miss, so the
213
+ // caller falls back to the async path (previous behavior).
214
+ function hydrateFromSyncCache() {
215
+ const currentLocale = typeof locale === 'function' ? locale() : locale;
216
+ const localeKey = (currentLocale || '').toString().toLowerCase() || 'default';
217
+ const localeForConfig = localeKey === 'default' ? undefined : localeKey;
218
+
219
+ const primed = peekConfigSync(localeForConfig);
220
+ if (!primed) return false;
221
+
222
+ try {
223
+ applyConfig(primed);
224
+ } catch {
225
+ return false;
226
+ }
227
+ cachedConfig = primed;
228
+ lastLocaleKey = localeKey;
229
+
230
+ // Decide whether a soft (no blank-out) background reload of the FULL config
231
+ // is needed after this first synchronous paint. Two independent triggers,
232
+ // both satisfied by the same single reload:
233
+ //
234
+ // 1. Partial embed: the server trimmed the embedded config to just this
235
+ // page (site.trimInitialState), so the primed config can't resolve
236
+ // OTHER pages for in-SPA navigation. Load the full config now; the
237
+ // current page re-applies identically, so there's no visible change.
238
+ // 2. Saved-locale switch: the server has no localStorage, so a base-locale
239
+ // route rendered the base locale. If a returning visitor stored a
240
+ // different locale, reconcile to it. (loadConfigData reads localStorage
241
+ // when no explicit locale is passed, so the same reload resolves it.)
242
+ let needsFullReload = Boolean(primed && primed.pagesPartial);
243
+
244
+ if (localeForConfig === undefined) {
245
+ const stored = (readStoredLocale() || '').trim().toLowerCase();
246
+ if (stored) needsFullReload = true;
247
+ }
248
+
249
+ if (needsFullReload) {
250
+ lastLocaleKey = null; // force syncPage to reload (localStorage-resolved for base routes)
251
+ syncPage({ soft: true });
252
+ }
253
+ return true;
254
+ }
255
+
158
256
  if (import.meta.env.SSR) {
159
257
  onServerPrefetch(async () => {
160
258
  await syncPage();
161
259
  });
162
- } else {
260
+ } else if (!hydrateFromSyncCache()) {
163
261
  syncPage();
164
262
  }
165
263