poops 1.2.3 → 1.2.4

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
@@ -70,13 +70,13 @@ or pass a custom config. This is useful when you have multiple environments:
70
70
 
71
71
  **CLI Options:**
72
72
 
73
- | Flag | Short | Description |
74
- |------|-------|-------------|
75
- | `--build` | `-b` | Build the project and exit |
76
- | `--config <path>` | `-c` | Specify the config file |
77
- | `--port <number>` | `-p` | Specify the server port, overrides config |
78
- | `--livereload-port <number>` | `-l` | Specify the livereload port, overrides config |
79
- | `--base-url <path>` | `-u` | Set the base URL prefix for markup, overrides config |
73
+ | Flag | Short | Description |
74
+ | ---------------------------- | ----- | ---------------------------------------------------- |
75
+ | `--build` | `-b` | Build the project and exit |
76
+ | `--config <path>` | `-c` | Specify the config file |
77
+ | `--port <number>` | `-p` | Specify the server port, overrides config |
78
+ | `--livereload-port <number>` | `-l` | Specify the livereload port, overrides config |
79
+ | `--base-url <path>` | `-u` | Set the base URL prefix for markup, overrides config |
80
80
 
81
81
  The `--base-url` flag is particularly useful for CI/CD pipelines where the deploy path may differ per environment:
82
82
 
@@ -146,14 +146,8 @@ Just create a `poops.json` file in the root of your project and add the followin
146
146
  "title": "Poops",
147
147
  "description": "A super simple bundler for simple web projects."
148
148
  },
149
- "data": [
150
- "data/links.json",
151
- "data/poops.yaml"
152
- ],
153
- "includePaths": [
154
- "_layouts",
155
- "_partials"
156
- ]
149
+ "data": ["data/links.json", "data/poops.yaml"],
150
+ "includePaths": ["_layouts", "_partials"]
157
151
  },
158
152
  "copy": [
159
153
  {
@@ -375,13 +369,15 @@ Add `tokenPaths` to your styles config:
375
369
 
376
370
  ```json
377
371
  {
378
- "styles": [{
379
- "in": "src/scss/index.scss",
380
- "out": "dist/css/styles.css",
381
- "options": {
382
- "tokenPaths": ["src/tokens"]
372
+ "styles": [
373
+ {
374
+ "in": "src/scss/index.scss",
375
+ "out": "dist/css/styles.css",
376
+ "options": {
377
+ "tokenPaths": ["src/tokens"]
378
+ }
383
379
  }
384
- }]
380
+ ]
385
381
  }
386
382
  ```
387
383
 
@@ -459,9 +455,7 @@ You can also pass options to plugins using the tuple form:
459
455
  "in": "src/css/main.css",
460
456
  "out": "dist/css/main.css",
461
457
  "options": {
462
- "plugins": [
463
- ["autoprefixer", { "grid": true }]
464
- ]
458
+ "plugins": [["autoprefixer", { "grid": true }]]
465
459
  }
466
460
  }
467
461
  }
@@ -538,13 +532,8 @@ Here is a sample markup configuration using the default Nunjucks engine:
538
532
  "title": "My Awesome Site",
539
533
  "description": "This is my awesome site"
540
534
  },
541
- "data": [
542
- "data/links.json",
543
- "data/other.yaml"
544
- ],
545
- "includePaths": [
546
- "_includes"
547
- ],
535
+ "data": ["data/links.json", "data/other.yaml"],
536
+ "includePaths": ["_includes"],
548
537
  "baseURL": "/blog"
549
538
  }
550
539
  }
@@ -562,14 +551,8 @@ To use Liquid instead, set the `engine` property:
562
551
  "title": "My Awesome Site",
563
552
  "description": "This is my awesome site"
564
553
  },
565
- "data": [
566
- "_data/links.json",
567
- "_data/other.yaml"
568
- ],
569
- "includePaths": [
570
- "_layouts",
571
- "_partials"
572
- ]
554
+ "data": ["_data/links.json", "_data/other.yaml"],
555
+ "includePaths": ["_layouts", "_partials"]
573
556
  }
