@koehler8/cms 1.0.0-beta.9 → 1.0.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 +1491 -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 +184 -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 +126 -28
- 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/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -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 +545 -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
|
@@ -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,18 @@
|
|
|
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
|
+
*
|
|
7
|
+
* The pipeline runs at build/dev time, generates variants to
|
|
8
|
+
* node_modules/.cache/@koehler8/cms/image-variants/{siteKey}/, and never
|
|
9
|
+
* touches the site's source tree.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { resolveImageVariantConfig, DEFAULT_WIDTHS, DEFAULT_FORMATS, DEFAULT_QUALITY } from './config.js';
|
|
13
|
+
export { classifyImageFile, SOURCE_EXTENSIONS, FORMAT_OUTPUT_EXT } from './classifier.js';
|
|
14
|
+
export { planVariantJobsFromFlatDir, walkRecursive } from './planner.js';
|
|
15
|
+
export { renderOutput } from './renderer.js';
|
|
16
|
+
export { readManifest, writeManifest, configsEqual, MANIFEST_VERSION } from './manifest.js';
|
|
17
|
+
export { computeVariantCacheDir, siteKeyFor } from './cache.js';
|
|
18
|
+
export { reconcileVariantCache } from './reconcile.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
// Write-to-temp-then-rename: an interrupted run must never leave a
|
|
37
|
+
// truncated manifest (readManifest would recover, but at the cost of a
|
|
38
|
+
// full regeneration).
|
|
39
|
+
const tmpPath = `${manifestPath}.tmp-${process.pid}`;
|
|
40
|
+
fs.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2));
|
|
41
|
+
fs.renameSync(tmpPath, manifestPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deep equality check for image-variant config blocks. Returns true when
|
|
46
|
+
* widths, formats, and quality (per-format) all match. Order-insensitive
|
|
47
|
+
* for arrays.
|
|
48
|
+
*/
|
|
49
|
+
export function configsEqual(a, b) {
|
|
50
|
+
if (!a || !b) return false;
|
|
51
|
+
if (!arraysEqualUnordered(a.widths, b.widths)) return false;
|
|
52
|
+
if (!arraysEqualUnordered(a.formats, b.formats)) return false;
|
|
53
|
+
return objectsEqual(a.quality, b.quality);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function arraysEqualUnordered(a, b) {
|
|
57
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
58
|
+
if (a.length !== b.length) return false;
|
|
59
|
+
const sa = [...a].sort();
|
|
60
|
+
const sb = [...b].sort();
|
|
61
|
+
for (let i = 0; i < sa.length; i += 1) {
|
|
62
|
+
if (sa[i] !== sb[i]) return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function objectsEqual(a, b) {
|
|
68
|
+
if (!a || !b) return a === b;
|
|
69
|
+
const ak = Object.keys(a);
|
|
70
|
+
const bk = Object.keys(b);
|
|
71
|
+
if (ak.length !== bk.length) return false;
|
|
72
|
+
for (const k of ak) {
|
|
73
|
+
if (a[k] !== b[k]) return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
@@ -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,194 @@
|
|
|
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
|
+
plannedRenders.push({ job, sourceMtime, render: !allCached, previousVariants });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const renderJobs = plannedRenders.filter((p) => p.render);
|
|
84
|
+
|
|
85
|
+
let generated = 0;
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
let sharp = null;
|
|
88
|
+
|
|
89
|
+
if (renderJobs.length > 0) {
|
|
90
|
+
const mod = await import('sharp');
|
|
91
|
+
sharp = mod.default;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Per-source: filter outputs to widths the source can actually fulfill.
|
|
95
|
+
// Sharp's `withoutEnlargement: true` clamps oversize requests to the
|
|
96
|
+
// source dimensions, producing content-identical files for every width
|
|
97
|
+
// greater than the source. Vite content-hashes those files identically
|
|
98
|
+
// and emits ONE physical asset under one of the names — but assetUrlMap
|
|
99
|
+
// still has URL keys for every width, so requests for the deduped
|
|
100
|
+
// higher-width URLs 404.
|
|
101
|
+
//
|
|
102
|
+
// Fix: only generate variants for widths < source.width. Plus, always
|
|
103
|
+
// generate at exactly source.width (for crisp 1× display) by remapping
|
|
104
|
+
// any single oversize width down to source.width if no smaller variant
|
|
105
|
+
// would otherwise exist. The pipeline's natural fallback to the bare
|
|
106
|
+
// `img/{name}.{ext}` URL (which exists because the original is in the
|
|
107
|
+
// flat dir) covers tiny sources where no variant width fits.
|
|
108
|
+
// Only computed for jobs that actually render — a cached job's truth is
|
|
109
|
+
// `previousVariants` (what an earlier run really wrote). Computing this
|
|
110
|
+
// filter for cached jobs used to poison the manifest: with no render work
|
|
111
|
+
// sharp stays unloaded, sourceWidth degraded to MAX_SAFE_INTEGER, and the
|
|
112
|
+
// manifest recorded the full unfiltered planner matrix — 9 phantom files
|
|
113
|
+
// for every under-width source — forcing a full re-render every other
|
|
114
|
+
// build.
|
|
115
|
+
const filteredOutputsByJob = new Map();
|
|
116
|
+
for (const { job, render } of plannedRenders) {
|
|
117
|
+
if (!render) continue;
|
|
118
|
+
let sourceWidth = Number.MAX_SAFE_INTEGER;
|
|
119
|
+
if (sharp) {
|
|
120
|
+
try {
|
|
121
|
+
const meta = await sharp(job.sourcePath).metadata();
|
|
122
|
+
if (meta && Number.isFinite(meta.width)) sourceWidth = meta.width;
|
|
123
|
+
} catch {
|
|
124
|
+
// If metadata read fails, fall through and render everything —
|
|
125
|
+
// the failure is benign (just the dedup risk we're trying to avoid).
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const kept = job.outputs.filter((o) => o.width <= sourceWidth);
|
|
129
|
+
filteredOutputsByJob.set(job.sourceKey, kept);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// What each rendered job actually produced. The manifest records only
|
|
133
|
+
// successful writes: a failed render must never be asserted as an
|
|
134
|
+
// existing variant. (A transiently-failed output is retried when the
|
|
135
|
+
// source mtime or config next changes; a permanently-broken source stops
|
|
136
|
+
// forcing a full re-render on every build.)
|
|
137
|
+
const successfulByJob = new Map();
|
|
138
|
+
for (const { job, render, previousVariants } of plannedRenders) {
|
|
139
|
+
if (!render) {
|
|
140
|
+
skipped += previousVariants.length;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const outputs = filteredOutputsByJob.get(job.sourceKey) || job.outputs;
|
|
144
|
+
const successes = [];
|
|
145
|
+
for (const output of outputs) {
|
|
146
|
+
const outPath = path.join(cacheDir, output.cacheRelPath);
|
|
147
|
+
try {
|
|
148
|
+
await renderOutput(sharp, job.sourcePath, { ...output, outPath }, currentConfig);
|
|
149
|
+
generated += 1;
|
|
150
|
+
successes.push(output.cacheRelPath);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log(`failed ${output.cacheRelPath}: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
successfulByJob.set(job.sourceKey, successes);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Evict any cached variants that the source can no longer fulfill
|
|
159
|
+
// (e.g. someone replaced a 4000px source with a 200px one — the
|
|
160
|
+
// 1280/1920/2560 outputs from the previous run are now stale).
|
|
161
|
+
for (const { job, render } of plannedRenders) {
|
|
162
|
+
if (!render) continue;
|
|
163
|
+
const kept = new Set((filteredOutputsByJob.get(job.sourceKey) || job.outputs).map((o) => o.cacheRelPath));
|
|
164
|
+
for (const o of job.outputs) {
|
|
165
|
+
if (kept.has(o.cacheRelPath)) continue;
|
|
166
|
+
const stalePath = path.join(cacheDir, o.cacheRelPath);
|
|
167
|
+
if (fs.existsSync(stalePath)) {
|
|
168
|
+
await fs.promises.rm(stalePath, { force: true });
|
|
169
|
+
evicted += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const newManifest = {
|
|
175
|
+
version: MANIFEST_VERSION,
|
|
176
|
+
config: currentConfig,
|
|
177
|
+
sources: {},
|
|
178
|
+
};
|
|
179
|
+
for (const { job, sourceMtime, render, previousVariants } of plannedRenders) {
|
|
180
|
+
newManifest.sources[job.sourceKey] = {
|
|
181
|
+
mtimeMs: sourceMtime,
|
|
182
|
+
// Cached job → carry forward what actually exists on disk; rendered
|
|
183
|
+
// job → record only what this run successfully wrote.
|
|
184
|
+
variants: render ? (successfulByJob.get(job.sourceKey) || []) : previousVariants,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
writeManifest(manifestPath, newManifest);
|
|
188
|
+
|
|
189
|
+
if (generated || evicted) {
|
|
190
|
+
log(`sources: ${jobs.length}, generated: ${generated}, skipped: ${skipped}, evicted: ${evicted}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { generated, skipped, evicted };
|
|
194
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
// Render to a temp path and rename: a killed run must never leave a
|
|
41
|
+
// truncated image that reconciliation would then trust as a valid variant
|
|
42
|
+
// (it validates presence, not bytes).
|
|
43
|
+
const tmpPath = `${outPath}.tmp-${process.pid}`;
|
|
44
|
+
try {
|
|
45
|
+
await pipeline.toFile(tmpPath);
|
|
46
|
+
await fs.promises.rename(tmpPath, outPath);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
await fs.promises.rm(tmpPath, { force: true });
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// framework root = two levels up from scripts/lib/
|
|
6
|
+
const FRAMEWORK_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
7
|
+
|
|
8
|
+
// Build-time extension-manifest validation. Manifests are static JSON, fixed
|
|
9
|
+
// at build time, so full JSON-Schema validation happens here — failing the
|
|
10
|
+
// build loudly — instead of shipping ajv to every visitor's browser (the
|
|
11
|
+
// runtime loader keeps only cheap structural checks). Spec resolution mirrors
|
|
12
|
+
// how the generated entry imports each extension: './'-relative specs resolve
|
|
13
|
+
// from the project root, package names from the project's node_modules.
|
|
14
|
+
// Exported for tests.
|
|
15
|
+
export async function validateExtensionManifests(extensionPackages, projectRoot, frameworkDir = FRAMEWORK_ROOT) {
|
|
16
|
+
if (!Array.isArray(extensionPackages) || extensionPackages.length === 0) return [];
|
|
17
|
+
|
|
18
|
+
const [{ default: Ajv }, ajvFormats] = await Promise.all([
|
|
19
|
+
import('ajv/dist/2020.js'),
|
|
20
|
+
import('ajv-formats'),
|
|
21
|
+
]);
|
|
22
|
+
const addFormats = ajvFormats.default || ajvFormats;
|
|
23
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
24
|
+
addFormats(ajv);
|
|
25
|
+
const schemaPath = path.join(frameworkDir, 'extensions', 'manifest.schema.json');
|
|
26
|
+
const validate = ajv.compile(JSON.parse(fs.readFileSync(schemaPath, 'utf-8')));
|
|
27
|
+
|
|
28
|
+
const problems = [];
|
|
29
|
+
const manifests = [];
|
|
30
|
+
for (const spec of extensionPackages) {
|
|
31
|
+
const baseDir = spec.startsWith('.') || path.isAbsolute(spec)
|
|
32
|
+
? path.resolve(projectRoot, spec)
|
|
33
|
+
: path.join(projectRoot, 'node_modules', ...spec.split('/'));
|
|
34
|
+
const manifestPath = path.join(baseDir, 'extension.config.json');
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(manifestPath)) {
|
|
37
|
+
// A spec may legitimately carry its manifest elsewhere (the entry
|
|
38
|
+
// module imports it); the runtime structural check still covers it.
|
|
39
|
+
console.warn(`[@koehler8/cms] extension "${spec}": no extension.config.json at ${manifestPath} — skipping build-time schema validation.`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let manifest;
|
|
44
|
+
try {
|
|
45
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
problems.push(`- ${spec}: extension.config.json is not valid JSON (${err.message})`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!validate(manifest)) {
|
|
51
|
+
const details = (validate.errors || [])
|
|
52
|
+
.map((e) => ` ${e.instancePath || '(root)'} ${e.message}`)
|
|
53
|
+
.join('\n');
|
|
54
|
+
problems.push(`- ${spec}: manifest failed schema validation\n${details}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
manifests.push({
|
|
58
|
+
spec,
|
|
59
|
+
slug: manifest.slug,
|
|
60
|
+
componentNames: new Set(
|
|
61
|
+
(manifest.components || []).map((c) => c && c.name).filter(Boolean),
|
|
62
|
+
),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (problems.length) {
|
|
67
|
+
throw new Error(`[@koehler8/cms] Extension manifest validation failed:\n${problems.join('\n')}`);
|
|
68
|
+
}
|
|
69
|
+
return manifests;
|
|
70
|
+
}
|