poops 1.6.0 → 1.7.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
@@ -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
@@ -908,6 +975,8 @@ All filters are available in both engines. The only syntax difference is how arg
908
975
 
909
976
  - `slugify` — slugifies a string. Usage: `{{ "My Awesome Title" | slugify }}` will output `my-awesome-title`
910
977
 
978
+ - `humanize` — the inverse of `slugify`: turns a slug or raw term into a display label. Usage: `{{ "static-site" | humanize }}` will output `Static Site`
979
+
911
980
  - `jsonify` — serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
912
981
 
913
982
  - `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 }}`
@@ -978,7 +1047,7 @@ All filters are available in both engines. The only syntax difference is how arg
978
1047
 
979
1048
  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
1049
 
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.
1050
+ - `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
1051
  - Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
983
1052
  - Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
984
1053
 
@@ -1149,7 +1218,7 @@ All front matter fields are passed through to the index automatically. Internal
1149
1218
  ]
1150
1219
  ```
1151
1220
 
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.
1221
+ **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
1222
 
1154
1223
  **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
1224
 
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
 
@@ -520,12 +528,18 @@ export default class Markups {
520
528
  const pageEntries = shouldIndex ? [] : null
521
529
 
522
530
  buildCollectionPaginationData(collectionData)
531
+ // attach collection.taxonomies before any render so index/listing templates
532
+ // can link tag/category pages on page 1
533
+ buildTaxonomyData(collectionData)
523
534
 
524
535
  // must precede any rendering (incl. pagination pages) so every template
525
536
  // sees the current build's nav
526
537
  this.buildNavGlobal(markupIn, collectionData)
527
538
 
528
- const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
539
+ const collectionPromises = [
540
+ ...generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData),
541
+ ...generateTaxonomyPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData)
542
+ ]
529
543
 
530
544
  await Promise.all(collectionPromises)
531
545
 
@@ -542,6 +556,20 @@ export default class Markups {
542
556
  isIndex: true
543
557
  })
544
558
  }
559
+
560
+ // taxonomy term pages: crawlable (sitemap) but isIndex, so out of the
561
+ // search index, llms.txt and nav — same as pagination pages
562
+ for (const tax of collection.taxonomies || []) {
563
+ for (const term of tax.terms) {
564
+ for (let p = 0; p < term.totalPages; p++) {
565
+ pageEntries.push({
566
+ url: p === 0 ? term.url : `${term.url}/${p + 1}`,
567
+ title: term.term,
568
+ isIndex: true
569
+ })
570
+ }
571
+ }
572
+ }
545
573
  }
546
574
  }
547
575
 
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.0",
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)