574
557
  }
575
558
  ```
@@ -580,17 +563,104 @@ If your project doesn't have markups, you can remove the `markup` property from
580
563
 
581
564
  Both engines support the same feature set (collections, pagination, search index, sitemap, custom tags, and filters). The main differences are in template syntax:
582
565
 
583
- | Feature | Nunjucks | Liquid |
584
- |---------|----------|--------|
585
- | File extension | `.njk` | `.liquid` |
586
- | Inheritance | `{% extends "base.html" %}` | `{% layout "base.liquid" %}` |
587
- | Default values | `{{ x or "fallback" }}` | `{{ x \| default: "fallback" }}` |
588
- | Contains check | `{% if "x" in items %}` | `{% if items contains "x" %}` |
589
- | Safe output | `{{ html \| safe }}` | `{{ html }}` (no escaping by default) |
590
- | Includes | `{% include "partial.njk" %}` | `{% render "partial.liquid" %}` |
566
+ | Feature | Nunjucks | Liquid |
567
+ | -------------- | ----------------------------- | ------------------------------------- |
568
+ | File extension | `.njk` | `.liquid` |
569
+ | Inheritance | `{% extends "base.html" %}` | `{% layout "base.liquid" %}` |
570
+ | Default values | `{{ x or "fallback" }}` | `{{ x \| default: "fallback" }}` |
571
+ | Contains check | `{% if "x" in items %}` | `{% if items contains "x" %}` |
572
+ | Safe output | `{{ html \| safe }}` | `{{ html }}` (no escaping by default) |
573
+ | Includes | `{% include "partial.njk" %}` | `{% render "partial.liquid" %}` |
591
574
 
592
575
  Both engines process `.html` and `.md` files in addition to their native extension.
593
576
 
577
+ #### Collections & Pagination
578
+
579
+ Collections turn a directory of pages into a sorted, optionally paginated list — blog posts, changelog entries, documentation. A collection maps to a direct subdirectory of your markup `in` directory: every `.html`, `.njk`, `.liquid` or `.md` file inside it (except the `index.*` file) becomes a collection item.
580
+
581
+ There are two ways to declare a collection:
582
+
583
+ **1. Front matter auto-discovery** — add `collection` to the front matter of the directory's index file:
584
+
585
+ ```yaml
586
+ ---
587
+ title: Changelog
588
+ collection: true
589
+ paginate: 10
590
+ sort: date
591
+ ---
592
+ ```
593
+
594
+ `collection: true` uses the directory name as the collection name; a string (e.g. `collection: changelog`) names it explicitly. `paginate` and `sort` are optional.
595
+
596
+ **2. Config** — list collections in the markup config. The name must match a subdirectory of `in`:
597
+
598
+ ```json
599
+ {
600
+ "markup": {
601
+ "in": "src/markup",
602
+ "out": "dist",
603
+ "collections": [
604
+ "changelog",
605
+ {
606
+ "name": "blog",
607
+ "paginate": 5,
608
+ "sort": { "by": "title", "order": "asc" }
609
+ }
610
+ ]
611
+ }
612
+ }
613
+ ```
614
+
615
+ **Sorting.** By default items are sorted by `date`, newest first. `sort` can be a field name shorthand (`"sort": "title"`) or an object `{ "by": "field", "order": "asc" | "desc" }`. Sorting by `date` compares dates (default order `desc`); any other field compares alphabetically (default order `asc`).
616
+
617
+ **Items.** Each item exposes its own front matter plus properties Poops adds:
618
+
619
+ - `url` - the item's output path relative to the site root (e.g. `changelog/my-post.html`)
620
+ - `title` - falls back to the file name if not set in front matter
621
+ - `date` - falls back to the file's modification time if not set, with a build warning. Set a real `date` in front matter — mtime is meaningless on CI checkouts (git clone resets it), so undated posts will reshuffle between deploys.
622
+ - `wordcount`, `fileName`, `filePath`, `collection`
623
+
624
+ An item with `published: false` in its front matter is excluded from the collection and its page is not built.
625
+
626
+ **Using collections in templates.** Every collection is available as a global variable named after it, on every page:
627
+
628
+ ```nunjucks
629
+ {% for post in changelog.items %}
630
+ <a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a> — {{ post.date | date }}
631
+ {% endfor %}
632
+ ```
633
+
634
+ **Pagination.** With `paginate: N` set, the collection's index file is rendered once per page of N items: page 1 to `out/changelog/index.html`, page 2 to `out/changelog/2/index.html`, and so on. Inside the index template the collection object carries the page state:
635
+
636
+ | Variable | Description |
637
+ | --------------------------- | ------------------------------------------------------- |
638
+ | `pageItems` | the items on the current page |
639
+ | `pageNumber` / `totalPages` | current page (1-based) / total page count |
640
+ | `pageUrl` | URL of the current page (`changelog`, `changelog/2`, …) |
641
+ | `nextPage` / `nextPageUrl` | next page number / URL, `null` on the last page |
642
+ | `prevPage` / `prevPageUrl` | previous page number / URL, `null` on the first page |
643
+
644
+ From the example site's `changelog/index.html`:
645
+
646
+ ```nunjucks
647
+ {% for post in changelog.pageItems %}
648
+ <div class="post">
649
+ <h2><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></h2>
650
+ <div class="date">{{ post.date | date }}</div>
651
+ {{ post.description }}
652
+ </div>
653
+ {% endfor %}
654
+
655
+ {% if changelog.totalPages > 1 %}
656
+ {% if changelog.nextPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.nextPageUrl }}">Next</a>{% endif %}
657
+ {{ changelog.pageNumber }} of {{ changelog.totalPages }}
658
+ {% if changelog.prevPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.prevPageUrl }}">Previous</a>{% endif %}
659
+ {% endif %}
660
+ ```
661
+
662
+ Item pages themselves are compiled like any other markup file, preserving the directory structure: `src/markup/changelog/my-post.md` → `dist/changelog/my-post.html`. A collection directory without an index file still builds its items and exposes the collection to templates — only the paginated listing pages are skipped.
663
+
594
664
  #### Custom Tags
595
665
 
596
666
  ##### image
@@ -602,11 +672,13 @@ Poops can generate responsive `<img>` elements with `srcset` attributes. Image p
602
672
  **`{% image %}` tag** — generates a full `<img>` element:
603
673
 
604
674
  Nunjucks:
675
+
605
676
  ```nunjucks
