basecampjs 0.0.9 → 0.0.10

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 (2) hide show
  1. package/index.js +171 -4
  2. package/package.json +3 -2
package/index.js CHANGED
@@ -15,6 +15,7 @@ import nunjucks from "nunjucks";
15
15
  import { Liquid } from "liquidjs";
16
16
  import { minify as minifyCss } from "csso";
17
17
  import { minify as minifyHtml } from "html-minifier-terser";
18
+ import sharp from "sharp";
18
19
 
19
20
  const cwd = process.cwd();
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -29,6 +30,14 @@ const defaultConfig = {
29
30
  minifyCSS: false,
30
31
  minifyHTML: false,
31
32
  cacheBustAssets: false,
33
+ excludeFiles: [],
34
+ compressPhotos: false,
35
+ compressionSettings: {
36
+ quality: 80,
37
+ formats: [".webp"],
38
+ inputFormats: [".jpg", ".jpeg", ".png"],
39
+ preserveOriginal: true
40
+ },
32
41
  integrations: { nunjucks: true, liquid: false, mustache: false, vue: false, alpine: false }
33
42
  };
34
43
 
@@ -145,12 +154,166 @@ async function cleanDir(dir) {
145
154
  await mkdir(dir, { recursive: true });
146
155
  }
147
156
 
