@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
package/vite-plugin.js CHANGED
@@ -12,8 +12,19 @@ import { createRequire } from 'node:module';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
  import vue from '@vitejs/plugin-vue';
14
14
 
15
- import { SUPPORTED_LOCALES } from './src/constants/locales.js';
16
15
  import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
16
+ import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
17
+ import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
18
+ import { buildWebAppManifest } from './src/utils/webAppManifest.js';
19
+ import { sha256Hex } from './src/utils/sha256.js';
20
+ import { applyRouteShard } from './src/utils/ssgRoutes.js';
21
+ import {
22
+ computeVariantCacheDir,
23
+ resolveImageVariantConfig,
24
+ planVariantJobsFromFlatDir,
25
+ reconcileVariantCache,
26
+ } from './scripts/image-variants/index.js';
27
+ import { validateExtensionManifests } from './scripts/lib/extension-manifests.mjs';
17
28
 
18
29
  // ---- Helpers ----
19
30
 
@@ -22,6 +33,15 @@ function loadSplitConfig(contentLocaleDir) {
22
33
  if (!fs.existsSync(sitePath)) return null;
23
34
 
24
35
  const site = inflateFlatConfig(JSON.parse(fs.readFileSync(sitePath, 'utf-8')));
36
+ // The plaintext password is read at build time so the plugin can produce
37
+ // robots.txt / sitemap.xml without surfacing it; strip it out of the
38
+ // in-memory siteConfig so accidental future code paths can't pass it
39
+ // through to the bundle. The `transform` hook below replaces the
40
+ // plaintext with a SHA-256 hash before any JSON file is imported by the
41
+ // runtime.
42
+ if (site && typeof site === 'object') {
43
+ delete site.draftPassword;
44
+ }
25
45
  const sharedPath = path.join(contentLocaleDir, 'shared.json');
26
46
  const shared = fs.existsSync(sharedPath) ? inflateFlatConfig(JSON.parse(fs.readFileSync(sharedPath, 'utf-8'))) : {};
27
47
 
@@ -38,6 +58,29 @@ function loadSplitConfig(contentLocaleDir) {
38
58
  return { site, shared, pages };
39
59
  }
40
60
 
61
+ // Replace site.json's plaintext draftPassword with a SHA-256 hash before
62
+ // the JSON ever enters the bundle. Returns the original code untouched
63
+ // when the file doesn't carry a password (or doesn't parse as JSON), so
64
+ // non-CMS site.json files anywhere else in the project are unaffected.
65
+ async function transformSiteJson(code) {
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(code);
69
+ } catch {
70
+ return null;
71
+ }
72
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
73
+
74
+ const password = typeof parsed.draftPassword === 'string' ? parsed.draftPassword.trim() : '';
75
+ if (!('draftPassword' in parsed) && !password) return null;
76
+
77
+ delete parsed.draftPassword;
78
+ if (password) {
79
+ parsed.draftPasswordHash = await sha256Hex(password);
80
+ }
81
+ return JSON.stringify(parsed);
82
+ }
83
+
41
84
  function normalizeRoutePath(value = '/') {
42
85
  if (typeof value !== 'string') return '/';
43
86
  const trimmed = value.trim();
@@ -83,14 +126,21 @@ function extractSiteMetadata(siteConfig) {
83
126
  url: siteUrl = '',
84
127
  googleId: siteGoogleId = '',
85
128
  sameAs: siteSameAs = [],
129
+ manifest: siteManifest = {},
86
130
  } = siteConfig.site || {};
87
131
 
88
132
  const siteSameAsList = Array.isArray(siteSameAs)
89
133
  ? siteSameAs.map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean)
90
134
  : [];
91
- const siteSameAsJson = JSON.stringify(siteSameAsList);
92
135
 
93
- return { siteTitle, siteDescription, siteUrl, siteGoogleId, siteSameAsJson };
136
+ // Mobile chrome bar / PWA splash color. Defaults match buildWebAppManifest's
137
+ // default so the rendered <meta name="theme-color"> matches whatever
138
+ // manifest.json advertises.
139
+ const manifestThemeColor = (typeof siteManifest.themeColor === 'string'
140
+ ? siteManifest.themeColor.trim()
141
+ : '') || '#ffffff';
142
+
143
+ return { siteTitle, siteDescription, siteUrl, siteGoogleId, siteSameAsList, manifestThemeColor };
94
144
  }
95
145
 
96
146
  const __filename = fileURLToPath(import.meta.url);
@@ -105,8 +155,12 @@ const VIRTUAL_ASSETS = 'virtual:cms-asset-resolver';
105
155
  // <link rel="stylesheet"> in <head>, so theme CSS variables apply during the
106
156
  // initial paint instead of after Vue hydrates.
107
157
  const VIRTUAL_THEME_VARS = 'virtual:cms-theme-vars.css';
158
+ // Eager glob over `site/components/**/*.vue` so site-local Vue files can be
159
+ // referenced by basename in pages/{pageId}.json `components[]`. Resolution
160
+ // priority: site > extension > bundled (see useComponentResolver).
161
+ const VIRTUAL_SITE_COMPONENTS = 'virtual:cms-site-components';
108
162
 
109
- const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS, VIRTUAL_THEME_VARS]);
163
+ const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS, VIRTUAL_THEME_VARS, VIRTUAL_SITE_COMPONENTS]);
110
164
 
111
165
  // Entry file name (written to site repo root, gitignored, cleaned up in buildEnd)
112
166
  const ENTRY_FILENAME = '.cms-entry.js';
