bunki 0.6.1 → 0.7.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
@@ -4,7 +4,7 @@
4
4
  [![Coverage Status](https://coveralls.io/repos/github/kahwee/bunki/badge.svg?branch=main)](https://coveralls.io/github/kahwee/bunki?branch=main)
5
5
  [![npm version](https://badge.fury.io/js/bunki.svg)](https://badge.fury.io/js/bunki)
6
6
 
7
- Fast static site generator for blogs and documentation built with Bun. Supports Markdown + frontmatter, tags, year-based archives, pagination, RSS feeds, sitemaps, secure HTML sanitization, syntax highlighting, PostCSS pipelines, image uploads (S3/R2), and Nunjucks templating.
7
+ Fast static site generator for blogs and documentation built with Bun. Supports Markdown + frontmatter, tags, year-based archives, pagination, RSS feeds, sitemaps, secure HTML sanitization, syntax highlighting, PostCSS pipelines, media uploads (images & videos to S3/R2), incremental uploads with year filtering, and Nunjucks templating.
8
8
 
9
9
  ## Install
10
10
 
@@ -124,22 +124,411 @@ CSS is processed automatically during `bunki generate`.
124
124
 
125
125
  ## Image Management
126
126
 
127
- Upload images to Cloudflare R2 or S3:
127
+ ### Overview
128
+
129
+ The `images:push` command uploads local media (images and videos) to Cloudflare R2, AWS S3, or any S3-compatible storage provider. Media files are organized by year in the `images/` directory and uploaded with their full directory structure preserved.
130
+
131
+ **Supported formats:**
132
+
133
+ - **Images:** JPG, JPEG, PNG, GIF, WebP, SVG
134
+ - **Video:** MP4
135
+
136
+ ### Directory Structure
137
+
138
+ Organize images by year and post slug:
139
+
140
+ ```
141
+ images/
142
+ ├── 2023/
143
+ │ ├── post-slug-1/
144
+ │ │ ├── image-1.jpg
145
+ │ │ └── image-2.png
146
+ │ └── post-slug-2/
147
+ │ └── photo.webp
148
+ ├── 2024/
149
+ │ └── travel-guide/
150
+ │ ├── paris-1.jpg
151
+ │ ├── london-2.jpg
152
+ │ ├── tokyo-3.png
153
+ │ └── travel-vlog.mp4
154
+ └── 2025/
155
+ └── new-post/
156
+ ├── screenshot.jpg
157
+ └── demo-video.mp4
158
+ ```
159
+
160
+ The directory structure is preserved when uploading to cloud storage.
161
+
162
+ ### Configuration
163
+
164
+ Add S3/R2 configuration to `bunki.config.ts`:
165
+
166
+ ```typescript
167
+ import { SiteConfig } from "bunki";
168
+
169
+ export default (): SiteConfig => ({
170
+ title: "My Blog",
171
+ // ... other config
172
+
173
+ // Image upload configuration
174
+ s3: {
175
+ accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
176
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
177
+ bucket: process.env.S3_BUCKET || "",
178
+ endpoint: process.env.S3_ENDPOINT, // Optional: for R2, etc.
179
+ region: process.env.S3_REGION || "auto",
180
+ publicUrl: process.env.S3_PUBLIC_URL || "",
181
+ },
182
+ });
183
+ ```
184
+
185
+ ### Environment Variables
186
+
187
+ Set these in your `.env` file or export them in your shell:
188
+
189
+ ```bash
190
+ # Required
191
+ export S3_ACCESS_KEY_ID="your-access-key"
192
+ export S3_SECRET_ACCESS_KEY="your-secret-key"
193
+ export S3_BUCKET="your-bucket-name"
194
+ export S3_PUBLIC_URL="https://cdn.example.com"
195
+
196
+ # Optional (for Cloudflare R2 or custom endpoints)
197
+ export S3_ENDPOINT="https://r2.cloudflarestorage.com"
198
+ export S3_REGION="auto"
199
+
200
+ # Optional (custom domain per bucket)
201
+ export S3_CUSTOM_DOMAIN_YOUR_BUCKET="cdn.example.com"
202
+ ```
203
+
204
+ ### Basic Usage
205
+
206
+ Upload all images:
207
+
208
+ ```bash
209
+ bunki images:push
210
+ ```
211
+
212
+ This command:
213
+
214
+ 1. Scans the `images/` directory recursively
215
+ 2. Uploads all supported image formats
216
+ 3. Preserves the directory structure (year/slug/filename)
217
+ 4. Generates public URLs for each image
218
+
219
+ ### Command Options
220
+
221
+ #### `--images <dir>`
222
+
223
+ Specify a custom images directory (default: `./images`)
224
+
225
+ ```bash
226
+ bunki images:push --images ./assets/images
227
+ ```
228
+
229
+ #### `--domain <domain>`
230
+
231
+ Set a custom domain for bucket identification (optional)
232
+
233
+ ```bash
234
+ bunki images:push --domain my-blog
235
+ ```
236
+
237
+ #### `--output-json <file>`
238
+
239
+ Export a JSON mapping of filenames to their public URLs
240
+
241
+ ```bash
242
+ bunki images:push --output-json image-urls.json
243
+ ```
244
+
245
+ This creates a JSON file with the structure:
246
+
247
+ ```json
248
+ {
249
+ "2023/post-slug/image.jpg": "https://cdn.example.com/2023/post-slug/image.jpg",
250
+ "2024/travel/paris.jpg": "https://cdn.example.com/2024/travel/paris.jpg"
251
+ }
252
+ ```
253
+
254
+ #### `--min-year <year>`
255
+
256
+ Upload only images from the specified year onwards
257
+
258
+ ```bash
259
+ # Upload only 2023 and 2024 images (skip 2021, 2022)
260
+ bunki images:push --min-year 2023
261
+
262
+ # Upload only 2024 and newer images
263
+ bunki images:push --min-year 2024
264
+
265
+ # Upload from 2022 onwards (all images in this example)
266
+ bunki images:push --min-year 2022
267
+ ```
268
+
269
+ This is useful for:
270
+
271
+ - Incremental uploads (upload only new images)
272
+ - Testing uploads for specific years
273
+ - Managing large image collections across multiple uploads
274
+
275
+ ### Complete Examples
276
+
277
+ #### Cloudflare R2 Setup
278
+
279
+ 1. **Create R2 bucket and API token** in Cloudflare dashboard
280
+
281
+ 2. **Set environment variables:**
282
+
283
+ ```bash
284
+ export S3_ACCESS_KEY_ID="your-r2-api-token-id"
285
+ export S3_SECRET_ACCESS_KEY="your-r2-api-token-secret"
286
+ export S3_BUCKET="my-blog-images"
287
+ export S3_ENDPOINT="https://r2.cloudflarestorage.com"
288
+ export S3_REGION="auto"
289
+ export S3_PUBLIC_URL="https://cdn.example.com"
290
+ ```
291
+
292
+ 3. **Upload images:**
293
+
294
+ ```bash
295
+ bunki images:push --output-json image-urls.json
296
+ ```
297
+
298
+ #### AWS S3 Setup
299
+
300
+ 1. **Create S3 bucket and IAM user** in AWS Console
301
+
302
+ 2. **Set environment variables:**
303
+
304
+ ```bash
305
+ export S3_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
306
+ export S3_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
307
+ export S3_BUCKET="my-blog-bucket"
308
+ export S3_REGION="us-east-1"
309
+ export S3_PUBLIC_URL="https://my-blog-bucket.s3.amazonaws.com"
310
+ ```
311
+
312
+ 3. **Upload images:**
313
+
314
+ ```bash
315
+ bunki images:push
316
+ ```
317
+
318
+ #### Incremental Upload (Year-Based)
319
+
320
+ If you have thousands of images and want to upload them incrementally:
321
+
322
+ ```bash
323
+ # First, upload all 2023 images
324
+ bunki images:push --min-year 2023 --max-year 2023
325
+
326
+ # Next, upload 2024 images
327
+ bunki images:push --min-year 2024 --max-year 2024
328
+
329
+ # Finally, upload 2025 images
330
+ bunki images:push --min-year 2025
331
+ ```
332
+
333
+ ### Using Uploaded Images in Markdown
334
+
335
+ After uploading, reference images in your Markdown posts:
336
+
337
+ ```markdown
338
+ ---
339
+ title: "Paris Trip"
340
+ date: 2024-06-15T10:00:00
341
+ tags: [travel, france]
342
+ ---
343
+
344
+ # My Trip to Paris
345
+
346
+ ![Eiffel Tower at sunset](https://cdn.example.com/2024/paris-trip/eiffel-tower.jpg)
347
+
348
+ ![Louvre Museum](https://cdn.example.com/2024/paris-trip/louvre.jpg)
349
+
350
+ ## Evening Stroll
351
+
352
+ The Parisian streets at night are magical.
353
+
354
+ ![Seine River at night](https://cdn.example.com/2024/paris-trip/seine-night.jpg)
355
+ ```
356
+
357
+ ### Using Uploaded Videos in Markdown
358
+
359
+ Upload MP4 videos alongside your images and embed them in your posts:
360
+
361
+ ```markdown
362
+ ---
363
+ title: "Travel Vlog"
364
+ date: 2024-06-15T10:00:00
365
+ tags: [travel, video]
366
+ ---
367
+
368
+ # My Paris Adventure
369
+
370
+ Watch my trip to Paris:
371
+
372
+ <video controls width="640" height="360">
373
+ <source src="https://cdn.example.com/2024/paris-trip/travel-vlog.mp4" type="video/mp4">
374
+ Your browser does not support HTML5 video.
375
+ </video>
376
+
377
+ ## Behind the Scenes
378
+
379
+ Check out the making of the vlog:
380
+
381
+ <video controls width="640" height="360">
382
+ <source src="https://cdn.example.com/2024/paris-trip/behind-scenes.mp4" type="video/mp4">
383
+ Your browser does not support HTML5 video.
384
+ </video>
385
+ ```
386
+
387
+ **Video Upload Example:**
388
+
389
+ ```bash
390
+ # Upload all images and videos (including MP4 files)
391
+ bunki images:push
392
+
393
+ # Upload only 2024 videos and images
394
+ bunki images:push --min-year 2024
395
+
396
+ # Preview what would be uploaded without actually uploading
397
+ BUNKI_DRY_RUN=true bunki images:push --min-year 2024
398
+ ```
399
+
400
+ **Video File Organization:**
401
+
402
+ Keep videos organized the same way as images for consistency:
403
+
404
+ ```
405
+ images/
406
+ ├── 2024/
407
+ │ └── travel-vlog/
408
+ │ ├── intro.mp4
409
+ │ ├── highlights.mp4
410
+ │ ├── thumbnail.jpg
411
+ │ └── poster.jpg
412
+ └── 2025/
413
+ └── tutorial/
414
+ ├── part-1.mp4
415
+ ├── part-2.mp4
416
+ └── preview.jpg
417
+ ```
418
+
419
+ **Video Tips:**
420
+
421
+ 1. **File Size**: Keep MP4 files optimized (under 50MB recommended)
422
+ - Use tools like FFmpeg to compress before uploading
423
+ - Example: `ffmpeg -i input.mp4 -crf 28 output.mp4`
424
+
425
+ 2. **Format & Codec**:
426
+ - Use H.264 video codec for best compatibility
427
+ - Use AAC audio codec
428
+ - Container: MP4 (.mp4 extension)
429
+
430
+ 3. **Video Dimensions**:
431
+ - Keep 16:9 aspect ratio for web
432
+ - Common resolutions: 640x360, 1280x720, 1920x1080
433
+
434
+ 4. **Hosting**:
435
+ - MP4s benefit from CDN caching via S3/R2
436
+ - Cloudflare R2 provides excellent video delivery
437
+ - AWS S3 with CloudFront for additional acceleration
438
+
439
+ ### Dry Run Mode
440
+
441
+ Test the upload process without actually uploading:
442
+
443
+ ```bash
444
+ # Preview what would be uploaded (no actual upload)
445
+ BUNKI_DRY_RUN=true bunki images:push
446
+ ```
447
+
448
+ This shows:
449
+
450
+ - Which images would be uploaded
451
+ - The directory structure that would be created
452
+ - Generated public URLs
453
+
454
+ ### Troubleshooting
455
+
456
+ #### "Missing S3 configuration"
457
+
458
+ Ensure all required environment variables are set. Check `bunki.config.ts` and your `.env` file.
459
+
460
+ #### "No image files found"
461
+
462
+ - Verify images exist in `images/` directory
463
+ - Check that files have supported extensions (.jpg, .png, .gif, .webp, .svg)
464
+ - Ensure the directory structure is correct (e.g., `images/2024/post-slug/image.jpg`)
465
+
466
+ #### "Unauthorized" or "Access Denied"
467
+
468
+ - Verify S3 credentials (access key and secret key)
469
+ - Check that the IAM user/API token has S3 permissions
470
+ - Confirm the bucket name is correct
471
+
472
+ #### "Invalid bucket name"
473
+
474
+ - S3 bucket names must be globally unique
475
+ - Use only lowercase letters, numbers, and hyphens
476
+ - Bucket names must be 3-63 characters long
477
+
478
+ ### Advanced Configuration
479
+
480
+ #### Custom Domain per Bucket
481
+
482
+ If you have multiple S3 buckets with different custom domains:
128
483
 
129
484
  ```bash
130
- # Set up environment variables first
131
- cat > .env << EOF
132
- R2_ACCESS_KEY_ID=...
133
- R2_SECRET_ACCESS_KEY=...
134
- R2_BUCKET=...
135
- R2_PUBLIC_URL=https://cdn.example.com
136
- EOF
485
+ export S3_CUSTOM_DOMAIN_MY_BUCKET="cdn1.example.com"
486
+ export S3_CUSTOM_DOMAIN_BACKUP_BUCKET="cdn2.example.com"
487
+ ```
488
+
489
+ The bucket name is converted to uppercase and hyphens to underscores for the environment variable name.
490
+
491
+ #### Direct CDN URLs
492
+
493
+ Configure public URLs with custom domains:
494
+
495
+ ```typescript
496
+ // bunki.config.ts
497
+ s3: {
498
+ // ... other config
499
+ publicUrl: "https://img.example.com",
500
+ }
501
+ ```
502
+
503
+ Or via environment variable:
137
504
 
138
- # Upload images
139
- bunki images:push --images ./images --output-json image-map.json
505
+ ```bash
506
+ export S3_PUBLIC_URL="https://img.example.com"
140
507
  ```
141
508
 
142
- Supported formats: JPG, PNG, GIF, WebP, SVG
509
+ ### Performance Tips
510
+
511
+ 1. **Use year-based filtering** for large image collections:
512
+
513
+ ```bash
514
+ bunki images:push --min-year 2024 # Only newest images
515
+ ```
516
+
517
+ 2. **Organize by post slug** for better directory structure:
518
+
519
+ ```
520
+ images/2024/post-title/image.jpg
521
+ images/2024/post-title/photo.jpg
522
+ ```
523
+
524
+ 3. **Compress images before uploading** to save storage:
525
+ - Use tools like `imagemin` or built-in OS utilities
526
+ - Aim for 500KB or smaller per image
527
+
528
+ 4. **Use modern formats** (WebP) for better compression:
529
+ - JPG/PNG for screenshots
530
+ - WebP for photos
531
+ - SVG for icons/graphics
143
532
 
144
533
  ## CLI Commands
145
534
 
@@ -177,7 +566,8 @@ dist/
177
566
  - **Performance**: Static files, optional gzip, optimized output
178
567
  - **Templating**: Nunjucks with custom filters and macros
179
568
  - **Styling**: Built-in PostCSS support for modern CSS frameworks
180
- - **Images**: Direct S3/R2 uploads with URL mapping
569
+ - **Media Management**: Direct S3/R2 uploads for images and MP4 videos with URL mapping
570
+ - **Incremental Uploads**: Year-based filtering (`--min-year`) for large media collections
181
571
  - **SEO**: Automatic RSS feeds, sitemaps, meta tags
182
572
  - **Pagination**: Configurable posts per page
183
573
  - **Archives**: Year-based and tag-based organization
@@ -216,7 +606,21 @@ bunki/
216
606
 
217
607
  ## Changelog
218
608
 
219
- ### v0.5.3 (Current)
609
+ ### v0.7.0 (Current)
610
+
611
+ - **Media uploads**: Added MP4 video support alongside image uploads
612
+ - **Incremental uploads**: Year-based filtering with `--min-year` option
613
+ - **Enhanced documentation**: Comprehensive video upload guide with examples
614
+ - **Test coverage**: Added 10+ tests for image/video uploader functionality
615
+ - **Fixed timestamps**: Stable dates in test fixtures to prevent flipping
616
+
617
+ ### v0.6.1
618
+
619
+ - Version bump and welcome date stabilization
620
+ - Test formatting improvements
621
+ - Code style consistency updates
622
+
623
+ ### v0.5.3
220
624
 
221
625
  - Modularized CLI commands with dependency injection
222
626
  - Enhanced test coverage (130+ tests, 539+ assertions)
@@ -9,6 +9,7 @@ export declare function handleImagesPushCommand(options: {
9
9
  domain?: string;
10
10
  images: string;
11
11
  outputJson?: string;
12
+ minYear?: string;
12
13
  }, deps?: ImagesPushDeps): Promise<void>;
13
14
  export declare function registerImagesPushCommand(program: Command): Command;
14
15
  export {};
package/dist/cli.js CHANGED
@@ -1596,7 +1596,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
1596
1596
  return arg.length > 1 && arg[0] === "-";
1597
1597
  }
1598
1598
  const negativeNumberArg = (arg) => {
1599
- if (!/^-\d*\.?\d+(e[+-]?\d+)?$/.test(arg))
1599
+ if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(arg))
1600
1600
  return false;
1601
1601
  return !this._getCommandAndAncestors().some((cmd) => cmd.options.map((opt) => opt.short).some((short) => /^-\d$/.test(short)));
1602
1602
  };
@@ -28255,7 +28255,14 @@ async function processCSS(options2) {
28255
28255
  }
28256
28256
  function runPostCSS(inputPath, outputPath, configPath, projectRoot, verbose) {
28257
28257
  return new Promise((resolve, reject) => {
28258
- const args = ["postcss", inputPath, "-o", outputPath, "--config", configPath];
28258
+ const args = [
28259
+ "postcss",
28260
+ inputPath,
28261
+ "-o",
28262
+ outputPath,
28263
+ "--config",
28264
+ configPath
28265
+ ];
28259
28266
  const postcss = spawn("bunx", args, {
28260
28267
  stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"],
28261
28268
  cwd: projectRoot
@@ -33595,52 +33602,59 @@ class S3Uploader {
33595
33602
  }
33596
33603
  }
33597
33604
  }
33598
- async uploadImages(imagesDir) {
33605
+ async executeWithConcurrency(tasks, concurrency) {
33606
+ const results = [];
33607
+ const executing = [];
33608
+ for (const task of tasks) {
33609
+ const promise = task().then((result) => {
33610
+ results.push(result);
33611
+ const index = executing.indexOf(promise);
33612
+ if (index > -1)
33613
+ executing.splice(index, 1);
33614
+ }).catch((error) => {
33615
+ const index = executing.indexOf(promise);
33616
+ if (index > -1)
33617
+ executing.splice(index, 1);
33618
+ });
33619
+ executing.push(promise);
33620
+ if (executing.length >= concurrency) {
33621
+ await Promise.race(executing);
33622
+ }
33623
+ }
33624
+ await Promise.all(executing);
33625
+ return results;
33626
+ }
33627
+ async uploadImages(imagesDir, minYear) {
33599
33628
  console.log(`[S3] Uploading all images from ${imagesDir} to bucket ${this.s3Config.bucket}...`);
33629
+ if (minYear) {
33630
+ console.log(`[S3] Filtering images from year ${minYear} onwards`);
33631
+ }
33600
33632
  const imageUrls = {};
33601
33633
  try {
33602
- console.log(`[S3] Checking if directory exists: ${imagesDir}`);
33603
- try {
33604
- const glob2 = new Bun.Glob("**/*");
33605
- let hasContent = false;
33606
- for await (const file of glob2.scan({
33607
- cwd: imagesDir,
33608
- absolute: false
33609
- })) {
33610
- hasContent = true;
33611
- break;
33612
- }
33613
- if (!hasContent) {
33614
- console.warn(`Directory exists but is empty: ${imagesDir}`);
33615
- }
33616
- console.log(`[S3] Directory exists and is accessible`);
33617
- } catch (err) {
33618
- const errorMessage = err instanceof Error ? err.message : String(err);
33619
- console.warn(`No images directory found at ${imagesDir}, skipping image upload. Error: ${errorMessage}`);
33620
- return imageUrls;
33621
- }
33622
33634
  const glob = new Bun.Glob("**/*.{jpg,jpeg,png,gif,webp,svg}");
33623
33635
  const files = [];
33624
33636
  console.log(`[S3] Scanning directory ${imagesDir} for image files...`);
33625
33637
  try {
33626
- const dirGlob = new Bun.Glob("**/*");
33627
- const allFiles = [];
33628
- for await (const file of dirGlob.scan({
33638
+ for await (const file of glob.scan({
33629
33639
  cwd: imagesDir,
33630
33640
  absolute: false
33631
33641
  })) {
33632
- allFiles.push(file);
33642
+ if (minYear) {
33643
+ const yearMatch = file.match(/^(\d{4})\//);
33644
+ if (yearMatch) {
33645
+ const fileYear = parseInt(yearMatch[1], 10);
33646
+ if (fileYear >= minYear) {
33647
+ files.push(file);
33648
+ }
33649
+ }
33650
+ } else {
33651
+ files.push(file);
33652
+ }
33633
33653
  }
33634
- console.log(`[S3] Files in directory (including subdirs): ${allFiles.length > 0 ? allFiles.slice(0, 10).join(", ") + (allFiles.length > 10 ? "..." : "") : "none"}`);
33635
33654
  } catch (err) {
33636
- console.error(`[S3] Error reading directory:`, err);
33637
- }
33638
- for await (const file of glob.scan({
33639
- cwd: imagesDir,
33640
- absolute: false
33641
- })) {
33642
- console.log(`[S3] Found image file: ${file}`);
33643
- files.push(file);
33655
+ const errorMessage = err instanceof Error ? err.message : String(err);
33656
+ console.warn(`Error scanning images directory: ${errorMessage}`);
33657
+ return imageUrls;
33644
33658
  }
33645
33659
  const imageFiles = files;
33646
33660
  if (imageFiles.length === 0) {
@@ -33648,27 +33662,35 @@ class S3Uploader {
33648
33662
  return imageUrls;
33649
33663
  }
33650
33664
  console.log(`Found ${imageFiles.length} images to upload`);
33651
- for (const imageFile of imageFiles) {
33665
+ console.log(`[S3] Processing with 10 concurrent uploads...`);
33666
+ const concurrencyLimit = 10;
33667
+ let uploadedCount = 0;
33668
+ let failedCount = 0;
33669
+ const uploadTasks = imageFiles.map((imageFile) => async () => {
33652
33670
  try {
33653
33671
  const imagePath = path7.join(imagesDir, imageFile);
33654
33672
  const filename = path7.basename(imagePath);
33655
- console.log(`[S3] Uploading image ${imagePath} to S3 bucket ${this.s3Config.bucket}/${imageFile}...`);
33656
33673
  const file = Bun.file(imagePath);
33657
33674
  const contentType = file.type;
33658
- if (process.env.BUNKI_DRY_RUN === "true") {
33659
- console.log(`[S3] Dry run: would upload ${imageFile} with content type ${contentType}`);
33660
- } else {
33675
+ if (process.env.BUNKI_DRY_RUN === "true") {} else {
33661
33676
  const s3File = this.client.file(imageFile);
33662
33677
  await s3File.write(file);
33663
33678
  }
33664
33679
  const imageUrl = this.getPublicUrl(imageFile);
33665
- console.log(`[S3] Image uploaded to ${imageUrl}`);
33666
33680
  imageUrls[imageFile] = imageUrl;
33681
+ uploadedCount++;
33682
+ if (uploadedCount % 10 === 0) {
33683
+ console.log(`[S3] Progress: ${uploadedCount}/${imageFiles.length} images uploaded`);
33684
+ }
33685
+ return { success: true, file: imageFile };
33667
33686
  } catch (error) {
33668
- console.error(`Error uploading ${imageFile}:`, error);
33687
+ failedCount++;
33688
+ console.error(`[S3] Error uploading ${imageFile}:`, error);
33689
+ return { success: false, file: imageFile };
33669
33690
  }
33670
- }
33671
- console.log(`[S3] Successfully uploaded ${Object.keys(imageUrls).length} of ${imageFiles.length} images`);
33691
+ });
33692
+ await this.executeWithConcurrency(uploadTasks, concurrencyLimit);
33693
+ console.log(`[S3] Upload complete: ${uploadedCount} succeeded, ${failedCount} failed out of ${imageFiles.length} images`);
33672
33694
  return imageUrls;
33673
33695
  } catch (error) {
33674
33696
  console.error(`Error uploading images:`, error);
@@ -33715,8 +33737,11 @@ async function uploadImages(options2 = {}) {
33715
33737
  };
33716
33738
  }
33717
33739
  console.log(`Uploading images from ${imagesDir} to bucket ${s3Config.bucket}`);
33740
+ if (options2.minYear) {
33741
+ console.log(`Filtering images from year ${options2.minYear} onwards`);
33742
+ }
33718
33743
  const uploader = createUploader(s3Config);
33719
- const imageUrlMap = await uploader.uploadImages(imagesDir);
33744
+ const imageUrlMap = await uploader.uploadImages(imagesDir, options2.minYear);
33720
33745
  if (options2.outputJson) {
33721
33746
  const outputFile = path8.resolve(options2.outputJson);
33722
33747
  await Bun.write(outputFile, JSON.stringify(imageUrlMap, null, 2));
@@ -33734,7 +33759,7 @@ Image upload completed successfully!`);
33734
33759
  return imageUrlMap;
33735
33760
  } catch (error) {
33736
33761
  console.error("Error uploading images:", error);
33737
- process.exit(1);
33762
+ throw error;
33738
33763
  }
33739
33764
  }
33740
33765
 
@@ -33749,7 +33774,8 @@ async function handleImagesPushCommand(options2, deps = defaultDeps3) {
33749
33774
  await deps.uploadImages({
33750
33775
  domain: options2.domain,
33751
33776
  images: options2.images,
33752
- outputJson: options2.outputJson
33777
+ outputJson: options2.outputJson,
33778
+ minYear: options2.minYear ? parseInt(options2.minYear, 10) : undefined
33753
33779
  });
33754
33780
  } catch (error) {
33755
33781
  deps.logger.error("Error uploading images:", error);
@@ -33757,7 +33783,7 @@ async function handleImagesPushCommand(options2, deps = defaultDeps3) {
33757
33783
  }
33758
33784
  }
33759
33785
  function registerImagesPushCommand(program2) {
33760
- return program2.command("images:push").description("Upload images to S3-compatible storage").option("-d, --domain <domain>", "Domain name for bucket identification (defaults to domain in bunki.config.ts)").option("-i, --images <dir>", "Images directory path", DEFAULT_IMAGES_DIR).option("--output-json <file>", "Output URL mapping to JSON file").action(async (options2) => {
33786
+ return program2.command("images:push").description("Upload images to S3-compatible storage").option("-d, --domain <domain>", "Domain name for bucket identification (defaults to domain in bunki.config.ts)").option("-i, --images <dir>", "Images directory path", DEFAULT_IMAGES_DIR).option("--output-json <file>", "Output URL mapping to JSON file").option("--min-year <year>", "Only upload images from the specified year onwards (e.g., 2023 uploads 2023, 2024, etc.)").action(async (options2) => {
33761
33787
  await handleImagesPushCommand(options2);
33762
33788
  });
33763
33789
  }
@@ -34234,7 +34260,7 @@ function getDefaultCss() {
34234
34260
  function getSamplePost() {
34235
34261
  return `---
34236
34262
  title: Welcome to Bunki
34237
- date: ${new Date().toISOString()}
34263
+ date: 2025-01-15T12:00:00Z
34238
34264
  tags: [getting-started, bunki]
34239
34265
  ---
34240
34266
 
package/dist/index.js CHANGED
@@ -30815,7 +30815,14 @@ async function processCSS(options2) {
30815
30815
  }
30816
30816
  function runPostCSS(inputPath, outputPath, configPath, projectRoot, verbose) {
30817
30817
  return new Promise((resolve, reject) => {
30818
- const args = ["postcss", inputPath, "-o", outputPath, "--config", configPath];
30818
+ const args = [
30819
+ "postcss",
30820
+ inputPath,
30821
+ "-o",
30822
+ outputPath,
30823
+ "--config",
30824
+ configPath
30825
+ ];
30819
30826
  const postcss = spawn("bunx", args, {
30820
30827
  stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"],
30821
30828
  cwd: projectRoot
@@ -31502,52 +31509,59 @@ class S3Uploader {
31502
31509
  }
31503
31510
  }
31504
31511
  }
31505
- async uploadImages(imagesDir) {
31512
+ async executeWithConcurrency(tasks, concurrency) {
31513
+ const results = [];
31514
+ const executing = [];
31515
+ for (const task of tasks) {
31516
+ const promise = task().then((result) => {
31517
+ results.push(result);
31518
+ const index = executing.indexOf(promise);
31519
+ if (index > -1)
31520
+ executing.splice(index, 1);
31521
+ }).catch((error) => {
31522
+ const index = executing.indexOf(promise);
31523
+ if (index > -1)
31524
+ executing.splice(index, 1);
31525
+ });
31526
+ executing.push(promise);
31527
+ if (executing.length >= concurrency) {
31528
+ await Promise.race(executing);
31529
+ }
31530
+ }
31531
+ await Promise.all(executing);
31532
+ return results;
31533
+ }
31534
+ async uploadImages(imagesDir, minYear) {
31506
31535
  console.log(`[S3] Uploading all images from ${imagesDir} to bucket ${this.s3Config.bucket}...`);
31536
+ if (minYear) {
31537
+ console.log(`[S3] Filtering images from year ${minYear} onwards`);
31538
+ }
31507
31539
  const imageUrls = {};
31508
31540
  try {
31509
- console.log(`[S3] Checking if directory exists: ${imagesDir}`);
31510
- try {
31511
- const glob2 = new Bun.Glob("**/*");
31512
- let hasContent = false;
31513
- for await (const file of glob2.scan({
31514
- cwd: imagesDir,
31515
- absolute: false
31516
- })) {
31517
- hasContent = true;
31518
- break;
31519
- }
31520
- if (!hasContent) {
31521
- console.warn(`Directory exists but is empty: ${imagesDir}`);
31522
- }
31523
- console.log(`[S3] Directory exists and is accessible`);
31524
- } catch (err) {
31525
- const errorMessage = err instanceof Error ? err.message : String(err);
31526
- console.warn(`No images directory found at ${imagesDir}, skipping image upload. Error: ${errorMessage}`);
31527
- return imageUrls;
31528
- }
31529
31541
  const glob = new Bun.Glob("**/*.{jpg,jpeg,png,gif,webp,svg}");
31530
31542
  const files = [];
31531
31543
  console.log(`[S3] Scanning directory ${imagesDir} for image files...`);
31532
31544
  try {
31533
- const dirGlob = new Bun.Glob("**/*");
31534
- const allFiles = [];
31535
- for await (const file of dirGlob.scan({
31545
+ for await (const file of glob.scan({
31536
31546
  cwd: imagesDir,
31537
31547
  absolute: false
31538
31548
  })) {
31539
- allFiles.push(file);
31549
+ if (minYear) {
31550
+ const yearMatch = file.match(/^(\d{4})\//);
31551
+ if (yearMatch) {
31552
+ const fileYear = parseInt(yearMatch[1], 10);
31553
+ if (fileYear >= minYear) {
31554
+ files.push(file);
31555
+ }
31556
+ }
31557
+ } else {
31558
+ files.push(file);
31559
+ }
31540
31560
  }
31541
- console.log(`[S3] Files in directory (including subdirs): ${allFiles.length > 0 ? allFiles.slice(0, 10).join(", ") + (allFiles.length > 10 ? "..." : "") : "none"}`);
31542
31561
  } catch (err) {
31543
- console.error(`[S3] Error reading directory:`, err);
31544
- }
31545
- for await (const file of glob.scan({
31546
- cwd: imagesDir,
31547
- absolute: false
31548
- })) {
31549
- console.log(`[S3] Found image file: ${file}`);
31550
- files.push(file);
31562
+ const errorMessage = err instanceof Error ? err.message : String(err);
31563
+ console.warn(`Error scanning images directory: ${errorMessage}`);
31564
+ return imageUrls;
31551
31565
  }
31552
31566
  const imageFiles = files;
31553
31567
  if (imageFiles.length === 0) {
@@ -31555,27 +31569,35 @@ class S3Uploader {
31555
31569
  return imageUrls;
31556
31570
  }
31557
31571
  console.log(`Found ${imageFiles.length} images to upload`);
31558
- for (const imageFile of imageFiles) {
31572
+ console.log(`[S3] Processing with 10 concurrent uploads...`);
31573
+ const concurrencyLimit = 10;
31574
+ let uploadedCount = 0;
31575
+ let failedCount = 0;
31576
+ const uploadTasks = imageFiles.map((imageFile) => async () => {
31559
31577
  try {
31560
31578
  const imagePath = path6.join(imagesDir, imageFile);
31561
31579
  const filename = path6.basename(imagePath);
31562
- console.log(`[S3] Uploading image ${imagePath} to S3 bucket ${this.s3Config.bucket}/${imageFile}...`);
31563
31580
  const file = Bun.file(imagePath);
31564
31581
  const contentType = file.type;
31565
- if (process.env.BUNKI_DRY_RUN === "true") {
31566
- console.log(`[S3] Dry run: would upload ${imageFile} with content type ${contentType}`);
31567
- } else {
31582
+ if (process.env.BUNKI_DRY_RUN === "true") {} else {
31568
31583
  const s3File = this.client.file(imageFile);
31569
31584
  await s3File.write(file);
31570
31585
  }
31571
31586
  const imageUrl = this.getPublicUrl(imageFile);
31572
- console.log(`[S3] Image uploaded to ${imageUrl}`);
31573
31587
  imageUrls[imageFile] = imageUrl;
31588
+ uploadedCount++;
31589
+ if (uploadedCount % 10 === 0) {
31590
+ console.log(`[S3] Progress: ${uploadedCount}/${imageFiles.length} images uploaded`);
31591
+ }
31592
+ return { success: true, file: imageFile };
31574
31593
  } catch (error) {
31575
- console.error(`Error uploading ${imageFile}:`, error);
31594
+ failedCount++;
31595
+ console.error(`[S3] Error uploading ${imageFile}:`, error);
31596
+ return { success: false, file: imageFile };
31576
31597
  }
31577
- }
31578
- console.log(`[S3] Successfully uploaded ${Object.keys(imageUrls).length} of ${imageFiles.length} images`);
31598
+ });
31599
+ await this.executeWithConcurrency(uploadTasks, concurrencyLimit);
31600
+ console.log(`[S3] Upload complete: ${uploadedCount} succeeded, ${failedCount} failed out of ${imageFiles.length} images`);
31579
31601
  return imageUrls;
31580
31602
  } catch (error) {
31581
31603
  console.error(`Error uploading images:`, error);
@@ -31622,8 +31644,11 @@ async function uploadImages(options2 = {}) {
31622
31644
  };
31623
31645
  }
31624
31646
  console.log(`Uploading images from ${imagesDir} to bucket ${s3Config.bucket}`);
31647
+ if (options2.minYear) {
31648
+ console.log(`Filtering images from year ${options2.minYear} onwards`);
31649
+ }
31625
31650
  const uploader = createUploader(s3Config);
31626
- const imageUrlMap = await uploader.uploadImages(imagesDir);
31651
+ const imageUrlMap = await uploader.uploadImages(imagesDir, options2.minYear);
31627
31652
  if (options2.outputJson) {
31628
31653
  const outputFile = path7.resolve(options2.outputJson);
31629
31654
  await Bun.write(outputFile, JSON.stringify(imageUrlMap, null, 2));
@@ -31641,7 +31666,7 @@ Image upload completed successfully!`);
31641
31666
  return imageUrlMap;
31642
31667
  } catch (error) {
31643
31668
  console.error("Error uploading images:", error);
31644
- process.exit(1);
31669
+ throw error;
31645
31670
  }
31646
31671
  }
31647
31672
  export {
package/dist/types.d.ts CHANGED
@@ -146,9 +146,10 @@ export interface ImageUploader {
146
146
  /**
147
147
  * Upload all images from a directory
148
148
  * @param imagesDir Directory containing images to upload
149
+ * @param minYear Optional minimum year to filter (e.g., 2023 uploads 2023, 2024, etc.)
149
150
  * @returns Record of image filenames to their public URLs
150
151
  */
151
- uploadImages(imagesDir: string): Promise<Record<string, string>>;
152
+ uploadImages(imagesDir: string, minYear?: number): Promise<Record<string, string>>;
152
153
  }
153
154
  /**
154
155
  * S3 configuration type
@@ -169,4 +170,5 @@ export interface ImageUploadOptions {
169
170
  domain?: string;
170
171
  images?: string;
171
172
  outputJson?: string;
173
+ minYear?: number;
172
174
  }
@@ -13,7 +13,13 @@ export declare class S3Uploader implements Uploader, ImageUploader {
13
13
  * @returns The public URL for the file
14
14
  */
15
15
  private getPublicUrl;
16
- uploadImages(imagesDir: string): Promise<Record<string, string>>;
16
+ /**
17
+ * Execute async tasks with concurrency limit
18
+ * @param tasks Array of task functions that return promises
19
+ * @param concurrency Maximum number of concurrent tasks
20
+ */
21
+ private executeWithConcurrency;
22
+ uploadImages(imagesDir: string, minYear?: number): Promise<Record<string, string>>;
17
23
  }
18
24
  /**
19
25
  * Create an S3 uploader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunki",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "An opinionated static site generator built with Bun featuring PostCSS integration and modern web development workflows",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "homepage": "https://github.com/kahwee/bunki#readme",
49
49
  "dependencies": {
50
- "commander": "^14.0.1",
50
+ "commander": "^14.0.2",
51
51
  "gray-matter": "^4.0.3",
52
52
  "highlight.js": "^11.11.1",
53
53
  "marked": "^16.4.1",
@@ -59,15 +59,15 @@
59
59
  "slugify": "^1.6.6"
60
60
  },
61
61
  "devDependencies": {
62
- "@tailwindcss/postcss": "^4.1.15",
62
+ "@tailwindcss/postcss": "^4.1.16",
63
63
  "@types/nunjucks": "^3.2.6",
64
64
  "@types/sanitize-html": "^2.16.0",
65
- "autoprefixer": "^10.4.20",
66
- "bun-types": "^1.3.0",
65
+ "autoprefixer": "^10.4.21",
66
+ "bun-types": "^1.3.1",
67
67
  "husky": "^9.1.7",
68
- "lint-staged": "^16.2.5",
68
+ "lint-staged": "^16.2.6",
69
69
  "prettier": "^3.6.2",
70
- "tailwindcss": "^4.1.15",
70
+ "tailwindcss": "^4.1.16",
71
71
  "typescript": "^5.9.3"
72
72
  },
73
73
  "peerDependencies": {