@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.
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 +36 -16
  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 +81 -48
  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
@@ -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 = description
67
- ? [{ name: 'description', content: description, key: 'description' }]
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 CSS background-image declaration that respects the neon backdrop toggle
27
- * and reuses the responsive image metadata returned by `useResponsiveImage`.
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
- const formatEntries = [];
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 cssImageSet = formatEntries.length ? `image-set(${formatEntries.join(', ')})` : '';
56
- let style = '';
57
-
58
- if (fallbackSrc) {
59
- style += `background-image: url("${fallbackSrc}");`;
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
- // Lazy-load ajv via dynamic import — the static `import Ajv from 'ajv/...'`
27
- // fails when Vite serves this file outside the dep optimizer (CJS module
28
- // without a default export shim). Dynamic import always gets the shim.
29
- let validateManifest;
30
- async function getValidator() {
31
- if (!validateManifest) {
32
- const [{ default: Ajv }, ajvFormats] = await Promise.all([
33
- import('ajv/dist/2020'),
34
- import('ajv-formats'),
35
- ]);
36
- const addFormats = ajvFormats.default || ajvFormats;
37
- const ajv = new Ajv({ allErrors: true, strict: false });
38
- addFormats(ajv);
39
- validateManifest = ajv.compile(manifestSchema);
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 validateManifest;
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 validate = await getValidator();
132
- const isValid = validate(manifest);
133
- if (!isValid) {
134
- warn(`Manifest at ${manifestPath} failed validation.`, validate.errors);
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 validate = await getValidator();
294
- const isValid = validate(manifest);
295
- if (!isValid) {
296
- warn(`External extension "${manifest.slug}" failed validation.`, validate.errors);
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
- const resolved = normalized || 'base';
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': resolved } });
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 = resolved;
60
- applyThemeVariables(resolved);
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);