poops 1.6.0 β 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -10
- 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 +34 -3
- package/package.json +2 -2
- package/poops.js +2 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# π© Poops [](https://www.npmjs.com/package/poops)
|
|
1
|
+
# π© Poops [](https://www.npmjs.com/package/poops) [](https://github.com/stamat/poops/actions/workflows/ci.yml) [](https://github.com/stamat/poops/blob/main/LICENSE)
|
|
2
2
|
|
|
3
3
|
Straightforward, no-bullshit bundler for the web.
|
|
4
4
|
|
|
@@ -40,6 +40,7 @@ It uses a simple config file where you define your input and output paths and it
|
|
|
40
40
|
- [Nunjucks vs Liquid](#nunjucks-vs-liquid)
|
|
41
41
|
- [Custom Engines](#custom-engines)
|
|
42
42
|
- [Collections & Pagination](#collections--pagination)
|
|
43
|
+
- [Taxonomies (Tags & Categories)](#taxonomies-tags--categories)
|
|
43
44
|
- [Custom Tags](#custom-tags)
|
|
44
45
|
- [image](#image)
|
|
45
46
|
- [googleFonts](#googlefonts)
|
|
@@ -71,6 +72,7 @@ It uses a simple config file where you define your input and output paths and it
|
|
|
71
72
|
- Resolves node modules
|
|
72
73
|
- Can add a templatable banner to output files (optional)
|
|
73
74
|
- Static site generation with swappable template engines: [Nunjucks](https://mozilla.github.io/nunjucks/) (default) or [Liquid](https://liquidjs.com/) β with blogging option (optional)
|
|
75
|
+
- Collections with pagination, and taxonomies β tags/categories as paginated, crawlable landing pages (with localizable labels)
|
|
74
76
|
- Has a configurable local server (optional)
|
|
75
77
|
- Rebuilds on file changes (optional)
|
|
76
78
|
- Live reloads on file changes (optional)
|
|
@@ -749,8 +751,73 @@ Or use the `{% pagination %}` shorthand tag (available in both engines), which r
|
|
|
749
751
|
{% pagination changelog %}
|
|
750
752
|
```
|
|
751
753
|
|
|
754
|
+
Pages 2..N automatically get a distinct `<title>` β `Changelog β Page 2` β so paginated pages don't all share the landing page's title (and its `og`/`jsonld` metadata). Page 1 keeps its own title.
|
|
755
|
+
|
|
756
|
+
**Localizing the labels.** The `β Page N` title suffix and the `{% pagination %}` tag's `Previous`/`Next`/`of` wording default to English. Override them site-wide under `site.pagination`:
|
|
757
|
+
|
|
758
|
+
```json
|
|
759
|
+
{
|
|
760
|
+
"markup": {
|
|
761
|
+
"site": {
|
|
762
|
+
"pagination": {
|
|
763
|
+
"title": "{title} β Seite {n}",
|
|
764
|
+
"prev": "ZurΓΌck",
|
|
765
|
+
"next": "Weiter",
|
|
766
|
+
"of": "von"
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
`title` accepts `{title}`, `{n}` and `{total}` tokens and applies to pages 2..N (and taxonomy term pages); `prev`/`next`/`of` localize the `{% pagination %}` tag (`{n} of {total}` β `{n} von {total}`).
|
|
774
|
+
|
|
752
775
|
Item pages themselves are compiled like any other markup file, preserving the directory structure: `src/markup/changelog/my-post.md` β `dist/changelog/my-post.html`. A collection directory without an index file still builds its items and exposes the collection to templates β only the paginated listing pages are skipped.
|
|
753
776
|
|
|
777
|
+
#### Taxonomies (Tags & Categories)
|
|
778
|
+
|
|
779
|
+
A **taxonomy** turns a front-matter field (tags, categories, authors) into its own paginated, crawlable landing page per term β `changelog/tag/feature/`, `blog/category/release/`. Declare which fields become taxonomies on the collection, alongside `paginate`/`sort` β either in the index front matter or the config entry:
|
|
780
|
+
|
|
781
|
+
```yaml
|
|
782
|
+
---
|
|
783
|
+
title: Changelog
|
|
784
|
+
collection: true
|
|
785
|
+
paginate: 10
|
|
786
|
+
taxonomies:
|
|
787
|
+
- name: tags # front-matter field to group on
|
|
788
|
+
path: tag # URL segment (defaults to name); "tag" for a singular URL
|
|
789
|
+
paginate: 5 # per-term page size (defaults to the collection's paginate)
|
|
790
|
+
---
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Shorthand: a bare string list (`taxonomies: [tags, category]`) uses each field name as the URL segment and inherits the collection's `paginate`. Array-valued fields split per element β a post with `tags: [js, css]` lands under **both** `tag/js/` and `tag/css/`. Terms are slugified for the URL (`Static Site` β `static-site`).
|
|
794
|
+
|
|
795
|
+
Term pages render with the **collection's own index template** β no extra file. On a term page the collection object carries the term context; branch on `activeTerm` to render a term view:
|
|
796
|
+
|
|
797
|
+
```nunjucks
|
|
798
|
+
{% if changelog.activeTerm %}
|
|
799
|
+
<h1>Tagged {{ changelog.activeTerm | humanize }}</h1>
|
|
800
|
+
{% for post in changelog.pageItems %}
|
|
801
|
+
<a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a>
|
|
802
|
+
{% endfor %}
|
|
803
|
+
{% pagination changelog %}
|
|
804
|
+
{% endif %}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
On a term page `items`/`pageItems` are scoped to that term (so `pagination` and `groupby` narrow to it too); `activeTaxonomy` holds the URL segment and `activeTermSlug` the slug. Build tag links anywhere from `collection.taxonomies`:
|
|
808
|
+
|
|
809
|
+
```nunjucks
|
|
810
|
+
{% for tax in changelog.taxonomies %}
|
|
811
|
+
{% for term in tax.terms %}
|
|
812
|
+
<a href="{{ relativePathPrefix }}{{ term.url }}">{{ term.term | humanize }} ({{ term.count }})</a>
|
|
813
|
+
{% endfor %}
|
|
814
|
+
{% endfor %}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Each term exposes `term`, `slug`, `url`, `count` and `totalPages`.
|
|
818
|
+
|
|
819
|
+
Term pages get a distinct `<title>` and `og`/`jsonld` metadata (`Tag: Feature`, paged `Tag: Feature β Page 2`), and the `breadcrumb`/`jsonld` filters resolve them to a **Home βΊ Collection βΊ Tag: Term** trail automatically (skipping the non-page `tag`/`category` URL segment). The `Tag:`/`Category:` label comes from `path`, so it localizes by naming the path in your language (`path: etiqueta` β `Etiqueta: β¦`). Term pages are listed in the sitemap but kept out of the search index and nav.
|
|
820
|
+
|
|
754
821
|
#### Custom Tags
|
|
755
822
|
|
|
756
823
|
##### image
|
|
@@ -804,9 +871,15 @@ Output:
|
|
|
804
871
|
```
|
|
805
872
|
|
|
806
873
|
```html
|
|
807
|
-
<img
|
|
808
|
-
|
|
809
|
-
|
|
874
|
+
<img
|
|
875
|
+
src="static/photo-thumb-480w.webp"
|
|
876
|
+
srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
|
|
877
|
+
width="480"
|
|
878
|
+
height="480"
|
|
879
|
+
sizes="240px"
|
|
880
|
+
alt=""
|
|
881
|
+
loading="lazy"
|
|
882
|
+
/>
|
|
810
883
|
```
|
|
811
884
|
|
|
812
885
|
This requires the [poops-images](https://github.com/stamat/poops-images) compile cache (named-size widths are read from it). The `size` name matches a named entry in your `images.sizes` config. The largest member of the group is written without a width suffix (`photo-thumb.webp`) β poops still srcsets it at its real width from the cache.
|
|
@@ -908,6 +981,8 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
908
981
|
|
|
909
982
|
- `slugify` β slugifies a string. Usage: `{{ "My Awesome Title" | slugify }}` will output `my-awesome-title`
|
|
910
983
|
|
|
984
|
+
- `humanize` β the inverse of `slugify`: turns a slug or raw term into a display label. Usage: `{{ "static-site" | humanize }}` will output `Static Site`
|
|
985
|
+
|
|
911
986
|
- `jsonify` β serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
|
|
912
987
|
|
|
913
988
|
- `markdown` β renders a markdown string to HTML with GitHub Flavored Markdown extras: emoji shortcodes (e.g. `:rocket:` β π), alert callouts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, `[!INFO]`) and footnotes (`[^1]`). Code fences are syntax-highlighted and headings get slug `id`s plus permalink anchors. Usage: `{{ "**bold** :rocket:" | markdown }}`
|
|
@@ -972,13 +1047,13 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
972
1047
|
|
|
973
1048
|
poops auto-picks `BlogPosting` (page has a `date`) or `WebPage`. Override `@type` with the `jsonld` object for any schema.org type β common ones search/generative engines act on: `Article`, `NewsArticle`, `HowTo`, `FAQPage`, `QAPage`, `Product`, `Recipe`, `Event`, `Course`, `VideoObject`, `SoftwareApplication`, `Organization`, `Person`, `BreadcrumbList`, `WebSite`. Full list at [schema.org/docs/full](https://schema.org/docs/full.html); validate with the [Rich Results Test](https://search.google.com/test/rich-results). A per-`@type` table with the notable fields is in the [Templating docs](example/src/markup/docs/quick-start/templating-html.md).
|
|
974
1049
|
|
|
975
|
-
- `breadcrumb` β generates a visible breadcrumb `<nav class="breadcrumb"><ol>β¦</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` β
|
|
1050
|
+
- `breadcrumb` β generates a visible breadcrumb `<nav class="breadcrumb"><ol>β¦</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` β _Static Site_), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) β not the absolute domain.
|
|
976
1051
|
- Nunjucks: `{{ page | breadcrumb(site, relativePathPrefix) }}`
|
|
977
1052
|
- Liquid: `{{ page | breadcrumb: site, relativePathPrefix }}`
|
|
978
1053
|
|
|
979
1054
|
The home crumb is optional: set `breadcrumb: { home: false }` (or `{ homeLabel: "Start" }` to rename it) under `site` or in a page's front matter β front matter wins. With the home crumb off, top-level pages fall to a single crumb and render nothing, while nested pages still show their folder trail. `breadcrumb: false` on a page or on `site` disables both the visible trail and the auto `BreadcrumbList` JSON-LD. Returns nothing on the homepage or any single-crumb page.
|
|
980
1055
|
|
|
981
|
-
- `groupby` β groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too.
|
|
1056
|
+
- `groupby` β groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too. Array-valued fields split per element β an item with `tags: [js, css]` appears in **both** the `js` and `css` groups (the mechanism behind [taxonomies](#taxonomies-tags--categories)).
|
|
982
1057
|
- Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
|
|
983
1058
|
- Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
|
|
984
1059
|
|
|
@@ -1007,7 +1082,7 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
1007
1082
|
|
|
1008
1083
|
Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
|
|
1009
1084
|
|
|
1010
|
-
|
|
1085
|
+
Pass a named crop/resize group as the second argument to get that group's srcset instead of the default widths: `{{ 'static/photo.jpg' | srcset: 'thumb' }}` β `static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w`.
|
|
1011
1086
|
|
|
1012
1087
|
- `exif` β returns the EXIF metadata object for an image from the [poops-images](https://github.com/stamat/poops-images) compile cache (`.poops-images-cache.json` in the output directory), or `null` if there is no cache or no EXIF data. The object includes camera (`make`, `model`, `lensModel`), exposure (`fNumber`, `exposure.formatted`, `iso`, `focalLength35mm`), `dateTime`, and `gps` (`latitude.formatted`, `longitude.formatted`, `altitude`, and a ready-made `googleMapsUrl`).
|
|
1013
1088
|
|
|
@@ -1149,7 +1224,7 @@ All front matter fields are passed through to the index automatically. Internal
|
|
|
1149
1224
|
]
|
|
1150
1225
|
```
|
|
1151
1226
|
|
|
1152
|
-
**Sitemap** generates a standard `sitemap.xml` with `<loc>` and `<lastmod>` (from front matter `date`). If `site.url` is set in your markup config, it is prepended to all URLs. Collection index/pagination pages are included in the sitemap but excluded from the search index.
|
|
1227
|
+
**Sitemap** generates a standard `sitemap.xml` with `<loc>` and `<lastmod>` (from front matter `date`). If `site.url` is set in your markup config, it is prepended to all URLs. Collection index/pagination and taxonomy term pages are included in the sitemap but excluded from the search index.
|
|
1153
1228
|
|
|
1154
1229
|
**llms.txt** generates an [`llms.txt`](https://llmstxt.org) β a Markdown index of your pages that LLMs and generative engines (GEO) read to understand your site. It has an `# H1` title, a `> ` blockquote summary, then `- [title](url): description` links grouped by URL path: the first folder is a `## section`, a second folder nests as a `### subsection` under it, and root-level pages fall under a lead "Pages" section. So `docs/config-reference.html` lands directly under `## Docs` while `docs/quick-start/x.html` lands under `### Quick Start` inside it. Collection items (which live under `collection/β¦`) group the same way and are ordered newest-first by their `date`; other sections keep file order. Set `intro` to a Markdown file path (relative to the project root) to insert free-form context between the blockquote and the link sections β a file authored for LLMs, e.g. `llms-intro.md`. Avoid `##` headings in it; they read as sections. (A raw README is a poor fit β badges, install noise and its own headings collide.) `title` and `description` default to your `site.title`/`site.description`; override them (and the lead section name via `sectionTitle`) with the object form. `site.url` makes the links absolute. Collection index/pagination pages are skipped, like the search index.
|
|
1155
1230
|
|
|
@@ -1172,7 +1247,8 @@ Pages with `published: false` in their front matter are excluded from all output
|
|
|
1172
1247
|
A page's front matter `robots: noindex` (or `none`) drops it from the **sitemap and llms.txt** β for drafts, thin or utility pages (a 404, say) you don't want crawled or fed to LLMs. It stays in the search index (that's your own on-site search). Emit the matching crawler directive in your layout `<head>` so the page itself carries it:
|
|
1173
1248
|
|
|
1174
1249
|
```html
|
|
1175
|
-
{% if page.robots %}<meta name="robots" content="{{ page.robots }}"
|
|
1250
|
+
{% if page.robots %}<meta name="robots" content="{{ page.robots }}" />{% endif
|
|
1251
|
+
%}
|
|
1176
1252
|
```
|
|
1177
1253
|
|
|
1178
1254
|
**Navigation tree** builds your page hierarchy as sidebar-ready data, exposed two ways: as the `nav` template global (loaded automatically, always reflecting the current build) and as a nested JSON file for client-side rendering. Subpages nest automatically from URL structure: `guide/index.md` becomes a parent node and `guide/getting-started.md`, `guide/advanced/config.md` become its (and its subsections') children. Add `nav` to your markup config:
|
|
@@ -1316,7 +1392,11 @@ Shorthand forms: `"feed": true` (or a filename string) emits an RSS feed for eve
|
|
|
1316
1392
|
Point browsers and readers at it from your layout `<head>`:
|
|
1317
1393
|
|
|
1318
1394
|
```html
|
|
1319
|
-
<link
|
|
1395
|
+
<link
|
|
1396
|
+
rel="alternate"
|
|
1397
|
+
type="application/rss+xml"
|
|
1398
|
+
href="{{ site.url }}/blog/feed.rss"
|
|
1399
|
+
/>
|
|
1320
1400
|
```
|
|
1321
1401
|
|
|
1322
1402
|
### Images (optional)
|
package/lib/exec.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
2
|
import log, { styledLog } from './utils/log.js'
|
|
3
3
|
|
|
4
|
+
// Every stage runExec fires a hook for. A key in config.exec outside this set
|
|
5
|
+
// never runs (runExec only looks up known stages), so it's almost always a typo
|
|
6
|
+
// (e.g. `style`, `script`). validateExec warns once at startup instead of
|
|
7
|
+
// letting the hook silently no-op.
|
|
8
|
+
export const EXEC_STAGES = ['reactor', 'scripts', 'images', 'markup', 'styles', 'copy', 'build']
|
|
9
|
+
|
|
10
|
+
// Returns the unknown stage keys (for tests); warns for each as a side effect.
|
|
11
|
+
export function validateExec(config) {
|
|
12
|
+
const unknown = Object.keys(config.exec || {}).filter((key) => !EXEC_STAGES.includes(key))
|
|
13
|
+
for (const key of unknown) {
|
|
14
|
+
log({ tag: 'exec', warn: true, text: `unknown stage "${key}" β never runs. Valid: ${EXEC_STAGES.join(', ')}` })
|
|
15
|
+
}
|
|
16
|
+
return unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
// Post-stage shell hooks. `config.exec` maps a pipeline stage to a command (or
|
|
5
20
|
// array of commands) run after that stage compiles β in both build and watch,
|
|
6
21
|
// so a post-processor (e.g. stripping CSS comments, regenerating a reference)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import { globSync } from 'glob'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { slugify, humanize } from 'book-of-spells'
|
|
4
5
|
import log from '../utils/log.js'
|
|
5
6
|
import { mkDir, toPosix } from '../utils/helpers.js'
|
|
6
|
-
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './helpers.js'
|
|
7
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt, groupby } from './helpers.js'
|
|
7
8
|
|
|
8
9
|
export function getSingleCollectionData(markupInDir, collectionName) {
|
|
9
10
|
const collectionData = []
|
|
@@ -158,9 +159,75 @@ export function buildCollectionObject(markupInDir, collectionProtoObject) {
|
|
|
158
159
|
}
|
|
159
160
|
})
|
|
160
161
|
|
|
162
|
+
if (collectionProtoObject.taxonomies) {
|
|
163
|
+
collection._taxonomies = normalizeTaxonomies(collectionProtoObject.taxonomies, collection.paginate)
|
|
164
|
+
}
|
|
165
|
+
|
|
161
166
|
return collection
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
// Normalizes the `taxonomies` collection option into { name, path, paginate }.
|
|
170
|
+
// Accepts "tags" | ["tags", "category"] | [{ name, path, paginate }]. `name` is
|
|
171
|
+
// the front-matter field grouped on; `path` is the URL segment under the
|
|
172
|
+
// collection (defaults to `name`, so use `{ name: "tags", path: "tag" }` for a
|
|
173
|
+
// singular /tag/ URL); `paginate` falls back to the collection's page size.
|
|
174
|
+
function normalizeTaxonomies(config, defaultPaginate) {
|
|
175
|
+
const items = Array.isArray(config) ? config : [config]
|
|
176
|
+
const out = []
|
|
177
|
+
for (let item of items) {
|
|
178
|
+
if (typeof item === 'string') item = { name: item }
|
|
179
|
+
if (!item || !item.name) continue
|
|
180
|
+
const paginate = parseInt(item.paginate)
|
|
181
|
+
out.push({
|
|
182
|
+
name: item.name,
|
|
183
|
+
path: item.path || item.name,
|
|
184
|
+
paginate: !isNaN(paginate) ? paginate : defaultPaginate
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Chunks items into pages of `size`; no/invalid size β a single page holding all.
|
|
191
|
+
export function paginateItems(items, size) {
|
|
192
|
+
if (!size || isNaN(size) || size < 1) return [items]
|
|
193
|
+
const pages = []
|
|
194
|
+
for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size))
|
|
195
|
+
return pages.length ? pages : [items]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Groups each collection's items by every configured taxonomy field and attaches
|
|
199
|
+
// collection.taxonomies = [{ name, path, terms: [{ term, slug, url, count,
|
|
200
|
+
// totalPages, pages, items }] }] β consumed by both the listing template (to link
|
|
201
|
+
// tag/category pages) and generateTaxonomyPages. Must run before any collection
|
|
202
|
+
// template renders so page 1 of the index can already see the term links.
|
|
203
|
+
// ponytail: two terms that slugify to the same slug collide on one URL (last
|
|
204
|
+
// write wins). Add a per-slug de-dupe counter if real tag sets ever clash.
|
|
205
|
+
export function buildTaxonomyData(collectionData) {
|
|
206
|
+
if (!collectionData) return
|
|
207
|
+
for (const name of Object.keys(collectionData)) {
|
|
208
|
+
const collection = collectionData[name]
|
|
209
|
+
if (!collection._taxonomies) continue
|
|
210
|
+
collection.taxonomies = collection._taxonomies.map((tax) => {
|
|
211
|
+
const terms = groupby(collection.items, tax.name)
|
|
212
|
+
.filter((g) => g.key !== '')
|
|
213
|
+
.map((g) => {
|
|
214
|
+
const slug = slugify(g.key)
|
|
215
|
+
const pages = paginateItems(g.items, tax.paginate)
|
|
216
|
+
return {
|
|
217
|
+
term: g.key,
|
|
218
|
+
slug,
|
|
219
|
+
url: toPosix(path.join(collection.name, tax.path, slug)),
|
|
220
|
+
count: g.items.length,
|
|
221
|
+
totalPages: pages.length,
|
|
222
|
+
pages,
|
|
223
|
+
items: g.items
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
return { name: tax.name, path: tax.path, terms }
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
164
231
|
export function buildCollectionPaginationData(collectionData) {
|
|
165
232
|
if (!collectionData) return
|
|
166
233
|
|
|
@@ -204,9 +271,83 @@ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutD
|
|
|
204
271
|
}
|
|
205
272
|
}
|
|
206
273
|
|
|
207
|
-
|
|
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
|
|
|
@@ -339,7 +347,10 @@ export default class Markups {
|
|
|
339
347
|
relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
|
|
340
348
|
// output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
|
|
341
349
|
// otherwise `item.url == page.url` sidebar/active checks never fire
|
|
342
|
-
_url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
|
|
350
|
+
_url: getPageUrlRelativeToOutput(markupOut, this.markupOut),
|
|
351
|
+
// source path relative to markup input (posix), so templates can build
|
|
352
|
+
// "edit on GitHub" links β .html output can't be reversed to .md reliably
|
|
353
|
+
_page: { inputPath: toPosix(relativePath) }
|
|
343
354
|
}
|
|
344
355
|
|
|
345
356
|
const shouldIndex = pageEntries && this.engine.indexableExtensions.has(path.extname(file))
|
|
@@ -520,12 +531,18 @@ export default class Markups {
|
|
|
520
531
|
const pageEntries = shouldIndex ? [] : null
|
|
521
532
|
|
|
522
533
|
buildCollectionPaginationData(collectionData)
|
|
534
|
+
// attach collection.taxonomies before any render so index/listing templates
|
|
535
|
+
// can link tag/category pages on page 1
|
|
536
|
+
buildTaxonomyData(collectionData)
|
|
523
537
|
|
|
524
538
|
// must precede any rendering (incl. pagination pages) so every template
|
|
525
539
|
// sees the current build's nav
|
|
526
540
|
this.buildNavGlobal(markupIn, collectionData)
|
|
527
541
|
|
|
528
|
-
const collectionPromises =
|
|
542
|
+
const collectionPromises = [
|
|
543
|
+
...generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData),
|
|
544
|
+
...generateTaxonomyPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData)
|
|
545
|
+
]
|
|
529
546
|
|
|
530
547
|
await Promise.all(collectionPromises)
|
|
531
548
|
|
|
@@ -542,6 +559,20 @@ export default class Markups {
|
|
|
542
559
|
isIndex: true
|
|
543
560
|
})
|
|
544
561
|
}
|
|
562
|
+
|
|
563
|
+
// taxonomy term pages: crawlable (sitemap) but isIndex, so out of the
|
|
564
|
+
// search index, llms.txt and nav β same as pagination pages
|
|
565
|
+
for (const tax of collection.taxonomies || []) {
|
|
566
|
+
for (const term of tax.terms) {
|
|
567
|
+
for (let p = 0; p < term.totalPages; p++) {
|
|
568
|
+
pageEntries.push({
|
|
569
|
+
url: p === 0 ? term.url : `${term.url}/${p + 1}`,
|
|
570
|
+
title: term.term,
|
|
571
|
+
isIndex: true
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
545
576
|
}
|
|
546
577
|
}
|
|
547
578
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "poops",
|
|
3
3
|
"description": "Straightforward, no-bullshit bundler for the web.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "poops.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"argoyle": "^1.0.0",
|
|
55
|
-
"book-of-spells": "^1.0
|
|
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)
|