@koehler8/cms 1.0.0-beta.26 → 1.0.0-beta.28
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/bin/cms-migrate-image-variants.js +11 -0
- package/package.json +2 -2
- 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 +20 -0
- package/scripts/image-variants/manifest.js +71 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +181 -0
- package/scripts/image-variants/renderer.js +41 -0
- package/scripts/migrate-image-variants.mjs +316 -0
- package/vite-plugin.js +121 -5
- package/bin/cms-generate-image-variants.js +0 -33
- package/scripts/generate-image-variants.js +0 -268
|
@@ -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
|
-
}
|