@koehler8/cms 1.0.0-beta.27 → 1.0.0-beta.29

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.29",
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);
@@ -273,10 +273,19 @@ function getHeaderOffset() {
273
273
  .promo-surface {
274
274
  position: relative;
275
275
  min-height: var(--promo-surface-min-height, calc(100vh + 75px));
276
- background: var(
276
+ background-color: var(
277
277
  --promo-surface-bg,
278
278
  var(--hero-surface-bg, var(--theme-body-background, #060212))
279
279
  );
280
+ /* Two background-image declarations form a CSS-cascade fallback chain.
281
+ The first sets a basic url(); the second, if the browser parses
282
+ image-set() (and the type() argument), overrides with format
283
+ negotiation. Browsers that can't parse image-set fall back to the
284
+ url() above. Custom properties are populated by usePromoBackgroundStyles
285
+ via Vue's :style object binding — see that composable for why this
286
+ pattern is needed instead of a single background-image string. */
287
+ background-image: var(--promo-bg-fallback);
288
+ background-image: var(--promo-bg-set, var(--promo-bg-fallback));
280
289
  background-repeat: no-repeat;
281
290
  background-size: cover;
282
291
  background-position: center;
@@ -23,14 +23,34 @@ function selectLargestEntry(srcset = '') {
23
23
  }
24
24
 
25
25
  /**
26
- * Build a CSS background-image declaration that respects the neon backdrop toggle
27
- * and reuses the responsive image metadata returned by `useResponsiveImage`.
26
+ * Build a hero-background style binding that pairs a `url()` fallback with an
27
+ * `image-set()` upgrade. Returns an OBJECT with two CSS custom properties.
28
+ *
29
+ * Why an object with custom properties instead of a single `background-image`
30
+ * string: Vue 3's `:style` parses a string into a normalized object keyed by
31
+ * property name. A string with two `background-image` declarations
32
+ * (`background-image: url(...); background-image: image-set(...)`) collapses
33
+ * to a single entry — only the last value survives. This loses the
34
+ * progressive-enhancement chain that's the whole point of pairing a `url()`
35
+ * fallback with an `image-set()` upgrade. By emitting two custom properties,
36
+ * Vue keeps both, and the consuming CSS rule chains them as two real
37
+ * declarations of `background-image` so browsers walk the cascade naturally.
38
+ *
39
+ * Consumed by Hero.vue's `.promo-surface` rule, which declares:
40
+ *
41
+ * background-image: var(--promo-bg-fallback);
42
+ * background-image: var(--promo-bg-set, var(--promo-bg-fallback));
43
+ *
44
+ * Browsers that can parse `image-set()` (and its `type()` argument) use the
45
+ * second declaration; browsers that can't fall through to the first.
46
+ *
28
47
  * @param {object} options
29
48
  * @param {{
30
49
  * sources: import('vue').MaybeRefOrGetter<Array<{ type: string, srcset: string }>>,
31
50
  * fallbackSrc: import('vue').MaybeRefOrGetter<string>,
32
51
  * fallbackFormat: import('vue').MaybeRefOrGetter<string>
33
52
  * }} options.imageSet
53
+ * @returns {import('vue').ComputedRef<Record<string, string> | null>}
34
54
  */
35
55
  export function usePromoBackgroundStyles({ imageSet }) {
36
56
  return computed(() => {
@@ -38,30 +58,26 @@ export function usePromoBackgroundStyles({ imageSet }) {
38
58
  const fallbackFormat = (unref(imageSet?.fallbackFormat) || 'jpg').toLowerCase();
39
59
  const sources = unref(imageSet?.sources) || [];
40
60
 
41
- const formatEntries = [];
61
+ if (!fallbackSrc) return null;
42
62
 
63
+ const formatEntries = [];
43
64
  sources.forEach((source) => {
44
65
  const largest = selectLargestEntry(source?.srcset);
45
66
  if (largest) {
46
67
  formatEntries.push(`url("${largest}") type("${source?.type}") 1x`);
47
68
  }
48
69
  });
49
-
50
70
  if (fallbackSrc) {
51
71
  const fallbackMime = fallbackFormat === 'jpg' ? 'jpeg' : fallbackFormat;
52
72
  formatEntries.push(`url("${fallbackSrc}") type("image/${fallbackMime}") 1x`);
53
73
  }
54
74
 
55
- const cssImageSet = formatEntries.length ? `image-set(${formatEntries.join(', ')})` : '';
56
- let style = '';
57
-
58
- if (fallbackSrc) {
59
- style += `background-image: url("${fallbackSrc}");`;
60
- }
61
- if (cssImageSet) {
62
- style += `background-image: ${cssImageSet};`;
75
+ const result = {
76
+ '--promo-bg-fallback': `url("${fallbackSrc}")`,
77
+ };
78
+ if (formatEntries.length) {
79
+ result['--promo-bg-set'] = `image-set(${formatEntries.join(', ')})`;
63
80
  }
64
-
65
- return style;
81
+ return result;
66
82
  });
67
83
  }