@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.
- package/CHANGELOG.md +1522 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +33 -17
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +365 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +82 -66
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +136 -36
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/notFound.js +52 -0
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +119 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +549 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- package/scripts/patch-lru-cache-tla.js +0 -164
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
|
-
|
|
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 "), 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
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 (!
|
|
1080
|
+
if (!isDevServer) cleanupTempFiles();
|
|
612
1081
|
},
|
|
613
1082
|
|
|
614
1083
|
closeBundle() {
|
|
615
1084
|
// Only clean up during actual builds, not dev server
|
|
616
|
-
if (!
|
|
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
|
-
|
|
1202
|
+
siteSameAsList: metadata.siteSameAsList,
|
|
1203
|
+
manifestThemeColor: metadata.manifestThemeColor,
|
|
687
1204
|
});
|
|
688
1205
|
|
|
689
1206
|
html = html.replace(
|