@koehler8/cms 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -1,392 +1,60 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
3
- import { fileURLToPath, pathToFileURL } from 'node:url';
4
-
5
- import { createRequire } from 'node:module';
6
- import Ajv from 'ajv/dist/2020.js';
7
- import addFormats from 'ajv-formats';
8
-
9
- const require = createRequire(import.meta.url);
10
- const packageJson = require('../package.json');
11
- import { compareVersions } from '../src/utils/semver.js';
12
- import { validateRequiredContentPaths } from '../src/utils/contentRequirements.js';
13
-
14
- const ROOT_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
15
- const EXTENSIONS_DIR = join(ROOT_DIR, 'extensions');
16
- const SITES_DIR = join(ROOT_DIR, 'sites');
17
-
18
- const ajv = new Ajv({ allErrors: true, strict: false });
19
- addFormats(ajv);
20
- const APP_VERSION = process.env.VITE_APP_VERSION || packageJson.version || '0.0.0';
21
- const normalizeSlug = (value) => (value || '').toString().trim().toLowerCase();
22
-
23
- function isJsonFile(fileName) {
24
- return fileName.toLowerCase().endsWith('.json');
25
- }
26
-
27
- async function collectExtensionPackages(dir) {
28
- const entries = await readdir(dir, { withFileTypes: true });
29
- const packages = [];
30
-
31
- for (const entry of entries) {
32
- if (entry.name.startsWith('.')) continue;
33
- const absolute = join(dir, entry.name);
34
- if (entry.isDirectory()) {
35
- const nested = await collectExtensionPackages(absolute);
36
- packages.push(...nested);
37
- continue;
38
- }
39
- if (entry.name === 'extension.config.json') {
40
- packages.push(absolute);
41
- }
42
- }
43
-
44
- return packages;
45
- }
46
-
47
- async function loadJson(filePath) {
48
- const raw = await readFile(filePath, 'utf-8');
49
- return JSON.parse(raw);
50
- }
51
-
52
- async function loadExtensionRegistry() {
53
- const manifestPaths = await collectExtensionPackages(EXTENSIONS_DIR);
54
- const map = new Map();
55
-
56
- for (const manifestPath of manifestPaths) {
57
- const manifestDir = manifestPath.replace(/extension\.config\.json$/i, '');
58
- const manifest = await loadJson(manifestPath);
59
- const slug = manifest.slug || manifestDir;
60
- map.set(slug, {
61
- manifest,
62
- dir: manifestDir,
63
- });
64
- }
65
-
66
- return map;
67
- }
68
-
69
- async function importSchema(schemaPath) {
70
- if (!schemaPath) return null;
71
- const lower = schemaPath.toLowerCase();
72
- if (lower.endsWith('.json')) {
73
- const raw = await readFile(schemaPath, 'utf-8');
74
- return JSON.parse(raw);
75
- }
76
- const url = pathToFileURL(schemaPath).href;
77
- const module = await import(url);
78
- return module?.default ?? module;
79
- }
80
-
81
- function buildComponentMaps(extensionRegistry) {
82
- const componentMap = new Map();
83
- const extensionNameToSources = new Map();
84
-
85
- for (const { manifest, dir } of extensionRegistry.values()) {
86
- if (!Array.isArray(manifest.components)) continue;
87
- const slug = typeof manifest.slug === 'string' ? manifest.slug.trim() : '';
88
- const normalizedSlug = slug ? slug.toLowerCase() : '';
89
-
90
- for (const component of manifest.components) {
91
- if (!component?.name) continue;
92
- const entry = {
93
- manifest,
94
- dir,
95
- component,
96
- slug,
97
- normalizedSlug,
98
- };
99
- if (normalizedSlug) {
100
- const sourceKey = `${normalizedSlug}:${component.name}`;
101
- componentMap.set(sourceKey, entry);
102
- const existingSources = extensionNameToSources.get(component.name) || new Set();
103
- existingSources.add(normalizedSlug);
104
- extensionNameToSources.set(component.name, existingSources);
105
- }
106
- }
107
- }
108
-
109
- return {
110
- componentMap,
111
- extensionNameToSources,
112
- };
113
- }
114
-
115
- async function collectSiteConfigs(dir) {
116
- const configs = [];
117
- const entries = await readdir(dir, { withFileTypes: true });
118
- for (const entry of entries) {
119
- if (entry.name.startsWith('.')) continue;
120
- const absolute = join(dir, entry.name);
121
- if (entry.isDirectory()) {
122
- const nested = await collectSiteConfigs(absolute);
123
- configs.push(...nested);
124
- continue;
125
- }
126
- if (isJsonFile(entry.name)) {
127
- configs.push(absolute);
1
+ /**
2
+ * cms-validate-extensions — validate a SITE's extension manifests.
3
+ *
4
+ * Validates, against the framework's JSON Schema (the same validation the
5
+ * Vite plugin runs at build time):
6
+ * - every site-local extension at <projectRoot>/extensions/<slug>/
7
+ * - any extension package names passed as positional arguments
8
+ * (e.g. `cms-validate-extensions @koehler8/cms-ext-compliance`)
9
+ *
10
+ * The project root is the parent of --site-dir (default ./site), matching
11
+ * how the Vite plugin resolves it. Exits non-zero on any invalid manifest.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { validateExtensionManifests } from './lib/extension-manifests.mjs';
17
+
18
+ const siteDir = process.env.CMS_SITE_DIR
19
+ ? path.resolve(process.env.CMS_SITE_DIR)
20
+ : path.resolve(process.cwd(), './site');
21
+ const projectRoot = path.dirname(siteDir);
22
+
23
+ const packageSpecs = (process.env.CMS_VALIDATE_PACKAGES || '')
24
+ .split(',')
25
+ .map((s) => s.trim())
26
+ .filter(Boolean);
27
+
28
+ const localSpecs = [];
29
+ const localExtensionsDir = path.join(projectRoot, 'extensions');
30
+ if (fs.existsSync(localExtensionsDir)) {
31
+ for (const entry of fs.readdirSync(localExtensionsDir, { withFileTypes: true })) {
32
+ if (!entry.isDirectory()) continue;
33
+ if (fs.existsSync(path.join(localExtensionsDir, entry.name, 'extension.config.json'))) {
34
+ localSpecs.push(`./extensions/${entry.name}`);
128
35
  }
129
36
  }
130
- return configs;
131
37
  }
132
38
 
133
- async function validateConfigBlocks({
134
- siteConfigPath,
135
- siteConfig,
136
- componentMap,
137
- extensionNameToSources,
138
- appVersion,
139
- }) {
140
- const failures = [];
141
-
142
- if (!siteConfig?.pages || typeof siteConfig.pages !== 'object') {
143
- return failures;
144
- }
145
-
146
- for (const [pageKey, pageConfig] of Object.entries(siteConfig.pages)) {
147
- if (!pageConfig || typeof pageConfig !== 'object') continue;
148
- const components = Array.isArray(pageConfig.components) ? pageConfig.components : [];
149
- const content = pageConfig.content && typeof pageConfig.content === 'object' ? pageConfig.content : {};
150
- const normalizedPageKey = (pageKey || '').toString().trim().toLowerCase();
151
-
152
- for (const entry of components) {
153
- let name = '';
154
- let configKeyOverride;
155
- let enabled = true;
156
- let source;
157
-
158
- if (typeof entry === 'string') {
159
- name = entry;
160
- } else if (entry && typeof entry === 'object') {
161
- enabled = entry.enabled !== false;
162
- name = typeof entry.name === 'string' ? entry.name : '';
163
- configKeyOverride = typeof entry.configKey === 'string' && entry.configKey.trim()
164
- ? entry.configKey.trim()
165
- : undefined;
166
- source = typeof entry.source === 'string' ? entry.source : undefined;
167
- } else {
168
- continue;
169
- }
170
-
171
- if (typeof name === 'string') {
172
- name = name.trim();
173
- }
174
-
175
- if (!enabled || !name) continue;
176
-
177
- const normalizedSource = source ? normalizeSlug(source) : '';
178
- const sourcesForName = extensionNameToSources.get(name);
179
- const isExtensionName = Boolean(sourcesForName && sourcesForName.size);
180
-
181
- if (isExtensionName && !normalizedSource) {
182
- failures.push({
183
- siteConfigPath,
184
- pageKey,
185
- componentName: name,
186
- message: `Component comes from an extension and must specify "source". Available sources: ${Array.from(sourcesForName).join(', ')}`,
187
- });
188
- continue;
189
- }
190
-
191
- if (isExtensionName && normalizedSource && !sourcesForName.has(normalizedSource)) {
192
- failures.push({
193
- siteConfigPath,
194
- pageKey,
195
- componentName: name,
196
- source: normalizedSource,
197
- message: `Component is not provided by the "${normalizedSource}" extension.`,
198
- });
199
- continue;
200
- }
201
-
202
- if (!isExtensionName && normalizedSource) {
203
- failures.push({
204
- siteConfigPath,
205
- pageKey,
206
- componentName: name,
207
- source: normalizedSource,
208
- message: 'Component does not belong to an extension so "source" must be omitted.',
209
- });
210
- continue;
211
- }
212
-
213
- if (!isExtensionName) {
214
- continue;
215
- }
216
-
217
- const lookupKey = `${normalizedSource}:${name}`;
218
- const componentMeta = componentMap.get(lookupKey);
219
- if (!componentMeta) {
220
- failures.push({
221
- siteConfigPath,
222
- pageKey,
223
- componentName: name,
224
- source: normalizedSource,
225
- message: 'Component metadata could not be resolved for the specified source.',
226
- });
227
- continue;
228
- }
229
- const { component, dir } = componentMeta;
230
- const configKey = configKeyOverride || component.configKey;
231
- if (!configKey) continue;
232
- const block = content[configKey];
233
-
234
- if (Array.isArray(component.allowedPages) && component.allowedPages.length) {
235
- const allowed = component.allowedPages
236
- .map((slug) => (slug || '').toString().trim().toLowerCase())
237
- .filter(Boolean);
238
- if (allowed.length && !allowed.includes(normalizedPageKey)) {
239
- failures.push({
240
- siteConfigPath,
241
- pageKey,
242
- componentName: name,
243
- configKey,
244
- source: normalizedSource || undefined,
245
- message: `Component is not allowed on this page (allowed: ${allowed.join(', ')}).`,
246
- });
247
- continue;
248
- }
249
- }
250
-
251
- if (component.minAppVersion && compareVersions(appVersion, component.minAppVersion) < 0) {
252
- failures.push({
253
- siteConfigPath,
254
- pageKey,
255
- componentName: name,
256
- configKey,
257
- source: normalizedSource || undefined,
258
- message: `Component requires app version ${component.minAppVersion} or higher (current ${appVersion}).`,
259
- });
260
- continue;
261
- }
39
+ const specs = [...localSpecs, ...packageSpecs];
262
40
 
263
- if (Array.isArray(component.requiredContent) && component.requiredContent.length) {
264
- const { isValid, missing } = validateRequiredContentPaths(block, component.requiredContent);
265
- if (!isValid) {
266
- failures.push({
267
- siteConfigPath,
268
- pageKey,
269
- componentName: name,
270
- configKey,
271
- source: normalizedSource || undefined,
272
- message: `Missing required content paths: ${missing.join(', ')}`,
273
- });
274
- continue;
275
- }
276
- }
277
-
278
- const schemaRef = component.propsInterface;
279
- if (!schemaRef) continue;
280
-
281
- const schemaPath = join(dir, schemaRef.replace(/^\.\/+/, ''));
282
- let schema;
283
- let importFailed = false;
284
- try {
285
- schema = await importSchema(schemaPath);
286
- } catch (error) {
287
- importFailed = true;
288
- if (!(/Cannot use import statement/.test(String(error)) || /Unexpected reserved word/.test(String(error)))) {
289
- throw error;
290
- }
291
- }
292
- if (importFailed) {
293
- schema = await loadJson(schemaPath);
294
- }
295
- if (!schema) {
296
- failures.push({
297
- siteConfigPath,
298
- pageKey,
299
- componentName: name,
300
- configKey,
301
- source: normalizedSource || undefined,
302
- message: `Schema "${schemaRef}" could not be loaded.`,
303
- });
304
- continue;
305
- }
306
-
307
- const validate = ajv.compile(schema);
308
- const valid = validate(block);
309
- if (!valid) {
310
- failures.push({
311
- siteConfigPath,
312
- pageKey,
313
- componentName: name,
314
- configKey,
315
- source: normalizedSource || undefined,
316
- errors: validate.errors,
317
- });
318
- }
319
- }
320
- }
321
-
322
- return failures;
41
+ if (specs.length === 0) {
42
+ console.log(`No extension manifests found (looked in ${localExtensionsDir}, no packages named).`);
43
+ console.log('Nothing to validate. Pass package names as arguments to check installed extensions.');
44
+ process.exit(0);
323
45
  }
324
46
 
325
- async function main() {
326
- const extensionRegistry = await loadExtensionRegistry();
327
- if (!extensionRegistry.size) {
328
- console.warn('No extension manifests found under /extensions.');
329
- return;
47
+ try {
48
+ const validated = await validateExtensionManifests(specs, projectRoot);
49
+ for (const { spec, slug, componentNames } of validated) {
50
+ console.log(`✓ ${spec} (slug "${slug}", ${componentNames.size} component${componentNames.size === 1 ? '' : 's'})`);
330
51
  }
331
- const { componentMap, extensionNameToSources } = buildComponentMaps(extensionRegistry);
332
-
333
- const siteConfigs = await collectSiteConfigs(SITES_DIR);
334
- const failures = [];
335
-
336
- for (const siteConfigPath of siteConfigs) {
337
- let siteConfig;
338
- try {
339
- siteConfig = await loadJson(siteConfigPath);
340
- } catch (error) {
341
- failures.push({
342
- siteConfigPath,
343
- message: `Failed to parse JSON: ${error.message}`,
344
- });
345
- continue;
346
- }
347
- const blockFailures = await validateConfigBlocks({
348
- siteConfigPath,
349
- siteConfig,
350
- componentMap,
351
- extensionNameToSources,
352
- appVersion: APP_VERSION,
353
- });
354
- failures.push(...blockFailures);
355
- }
356
-
357
- if (failures.length) {
358
- console.error('Extension content validation failed:');
359
- failures.forEach((failure) => {
360
- console.error(`\n- ${failure.siteConfigPath}`);
361
- if (failure.pageKey) {
362
- console.error(` Page: ${failure.pageKey}`);
363
- }
364
- if (failure.componentName) {
365
- console.error(` Component: ${failure.componentName}`);
366
- }
367
- if (failure.source) {
368
- console.error(` Source: ${failure.source}`);
369
- }
370
- if (failure.configKey) {
371
- console.error(` Config key: ${failure.configKey}`);
372
- }
373
- if (failure.message) {
374
- console.error(` Error: ${failure.message}`);
375
- }
376
- if (failure.errors) {
377
- failure.errors.forEach((error) => {
378
- console.error(` • ${error.instancePath || '/'} ${error.message}`);
379
- });
380
- }
381
- });
382
- process.exitCode = 1;
383
- return;
52
+ const skipped = specs.length - validated.length;
53
+ if (skipped > 0) {
54
+ console.warn(`⚠ ${skipped} spec(s) had no extension.config.json to validate (see warnings above).`);
384
55
  }
385
-
386
- console.log('All extension content blocks validated successfully.');
387
- }
388
-
389
- main().catch((error) => {
390
- console.error(error);
56
+ console.log('\nAll extension manifests validated successfully.');
57
+ } catch (err) {
58
+ console.error(err.message);
391
59
  process.exitCode = 1;
392
- });
60
+ }
@@ -1,11 +1,26 @@
1
+ /**
2
+ * cms-validate-themes — validate theme manifests.
3
+ *
4
+ * Validates:
5
+ * - the framework's bundled themes (themes/ at the package root)
6
+ * - a site's local themes at <projectRoot>/themes/<slug>/ when --site-dir
7
+ * is given (project root = parent of the site dir, matching the plugin)
8
+ *
9
+ * Exits non-zero on any invalid manifest.
10
+ */
11
+
1
12
  import { readdir } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { dirname, join, resolve } from 'node:path';
3
15
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
16
  import { validateThemeManifest } from '../src/themes/themeValidator.js';
5
17
 
6
18
  const MANIFEST_PATTERN = /theme\.config\.(js|mjs|json)$/;
7
19
  const ROOT_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
8
- const THEMES_DIR = join(ROOT_DIR, 'themes');
20
+ const FRAMEWORK_THEMES_DIR = join(ROOT_DIR, 'themes');
21
+
22
+ const siteDir = process.env.CMS_SITE_DIR ? resolve(process.env.CMS_SITE_DIR) : '';
23
+ const SITE_THEMES_DIR = siteDir ? join(dirname(siteDir), 'themes') : '';
9
24
 
10
25
  async function collectManifestPaths(dir) {
11
26
  const entries = await readdir(dir, { withFileTypes: true });
@@ -29,14 +44,31 @@ async function loadManifestFromPath(filePath) {
29
44
  }
30
45
 
31
46
  async function main() {
32
- const manifestPaths = await collectManifestPaths(THEMES_DIR);
47
+ const seen = new Set();
48
+ const manifestPaths = [];
49
+ for (const dir of [FRAMEWORK_THEMES_DIR, SITE_THEMES_DIR]) {
50
+ if (!dir || !existsSync(dir)) continue;
51
+ for (const p of await collectManifestPaths(dir)) {
52
+ if (seen.has(p)) continue;
53
+ seen.add(p);
54
+ manifestPaths.push(p);
55
+ }
56
+ }
57
+
33
58
  if (!manifestPaths.length) {
34
- console.warn('No theme manifests found under /themes.');
59
+ console.log('No theme manifests found (framework themes/ and site themes/ are both empty).');
35
60
  return;
36
61
  }
62
+
37
63
  const failures = [];
38
64
  for (const manifestPath of manifestPaths) {
39
- const manifest = await loadManifestFromPath(manifestPath);
65
+ let manifest;
66
+ try {
67
+ manifest = await loadManifestFromPath(manifestPath);
68
+ } catch (error) {
69
+ failures.push({ path: manifestPath, errors: [`failed to load: ${error.message}`] });
70
+ continue;
71
+ }
40
72
  const { valid, errors } = validateThemeManifest(manifest, { throwOnError: false });
41
73
  if (!valid) {
42
74
  failures.push({ path: manifestPath, errors });
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <section
3
+ v-if="hasContent"
3
4
  id="about"
4
5
  class="about-section section-shell section-shell--tight ui-section--overlay"
5
6
  data-analytics-section="about-overview"
@@ -99,6 +100,9 @@ const pageContent = inject('pageContent', ref({}));
99
100
  const { resolve: resolveComingSoon } = useComingSoonResolver(pageContent);
100
101
 
101
102
  const aboutContent = computed(() => pageContent.value?.about || {});
103
+ // components[] contract: no content.about block → render nothing (never
104
+ // empty headings and image frames).
105
+ const hasContent = computed(() => Object.keys(aboutContent.value).length > 0);
102
106
 
103
107
  const getAboutValue = (key, fallback) => {
104
108
  const value = aboutContent.value?.[key];
@@ -115,12 +119,10 @@ const aboutTopButton = computed(() => getAboutValue('topButton', {}));
115
119
  const aboutBottomButton = computed(() => getAboutValue('bottomButton', {}));
116
120
 
117
121
  const topImage = useResponsiveImage('img/about-top', {
118
- widths: [320, 900],
119
122
  fallbackFormat: 'jpg',
120
123
  });
121
124
  const topImageHandleError = topImage.handleError;
122
125
  const bottomImage = useResponsiveImage('img/about-bottom', {
123
- widths: [320, 600],
124
126
  fallbackFormat: 'jpg',
125
127
  });
126
128
  const bottomImageHandleError = bottomImage.handleError;
@@ -1,6 +1,6 @@
1
1
  <template>
2
- <section id="about" class="about-value-section section-shell" data-analytics-section="about-value">
3
- <div v-if="hasValueProps" class="container">
2
+ <section v-if="hasValueProps" id="about-value" class="about-value-section section-shell" data-analytics-section="about-value">
3
+ <div class="container">
4
4
  <div class="about-value__grid">
5
5
  <div class="about-value__media">
6
6
  <div
@@ -159,7 +159,6 @@ const hasValueProps = computed(() => {
159
159
  });
160
160
 
161
161
  const DEFAULT_VALUE_IMAGE_PATH = 'img/about-top';
162
- const DEFAULT_VALUE_IMAGE_WIDTHS = [320, 900];
163
162
  const DEFAULT_VALUE_IMAGE_FALLBACK = 'jpg';
164
163
 
165
164
  const valueImagePath = computed(() => {
@@ -179,7 +178,9 @@ const valueImageWidths = computed(() => {
179
178
  if (Array.isArray(widths) && widths.length) {
180
179
  return widths;
181
180
  }
182
- return DEFAULT_VALUE_IMAGE_WIDTHS;
181
+ // No site override → useResponsiveImage falls back to the pipeline's
182
+ // resolved width matrix.
183
+ return undefined;
183
184
  });
184
185
 
185
186
  const valueImageFallbackFormat = computed(() => {
@@ -390,11 +390,22 @@ function handleActionClick(event, action) {
390
390
  }
391
391
 
392
392
  .coming-soon-action:hover,
393
- .coming-soon-action:focus {
393
+ .coming-soon-action:focus-visible {
394
394
  transform: translateY(-2px);
395
395
  opacity: 0.92;
396
396
  }
397
397
 
398
+ @media (prefers-reduced-motion: reduce) {
399
+ .coming-soon-action {
400
+ transition: none;
401
+ }
402
+
403
+ .coming-soon-action:hover,
404
+ .coming-soon-action:focus-visible {
405
+ transform: none;
406
+ }
407
+ }
408
+
398
409
  @media (max-width: 767px) {
399
410
  .coming-soon-shell {
400
411
  padding: 1.75rem;