@megabudino/stack-utils 1.4.0 → 1.4.1

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/README.md CHANGED
@@ -99,18 +99,24 @@ What it does:
99
99
  - reads source images from `static/images`
100
100
  - writes responsive variants to `static/_image-variants`
101
101
  - writes its generated manifest to `.stack-utils/image-variants.generated.json`
102
+ - writes cache metadata to `.stack-utils/image-variants.cache.json`
102
103
  - rewrites compatible `<img src="/images/...">` tags to an internal `StaticImage` component
103
104
  - keeps `StaticImage` internal to the package so the public API stays small
104
105
 
105
- ### Consumer gitignore
106
+ ### Reusing built images
106
107
 
107
- In the consuming app, ignore the generated pipeline output:
108
+ If you want to build the image variants once and reuse them across machines or CI runs, commit:
108
109
 
109
- ```gitignore
110
- static/_image-variants
111
- .stack-utils
110
+ ```text
111
+ static/_image-variants/
112
+ .stack-utils/image-variants.generated.json
113
+ .stack-utils/image-variants.cache.json
112
114
  ```
113
115
 
116
+ The pipeline will hash the current source images, verify the referenced generated files still exist, and skip regeneration when everything matches.
117
+
118
+ If you prefer generated artifacts to stay local, you can still ignore those paths in the consuming app.
119
+
114
120
  ## Publishing Notes
115
121
 
116
122
  The package is intended to be consumed via subpath imports:
@@ -4,6 +4,7 @@ export declare const staticImagePipelineConfig: {
4
4
  readonly publicSourcePrefix: "/images";
5
5
  readonly publicOutputPrefix: "/_image-variants";
6
6
  readonly manifestPath: ".stack-utils/image-variants.generated.json";
7
+ readonly cachePath: ".stack-utils/image-variants.cache.json";
7
8
  readonly supportedExtensions: readonly [".jpg", ".jpeg", ".png", ".webp"];
8
9
  readonly widths: readonly [320, 480, 640, 768, 960, 1280, 1536, 1920];
9
10
  readonly quality: {
@@ -4,6 +4,7 @@ export const staticImagePipelineConfig = {
4
4
  publicSourcePrefix: '/images',
5
5
  publicOutputPrefix: '/_image-variants',
6
6
  manifestPath: '.stack-utils/image-variants.generated.json',
7
+ cachePath: '.stack-utils/image-variants.cache.json',
7
8
  supportedExtensions: ['.jpg', '.jpeg', '.png', '.webp'],
8
9
  widths: [320, 480, 640, 768, 960, 1280, 1536, 1920],
9
10
  quality: {
@@ -1,20 +1,37 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream, promises as fs } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import sharp from 'sharp';
4
5
  import { staticImagePipelineConfig } from './config.js';
5
6
  const SUPPORTED_EXTENSIONS = new Set(staticImagePipelineConfig.supportedExtensions);
7
+ const IMAGE_VARIANT_CACHE_VERSION = 1;
6
8
  export async function generateStaticImageVariants({ root, log = () => undefined }) {
7
9
  const sourceDirectory = path.join(root, staticImagePipelineConfig.sourceDirectory);
8
10
  const outputDirectory = path.join(root, staticImagePipelineConfig.outputDirectory);
9
11
  const manifestPath = path.join(root, staticImagePipelineConfig.manifestPath);
12
+ const cachePath = path.join(root, staticImagePipelineConfig.cachePath);
10
13
  await fs.mkdir(path.dirname(manifestPath), { recursive: true });
14
+ await fs.mkdir(path.dirname(cachePath), { recursive: true });
11
15
  const sourceFiles = await collectSourceImages(sourceDirectory);
16
+ const cacheMetadata = await buildCacheMetadata(sourceFiles);
12
17
  if (sourceFiles.length === 0) {
13
18
  await fs.mkdir(outputDirectory, { recursive: true });
14
19
  await fs.writeFile(manifestPath, '{}\n');
20
+ await fs.writeFile(cachePath, `${JSON.stringify(cacheMetadata, null, 2)}\n`);
15
21
  return {};
16
22
  }
17
23
  await fs.mkdir(outputDirectory, { recursive: true });
24
+ const reusableManifest = await readReusableManifest({
25
+ root,
26
+ sourceFiles,
27
+ manifestPath,
28
+ cachePath,
29
+ cacheMetadata
30
+ });
31
+ if (reusableManifest) {
32
+ log(`[static-image-pipeline] reused ${Object.keys(reusableManifest).length} manifest entries`);
33
+ return reusableManifest;
34
+ }
18
35
  const manifest = {};
19
36
  for (const file of sourceFiles) {
20
37
  const entry = await generateManifestEntry({ file, outputDirectory });
@@ -23,9 +40,34 @@ export async function generateStaticImageVariants({ root, log = () => undefined
23
40
  }
24
41
  }
25
42
  await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
43
+ await fs.writeFile(cachePath, `${JSON.stringify(cacheMetadata, null, 2)}\n`);
26
44
  log(`[static-image-pipeline] generated ${Object.keys(manifest).length} manifest entries`);
27
45
  return manifest;
28
46
  }
47
+ async function readReusableManifest({ root, sourceFiles, manifestPath, cachePath, cacheMetadata }) {
48
+ const [manifest, persistedCache] = await Promise.all([
49
+ readJsonFile(manifestPath),
50
+ readJsonFile(cachePath)
51
+ ]);
52
+ if (!manifest || !persistedCache) {
53
+ return null;
54
+ }
55
+ if (persistedCache.version !== IMAGE_VARIANT_CACHE_VERSION) {
56
+ return null;
57
+ }
58
+ if (persistedCache.configHash !== cacheMetadata.configHash) {
59
+ return null;
60
+ }
61
+ if (!areStringRecordsEqual(persistedCache.sources, cacheMetadata.sources)) {
62
+ return null;
63
+ }
64
+ if (!manifestMatchesSources(manifest, sourceFiles)) {
65
+ return null;
66
+ }
67
+ const outputFiles = getOutputFilesFromManifest(root, manifest);
68
+ const outputFilesExist = await allFilesExist(outputFiles);
69
+ return outputFilesExist ? manifest : null;
70
+ }
29
71
  async function generateManifestEntry({ file, outputDirectory }) {
30
72
  const image = sharp(file.absolutePath, { failOn: 'none' });
31
73
  const metadata = await image.metadata();
@@ -146,6 +188,22 @@ async function collectSourceImages(directory) {
146
188
  files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
147
189
  return files;
148
190
  }
191
+ async function buildCacheMetadata(sourceFiles) {
192
+ const sources = Object.fromEntries(await Promise.all(sourceFiles.map(async (file) => [toSourcePath(file.relativePath), await hashFile(file.absolutePath)])));
193
+ return {
194
+ version: IMAGE_VARIANT_CACHE_VERSION,
195
+ configHash: createHash('sha1')
196
+ .update(JSON.stringify({
197
+ outputDirectory: staticImagePipelineConfig.outputDirectory,
198
+ publicOutputPrefix: staticImagePipelineConfig.publicOutputPrefix,
199
+ widths: staticImagePipelineConfig.widths,
200
+ quality: staticImagePipelineConfig.quality,
201
+ supportedExtensions: staticImagePipelineConfig.supportedExtensions
202
+ }))
203
+ .digest('hex'),
204
+ sources
205
+ };
206
+ }
149
207
  async function walkDirectory(directory, onFile, baseDirectory = directory) {
150
208
  let entries;
151
209
  try {
@@ -179,10 +237,71 @@ async function needsWrite(outputPath, sourceMtimeMs) {
179
237
  throw error;
180
238
  }
181
239
  }
240
+ async function hashFile(filePath) {
241
+ return new Promise((resolve, reject) => {
242
+ const hash = createHash('sha1');
243
+ const stream = createReadStream(filePath);
244
+ stream.on('data', (chunk) => hash.update(chunk));
245
+ stream.on('end', () => resolve(hash.digest('hex')));
246
+ stream.on('error', reject);
247
+ });
248
+ }
249
+ async function readJsonFile(filePath) {
250
+ try {
251
+ const fileContents = await fs.readFile(filePath, 'utf8');
252
+ return JSON.parse(fileContents);
253
+ }
254
+ catch (error) {
255
+ if (error.code === 'ENOENT') {
256
+ return null;
257
+ }
258
+ throw error;
259
+ }
260
+ }
261
+ function areStringRecordsEqual(left, right) {
262
+ const leftEntries = Object.entries(left).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
263
+ const rightEntries = Object.entries(right).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
264
+ if (leftEntries.length !== rightEntries.length) {
265
+ return false;
266
+ }
267
+ return leftEntries.every(([leftKey, leftValue], index) => leftKey === rightEntries[index]?.[0] && leftValue === rightEntries[index]?.[1]);
268
+ }
269
+ function manifestMatchesSources(manifest, sourceFiles) {
270
+ const manifestSources = Object.keys(manifest).sort((left, right) => left.localeCompare(right));
271
+ const expectedSources = sourceFiles
272
+ .map((file) => toSourcePath(file.relativePath))
273
+ .sort((left, right) => left.localeCompare(right));
274
+ if (manifestSources.length !== expectedSources.length) {
275
+ return false;
276
+ }
277
+ return manifestSources.every((sourcePath, index) => sourcePath === expectedSources[index]);
278
+ }
279
+ async function allFilesExist(filePaths) {
280
+ for (const filePath of filePaths) {
281
+ try {
282
+ await fs.stat(filePath);
283
+ }
284
+ catch (error) {
285
+ if (error.code === 'ENOENT') {
286
+ return false;
287
+ }
288
+ throw error;
289
+ }
290
+ }
291
+ return true;
292
+ }
182
293
  function getTargetWidths(originalWidth) {
183
294
  const widths = staticImagePipelineConfig.widths.filter((width) => width < originalWidth).map(Number);
184
295
  return Array.from(new Set([...widths, originalWidth])).sort((left, right) => left - right);
185
296
  }
297
+ function getOutputFilesFromManifest(root, manifest) {
298
+ return Object.values(manifest).flatMap((entry) => Object.values(entry.formats).flatMap((variants) => variants.map((variant) => {
299
+ const outputRelativePath = variant.url
300
+ .replace(`${staticImagePipelineConfig.publicOutputPrefix}/`, '')
301
+ .replace(`${staticImagePipelineConfig.publicOutputPrefix}`, '');
302
+ return path.join(root, staticImagePipelineConfig.outputDirectory, outputRelativePath);
303
+ })));
304
+ }
186
305
  function getFallbackExtension(extension) {
187
306
  if (extension === '.png') {
188
307
  return 'png';
@@ -201,3 +320,6 @@ function getGeneratedExtension(format, fallbackExtension) {
201
320
  function toPosixPath(filePath) {
202
321
  return filePath.split(path.sep).join('/');
203
322
  }
323
+ function toSourcePath(relativePath) {
324
+ return `${staticImagePipelineConfig.publicSourcePrefix}/${toPosixPath(relativePath)}`;
325
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@megabudino/stack-utils",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Reusable utilities for a SvelteKit stack, including a reverse proxy and static image pipeline",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "build": "node ./scripts/build.mjs",
31
- "test": "node --test proxy.test.mjs",
31
+ "test": "node --test *.test.mjs",
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {