@koehler8/cms 1.0.0-beta.8 → 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.
- package/CHANGELOG.md +1491 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +36 -16
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +184 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +81 -48
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +126 -28
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +545 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- 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%, #
|
|
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>
|
package/src/components/Team.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|