606
677
  {% image 'static/photo.jpg', alt='Hero', class='hero-img', sizes='(max-width: 640px) 100vw, 50vw' %}
607
678
  ```
608
679
 
609
680
  Liquid:
681
+
610
682
  ```liquid
611
683
  {% image 'static/photo.jpg', alt: 'Hero', class: 'hero-img', sizes: '(max-width: 640px) 100vw, 50vw' %}
612
684
  ```
@@ -635,21 +707,31 @@ Output:
635
707
  - Defaults: `sizes="100vw"`, `loading="lazy"`
636
708
  - Falls back to a plain `<img src="...">` if no variants are found
637
709
 
710
+ **[poops-images](https://github.com/stamat/poops-images) integration:** if a `.poops-images-cache.json` compile cache is found in the output directory (poops-images writes one next to the images it generates), Poops reads variants from it instead of scanning the directory. On top of the scan behavior above, the cache gives you:
711
+
712
+ - `width` and `height` attributes on the `<img>` element (exact dimensions from the cache — prevents layout shift). Pass your own `width`/`height` kwargs to override.
713
+ - Correct `src` when the source format was converted (e.g. `photo.heic` → `photo.jpg`), even when there are no size variants.
714
+ - Named sizes (`photo-thumb-200w.jpg`) and preprocessed outputs (`photo-blurred-640w.jpg`) are kept out of the srcset — they are crops and effects with their own aspect ratios. Only plain `{name}-{width}w.{ext}` variants (from the poops-images `widths` option) are used.
715
+ - EXIF metadata via the `exif` filter (see below).
716
+
638
717
  ##### googleFonts
639
718
 
640
719
  Generates Google Fonts `<link>` tags with preconnect hints. Accepts an array of font names (strings) or font objects with weight/italic options.
641
720
 
642
721
  Nunjucks (supports inline arrays):
722
+
643
723
  ```nunjucks
