poops 1.2.3 → 1.3.0

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
  }
@@ -517,7 +511,7 @@ Then use Tailwind utility classes directly in your markup templates. Tailwind v4
517
511
 
518
512
  ### Markups
519
513
 
520
- - `engine` (optional) - the template engine to use. Can be `"nunjucks"` (default) or `"liquid"`. [Nunjucks](https://mozilla.github.io/nunjucks/) is a Mozilla template engine inspired by Jinja2. [Liquid](https://liquidjs.com/) is a Shopify-compatible template engine. Both engines support the same tags, filters, collections, search index, and sitemap features documented below.
514
+ - `engine` (optional) - the template engine to use. Can be `"nunjucks"` (default) or `"liquid"`. [Nunjucks](https://mozilla.github.io/nunjucks/) is a Mozilla template engine inspired by Jinja2. [Liquid](https://liquidjs.com/) is a Shopify-compatible template engine. Both engines support the same tags, filters, collections, search index, sitemap, and navigation tree features documented below.
521
515
  - `in` - the input path, can be a directory or a file path, but please just use it as a directory path for now. All files in this directory will be processed and the structure of the directory will be preserved in the output directory with exception to directories that begin with an underscore `_` will be ignored.
522
516
  - `out` - the output path, can be only a directory path (for now)
523
517
  - `site` (optional) - global data that will be available to all templates in the markup directory. Like site title, description, social media links, etc. You can then use this data in your templates `{{ site.title }}` for instance.
@@ -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
  ```
@@ -578,19 +561,158 @@ If your project doesn't have markups, you can remove the `markup` property from
578
561
 
579
562
  #### Nunjucks vs Liquid
580
563
 
581
- Both engines support the same feature set (collections, pagination, search index, sitemap, custom tags, and filters). The main differences are in template syntax:
564
+ Both engines support the same feature set (collections, pagination, search index, sitemap, navigation tree, 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
+ #### Custom Engines
578
+
579
+ The `engine` option also accepts a module specifier — an npm package name or a path relative to your project root. The module's default export must be an engine class:
580
+
581
+ ```json
582
+ {
583
+ "markup": {
584
+ "in": "src/markup",
585
+ "out": "dist",
586
+ "engine": "poops-shopify"
587
+ }
588
+ }
589
+ ```
590
+
591
+ An engine class implements this contract (see [`lib/markup/engines/`](lib/markup/engines/) for the two built-in reference implementations):
592
+
593
+ ```js
594
+ export default class MyEngine {
595
+ constructor(templatesDir, includePaths, options) {} // options: { autoescape }
596
+ get fileExtension() { return '.liquid' } // native template extension
597
+ get indexableExtensions() { return new Set(['.html']) } // extensions eligible for search index/nav
598
+ get markupExtensions() { return 'html|liquid|md' } // glob alternation of processed extensions
599
+ registerFilters({ timeDateFormat, markupOut }) {}
600
+ registerTags(getOutputDir) {}
601
+ setGlobal(key, value) {}
602
+ removeGlobal(key) {}
603
+ async render(templatePath, context) { return 'html' } // templatePath is an absolute file path
604
+ async renderString(source, context) { return 'html' }
605
+ }
606
+ ```
607
+
608
+ Optionally, an engine may implement `replaceOutExtensions(outputPath)` to control how source extensions map to output extensions (the default maps `.md`/`.njk`/`.liquid` to `.html`).
609
+
610
+ The easiest starting point is extending a built-in engine — deep imports are intentionally supported for this:
611
+
612
+ ```js
613
+ import LiquidEngine from 'poops/lib/markup/engines/liquid.js'
614
+
615
+ export default class MyEngine extends LiquidEngine {
616
+ registerFilters(opts) {
617
+ super.registerFilters(opts)
618
+ this.engine.registerFilter('shout', (str) => String(str).toUpperCase())
619
+ }
620
+ }
621
+ ```
622
+
623
+ #### Collections & Pagination
624
+
625
+ 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.
626
+
627
+ There are two ways to declare a collection:
628
+
629
+ **1. Front matter auto-discovery** — add `collection` to the front matter of the directory's index file:
630
+
631
+ ```yaml
632
+ ---
633
+ title: Changelog
634
+ collection: true
635
+ paginate: 10
636
+ sort: date
637
+ ---
638
+ ```
639
+
640
+ `collection: true` uses the directory name as the collection name; a string (e.g. `collection: changelog`) names it explicitly. `paginate` and `sort` are optional.
641
+
642
+ **2. Config** — list collections in the markup config. The name must match a subdirectory of `in`:
643
+
644
+ ```json
645
+ {
646
+ "markup": {
647
+ "in": "src/markup",
648
+ "out": "dist",
649
+ "collections": [
650
+ "changelog",
651
+ {
652
+ "name": "blog",
653
+ "paginate": 5,
654
+ "sort": { "by": "title", "order": "asc" }
655
+ }
656
+ ]
657
+ }
658
+ }
659
+ ```
660
+
661
+ **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`).
662
+
663
+ **Items.** Each item exposes its own front matter plus properties Poops adds:
664
+
665
+ - `url` - the item's output path relative to the site root (e.g. `changelog/my-post.html`)
666
+ - `title` - falls back to the file name if not set in front matter
667
+ - `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.
668
+ - `wordcount`, `fileName`, `filePath`, `collection`
669
+
670
+ An item with `published: false` in its front matter is excluded from the collection and its page is not built.
671
+
672
+ **Using collections in templates.** Every collection is available as a global variable named after it, on every page:
673
+
674
+ ```nunjucks
675
+ {% for post in changelog.items %}
676
+ <a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a> — {{ post.date | date }}
677
+ {% endfor %}
678
+ ```
679
+
680
+ **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:
681
+
682
+ | Variable | Description |
683
+ | --------------------------- | ------------------------------------------------------- |
684
+ | `pageItems` | the items on the current page |
685
+ | `pageNumber` / `totalPages` | current page (1-based) / total page count |
686
+ | `pageUrl` | URL of the current page (`changelog`, `changelog/2`, …) |
687
+ | `nextPage` / `nextPageUrl` | next page number / URL, `null` on the last page |
688
+ | `prevPage` / `prevPageUrl` | previous page number / URL, `null` on the first page |
689
+
690
+ From the example site's `changelog/index.html`:
691
+
692
+ ```nunjucks
693
+ {% for post in changelog.pageItems %}
694
+ <div class="post">
695
+ <h2><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></h2>
696
+ <div class="date">{{ post.date | date }}</div>
697
+ {{ post.description }}
698
+ </div>
699
+ {% endfor %}
700
+
701
+ {% if changelog.totalPages > 1 %}
702
+ {% if changelog.nextPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.nextPageUrl }}">Next</a>{% endif %}
703
+ {{ changelog.pageNumber }} of {{ changelog.totalPages }}
704
+ {% if changelog.prevPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.prevPageUrl }}">Previous</a>{% endif %}
705
+ {% endif %}
706
+ ```
707
+
708
+ Or use the `{% pagination %}` shorthand tag (available in both engines), which renders Previous/Next links and a "page of total" counter — with `relativePathPrefix` applied — and outputs nothing when there is only one page:
709
+
710
+ ```nunjucks
711
+ {% pagination changelog %}
712
+ ```
713
+
714
+ 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.
715
+
594
716
  #### Custom Tags
595
717
 
596
718
  ##### image
@@ -602,11 +724,13 @@ Poops can generate responsive `<img>` elements with `srcset` attributes. Image p
602
724
  **`{% image %}` tag** — generates a full `<img>` element:
603
725
 
604
726
  Nunjucks:
727
+
605
728
  ```nunjucks
