poops 1.6.0 β†’ 1.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
@@ -1,4 +1,4 @@
1
- # πŸ’© Poops [![npm version](https://img.shields.io/npm/v/poops)](https://www.npmjs.com/package/poops)
1
+ # πŸ’© Poops [![npm version](https://img.shields.io/npm/v/poops)](https://www.npmjs.com/package/poops) [![build status](https://github.com/stamat/poops/actions/workflows/ci.yml/badge.svg)](https://github.com/stamat/poops/actions/workflows/ci.yml) [![license](https://img.shields.io/github/license/stamat/poops.svg)](https://github.com/stamat/poops/blob/main/LICENSE)
2
2
 
3
3
  Straightforward, no-bullshit bundler for the web.
4
4
 
@@ -40,6 +40,7 @@ It uses a simple config file where you define your input and output paths and it
40
40
  - [Nunjucks vs Liquid](#nunjucks-vs-liquid)
41
41
  - [Custom Engines](#custom-engines)
42
42
  - [Collections & Pagination](#collections--pagination)
43
+ - [Taxonomies (Tags & Categories)](#taxonomies-tags--categories)
43
44
  - [Custom Tags](#custom-tags)
44
45
  - [image](#image)
45
46
  - [googleFonts](#googlefonts)
@@ -71,6 +72,7 @@ It uses a simple config file where you define your input and output paths and it
71
72
  - Resolves node modules
72
73
  - Can add a templatable banner to output files (optional)
73
74
  - Static site generation with swappable template engines: [Nunjucks](https://mozilla.github.io/nunjucks/) (default) or [Liquid](https://liquidjs.com/) β€” with blogging option (optional)
75
+ - Collections with pagination, and taxonomies β€” tags/categories as paginated, crawlable landing pages (with localizable labels)
74
76
  - Has a configurable local server (optional)
75
77
  - Rebuilds on file changes (optional)
76
78
  - Live reloads on file changes (optional)
@@ -749,8 +751,73 @@ Or use the `{% pagination %}` shorthand tag (available in both engines), which r
749
751
  {% pagination changelog %}
750
752
  ```
751
753
 
754
+ Pages 2..N automatically get a distinct `<title>` β€” `Changelog β€” Page 2` β€” so paginated pages don't all share the landing page's title (and its `og`/`jsonld` metadata). Page 1 keeps its own title.
755
+
756
+ **Localizing the labels.** The `β€” Page N` title suffix and the `{% pagination %}` tag's `Previous`/`Next`/`of` wording default to English. Override them site-wide under `site.pagination`:
757
+
758
+ ```json
759
+ {
760
+ "markup": {
761
+ "site": {
762
+ "pagination": {
763
+ "title": "{title} β€” Seite {n}",
764
+ "prev": "ZurΓΌck",
765
+ "next": "Weiter",
766
+ "of": "von"
767
+ }
768
+ }
769
+ }
770
+ }
771
+ ```
772
+
773
+ `title` accepts `{title}`, `{n}` and `{total}` tokens and applies to pages 2..N (and taxonomy term pages); `prev`/`next`/`of` localize the `{% pagination %}` tag (`{n} of {total}` β†’ `{n} von {total}`).
774
+
752
775
  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.
753
776
 
777
+ #### Taxonomies (Tags & Categories)
778
+
779
+ A **taxonomy** turns a front-matter field (tags, categories, authors) into its own paginated, crawlable landing page per term β€” `changelog/tag/feature/`, `blog/category/release/`. Declare which fields become taxonomies on the collection, alongside `paginate`/`sort` β€” either in the index front matter or the config entry:
780
+
781
+ ```yaml
782
+ ---
783
+ title: Changelog
784
+ collection: true
785
+ paginate: 10
786
+ taxonomies:
787
+ - name: tags # front-matter field to group on
788
+ path: tag # URL segment (defaults to name); "tag" for a singular URL
789
+ paginate: 5 # per-term page size (defaults to the collection's paginate)
790
+ ---
791
+ ```
792
+
793
+ Shorthand: a bare string list (`taxonomies: [tags, category]`) uses each field name as the URL segment and inherits the collection's `paginate`. Array-valued fields split per element β€” a post with `tags: [js, css]` lands under **both** `tag/js/` and `tag/css/`. Terms are slugified for the URL (`Static Site` β†’ `static-site`).
794
+
795
+ Term pages render with the **collection's own index template** β€” no extra file. On a term page the collection object carries the term context; branch on `activeTerm` to render a term view:
796
+
797
+ ```nunjucks
798
+ {% if changelog.activeTerm %}
799
+ <h1>Tagged {{ changelog.activeTerm | humanize }}</h1>
800
+ {% for post in changelog.pageItems %}
801
+ <a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a>
802
+ {% endfor %}
803
+ {% pagination changelog %}
804
+ {% endif %}
805
+ ```
806
+
807
+ On a term page `items`/`pageItems` are scoped to that term (so `pagination` and `groupby` narrow to it too); `activeTaxonomy` holds the URL segment and `activeTermSlug` the slug. Build tag links anywhere from `collection.taxonomies`:
808
+
809
+ ```nunjucks
810
+ {% for tax in changelog.taxonomies %}
811
+ {% for term in tax.terms %}
812
+ <a href="{{ relativePathPrefix }}{{ term.url }}">{{ term.term | humanize }} ({{ term.count }})</a>
813
+ {% endfor %}
814
+ {% endfor %}
815
+ ```
816
+
817
+ Each term exposes `term`, `slug`, `url`, `count` and `totalPages`.
818
+
819
+ Term pages get a distinct `<title>` and `og`/`jsonld` metadata (`Tag: Feature`, paged `Tag: Feature β€” Page 2`), and the `breadcrumb`/`jsonld` filters resolve them to a **Home β€Ί Collection β€Ί Tag: Term** trail automatically (skipping the non-page `tag`/`category` URL segment). The `Tag:`/`Category:` label comes from `path`, so it localizes by naming the path in your language (`path: etiqueta` β†’ `Etiqueta: …`). Term pages are listed in the sitemap but kept out of the search index and nav.
820
+
754
821
  #### Custom Tags
755
822
 
756
823
  ##### image
@@ -804,9 +871,15 @@ Output:
804
871
  ```
805
872
 
806
873
  ```html
807
- <img src="static/photo-thumb-480w.webp"
808
- srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
809
- width="480" height="480" sizes="240px" alt="" loading="lazy">
874
+ <img
875
+ src="static/photo-thumb-480w.webp"
876
+ srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
877
+ width="480"
878
+ height="480"
879
+ sizes="240px"
880
+ alt=""
881
+ loading="lazy"
882
+ />
810
883
  ```
811
884
 
812
885
  This requires the [poops-images](https://github.com/stamat/poops-images) compile cache (named-size widths are read from it). The `size` name matches a named entry in your `images.sizes` config. The largest member of the group is written without a width suffix (`photo-thumb.webp`) β€” poops still srcsets it at its real width from the cache.
@@ -908,6 +981,8 @@ All filters are available in both engines. The only syntax difference is how arg
908
981
 
909
982
  - `slugify` β€” slugifies a string. Usage: `{{ "My Awesome Title" | slugify }}` will output `my-awesome-title`
910
983
 
984
+ - `humanize` β€” the inverse of `slugify`: turns a slug or raw term into a display label. Usage: `{{ "static-site" | humanize }}` will output `Static Site`
985
+
911
986
  - `jsonify` β€” serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
912
987
 
913
988
  - `markdown` β€” renders a markdown string to HTML with GitHub Flavored Markdown extras: emoji shortcodes (e.g. `:rocket:` β†’ πŸš€), alert callouts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, `[!INFO]`) and footnotes (`[^1]`). Code fences are syntax-highlighted and headings get slug `id`s plus permalink anchors. Usage: `{{ "**bold** :rocket:" | markdown }}`
@@ -972,13 +1047,13 @@ All filters are available in both engines. The only syntax difference is how arg
972
1047
 
973
1048
  poops auto-picks `BlogPosting` (page has a `date`) or `WebPage`. Override `@type` with the `jsonld` object for any schema.org type β€” common ones search/generative engines act on: `Article`, `NewsArticle`, `HowTo`, `FAQPage`, `QAPage`, `Product`, `Recipe`, `Event`, `Course`, `VideoObject`, `SoftwareApplication`, `Organization`, `Person`, `BreadcrumbList`, `WebSite`. Full list at [schema.org/docs/full](https://schema.org/docs/full.html); validate with the [Rich Results Test](https://search.google.com/test/rich-results). A per-`@type` table with the notable fields is in the [Templating docs](example/src/markup/docs/quick-start/templating-html.md).
974
1049
 
975
- - `breadcrumb` β€” generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` β†’ *Static Site*), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) β€” not the absolute domain.
1050
+ - `breadcrumb` β€” generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` β†’ _Static Site_), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) β€” not the absolute domain.
976
1051
  - Nunjucks: `{{ page | breadcrumb(site, relativePathPrefix) }}`
977
1052
  - Liquid: `{{ page | breadcrumb: site, relativePathPrefix }}`
978
1053
 
979
1054
  The home crumb is optional: set `breadcrumb: { home: false }` (or `{ homeLabel: "Start" }` to rename it) under `site` or in a page's front matter β€” front matter wins. With the home crumb off, top-level pages fall to a single crumb and render nothing, while nested pages still show their folder trail. `breadcrumb: false` on a page or on `site` disables both the visible trail and the auto `BreadcrumbList` JSON-LD. Returns nothing on the homepage or any single-crumb page.
980
1055
 
981
- - `groupby` β€” groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too.
1056
+ - `groupby` β€” groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too. Array-valued fields split per element β€” an item with `tags: [js, css]` appears in **both** the `js` and `css` groups (the mechanism behind [taxonomies](#taxonomies-tags--categories)).
982
1057
  - Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
983
1058
  - Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
984
1059
 
@@ -1007,7 +1082,7 @@ All filters are available in both engines. The only syntax difference is how arg
1007
1082
 
1008
1083
  Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
1009
1084
 
1010
- Pass a named crop/resize group as the second argument to get that group's srcset instead of the default widths: `{{ 'static/photo.jpg' | srcset: 'thumb' }}` β†’ `static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w`.
1085
+ Pass a named crop/resize group as the second argument to get that group's srcset instead of the default widths: `{{ 'static/photo.jpg' | srcset: 'thumb' }}` β†’ `static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w`.
1011
1086
 
1012
1087
  - `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`).
1013
1088
 
@@ -1149,7 +1224,7 @@ All front matter fields are passed through to the index automatically. Internal
1149
1224
  ]
1150
1225
  ```
1151
1226
 
1152
- **Sitemap** generates a standard `sitemap.xml` with `<loc>` and `<lastmod>` (from front matter `date`). If `site.url` is set in your markup config, it is prepended to all URLs. Collection index/pagination pages are included in the sitemap but excluded from the search index.
1227
+ **Sitemap** generates a standard `sitemap.xml` with `<loc>` and `<lastmod>` (from front matter `date`). If `site.url` is set in your markup config, it is prepended to all URLs. Collection index/pagination and taxonomy term pages are included in the sitemap but excluded from the search index.
1153
1228
 
1154
1229
  **llms.txt** generates an [`llms.txt`](https://llmstxt.org) β€” a Markdown index of your pages that LLMs and generative engines (GEO) read to understand your site. It has an `# H1` title, a `> ` blockquote summary, then `- [title](url): description` links grouped by URL path: the first folder is a `## section`, a second folder nests as a `### subsection` under it, and root-level pages fall under a lead "Pages" section. So `docs/config-reference.html` lands directly under `## Docs` while `docs/quick-start/x.html` lands under `### Quick Start` inside it. Collection items (which live under `collection/…`) group the same way and are ordered newest-first by their `date`; other sections keep file order. Set `intro` to a Markdown file path (relative to the project root) to insert free-form context between the blockquote and the link sections β€” a file authored for LLMs, e.g. `llms-intro.md`. Avoid `##` headings in it; they read as sections. (A raw README is a poor fit β€” badges, install noise and its own headings collide.) `title` and `description` default to your `site.title`/`site.description`; override them (and the lead section name via `sectionTitle`) with the object form. `site.url` makes the links absolute. Collection index/pagination pages are skipped, like the search index.
1155
1230
 
@@ -1172,7 +1247,8 @@ Pages with `published: false` in their front matter are excluded from all output
1172
1247
  A page's front matter `robots: noindex` (or `none`) drops it from the **sitemap and llms.txt** β€” for drafts, thin or utility pages (a 404, say) you don't want crawled or fed to LLMs. It stays in the search index (that's your own on-site search). Emit the matching crawler directive in your layout `<head>` so the page itself carries it:
1173
1248
 
1174
1249
  ```html
1175
- {% if page.robots %}<meta name="robots" content="{{ page.robots }}">{% endif %}
1250
+ {% if page.robots %}<meta name="robots" content="{{ page.robots }}" />{% endif
1251
+ %}
1176
1252
  ```
1177
1253
 
1178
1254
  **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:
@@ -1316,7 +1392,11 @@ Shorthand forms: `"feed": true` (or a filename string) emits an RSS feed for eve
1316
1392
  Point browsers and readers at it from your layout `<head>`:
1317
1393
 
1318
1394
  ```html
1319
- <link rel="alternate" type="application/rss+xml" href="{{ site.url }}/blog/feed.rss">
1395
+ <link
1396
+ rel="alternate"
1397
+ type="application/rss+xml"
1398
+ href="{{ site.url }}/blog/feed.rss"
1399
+ />
1320
1400
  ```
1321
1401
 
1322
1402
  ### Images (optional)
package/lib/exec.js CHANGED
@@ -1,6 +1,21 @@
1
1
  import { execSync } from 'node:child_process'
2
2
  import log, { styledLog } from './utils/log.js'
3
3
 
4
+ // Every stage runExec fires a hook for. A key in config.exec outside this set
5
+ // never runs (runExec only looks up known stages), so it's almost always a typo
6
+ // (e.g. `style`, `script`). validateExec warns once at startup instead of
7
+ // letting the hook silently no-op.
8
+ export const EXEC_STAGES = ['reactor', 'scripts', 'images', 'markup', 'styles', 'copy', 'build']
9
+
10
+ // Returns the unknown stage keys (for tests); warns for each as a side effect.
11
+ export function validateExec(config) {
12
+ const unknown = Object.keys(config.exec || {}).filter((key) => !EXEC_STAGES.includes(key))
13
+ for (const key of unknown) {
14
+ log({ tag: 'exec', warn: true, text: `unknown stage "${key}" β€” never runs. Valid: ${EXEC_STAGES.join(', ')}` })
15
+ }
16
+ return unknown
17
+ }
18
+
4
19
  // Post-stage shell hooks. `config.exec` maps a pipeline stage to a command (or
5
20
  // array of commands) run after that stage compiles β€” in both build and watch,
6
21
  // so a post-processor (e.g. stripping CSS comments, regenerating a reference)
@@ -1,9 +1,10 @@
1
1
  import fs from 'node:fs'
2
2
  import { globSync } from 'glob'
3
3
  import path from 'node:path'
4
+ import { slugify, humanize } from 'book-of-spells'
4
5
  import log from '../utils/log.js'
5
6
  import { mkDir, toPosix } from '../utils/helpers.js'
6
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './helpers.js'
7
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt, groupby } from './helpers.js'
7
8
 
8
9
  export function getSingleCollectionData(markupInDir, collectionName) {
9
10
  const collectionData = []
@@ -158,9 +159,75 @@ export function buildCollectionObject(markupInDir, collectionProtoObject) {
158
159
  }
159
160
  })
160
161
 
162
+ if (collectionProtoObject.taxonomies) {
163
+ collection._taxonomies = normalizeTaxonomies(collectionProtoObject.taxonomies, collection.paginate)
164
+ }
165
+
161
166
  return collection
162
167
  }
163
168
 
169
+ // Normalizes the `taxonomies` collection option into { name, path, paginate }.
170
+ // Accepts "tags" | ["tags", "category"] | [{ name, path, paginate }]. `name` is
171
+ // the front-matter field grouped on; `path` is the URL segment under the
172
+ // collection (defaults to `name`, so use `{ name: "tags", path: "tag" }` for a
173
+ // singular /tag/ URL); `paginate` falls back to the collection's page size.
174
+ function normalizeTaxonomies(config, defaultPaginate) {
175
+ const items = Array.isArray(config) ? config : [config]
176
+ const out = []
177
+ for (let item of items) {
178
+ if (typeof item === 'string') item = { name: item }
179
+ if (!item || !item.name) continue
180
+ const paginate = parseInt(item.paginate)
181
+ out.push({
182
+ name: item.name,
183
+ path: item.path || item.name,
184
+ paginate: !isNaN(paginate) ? paginate : defaultPaginate
185
+ })
186
+ }
187
+ return out
188
+ }
189
+
190
+ // Chunks items into pages of `size`; no/invalid size β†’ a single page holding all.
191
+ export function paginateItems(items, size) {
192
+ if (!size || isNaN(size) || size < 1) return [items]
193
+ const pages = []
194
+ for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size))
195
+ return pages.length ? pages : [items]
196
+ }
197
+
198
+ // Groups each collection's items by every configured taxonomy field and attaches
199
+ // collection.taxonomies = [{ name, path, terms: [{ term, slug, url, count,
200
+ // totalPages, pages, items }] }] β€” consumed by both the listing template (to link
201
+ // tag/category pages) and generateTaxonomyPages. Must run before any collection
202
+ // template renders so page 1 of the index can already see the term links.
203
+ // ponytail: two terms that slugify to the same slug collide on one URL (last
204
+ // write wins). Add a per-slug de-dupe counter if real tag sets ever clash.
205
+ export function buildTaxonomyData(collectionData) {
206
+ if (!collectionData) return
207
+ for (const name of Object.keys(collectionData)) {
208
+ const collection = collectionData[name]
209
+ if (!collection._taxonomies) continue
210
+ collection.taxonomies = collection._taxonomies.map((tax) => {
211
+ const terms = groupby(collection.items, tax.name)
212
+ .filter((g) => g.key !== '')
213
+ .map((g) => {
214
+ const slug = slugify(g.key)
215
+ const pages = paginateItems(g.items, tax.paginate)
216
+ return {
217
+ term: g.key,
218
+ slug,
219
+ url: toPosix(path.join(collection.name, tax.path, slug)),
220
+ count: g.items.length,
221
+ totalPages: pages.length,
222
+ pages,
223
+ items: g.items
224
+ }
225
+ })
226
+ return { name: tax.name, path: tax.path, terms }
227
+ })
228
+ }
229
+ }
230
+
164
231
  export function buildCollectionPaginationData(collectionData) {
165
232
  if (!collectionData) return
166
233
 
@@ -204,9 +271,83 @@ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutD
204
271
  }
205
272
  }
206
273
 
207
- export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL) {
274
+ // Renders a collection template once per page in `pages`, writing page 1 to
275
+ // out/<urlBase>/index.html and page N (2..) to out/<urlBase>/N/index.html.
276
+ // Shared by plain collection pagination and per-term taxonomy pages: the only
277
+ // differences are urlBase and the `extra` context merged onto the collection
278
+ // snapshot (taxonomy pages add activeTerm/activeTaxonomy and a term-filtered
279
+ // items/pageItems). Returns a promise per page.
280
+ function renderPages({ pages, urlBase, collection, extra, pageProps, titleBase, titleAlways, titleFormat, collectionName, collectionData, file, markupOutDir, compileEntryFn, baseURL }) {
281
+ const promises = []
282
+ const totalPages = pages.length
283
+
284
+ for (let i = 0; i < totalPages; i++) {
285
+ const pageNumber = i + 1
286
+ const pageUrl = pageNumber === 1 ? urlBase : `${urlBase}/${pageNumber}`
287
+ const nextPage = pageNumber === totalPages ? null : pageNumber + 1
288
+ const nextPageUrl = nextPage === null ? null : `${urlBase}/${nextPage}`
289
+ const prevPage = pageNumber === 1 ? null : pageNumber - 1
290
+ let prevPageUrl = prevPage === null ? null : `${urlBase}/${prevPage}`
291
+ if (prevPage === 1) prevPageUrl = urlBase
292
+
293
+ // Snapshot per-page properties to avoid async mutation
294
+ const pageSnapshot = {
295
+ ...collection,
296
+ ...extra,
297
+ pageItems: pages[i],
298
+ pageNumber,
299
+ totalPages,
300
+ pageUrl,
301
+ nextPage,
302
+ nextPageUrl,
303
+ prevPage,
304
+ prevPageUrl
305
+ }
306
+
307
+ const markupOut = path.resolve(process.cwd(), markupOutDir, pageUrl, 'index.html')
308
+ const fromPath = path.resolve(process.cwd(), markupOutDir)
309
+ const markupOutDirFull = path.dirname(markupOut)
310
+
311
+ const context = {
312
+ ...collectionData,
313
+ [collectionName]: pageSnapshot,
314
+ relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
315
+ // output-relative so page.url matches nav.json/index urls, same as compileDirectory
316
+ _url: getPageUrlRelativeToOutput(markupOut, markupOutDir)
317
+ }
318
+ // extra props stamped onto page.* (compileEntry honors _page) β€” taxonomy
319
+ // pages use it to feed the breadcrumb a correct term trail, and both paths
320
+ // set a distinct title so <title>/og/jsonld don't duplicate across pages.
321
+ // titleAlways overrides page 1 too (term pages want "Tag: Feature"); plain
322
+ // pagination keeps page 1's front-matter title and only suffixes 2..N.
323
+ const props = { ...pageProps }
324
+ if (titleBase && (titleAlways || pageNumber > 1)) {
325
+ props.title = pageNumber === 1
326
+ ? titleBase
327
+ : (titleFormat || '{title} β€” Page {n}')
328
+ .replace('{title}', titleBase)
329
+ .replace('{n}', pageNumber)
330
+ .replace('{total}', totalPages)
331
+ }
332
+ if (Object.keys(props).length) context._page = props
333
+
334
+ // mkDir only when a page is actually written: no empty pagination dirs
335
+ // for index-less or unpublished (skipped) collection indexes
336
+ promises.push(compileEntryFn(file, context).then(({ result, skipped }) => {
337
+ if (skipped) return
338
+ mkDir(markupOutDirFull)
339
+ // async write so I/O overlaps rendering of the other pages
340
+ return fs.promises.writeFile(markupOut, result)
341
+ }))
342
+ }
343
+
344
+ return promises
345
+ }
346
+
347
+ export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL, site = {}) {
208
348
  if (!collectionData) return []
209
349
 
350
+ const titleFormat = site.pagination && site.pagination.title
210
351
  const compilePromises = []
211
352
 
212
353
  for (const collectionName of Object.keys(collectionData)) {
@@ -223,50 +364,106 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
223
364
 
224
365
  if (!file) continue
225
366
 
226
- for (let i = 0; i < collection.totalPages; i++) {
227
- const pageNumber = i + 1
228
- const pageUrl = pageNumber === 1 ? collection.name : `${collection.name}/${pageNumber}`
229
- const nextPage = pageNumber === collection.totalPages ? null : pageNumber + 1
230
- const nextPageUrl = pageNumber === collection.totalPages ? null : `${collection.name}/${pageNumber + 1}`
231
- const prevPage = pageNumber === 1 ? null : pageNumber - 1
232
- let prevPageUrl = pageNumber === 1 ? null : `${collection.name}/${pageNumber - 1}`
233
- if (prevPage === 1) {
234
- prevPageUrl = collection.name
235
- }
367
+ // base for "<title> β€” Page N" on pages 2..N (page 1 keeps its own title)
368
+ const indexTitle = parseFrontMatter(file).frontMatter.title || humanize(collection.name)
369
+
370
+ compilePromises.push(...renderPages({
371
+ pages: collection.pages,
372
+ urlBase: collection.name,
373
+ collection,
374
+ extra: {},
375
+ titleBase: indexTitle,
376
+ titleFormat,
377
+ collectionName,
378
+ collectionData,
379
+ file,
380
+ markupOutDir,
381
+ compileEntryFn,
382
+ baseURL
383
+ }))
384
+ }
236
385
 
237
- // Snapshot per-page properties to avoid async mutation
238
- const pageSnapshot = {
239
- ...collection,
240
- pageItems: collection.pages[i],
241
- pageNumber,
242
- pageUrl,
243
- nextPage,
244
- nextPageUrl,
245
- prevPage,
246
- prevPageUrl
247
- }
386
+ return compilePromises
387
+ }
248
388
 
249
- const markupOut = path.resolve(process.cwd(), markupOutDir, pageUrl, 'index.html')
250
- const fromPath = path.resolve(process.cwd(), markupOutDir)
251
- const markupOutDirFull = path.dirname(markupOut)
389
+ // Removes stale output under out/<collection>/<tax.path>/: whole term-slug dirs
390
+ // for terms that no longer exist, plus leftover page dirs (2..) inside a
391
+ // surviving term whose page count shrank. Guarded so a real source subdir
392
+ // mirrored at the same path is never deleted.
393
+ export function pruneStaleTaxonomyDirs(collectionName, tax, markupInDir, markupOutDir, terms) {
394
+ const taxDir = path.resolve(process.cwd(), markupOutDir, collectionName, tax.path)
395
+ if (!fs.existsSync(taxDir)) return
252
396
 
253
- const context = {
254
- ...collectionData,
255
- [collectionName]: pageSnapshot,
256
- relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
257
- // output-relative so page.url matches nav.json/index urls, same as compileDirectory
258
- _url: getPageUrlRelativeToOutput(markupOut, markupOutDir)
259
- }
397
+ // never touch a taxonomy path that shadows a real source directory
398
+ if (fs.existsSync(path.resolve(process.cwd(), markupInDir, collectionName, tax.path))) return
399
+
400
+ const bySlug = new Map(terms.map((t) => [t.slug, t]))
260
401
 
261
- // mkDir only when a page is actually written: no empty pagination dirs
262
- // for index-less or unpublished (skipped) collection indexes
263
- const compilePromise = compileEntryFn(file, context).then(({ result, skipped }) => {
264
- if (skipped) return
265
- mkDir(markupOutDirFull)
266
- // async write so I/O overlaps rendering of the other pages
267
- return fs.promises.writeFile(markupOut, result)
268
- })
269
- compilePromises.push(compilePromise)
402
+ for (const entry of fs.readdirSync(taxDir)) {
403
+ const slugDir = path.join(taxDir, entry)
404
+ if (!fs.statSync(slugDir).isDirectory()) continue
405
+
406
+ const term = bySlug.get(entry)
407
+ if (!term) {
408
+ fs.rmSync(slugDir, { recursive: true, force: true })
409
+ continue
410
+ }
411
+
412
+ // surviving term: drop page-number dirs beyond its current totalPages
413
+ for (const sub of fs.readdirSync(slugDir)) {
414
+ const pageNumber = parseInt(sub, 10)
415
+ if (String(pageNumber) !== sub || pageNumber < 2 || pageNumber <= term.totalPages) continue
416
+ fs.rmSync(path.join(slugDir, sub), { recursive: true, force: true })
417
+ }
418
+ }
419
+ }
420
+
421
+ // Renders per-term taxonomy pages (e.g. changelog/tag/release/) using the
422
+ // collection's own index template. Each term page carries the term-filtered
423
+ // items/pageItems and activeTaxonomy/activeTerm so the shared template can show
424
+ // a heading and paginate. buildTaxonomyData must have run first.
425
+ export function generateTaxonomyPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL, site = {}) {
426
+ if (!collectionData) return []
427
+
428
+ const titleFormat = site.pagination && site.pagination.title
429
+ const compilePromises = []
430
+
431
+ for (const collectionName of Object.keys(collectionData)) {
432
+ const collection = collectionData[collectionName]
433
+ if (!collection.taxonomies) continue
434
+
435
+ const file = getCollectionIndexFile(markupInDir, collectionName)
436
+
437
+ for (const tax of collection.taxonomies) {
438
+ // prune before the index check so removed tags are cleaned even if the
439
+ // index template was deleted
440
+ pruneStaleTaxonomyDirs(collectionName, tax, markupInDir, markupOutDir, file ? tax.terms : [])
441
+ if (!file) continue
442
+
443
+ for (const term of tax.terms) {
444
+ compilePromises.push(...renderPages({
445
+ pages: term.pages,
446
+ urlBase: term.url,
447
+ collection,
448
+ // term-scope items so `.items` and `groupby(.items…)` also filter to
449
+ // the term on its page, not the whole collection
450
+ extra: { items: term.items, activeTaxonomy: tax.path, activeTerm: term.term, activeTermSlug: term.slug },
451
+ // breadcrumb hint: Home > collection > term (the tag/ path segment has
452
+ // no page, and page.title is the index's, not the term's)
453
+ pageProps: { _taxonomy: { collection: collectionName, path: tax.path, term: term.term, termUrl: term.url } },
454
+ // distinct <title>/og/jsonld per term ("Tag: Feature"), not the shared
455
+ // index title β€” matches the breadcrumb crumb label
456
+ titleBase: `${humanize(tax.path)}: ${humanize(term.term)}`,
457
+ titleAlways: true,
458
+ titleFormat,
459
+ collectionName,
460
+ collectionData,
461
+ file,
462
+ markupOutDir,
463
+ compileEntryFn,
464
+ baseURL
465
+ }))
466
+ }
270
467
  }
271
468
  }
272
469
 
@@ -5,7 +5,7 @@ import { highlightCode } from '../highlight.js'
5
5
  import { marked, renderMarkdownCached } from '../renderer.js'
6
6
  import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc, buildJsonLd, buildOpenGraph, buildCanonical, buildBreadcrumb } from '../helpers.js'
7
7
  import { getImageExif, listImages } from '../image-cache.js'
8
- import { slugify } from 'book-of-spells'
8
+ import { slugify, humanize } from 'book-of-spells'
9
9
  import dayjs from 'dayjs'
10
10
 
11
11
  // liquidjs parse-cache keys: "<lookupType>:<name>" (root/layouts/partials)
@@ -76,6 +76,7 @@ export default class LiquidEngine {
76
76
  registerFilters({ timeDateFormat, markupOut }) {
77
77
  const engine = this.engine
78
78
  engine.registerFilter('slugify', (str) => slugify(str))
79
+ engine.registerFilter('humanize', (str) => humanize(str))
79
80
  engine.registerFilter('jsonify', (obj) => JSON.stringify(obj))
80
81
  engine.registerFilter('markdown', (str) => marked.parse(String(str || '')))
81
82
  engine.registerFilter('toc', (html) => renderToc(String(html || '')))
@@ -293,7 +294,8 @@ function registerPaginationTag(engine) {
293
294
  * render(ctx) {
294
295
  const pagination = yield this.liquid._evalValue(this.value, ctx)
295
296
  const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
296
- return buildPaginationTag(pagination, prefix)
297
+ const site = (yield ctx.get(['site'])) || {}
298
+ return buildPaginationTag(pagination, prefix, site.pagination)
297
299
  }
298
300
  })
299
301
  }
@@ -7,7 +7,7 @@ import { getImageExif, listImages } from '../image-cache.js'
7
7
  import { toPosix } from '../../utils/helpers.js'
8
8
  import { highlightCode } from '../highlight.js'
9
9
  import { marked, renderMarkdownCached } from '../renderer.js'
10
- import { slugify } from 'book-of-spells'
10
+ import { slugify, humanize } from 'book-of-spells'
11
11
  import dayjs from 'dayjs'
12
12
  import log from '../../utils/log.js'
13
13
 
@@ -133,6 +133,7 @@ export default class NunjucksEngine {
133
133
  registerFilters({ timeDateFormat, markupOut }) {
134
134
  const env = this.env
135
135
  env.addFilter('slugify', slugify)
136
+ env.addFilter('humanize', humanize)
136
137
  env.addFilter('jsonify', (obj) => JSON.stringify(obj))
137
138
  env.addFilter('markdown', (str) => marked.parse(String(str || '')))
138
139
  env.addFilter('toc', (html) => {
@@ -341,6 +342,7 @@ export class PaginationExtension {
341
342
 
342
343
  run(context, pagination) {
343
344
  const prefix = context.lookup('relativePathPrefix') || ''
344
- return new nunjucks.runtime.SafeString(buildPaginationTag(pagination, prefix))
345
+ const site = context.lookup('site') || {}
346
+ return new nunjucks.runtime.SafeString(buildPaginationTag(pagination, prefix, site.pagination))
345
347
  }
346
348
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import yaml from 'yaml'
4
+ import { humanize } from 'book-of-spells'
4
5
  import { toPosix } from '../utils/helpers.js'
5
6
  import { getImageEntry } from './image-cache.js'
6
7
 
@@ -131,8 +132,18 @@ export function groupby(arr, key, datePart) {
131
132
  if (!Array.isArray(arr)) return []
132
133
 
133
134
  const map = new Map()
135
+ const put = (groupKey, item) => {
136
+ if (!map.has(groupKey)) map.set(groupKey, [])
137
+ map.get(groupKey).push(item)
138
+ }
134
139
  for (const item of arr) {
135
140
  let value = item[key]
141
+ // Array value (e.g. `tags: [a, b]`) β†’ item lands in each element's bucket,
142
+ // so one post appears under every tag it carries.
143
+ if (Array.isArray(value)) {
144
+ for (const v of value) put(v != null ? String(v) : '', item)
145
+ continue
146
+ }
136
147
  if (datePart && value) {
137
148
  const date = new Date(value)
138
149
  if (!isNaN(date)) {
@@ -143,9 +154,7 @@ export function groupby(arr, key, datePart) {
143
154
  }
144
155
  }
145
156
  }
146
- const groupKey = value != null ? String(value) : ''
147
- if (!map.has(groupKey)) map.set(groupKey, [])
148
- map.get(groupKey).push(item)
157
+ put(value != null ? String(value) : '', item)
149
158
  }
150
159
 
151
160
  return Array.from(map, ([key, items]) => ({ key, items }))
@@ -344,15 +353,21 @@ export function escapeAttr(value) {
344
353
  return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
345
354
  }
346
355
 
347
- export function buildPaginationTag(pagination, prefix = '') {
356
+ // `labels` (site.pagination) localizes the prev/next/of wording; English
357
+ // defaults. Author-supplied, but escaped anyway since it lands in HTML.
358
+ export function buildPaginationTag(pagination, prefix = '', labels = {}) {
348
359
  const totalPages = Number(pagination && pagination.totalPages) || 1
349
360
  if (totalPages <= 1) return ''
350
361
 
362
+ const prev = escapeAttr(labels.prev || 'Previous')
363
+ const next = escapeAttr(labels.next || 'Next')
364
+ const of = escapeAttr(labels.of || 'of')
365
+
351
366
  const pageNumber = Number(pagination && pagination.pageNumber) || 1
352
367
  const parts = []
353
- if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">Previous</a>`)
354
- parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
355
- if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">Next</a>`)
368
+ if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">${prev}</a>`)
369
+ parts.push(`<span>${pageNumber} ${of} ${totalPages}</span>`)
370
+ if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">${next}</a>`)
356
371
  return parts.join('\n')
357
372
  }
358
373
 
@@ -407,12 +422,7 @@ function jsonLdScript(data) {
407
422
  // that has no page object of its own β€” same rule the nav tree uses for virtual
408
423
  // parents. Strips a trailing extension, then dash/underscore β†’ spaced Title Case.
409
424
  function humanizeSegment(seg) {
410
- return String(seg)
411
- .replace(/\.[a-z0-9]+$/i, '')
412
- .replace(/[-_]+/g, ' ')
413
- .replace(/\s+/g, ' ')
414
- .trim()
415
- .replace(/\b\w/g, c => c.toUpperCase())
425
+ return humanize(String(seg).replace(/\.[a-z0-9]+$/i, ''))
416
426
  }
417
427
 
418
428
  // Builds a breadcrumb trail β€” a { name, path } array from the site root down to
@@ -436,6 +446,22 @@ export function breadcrumbCrumbs(page, site = {}) {
436
446
  if (site.breadcrumb && typeof site.breadcrumb === 'object') Object.assign(cfg, site.breadcrumb)
437
447
  if (page.breadcrumb && typeof page.breadcrumb === 'object') Object.assign(cfg, page.breadcrumb)
438
448
 
449
+ // Taxonomy term page: its URL segments (e.g. changelog/tag/feature) are not a
450
+ // real page hierarchy β€” the `tag` segment has no page, and the last segment is
451
+ // a slug, not the page title. Build the trail from the term hint instead:
452
+ // Home > collection landing > term.
453
+ if (page._taxonomy) {
454
+ const t = page._taxonomy
455
+ const crumbs = []
456
+ if (cfg.home) crumbs.push({ name: cfg.homeLabel, path: '' })
457
+ crumbs.push({ name: humanizeSegment(t.collection), path: t.collection })
458
+ // prefix with the taxonomy label ("Tag: Feature") β€” the dropped `tag/` path
459
+ // segment isn't a crumb, so this restores the "it's an archive" context
460
+ const label = t.path ? `${humanizeSegment(t.path)}: ` : ''
461
+ crumbs.push({ name: `${label}${humanizeSegment(t.term)}`, path: t.termUrl })
462
+ return crumbs.length >= 2 ? crumbs : []
463
+ }
464
+
439
465
  const rawUrl = page.url != null ? String(page.url) : ''
440
466
  if (rawUrl === '') return [] // homepage β€” you're already home
441
467
 
@@ -539,7 +565,7 @@ export function buildJsonLd(page, site = {}) {
539
565
  description: page.description || page.excerpt || site.description,
540
566
  url: absUrl(page.url),
541
567
  inLanguage: page.lang || site.lang,
542
- image: absUrl(page.image),
568
+ image: absUrl(page.image || site.image),
543
569
  publisher
544
570
  }
545
571
 
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { readJsonFile, fileSize } from '../utils/helpers.js'
5
5
  import { parseFrontMatter } from './helpers.js'
6
6
  import { renderMarkdownCached } from './renderer.js'
7
+ import { humanize } from 'book-of-spells'
7
8
  import log from '../utils/log.js'
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -238,10 +239,10 @@ export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
238
239
  if (segs.length === 0) {
239
240
  sectionOf(lead).direct.push(e)
240
241
  } else if (segs.length === 1) {
241
- sectionOf(humanizeSegment(segs[0])).direct.push(e)
242
+ sectionOf(humanize(segs[0])).direct.push(e)
242
243
  } else {
243
- const subs = sectionOf(humanizeSegment(segs[0])).subs
244
- const key = humanizeSegment(segs[1])
244
+ const subs = sectionOf(humanize(segs[0])).subs
245
+ const key = humanize(segs[1])
245
246
  if (!subs.has(key)) subs.set(key, [])
246
247
  subs.get(key).push(e)
247
248
  }
@@ -372,14 +373,6 @@ export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
372
373
  log({ tag: 'indexer', text: 'Generated robots.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
373
374
  }
374
375
 
375
- function humanizeSegment(seg) {
376
- return seg
377
- .replace(/[-_]+/g, ' ')
378
- .replace(/\s+/g, ' ')
379
- .trim()
380
- .replace(/\b\w/g, c => c.toUpperCase())
381
- }
382
-
383
376
  function navNodeTitle(entry) {
384
377
  return entry.navTitle || entry.title
385
378
  }
@@ -401,7 +394,7 @@ function navFilterEntries(pageEntries, collectionsOpt) {
401
394
  // pagination pages (url has a slash) always dropped. Landing titles are
402
395
  // the raw collection name ("blog"), so humanize for display.
403
396
  if (mode === 'index' && !e.url.includes('/')) {
404
- result.push({ ...e, title: humanizeSegment(e.title) })
397
+ result.push({ ...e, title: humanize(e.title) })
405
398
  }
406
399
  continue
407
400
  }
@@ -454,7 +447,7 @@ function serializeNavNode(node) {
454
447
  const children = [...node.children.values()].map(serializeNavNode)
455
448
  sortNavSiblings(children)
456
449
 
457
- const out = { title: node.title != null ? node.title : humanizeSegment(node.segment) }
450
+ const out = { title: node.title != null ? node.title : humanize(node.segment) }
458
451
  if (node.url != null) out.url = node.url
459
452
 
460
453
  let order = node.order
@@ -564,7 +557,7 @@ function resolveFeedSpecs(config, collectionNames, site) {
564
557
  output: outName.includes('/') ? outName : `${name}/${outName}`,
565
558
  type: item.type === 'atom' ? 'atom' : 'rss',
566
559
  limit: Number(item.limit) > 0 ? Number(item.limit) : 20,
567
- title: item.title || (site.title ? `${humanizeSegment(name)} | ${site.title}` : humanizeSegment(name)),
560
+ title: item.title || (site.title ? `${humanize(name)} | ${site.title}` : humanize(name)),
568
561
  description: item.description || site.description || '',
569
562
  author: feedAuthorName(item.author || site.author),
570
563
  lang: item.lang || site.lang || '',
package/lib/markups.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
2
  import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './markup/helpers.js'
3
- import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
3
+ import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages, buildTaxonomyData, generateTaxonomyPages } from './markup/collections.js'
4
4
  import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
5
5
  import NunjucksEngine from './markup/engines/nunjucks.js'
6
6
  import LiquidEngine from './markup/engines/liquid.js'
@@ -225,11 +225,18 @@ export default class Markups {
225
225
  const context = { page: {} }
226
226
  let pageUrl
227
227
 
228
+ let pageExtra
228
229
  if (additionalContext) {
229
230
  if (additionalContext._url) {
230
231
  pageUrl = additionalContext._url
231
232
  delete additionalContext._url
232
233
  }
234
+ // extra props to stamp on page.* after front-matter parse (e.g. taxonomy
235
+ // pages inject the breadcrumb hint the index template's front matter lacks)
236
+ if (additionalContext._page) {
237
+ pageExtra = additionalContext._page
238
+ delete additionalContext._page
239
+ }
233
240
  Object.assign(context, additionalContext)
234
241
  }
235
242
 
@@ -245,6 +252,7 @@ export default class Markups {
245
252
  }
246
253
 
247
254
  if (pageUrl) context.page.url = pageUrl
255
+ if (pageExtra) Object.assign(context.page, pageExtra)
248
256
 
249
257
  const frontMatter = context.page
250
258
 
@@ -339,7 +347,10 @@ export default class Markups {
339
347
  relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
340
348
  // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
341
349
  // otherwise `item.url == page.url` sidebar/active checks never fire
342
- _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
350
+ _url: getPageUrlRelativeToOutput(markupOut, this.markupOut),
351
+ // source path relative to markup input (posix), so templates can build
352
+ // "edit on GitHub" links β€” .html output can't be reversed to .md reliably
353
+ _page: { inputPath: toPosix(relativePath) }
343
354
  }
344
355
 
345
356
  const shouldIndex = pageEntries && this.engine.indexableExtensions.has(path.extname(file))
@@ -520,12 +531,18 @@ export default class Markups {
520
531
  const pageEntries = shouldIndex ? [] : null
521
532
 
522
533
  buildCollectionPaginationData(collectionData)
534
+ // attach collection.taxonomies before any render so index/listing templates
535
+ // can link tag/category pages on page 1
536
+ buildTaxonomyData(collectionData)
523
537
 
524
538
  // must precede any rendering (incl. pagination pages) so every template
525
539
  // sees the current build's nav
526
540
  this.buildNavGlobal(markupIn, collectionData)
527
541
 
528
- const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
542
+ const collectionPromises = [
543
+ ...generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData),
544
+ ...generateTaxonomyPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData)
545
+ ]
529
546
 
530
547
  await Promise.all(collectionPromises)
531
548
 
@@ -542,6 +559,20 @@ export default class Markups {
542
559
  isIndex: true
543
560
  })
544
561
  }
562
+
563
+ // taxonomy term pages: crawlable (sitemap) but isIndex, so out of the
564
+ // search index, llms.txt and nav β€” same as pagination pages
565
+ for (const tax of collection.taxonomies || []) {
566
+ for (const term of tax.terms) {
567
+ for (let p = 0; p < term.totalPages; p++) {
568
+ pageEntries.push({
569
+ url: p === 0 ? term.url : `${term.url}/${p + 1}`,
570
+ title: term.term,
571
+ isIndex: true
572
+ })
573
+ }
574
+ }
575
+ }
545
576
  }
546
577
  }
547
578
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poops",
3
3
  "description": "Straightforward, no-bullshit bundler for the web.",
4
- "version": "1.6.0",
4
+ "version": "1.7.1",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "argoyle": "^1.0.0",
55
- "book-of-spells": "^1.0.32",
55
+ "book-of-spells": "^1.3.0",
56
56
  "chokidar": "^3.5.3",
57
57
  "connect": "^3.7.0",
58
58
  "dayjs": "^1.11.19",
package/poops.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import chokidar from 'chokidar'
4
4
  import connect from 'connect'
5
5
  import Copy from './lib/copy.js'
6
- import runExec from './lib/exec.js'
6
+ import runExec, { validateExec } from './lib/exec.js'
7
7
  import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs } from './lib/utils/helpers.js'
8
8
  import http from 'node:http'
9
9
  import os from 'node:os'
@@ -246,6 +246,7 @@ function setupWatchers(config, modules) {
246
246
 
247
247
  // Main function πŸ’©
248
248
  async function poops() {
249
+ validateExec(config)
249
250
  const styles = new Styles(config)
250
251
  const postcss = new PostCSS(config)
251
252
  const reactor = new Reactor(config)