148
- async function copyPublic(publicDir, outDir) {
149
- if (existsSync(publicDir)) {
150
- await cp(publicDir, outDir, { recursive: true });
157
+ function shouldExcludeFile(filePath, excludePatterns) {
158
+ if (!excludePatterns || excludePatterns.length === 0) return false;
159
+
160
+ const fileName = basename(filePath).toLowerCase();
161
+ const ext = extname(filePath).toLowerCase();
162
+
163
+ return excludePatterns.some(pattern => {
164
+ const normalized = pattern.toLowerCase();
165
+ // Support extension patterns like '.pdf' or 'pdf'
166
+ if (normalized.startsWith('.')) {
167
+ return ext === normalized;
168
+ }
169
+ if (normalized.startsWith('*.')) {
170
+ return ext === normalized.slice(1);
171
+ }
172
+ // Support exact filename matches
173
+ if (fileName === normalized) {
174
+ return true;
175
+ }
176
+ // Support glob-like patterns with wildcards
177
+ if (normalized.includes('*')) {
178
+ const regex = new RegExp('^' + normalized.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
179
+ return regex.test(fileName);
180
+ }
181
+ return false;
182
+ });
183
+ }
184
+
185
+ async function copyPublic(publicDir, outDir, excludePatterns = []) {
186
+ if (!existsSync(publicDir)) return;
187
+
188
+ const files = await walkFiles(publicDir);
189
+ for (const file of files) {
190
+ const rel = relative(publicDir, file);
191
+
192
+ // Skip excluded files
193
+ if (shouldExcludeFile(file, excludePatterns)) {
194
+ console.log(kolor.dim(`Skipping excluded file: ${rel}`));
195
+ continue;
196
+ }
197
+
198
+ const destPath = join(outDir, rel);
199
+ await ensureDir(dirname(destPath));
200
+ await cp(file, destPath);
151
201
  }
152
202
  }
153
203
 
204
+ function shouldProcessImage(filePath, config) {
205
+ if (!config.compressPhotos) return false;
206
+ const ext = extname(filePath).toLowerCase();
207
+ const inputFormats = config.compressionSettings?.inputFormats || [".jpg", ".jpeg", ".png"];
208
+ return inputFormats.includes(ext);
209
+ }
210
+
211
+ async function processImage(inputPath, outDir, settings) {
212
+ const ext = extname(inputPath);
213
+ const baseName = basename(inputPath, ext);
214
+ const dir = dirname(inputPath);
215
+ const relDir = relative(outDir, dir);
216
+
217
+ const results = [];
218
+ const quality = settings.quality || 80;
219
+ const formats = settings.formats || [".webp"];
220
+
221
+ for (const format of formats) {
222
+ try {
223
+ const outputName = `${baseName}${format}`;
224
+ const outputPath = join(dir, outputName);
225
+
226
+ const sharpInstance = sharp(inputPath);
227
+
228
+ if (format === ".webp") {
229
+ await sharpInstance.webp({ quality }).toFile(outputPath);
230
+ } else if (format === ".avif") {
231
+ await sharpInstance.avif({ quality }).toFile(outputPath);
232
+ } else if (format === ".jpg" || format === ".jpeg") {
233
+ await sharpInstance.jpeg({ quality }).toFile(outputPath);
234
+ } else if (format === ".png") {
235
+ await sharpInstance.png({ quality }).toFile(outputPath);
236
+ } else {
237
+ continue;
238
+ }
239
+
240
+ const stats = await stat(outputPath);
241
+ results.push({
242
+ path: outputPath,
243
+ format,
244
+ size: stats.size
245
+ });
246
+ } catch (err) {
247
+ console.error(kolor.red(`Failed to convert ${basename(inputPath)} to ${format}: ${err.message}`));
248
+ }
249
+ }
250
+
251
+ return results;
252
+ }
253
+
254
+ async function processImages(outDir, config) {
255
+ if (!config.compressPhotos) return;
256
+
257
+ const settings = {
258
+ quality: config.compressionSettings?.quality || 80,
259
+ formats: config.compressionSettings?.formats || [".webp"],
260
+ preserveOriginal: config.compressionSettings?.preserveOriginal !== false
261
+ };
262
+
263
+ console.log(kolor.cyan("🖼️ Processing images..."));
264
+
265
+ const files = await walkFiles(outDir);
266
+ const imageFiles = files.filter((file) => shouldProcessImage(file, config));
267
+
268
+ if (imageFiles.length === 0) {
269
+ console.log(kolor.dim("No images found to process"));
270
+ return;
271
+ }
272
+
273
+ let totalGenerated = 0;
274
+ let totalOriginalSize = 0;
275
+ let totalConvertedSize = 0;
276
+
277
+ await Promise.all(imageFiles.map(async (file) => {
278
+ const originalStats = await stat(file);
279
+ totalOriginalSize += originalStats.size;
280
+
281
+ const results = await processImage(file, outDir, settings);
282
+
283
+ if (results.length > 0) {
284
+ const formats = results.map(r => r.format.slice(1)).join(", ");
285
+ const rel = relative(outDir, file);
286
+ console.log(kolor.dim(` ${rel} → ${formats}`));
287
+
288
+ results.forEach(r => {
289
+ totalConvertedSize += r.size;
290
+ totalGenerated++;
291
+ });
292
+ }
293
+
294
+ // Remove original if preserveOriginal is false
295
+ if (!settings.preserveOriginal && results.length > 0) {
296
+ await rm(file, { force: true });
297
+ }
298
+ }));
299
+
300
+ const savedBytes = totalOriginalSize - totalConvertedSize;
301
+ const savedPercent = totalOriginalSize > 0 ? ((savedBytes / totalOriginalSize) * 100).toFixed(1) : 0;
302
+
303
+ console.log(kolor.green(`✓ Generated ${totalGenerated} image(s)`));
304
+ if (!settings.preserveOriginal) {
305
+ console.log(kolor.green(` Saved ${formatBytes(savedBytes)} (${savedPercent}% reduction)`));
306
+ }
307
+ }
308
+
309
+ function formatBytes(bytes) {
310
+ if (bytes === 0) return "0 B";
311
+ const k = 1024;
312
+ const sizes = ["B", "KB", "MB", "GB"];
313
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
314
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
315
+ }
316
+
154
317
  async function minifyCSSFiles(outDir) {
155
318
  const files = await walkFiles(outDir);
156
319
  const cssFiles = files.filter((file) => extname(file).toLowerCase() === ".css");
@@ -470,7 +633,11 @@ async function build(cwdArg = cwd) {
470
633
  const data = await loadData([dataDir, collectionsDir]);
471
634
 
472
635
  await cleanDir(outDir);
473
- await copyPublic(publicDir, outDir);
636
+ await copyPublic(publicDir, outDir, config.excludeFiles);
637
+
638
+ if (config.compressPhotos) {
639
+ await processImages(outDir, config);
640
+ }
474
641
 
475
642
  const files = await walkFiles(pagesDir);
476
643
  if (files.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basecampjs",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "BasecampJS engine for CampsiteJS static site generator.",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "liquidjs": "^10.12.0",
20
20
  "markdown-it": "^14.1.0",
21
21
  "mustache": "^4.2.0",
22
- "nunjucks": "^3.2.4"
22
+ "nunjucks": "^3.2.4",
23
+ "sharp": "^0.33.5"
23
24
  },
24
25
  "engines": {
25
26
  "node": ">=18"