606
729
  {% image 'static/photo.jpg', alt='Hero', class='hero-img', sizes='(max-width: 640px) 100vw, 50vw' %}
607
730
  ```
608
731
 
609
732
  Liquid:
733
+
610
734
  ```liquid
611
735
  {% image 'static/photo.jpg', alt: 'Hero', class: 'hero-img', sizes: '(max-width: 640px) 100vw, 50vw' %}
612
736
  ```
@@ -635,21 +759,31 @@ Output:
635
759
  - Defaults: `sizes="100vw"`, `loading="lazy"`
636
760
  - Falls back to a plain `<img src="...">` if no variants are found
637
761
 
762
+ **[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:
763
+
764
+ - `width` and `height` attributes on the `<img>` element (exact dimensions from the cache — prevents layout shift). Pass your own `width`/`height` kwargs to override.
765
+ - Correct `src` when the source format was converted (e.g. `photo.heic` → `photo.jpg`), even when there are no size variants.
766
+ - 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.
767
+ - EXIF metadata via the `exif` filter (see below).
768
+
638
769
  ##### googleFonts
639
770
 
640
771
  Generates Google Fonts `<link>` tags with preconnect hints. Accepts an array of font names (strings) or font objects with weight/italic options.
641
772
 
642
773
  Nunjucks (supports inline arrays):
774
+
643
775
  ```nunjucks
