@koehler8/cms 1.0.0-beta.26 → 1.0.0-beta.27

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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // Thin wrapper that runs the migration helper from the consuming site's cwd.
3
+ // The script does its own argument handling and prints its own status.
4
+
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ await import(path.resolve(__dirname, '..', 'scripts', 'migrate-image-variants.mjs'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.26",
3
+ "version": "1.0.0-beta.27",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "bin": {
56
56
  "cms-generate-public-assets": "./bin/cms-generate-public-assets.js",
57
- "cms-generate-image-variants": "./bin/cms-generate-image-variants.js",
57
+ "cms-migrate-image-variants": "./bin/cms-migrate-image-variants.js",
58
58
  "cms-validate-themes": "./bin/cms-validate-themes.js",
59
59
  "cms-validate-extensions": "./bin/cms-validate-extensions.js",
60
60
  "cms-create-theme": "./bin/cms-create-theme.js",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Compute the per-site variant cache directory.
3
+ *
4
+ * Layout:
5
+ * {siteRoot}/node_modules/.cache/@koehler8/cms/image-variants/{siteKey}/
6
+ *
7
+ * where siteKey is the first 8 hex chars of sha1(siteDir). The keying
8
+ * prevents cache collisions when @koehler8/cms is hoisted in a workspace
9
+ * (one framework install serves multiple sites — without per-site keys,
10
+ * concurrent builds would clobber each other).
11
+ *
12
+ * The cache lives under the SITE's node_modules (matching Vite's own
13
+ * node_modules/.vite/ convention) so each site's cache is local to its
14
+ * own project tree.
15
+ */
16
+
17
+ import crypto from 'node:crypto';
18
+ import path from 'node:path';
19
+
20
+ export function siteKeyFor(siteDir) {
21
+ return crypto
22
+ .createHash('sha1')
23
+ .update(path.resolve(siteDir))
24
+ .digest('hex')
25
+ .slice(0, 8);
26
+ }
27
+
28
+ export function computeVariantCacheDir(siteRoot, siteDir) {
29
+ const root = path.resolve(siteRoot);
30
+ const key = siteKeyFor(siteDir || siteRoot);
31
+ return path.join(
32
+ root,
33
+ 'node_modules',
34
+ '.cache',
35
+ '@koehler8',
36
+ 'cms',
37
+ 'image-variants',
38
+ key,
39
+ );
40
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Classify a file under site/assets/img/** as one of:
3
+ * 'original' — a hand-authored source image we should generate variants from
4
+ * 'variant' — a previously-generated variant matching {name}-{width}.{format}
5
+ * where width is one of the configured widths
6
+ * 'ignore' — anything else (non-image file, README, etc.)
7
+ *
8
+ * The classifier is the bridge between the flat-dir convention and the old
9
+ * _source/ convention: by recognizing the variant naming pattern, we can keep
10
+ * originals and variants in the same directory without confusion. Files that
11
+ * happen to look variant-shaped but use widths outside the configured set
12
+ * (e.g. `team-2024.jpg`) are treated as originals — the documented escape
13
+ * hatch is to rename source files that genuinely conflict.
14
+ */
15
+
16
+ import path from 'node:path';
17
+
18
+ export const SOURCE_EXTENSIONS = new Set([
19
+ '.jpg', '.jpeg', '.png', '.webp', '.avif', '.tiff', '.tif',
20
+ ]);
21
+
22
+ export const FORMAT_OUTPUT_EXT = {
23
+ avif: 'avif',
24
+ webp: 'webp',
25
+ jpg: 'jpg',
26
+ jpeg: 'jpg',
27
+ png: 'png',
28
+ };
29
+
30
+ const VARIANT_FORMAT_EXTS = new Set(['avif', 'webp', 'jpg', 'jpeg']);
31
+
32
+ /**
33
+ * @param {string} relPath - File path relative to site/assets/img/
34
+ * @param {Set<number>} configWidths - Set of widths considered "variant widths"
35
+ * @returns {'original' | 'variant' | 'ignore'}
36
+ */
37
+ export function classifyImageFile(relPath, configWidths) {
38
+ const parsed = path.parse(relPath);
39
+ const ext = parsed.ext.toLowerCase().slice(1);
40
+
41
+ if (ext === 'svg') return 'original';
42
+ if (!ext) return 'ignore';
43
+ if (!SOURCE_EXTENSIONS.has(`.${ext}`)) return 'ignore';
44
+
45
+ const m = parsed.name.match(/^(.+)-(\d+)$/);
46
+ if (!m) return 'original';
47
+ if (!VARIANT_FORMAT_EXTS.has(ext)) return 'original';
48
+
49
+ const width = Number.parseInt(m[2], 10);
50
+ if (!configWidths.has(width)) return 'original';
51
+
52
+ return 'variant';
53
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Image-variant pipeline configuration.
3
+ *
4
+ * Resolves the `imageVariants` block from a site's `site.json` into a
5
+ * normalized config object: { widths: number[], formats: string[],
6
+ * quality: { avif, webp, jpg } }. Falls back to sensible marketing-site
7
+ * defaults when fields are missing or invalid.
8
+ */
9
+
10
+ import { FORMAT_OUTPUT_EXT } from './classifier.js';
11
+
12
+ export const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 2560];
13
+ export const DEFAULT_FORMATS = ['avif', 'webp', 'jpg'];
14
+ export const DEFAULT_QUALITY = { avif: 60, webp: 75, jpg: 80 };
15
+
16
+ function normalizeWidths(input) {
17
+ if (!Array.isArray(input)) return [...DEFAULT_WIDTHS];
18
+ const filtered = input
19
+ .map((v) => Number.parseInt(v, 10))
20
+ .filter((v) => Number.isFinite(v) && v > 0);
21
+ if (!filtered.length) return [...DEFAULT_WIDTHS];
22
+ return [...new Set(filtered)].sort((a, b) => a - b);
23
+ }
24
+
25
+ function normalizeFormats(input) {
26
+ if (!Array.isArray(input)) return [...DEFAULT_FORMATS];
27
+ const filtered = input
28
+ .map((v) => (typeof v === 'string' ? v.trim().toLowerCase() : ''))
29
+ .filter((v) => v && FORMAT_OUTPUT_EXT[v]);
30
+ if (!filtered.length) return [...DEFAULT_FORMATS];
31
+ return [...new Set(filtered)];
32
+ }
33
+
34
+ function normalizeQuality(input) {
35
+ const merged = { ...DEFAULT_QUALITY };
36
+ if (input && typeof input === 'object') {
37
+ for (const [k, v] of Object.entries(input)) {
38
+ const n = Number.parseInt(v, 10);
39
+ if (Number.isFinite(n) && n >= 1 && n <= 100) merged[k] = n;
40
+ }
41
+ }
42
+ return merged;
43
+ }
44
+
45
+ export function resolveImageVariantConfig(siteConfig) {
46
+ const block = (siteConfig?.imageVariants && typeof siteConfig.imageVariants === 'object')
47
+ ? siteConfig.imageVariants
48
+ : {};
49
+ return {
50
+ widths: normalizeWidths(block.widths),
51
+ formats: normalizeFormats(block.formats),
52
+ quality: normalizeQuality(block.quality),
53
+ };
54
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Public API for the image-variant pipeline.
3
+ *
4
+ * Used by:
5
+ * - The Vite plugin (vite-plugin.js) — buildStart hook + dev watcher.
6
+ * - The migration helper (scripts/migrate-image-variants.mjs) — reads
7
+ * `_source/` presence to know whether to migrate.
8
+ *
9
+ * The pipeline runs at build/dev time, generates variants to
10
+ * node_modules/.cache/@koehler8/cms/image-variants/{siteKey}/, and never
11
+ * touches the site's source tree.
12
+ */
13
+
14
+ export { resolveImageVariantConfig, DEFAULT_WIDTHS, DEFAULT_FORMATS, DEFAULT_QUALITY } from './config.js';
15
+ export { classifyImageFile, SOURCE_EXTENSIONS, FORMAT_OUTPUT_EXT } from './classifier.js';
16
+ export { planVariantJobsFromFlatDir, walkRecursive } from './planner.js';
17
+ export { renderOutput } from './renderer.js';
18
+ export { readManifest, writeManifest, configsEqual, MANIFEST_VERSION } from './manifest.js';
19
+ export { computeVariantCacheDir, siteKeyFor } from './cache.js';
20
+ export { reconcileVariantCache } from './reconcile.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cache manifest: persisted record of what's in the variant cache directory.
3
+ *
4
+ * Format:
5
+ * {
6
+ * version: 1,
7
+ * config: { widths, formats, quality },
8
+ * sources: {
9
+ * "img/logo.png": { mtimeMs, variants: ["assets/img/logo-320.webp", ...] },
10
+ * ...
11
+ * }
12
+ * }
13
+ *
14
+ * Used by reconcileVariantCache to:
15
+ * - Skip regeneration when source mtime is unchanged AND config matches.
16
+ * - Detect orphaned entries (source removed) and delete their cached files.
17
+ * - Force a full regen when config changes (e.g. user added a new width).
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+
22
+ export const MANIFEST_VERSION = 1;
23
+
24
+ export function readManifest(manifestPath) {
25
+ if (!fs.existsSync(manifestPath)) return null;
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
28
+ if (parsed && parsed.version === MANIFEST_VERSION) return parsed;
29
+ return null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function writeManifest(manifestPath, manifest) {
36
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
37
+ }
38
+
39
+ /**
40
+ * Deep equality check for image-variant config blocks. Returns true when
41
+ * widths, formats, and quality (per-format) all match. Order-insensitive
42
+ * for arrays.
43
+ */
44
+ export function configsEqual(a, b) {
45
+ if (!a || !b) return false;
46
+ if (!arraysEqualUnordered(a.widths, b.widths)) return false;
47
+ if (!arraysEqualUnordered(a.formats, b.formats)) return false;
48
+ return objectsEqual(a.quality, b.quality);
49
+ }
50
+
51
+ function arraysEqualUnordered(a, b) {
52
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
53
+ if (a.length !== b.length) return false;
54
+ const sa = [...a].sort();
55
+ const sb = [...b].sort();
56
+ for (let i = 0; i < sa.length; i += 1) {
57
+ if (sa[i] !== sb[i]) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ function objectsEqual(a, b) {
63
+ if (!a || !b) return a === b;
64
+ const ak = Object.keys(a);
65
+ const bk = Object.keys(b);
66
+ if (ak.length !== bk.length) return false;
67
+ for (const k of ak) {
68
+ if (a[k] !== b[k]) return false;
69
+ }
70
+ return true;
71
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Walk site/assets/img/** (recursive), classify each file, and build a list
3
+ * of variant-generation jobs.
4
+ *
5
+ * Each job is { sourceKey, sourcePath, outputs: [{ width, format, cacheRelPath }] }
6
+ * where:
7
+ * sourceKey - posix path relative to site/assets/ (e.g. "img/logo.png")
8
+ * sourcePath - absolute disk path of the original
9
+ * cacheRelPath - posix path relative to the cache dir root, including the
10
+ * `assets/img/` parent — so that the cache directory mirrors
11
+ * the site's `assets/` layout. The `assets/` parent is
12
+ * load-bearing: createAssetResolver's normalizeAssetKey
13
+ * strips up to and including `assets/`, so cache entries
14
+ * surface in the URL map under the same `img/...` keys as
15
+ * flat-dir variants would.
16
+ *
17
+ * SVGs are originals but produce no jobs (no variant generation).
18
+ * Files classified as 'variant' (leftover from the old pipeline) or 'ignore'
19
+ * are skipped.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ import { classifyImageFile, FORMAT_OUTPUT_EXT } from './classifier.js';
26
+
27
+ export function* walkRecursive(dir) {
28
+ if (!fs.existsSync(dir)) return;
29
+ const stack = [dir];
30
+ while (stack.length) {
31
+ const cur = stack.pop();
32
+ for (const entry of fs.readdirSync(cur, { withFileTypes: true })) {
33
+ const full = path.join(cur, entry.name);
34
+ if (entry.isDirectory()) {
35
+ stack.push(full);
36
+ continue;
37
+ }
38
+ if (entry.isFile()) yield full;
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {object} args
45
+ * @param {string} args.siteImgDir - absolute path to site/assets/img/
46
+ * @param {{ widths: number[], formats: string[] }} args.config
47
+ * @returns {Array<{sourceKey: string, sourcePath: string, outputs: Array<{width: number, format: string, cacheRelPath: string}>}>}
48
+ */
49
+ export function planVariantJobsFromFlatDir({ siteImgDir, config }) {
50
+ if (!fs.existsSync(siteImgDir)) return [];
51
+
52
+ const configWidths = new Set(config.widths);
53
+ const jobs = [];
54
+
55
+ for (const sourcePath of walkRecursive(siteImgDir)) {
56
+ const relFromImg = path.relative(siteImgDir, sourcePath);
57
+
58
+ if (relFromImg.split(path.sep)[0] === '_source') continue;
59
+
60
+ const cls = classifyImageFile(relFromImg, configWidths);
61
+ if (cls !== 'original') continue;
62
+
63
+ const ext = path.extname(relFromImg).toLowerCase();
64
+ if (ext === '.svg') continue;
65
+
66
+ const parsed = path.parse(relFromImg);
67
+ const subdir = parsed.dir;
68
+ const baseName = parsed.name;
69
+
70
+ const outputs = [];
71
+ for (const width of config.widths) {
72
+ for (const format of config.formats) {
73
+ const outExt = FORMAT_OUTPUT_EXT[format];
74
+ if (!outExt) continue;
75
+ const variantName = `${baseName}-${width}.${outExt}`;
76
+ const cacheRelPath = path.posix.join(
77
+ 'assets',
78
+ 'img',
79
+ subdir.split(path.sep).join('/'),
80
+ variantName,
81
+ );
82
+ outputs.push({ width, format, cacheRelPath });
83
+ }
84
+ }
85
+
86
+ const sourceKey = path.posix.join(
87
+ 'img',
88
+ relFromImg.split(path.sep).join('/'),
89
+ );
90
+
91
+ jobs.push({ sourceKey, sourcePath, outputs });
92
+ }
93
+
94
+ return jobs;
95
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Reconcile the variant cache directory against the planned jobs:
3
+ * - Generate variants whose source mtime is newer than cached, or whose
4
+ * cached file no longer exists, or when the config block has changed.
5
+ * - Skip jobs whose cached variants are all up-to-date.
6
+ * - Delete cached variants for sources that no longer exist (orphans).
7
+ * - Write the new manifest at the end.
8
+ *
9
+ * Sharp is imported lazily — only when there is actual rendering work to do.
10
+ * This keeps dev-mode cold-start fast when the cache is fully populated.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { readManifest, writeManifest, configsEqual, MANIFEST_VERSION } from './manifest.js';
17
+ import { renderOutput } from './renderer.js';
18
+
19
+ /**
20
+ * @param {object} args
21
+ * @param {Array} args.jobs - planVariantJobsFromFlatDir output
22
+ * @param {string} args.cacheDir - absolute path to the variant cache root
23
+ * @param {string} args.manifestPath - absolute path to .manifest.json
24
+ * @param {{widths, formats, quality}} args.currentConfig
25
+ * @param {(msg: string) => void} [args.log]
26
+ * @returns {Promise<{generated: number, skipped: number, evicted: number}>}
27
+ */
28
+ export async function reconcileVariantCache({
29
+ jobs,
30
+ cacheDir,
31
+ manifestPath,
32
+ currentConfig,
33
+ log = () => {},
34
+ }) {
35
+ await fs.promises.mkdir(cacheDir, { recursive: true });
36
+
37
+ const saved = readManifest(manifestPath);
38
+ const configChanged = !saved || !configsEqual(saved.config, currentConfig);
39
+
40
+ const currentSourceKeys = new Set(jobs.map((j) => j.sourceKey));
41
+
42
+ let evicted = 0;
43
+ if (saved) {
44
+ for (const oldKey of Object.keys(saved.sources || {})) {
45
+ if (currentSourceKeys.has(oldKey)) continue;
46
+ const oldEntry = saved.sources[oldKey];
47
+ for (const variantRel of (oldEntry.variants || [])) {
48
+ const fullPath = path.join(cacheDir, variantRel);
49
+ if (fs.existsSync(fullPath)) {
50
+ await fs.promises.rm(fullPath, { force: true });
51
+ evicted += 1;
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ const plannedRenders = [];
58
+ for (const job of jobs) {
59
+ const previous = saved?.sources?.[job.sourceKey];
60
+ let sourceMtime;
61
+ try {
62
+ sourceMtime = fs.statSync(job.sourcePath).mtimeMs;
63
+ } catch {
64
+ continue;
65
+ }
66
+
67
+ const allCached = !configChanged
68
+ && previous
69
+ && previous.mtimeMs >= sourceMtime
70
+ && job.outputs.every((o) => fs.existsSync(path.join(cacheDir, o.cacheRelPath)));
71
+
72
+ if (allCached) {
73
+ plannedRenders.push({ job, sourceMtime, render: false });
74
+ } else {
75
+ plannedRenders.push({ job, sourceMtime, render: true });
76
+ }
77
+ }
78
+
79
+ const renderJobs = plannedRenders.filter((p) => p.render);
80
+
81
+ let generated = 0;
82
+ let skipped = 0;
83
+ let sharp = null;
84
+
85
+ if (renderJobs.length > 0) {
86
+ const mod = await import('sharp');
87
+ sharp = mod.default;
88
+ }
89
+
90
+ for (const { job, sourceMtime, render } of plannedRenders) {
91
+ if (!render) {
92
+ skipped += job.outputs.length;
93
+ continue;
94
+ }
95
+ for (const output of job.outputs) {
96
+ const outPath = path.join(cacheDir, output.cacheRelPath);
97
+ try {
98
+ await renderOutput(sharp, job.sourcePath, { ...output, outPath }, currentConfig);
99
+ generated += 1;
100
+ } catch (err) {
101
+ log(`failed ${output.cacheRelPath}: ${err.message}`);
102
+ }
103
+ }
104
+ void sourceMtime;
105
+ }
106
+
107
+ const newManifest = {
108
+ version: MANIFEST_VERSION,
109
+ config: currentConfig,
110
+ sources: {},
111
+ };
112
+ for (const { job, sourceMtime } of plannedRenders) {
113
+ newManifest.sources[job.sourceKey] = {
114
+ mtimeMs: sourceMtime,
115
+ variants: job.outputs.map((o) => o.cacheRelPath),
116
+ };
117
+ }
118
+ writeManifest(manifestPath, newManifest);
119
+
120
+ if (generated || evicted) {
121
+ log(`sources: ${jobs.length}, generated: ${generated}, skipped: ${skipped}, evicted: ${evicted}`);
122
+ }
123
+
124
+ return { generated, skipped, evicted };
125
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sharp-powered variant renderer. Reads a source image, resizes to the
3
+ * requested width (clamped to source size — no enlargement), and writes
4
+ * the encoded output to outPath.
5
+ *
6
+ * Pure function: takes paths and config, performs IO, returns nothing.
7
+ * Sharp is imported lazily by the caller (see reconcile.js) so dev-mode
8
+ * cold start doesn't pay sharp's native-binding load cost when there's
9
+ * nothing to render.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ export async function renderOutput(sharp, sourcePath, output, config) {
16
+ const { width, format, outPath } = output;
17
+ await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
18
+
19
+ let pipeline = sharp(sourcePath, { failOn: 'truncated' }).rotate();
20
+ pipeline = pipeline.resize({ width, withoutEnlargement: true });
21
+
22
+ switch (format) {
23
+ case 'avif':
24
+ pipeline = pipeline.avif({ quality: config.quality.avif });
25
+ break;
26
+ case 'webp':
27
+ pipeline = pipeline.webp({ quality: config.quality.webp });
28
+ break;
29
+ case 'jpg':
30
+ case 'jpeg':
31
+ pipeline = pipeline.jpeg({ quality: config.quality.jpg, mozjpeg: true });
32
+ break;
33
+ case 'png':
34
+ pipeline = pipeline.png();
35
+ break;
36
+ default:
37
+ throw new Error(`unsupported format: ${format}`);
38
+ }
39
+
40
+ await pipeline.toFile(outPath);
41
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Per-site migration helper for the cms@1.0.0-beta.27 in-plugin image
3
+ * variant pipeline.
4
+ *
5
+ * Run from a site directory:
6
+ * npx @koehler8/cms migrate-image-variants
7
+ *
8
+ * What it does (idempotent — running it on an already-migrated site is a
9
+ * series of no-ops):
10
+ * 1. Validate this is a cms-using site (check package.json deps).
11
+ * 2. Promote site/assets/img/_source/* originals to flat dir, delete
12
+ * duplicate copies left over from beta.26's auto-copy step, then
13
+ * remove the now-empty _source/ directory.
14
+ * 3. git rm --cached any committed variants matching {name}-{N}.{ext}
15
+ * and append the patterns to .gitignore for future-proofing.
16
+ * 4. Drop the `generate:image-variants` entry from package.json scripts.
17
+ * 5. Drop the `npm run generate:image-variants` line from amplify.yml.
18
+ * 6. Bump @koehler8/cms range to ^1.0.0-beta.27.
19
+ * 7. Run npm install --prefer-offline --no-audit --no-fund.
20
+ * 8. Print verification instructions.
21
+ *
22
+ * Does NOT auto-commit or auto-push — humans review the diff.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { execSync } from 'node:child_process';
28
+
29
+ const RESET = '\x1b[0m';
30
+ const DIM = '\x1b[2m';
31
+ const BOLD = '\x1b[1m';
32
+ const GREEN = '\x1b[32m';
33
+ const YELLOW = '\x1b[33m';
34
+ const RED = '\x1b[31m';
35
+
36
+ const TARGET_CMS_VERSION = '^1.0.0-beta.27';
37
+ const VARIANT_NAME_RE = /-\d+\.(?:avif|webp|jpg|jpeg)$/i;
38
+
39
+ function log(msg) {
40
+ console.log(msg);
41
+ }
42
+ function step(n, msg) {
43
+ console.log(`${BOLD}[${n}]${RESET} ${msg}`);
44
+ }
45
+ function ok(msg) {
46
+ console.log(` ${GREEN}✓${RESET} ${msg}`);
47
+ }
48
+ function info(msg) {
49
+ console.log(` ${DIM}·${RESET} ${DIM}${msg}${RESET}`);
50
+ }
51
+ function warn(msg) {
52
+ console.log(` ${YELLOW}!${RESET} ${msg}`);
53
+ }
54
+ function fail(msg) {
55
+ console.log(` ${RED}✗${RESET} ${RED}${msg}${RESET}`);
56
+ }
57
+
58
+ function readJsonOrNull(p) {
59
+ if (!fs.existsSync(p)) return null;
60
+ try {
61
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function writeJson(p, obj) {
68
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
69
+ }
70
+
71
+ function* walkRecursive(dir) {
72
+ if (!fs.existsSync(dir)) return;
73
+ const stack = [dir];
74
+ while (stack.length) {
75
+ const cur = stack.pop();
76
+ for (const entry of fs.readdirSync(cur, { withFileTypes: true })) {
77
+ const full = path.join(cur, entry.name);
78
+ if (entry.isDirectory()) {
79
+ stack.push(full);
80
+ continue;
81
+ }
82
+ if (entry.isFile()) yield full;
83
+ }
84
+ }
85
+ }
86
+
87
+ function gitTryRm(siteDir, files) {
88
+ if (!files.length) return 0;
89
+ try {
90
+ const args = files.map((f) => `'${path.relative(siteDir, f).replace(/'/g, "'\\''")}'`).join(' ');
91
+ execSync(`git rm --cached --quiet ${args}`, { cwd: siteDir, stdio: 'pipe' });
92
+ return files.length;
93
+ } catch {
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ function isCmsSite(siteDir) {
99
+ const pkg = readJsonOrNull(path.join(siteDir, 'package.json'));
100
+ if (!pkg) return false;
101
+ return Boolean(pkg.dependencies?.['@koehler8/cms'] || pkg.devDependencies?.['@koehler8/cms']);
102
+ }
103
+
104
+ function migrateSourceDir(siteDir) {
105
+ const imgDir = path.join(siteDir, 'assets', 'img');
106
+ const altImgDir = path.join(siteDir, 'site', 'assets', 'img');
107
+ const target = fs.existsSync(altImgDir) ? altImgDir : imgDir;
108
+ const sourceDir = path.join(target, '_source');
109
+ if (!fs.existsSync(sourceDir)) {
110
+ info('no site/assets/img/_source/ to migrate');
111
+ return;
112
+ }
113
+ let promoted = 0;
114
+ let deletedDup = 0;
115
+ for (const sourcePath of walkRecursive(sourceDir)) {
116
+ const rel = path.relative(sourceDir, sourcePath);
117
+ const flatPath = path.join(target, rel);
118
+ if (fs.existsSync(flatPath)) {
119
+ fs.rmSync(sourcePath, { force: true });
120
+ deletedDup += 1;
121
+ } else {
122
+ fs.mkdirSync(path.dirname(flatPath), { recursive: true });
123
+ fs.renameSync(sourcePath, flatPath);
124
+ promoted += 1;
125
+ }
126
+ }
127
+ function removeEmptyDirs(dir) {
128
+ if (!fs.existsSync(dir)) return;
129
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
130
+ if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name));
131
+ }
132
+ if (!fs.readdirSync(dir).length) {
133
+ fs.rmdirSync(dir);
134
+ }
135
+ }
136
+ removeEmptyDirs(sourceDir);
137
+ if (promoted) ok(`promoted ${promoted} originals from _source/ to flat dir`);
138
+ if (deletedDup) ok(`removed ${deletedDup} duplicate copies left in _source/`);
139
+ if (!fs.existsSync(sourceDir)) ok('removed empty _source/ directory');
140
+ }
141
+
142
+ function untrackVariants(siteDir) {
143
+ const candidates = [
144
+ path.join(siteDir, 'assets', 'img'),
145
+ path.join(siteDir, 'site', 'assets', 'img'),
146
+ ].filter((d) => fs.existsSync(d));
147
+ const variantPaths = [];
148
+ for (const dir of candidates) {
149
+ for (const f of walkRecursive(dir)) {
150
+ if (f.includes(`${path.sep}_source${path.sep}`)) continue;
151
+ if (VARIANT_NAME_RE.test(f)) variantPaths.push(f);
152
+ }
153
+ }
154
+ if (!variantPaths.length) {
155
+ info('no committed variants to untrack');
156
+ return;
157
+ }
158
+ const untracked = gitTryRm(siteDir, variantPaths);
159
+ if (untracked) {
160
+ ok(`git rm --cached on ${untracked} variant file(s) (kept on disk)`);
161
+ for (const v of variantPaths) {
162
+ try { fs.rmSync(v, { force: true }); } catch { /* noop */ }
163
+ }
164
+ ok(`removed ${variantPaths.length} variant file(s) from working tree`);
165
+ } else {
166
+ warn(`found ${variantPaths.length} variant-shaped files but git rm failed (not in git or git not present); deleting from disk anyway`);
167
+ for (const v of variantPaths) {
168
+ try { fs.rmSync(v, { force: true }); } catch { /* noop */ }
169
+ }
170
+ }
171
+ }
172
+
173
+ function appendGitignore(siteDir) {
174
+ const gi = path.join(siteDir, '.gitignore');
175
+ const block = [
176
+ '',
177
+ '# Image variants are generated at build time into node_modules/.cache/',
178
+ '# (cms@1.0.0-beta.27+ pipeline). Defensive ignore so accidental local',
179
+ '# variants in the source tree don’t get committed.',
180
+ 'site/assets/img/*-[0-9]*.avif',
181
+ 'site/assets/img/*-[0-9]*.webp',
182
+ 'site/assets/img/*-[0-9]*.jpg',
183
+ 'site/assets/img/*-[0-9]*.jpeg',
184
+ 'site/assets/img/**/*-[0-9]*.avif',
185
+ 'site/assets/img/**/*-[0-9]*.webp',
186
+ 'site/assets/img/**/*-[0-9]*.jpg',
187
+ 'site/assets/img/**/*-[0-9]*.jpeg',
188
+ 'assets/img/*-[0-9]*.avif',
189
+ 'assets/img/*-[0-9]*.webp',
190
+ 'assets/img/*-[0-9]*.jpg',
191
+ 'assets/img/*-[0-9]*.jpeg',
192
+ '',
193
+ ].join('\n');
194
+ let cur = '';
195
+ if (fs.existsSync(gi)) cur = fs.readFileSync(gi, 'utf-8');
196
+ if (cur.includes('# Image variants are generated at build time')) {
197
+ info('.gitignore already has image-variant patterns');
198
+ return;
199
+ }
200
+ fs.writeFileSync(gi, cur + (cur.endsWith('\n') ? '' : '\n') + block);
201
+ ok('appended image-variant patterns to .gitignore');
202
+ }
203
+
204
+ function dropPackageJsonScript(siteDir) {
205
+ const pkgPath = path.join(siteDir, 'package.json');
206
+ const pkg = readJsonOrNull(pkgPath);
207
+ if (!pkg) {
208
+ fail('package.json missing');
209
+ return;
210
+ }
211
+ if (pkg.scripts?.['generate:image-variants']) {
212
+ delete pkg.scripts['generate:image-variants'];
213
+ writeJson(pkgPath, pkg);
214
+ ok('removed generate:image-variants script from package.json');
215
+ } else {
216
+ info('package.json scripts already clean');
217
+ }
218
+ }
219
+
220
+ function dropAmplifyStep(siteDir) {
221
+ const ymlPath = path.join(siteDir, 'amplify.yml');
222
+ if (!fs.existsSync(ymlPath)) {
223
+ info('no amplify.yml present');
224
+ return;
225
+ }
226
+ const cur = fs.readFileSync(ymlPath, 'utf-8');
227
+ const next = cur
228
+ .split(/\r?\n/)
229
+ .filter((line) => !/^\s*-\s*npm\s+run\s+generate:image-variants\s*$/.test(line))
230
+ .join('\n');
231
+ if (next === cur) {
232
+ info('amplify.yml already clean');
233
+ return;
234
+ }
235
+ fs.writeFileSync(ymlPath, next);
236
+ ok('removed generate:image-variants step from amplify.yml');
237
+ }
238
+
239
+ function bumpCmsVersion(siteDir) {
240
+ const pkgPath = path.join(siteDir, 'package.json');
241
+ const pkg = readJsonOrNull(pkgPath);
242
+ if (!pkg) return;
243
+ let bumped = false;
244
+ if (pkg.dependencies?.['@koehler8/cms'] && pkg.dependencies['@koehler8/cms'] !== TARGET_CMS_VERSION) {
245
+ pkg.dependencies['@koehler8/cms'] = TARGET_CMS_VERSION;
246
+ bumped = true;
247
+ }
248
+ if (pkg.devDependencies?.['@koehler8/cms'] && pkg.devDependencies['@koehler8/cms'] !== TARGET_CMS_VERSION) {
249
+ pkg.devDependencies['@koehler8/cms'] = TARGET_CMS_VERSION;
250
+ bumped = true;
251
+ }
252
+ if (bumped) {
253
+ writeJson(pkgPath, pkg);
254
+ ok(`bumped @koehler8/cms to ${TARGET_CMS_VERSION}`);
255
+ } else {
256
+ info(`@koehler8/cms already at ${TARGET_CMS_VERSION}`);
257
+ }
258
+ }
259
+
260
+ function runNpmInstall(siteDir) {
261
+ try {
262
+ execSync('npm install --prefer-offline --no-audit --no-fund', {
263
+ cwd: siteDir,
264
+ stdio: 'inherit',
265
+ });
266
+ ok('npm install completed');
267
+ } catch (err) {
268
+ fail(`npm install failed: ${err.message}`);
269
+ process.exitCode = 1;
270
+ }
271
+ }
272
+
273
+ function main() {
274
+ const siteDir = path.resolve(process.cwd());
275
+ log('');
276
+ log(`${BOLD}cms image-variant migration${RESET}`);
277
+ log(`${DIM}site:${RESET} ${siteDir}`);
278
+ log('');
279
+
280
+ if (!isCmsSite(siteDir)) {
281
+ fail('Not a @koehler8/cms-consuming site (no @koehler8/cms in package.json deps).');
282
+ process.exit(1);
283
+ }
284
+
285
+ step(1, 'Promote originals from _source/ to flat dir');
286
+ migrateSourceDir(siteDir);
287
+
288
+ step(2, 'Untrack and remove committed variants');
289
+ untrackVariants(siteDir);
290
+
291
+ step(3, 'Append image-variant patterns to .gitignore');
292
+ appendGitignore(siteDir);
293
+
294
+ step(4, 'Remove generate:image-variants script from package.json');
295
+ dropPackageJsonScript(siteDir);
296
+
297
+ step(5, 'Remove generate:image-variants step from amplify.yml');
298
+ dropAmplifyStep(siteDir);
299
+
300
+ step(6, `Bump @koehler8/cms to ${TARGET_CMS_VERSION}`);
301
+ bumpCmsVersion(siteDir);
302
+
303
+ step(7, 'npm install');
304
+ runNpmInstall(siteDir);
305
+
306
+ log('');
307
+ log(`${BOLD}${GREEN}Done.${RESET}`);
308
+ log('');
309
+ log('Next steps:');
310
+ log(` ${DIM}·${RESET} npm run build:ssg # verify variants render in dist/`);
311
+ log(` ${DIM}·${RESET} git diff # review the changes`);
312
+ log(` ${DIM}·${RESET} git add -A && git commit && git push`);
313
+ log('');
314
+ }
315
+
316
+ main();
package/vite-plugin.js CHANGED
@@ -17,6 +17,12 @@ import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
17
17
  import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
18
18
  import { buildWebAppManifest } from './src/utils/webAppManifest.js';
19
19
  import { sha256Hex } from './src/utils/sha256.js';
20
+ import {
21
+ computeVariantCacheDir,
22
+ resolveImageVariantConfig,
23
+ planVariantJobsFromFlatDir,
24
+ reconcileVariantCache,
25
+ } from './scripts/image-variants/index.js';
20
26
 
21
27
  // ---- Helpers ----
22
28
 
@@ -349,6 +355,8 @@ export default function cmsPlugin(options = {}) {
349
355
  let metadata;
350
356
  let tempIndexPath;
351
357
  let tempEntryPath;
358
+ let variantCacheDir;
359
+ let variantManifestPath;
352
360
 
353
361
  const writeTempFiles = () => {
354
362
  // Write entry file (with optional external theme registrations)
@@ -453,6 +461,8 @@ export default function cmsPlugin(options = {}) {
453
461
  siteRoot = path.dirname(siteDir);
454
462
  tempIndexPath = path.join(siteRoot, 'index.html');
455
463
  tempEntryPath = path.join(siteRoot, ENTRY_FILENAME);
464
+ variantCacheDir = computeVariantCacheDir(siteRoot, siteDir);
465
+ variantManifestPath = path.join(variantCacheDir, '.manifest.json');
456
466
 
457
467
  // Load site config at config-resolution time (needed for SSG routes + HTML injection)
458
468
  const contentDir = path.join(siteDir, 'content');
@@ -498,6 +508,7 @@ export default function cmsPlugin(options = {}) {
498
508
  alias: {
499
509
  '@cms-site': siteDir,
500
510
  '@cms-framework': frameworkRoot,
511
+ '@cms-variant-cache': variantCacheDir,
501
512
  '@': path.join(frameworkRoot, 'src'),
502
513
  '@extensions': extensionsDir,
503
514
  },
@@ -722,6 +733,18 @@ export default modules;
722
733
  }
723
734
 
724
735
  if (virtualId === VIRTUAL_ASSETS) {
736
+ // Three globs:
737
+ // shared — framework-bundled assets
738
+ // site — site/assets/** flat-dir originals (everything the
739
+ // author put on disk, including any leftover variants
740
+ // from the old pipeline that haven't been cleaned up)
741
+ // variants — generated variants from node_modules/.cache/
742
+ // @koehler8/cms/image-variants/{siteKey}/assets/**.
743
+ // The cache mirrors the site's `assets/` layout so
744
+ // normalizeAssetKey strips the same `assets/` prefix
745
+ // and surfaces variants under canonical `img/...` keys.
746
+ // Spread order: variants first so site flat-dir wins on collision —
747
+ // a hand-placed file in site/assets/img/ overrides the cached variant.
725
748
  return `
726
749
  import { createAssetResolver } from '@koehler8/cms/utils/assetResolver';
727
750
  const shared = import.meta.glob('@cms-framework/src/assets/**/*', {
@@ -729,15 +752,17 @@ const shared = import.meta.glob('@cms-framework/src/assets/**/*', {
729
752
  query: '?url',
730
753
  import: 'default',
731
754
  });
732
- const site = import.meta.glob([
733
- '@cms-site/assets/**/*',
734
- '!@cms-site/assets/_source/**',
735
- ], {
755
+ const site = import.meta.glob('@cms-site/assets/**/*', {
736
756
  eager: true,
737
757
  query: '?url',
738
758
  import: 'default',
739
759
  });
740
- const resolver = createAssetResolver(shared, site);
760
+ const variants = import.meta.glob('@cms-variant-cache/assets/**/*', {
761
+ eager: true,
762
+ query: '?url',
763
+ import: 'default',
764
+ });
765
+ const resolver = createAssetResolver(shared, { ...variants, ...site });
741
766
  export const resolveAssetUrl = resolver.resolveAssetUrl;
742
767
  export const resolveAsset = resolver.resolveAsset;
743
768
  export const resolveMedia = resolver.resolveMedia;
@@ -765,6 +790,50 @@ export const assetUrlMap = resolver.assetUrlMap;
765
790
  return { code: transformed, map: null };
766
791
  },
767
792
 
793
+ async buildStart() {
794
+ // Generate image variants into the cache dir BEFORE the VIRTUAL_ASSETS
795
+ // load() block runs its import.meta.glob — that glob picks up the cache
796
+ // files and surfaces them as Vite-pipelined assets (hashed, emitted to
797
+ // dist/assets/) at build, or served straight from disk during dev.
798
+ //
799
+ // Errors here are warned, not thrown — a broken variant pipeline should
800
+ // not kill the entire build. Sites without source images (no img dir,
801
+ // empty img dir) are no-ops.
802
+ const siteImgDir = path.join(siteDir, 'assets', 'img');
803
+
804
+ // Strict deprecation: warn loudly if the legacy _source/ dir exists.
805
+ // Pipeline only reads the flat dir — sites that haven't migrated will
806
+ // see broken image URLs until they run `npx @koehler8/cms migrate-image-variants`.
807
+ const legacySourceDir = path.join(siteImgDir, '_source');
808
+ if (fs.existsSync(legacySourceDir)) {
809
+ this.warn(
810
+ `[@koehler8/cms] Found legacy site/assets/img/_source/ directory.`
811
+ + ` As of beta.27 image originals live directly in site/assets/img/`
812
+ + ` and variants generate at build time into node_modules/.cache/.`
813
+ + ` Run \`npx @koehler8/cms migrate-image-variants\` from the site`
814
+ + ` directory to migrate. The pipeline is NOT reading from _source/`
815
+ + ` — image URLs will 404 until you migrate.`,
816
+ );
817
+ }
818
+
819
+ try {
820
+ const variantConfig = resolveImageVariantConfig(siteConfig);
821
+ const jobs = planVariantJobsFromFlatDir({
822
+ siteImgDir,
823
+ config: variantConfig,
824
+ });
825
+ await reconcileVariantCache({
826
+ jobs,
827
+ cacheDir: variantCacheDir,
828
+ manifestPath: variantManifestPath,
829
+ currentConfig: variantConfig,
830
+ log: (msg) => this.warn(`[@koehler8/cms image-variants] ${msg}`),
831
+ });
832
+ } catch (err) {
833
+ this.warn(`[@koehler8/cms image-variants] generation failed: ${err.message}`);
834
+ }
835
+ },
836
+
768
837
  configResolved(resolvedConfig) {
769
838
  // Write temp files as soon as config is resolved so vite-ssg's
770
839
  // detectEntry (which runs before Vite's build lifecycle) finds them.
@@ -831,8 +900,55 @@ export const assetUrlMap = resolver.assetUrlMap;
831
900
  // Watch site content for HMR
832
901
  server.watcher.add(siteDir);
833
902
 
903
+ // Image-variant regen on source-image change. Debounced so bulk
904
+ // drag-and-drop adds don't trigger N pipeline runs. Writes to the
905
+ // cache dir don't re-trigger because we early-return on those events.
906
+ const siteImgDir = path.join(siteDir, 'assets', 'img');
907
+ let pendingVariantRegen = null;
908
+ const scheduleVariantRegen = () => {
909
+ if (pendingVariantRegen) clearTimeout(pendingVariantRegen);
910
+ pendingVariantRegen = setTimeout(async () => {
911
+ pendingVariantRegen = null;
912
+ try {
913
+ const variantConfig = resolveImageVariantConfig(siteConfig);
914
+ const jobs = planVariantJobsFromFlatDir({
915
+ siteImgDir,
916
+ config: variantConfig,
917
+ });
918
+ await reconcileVariantCache({
919
+ jobs,
920
+ cacheDir: variantCacheDir,
921
+ manifestPath: variantManifestPath,
922
+ currentConfig: variantConfig,
923
+ log: (msg) => console.log(`[@koehler8/cms image-variants] ${msg}`),
924
+ });
925
+ // Invalidate the asset-resolver virtual module so its
926
+ // import.meta.glob re-evaluates and the new variant URLs land
927
+ // in the asset map. The full-reload below picks them up.
928
+ const assetMod = server.moduleGraph.getModuleById(resolved(VIRTUAL_ASSETS));
929
+ if (assetMod) server.moduleGraph.invalidateModule(assetMod);
930
+ server.ws.send({ type: 'full-reload' });
931
+ } catch (err) {
932
+ console.warn(`[@koehler8/cms image-variants] regen failed: ${err.message}`);
933
+ }
934
+ }, 250);
935
+ };
936
+ const handleImageEvent = (file) => {
937
+ if (typeof file !== 'string') return;
938
+ // Ignore writes our own pipeline made into the cache dir.
939
+ if (variantCacheDir && file.startsWith(variantCacheDir)) return;
940
+ if (!file.startsWith(siteImgDir)) return;
941
+ scheduleVariantRegen();
942
+ };
943
+ server.watcher.on('add', handleImageEvent);
944
+ server.watcher.on('change', handleImageEvent);
945
+ server.watcher.on('unlink', handleImageEvent);
946
+
834
947
  // Invalidate virtual modules when site content changes
835
948
  const invalidate = (file) => {
949
+ // Don't double-handle image events — the dedicated handler above
950
+ // already invalidates VIRTUAL_ASSETS and triggers full-reload.
951
+ if (variantCacheDir && file.startsWith(variantCacheDir)) return;
836
952
  if (!file.startsWith(siteDir)) return;
837
953
  for (const id of VIRTUAL_IDS) {
838
954
  const mod = server.moduleGraph.getModuleById(resolved(id));
@@ -1,33 +0,0 @@
1
- #!/usr/bin/env node
2
- // Wrapper that runs the framework's generate-image-variants.js from the
3
- // consuming site repo's CWD. Accepts --site-dir <path> to locate site assets;
4
- // defaults to ./site.
5
-
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = path.dirname(__filename);
11
-
12
- const args = process.argv.slice(2);
13
- let siteDir = './site';
14
- for (let i = 0; i < args.length; i++) {
15
- if (args[i] === '--site-dir' && args[i + 1]) {
16
- siteDir = args[i + 1];
17
- i++;
18
- }
19
- }
20
-
21
- const resolvedSiteDir = path.resolve(process.cwd(), siteDir);
22
- process.env.CMS_SITE_DIR = resolvedSiteDir;
23
-
24
- const { generateVariants } = await import(
25
- path.resolve(__dirname, '..', 'scripts', 'generate-image-variants.js')
26
- );
27
-
28
- try {
29
- await generateVariants({ siteDir: resolvedSiteDir });
30
- } catch (err) {
31
- console.error(`❌ ${err.message}`);
32
- process.exit(1);
33
- }
@@ -1,268 +0,0 @@
1
- /**
2
- * Generate responsive image variants from `{siteDir}/assets/img/_source/**`
3
- * into `{siteDir}/assets/img/`.
4
- *
5
- * For each source image (jpg/jpeg/png/webp/avif/tiff), produces:
6
- * {name}-{width}.avif
7
- * {name}-{width}.webp
8
- * {name}-{width}.jpg
9
- * across the configured widths. Subdirectory layout is preserved.
10
- *
11
- * Skips variants whose mtime is newer than the source's mtime — re-running
12
- * the script after a `git pull` only regenerates the images that changed.
13
- *
14
- * Configuration is read from `{siteDir}/content/{baseLocale}/site.json`'s
15
- * optional `imageVariants` block; defaults are sensible for marketing sites.
16
- *
17
- * Usage from a consuming site:
18
- * CMS_SITE_DIR=./site node node_modules/@koehler8/cms/scripts/generate-image-variants.js
19
- *
20
- * Or via the CLI wrapper:
21
- * npx cms-generate-image-variants --site-dir ./site
22
- */
23
-
24
- import fs from 'node:fs';
25
- import path from 'node:path';
26
- import sharp from 'sharp';
27
-
28
- import { inflateFlatConfig } from '../src/utils/inflateFlatConfig.js';
29
-
30
- // ---- Configuration ----
31
-
32
- const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 2560];
33
- const DEFAULT_FORMATS = ['avif', 'webp', 'jpg'];
34
- const DEFAULT_QUALITY = { avif: 60, webp: 75, jpg: 80 };
35
- const SOURCE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.tiff', '.tif']);
36
-
37
- const FORMAT_OUTPUT_EXT = {
38
- avif: 'avif',
39
- webp: 'webp',
40
- jpg: 'jpg',
41
- jpeg: 'jpg',
42
- png: 'png',
43
- };
44
-
45
- function readJsonIfExists(filePath) {
46
- if (!fs.existsSync(filePath)) return null;
47
- try {
48
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- function resolveSiteConfig(siteDir) {
55
- const contentDir = path.join(siteDir, 'content');
56
- const contentConfig = readJsonIfExists(path.join(contentDir, 'content.config.json')) || {};
57
- const baseLocale = contentConfig.baseLocale || 'en';
58
- const sitePath = path.join(contentDir, baseLocale, 'site.json');
59
- const raw = readJsonIfExists(sitePath) || {};
60
- return inflateFlatConfig(raw);
61
- }
62
-
63
- function normalizeWidths(input) {
64
- if (!Array.isArray(input)) return [...DEFAULT_WIDTHS];
65
- const filtered = input
66
- .map((v) => Number.parseInt(v, 10))
67
- .filter((v) => Number.isFinite(v) && v > 0);
68
- if (!filtered.length) return [...DEFAULT_WIDTHS];
69
- return [...new Set(filtered)].sort((a, b) => a - b);
70
- }
71
-
72
- function normalizeFormats(input) {
73
- if (!Array.isArray(input)) return [...DEFAULT_FORMATS];
74
- const filtered = input
75
- .map((v) => (typeof v === 'string' ? v.trim().toLowerCase() : ''))
76
- .filter((v) => v && FORMAT_OUTPUT_EXT[v]);
77
- if (!filtered.length) return [...DEFAULT_FORMATS];
78
- return [...new Set(filtered)];
79
- }
80
-
81
- function normalizeQuality(input) {
82
- const merged = { ...DEFAULT_QUALITY };
83
- if (input && typeof input === 'object') {
84
- for (const [k, v] of Object.entries(input)) {
85
- const n = Number.parseInt(v, 10);
86
- if (Number.isFinite(n) && n >= 1 && n <= 100) merged[k] = n;
87
- }
88
- }
89
- return merged;
90
- }
91
-
92
- // Resolve `imageVariants` config block, with safe defaults.
93
- export function resolveImageVariantConfig(siteConfig) {
94
- const block = (siteConfig?.imageVariants && typeof siteConfig.imageVariants === 'object')
95
- ? siteConfig.imageVariants
96
- : {};
97
- return {
98
- widths: normalizeWidths(block.widths),
99
- formats: normalizeFormats(block.formats),
100
- quality: normalizeQuality(block.quality),
101
- };
102
- }
103
-
104
- // ---- File walking ----
105
-
106
- function* walkSourceImages(sourceDir) {
107
- if (!fs.existsSync(sourceDir)) return;
108
- const stack = [sourceDir];
109
- while (stack.length) {
110
- const dir = stack.pop();
111
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
112
- const full = path.join(dir, entry.name);
113
- if (entry.isDirectory()) {
114
- stack.push(full);
115
- continue;
116
- }
117
- if (!entry.isFile()) continue;
118
- const ext = path.extname(entry.name).toLowerCase();
119
- if (!SOURCE_EXTENSIONS.has(ext)) continue;
120
- yield full;
121
- }
122
- }
123
- }
124
-
125
- // Build the list of (sourcePath, outputs[]) jobs without doing IO yet.
126
- export function planVariantJobs({ siteDir, config }) {
127
- const imgDir = path.join(siteDir, 'assets', 'img');
128
- const sourceDir = path.join(imgDir, '_source');
129
- const jobs = [];
130
- if (!fs.existsSync(sourceDir)) return jobs;
131
-
132
- for (const sourcePath of walkSourceImages(sourceDir)) {
133
- const relFromSource = path.relative(sourceDir, sourcePath);
134
- const parsed = path.parse(relFromSource);
135
- const subdir = parsed.dir;
136
- const baseName = parsed.name;
137
-
138
- const outputs = [];
139
-
140
- // Copy the source to the flat dir under its bare name. Components that
141
- // reference assets by their bare path (`resolveAsset('img/logo.png')`,
142
- // useResponsiveImage's `${basePath}.${format}` fallback) need the
143
- // original present at `site/assets/img/{name}.{ext}`; without this,
144
- // sites that migrate originals into _source/ break every bare-path
145
- // lookup.
146
- outputs.push({
147
- kind: 'copy',
148
- outPath: path.join(imgDir, subdir, parsed.base),
149
- });
150
-
151
- for (const width of config.widths) {
152
- for (const format of config.formats) {
153
- const ext = FORMAT_OUTPUT_EXT[format];
154
- if (!ext) continue;
155
- const outName = `${baseName}-${width}.${ext}`;
156
- const outPath = path.join(imgDir, subdir, outName);
157
- outputs.push({ kind: 'variant', width, format, outPath });
158
- }
159
- }
160
- jobs.push({ sourcePath, outputs });
161
- }
162
- return jobs;
163
- }
164
-
165
- // Apply a per-output sharp transform pipeline.
166
- async function renderOutput(sourcePath, output, config) {
167
- const { width, format, outPath } = output;
168
- await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
169
-
170
- if (output.kind === 'copy') {
171
- await fs.promises.copyFile(sourcePath, outPath);
172
- return;
173
- }
174
-
175
- let pipeline = sharp(sourcePath, { failOn: 'truncated' }).rotate();
176
- pipeline = pipeline.resize({ width, withoutEnlargement: true });
177
- switch (format) {
178
- case 'avif':
179
- pipeline = pipeline.avif({ quality: config.quality.avif });
180
- break;
181
- case 'webp':
182
- pipeline = pipeline.webp({ quality: config.quality.webp });
183
- break;
184
- case 'jpg':
185
- case 'jpeg':
186
- pipeline = pipeline.jpeg({ quality: config.quality.jpg, mozjpeg: true });
187
- break;
188
- case 'png':
189
- pipeline = pipeline.png();
190
- break;
191
- default:
192
- throw new Error(`unsupported format: ${format}`);
193
- }
194
- await pipeline.toFile(outPath);
195
- }
196
-
197
- function isUpToDate(sourcePath, outPath) {
198
- if (!fs.existsSync(outPath)) return false;
199
- try {
200
- const src = fs.statSync(sourcePath).mtimeMs;
201
- const dst = fs.statSync(outPath).mtimeMs;
202
- return dst >= src;
203
- } catch {
204
- return false;
205
- }
206
- }
207
-
208
- // ---- Main ----
209
-
210
- export async function generateVariants({ siteDir, log = console.log, warn = console.warn } = {}) {
211
- if (!siteDir || !fs.existsSync(siteDir)) {
212
- throw new Error(`siteDir does not exist: ${siteDir}`);
213
- }
214
- const siteConfig = resolveSiteConfig(siteDir);
215
- const config = resolveImageVariantConfig(siteConfig);
216
- const jobs = planVariantJobs({ siteDir, config });
217
- if (jobs.length === 0) {
218
- log(`[cms-image-variants] no sources found under ${path.join(siteDir, 'assets', 'img', '_source')}`);
219
- return { generated: 0, skipped: 0, sources: 0 };
220
- }
221
-
222
- let generated = 0;
223
- let skipped = 0;
224
- for (const { sourcePath, outputs } of jobs) {
225
- for (const output of outputs) {
226
- if (isUpToDate(sourcePath, output.outPath)) {
227
- skipped += 1;
228
- continue;
229
- }
230
- try {
231
- await renderOutput(sourcePath, output, config);
232
- generated += 1;
233
- } catch (err) {
234
- warn(`[cms-image-variants] failed ${path.relative(siteDir, output.outPath)}: ${err.message}`);
235
- }
236
- }
237
- }
238
- log(
239
- `[cms-image-variants] sources: ${jobs.length}, generated: ${generated}, skipped (up-to-date): ${skipped}`,
240
- );
241
- return { generated, skipped, sources: jobs.length };
242
- }
243
-
244
- // Allow invocation as `node scripts/generate-image-variants.js` (used by the
245
- // bin wrapper). Reads CMS_SITE_DIR like generate-public-assets does.
246
- const isMain = (() => {
247
- try {
248
- const __filename = new URL(import.meta.url).pathname;
249
- return process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
250
- } catch {
251
- return false;
252
- }
253
- })();
254
-
255
- if (isMain) {
256
- const siteDir = process.env.CMS_SITE_DIR
257
- ? path.resolve(process.env.CMS_SITE_DIR)
258
- : null;
259
- if (!siteDir) {
260
- console.error('❌ CMS_SITE_DIR environment variable is required.');
261
- console.error(' Example: CMS_SITE_DIR=./site npx cms-generate-image-variants');
262
- process.exit(1);
263
- }
264
- generateVariants({ siteDir }).catch((err) => {
265
- console.error(`❌ ${err.message}`);
266
- process.exit(1);
267
- });
268
- }