644
724
  {% googleFonts ["Open Sans", "Roboto"] %}
645
725
  ```
646
726
 
647
727
  Liquid (pass a variable — inline arrays are not supported in Liquid syntax):
728
+
648
729
  ```liquid
649
730
  {% googleFonts fonts %}
650
731
  ```
651
732
 
652
733
  Where `fonts` is defined in a data file (e.g. `fonts.json`):
734
+
653
735
  ```json
654
736
  ["Open Sans", "Roboto"]
655
737
  ```
@@ -674,7 +756,7 @@ With specific weights and italics (Nunjucks):
674
756
  With specific weights and italics (Liquid — via data file):
675
757
 
676
758
  ```json
677
- ["DM Sans", {"name": "Poppins", "weights": [400, 700], "ital": true}]
759
+ ["DM Sans", { "name": "Poppins", "weights": [400, 700], "ital": true }]
678
760
  ```
679
761
 
680
762
  Font object options:
@@ -749,6 +831,7 @@ All filters are available in both engines. The only syntax difference is how arg
749
831
  - Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
750
832
 
751
833
  Example — group posts by year:
834
+
752
835
  ```nunjucks
753
836
  {% set byYear = changelog.items | groupby("date", "year") %}
754
837
  {% for group in byYear %}
@@ -772,6 +855,69 @@ All filters are available in both engines. The only syntax difference is how arg
772
855
 
773
856
  Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
774
857
 