644
776
  {% googleFonts ["Open Sans", "Roboto"] %}
645
777
  ```
646
778
 
647
779
  Liquid (pass a variable — inline arrays are not supported in Liquid syntax):
780
+
648
781
  ```liquid
649
782
  {% googleFonts fonts %}
650
783
  ```
651
784
 
652
785
  Where `fonts` is defined in a data file (e.g. `fonts.json`):
786
+
653
787
  ```json
654
788
  ["Open Sans", "Roboto"]
655
789
  ```
@@ -674,7 +808,7 @@ With specific weights and italics (Nunjucks):
674
808
  With specific weights and italics (Liquid — via data file):
675
809
 
676
810
  ```json
677
- ["DM Sans", {"name": "Poppins", "weights": [400, 700], "ital": true}]
811
+ ["DM Sans", { "name": "Poppins", "weights": [400, 700], "ital": true }]
678
812
  ```
679
813
 
680
814
  Font object options:
@@ -749,6 +883,7 @@ All filters are available in both engines. The only syntax difference is how arg
749
883
  - Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
750
884
 
751
885
  Example — group posts by year:
886
+
752
887
  ```nunjucks
753
888
  {% set byYear = changelog.items | groupby("date", "year") %}
754
889
  {% for group in byYear %}
@@ -772,11 +907,74 @@ All filters are available in both engines. The only syntax difference is how arg
772
907
 
773
908
  Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
774
909
 
775
- #### Search Index & Sitemap
910
+ - `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`).
911
+
912
+ Example — a photo with date and location caption:
776
913
 
777
- 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.
914
+ ```nunjucks
915
+ {% set meta = 'static/photo.jpeg' | exif %}
916
+ <figure>
917
+ {% image 'static/photo.jpeg', alt='Sendai at dusk' %}
918
+ {% if meta %}
919
+ <figcaption>
920
+ {{ meta.dateTime | date("MMMM D, YYYY") }}
921
+ {% if meta.gps %}
922
+ — <a href="{{ meta.gps.googleMapsUrl }}">{{ meta.gps.latitude.formatted }}, {{ meta.gps.longitude.formatted }}</a>
923
+ {% endif %}
924
+ {% if meta.model %}· {{ meta.model }}{% endif %}
925
+ </figcaption>
926
+ {% endif %}
927
+ </figure>
928
+ ```
778
929
 
