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 +71 -2
- package/lib/exec.js +15 -0
- package/lib/markup/collections.js +239 -42
- package/lib/markup/engines/liquid.js +4 -2
- package/lib/markup/engines/nunjucks.js +4 -2
- package/lib/markup/helpers.js +40 -14
- package/lib/markup/indexer.js +7 -14
- package/lib/markups.js +30 -2
- package/package.json +2 -2
- package/poops.js +2 -1
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
+
const site = context.lookup('site') || {}
|
|
346
|
+
return new nunjucks.runtime.SafeString(buildPaginationTag(pagination, prefix, site.pagination))
|
|
345
347
|
}
|
|
346
348
|
}
|
package/lib/markup/helpers.js
CHANGED
|
@@ -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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
345
354
|
}
|
|
346
355
|
|
|
347
|
-
|
|
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)}"
|
|
354
|
-
parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
|
|
355
|
-
if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}"
|
|
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
|
|
package/lib/markup/indexer.js
CHANGED
|
@@ -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(
|
|
242
|
+
sectionOf(humanize(segs[0])).direct.push(e)
|
|
242
243
|
} else {
|
|
243
|
-
const subs = sectionOf(
|
|
244
|
-
const key =
|
|
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:
|
|
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 :
|
|
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 ? `${
|
|
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 =
|
|
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.
|
|
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
|
|
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)
|