858
+ - `exif` — returns the EXIF metadata object for an image from the [poops-images](https://github.com/stamat/poops-images) compile cache (`.poops-images-cache.json` in the output directory), or `null` if there is no cache or no EXIF data. The object includes camera (`make`, `model`, `lensModel`), exposure (`fNumber`, `exposure.formatted`, `iso`, `focalLength35mm`), `dateTime`, and `gps` (`latitude.formatted`, `longitude.formatted`, `altitude`, and a ready-made `googleMapsUrl`).
859
+
860
+ Example — a photo with date and location caption:
861
+
862
+ ```nunjucks
863
+ {% set meta = 'static/photo.jpeg' | exif %}
864
+ <figure>
865
+ {% image 'static/photo.jpeg', alt='Sendai at dusk' %}
866
+ {% if meta %}
867
+ <figcaption>
868
+ {{ meta.dateTime | date("MMMM D, YYYY") }}
869
+ {% if meta.gps %}
870
+ — <a href="{{ meta.gps.googleMapsUrl }}">{{ meta.gps.latitude.formatted }}, {{ meta.gps.longitude.formatted }}</a>
871
+ {% endif %}
872
+ {% if meta.model %}· {{ meta.model }}{% endif %}
873
+ </figcaption>
874
+ {% endif %}
875
+ </figure>
876
+ ```
877
+
878
+ - `images` — lists all images under a site-relative directory from the [poops-images](https://github.com/stamat/poops-images) compile cache. Returns an array of `{ path, width, height, date, exif, outputs }` objects, or an empty array if there is no cache:
879
+ - `path` — site-relative source path, feeds straight into the `{% image %}` tag
880
+ - `date` — `exif.dateTime` when the photo has EXIF, file modification time otherwise — so sorting and grouping work for every image
881
+ - `outputs` — every generated file for the image (site-relative), useful for picking LQIP or preprocessed variants
882
+ - Pass a subdirectory (`'images/2025'`) to scope the list
883
+
884
+ Combined with `groupby`, engine-native sorting and the `{% image %}` tag, a photo gallery is a pure template concern:
885
+
886
+ Nunjucks:
887
+
888
+ ```nunjucks
889
+ {% for group in 'images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %}
890
+ <h2>{{ group.key }}</h2>
891
+ <div class="grid">
892
+ {% for img in group.items %}
893
+ <figure>
894
+ {% image img.path, alt='', sizes='(max-width: 640px) 50vw, 25vw' %}
895
+ {% if img.exif and img.exif.gps %}
896
+ <figcaption>
897
+ <a href="{{ img.exif.gps.googleMapsUrl }}">📍</a> {{ img.date | date("MMM D, YYYY") }}
898
+ </figcaption>
899
+ {% endif %}
900
+ </figure>
901
+ {% endfor %}
902
+ </div>
903
+ {% endfor %}
904
+ ```
905
+
906
+ Liquid:
907
+
908
+ ```liquid
909
+ {% assign imgs = 'images' | images | sort: 'date' | reverse %}
910
+ {% assign groups = imgs | groupby: "date", "year" %}
911
+ {% for group in groups %}
912
+ <h2>{{ group.key }}</h2>
913
+ <div class="grid">
914
+ {% for img in group.items %}
915
+ <figure>{% image img.path, alt: '' %}</figure>
916
+ {% endfor %}
917
+ </div>
918
+ {% endfor %}
919
+ ```
920
+
775
921
  #### Search Index & Sitemap
776
922
 
777
923
  Poops can automatically generate a JSON search index and/or an XML sitemap from your compiled pages. Both are generated in a single pass during the markup compilation phase.
@@ -842,6 +988,39 @@ All front matter fields are passed through to the index automatically. Internal
842
988
 
843
989
  Pages with `published: false` in their front matter are excluded from both outputs.
844
990
 
991
+ ### Images (optional)
992
+
993
+ Process and optimize images — compression, responsive size variants, format conversion (WebP/AVIF), crops and EXIF extraction — by running [poops-images](https://github.com/stamat/poops-images) as part of the build. This is what feeds the `{% image %}` tag, the `exif`/`images` filters and the `.poops-images-cache.json` compile cache described in [Custom Tags](#custom-tags) and [Custom Filters](#custom-filters).
994
+
995
+ poops-images (and its `sharp` dependency) is **not** bundled with Poops. Install it in your project only if you use the `images` config:
996
+
997
+ ```bash
998
+ npm i poops-images
999
+ ```
1000
+
1001
+ If the `images` key is present but poops-images is not installed, Poops logs a warning and skips image processing — the rest of the build still runs.
1002
+
1003
+ The `images` value is a poops-images config object (see the [poops-images options reference](https://github.com/stamat/poops-images#configuration)). The most common keys:
1004
+
1005
+ - `in` — source images directory
1006
+ - `out` — output directory (keep it distinct from `in`, and outside your watched source, so generated variants don't retrigger the build)
1007
+ - `sizes` — responsive widths to generate
1008
+ - `format` — target formats (e.g. `["webp"]`, or `"smart"` to keep whichever of JPEG/WebP is smaller)
1009
+ - `verbose` - defaults to `false`, so you get a single `[image]` summary line (count + time) instead of one log per file. Set `"verbose": true` to restore the per-file logs.
1010
+
1011
+ ```json
1012
+ {
1013
+ "images": {
1014
+ "in": "src/images",
1015
+ "out": "dist/images",
1016
+ "sizes": [{ "width": 640 }, { "width": 1280 }],
1017
+ "format": "smart"
1018
+ }
1019
+ }
1020
+ ```
1021
+
1022
+ Images are processed **before** markup, so `{% image %}` and the `images` filter always read a fresh cache. In watch mode, changing a source image reprocesses it and recompiles markup; deleting one removes its generated variants and updates the galleries that referenced it. Custom handlers and composite overlays resolve relative to your `poops.json`.
1023
+
845
1024
  ### Copy
846
1025
 
847
1026
  Configuration entry to copy files or directories - copy your static files like images and fonts, for instance, from `src` to `dist` directory. This feature was added to enable moving static files if you deploy GitHub pages via a GitHub action. If you don't want to use this feature, simply exclude the `copy` property from your config file.
@@ -945,6 +1124,18 @@ Live reload options:
945
1124
 
946
1125
  - `port` - the port on which the livereload server will run
947
1126
  - `exclude` - an array of files and directories to exclude from livereload
1127
+ - `extraExts` - an array of extra file extensions (without the dot) that trigger a browser refresh, added to the defaults
1128
+ - `exts` - an array of file extensions that replaces the default list entirely
1129
+
1130
+ By default a refresh is triggered by changes to: `html`, `css`, `js`, `png`, `gif`, `jpg`, `php`, `php5`, `py`, `rb`, `erb`, `coffee`. If you work with other file types, for example Slim or Nunjucks templates, add them:
1131
+
1132
+ ```json
1133
+ {
1134
+ "livereload": {
1135
+ "extraExts": ["slim", "njk"]
1136
+ }
1137
+ }
1138
+ ```
948
1139
 
949
1140
  `livereload` can only be `true`, which means that it will run on the default port (`35729`) or you can specify a port:
950
1141
 
@@ -1011,23 +1202,6 @@ Same as `watch` property, `includePaths` accepts an array of paths to include. I
1011
1202
  }
1012
1203
  ```
1013
1204
 
1014
- ## Todo
1015
-
1016
- - [ ] Run esbuild for each input path individually if there are multiple input paths
1017
- - [ ] Styles `in` should be able to support array of inputs like we have it on scripts
1018
- - [ ] Build a cli config creation helper tool. If the user doesn't have a config file, we can ask them a few questions and create a config file for them. Create Yeoman generator for poops projects.
1019
- - [x] Add nunjucks static templating
1020
- - [x] Refactor nunjucks implementation
1021
- - [x] Complete documentation for nunjucks
1022
- - [x] Add markdown support
1023
- - [x] Front Matter support
1024
- - [x] Future implementation: posts and custom collections, so we can have a real static site generator
1025
- - [x] Collection pagination system
1026
- - [x] Post published toggle
1027
- - [x] RSS and ATOM generation for collections
1028
- - [x] Support for images and creating srcsets
1029
- - [x] Add Liquid template engine as a swappable alternative to Nunjucks
1030
-
1031
1205
  ## Why?
1032
1206
 
1033
1207
  Why doesn't anyone maintain GULP anymore? Why does Parcel hate config files? Why are Rollup and Webpack so complex to setup for simple tasks? Vite???? What's going on?
package/lib/copy.js CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  pathIsDirectory,
5
5
  mkDir,
6
6
  copyDirectory,
7
- buildTime
7
+ buildTime,
8
+ toPosix
8
9
  } from './utils/helpers.js'
9
10
  import fs from 'node:fs'
10
11
  import path from 'node:path'
@@ -91,7 +92,8 @@ export default class Copy {
91
92
  copyPaths.out = path.join(copyPaths.out, inBaseName)
92
93
  }
93
94
 
94
- file = file.replace(copyPaths.in, copyPaths.out)
95
+ // Watcher paths arrive with native separators, config `in` uses `/`
96
+ file = toPosix(file).replace(copyPaths.in, copyPaths.out)
95
97
 
96
98
  const outputFilePath = path.join(process.cwd(), file)
97
99
 
package/lib/images.js ADDED
@@ -0,0 +1,57 @@
1
+ import log from './utils/log.js'
2
+
3
+ // Runs poops-images (https://github.com/stamat/poops-images) as a regular
4
+ // runner when a `config.images` block is present AND the package is installed.
5
+ // poops-images stays an optional peer dependency — sharp never becomes a hard
6
+ // dep of poops. The markup engines read the cache poops-images writes, so this
7
+ // runner must execute before markups.compile().
8
+ export default class Images {
9
+ constructor(config) {
10
+ this.config = config
11
+ this.processor = null // lazily created on first compile()
12
+ this.disabled = false // no images config, or poops-images not installed
13
+ }
14
+
15
+ async init() {
16
+ if (this.processor || this.disabled) return
17
+ if (!this.config.images) { this.disabled = true; return }
18
+
19
+ let ImageProcessor
20
+ try {
21
+ ImageProcessor = (await import('poops-images')).default
22
+ } catch (err) {
23
+ // Native ESM throws ERR_MODULE_NOT_FOUND; CJS-style/jest resolvers throw
24
+ // MODULE_NOT_FOUND. Either means the optional dep simply isn't installed.
25
+ if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND') {
26
+ log({ tag: 'image', warn: true, text: 'images config found but poops-images is not installed — run: npm i poops-images' })
27
+ this.disabled = true
28
+ return
29
+ }
30
+ throw err
31
+ }
32
+
33
+ // Quiet by default inside poops — one summary line instead of per-image
34
+ // logs drowning the other runners. Opt back in with "verbose": true.
35
+ // A bad images config throws here, propagating to the build's step() so
36
+ // the build is marked failed rather than silently skipping images.
37
+ this.processor = new ImageProcessor({ verbose: false, ...this.config.images })
38
+ }
39
+
40
+ async compile() {
41
+ await this.init()
42
+ if (!this.processor) return
43
+ const stats = await this.processor.processAll()
44
+ // Route through poops' log so hasLoggedErrors() flips the build exit code.
45
+ // Older poops-images without the errors field: undefined is falsy, no-op.
46
+ if (stats.errors > 0) {
47
+ log({ tag: 'image', error: true, text: `${stats.errors} image(s) failed to process` })
48
+ }
49
+ }
50
+
51
+ // Watch mode: a deleted source image removes its generated variants + cache entry.
52
+ async remove(file) {
53
+ await this.init()
54
+ if (!this.processor) return
55
+ this.processor.removeSource(file)
56
+ }
57
+ }
@@ -2,12 +2,13 @@ import fs from 'node:fs'
2
2
  import { globSync } from 'glob'
3
3
  import path from 'node:path'
4
4
  import log from '../utils/log.js'
5
- import { mkDir } from '../utils/helpers.js'
5
+ import { mkDir, toPosix } from '../utils/helpers.js'
6
6
  import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter, wordcount } from './helpers.js'
7
7
 
8
8
  export function getSingleCollectionData(markupInDir, collectionName) {
9
9
  const collectionData = []
10
- globSync(path.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)'), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
10
+ // glob patterns must use `/` on Windows `\` is an escape character
11
+ globSync(toPosix(path.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)')), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
11
12
  let frontMatter = {}
12
13
 
13
14
  let content = ''
@@ -23,13 +24,16 @@ export function getSingleCollectionData(markupInDir, collectionName) {
23
24
  if (frontMatter.published === false) return
24
25
 
25
26
  if (!frontMatter.date) {
26
- frontMatter.date = fs.statSync(file).ctime.toISOString().slice(0, 16)
27
+ // mtime is only a local-dev approximation — git clone resets it, so CI
28
+ // builds date undated posts "now". The warning is the real fix.
29
+ frontMatter.date = fs.statSync(file).mtime.toISOString().slice(0, 16)
30
+ log({ tag: 'markup', warn: true, text: 'No date in front matter, falling back to file mtime:', link: file })
27
31
  }
28
32
  frontMatter.wordcount = wordcount(content)
29
33
  frontMatter.fileName = path.basename(file)
30
34
  frontMatter.filePath = path.relative(process.cwd(), file)
31
35
  frontMatter.collection = collectionName
32
- frontMatter.url = path.join(collectionName, path.basename(frontMatter.filePath))
36
+ frontMatter.url = toPosix(path.join(collectionName, path.basename(frontMatter.filePath)))
33
37
 
34
38
  frontMatter.url = replaceOutExtensions(frontMatter.url)
35
39
 
@@ -43,7 +47,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
43
47
  }
44
48
 
45
49
  export function collectionAutoDiscovery(markupInDir) {
46
- const indexFiles = globSync(path.join(process.cwd(), markupInDir, '/**/index.+(html|njk|liquid|md)'))
50
+ const indexFiles = globSync(toPosix(path.join(process.cwd(), markupInDir, '/**/index.+(html|njk|liquid|md)')))
47
51
 
48
52
  const collectionData = {}
49
53
 
@@ -180,11 +184,25 @@ export function buildCollectionPaginationData(collectionData) {
180
184
  }
181
185
 
182
186
  export function getCollectionIndexFile(markupInDir, collectionName) {
183
- const indexFiles = globSync(path.join(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)'))
187
+ const indexFiles = globSync(toPosix(path.join(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)')))
184
188
  if (indexFiles.length === 0) return null
185
189
  return indexFiles[0]
186
190
  }
187
191
 
192
+ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutDir, keepPages) {
193
+ const outDir = path.join(process.cwd(), markupOutDir, collectionName)
194
+ if (!fs.existsSync(outDir)) return
195
+
196
+ for (const entry of fs.readdirSync(outDir)) {
197
+ const pageNumber = parseInt(entry, 10)
198
+ // pagination only ever writes out/<name>/2..totalPages/
199
+ if (String(pageNumber) !== entry || pageNumber < 2 || pageNumber <= keepPages) continue
200
+ // numeric dir mirrored from a real source dir — not pagination output
201
+ if (fs.existsSync(path.join(process.cwd(), markupInDir, collectionName, entry))) continue
202
+ fs.rmSync(path.join(outDir, entry), { recursive: true, force: true })
203
+ }
204
+ }
205
+
188
206
  export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL) {
189
207
  if (!collectionData) return []
190
208
 
@@ -199,6 +217,11 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
199
217
  collection.pages = [collection.items]
200
218
  }
201
219
 
220
+ // a shrunk page count (or removed index) leaves stale out/<name>/N/ dirs
221
+ pruneStalePaginationDirs(collection.name, markupInDir, markupOutDir, file ? collection.totalPages : 1)
222
+
223
+ if (!file) continue
224
+
202
225
  for (let i = 0; i < collection.totalPages; i++) {
203
226
  const pageNumber = i + 1
204
227
  const pageUrl = pageNumber === 1 ? collection.name : `${collection.name}/${pageNumber}`
@@ -226,8 +249,6 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
226
249
  const fromPath = path.join(process.cwd(), markupOutDir)
227
250
  const markupOutDirFull = path.dirname(markupOut)
228
251
 
229
- mkDir(markupOutDirFull)
230
-
231
252
  const context = {
232
253
  ...collectionData,
233
254
  [collectionName]: pageSnapshot,
@@ -235,11 +256,11 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
235
256
  _url: getPageUrl(markupOut)
236
257
  }
237
258
 
238
- if (!file) {
239
- continue
240
- }
241
-
242
- const compilePromise = compileEntryFn(file, context).then(({ result }) => {
259
+ // mkDir only when a page is actually written: no empty pagination dirs
260
+ // for index-less or unpublished (skipped) collection indexes
261
+ const compilePromise = compileEntryFn(file, context).then(({ result, skipped }) => {
262
+ if (skipped) return
263
+ mkDir(markupOutDirFull)
243
264
  fs.writeFileSync(markupOut, result)
244
265
  })
245
266
  compilePromises.push(compilePromise)