poops 1.5.3 → 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.
@@ -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 } 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 = []
@@ -30,6 +31,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
30
31
  log({ tag: 'markup', warn: true, text: 'No date in front matter, falling back to file mtime:', link: file })
31
32
  }
32
33
  frontMatter.wordcount = wordcount(content)
34
+ frontMatter.excerpt = excerpt(content)
33
35
  frontMatter.fileName = path.basename(file)
34
36
  frontMatter.filePath = path.relative(process.cwd(), file)
35
37
  frontMatter.collection = collectionName
@@ -157,9 +159,75 @@ export function buildCollectionObject(markupInDir, collectionProtoObject) {
157
159
  }
158
160
  })
159
161
 
162
+ if (collectionProtoObject.taxonomies) {
163
+ collection._taxonomies = normalizeTaxonomies(collectionProtoObject.taxonomies, collection.paginate)
164
+ }
165
+
160
166
  return collection
161
167
  }
162
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
+
163
231
  export function buildCollectionPaginationData(collectionData) {
164
232
  if (!collectionData) return
165
233
 
@@ -203,9 +271,83 @@ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutD
203
271
  }
204
272
  }
205
273
 
206
- 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 = {}) {
207
348
  if (!collectionData) return []
208
349
 
350
+ const titleFormat = site.pagination && site.pagination.title
209
351
  const compilePromises = []
210
352
 
211
353
  for (const collectionName of Object.keys(collectionData)) {
@@ -222,50 +364,106 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
222
364
 
223
365
  if (!file) continue
224
366
 
225
- for (let i = 0; i < collection.totalPages; i++) {
226
- const pageNumber = i + 1
227
- const pageUrl = pageNumber === 1 ? collection.name : `${collection.name}/${pageNumber}`
228
- const nextPage = pageNumber === collection.totalPages ? null : pageNumber + 1
229
- const nextPageUrl = pageNumber === collection.totalPages ? null : `${collection.name}/${pageNumber + 1}`
230
- const prevPage = pageNumber === 1 ? null : pageNumber - 1
231
- let prevPageUrl = pageNumber === 1 ? null : `${collection.name}/${pageNumber - 1}`
232
- if (prevPage === 1) {
233
- prevPageUrl = collection.name
234
- }
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
+ }
235
385
 
236
- // Snapshot per-page properties to avoid async mutation
237
- const pageSnapshot = {
238
- ...collection,
239
- pageItems: collection.pages[i],
240
- pageNumber,
241
- pageUrl,
242
- nextPage,
243
- nextPageUrl,
244
- prevPage,
245
- prevPageUrl
246
- }
386
+ return compilePromises
387
+ }
247
388
 
248
- const markupOut = path.resolve(process.cwd(), markupOutDir, pageUrl, 'index.html')
249
- const fromPath = path.resolve(process.cwd(), markupOutDir)
250
- 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
251
396
 
252
- const context = {
253
- ...collectionData,
254
- [collectionName]: pageSnapshot,
255
- relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
256
- // output-relative so page.url matches nav.json/index urls, same as compileDirectory
257
- _url: getPageUrlRelativeToOutput(markupOut, markupOutDir)
258
- }
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]))
259
401
 
260
- // mkDir only when a page is actually written: no empty pagination dirs
261
- // for index-less or unpublished (skipped) collection indexes
262
- const compilePromise = compileEntryFn(file, context).then(({ result, skipped }) => {
263
- if (skipped) return
264
- mkDir(markupOutDirFull)
265
- // async write so I/O overlaps rendering of the other pages
266
- return fs.promises.writeFile(markupOut, result)
267
- })
268
- 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
+ }
269
467
  }
270
468
  }
271
469
 
@@ -3,9 +3,9 @@ import path from 'node:path'
3
3
  import { Liquid } from 'liquidjs'
4
4
  import { highlightCode } from '../highlight.js'
5
5
  import { marked, renderMarkdownCached } from '../renderer.js'
6
- import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
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 || '')))
@@ -115,6 +116,10 @@ export default class LiquidEngine {
115
116
  const outputDir = path.resolve(process.cwd(), markupOut)
116
117
  return listImages(dirPath, outputDir)
117
118
  })
119
+ engine.registerFilter('jsonld', (page, site) => buildJsonLd(page, site))
120
+ engine.registerFilter('og', (page, site) => buildOpenGraph(page, site))
121
+ engine.registerFilter('canonical', (page, site) => buildCanonical(page, site))
122
+ engine.registerFilter('breadcrumb', (page, site, prefix) => buildBreadcrumb(page, site, prefix))
118
123
  engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
119
124
  engine.registerFilter('highlight', (code, lang) => {
120
125
  const highlighted = highlightCode(code, lang)
@@ -289,7 +294,8 @@ function registerPaginationTag(engine) {
289
294
  * render(ctx) {
290
295
  const pagination = yield this.liquid._evalValue(this.value, ctx)
291
296
  const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
292
- return buildPaginationTag(pagination, prefix)
297
+ const site = (yield ctx.get(['site'])) || {}
298
+ return buildPaginationTag(pagination, prefix, site.pagination)
293
299
  }
294
300
  })
295
301
  }
@@ -2,12 +2,12 @@ import fs from 'node:fs'
2
2
  import { globSync } from 'glob'
3
3
  import nunjucks from 'nunjucks'
4
4
  import path from 'node:path'
5
- import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
5
+ import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc, buildJsonLd, buildOpenGraph, buildCanonical, buildBreadcrumb } from '../helpers.js'
6
6
  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) => {
@@ -177,6 +178,10 @@ export default class NunjucksEngine {
177
178
  const outputDir = path.resolve(process.cwd(), markupOut)
178
179
  return listImages(dirPath, outputDir)
179
180
  })
181
+ env.addFilter('jsonld', (page, site) => new nunjucks.runtime.SafeString(buildJsonLd(page, site)))
182
+ env.addFilter('og', (page, site) => new nunjucks.runtime.SafeString(buildOpenGraph(page, site)))
183
+ env.addFilter('canonical', (page, site) => new nunjucks.runtime.SafeString(buildCanonical(page, site)))
184
+ env.addFilter('breadcrumb', (page, site, prefix) => new nunjucks.runtime.SafeString(buildBreadcrumb(page, site, prefix)))
180
185
  env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
181
186
  env.addFilter('highlight', (code, lang) => {
182
187
  const highlighted = highlightCode(code, lang)
@@ -337,6 +342,7 @@ export class PaginationExtension {
337
342
 
338
343
  run(context, pagination) {
339
344
  const prefix = context.lookup('relativePathPrefix') || ''
340
- 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))
341
347
  }
342
348
  }