@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.
@@ -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.28",
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,181 @@
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
+ // Source is "cached" if the manifest knows it, mtime hasn't changed,
68
+ // config hasn't changed, AND every variant the previous run wrote is
69
+ // still on disk. We check `previous.variants` (what was actually
70
+ // generated) rather than `job.outputs` (everything the planner asked
71
+ // for) so tiny-source sites — where the renderer skipped over-large
72
+ // widths to avoid Vite content-dedup — aren't perpetually re-rendered
73
+ // on each build.
74
+ const previousVariants = previous?.variants || [];
75
+ const allCached = !configChanged
76
+ && previous
77
+ && previous.mtimeMs >= sourceMtime
78
+ && previousVariants.every((v) => fs.existsSync(path.join(cacheDir, v)));
79
+
80
+ if (allCached) {
81
+ plannedRenders.push({ job, sourceMtime, render: false });
82
+ } else {
83
+ plannedRenders.push({ job, sourceMtime, render: true });
84
+ }
85
+ }
86
+
87
+ const renderJobs = plannedRenders.filter((p) => p.render);
88
+
89
+ let generated = 0;
90
+ let skipped = 0;
91
+ let sharp = null;
92
+
93
+ if (renderJobs.length > 0) {
94
+ const mod = await import('sharp');
95
+ sharp = mod.default;
96
+ }
97
+
98
+ // Per-source: filter outputs to widths the source can actually fulfill.
99
+ // Sharp's `withoutEnlargement: true` clamps oversize requests to the
100
+ // source dimensions, producing content-identical files for every width
101
+ // greater than the source. Vite content-hashes those files identically
102
+ // and emits ONE physical asset under one of the names — but assetUrlMap
103
+ // still has URL keys for every width, so requests for the deduped
104
+ // higher-width URLs 404.
105
+ //
106
+ // Fix: only generate variants for widths < source.width. Plus, always
107
+ // generate at exactly source.width (for crisp 1× display) by remapping
108
+ // any single oversize width down to source.width if no smaller variant
109
+ // would otherwise exist. The pipeline's natural fallback to the bare
110
+ // `img/{name}.{ext}` URL (which exists because the original is in the
111
+ // flat dir) covers tiny sources where no variant width fits.
112
+ const filteredOutputsByJob = new Map();
113
+ for (const { job } of plannedRenders) {
114
+ let sourceWidth = Number.MAX_SAFE_INTEGER;
115
+ if (sharp) {
116
+ try {
117
+ const meta = await sharp(job.sourcePath).metadata();
118
+ if (meta && Number.isFinite(meta.width)) sourceWidth = meta.width;
119
+ } catch {
120
+ // If metadata read fails, fall through and render everything —
121
+ // the failure is benign (just the dedup risk we're trying to avoid).
122
+ }
123
+ }
124
+ const kept = job.outputs.filter((o) => o.width <= sourceWidth);
125
+ filteredOutputsByJob.set(job.sourceKey, kept);
126
+ }
127
+
128
+ for (const { job, sourceMtime, render } of plannedRenders) {
129
+ if (!render) {
130
+ skipped += (filteredOutputsByJob.get(job.sourceKey) || job.outputs).length;
131
+ continue;
132
+ }
133
+ const outputs = filteredOutputsByJob.get(job.sourceKey) || job.outputs;
134
+ for (const output of outputs) {
135
+ const outPath = path.join(cacheDir, output.cacheRelPath);
136
+ try {
137
+ await renderOutput(sharp, job.sourcePath, { ...output, outPath }, currentConfig);
138
+ generated += 1;
139
+ } catch (err) {
140
+ log(`failed ${output.cacheRelPath}: ${err.message}`);
141
+ }
142
+ }
143
+ void sourceMtime;
144
+ }
145
+
146
+ // Evict any cached variants that the source can no longer fulfill
147
+ // (e.g. someone replaced a 4000px source with a 200px one — the
148
+ // 1280/1920/2560 outputs from the previous run are now stale).
149
+ for (const { job, render } of plannedRenders) {
150
+ if (!render) continue;
151
+ const kept = new Set((filteredOutputsByJob.get(job.sourceKey) || job.outputs).map((o) => o.cacheRelPath));
152
+ for (const o of job.outputs) {
153
+ if (kept.has(o.cacheRelPath)) continue;
154
+ const stalePath = path.join(cacheDir, o.cacheRelPath);
155
+ if (fs.existsSync(stalePath)) {
156
+ await fs.promises.rm(stalePath, { force: true });
157
+ evicted += 1;
158
+ }
159
+ }
160
+ }
161
+
162
+ const newManifest = {
163
+ version: MANIFEST_VERSION,
164
+ config: currentConfig,
165
+ sources: {},
166
+ };
167
+ for (const { job, sourceMtime } of plannedRenders) {
168
+ const outputs = filteredOutputsByJob.get(job.sourceKey) || job.outputs;
169
+ newManifest.sources[job.sourceKey] = {
170
+ mtimeMs: sourceMtime,
171
+ variants: outputs.map((o) => o.cacheRelPath),
172
+ };
173
+ }
174
+ writeManifest(manifestPath, newManifest);
175
+
176
+ if (generated || evicted) {
177
+ log(`sources: ${jobs.length}, generated: ${generated}, skipped: ${skipped}, evicted: ${evicted}`);
178
+ }
179
+
180
+ return { generated, skipped, evicted };
181
+ }
@@ -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
+ }