@koehler8/cms 1.0.0-beta.27 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.27",
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",
@@ -64,10 +64,18 @@ export async function reconcileVariantCache({
64
64
  continue;
65
65
  }
66
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 || [];
67
75
  const allCached = !configChanged
68
76
  && previous
69
77
  && previous.mtimeMs >= sourceMtime
70
- && job.outputs.every((o) => fs.existsSync(path.join(cacheDir, o.cacheRelPath)));
78
+ && previousVariants.every((v) => fs.existsSync(path.join(cacheDir, v)));
71
79
 
72
80
  if (allCached) {
73
81
  plannedRenders.push({ job, sourceMtime, render: false });
@@ -87,12 +95,43 @@ export async function reconcileVariantCache({
87
95
  sharp = mod.default;
88
96
  }
89
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
+
90
128
  for (const { job, sourceMtime, render } of plannedRenders) {
91
129
  if (!render) {
92
- skipped += job.outputs.length;
130
+ skipped += (filteredOutputsByJob.get(job.sourceKey) || job.outputs).length;
93
131
  continue;
94
132
  }
95
- for (const output of job.outputs) {
133
+ const outputs = filteredOutputsByJob.get(job.sourceKey) || job.outputs;
134
+ for (const output of outputs) {
96
135
  const outPath = path.join(cacheDir, output.cacheRelPath);
97
136
  try {
98
137
  await renderOutput(sharp, job.sourcePath, { ...output, outPath }, currentConfig);
@@ -104,15 +143,32 @@ export async function reconcileVariantCache({
104
143
  void sourceMtime;
105
144
  }
106
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
+
107
162
  const newManifest = {
108
163
  version: MANIFEST_VERSION,
109
164
  config: currentConfig,
110
165
  sources: {},
111
166
  };
112
167
  for (const { job, sourceMtime } of plannedRenders) {
168
+ const outputs = filteredOutputsByJob.get(job.sourceKey) || job.outputs;
113
169
  newManifest.sources[job.sourceKey] = {
114
170
  mtimeMs: sourceMtime,
115
- variants: job.outputs.map((o) => o.cacheRelPath),
171
+ variants: outputs.map((o) => o.cacheRelPath),
116
172
  };
117
173
  }
118
174
  writeManifest(manifestPath, newManifest);