779
- To enable, add `searchIndex` and/or `sitemap` to your markup config:
930
+ - `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:
931
+ - `path` — site-relative source path, feeds straight into the `{% image %}` tag
932
+ - `date` — `exif.dateTime` when the photo has EXIF, file modification time otherwise — so sorting and grouping work for every image
933
+ - `outputs` — every generated file for the image (site-relative), useful for picking LQIP or preprocessed variants
934
+ - Pass a subdirectory (`'images/2025'`) to scope the list
935
+
936
+ Combined with `groupby`, engine-native sorting and the `{% image %}` tag, a photo gallery is a pure template concern:
937
+
938
+ Nunjucks:
939
+
940
+ ```nunjucks
941
+ {% for group in 'images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %}
942
+ <h2>{{ group.key }}</h2>
943
+ <div class="grid">
944
+ {% for img in group.items %}
945
+ <figure>
946
+ {% image img.path, alt='', sizes='(max-width: 640px) 50vw, 25vw' %}
947
+ {% if img.exif and img.exif.gps %}
948
+ <figcaption>
949
+ <a href="{{ img.exif.gps.googleMapsUrl }}">📍</a> {{ img.date | date("MMM D, YYYY") }}
950
+ </figcaption>
951
+ {% endif %}
952
+ </figure>
953
+ {% endfor %}
954
+ </div>
955
+ {% endfor %}
956
+ ```
957
+
958
+ Liquid:
959
+
960
+ ```liquid
961
+ {% assign imgs = 'images' | images | sort: 'date' | reverse %}
962
+ {% assign groups = imgs | groupby: "date", "year" %}
963
+ {% for group in groups %}
964
+ <h2>{{ group.key }}</h2>
965
+ <div class="grid">
966
+ {% for img in group.items %}
967
+ <figure>{% image img.path, alt: '' %}</figure>
968
+ {% endfor %}
969
+ </div>
970
+ {% endfor %}
971
+ ```
972
+
973
+ #### Search Index, Sitemap & Navigation
974
+
975
+ Poops can automatically generate a JSON search index, an XML sitemap and a navigation tree from your compiled pages. All are generated in a single pass during the markup compilation phase.
976
+
977
+ To enable, add `searchIndex`, `sitemap` and/or `nav` to your markup config:
780
978
 
781
979
  ```json
782
980
  {
@@ -842,6 +1040,138 @@ All front matter fields are passed through to the index automatically. Internal
842
1040
 
843
1041
  Pages with `published: false` in their front matter are excluded from both outputs.
844
1042
 
1043
+ **Navigation tree** builds your page hierarchy as sidebar-ready data, exposed two ways: as the `nav` template global (loaded automatically, always reflecting the current build) and as a nested JSON file for client-side rendering. Subpages nest automatically from URL structure: `guide/index.md` becomes a parent node and `guide/getting-started.md`, `guide/advanced/config.md` become its (and its subsections') children. Add `nav` to your markup config:
1044
+
1045
+ ```json
1046
+ {
1047
+ "markup": {
1048
+ "in": "src/markup",
1049
+ "out": "dist",
1050
+ "options": {
1051
+ "nav": "nav.json"
1052
+ }
1053
+ }
1054
+ }
1055
+ ```
1056
+
1057
+ The string shorthand sets the output filename. For docs sites, use the object form:
1058
+
1059
+ ```json
1060
+ {
1061
+ "nav": {
1062
+ "output": "nav.json",
1063
+ "root": "docs",
1064
+ "collections": "index",
1065
+ "home": false
1066
+ }
1067
+ }
1068
+ ```
1069
+
1070
+ **Navigation options:**
1071
+
1072
+ - `output` — output filename, written to the markup output directory
1073
+ - `collections` — how to treat collection pages (default `true`):
1074
+ - `true` — include every collection page, nested under its collection
1075
+ - `false` — exclude all collection pages (drops a blog's posts from the sidebar)
1076
+ - `["docs", ...]` — allowlist; only these collections' pages are included (non-collection pages are always kept)
1077
+ - `"index"` — include only each collection's landing page as a single leaf, not its posts
1078
+ - `home` — `false` drops the site's root index page (url `""`) from the tree (default `true`)
1079
+ - `root` — scope the tree to a subdirectory (e.g. `"docs"`); its children are emitted at the top level and the section's own index page is pinned first as the overview link. URLs are kept full (`docs/getting-started`), so the homepage is naturally excluded
1080
+
1081
+ **Front matter fields** that shape the tree:
1082
+
1083
+ - `order` — a number that sorts a page within its sibling level (optional). Pages without `order` fall to the bottom, sorted alphabetically by title — so a hand-authored docs sequence (`1`, `2`, `3`) wins over alphabetical. This applies to the homepage too: give it `order: 0` in its front matter to pin it to the top, otherwise it sorts last like any page without `order`.
1084
+ - `nav: false` — hide a page from the sidebar (it stays in the search index and sitemap).
1085
+ - `navTitle` — a sidebar label that overrides `title`.
1086
+
1087
+ **Navigation output format** — each node has a `title`, a `url` (omitted on synthesized section nodes that have no index page of their own), an `order` when set, and `children` when it has subpages:
1088
+
1089
+ ```json
1090
+ [
1091
+ { "title": "Guide", "url": "guide", "order": 1, "children": [
1092
+ { "title": "Getting Started", "url": "guide/getting-started", "order": 1 },
1093
+ { "title": "Advanced", "url": "guide/advanced", "children": [
1094
+ { "title": "Config", "url": "guide/advanced/config" }
1095
+ ]}
1096
+ ]}
1097
+ ]
1098
+ ```
1099
+
1100
+ Pages with `published: false` or `nav: false` are excluded. If nothing survives filtering, an empty array `[]` is written so consumers never have to special-case a missing file.
1101
+
1102
+ **Rendering the sidebar.** The tree is arbitrarily deep, so render it with a recursive template. The `nav` global is built from front matter in a pre-pass before templating, so it always reflects the current build — no need to load the generated `nav.json` back in via `data` (which would be one build behind). The written `nav.json` is for client-side rendering (`fetch('/nav.json')`). Prefix each `url` with `relativePathPrefix` so links resolve from any page depth.
1103
+
1104
+ Nunjucks — a self-recursing macro:
1105
+
1106
+ ```njk
1107
+ {% macro navtree(items) %}
1108
+ <ul>
1109
+ {% for item in items %}
1110
+ <li>
1111
+ {% if item.url != null %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
1112
+ {% else %}<span>{{ item.title }}</span>{% endif %}
1113
+ {% if item.children %}{{ navtree(item.children) }}{% endif %}
1114
+ </li>
1115
+ {% endfor %}
1116
+ </ul>
1117
+ {% endmacro %}
1118
+
1119
+ {{ navtree(nav) }}
1120
+ ```
1121
+
1122
+ Note the `!= null` check: the homepage node's `url` is an empty string (a valid link — `relativePathPrefix` resolves it), while synthesized section nodes have no `url` at all. A plain `{% if item.url %}` would wrongly demote the homepage to a `<span>`. Node titles already have `navTitle` applied, so `{{ item.title }}` is all you need.
1123
+
1124
+ Liquid — a partial that recurses via `render` (save as `_partials/navtree.liquid`). Liquid treats empty strings as truthy, so the plain `if` is safe here:
1125
+
1126
+ ```liquid
1127
+ <ul>
1128
+ {% for item in items %}
1129
+ <li>
1130
+ {% if item.url %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
1131
+ {% else %}<span>{{ item.title }}</span>{% endif %}
1132
+ {% if item.children %}{% render 'navtree', items: item.children, relativePathPrefix: relativePathPrefix %}{% endif %}
1133
+ </li>
1134
+ {% endfor %}
1135
+ </ul>
1136
+ ```
1137
+
1138
+ ```liquid
1139
+ {% render 'navtree', items: nav, relativePathPrefix: relativePathPrefix %}
1140
+ ```
1141
+
1142
+ ### Images (optional)
1143
+
1144
+ 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).
1145
+
1146
+ poops-images (and its `sharp` dependency) is **not** bundled with Poops. Install it in your project only if you use the `images` config:
1147
+
1148
+ ```bash
1149
+ npm i poops-images
1150
+ ```
1151
+
1152
+ 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.
1153
+
1154
+ 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:
1155
+
1156
+ - `in` — source images directory
1157
+ - `out` — output directory (keep it distinct from `in`, and outside your watched source, so generated variants don't retrigger the build)
1158
+ - `sizes` — responsive widths to generate
1159
+ - `format` — target formats (e.g. `["webp"]`, or `"smart"` to keep whichever of JPEG/WebP is smaller)
1160
+ - `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.
1161
+
1162
+ ```json
1163
+ {
1164
+ "images": {
1165
+ "in": "src/images",
1166
+ "out": "dist/images",
1167
+ "sizes": [{ "width": 640 }, { "width": 1280 }],
1168
+ "format": "smart"
1169
+ }
1170
+ }
1171
+ ```
1172
+
1173
+ 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`.
1174
+
845
1175
  ### Copy
846
1176
 
847
1177
  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 +1275,18 @@ Live reload options:
945
1275
 
946
1276
  - `port` - the port on which the livereload server will run
947
1277
  - `exclude` - an array of files and directories to exclude from livereload
1278
+ - `extraExts` - an array of extra file extensions (without the dot) that trigger a browser refresh, added to the defaults
1279
+ - `exts` - an array of file extensions that replaces the default list entirely
1280
+
1281
+ 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:
1282
+
1283
+ ```json
1284
+ {
1285
+ "livereload": {
1286
+ "extraExts": ["slim", "njk"]
1287
+ }
1288
+ }
1289
+ ```
948
1290
 
949
1291
  `livereload` can only be `true`, which means that it will run on the default port (`35729`) or you can specify a port:
950
1292
 
@@ -1011,23 +1353,6 @@ Same as `watch` property, `includePaths` accepts an array of paths to include. I
1011
1353
  }
1012
1354
  ```
1013
1355
 
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
1356
  ## Why?
1032
1357
 
1033
1358
  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,9 +92,10 @@ 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
- const outputFilePath = path.join(process.cwd(), file)
98
+ const outputFilePath = path.resolve(process.cwd(), file)
97
99
 
98
100
  if (pathExists(outputFilePath)) {
99
101
  if (pathIsDirectory(outputFilePath)) {