fossbook 0.2.14 → 0.2.16

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.
@@ -0,0 +1,130 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { loadConfig } = require("./config");
5
+ const { CACHE_SCHEMA_VERSION } = require("./image");
6
+
7
+ function createGitHubCacheInfo(config, projectRoot = process.cwd()) {
8
+ const hash = crypto.createHash("sha256");
9
+ hash.update(
10
+ JSON.stringify({
11
+ schema: CACHE_SCHEMA_VERSION,
12
+ languages: (config.languageConfigs || [config]).map((languageConfig) => ({
13
+ imageOptimization: languageConfig.imageOptimization,
14
+ imageMaxWidth: languageConfig.imageMaxWidth,
15
+ imageMaxWidthOverride: languageConfig.imageMaxWidthOverride,
16
+ imageWebP: languageConfig.imageWebP,
17
+ imageResponsiveWidths: languageConfig.imageResponsiveWidths,
18
+ })),
19
+ }),
20
+ );
21
+
22
+ updateFile(
23
+ hash,
24
+ "package-lock.json",
25
+ path.join(projectRoot, "package-lock.json"),
26
+ );
27
+ updateDirectory(
28
+ hash,
29
+ "posts",
30
+ config.dev.postsdir,
31
+ (filePath) => {
32
+ const extension = path.extname(filePath).toLowerCase();
33
+ return extension === ".md" || extension === ".png";
34
+ },
35
+ (filePath) =>
36
+ path.extname(filePath).toLowerCase() === ".md"
37
+ ? Buffer.from(extractImageTransformMetadata(filePath))
38
+ : fs.readFileSync(filePath),
39
+ );
40
+ updateDirectory(
41
+ hash,
42
+ "static",
43
+ path.join(config.dev.staticDir, "images"),
44
+ (filePath) => path.extname(filePath).toLowerCase() === ".png",
45
+ );
46
+ updateDirectory(
47
+ hash,
48
+ "theme",
49
+ path.join(config.themePath, "assets"),
50
+ (filePath) => path.extname(filePath).toLowerCase() === ".png",
51
+ );
52
+
53
+ return {
54
+ cachePath: config.dev.cacheDir,
55
+ inputHash: hash.digest("hex"),
56
+ statusPath: path.join(config.dev.cacheDir, "image-cache-status.json"),
57
+ };
58
+ }
59
+
60
+ function writeGitHubCacheOutputs(configPath) {
61
+ const info = createGitHubCacheInfo(loadConfig(configPath));
62
+ if (!process.env.GITHUB_OUTPUT) {
63
+ throw new Error("GITHUB_OUTPUT is required");
64
+ }
65
+ fs.appendFileSync(
66
+ process.env.GITHUB_OUTPUT,
67
+ `cache-path=${info.cachePath}\n` +
68
+ `input-hash=${info.inputHash}\n` +
69
+ `status-path=${info.statusPath}\n`,
70
+ );
71
+ return info;
72
+ }
73
+
74
+ function updateDirectory(
75
+ hash,
76
+ label,
77
+ root,
78
+ includeFile,
79
+ readContents = (filePath) => fs.readFileSync(filePath),
80
+ ) {
81
+ if (!fs.existsSync(root)) return;
82
+
83
+ const files = [];
84
+ collectFiles(root, includeFile, files);
85
+ files.sort((left, right) => left.localeCompare(right));
86
+ for (const filePath of files) {
87
+ const relativePath = path.relative(root, filePath).replace(/\\/g, "/");
88
+ updateContents(hash, `${label}/${relativePath}`, readContents(filePath));
89
+ }
90
+ }
91
+
92
+ function collectFiles(directory, includeFile, files) {
93
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
94
+ const entryPath = path.join(directory, entry.name);
95
+ if (entry.isDirectory()) {
96
+ collectFiles(entryPath, includeFile, files);
97
+ } else if (entry.isFile() && includeFile(entryPath)) {
98
+ files.push(entryPath);
99
+ }
100
+ }
101
+ }
102
+
103
+ function updateFile(hash, label, filePath) {
104
+ if (!fs.existsSync(filePath)) return;
105
+ updateContents(hash, label, fs.readFileSync(filePath));
106
+ }
107
+
108
+ function updateContents(hash, label, contents) {
109
+ hash.update(label);
110
+ hash.update("\0");
111
+ hash.update(contents);
112
+ hash.update("\0");
113
+ }
114
+
115
+ function extractImageTransformMetadata(filePath) {
116
+ const markdown = fs.readFileSync(filePath, "utf8");
117
+ const imagePattern = /!\[[^\]]*\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g;
118
+ return JSON.stringify(
119
+ [...markdown.matchAll(imagePattern)].map((match) => ({
120
+ href: match[1],
121
+ publishWidth: match[2]?.match(/publish-width:(\d+)/)?.[1] || null,
122
+ })),
123
+ );
124
+ }
125
+
126
+ module.exports = {
127
+ createGitHubCacheInfo,
128
+ extractImageTransformMetadata,
129
+ writeGitHubCacheOutputs,
130
+ };
@@ -0,0 +1,411 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const sharp = require("sharp");
5
+
6
+ const CACHE_SCHEMA_VERSION = 4;
7
+ const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true };
8
+ const WEBP_OPTIONS = { lossless: true, effort: 4 };
9
+ const activeCachePaths = new Set();
10
+ let cacheStats;
11
+
12
+ function beginImageBuild() {
13
+ activeCachePaths.clear();
14
+ cacheStats = {
15
+ hits: 0,
16
+ misses: 0,
17
+ writes: 0,
18
+ regeneratedImages: new Set(),
19
+ };
20
+ }
21
+
22
+ async function pruneImageCache(config) {
23
+ const cacheDir = getImageCacheDir(config);
24
+ if (!fs.existsSync(cacheDir)) return;
25
+
26
+ await Promise.all(
27
+ fs.readdirSync(cacheDir).map((fileName) => {
28
+ const cachePath = path.join(cacheDir, fileName);
29
+ if (activeCachePaths.has(cachePath)) return Promise.resolve();
30
+ return fs.promises.rm(cachePath, { recursive: true, force: true });
31
+ }),
32
+ );
33
+ }
34
+
35
+ async function finishImageBuild(config) {
36
+ const stats = getImageCacheStats();
37
+ try {
38
+ await pruneImageCache(config);
39
+ const cacheRoot = getCacheRoot(config);
40
+ await fs.promises.mkdir(cacheRoot, { recursive: true });
41
+ await writeFileAtomically(
42
+ path.join(cacheRoot, "image-cache-status.json"),
43
+ JSON.stringify(stats, null, 2) + "\n",
44
+ );
45
+ } catch (error) {
46
+ console.warn(`Warning: Image cache finalization failed. ${error.message}`);
47
+ }
48
+ console.log(
49
+ `Image cache: ${stats.hits} hits, ${stats.misses} misses, ` +
50
+ `${stats.regeneratedImages} images regenerated.`,
51
+ );
52
+ return stats;
53
+ }
54
+
55
+ function getImageCacheStats() {
56
+ ensureCacheStats();
57
+ return {
58
+ hits: cacheStats.hits,
59
+ misses: cacheStats.misses,
60
+ writes: cacheStats.writes,
61
+ regeneratedImages: cacheStats.regeneratedImages.size,
62
+ };
63
+ }
64
+
65
+ async function publishImage(
66
+ sourcePath,
67
+ destinationPath,
68
+ config,
69
+ requestedMaxWidth,
70
+ generateWebP = true,
71
+ ) {
72
+ await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
73
+
74
+ if (
75
+ config.imageOptimization === false ||
76
+ path.extname(sourcePath).toLowerCase() !== ".png"
77
+ ) {
78
+ await fs.promises.copyFile(sourcePath, destinationPath);
79
+ return null;
80
+ }
81
+
82
+ const configuredMaxWidth =
83
+ Number.isInteger(config.imageMaxWidth) && config.imageMaxWidth > 0
84
+ ? config.imageMaxWidth
85
+ : 1400;
86
+ const maximumOverride =
87
+ Number.isInteger(config.imageMaxWidthOverride) &&
88
+ config.imageMaxWidthOverride > 0
89
+ ? config.imageMaxWidthOverride
90
+ : 1200;
91
+ const validRequestedWidth =
92
+ Number.isInteger(requestedMaxWidth) && requestedMaxWidth > 0;
93
+ const maxWidth = validRequestedWidth
94
+ ? Math.min(requestedMaxWidth, maximumOverride)
95
+ : configuredMaxWidth;
96
+ const source = await fs.promises.readFile(sourcePath);
97
+ let sourceMetadata;
98
+ try {
99
+ sourceMetadata = await sharp(source).metadata();
100
+ } catch {
101
+ await fs.promises.copyFile(sourcePath, destinationPath);
102
+ return null;
103
+ }
104
+ const cacheDir = getImageCacheDir(config);
105
+ const sourceHash = crypto.createHash("sha256").update(source).digest("hex");
106
+ const sourceWidth = sourceMetadata.autoOrient?.width ?? sourceMetadata.width;
107
+ const fallback = await publishPng(
108
+ sourcePath,
109
+ source,
110
+ sourceWidth,
111
+ sourceHash,
112
+ destinationPath,
113
+ cacheDir,
114
+ maxWidth,
115
+ );
116
+
117
+ if (!generateWebP || config.imageWebP === false) {
118
+ return { fallback, webp: [] };
119
+ }
120
+
121
+ const responsiveWidths = Array.isArray(config.imageResponsiveWidths)
122
+ ? config.imageResponsiveWidths
123
+ : [600, 800, 1000];
124
+ const effectiveMaxWidth = Math.min(sourceWidth, maxWidth);
125
+ const widths = [...new Set([...responsiveWidths, effectiveMaxWidth])]
126
+ .filter((width) => Number.isInteger(width) && width > 0)
127
+ .filter((width) => width <= effectiveMaxWidth)
128
+ .sort((left, right) => left - right);
129
+ const parsedDestination = path.parse(destinationPath);
130
+ const webp = [];
131
+
132
+ for (const width of widths) {
133
+ const suffix = width === effectiveMaxWidth ? "" : `-${width}`;
134
+ const fileName = `${parsedDestination.name}${suffix}.webp`;
135
+ const outputPath = path.join(parsedDestination.dir, fileName);
136
+ const published = await publishWebP(
137
+ sourcePath,
138
+ source,
139
+ sourceHash,
140
+ outputPath,
141
+ cacheDir,
142
+ width,
143
+ );
144
+ if (published) webp.push({ fileName, width });
145
+ }
146
+
147
+ return { fallback, webp };
148
+ }
149
+
150
+ async function publishPng(
151
+ sourcePath,
152
+ source,
153
+ sourceWidth,
154
+ sourceHash,
155
+ destinationPath,
156
+ cacheDir,
157
+ maxWidth,
158
+ ) {
159
+ const outputWidth = Math.min(sourceWidth, maxWidth);
160
+ const cachePath = getCachePath(
161
+ cacheDir,
162
+ sourceHash,
163
+ "png",
164
+ maxWidth,
165
+ PNG_OPTIONS,
166
+ );
167
+ activeCachePaths.add(cachePath);
168
+ activeCachePaths.add(getManifestPath(cachePath));
169
+
170
+ if (await useCachedEntry(cachePath, "png", outputWidth)) {
171
+ await fs.promises.copyFile(cachePath, destinationPath);
172
+ return {
173
+ fileName: path.basename(destinationPath),
174
+ width: outputWidth,
175
+ };
176
+ }
177
+
178
+ recordCacheMiss(sourcePath);
179
+ let temporaryPath;
180
+
181
+ try {
182
+ await fs.promises.mkdir(cacheDir, { recursive: true });
183
+ temporaryPath = createTemporaryPath(cachePath);
184
+ await sharp(source)
185
+ .rotate()
186
+ .resize({ width: maxWidth, withoutEnlargement: true })
187
+ .png(PNG_OPTIONS)
188
+ .toFile(temporaryPath);
189
+
190
+ const optimizedSize = (await fs.promises.stat(temporaryPath)).size;
191
+ if (sourceWidth <= maxWidth && optimizedSize >= source.length) {
192
+ await fs.promises.rm(temporaryPath, { force: true });
193
+ await writeFileAtomically(cachePath, source);
194
+ } else {
195
+ await fs.promises.rename(temporaryPath, cachePath);
196
+ }
197
+ await writeCacheManifest(cachePath, "png", outputWidth);
198
+ cacheStats.writes += 1;
199
+ } catch (error) {
200
+ if (temporaryPath) await fs.promises.rm(temporaryPath, { force: true });
201
+ console.warn(
202
+ `Warning: PNG cache processing failed for ${sourcePath}; publishing the source image. ${error.message}`,
203
+ );
204
+ await fs.promises.copyFile(sourcePath, destinationPath);
205
+ return {
206
+ fileName: path.basename(destinationPath),
207
+ width: sourceWidth,
208
+ };
209
+ }
210
+
211
+ await fs.promises.copyFile(cachePath, destinationPath);
212
+ return {
213
+ fileName: path.basename(destinationPath),
214
+ width: outputWidth,
215
+ };
216
+ }
217
+
218
+ async function publishWebP(
219
+ sourcePath,
220
+ source,
221
+ sourceHash,
222
+ destinationPath,
223
+ cacheDir,
224
+ width,
225
+ ) {
226
+ const cachePath = getCachePath(
227
+ cacheDir,
228
+ sourceHash,
229
+ "webp",
230
+ width,
231
+ WEBP_OPTIONS,
232
+ );
233
+ activeCachePaths.add(cachePath);
234
+ activeCachePaths.add(getManifestPath(cachePath));
235
+
236
+ if (!(await useCachedEntry(cachePath, "webp", width))) {
237
+ recordCacheMiss(sourcePath);
238
+ let temporaryPath;
239
+ try {
240
+ await fs.promises.mkdir(cacheDir, { recursive: true });
241
+ temporaryPath = createTemporaryPath(cachePath);
242
+ await sharp(source)
243
+ .rotate()
244
+ .resize({ width, withoutEnlargement: true })
245
+ .webp(WEBP_OPTIONS)
246
+ .toFile(temporaryPath);
247
+ await fs.promises.rename(temporaryPath, cachePath);
248
+ await writeCacheManifest(cachePath, "webp", width);
249
+ cacheStats.writes += 1;
250
+ } catch (error) {
251
+ if (temporaryPath) await fs.promises.rm(temporaryPath, { force: true });
252
+ console.warn(
253
+ `Warning: WebP cache processing failed for ${sourcePath}; publishing only the PNG fallback. ${error.message}`,
254
+ );
255
+ return false;
256
+ }
257
+ }
258
+
259
+ await fs.promises.copyFile(cachePath, destinationPath);
260
+ return true;
261
+ }
262
+
263
+ async function useCachedEntry(cachePath, format, width) {
264
+ ensureCacheStats();
265
+ const manifestPath = getManifestPath(cachePath);
266
+ if (!fs.existsSync(cachePath) || !fs.existsSync(manifestPath)) {
267
+ await Promise.all([
268
+ fs.promises.rm(cachePath, { recursive: true, force: true }),
269
+ fs.promises.rm(manifestPath, { recursive: true, force: true }),
270
+ ]);
271
+ return false;
272
+ }
273
+
274
+ try {
275
+ const [contents, manifestContents] = await Promise.all([
276
+ fs.promises.readFile(cachePath),
277
+ fs.promises.readFile(manifestPath, "utf8"),
278
+ ]);
279
+ const manifest = JSON.parse(manifestContents);
280
+ const digest = crypto.createHash("sha256").update(contents).digest("hex");
281
+ if (
282
+ manifest.schema === CACHE_SCHEMA_VERSION &&
283
+ manifest.format === format &&
284
+ manifest.width === width &&
285
+ manifest.size === contents.length &&
286
+ manifest.sha256 === digest
287
+ ) {
288
+ cacheStats.hits += 1;
289
+ return true;
290
+ }
291
+ } catch {
292
+ // Invalid cache entries are removed and regenerated below.
293
+ }
294
+
295
+ await fs.promises.rm(cachePath, { recursive: true, force: true });
296
+ await fs.promises.rm(manifestPath, { recursive: true, force: true });
297
+ return false;
298
+ }
299
+
300
+ async function writeCacheManifest(cachePath, format, width) {
301
+ const contents = await fs.promises.readFile(cachePath);
302
+ await writeFileAtomically(
303
+ getManifestPath(cachePath),
304
+ JSON.stringify({
305
+ schema: CACHE_SCHEMA_VERSION,
306
+ format,
307
+ width,
308
+ size: contents.length,
309
+ sha256: crypto.createHash("sha256").update(contents).digest("hex"),
310
+ }),
311
+ );
312
+ }
313
+
314
+ function getManifestPath(cachePath) {
315
+ return `${cachePath}.json`;
316
+ }
317
+
318
+ function getCachePath(cacheDir, sourceHash, format, width, options) {
319
+ const cacheKey = createCacheKey(sourceHash, format, width, options);
320
+ return path.join(cacheDir, `${cacheKey}.${format}`);
321
+ }
322
+
323
+ function createCacheKey(
324
+ sourceHash,
325
+ format,
326
+ width,
327
+ options,
328
+ encoderVersions = getEncoderVersions(format),
329
+ ) {
330
+ return crypto
331
+ .createHash("sha256")
332
+ .update(
333
+ JSON.stringify({
334
+ schema: CACHE_SCHEMA_VERSION,
335
+ sourceHash,
336
+ format,
337
+ width,
338
+ rotate: true,
339
+ withoutEnlargement: true,
340
+ options,
341
+ encoderVersions,
342
+ }),
343
+ )
344
+ .digest("hex");
345
+ }
346
+
347
+ function getEncoderVersions(format) {
348
+ return format === "webp"
349
+ ? {
350
+ sharp: sharp.versions.sharp,
351
+ vips: sharp.versions.vips,
352
+ webp: sharp.versions.webp,
353
+ }
354
+ : {
355
+ sharp: sharp.versions.sharp,
356
+ vips: sharp.versions.vips,
357
+ png: sharp.versions.png,
358
+ };
359
+ }
360
+
361
+ function createTemporaryPath(cachePath) {
362
+ return `${cachePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
363
+ }
364
+
365
+ async function writeFileAtomically(destinationPath, contents) {
366
+ await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
367
+ const temporaryPath = createTemporaryPath(destinationPath);
368
+ try {
369
+ await fs.promises.writeFile(temporaryPath, contents);
370
+ try {
371
+ await fs.promises.rename(temporaryPath, destinationPath);
372
+ } catch (error) {
373
+ if (error.code !== "EEXIST" && error.code !== "EPERM") throw error;
374
+ await fs.promises.rm(destinationPath, { force: true });
375
+ await fs.promises.rename(temporaryPath, destinationPath);
376
+ }
377
+ } catch (error) {
378
+ await fs.promises.rm(temporaryPath, { force: true });
379
+ throw error;
380
+ }
381
+ }
382
+
383
+ function recordCacheMiss(sourcePath) {
384
+ ensureCacheStats();
385
+ cacheStats.misses += 1;
386
+ cacheStats.regeneratedImages.add(sourcePath);
387
+ }
388
+
389
+ function ensureCacheStats() {
390
+ if (!cacheStats) beginImageBuild();
391
+ }
392
+
393
+ function getCacheRoot(config) {
394
+ return (
395
+ config.dev.cacheDir || path.resolve(config.cacheDir || ".fossbook-cache")
396
+ );
397
+ }
398
+
399
+ function getImageCacheDir(config) {
400
+ return path.join(getCacheRoot(config), "images");
401
+ }
402
+
403
+ module.exports = {
404
+ CACHE_SCHEMA_VERSION,
405
+ beginImageBuild,
406
+ createCacheKey,
407
+ finishImageBuild,
408
+ getImageCacheStats,
409
+ pruneImageCache,
410
+ publishImage,
411
+ };