@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.
- 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 +33 -17
- 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 +82 -66
- 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
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { computed } from 'vue';
|
|
2
2
|
import { useHead } from '@unhead/vue';
|
|
3
|
+
import { isPathDraft } from '../utils/draftMode.js';
|
|
4
|
+
import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
|
|
5
|
+
import { buildSocialMeta } from '../utils/socialMeta.js';
|
|
6
|
+
import { buildJsonLdScripts, toJsonLdString } from '../utils/jsonLd.js';
|
|
7
|
+
import { buildBreadcrumbList } from '../utils/breadcrumbs.js';
|
|
8
|
+
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
9
|
+
|
|
10
|
+
// Site-verification meta-tag names per platform. Authors set
|
|
11
|
+
// `site.siteVerification.<platform>` in site.json with the token issued by
|
|
12
|
+
// the platform's webmaster console; we emit one <meta> per platform.
|
|
13
|
+
const SITE_VERIFICATION_META = {
|
|
14
|
+
google: 'google-site-verification',
|
|
15
|
+
bing: 'msvalidate.01',
|
|
16
|
+
yandex: 'yandex-verification',
|
|
17
|
+
pinterest: 'p:domain_verify',
|
|
18
|
+
facebook: 'facebook-domain-verification',
|
|
19
|
+
};
|
|
3
20
|
|
|
4
21
|
const SPECIAL_PAGE_LABELS = {
|
|
5
22
|
home: '',
|
|
@@ -35,13 +52,37 @@ function inferPageLabel(page) {
|
|
|
35
52
|
.join(' / ');
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
export function usePageMeta({ siteData, currentPage }) {
|
|
55
|
+
export function usePageMeta({ siteData, currentPage, locale }) {
|
|
39
56
|
const siteTitle = computed(() => siteData.value?.site?.title || '');
|
|
40
57
|
const siteDescription = computed(() => siteData.value?.site?.description || '');
|
|
58
|
+
const siteUrl = computed(() => siteData.value?.site?.url || '');
|
|
59
|
+
const trailingSlash = computed(() => siteData.value?.site?.trailingSlash === true);
|
|
60
|
+
const localeValue = computed(() => {
|
|
61
|
+
if (typeof locale === 'function') return locale() || '';
|
|
62
|
+
if (typeof locale === 'string') return locale;
|
|
63
|
+
return '';
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const isDraft = computed(() =>
|
|
67
|
+
isPathDraft(siteData.value, currentPage.value?.path, currentPage.value),
|
|
68
|
+
);
|
|
69
|
+
const isNotFound = computed(() => Boolean(currentPage.value?.isNotFound));
|
|
41
70
|
|
|
42
71
|
const pageMetaTitle = computed(() => {
|
|
43
72
|
const page = currentPage.value;
|
|
44
73
|
const site = siteTitle.value;
|
|
74
|
+
// 404 pages: generic "Page not found — Site". The page slug (which is
|
|
75
|
+
// some URL the visitor typed) shouldn't leak into <title>.
|
|
76
|
+
if (isNotFound.value) {
|
|
77
|
+
return site ? `Page not found — ${site}` : 'Page not found';
|
|
78
|
+
}
|
|
79
|
+
// Draft pages emit a generic title so the page slug doesn't leak via
|
|
80
|
+
// <title> in the SSG-rendered HTML on disk. The tab title remains
|
|
81
|
+
// generic even after client-side unlock — for "iterate before launch"
|
|
82
|
+
// the URL bar already identifies the page.
|
|
83
|
+
if (isDraft.value) {
|
|
84
|
+
return site ? `Draft — ${site}` : 'Draft';
|
|
85
|
+
}
|
|
45
86
|
const explicitTitle = page?.meta?.title;
|
|
46
87
|
if (explicitTitle) {
|
|
47
88
|
return explicitTitle;
|
|
@@ -54,6 +95,9 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
54
95
|
});
|
|
55
96
|
|
|
56
97
|
const pageMetaDescription = computed(() => {
|
|
98
|
+
if (isDraft.value || isNotFound.value) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
57
101
|
const explicitDescription = currentPage.value?.meta?.description;
|
|
58
102
|
if (explicitDescription && explicitDescription.trim()) {
|
|
59
103
|
return explicitDescription.trim();
|
|
@@ -61,16 +105,145 @@ export function usePageMeta({ siteData, currentPage }) {
|
|
|
61
105
|
return siteDescription.value || '';
|
|
62
106
|
});
|
|
63
107
|
|
|
108
|
+
// Canonical URL — base-locale URLs have no prefix; non-base locales get
|
|
109
|
+
// /{locale}/path. Drafts and 404s skip canonical (the URL itself isn't
|
|
110
|
+
// authoritative for gated/missing content). Sites without a configured
|
|
111
|
+
// site.url skip too — we can't build an absolute URL.
|
|
112
|
+
const canonicalHref = computed(() => {
|
|
113
|
+
if (isDraft.value || isNotFound.value) return '';
|
|
114
|
+
if (!siteUrl.value) return '';
|
|
115
|
+
return buildCanonicalUrl({
|
|
116
|
+
siteUrl: siteUrl.value,
|
|
117
|
+
locale: localeValue.value,
|
|
118
|
+
baseLocale: configBaseLocale,
|
|
119
|
+
path: currentPage.value?.path || '/',
|
|
120
|
+
trailingSlash: trailingSlash.value,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// hreflang alternates — only emitted when a site has more than one
|
|
125
|
+
// locale's content on disk. Includes one entry per available locale plus
|
|
126
|
+
// an `x-default` pointing at the base-locale URL (Google's preferred
|
|
127
|
+
// pattern for sites without explicit geo-targeting).
|
|
128
|
+
const hreflangLinks = computed(() => {
|
|
129
|
+
if (isDraft.value || isNotFound.value) return [];
|
|
130
|
+
if (!siteUrl.value) return [];
|
|
131
|
+
if (!Array.isArray(configAvailableLocales) || configAvailableLocales.length <= 1) return [];
|
|
132
|
+
const path = currentPage.value?.path || '/';
|
|
133
|
+
const links = [];
|
|
134
|
+
for (const code of configAvailableLocales) {
|
|
135
|
+
const href = buildCanonicalUrl({
|
|
136
|
+
siteUrl: siteUrl.value,
|
|
137
|
+
locale: code,
|
|
138
|
+
baseLocale: configBaseLocale,
|
|
139
|
+
path,
|
|
140
|
+
trailingSlash: trailingSlash.value,
|
|
141
|
+
});
|
|
142
|
+
if (href) {
|
|
143
|
+
links.push({ rel: 'alternate', hreflang: code, href, key: `hreflang-${code}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const defaultHref = buildCanonicalUrl({
|
|
147
|
+
siteUrl: siteUrl.value,
|
|
148
|
+
locale: configBaseLocale,
|
|
149
|
+
baseLocale: configBaseLocale,
|
|
150
|
+
path,
|
|
151
|
+
trailingSlash: trailingSlash.value,
|
|
152
|
+
});
|
|
153
|
+
if (defaultHref) {
|
|
154
|
+
links.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref, key: 'hreflang-x-default' });
|
|
155
|
+
}
|
|
156
|
+
return links;
|
|
157
|
+
});
|
|
158
|
+
|
|
64
159
|
useHead(() => {
|
|
65
160
|
const description = pageMetaDescription.value;
|
|
66
|
-
const meta =
|
|
67
|
-
|
|
68
|
-
:
|
|
161
|
+
const meta = [];
|
|
162
|
+
if (description) {
|
|
163
|
+
meta.push({ name: 'description', content: description, key: 'description' });
|
|
164
|
+
}
|
|
165
|
+
// 404 pages get noindex defensively — Amplify (or whichever host)
|
|
166
|
+
// already serves them with HTTP 404, but search engines occasionally
|
|
167
|
+
// crawl 404 URLs and the meta is a stronger signal than relying on
|
|
168
|
+
// the status header alone.
|
|
169
|
+
if (isDraft.value || isNotFound.value) {
|
|
170
|
+
meta.push({ name: 'robots', content: 'noindex, nofollow', key: 'robots' });
|
|
171
|
+
}
|
|
172
|
+
const social = buildSocialMeta({
|
|
173
|
+
siteData: siteData.value,
|
|
174
|
+
currentPage: currentPage.value,
|
|
175
|
+
pageTitle: pageMetaTitle.value,
|
|
176
|
+
pageDescription: pageMetaDescription.value,
|
|
177
|
+
canonicalHref: canonicalHref.value,
|
|
178
|
+
isDraft: isDraft.value,
|
|
179
|
+
isNotFound: isNotFound.value,
|
|
180
|
+
locale: localeValue.value || configBaseLocale,
|
|
181
|
+
availableLocales: configAvailableLocales,
|
|
182
|
+
});
|
|
183
|
+
for (const entry of social) meta.push(entry);
|
|
184
|
+
|
|
185
|
+
// Site-verification meta tags. These are emitted on every page (not
|
|
186
|
+
// just home) — verification platforms typically only check homepage
|
|
187
|
+
// but emitting site-wide is harmless and matches what most CMSs do.
|
|
188
|
+
const verification = siteData.value?.site?.siteVerification;
|
|
189
|
+
if (verification && typeof verification === 'object') {
|
|
190
|
+
for (const [platform, token] of Object.entries(verification)) {
|
|
191
|
+
const metaName = SITE_VERIFICATION_META[platform];
|
|
192
|
+
if (!metaName) continue;
|
|
193
|
+
if (typeof token !== 'string') continue;
|
|
194
|
+
const trimmed = token.trim();
|
|
195
|
+
if (!trimmed) continue;
|
|
196
|
+
meta.push({ name: metaName, content: trimmed, key: `verify-${platform}` });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const link = [];
|
|
201
|
+
if (canonicalHref.value) {
|
|
202
|
+
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
203
|
+
}
|
|
204
|
+
for (const entry of hreflangLinks.value) {
|
|
205
|
+
link.push(entry);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const script = buildJsonLdScripts({
|
|
209
|
+
siteData: siteData.value,
|
|
210
|
+
currentPage: currentPage.value,
|
|
211
|
+
isDraft: isDraft.value,
|
|
212
|
+
isNotFound: isNotFound.value,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Auto-generated BreadcrumbList JSON-LD. Appended after any
|
|
216
|
+
// author-supplied jsonld blocks so authors can override by emitting a
|
|
217
|
+
// hand-authored BreadcrumbList of their own (Google takes the first
|
|
218
|
+
// valid one when multiple are present).
|
|
219
|
+
const breadcrumb = buildBreadcrumbList({
|
|
220
|
+
siteUrl: siteUrl.value,
|
|
221
|
+
currentPage: currentPage.value,
|
|
222
|
+
pages: siteData.value?.pages,
|
|
223
|
+
baseLocale: configBaseLocale,
|
|
224
|
+
locale: localeValue.value,
|
|
225
|
+
trailingSlash: trailingSlash.value,
|
|
226
|
+
});
|
|
227
|
+
if (breadcrumb && !isDraft.value && !isNotFound.value) {
|
|
228
|
+
script.push({
|
|
229
|
+
type: 'application/ld+json',
|
|
230
|
+
innerHTML: toJsonLdString(breadcrumb),
|
|
231
|
+
key: 'jsonld-BreadcrumbList',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
69
235
|
return {
|
|
236
|
+
// Reflect the page's locale on <html lang> during SSR/prerender so the
|
|
237
|
+
// on-disk HTML for /{locale}/... ships the right language (the router
|
|
238
|
+
// guard only sets this client-side, after hydration). Base-locale pages
|
|
239
|
+
// fall back to the configured base locale.
|
|
240
|
+
htmlAttrs: { lang: localeValue.value || configBaseLocale || 'en' },
|
|
70
241
|
title: pageMetaTitle.value,
|
|
71
242
|
meta,
|
|
243
|
+
link,
|
|
244
|
+
script,
|
|
72
245
|
};
|
|
73
246
|
});
|
|
74
247
|
|
|
75
|
-
return { pageMetaTitle, pageMetaDescription };
|
|
248
|
+
return { pageMetaTitle, pageMetaDescription, isDraft, isNotFound, canonicalHref, hreflangLinks };
|
|
76
249
|
}
|
|
@@ -23,14 +23,34 @@ function selectLargestEntry(srcset = '') {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Build a
|
|
27
|
-
*
|
|
26
|
+
* Build a hero-background style binding that pairs a `url()` fallback with an
|
|
27
|
+
* `image-set()` upgrade. Returns an OBJECT with two CSS custom properties.
|
|
28
|
+
*
|
|
29
|
+
* Why an object with custom properties instead of a single `background-image`
|
|
30
|
+
* string: Vue 3's `:style` parses a string into a normalized object keyed by
|
|
31
|
+
* property name. A string with two `background-image` declarations
|
|
32
|
+
* (`background-image: url(...); background-image: image-set(...)`) collapses
|
|
33
|
+
* to a single entry — only the last value survives. This loses the
|
|
34
|
+
* progressive-enhancement chain that's the whole point of pairing a `url()`
|
|
35
|
+
* fallback with an `image-set()` upgrade. By emitting two custom properties,
|
|
36
|
+
* Vue keeps both, and the consuming CSS rule chains them as two real
|
|
37
|
+
* declarations of `background-image` so browsers walk the cascade naturally.
|
|
38
|
+
*
|
|
39
|
+
* Consumed by Hero.vue's `.promo-surface` rule, which declares:
|
|
40
|
+
*
|
|
41
|
+
* background-image: var(--promo-bg-fallback);
|
|
42
|
+
* background-image: var(--promo-bg-set, var(--promo-bg-fallback));
|
|
43
|
+
*
|
|
44
|
+
* Browsers that can parse `image-set()` (and its `type()` argument) use the
|
|
45
|
+
* second declaration; browsers that can't fall through to the first.
|
|
46
|
+
*
|
|
28
47
|
* @param {object} options
|
|
29
48
|
* @param {{
|
|
30
49
|
* sources: import('vue').MaybeRefOrGetter<Array<{ type: string, srcset: string }>>,
|
|
31
50
|
* fallbackSrc: import('vue').MaybeRefOrGetter<string>,
|
|
32
51
|
* fallbackFormat: import('vue').MaybeRefOrGetter<string>
|
|
33
52
|
* }} options.imageSet
|
|
53
|
+
* @returns {import('vue').ComputedRef<Record<string, string> | null>}
|
|
34
54
|
*/
|
|
35
55
|
export function usePromoBackgroundStyles({ imageSet }) {
|
|
36
56
|
return computed(() => {
|
|
@@ -38,30 +58,26 @@ export function usePromoBackgroundStyles({ imageSet }) {
|
|
|
38
58
|
const fallbackFormat = (unref(imageSet?.fallbackFormat) || 'jpg').toLowerCase();
|
|
39
59
|
const sources = unref(imageSet?.sources) || [];
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
if (!fallbackSrc) return null;
|
|
42
62
|
|
|
63
|
+
const formatEntries = [];
|
|
43
64
|
sources.forEach((source) => {
|
|
44
65
|
const largest = selectLargestEntry(source?.srcset);
|
|
45
66
|
if (largest) {
|
|
46
67
|
formatEntries.push(`url("${largest}") type("${source?.type}") 1x`);
|
|
47
68
|
}
|
|
48
69
|
});
|
|
49
|
-
|
|
50
70
|
if (fallbackSrc) {
|
|
51
71
|
const fallbackMime = fallbackFormat === 'jpg' ? 'jpeg' : fallbackFormat;
|
|
52
72
|
formatEntries.push(`url("${fallbackSrc}") type("image/${fallbackMime}") 1x`);
|
|
53
73
|
}
|
|
54
74
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
if (cssImageSet) {
|
|
62
|
-
style += `background-image: ${cssImageSet};`;
|
|
75
|
+
const result = {
|
|
76
|
+
'--promo-bg-fallback': `url("${fallbackSrc}")`,
|
|
77
|
+
};
|
|
78
|
+
if (formatEntries.length) {
|
|
79
|
+
result['--promo-bg-set'] = `image-set(${formatEntries.join(', ')})`;
|
|
63
80
|
}
|
|
64
|
-
|
|
65
|
-
return style;
|
|
81
|
+
return result;
|
|
66
82
|
});
|
|
67
83
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defineAsyncComponent } from 'vue';
|
|
2
2
|
|
|
3
|
-
import manifestSchema from '../../extensions/manifest.schema.json' with { type: 'json' };
|
|
4
3
|
import { unwrapDefault as toPlainModule } from '../utils/unwrapDefault.js';
|
|
5
4
|
|
|
6
5
|
const manifestModules = import.meta.glob('../../extensions/**/extension.config.json', {
|
|
@@ -23,22 +22,30 @@ const componentModules = import.meta.glob([
|
|
|
23
22
|
'!**/*.spec.*',
|
|
24
23
|
]);
|
|
25
24
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
// Structural sanity check only — full JSON-Schema validation runs at build
|
|
26
|
+
// time in the Vite plugin (validateExtensionManifests), which FAILS the
|
|
27
|
+
// build on an invalid manifest. This runtime check exists so a malformed
|
|
28
|
+
// manifest handed straight to registerExtension() can't crash the loader,
|
|
29
|
+
// and it is deliberately cheap: shipping ajv to the browser to re-validate
|
|
30
|
+
// build-frozen JSON was ~40 KB of bundle for zero new information. Checks
|
|
31
|
+
// only the fields the loader dereferences below.
|
|
32
|
+
function describeManifestProblem(manifest) {
|
|
33
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
34
|
+
return 'manifest is not an object';
|
|
35
|
+
}
|
|
36
|
+
if (typeof manifest.slug !== 'string' || !manifest.slug.trim()) {
|
|
37
|
+
return 'manifest.slug must be a non-empty string';
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(manifest.components)) {
|
|
40
|
+
return 'manifest.components must be an array';
|
|
41
|
+
}
|
|
42
|
+
for (const component of manifest.components) {
|
|
43
|
+
if (!component || typeof component !== 'object'
|
|
44
|
+
|| typeof component.name !== 'string' || !component.name.trim()) {
|
|
45
|
+
return 'every manifest.components entry needs a non-empty string "name"';
|
|
46
|
+
}
|
|
40
47
|
}
|
|
41
|
-
return
|
|
48
|
+
return null;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
const extensionComponentRegistry = {};
|
|
@@ -128,10 +135,11 @@ await Promise.all(
|
|
|
128
135
|
return;
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
const problem = describeManifestProblem(manifest);
|
|
139
|
+
if (problem) {
|
|
140
|
+
// Not DEV-gated: this fires once at boot, and a silently missing
|
|
141
|
+
// extension in production is exactly the failure mode to surface.
|
|
142
|
+
console.warn(`[extensions] Manifest at ${manifestPath} is invalid (${problem}); skipping.`);
|
|
135
143
|
return;
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -290,10 +298,12 @@ export async function registerExtension(extensionModule) {
|
|
|
290
298
|
throw new Error('[extensions] registerExtension() module must include a manifest');
|
|
291
299
|
}
|
|
292
300
|
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
301
|
+
const problem = describeManifestProblem(manifest);
|
|
302
|
+
if (problem) {
|
|
303
|
+
// Not DEV-gated: one boot-time line beats an extension vanishing
|
|
304
|
+
// silently in production. Schema-level validation already failed the
|
|
305
|
+
// build if this manifest shipped through the Vite plugin.
|
|
306
|
+
console.warn(`[extensions] External extension "${manifest?.slug || '(unknown)'}" has an invalid manifest (${problem}); skipping.`);
|
|
297
307
|
return;
|
|
298
308
|
}
|
|
299
309
|
|
package/src/main.js
CHANGED
|
@@ -5,8 +5,8 @@ import { createHead } from '@unhead/vue/client';
|
|
|
5
5
|
import App from './App.vue';
|
|
6
6
|
import { routes, resolveHistory, applyRouterGuards } from './router/index.js';
|
|
7
7
|
|
|
8
|
-
import { shouldEnableAnalytics, scheduleAnalyticsLoad } from './utils/cookieConsent.js';
|
|
9
|
-
import { loadConfigData } from './utils/loadConfig.js';
|
|
8
|
+
import { shouldEnableAnalytics, scheduleAnalyticsLoad, configureAnalyticsConsentMode } from './utils/cookieConsent.js';
|
|
9
|
+
import { loadConfigData, primeConfigSync, trimConfigToPage, resolvePageIdForRoute } from './utils/loadConfig.js';
|
|
10
10
|
import { persistAttributionFromLocation } from './utils/trackingContext.js';
|
|
11
11
|
import { applyThemeVariables } from './themes/themeManager.js';
|
|
12
12
|
import { setActiveThemeKey } from './utils/themeColors.js';
|
|
@@ -41,14 +41,29 @@ const extractThemeKey = (siteData) => {
|
|
|
41
41
|
|
|
42
42
|
const applySiteTheme = (themeKey, head) => {
|
|
43
43
|
const normalized = normalizeThemeKey(themeKey);
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
// No theme configured — leave `<html>` without `data-site-theme` so
|
|
46
|
+
// none of the `:root[data-site-theme="X"]` rules in the bundled
|
|
47
|
+
// virtual:cms-theme-vars.css apply. Components fall back to their
|
|
48
|
+
// hardcoded CSS defaults. Sites that intentionally want the bundled
|
|
49
|
+
// `base` palette must opt in via `"theme": "base"` in site.json (or
|
|
50
|
+
// any locale's site.json).
|
|
51
|
+
//
|
|
52
|
+
// (The implicit auto-apply of `base` was removed in 1.0.0-beta.12.
|
|
53
|
+
// It hid configuration mistakes — sites that registered a theme but
|
|
54
|
+
// forgot to activate it silently rendered with `base` instead of the
|
|
55
|
+
// theme they intended. Surfacing the misconfiguration via "no theme
|
|
56
|
+
// applied" is more honest.)
|
|
57
|
+
if (!normalized) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
45
60
|
|
|
46
61
|
// Push the html attribute via @unhead so `data-site-theme="X"` lands
|
|
47
62
|
// in the SSR-rendered HTML *before* Vue hydrates. Theme CSS selectors
|
|
48
63
|
// (`:root[data-site-theme="X"]`) then apply during the very first
|
|
49
64
|
// paint, eliminating the need for site-side critical-CSS overrides.
|
|
50
65
|
if (head && typeof head.push === 'function') {
|
|
51
|
-
head.push({ htmlAttrs: { 'data-site-theme':
|
|
66
|
+
head.push({ htmlAttrs: { 'data-site-theme': normalized } });
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
// Client only: also set the attribute imperatively (so runtime theme
|
|
@@ -56,8 +71,8 @@ const applySiteTheme = (themeKey, head) => {
|
|
|
56
71
|
// for themes that ship JS-only design tokens (e.g. the bundled `base`
|
|
57
72
|
// theme has no theme.css).
|
|
58
73
|
if (typeof document !== 'undefined') {
|
|
59
|
-
document.documentElement.dataset.siteTheme =
|
|
60
|
-
applyThemeVariables(
|
|
74
|
+
document.documentElement.dataset.siteTheme = normalized;
|
|
75
|
+
applyThemeVariables(normalized);
|
|
61
76
|
}
|
|
62
77
|
};
|
|
63
78
|
|
|
@@ -120,6 +135,21 @@ export function createCmsApp() {
|
|
|
120
135
|
};
|
|
121
136
|
|
|
122
137
|
const extractCurrentRoute = () => {
|
|
138
|
+
// During SSG, vite-ssg passes the route being pre-rendered as a string
|
|
139
|
+
// on `ctx.routePath` and only pushes the router AFTER this setup fn runs
|
|
140
|
+
// — so `router.currentRoute` is still vue-router's START_LOCATION ('/')
|
|
141
|
+
// here, identically on every page. Resolve `routePath` through the router
|
|
142
|
+
// (a pure match, no navigation) to recover the real path + `params.locale`.
|
|
143
|
+
// Without this, `resolveCurrentPageId` sees '/' for every route, so the
|
|
144
|
+
// payload trim targets the wrong page — or, when '/' is ambiguous, none.
|
|
145
|
+
const routePath = ctx?.routePath;
|
|
146
|
+
if (typeof routePath === 'string' && routePath) {
|
|
147
|
+
try {
|
|
148
|
+
return router.resolve(routePath);
|
|
149
|
+
} catch {
|
|
150
|
+
return { path: routePath, params: {} };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
123
153
|
if (ctx?.route) {
|
|
124
154
|
return ctx.route;
|
|
125
155
|
}
|
|
@@ -130,6 +160,18 @@ export function createCmsApp() {
|
|
|
130
160
|
return null;
|
|
131
161
|
};
|
|
132
162
|
|
|
163
|
+
// SSG only: resolve the route being pre-rendered to its page id so the
|
|
164
|
+
// embedded config can be trimmed to that single page when the site opts
|
|
165
|
+
// into `site.trimInitialState`. The matching rules live in the pure,
|
|
166
|
+
// unit-tested resolvePageIdForRoute (returns null on ambiguity → keep the
|
|
167
|
+
// full config, a safe no-op).
|
|
168
|
+
const resolveCurrentPageId = (config) => {
|
|
169
|
+
const route = extractCurrentRoute();
|
|
170
|
+
const rawPath = typeof route?.path === 'string' ? route.path : '';
|
|
171
|
+
const localeParam = pickLocaleParam(route?.params?.locale);
|
|
172
|
+
return resolvePageIdForRoute(config, rawPath, localeParam);
|
|
173
|
+
};
|
|
174
|
+
|
|
133
175
|
const loadSiteConfig = async (options = {}) => {
|
|
134
176
|
const requestedLocale =
|
|
135
177
|
pickLocaleParam(options.locale) ??
|
|
@@ -143,6 +185,10 @@ export function createCmsApp() {
|
|
|
143
185
|
initialState.siteTheme = existingTheme;
|
|
144
186
|
setActiveThemeKey(existingTheme);
|
|
145
187
|
applySiteTheme(existingTheme, ctx.head);
|
|
188
|
+
// Expose the server-resolved config for the first synchronous client
|
|
189
|
+
// render (see usePageConfig.hydrateFromSyncCache) so the prerendered
|
|
190
|
+
// DOM isn't replaced by an empty render while the async loader runs.
|
|
191
|
+
primeConfigSync(localeKey, initialState.siteConfig);
|
|
146
192
|
return initialState.siteConfig;
|
|
147
193
|
}
|
|
148
194
|
|
|
@@ -159,6 +205,22 @@ export function createCmsApp() {
|
|
|
159
205
|
if (!isClient) {
|
|
160
206
|
try {
|
|
161
207
|
await loadSiteConfig();
|
|
208
|
+
// Payload trim (default ON since 1.0.0; opt out with
|
|
209
|
+
// `"trimInitialState": false`): embed only the current page's config
|
|
210
|
+
// in window.__INITIAL_STATE__ rather than every page's (the
|
|
211
|
+
// full-site blob is byte-identical on every page and can dominate
|
|
212
|
+
// page weight — measured 81% of page bytes on a profiled site).
|
|
213
|
+
// The rendered HTML body is unaffected — usePageConfig resolves the
|
|
214
|
+
// SSR render from its own loadConfigData() call, not from
|
|
215
|
+
// initialState — so this only shrinks the serialized hydration blob.
|
|
216
|
+
// Safety valve: an ambiguous route→page match returns null and the
|
|
217
|
+
// full config is embedded (a no-op, never a broken page).
|
|
218
|
+
if (initialState.siteConfig?.site?.trimInitialState !== false) {
|
|
219
|
+
const currentPageId = resolveCurrentPageId(initialState.siteConfig);
|
|
220
|
+
if (currentPageId) {
|
|
221
|
+
initialState.siteConfig = trimConfigToPage(initialState.siteConfig, currentPageId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
162
224
|
} catch (error) {
|
|
163
225
|
console.error('Failed to load site configuration during SSG build', error);
|
|
164
226
|
}
|
|
@@ -168,6 +230,7 @@ export function createCmsApp() {
|
|
|
168
230
|
let siteData;
|
|
169
231
|
try {
|
|
170
232
|
siteData = await loadSiteConfig();
|
|
233
|
+
configureAnalyticsConsentMode(siteData?.site?.analytics?.consentMode);
|
|
171
234
|
if (shouldEnableAnalytics()) {
|
|
172
235
|
const googleId = siteData?.site?.googleId;
|
|
173
236
|
if (googleId) scheduleAnalyticsLoad(googleId);
|