@@ -146,6 +200,7 @@ function buildEntrySource(themePackages = [], extensionPackages = []) {
146
200
  import { setConfigLoader } from '@koehler8/cms/utils/loadConfig';
147
201
  import { setSiteStyleLoader } from '@koehler8/cms/utils/siteStyles';
148
202
  import { setAssetResolver } from '@koehler8/cms/utils/assetResolver';
203
+ import { setSiteComponents } from '@koehler8/cms/utils/componentRegistry';
149
204
  ${needsThemeRegister ? `import { registerTheme } from '@koehler8/cms/themes/themeLoader';` : ''}
150
205
  ${needsExtRegister ? `import { registerExtension } from '@koehler8/cms/extensions/extensionLoader';` : ''}
151
206
  ${themeImports}
@@ -154,11 +209,13 @@ ${extImports}
154
209
  import * as __cmsConfig from '${VIRTUAL_CONFIG}';
155
210
  import * as __cmsStyles from '${VIRTUAL_STYLES}';
156
211
  import * as __cmsAssets from '${VIRTUAL_ASSETS}';
212
+ import * as __cmsSiteComponents from '${VIRTUAL_SITE_COMPONENTS}';
157
213
  import '${VIRTUAL_THEME_VARS}';
158
214
 
159
215
  setConfigLoader(__cmsConfig);
160
216
  setSiteStyleLoader(__cmsStyles);
161
217
  setAssetResolver(__cmsAssets);
218
+ setSiteComponents(__cmsSiteComponents);
162
219
 
163
220
  ${themeRegistrations}
164
221
  ${extRegistrations}
@@ -195,6 +252,24 @@ function renderTemplate(template, data) {
195
252
  ? `${canonicalBase}${STATIC_OG_IMAGE_PATH}`
196
253
  : STATIC_OG_IMAGE_PATH;
197
254
 
255
+ // The Organization JSON-LD is built as real JSON rather than interpolated
256
+ // field-by-field in the template: <%= %> HTML-escaping corrupted values
257
+ // inside the script element (a `"` in a site title shipped to crawlers as
258
+ // a literal &quot;), and raw interpolation risked </script> breakout.
259
+ // JSON.stringify with `<` escaped is both valid JSON and script-safe.
260
+ const organizationJsonLd = JSON.stringify(
261
+ {
262
+ '@context': 'https://schema.org',
263
+ '@type': 'Organization',
264
+ name: data.site ?? '',
265
+ url: data.siteUrl ?? '',
266
+ logo: ABSOLUTE_LOGO,
267
+ sameAs: data.siteSameAsList ?? [],
268
+ },
269
+ null,
270
+ 2,
271
+ ).replace(/</g, '\\u003c');
272
+
198
273
  const scope = {
199
274
  ...data,
200
275
  STATIC_LOGO_PATH,
@@ -202,6 +277,7 @@ function renderTemplate(template, data) {
202
277
  canonicalBase,
203
278
  ABSOLUTE_LOGO,
204
279
  ABSOLUTE_OG_IMAGE,
280
+ organizationJsonLd,
205
281
  };
206
282
 
207
283
  // Strip <% ... %> blocks (declarations are pre-computed above)
@@ -275,24 +351,121 @@ function discoverExtensionCjsDeps(extensionPackages, projectRoot) {
275
351
  return [...cjsDeps];
276
352
  }
277
353
 
354
+ // Build-time extension-manifest validation lives in scripts/lib so the
355
+ // cms-validate-extensions CLI shares the exact same implementation. Imported
356
+ // above (a bare `export ... from` re-export would not bind the name for the
357
+ // plugin's own use) and re-exported here for tests and API stability.
358
+ export { validateExtensionManifests };
359
+
360
+ // List every *.vue basename under a directory (recursive). Mirrors the
361
+ // import.meta.glob discovery the runtime registries use.
362
+ function collectVueBasenames(dir) {
363
+ const names = new Set();
364
+ if (!fs.existsSync(dir)) return names;
365
+ const walk = (d) => {
366
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
367
+ const full = path.join(d, entry.name);
368
+ if (entry.isDirectory()) walk(full);
369
+ else if (entry.isFile() && entry.name.endsWith('.vue')) {
370
+ names.add(entry.name.replace(/\.vue$/i, ''));
371
+ }
372
+ }
373
+ };
374
+ walk(dir);
375
+ return names;
376
+ }
377
+
378
+ // Build-time components[] validation: every name referenced by a page must
379
+ // resolve to a bundled, site-local, or extension-manifest component — the
380
+ // same namespace the runtime resolver searches. At runtime an unknown name
381
+ // silently drops the section in production (the resolver's warnings are
382
+ // DEV-gated), which is the fleet's #1 documented authoring trap; here it
383
+ // fails the build with the page and name instead. Exported for tests.
384
+ export function collectComponentRefProblems({ pagesByLocale = {}, bundledNames, siteNames, extensions = [] }) {
385
+ const bundled = bundledNames instanceof Set ? bundledNames : new Set(bundledNames || []);
386
+ const site = siteNames instanceof Set ? siteNames : new Set(siteNames || []);
387
+ const bySlug = new Map(extensions.map((e) => [e.slug, e.componentNames]));
388
+ const extensionNames = new Set(extensions.flatMap((e) => [...e.componentNames]));
389
+ const allKnown = new Set([...bundled, ...site, ...extensionNames]);
390
+ const lowerToCanonical = new Map([...allKnown].map((n) => [n.toLowerCase(), n]));
391
+
392
+ const problems = [];
393
+ for (const [locale, pages] of Object.entries(pagesByLocale)) {
394
+ for (const [pageId, page] of Object.entries(pages || {})) {
395
+ const components = Array.isArray(page?.components) ? page.components : [];
396
+ components.forEach((entry, index) => {
397
+ let name = '';
398
+ let source = '';
399
+ if (typeof entry === 'string') {
400
+ name = entry.trim();
401
+ } else if (entry && typeof entry === 'object') {
402
+ name = typeof entry.name === 'string' ? entry.name.trim() : '';
403
+ source = typeof entry.source === 'string' ? entry.source.trim() : '';
404
+ }
405
+ if (!name) return;
406
+ if (!source && name.includes(':')) {
407
+ [source, name] = [name.slice(0, name.indexOf(':')), name.slice(name.indexOf(':') + 1)];
408
+ }
409
+
410
+ const where = `pages/${pageId}.json (locale ${locale}) components[${index}]`;
411
+ if (source === 'site') {
412
+ if (!site.has(name)) {
413
+ problems.push(`- ${where}: "site:${name}" — no site/components/${name}.vue exists`);
414
+ }
415
+ return;
416
+ }
417
+ if (source) {
418
+ const extSet = bySlug.get(source);
419
+ if (!extSet) {
420
+ problems.push(`- ${where}: unknown extension source "${source}" (registered: ${[...bySlug.keys()].join(', ') || 'none'})`);
421
+ } else if (!extSet.has(name)) {
422
+ problems.push(`- ${where}: extension "${source}" declares no component "${name}"`);
423
+ }
424
+ return;
425
+ }
426
+ if (!allKnown.has(name)) {
427
+ const suggestion = lowerToCanonical.get(name.toLowerCase());
428
+ problems.push(
429
+ `- ${where}: unknown component "${name}"${suggestion ? ` (did you mean "${suggestion}"?)` : ''}`,
430
+ );
431
+ }
432
+ });
433
+ }
434
+ }
435
+ return problems;
436
+ }
437
+
278
438
  export default function cmsPlugin(options = {}) {
279
439
  const {
280
440
  siteDir: siteDirOption = './site',
281
441
  frameworkRoot: frameworkRootOption = __dirname,
282
- locales = SUPPORTED_LOCALES,
283
442
  themes: themePackages = [],
284
443
  extensions: extensionPackages = [],
285
444
  } = options;
445
+ // The previous `locales` option is no longer accepted: per-site available
446
+ // locales are now discovered from on-disk content directories. Passing
447
+ // `locales` is silently ignored (kept here as a destructure to swallow it
448
+ // gracefully; the `SUPPORTED_LOCALES` constant remains exported only for
449
+ // any consumer that imports it directly).
286
450
 
287
451
  // These are resolved relative to the project cwd at config time
288
452
  let siteDir;
289
453
  let siteRoot; // project root — parent of siteDir
290
454
  let frameworkRoot;
291
455
  let siteConfig;
456
+ let baseLocale = 'en';
457
+ let availableLocales = [];
292
458
  let pagePaths;
293
459
  let metadata;
294
460
  let tempIndexPath;
295
461
  let tempEntryPath;
462
+ let variantCacheDir;
463
+ let variantManifestPath;
464
+ let imageVariantWidths = [];
465
+ // Closure-scoped (NOT this._isDevServer): Vite 8 binds configureServer to a
466
+ // shared minimal context, not the object buildEnd/closeBundle see, so a
467
+ // property stashed on `this` in one hook is invisible in the others.
468
+ let isDevServer = false;
296
469
 
297
470
  const writeTempFiles = () => {
298
471
  // Write entry file (with optional external theme registrations)
@@ -306,7 +479,8 @@ export default function cmsPlugin(options = {}) {
306
479
  siteDescription: metadata.siteDescription,
307
480
  siteUrl: metadata.siteUrl,
308
481
  siteGoogleId: metadata.siteGoogleId,
309
- siteSameAsJson: metadata.siteSameAsJson,
482
+ siteSameAsList: metadata.siteSameAsList,
483
+ manifestThemeColor: metadata.manifestThemeColor,
310
484
  });
311
485
 
312
486
  const processed = html.replace(
@@ -349,11 +523,61 @@ export default function cmsPlugin(options = {}) {
349
523
  mirrorDir(frameworkPublicDir, sitePublicDir);
350
524
  };
351
525
 
526
+ // Generate robots.txt and sitemap.xml from the inflated site config and
527
+ // write them to siteRoot/public/. Runs after syncPublicDir so dynamic
528
+ // output overwrites any framework or stale static copy. Sites pick up
529
+ // changes by rebuilding (the existing edit-JSON-rebuild-deploy flow).
530
+ // Skip the write when content is unchanged: siteRoot/public/ may be
531
+ // git-tracked, and a plain `vite dev` session should not churn mtimes on
532
+ // tracked files.
533
+ const writeFileIfChanged = (filePath, content) => {
534
+ try {
535
+ if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf-8') === content) return;
536
+ } catch { /* unreadable → fall through to write */ }
537
+ fs.writeFileSync(filePath, content, 'utf-8');
538
+ };
539
+
540
+ const writeSeoFiles = ({ removeStale = true } = {}) => {
541
+ const sitePublicDir = path.join(siteRoot, 'public');
542
+ fs.mkdirSync(sitePublicDir, { recursive: true });
543
+
544
+ const sitemapXml = buildSitemap(siteConfig, { availableLocales, baseLocale });
545
+ const sitemapPath = path.join(sitePublicDir, 'sitemap.xml');
546
+ if (sitemapXml) {
547
+ writeFileIfChanged(sitemapPath, sitemapXml);
548
+ } else if (fs.existsSync(sitemapPath)) {
549
+ if (removeStale) {
550
+ // Stale sitemap from a previous build (e.g. site.url was removed, or
551
+ // every page is now draft). Drop it so crawlers don't get a list of
552
+ // URLs that no longer apply.
553
+ try { fs.unlinkSync(sitemapPath); } catch { /* noop */ }
554
+ } else {
555
+ // Dev session: never delete a (possibly tracked) file over what may
556
+ // be a transient config typo — a build will reconcile it.
557
+ console.warn('[@koehler8/cms] sitemap.xml is stale (no site.url, or the whole site is draft); left in place — the next build will remove it.');
558
+ }
559
+ }
560
+
561
+ const sitemapUrl = sitemapXml ? getSitemapUrl(siteConfig) : '';
562
+ const robotsBody = buildRobotsTxt(siteConfig, sitemapUrl);
563
+ writeFileIfChanged(path.join(sitePublicDir, 'robots.txt'), robotsBody);
564
+
565
+ // Web App Manifest. The template emits <link rel="manifest"> on every
566
+ // page; browsers use this to enable Add-to-Home-Screen + control
567
+ // splash colors when the site is launched from a homescreen icon.
568
+ const manifest = buildWebAppManifest(siteConfig);
569
+ writeFileIfChanged(
570
+ path.join(sitePublicDir, 'manifest.json'),
571
+ JSON.stringify(manifest, null, 2) + '\n',
572
+ );
573
+ };
574
+
352
575
  const cmsCore = {
353
576
  name: '@koehler8/cms',
354
577
  enforce: 'pre',
355
578
 
356
- config(userConfig, env) {
579
+ async config(userConfig, env) {
580
+ isDevServer = env?.command === 'serve';
357
581
  // Resolve absolute paths
358
582
  frameworkRoot = path.resolve(frameworkRootOption);
359
583
  siteDir = path.isAbsolute(siteDirOption)
@@ -362,6 +586,13 @@ export default function cmsPlugin(options = {}) {
362
586
  siteRoot = path.dirname(siteDir);
363
587
  tempIndexPath = path.join(siteRoot, 'index.html');
364
588
  tempEntryPath = path.join(siteRoot, ENTRY_FILENAME);
589
+ variantCacheDir = computeVariantCacheDir(siteRoot, siteDir);
590
+ variantManifestPath = path.join(variantCacheDir, '.manifest.json');
591
+
592
+ // Invalid extension manifests fail the build here, loudly, instead of
593
+ // being silently skipped in the visitor's browser. The returned info
594
+ // (slug + declared component names) feeds components[] validation below.
595
+ const extensionManifestInfo = await validateExtensionManifests(extensionPackages, siteRoot, frameworkRoot);
365
596
 
366
597
  // Load site config at config-resolution time (needed for SSG routes + HTML injection)
367
598
  const contentDir = path.join(siteDir, 'content');
@@ -369,14 +600,85 @@ export default function cmsPlugin(options = {}) {
369
600
  const contentConfig = fs.existsSync(contentConfigPath)
370
601
  ? JSON.parse(fs.readFileSync(contentConfigPath, 'utf-8'))
371
602
  : {};
372
- const baseLocale = contentConfig.baseLocale || 'en';
603
+ baseLocale = contentConfig.baseLocale || 'en';
373
604
  const configDir = path.join(contentDir, baseLocale);
374
605
  siteConfig = loadSplitConfig(configDir);
375
606
  if (!siteConfig) {
376
607
  throw new Error(`[@koehler8/cms] site config not found at ${configDir}`);
377
608
  }
609
+ // Discover locales actually present on disk: any directory under
610
+ // content/ that has at least one of `site.json`, `shared.json`, or
611
+ // a `pages/` subdir is treated as a real locale. (Earlier the check
612
+ // required `site.json` specifically — too strict for sites that
613
+ // author per-locale page overrides without overriding the site
614
+ // metadata, which is the common pattern for translation-only locale
615
+ // dirs.)
616
+ const localeDirHasContent = (dir) =>
617
+ fs.existsSync(path.join(dir, 'site.json'))
618
+ || fs.existsSync(path.join(dir, 'shared.json'))
619
+ || fs.existsSync(path.join(dir, 'pages'));
620
+ availableLocales = fs.existsSync(contentDir)
621
+ ? fs
622
+ .readdirSync(contentDir, { withFileTypes: true })
623
+ .filter((d) => d.isDirectory())
624
+ .map((d) => d.name)
625
+ .filter((name) => localeDirHasContent(path.join(contentDir, name)))
626
+ .sort()
627
+ : [];
378
628
  pagePaths = collectPagePaths(siteConfig);
379
629
  metadata = extractSiteMetadata(siteConfig);
630
+ imageVariantWidths = resolveImageVariantConfig(siteConfig).widths;
631
+
632
+ // Unknown components[] references fail the build. Every locale's pages
633
+ // are checked — locale files can define their own component lists, and
634
+ // translation-only locale dirs may carry pages without a site.json.
635
+ const pagesByLocale = { [baseLocale]: siteConfig.pages || {} };
636
+ for (const locale of availableLocales) {
637
+ if (locale === baseLocale) continue;
638
+ const pagesDir = path.join(contentDir, locale, 'pages');
639
+ if (!fs.existsSync(pagesDir)) continue;
640
+ const localePages = {};
641
+ for (const file of fs.readdirSync(pagesDir)) {
642
+ if (!file.endsWith('.json')) continue;
643
+ try {
644
+ localePages[file.replace('.json', '')] = inflateFlatConfig(
645
+ JSON.parse(fs.readFileSync(path.join(pagesDir, file), 'utf-8')),
646
+ );
647
+ } catch {
648
+ /* unparseable page JSON surfaces via the real config load */
649
+ }
650
+ }
651
+ pagesByLocale[locale] = localePages;
652
+ }
653
+ const bundledComponentNames = collectVueBasenames(path.join(frameworkRoot, 'src', 'components'));
654
+ bundledComponentNames.delete('Home'); // page wrapper, not addressable from components[]
655
+ const componentProblems = collectComponentRefProblems({
656
+ pagesByLocale,
657
+ bundledNames: bundledComponentNames,
658
+ siteNames: collectVueBasenames(path.join(siteDir, 'components')),
659
+ extensions: extensionManifestInfo,
660
+ });
661
+ if (componentProblems.length) {
662
+ throw new Error(
663
+ `[@koehler8/cms] Unknown component reference(s) in components[] — these sections would silently render nothing:\n${componentProblems.join('\n')}`,
664
+ );
665
+ }
666
+
667
+ // Contact-form shape check: a page with contact content but no valid
668
+ // form action renders a disabled form. Warn (not fatal) so the
669
+ // operator finds out at build time, not from a visitor.
670
+ for (const [locale, localePages] of Object.entries(pagesByLocale)) {
671
+ for (const [pageId, page] of Object.entries(localePages || {})) {
672
+ const contact = page?.content?.contact;
673
+ if (!contact || typeof contact !== 'object') continue;
674
+ const action = contact.form?.action;
675
+ if (typeof action !== 'string' || !/^https?:\/\//i.test(action.trim())) {
676
+ console.warn(
677
+ `[@koehler8/cms] pages/${pageId}.json (locale ${locale}): content.contact has no valid form.action URL — the contact form will render disabled.`,
678
+ );
679
+ }
680
+ }
681
+ }
380
682
 
381
683
  const extensionsDir = path.join(frameworkRoot, 'extensions');
382
684
  const extensionSetupFiles = collectExtensionSetupFiles(extensionsDir);
@@ -388,10 +690,22 @@ export default function cmsPlugin(options = {}) {
388
690
  alias: {
389
691
  '@cms-site': siteDir,
390
692
  '@cms-framework': frameworkRoot,
693
+ '@cms-variant-cache': variantCacheDir,
391
694
  '@': path.join(frameworkRoot, 'src'),
392
695
  '@extensions': extensionsDir,
393
696
  },
394
697
  },
698
+ // Static framework-version literal for src/utils/appInfo.js — the old
699
+ // `package.json?raw` import shipped the framework's entire manifest
700
+ // (deps, exports map) as a string in every client bundle to read one
701
+ // field. A real VITE_APP_VERSION env var still wins.
702
+ define: {
703
+ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(
704
+ process.env.VITE_APP_VERSION
705
+ || JSON.parse(fs.readFileSync(path.join(frameworkRoot, 'package.json'), 'utf-8')).version
706
+ || '0.0.0',
707
+ ),
708
+ },
395
709
  optimizeDeps: {
396
710
  // vue and vue-router MUST NOT be pre-bundled (inlined) into
397
711
  // @koehler8/cms chunks — if they are, .vue components served raw
@@ -400,14 +714,11 @@ export default function cmsPlugin(options = {}) {
400
714
  // injected router context (renders empty).
401
715
  exclude: ['vue', 'vue-router', 'pinia', '@koehler8/cms', ...extensionPackages],
402
716
  include: [
403
- // CJS deps imported directly by framework source
404
- 'ajv',
405
- 'ajv/dist/2020',
406
- 'ajv/dist/2020.js',
407
- 'ajv-formats',
408
717
  // Auto-discovered CJS deps from extension packages.
409
718
  // Extensions may depend on ESM packages that import CJS modules;
410
719
  // these must be pre-bundled for proper default-export shims.
720
+ // (ajv is no longer here: manifest validation moved to build
721
+ // time in this plugin, so the runtime never imports it.)
411
722
  ...discoverExtensionCjsDeps(extensionPackages, siteRoot),
412
723
  ],
413
724
  },
@@ -418,6 +729,14 @@ export default function cmsPlugin(options = {}) {
418
729
  publicDir: path.join(siteRoot, 'public'),
419
730
  build: {
420
731
  modulePreload: false,
732
+ // `cms-ssg-build` renders each route shard as a separate build into
733
+ // its own outDir (e.g. `.cms-ssg/dist-0`), then merges them into
734
+ // `dist/`. Honor the override for the CLIENT build only — vite-ssg's
735
+ // SSR build sets its own temp outDir and then imports the server
736
+ // bundle back from there, so overriding it would break that import.
737
+ ...(process.env.CMS_SSG_OUTDIR && !(env?.isSsrBuild || userConfig?.build?.ssr)
738
+ ? { outDir: process.env.CMS_SSG_OUTDIR }
739
+ : {}),
421
740
  rollupOptions: {
422
741
  input: tempIndexPath,
423
742
  output: {
@@ -455,12 +774,51 @@ export default function cmsPlugin(options = {}) {
455
774
  },
456
775
  ssgOptions: {
457
776
  dirStyle: 'nested',
777
+ // Cap vite-ssg's render pool (default is 20). The SSG pre-render
778
+ // leaves a fixed retained cost per route (one jsdom document survives
779
+ // each page inside vite-ssg's loop), so peak heap is dominated by that
780
+ // accumulation rather than the concurrent batch — but a smaller pool
781
+ // still trims the transient working set layered on top and makes peak
782
+ // more predictable on large multi-locale sites. This is a mitigation,
783
+ // not the bound; see SSG-MEMORY-PLAN.md for the durable fix.
784
+ concurrency: 8,
785
+ // After vite-ssg pre-renders every route, copy the rendered
786
+ // dist/404/index.html to dist/404.html — the not-found target
787
+ // hosts point their catch-all at. (GitHub-Pages-style hosts
788
+ // auto-serve it at HTTP 404; AWS Amplify does NOT — it needs the
789
+ // fleet's customRule `/<*>` → `/404.html` @ `404`, which Amplify
790
+ // implements as a 302 redirect. Measured 2026-08-16.)
791
+ onFinished() {
792
+ const distDir = path.join(siteRoot, process.env.CMS_SSG_OUTDIR || 'dist');
793
+ const nestedNotFound = path.join(distDir, '404', 'index.html');
794
+ const flatNotFound = path.join(distDir, '404.html');
795
+ if (fs.existsSync(nestedNotFound)) {
796
+ try {
797
+ fs.copyFileSync(nestedNotFound, flatNotFound);
798
+ } catch (err) {
799
+ console.warn(`[@koehler8/cms] failed to write 404.html: ${err.message}`);
800
+ }
801
+ }
802
+ },
458
803
  includedRoutes(paths) {
459
- const staticRoutes = new Set(['/admin']);
804
+ // /404 is the not-found landing page (rendered via the
805
+ // catch-all + NotFound component). onFinished above copies
806
+ // dist/404/index.html to dist/404.html; the host's catch-all
807
+ // rule sends unmatched URLs there. (On AWS Amplify that rule is
808
+ // a 302 redirect — its `404` rule type redirects rather than
809
+ // rewriting, and it has no native 404.html fallback. Measured
810
+ // 2026-08-16; the page's noindex meta covers the SEO side.)
811
+ const staticRoutes = new Set(['/404']);
460
812
  pagePaths.forEach((routePath) => staticRoutes.add(routePath));
461
813
 
814
+ // Pre-render every page under each non-base locale prefix that
815
+ // actually has a content directory on disk. The base locale is
816
+ // served at the unprefixed path (it's already covered by
817
+ // staticRoutes) — emitting `/{baseLocale}/path` would create
818
+ // duplicate URLs that all render the same content.
819
+ const localePrefixes = availableLocales.filter((l) => l !== baseLocale);
462
820
  const localizedRoutes = new Set();
463
- locales.forEach((locale) => {
821
+ localePrefixes.forEach((locale) => {
464
822
  pagePaths.forEach((routePath) => {
465
823
  const localized =
466
824
  routePath === '/' ? `/${locale}` : `/${locale}${routePath}`;
@@ -473,7 +831,15 @@ export default function cmsPlugin(options = {}) {
473
831
  ...Array.from(staticRoutes),
474
832
  ...Array.from(localizedRoutes),
475
833
  ];
476
- return Array.from(new Set(candidates));
834
+ const allRoutes = Array.from(new Set(candidates));
835
+
836
+ // Opt-in durable memory bound: when `cms-ssg-build` sets
837
+ // CMS_SSG_SHARD=k/N in the environment, render only this shard's
838
+ // slice of routes so peak SSG heap is bounded by slice size, not
839
+ // total route count. Unset (the normal `vite-ssg build` path) →
840
+ // the full list, byte-identical to before. (applyRouteShard keeps
841
+ // /404 in every shard so each can emit its own 404.html.)
842
+ return applyRouteShard(allRoutes, process.env.CMS_SSG_SHARD);
477
843
  },
478
844
  },
479
845
  };
@@ -554,7 +920,17 @@ const loader = createConfigLoader(allModules);
554
920
  export const loadConfigData = loader.loadConfigData;
555
921
  export const mergeConfigTrees = loader.mergeConfigTrees;
556
922
  export const cloneConfig = loader.cloneConfig;
557
- export const availableLocales = loader.availableLocales;
923
+ // availableLocales + baseLocale are emitted as build-time literals derived
924
+ // from the same on-disk discovery the plugin uses for SSG includedRoutes
925
+ // and sitemap generation. This guarantees the runtime sees the exact same
926
+ // locale list as the build, regardless of glob-based heuristics.
927
+ export const availableLocales = ${JSON.stringify(availableLocales)};
928
+ export const baseLocale = ${JSON.stringify(baseLocale)};
929
+ // The resolved image-variant width matrix (site.imageVariants.widths or the
930
+ // pipeline default) — the single source of truth useResponsiveImage builds
931
+ // srcsets from, so components can never request widths the pipeline doesn't
932
+ // generate.
933
+ export const imageVariantWidths = ${JSON.stringify(imageVariantWidths)};
558
934
  `;
559
935
  }
560
936
 
@@ -567,7 +943,30 @@ export const ensureSiteStylesLoaded = loader.ensureSiteStylesLoaded;
567
943
  `;
568
944
  }
569
945
 
946
+ if (virtualId === VIRTUAL_SITE_COMPONENTS) {
947
+ // Eager glob — Vite resolves each match at build time and inlines
948
+ // the imported components. The result is a bare object literal of
949
+ // the form { './path/to/Foo.vue': ModuleLike } that componentRegistry
950
+ // re-keys by basename.
951
+ return `
952
+ const modules = import.meta.glob('@cms-site/components/**/*.vue', { eager: true });
953
+ export default modules;
954
+ `;
955
+ }
956
+
570
957
  if (virtualId === VIRTUAL_ASSETS) {
958
+ // Three globs:
959
+ // shared — framework-bundled assets
960
+ // site — site/assets/** flat-dir originals (everything the
961
+ // author put on disk, including any leftover variants
962
+ // from the old pipeline that haven't been cleaned up)
963
+ // variants — generated variants from node_modules/.cache/
964
+ // @koehler8/cms/image-variants/{siteKey}/assets/**.
965
+ // The cache mirrors the site's `assets/` layout so
966
+ // normalizeAssetKey strips the same `assets/` prefix
967
+ // and surfaces variants under canonical `img/...` keys.
968
+ // Spread order: variants first so site flat-dir wins on collision —
969
+ // a hand-placed file in site/assets/img/ overrides the cached variant.
571
970
  return `
572
971
  import { createAssetResolver } from '@koehler8/cms/utils/assetResolver';
573
972
  const shared = import.meta.glob('@cms-framework/src/assets/**/*', {
@@ -575,15 +974,17 @@ const shared = import.meta.glob('@cms-framework/src/assets/**/*', {
575
974
  query: '?url',
576
975
  import: 'default',
577
976
  });
578
- const site = import.meta.glob([
579
- '@cms-site/assets/**/*',
580
- '!@cms-site/assets/_source/**',
581
- ], {
977
+ const site = import.meta.glob('@cms-site/assets/**/*', {
582
978
  eager: true,
583
979
  query: '?url',
584
980
  import: 'default',
585
981
  });
586
- const resolver = createAssetResolver(shared, site);
982
+ const variants = import.meta.glob('@cms-variant-cache/assets/**/*', {
983
+ eager: true,
984
+ query: '?url',
985
+ import: 'default',
986
+ });
987
+ const resolver = createAssetResolver(shared, { ...variants, ...site });
587
988
  export const resolveAssetUrl = resolver.resolveAssetUrl;
588
989
  export const resolveAsset = resolver.resolveAsset;
589
990
  export const resolveMedia = resolver.resolveMedia;
@@ -594,33 +995,97 @@ export const assetUrlMap = resolver.assetUrlMap;
594
995
  return null;
595
996
  },
596
997
 
998
+ async transform(code, id) {
999
+ // Strip plaintext draftPassword from any site.json under content/
1000
+ // before it lands in the bundle. The runtime reads the resulting
1001
+ // draftPasswordHash via siteData and verifies user input against
1002
+ // the hash. enforce: 'pre' on this plugin runs the transform before
1003
+ // Vite's built-in JSON plugin converts the file to a JS module.
1004
+ if (!id) return null;
1005
+ const idPath = id.split('?')[0];
1006
+ if (!idPath.endsWith('/site.json')) return null;
1007
+ if (!idPath.includes(`${path.sep}content${path.sep}`) && !idPath.includes('/content/')) {
1008
+ return null;
1009
+ }
1010
+ const transformed = await transformSiteJson(code);
1011
+ if (transformed == null) return null;
1012
+ return { code: transformed, map: null };
1013
+ },
1014
+
1015
+ async buildStart() {
1016
+ // Generate image variants into the cache dir BEFORE the VIRTUAL_ASSETS
1017
+ // load() block runs its import.meta.glob — that glob picks up the cache
1018
+ // files and surfaces them as Vite-pipelined assets (hashed, emitted to
1019
+ // dist/assets/) at build, or served straight from disk during dev.
1020
+ //
1021
+ // Errors here are warned, not thrown — a broken variant pipeline should
1022
+ // not kill the entire build. Sites without source images (no img dir,
1023
+ // empty img dir) are no-ops.
1024
+ const siteImgDir = path.join(siteDir, 'assets', 'img');
1025
+
1026
+ // Strict deprecation: warn loudly if the legacy _source/ dir exists.
1027
+ // Pipeline only reads the flat dir — sites that haven't migrated will
1028
+ // see broken image URLs until the originals move up a level.
1029
+ const legacySourceDir = path.join(siteImgDir, '_source');
1030
+ if (fs.existsSync(legacySourceDir)) {
1031
+ this.warn(
1032
+ `[@koehler8/cms] Found legacy site/assets/img/_source/ directory.`
1033
+ + ` Image originals live directly in site/assets/img/ and variants`
1034
+ + ` generate at build time into node_modules/.cache/. Move the`
1035
+ + ` originals from _source/ up into site/assets/img/, delete any`
1036
+ + ` committed pre-generated variants ({name}-{width}.{ext} files),`
1037
+ + ` and remove _source/. The pipeline is NOT reading from _source/`
1038
+ + ` — image URLs will 404 until you migrate.`,
1039
+ );
1040
+ }
1041
+
1042
+ try {
1043
+ const variantConfig = resolveImageVariantConfig(siteConfig);
1044
+ const jobs = planVariantJobsFromFlatDir({
1045
+ siteImgDir,
1046
+ config: variantConfig,
1047
+ });
1048
+ await reconcileVariantCache({
1049
+ jobs,
1050
+ cacheDir: variantCacheDir,
1051
+ manifestPath: variantManifestPath,
1052
+ currentConfig: variantConfig,
1053
+ log: (msg) => this.warn(`[@koehler8/cms image-variants] ${msg}`),
1054
+ });
1055
+ } catch (err) {
1056
+ this.warn(`[@koehler8/cms image-variants] generation failed: ${err.message}`);
1057
+ }
1058
+ },
1059
+
597
1060
  configResolved(resolvedConfig) {
598
1061
  // Write temp files as soon as config is resolved so vite-ssg's
599
1062
  // detectEntry (which runs before Vite's build lifecycle) finds them.
600
1063
  writeTempFiles();
601
1064
 
602
1065
  // Mirror framework's public/ into the site's public/ so fonts, maps,
603
- // robots.txt, etc. end up in dist. Site-specific files (favicon.ico,
604
- // logo.png, og-image.jpg written by generate-public-assets) win over
605
- // any accidental collisions because we skip files that already exist.
1066
+ // etc. end up in dist. Site-specific files (favicon.ico, logo.png,
1067
+ // og-image.jpg written by generate-public-assets) win over any
1068
+ // accidental collisions because we skip files that already exist.
606
1069
  syncPublicDir();
1070
+
1071
+ // robots.txt and sitemap.xml are generated from the inflated site
1072
+ // config (so per-site draft state regenerates on every build). Dev
1073
+ // sessions write-if-changed only and never delete (public/ may be
1074
+ // git-tracked; a config typo mid-session must not remove files).
1075
+ writeSeoFiles({ removeStale: !isDevServer });
607
1076
  },
608
1077
 
609
1078
  buildEnd() {
610
1079
  // Only clean up during actual builds, not dev server
611
- if (!this._isDevServer) cleanupTempFiles();
1080
+ if (!isDevServer) cleanupTempFiles();
612
1081
  },
613
1082
 
614
1083
  closeBundle() {
615
1084
  // Only clean up during actual builds, not dev server
616
- if (!this._isDevServer) cleanupTempFiles();
1085
+ if (!isDevServer) cleanupTempFiles();
617
1086
  },
618
1087
 
619
1088
  configureServer(server) {
620
- // Mark that we're running as a dev server so buildEnd/closeBundle
621
- // don't prematurely delete the temp files.
622
- this._isDevServer = true;
623
-
624
1089
  // Clean up only when the dev server actually closes
625
1090
  server.httpServer?.once('close', cleanupTempFiles);
626
1091
  process.once('exit', cleanupTempFiles);
@@ -656,8 +1121,59 @@ export const assetUrlMap = resolver.assetUrlMap;
656
1121
  // Watch site content for HMR
657
1122
  server.watcher.add(siteDir);
658
1123
 
1124
+ // Image-variant regen on source-image change. Debounced so bulk
1125
+ // drag-and-drop adds don't trigger N pipeline runs. Writes to the
1126
+ // cache dir don't re-trigger because we early-return on those events.
1127
+ const siteImgDir = path.join(siteDir, 'assets', 'img');
1128
+ let pendingVariantRegen = null;
1129
+ const scheduleVariantRegen = () => {
1130
+ if (pendingVariantRegen) clearTimeout(pendingVariantRegen);
1131
+ pendingVariantRegen = setTimeout(async () => {
1132
+ pendingVariantRegen = null;
1133
+ try {
1134
+ const variantConfig = resolveImageVariantConfig(siteConfig);
1135
+ const jobs = planVariantJobsFromFlatDir({
1136
+ siteImgDir,
1137
+ config: variantConfig,
1138
+ });
1139
+ await reconcileVariantCache({
1140
+ jobs,
1141
+ cacheDir: variantCacheDir,
1142
+ manifestPath: variantManifestPath,
1143
+ currentConfig: variantConfig,
1144
+ log: (msg) => console.log(`[@koehler8/cms image-variants] ${msg}`),
1145
+ });
1146
+ // Invalidate the asset-resolver virtual module so its
1147
+ // import.meta.glob re-evaluates and the new variant URLs land
1148
+ // in the asset map. The full-reload below picks them up.
1149
+ const assetMod = server.moduleGraph.getModuleById(resolved(VIRTUAL_ASSETS));
1150
+ if (assetMod) server.moduleGraph.invalidateModule(assetMod);
1151
+ server.ws.send({ type: 'full-reload' });
1152
+ } catch (err) {
1153
+ console.warn(`[@koehler8/cms image-variants] regen failed: ${err.message}`);
1154
+ }
1155
+ }, 250);
1156
+ };
1157
+ const handleImageEvent = (file) => {
1158
+ if (typeof file !== 'string') return;
1159
+ // Ignore writes our own pipeline made into the cache dir.
1160
+ if (variantCacheDir && file.startsWith(variantCacheDir)) return;
1161
+ if (!file.startsWith(siteImgDir)) return;
1162
+ scheduleVariantRegen();
1163
+ };
1164
+ server.watcher.on('add', handleImageEvent);
1165
+ server.watcher.on('change', handleImageEvent);
1166
+ server.watcher.on('unlink', handleImageEvent);
1167
+
659
1168
  // Invalidate virtual modules when site content changes
660
1169
  const invalidate = (file) => {
1170
+ // Don't double-handle image events — the dedicated handler above
1171
+ // (debounced variant regen) owns everything under site/assets/img/
1172
+ // and the cache dir, and fires its own full-reload once the fresh
1173
+ // variants exist. Handling them here too caused two full reloads per
1174
+ // image edit, the first against stale variants.
1175
+ if (variantCacheDir && file.startsWith(variantCacheDir)) return;
1176
+ if (siteImgDir && file.startsWith(siteImgDir)) return;
661
1177
  if (!file.startsWith(siteDir)) return;
662
1178
  for (const id of VIRTUAL_IDS) {
663
1179
  const mod = server.moduleGraph.getModuleById(resolved(id));
@@ -683,7 +1199,8 @@ export const assetUrlMap = resolver.assetUrlMap;
683
1199
  siteDescription: metadata.siteDescription,
684
1200
  siteUrl: metadata.siteUrl,
685
1201
  siteGoogleId: metadata.siteGoogleId,
686
- siteSameAsJson: metadata.siteSameAsJson,
1202
+ siteSameAsList: metadata.siteSameAsList,
1203
+ manifestThemeColor: metadata.manifestThemeColor,
687
1204
  });
688
1205
 
689
1206
  html = html.replace(