@koehler8/cms 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. 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
+ }