poops 1.2.4 → 1.4.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 +220 -20
- package/lib/copy.js +1 -1
- package/lib/markup/collections.js +10 -9
- package/lib/markup/engines/liquid.js +23 -10
- package/lib/markup/engines/nunjucks.js +30 -9
- package/lib/markup/helpers.js +31 -0
- package/lib/markup/indexer.js +152 -0
- package/lib/markup/renderer.js +68 -0
- package/lib/markups.js +153 -47
- package/lib/styles.js +31 -5
- package/lib/utils/helpers.js +30 -0
- package/package.json +3 -1
- package/poops.js +17 -6
package/README.md
CHANGED
|
@@ -24,6 +24,37 @@ Intuitive with a minimal learning curve and minimal docs, utilizing the most eff
|
|
|
24
24
|
|
|
25
25
|
It uses a simple config file where you define your input and output paths and it poops out your bundled files. Simple as that.
|
|
26
26
|
|
|
27
|
+
## Table of Contents
|
|
28
|
+
|
|
29
|
+
- [Features](#features)
|
|
30
|
+
- [Quick Start](#quick-start)
|
|
31
|
+
- [Configuration](#configuration)
|
|
32
|
+
- [Scripts](#scripts)
|
|
33
|
+
- [JSX/TSX (React) Example](#jsxtsx-react-example)
|
|
34
|
+
- [Reactor (React Pre-rendering)](#reactor-react-pre-rendering)
|
|
35
|
+
- [Styles](#styles)
|
|
36
|
+
- [Design Tokens](#design-tokens)
|
|
37
|
+
- [PostCSS (optional)](#postcss-optional)
|
|
38
|
+
- [Tailwind CSS Example](#tailwind-css-example)
|
|
39
|
+
- [Markups](#markups)
|
|
40
|
+
- [Nunjucks vs Liquid](#nunjucks-vs-liquid)
|
|
41
|
+
- [Custom Engines](#custom-engines)
|
|
42
|
+
- [Collections & Pagination](#collections--pagination)
|
|
43
|
+
- [Custom Tags](#custom-tags)
|
|
44
|
+
- [image](#image)
|
|
45
|
+
- [googleFonts](#googlefonts)
|
|
46
|
+
- [highlight](#highlight)
|
|
47
|
+
- [Custom Filters](#custom-filters)
|
|
48
|
+
- [Search Index, Sitemap & Navigation](#search-index-sitemap--navigation)
|
|
49
|
+
- [Images (optional)](#images-optional)
|
|
50
|
+
- [Copy](#copy)
|
|
51
|
+
- [Banner (optional)](#banner-optional)
|
|
52
|
+
- [Local Server (optional)](#local-server-optional)
|
|
53
|
+
- [Live Reload (optional)](#live-reload-optional)
|
|
54
|
+
- [Watch (optional)](#watch-optional)
|
|
55
|
+
- [Include Paths (optional)](#include-paths-optional)
|
|
56
|
+
- [Why?](#why)
|
|
57
|
+
|
|
27
58
|
## Features
|
|
28
59
|
|
|
29
60
|
- Bundles SCSS/SASS to CSS
|
|
@@ -174,8 +205,8 @@ You can freely remove the properties that you don't need. For example, if you do
|
|
|
174
205
|
|
|
175
206
|
Scripts are bundled with [esbuild](https://esbuild.github.io/). Supports `.js`, `.ts`, `.jsx`, and `.tsx` files out of the box — including React and other JSX frameworks. You can specify multiple scripts to bundle. Each script has the following properties:
|
|
176
207
|
|
|
177
|
-
- `in` - the input path, can be an array of file paths,
|
|
178
|
-
- `out` - the output path, can be a directory or a file path
|
|
208
|
+
- `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/js/*.js"`). Globs must use `/` separators (even on Windows)
|
|
209
|
+
- `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — entry points from different directories nest their output under the common ancestor (esbuild's `outbase`)
|
|
179
210
|
- `options` - the options for the bundler. You can apply most of the esbuild options that are not in conflict with Poops. See [esbuild's options](https://esbuild.github.io/api/#build-api) for more info.
|
|
180
211
|
|
|
181
212
|
**Options:**
|
|
@@ -308,8 +339,8 @@ Poops does not need `react` or `react-dom` as its own dependency — they are re
|
|
|
308
339
|
|
|
309
340
|
Styles are bundled with [Dart Sass](https://sass-lang.com/dart-sass). You can specify multiple styles to bundle. Each style has the following properties:
|
|
310
341
|
|
|
311
|
-
- `in` - the input path,
|
|
312
|
-
- `out` - the output path, can be a directory or a file path
|
|
342
|
+
- `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/scss/*.scss"`). Globs must use `/` separators (even on Windows) and skip Sass partials (`_*.scss`). Each matched file is compiled separately
|
|
343
|
+
- `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — each input compiles to `<out>/<basename>.css`, so inputs sharing a basename (e.g. `a/main.scss` and `b/main.scss`) will overwrite each other
|
|
313
344
|
- `options` - the options for the bundler.
|
|
314
345
|
|
|
315
346
|
**Options:**
|
|
@@ -465,37 +496,34 @@ You can also pass options to plugins using the tuple form:
|
|
|
465
496
|
|
|
466
497
|
#### Tailwind CSS Example
|
|
467
498
|
|
|
468
|
-
|
|
499
|
+
Install the deps, then use a config like this:
|
|
469
500
|
|
|
470
501
|
```bash
|
|
471
502
|
npm i -D postcss @tailwindcss/postcss tailwindcss
|
|
472
|
-
node poops.js -c example-tailwind/poops.json
|
|
473
503
|
```
|
|
474
504
|
|
|
475
|
-
The example config (`example-tailwind/poops.json`):
|
|
476
|
-
|
|
477
505
|
```json
|
|
478
506
|
{
|
|
479
507
|
"postcss": {
|
|
480
|
-
"in": "
|
|
481
|
-
"out": "
|
|
508
|
+
"in": "src/css/main.css",
|
|
509
|
+
"out": "dist/css/main.css",
|
|
482
510
|
"options": {
|
|
483
511
|
"plugins": ["@tailwindcss/postcss"],
|
|
484
512
|
"minify": true
|
|
485
513
|
}
|
|
486
514
|
},
|
|
487
515
|
"markup": {
|
|
488
|
-
"in": "
|
|
489
|
-
"out": "
|
|
516
|
+
"in": "src/markup",
|
|
517
|
+
"out": "dist",
|
|
490
518
|
"site": {
|
|
491
519
|
"title": "Poops + Tailwind",
|
|
492
520
|
"description": "A Tailwind CSS example for Poops"
|
|
493
521
|
},
|
|
494
522
|
"includePaths": ["_layouts", "_partials"]
|
|
495
523
|
},
|
|
496
|
-
"serve": { "port":
|
|
524
|
+
"serve": { "port": 4040, "base": "/dist" },
|
|
497
525
|
"livereload": true,
|
|
498
|
-
"watch": ["
|
|
526
|
+
"watch": ["src"]
|
|
499
527
|
}
|
|
500
528
|
```
|
|
501
529
|
|
|
@@ -511,7 +539,7 @@ Then use Tailwind utility classes directly in your markup templates. Tailwind v4
|
|
|
511
539
|
|
|
512
540
|
### Markups
|
|
513
541
|
|
|
514
|
-
- `engine` (optional) - the template engine to use. Can be `"nunjucks"` (default) or `"liquid"`. [Nunjucks](https://mozilla.github.io/nunjucks/) is a Mozilla template engine inspired by Jinja2. [Liquid](https://liquidjs.com/) is a Shopify-compatible template engine. Both engines support the same tags, filters, collections, search index, and
|
|
542
|
+
- `engine` (optional) - the template engine to use. Can be `"nunjucks"` (default) or `"liquid"`. [Nunjucks](https://mozilla.github.io/nunjucks/) is a Mozilla template engine inspired by Jinja2. [Liquid](https://liquidjs.com/) is a Shopify-compatible template engine. Both engines support the same tags, filters, collections, search index, sitemap, and navigation tree features documented below.
|
|
515
543
|
- `in` - the input path, can be a directory or a file path, but please just use it as a directory path for now. All files in this directory will be processed and the structure of the directory will be preserved in the output directory with exception to directories that begin with an underscore `_` will be ignored.
|
|
516
544
|
- `out` - the output path, can be only a directory path (for now)
|
|
517
545
|
- `site` (optional) - global data that will be available to all templates in the markup directory. Like site title, description, social media links, etc. You can then use this data in your templates `{{ site.title }}` for instance.
|
|
@@ -561,7 +589,7 @@ If your project doesn't have markups, you can remove the `markup` property from
|
|
|
561
589
|
|
|
562
590
|
#### Nunjucks vs Liquid
|
|
563
591
|
|
|
564
|
-
Both engines support the same feature set (collections, pagination, search index, sitemap, custom tags, and filters). The main differences are in template syntax:
|
|
592
|
+
Both engines support the same feature set (collections, pagination, search index, sitemap, navigation tree, custom tags, and filters). The main differences are in template syntax:
|
|
565
593
|
|
|
566
594
|
| Feature | Nunjucks | Liquid |
|
|
567
595
|
| -------------- | ----------------------------- | ------------------------------------- |
|
|
@@ -574,6 +602,62 @@ Both engines support the same feature set (collections, pagination, search index
|
|
|
574
602
|
|
|
575
603
|
Both engines process `.html` and `.md` files in addition to their native extension.
|
|
576
604
|
|
|
605
|
+
#### Custom Engines
|
|
606
|
+
|
|
607
|
+
The `engine` option also accepts a module specifier — an npm package name or a path relative to your project root. The module's default export must be an engine class:
|
|
608
|
+
|
|
609
|
+
```json
|
|
610
|
+
{
|
|
611
|
+
"markup": {
|
|
612
|
+
"in": "src/markup",
|
|
613
|
+
"out": "dist",
|
|
614
|
+
"engine": "poops-shopify"
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
An engine class implements this contract (see [`lib/markup/engines/`](lib/markup/engines/) for the two built-in reference implementations):
|
|
620
|
+
|
|
621
|
+
```js
|
|
622
|
+
export default class MyEngine {
|
|
623
|
+
constructor(templatesDir, includePaths, options) {} // options: { autoescape }
|
|
624
|
+
get fileExtension() {
|
|
625
|
+
return ".liquid";
|
|
626
|
+
} // native template extension
|
|
627
|
+
get indexableExtensions() {
|
|
628
|
+
return new Set([".html"]);
|
|
629
|
+
} // extensions eligible for search index/nav
|
|
630
|
+
get markupExtensions() {
|
|
631
|
+
return "html|liquid|md";
|
|
632
|
+
} // glob alternation of processed extensions
|
|
633
|
+
registerFilters({ timeDateFormat, markupOut }) {}
|
|
634
|
+
registerTags(getOutputDir) {}
|
|
635
|
+
setGlobal(key, value) {}
|
|
636
|
+
removeGlobal(key) {}
|
|
637
|
+
async render(templatePath, context) {
|
|
638
|
+
return "html";
|
|
639
|
+
} // templatePath is an absolute file path
|
|
640
|
+
async renderString(source, context) {
|
|
641
|
+
return "html";
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Optionally, an engine may implement `replaceOutExtensions(outputPath)` to control how source extensions map to output extensions (the default maps `.md`/`.njk`/`.liquid` to `.html`).
|
|
647
|
+
|
|
648
|
+
The easiest starting point is extending a built-in engine — deep imports are intentionally supported for this:
|
|
649
|
+
|
|
650
|
+
```js
|
|
651
|
+
import LiquidEngine from "poops/lib/markup/engines/liquid.js";
|
|
652
|
+
|
|
653
|
+
export default class MyEngine extends LiquidEngine {
|
|
654
|
+
registerFilters(opts) {
|
|
655
|
+
super.registerFilters(opts);
|
|
656
|
+
this.engine.registerFilter("shout", (str) => String(str).toUpperCase());
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
577
661
|
#### Collections & Pagination
|
|
578
662
|
|
|
579
663
|
Collections turn a directory of pages into a sorted, optionally paginated list — blog posts, changelog entries, documentation. A collection maps to a direct subdirectory of your markup `in` directory: every `.html`, `.njk`, `.liquid` or `.md` file inside it (except the `index.*` file) becomes a collection item.
|
|
@@ -659,6 +743,12 @@ From the example site's `changelog/index.html`:
|
|
|
659
743
|
{% endif %}
|
|
660
744
|
```
|
|
661
745
|
|
|
746
|
+
Or use the `{% pagination %}` shorthand tag (available in both engines), which renders Previous/Next links and a "page of total" counter — with `relativePathPrefix` applied — and outputs nothing when there is only one page:
|
|
747
|
+
|
|
748
|
+
```nunjucks
|
|
749
|
+
{% pagination changelog %}
|
|
750
|
+
```
|
|
751
|
+
|
|
662
752
|
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.
|
|
663
753
|
|
|
664
754
|
#### Custom Tags
|
|
@@ -806,7 +896,7 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
806
896
|
|
|
807
897
|
- `jsonify` — serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
|
|
808
898
|
|
|
809
|
-
- `markdown` — renders a markdown string to HTML. Usage: `{{ "**bold**" | markdown }}`
|
|
899
|
+
- `markdown` — renders a markdown string to HTML with GitHub-style emoji shortcodes (e.g. `:rocket:` → 🚀). Usage: `{{ "**bold** :rocket:" | markdown }}`
|
|
810
900
|
|
|
811
901
|
- `date` — formats a date string. Uses [dayjs](https://day.js.org/) format tokens. A default format can be set via the `timeDateFormat` config option.
|
|
812
902
|
- Nunjucks: `{{ "2024-01-15" | date("MMMM D, YYYY") }}`
|
|
@@ -918,11 +1008,11 @@ Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo
|
|
|
918
1008
|
{% endfor %}
|
|
919
1009
|
```
|
|
920
1010
|
|
|
921
|
-
#### Search Index &
|
|
1011
|
+
#### Search Index, Sitemap & Navigation
|
|
922
1012
|
|
|
923
|
-
Poops can automatically generate a JSON search index
|
|
1013
|
+
Poops can automatically generate a JSON search index, an XML sitemap and a navigation tree from your compiled pages. All are generated in a single pass during the markup compilation phase.
|
|
924
1014
|
|
|
925
|
-
To enable, add `searchIndex` and/or `
|
|
1015
|
+
To enable, add `searchIndex`, `sitemap` and/or `nav` to your markup config:
|
|
926
1016
|
|
|
927
1017
|
```json
|
|
928
1018
|
{
|
|
@@ -988,6 +1078,116 @@ All front matter fields are passed through to the index automatically. Internal
|
|
|
988
1078
|
|
|
989
1079
|
Pages with `published: false` in their front matter are excluded from both outputs.
|
|
990
1080
|
|
|
1081
|
+
**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:
|
|
1082
|
+
|
|
1083
|
+
```json
|
|
1084
|
+
{
|
|
1085
|
+
"markup": {
|
|
1086
|
+
"in": "src/markup",
|
|
1087
|
+
"out": "dist",
|
|
1088
|
+
"options": {
|
|
1089
|
+
"nav": "nav.json"
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The string shorthand sets the output filename. For docs sites, use the object form:
|
|
1096
|
+
|
|
1097
|
+
```json
|
|
1098
|
+
{
|
|
1099
|
+
"nav": {
|
|
1100
|
+
"output": "nav.json",
|
|
1101
|
+
"root": "docs",
|
|
1102
|
+
"collections": "index",
|
|
1103
|
+
"home": false
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
**Navigation options:**
|
|
1109
|
+
|
|
1110
|
+
- `output` — output filename, written to the markup output directory
|
|
1111
|
+
- `collections` — how to treat collection pages (default `true`):
|
|
1112
|
+
- `true` — include every collection page, nested under its collection
|
|
1113
|
+
- `false` — exclude all collection pages (drops a blog's posts from the sidebar)
|
|
1114
|
+
- `["docs", ...]` — allowlist; only these collections' pages are included (non-collection pages are always kept)
|
|
1115
|
+
- `"index"` — include only each collection's landing page as a single leaf, not its posts
|
|
1116
|
+
- `home` — `false` drops the site's root index page (url `""`) from the tree (default `true`)
|
|
1117
|
+
- `root` — scope the tree to a subdirectory (e.g. `"docs"`); its children are emitted at the top level and the section's own index page is pinned first as the overview link. URLs are kept full (`docs/getting-started`), so the homepage is naturally excluded
|
|
1118
|
+
|
|
1119
|
+
**Front matter fields** that shape the tree:
|
|
1120
|
+
|
|
1121
|
+
- `order` — a number that sorts a page within its sibling level (optional). Pages without `order` fall to the bottom, sorted alphabetically by title — so a hand-authored docs sequence (`1`, `2`, `3`) wins over alphabetical. This applies to the homepage too: give it `order: 0` in its front matter to pin it to the top, otherwise it sorts last like any page without `order`.
|
|
1122
|
+
- `nav: false` — hide a page from the sidebar (it stays in the search index and sitemap).
|
|
1123
|
+
- `navTitle` — a sidebar label that overrides `title`.
|
|
1124
|
+
|
|
1125
|
+
**Navigation output format** — each node has a `title`, a `url` (omitted on synthesized section nodes that have no index page of their own), an `order` when set, and `children` when it has subpages:
|
|
1126
|
+
|
|
1127
|
+
```json
|
|
1128
|
+
[
|
|
1129
|
+
{
|
|
1130
|
+
"title": "Guide",
|
|
1131
|
+
"url": "guide",
|
|
1132
|
+
"order": 1,
|
|
1133
|
+
"children": [
|
|
1134
|
+
{
|
|
1135
|
+
"title": "Getting Started",
|
|
1136
|
+
"url": "guide/getting-started",
|
|
1137
|
+
"order": 1
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
"title": "Advanced",
|
|
1141
|
+
"url": "guide/advanced",
|
|
1142
|
+
"children": [{ "title": "Config", "url": "guide/advanced/config" }]
|
|
1143
|
+
}
|
|
1144
|
+
]
|
|
1145
|
+
}
|
|
1146
|
+
]
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
Pages with `published: false` or `nav: false` are excluded. If nothing survives filtering, an empty array `[]` is written so consumers never have to special-case a missing file.
|
|
1150
|
+
|
|
1151
|
+
**Rendering the sidebar.** The tree is arbitrarily deep, so render it with a recursive template. The `nav` global is built from front matter in a pre-pass before templating, so it always reflects the current build — no need to load the generated `nav.json` back in via `data` (which would be one build behind). The written `nav.json` is for client-side rendering (`fetch('/nav.json')`). Prefix each `url` with `relativePathPrefix` so links resolve from any page depth.
|
|
1152
|
+
|
|
1153
|
+
Nunjucks — a self-recursing macro:
|
|
1154
|
+
|
|
1155
|
+
```njk
|
|
1156
|
+
{% macro navtree(items) %}
|
|
1157
|
+
<ul>
|
|
1158
|
+
{% for item in items %}
|
|
1159
|
+
<li>
|
|
1160
|
+
{% if item.url != null %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
|
|
1161
|
+
{% else %}<span>{{ item.title }}</span>{% endif %}
|
|
1162
|
+
{% if item.children %}{{ navtree(item.children) }}{% endif %}
|
|
1163
|
+
</li>
|
|
1164
|
+
{% endfor %}
|
|
1165
|
+
</ul>
|
|
1166
|
+
{% endmacro %}
|
|
1167
|
+
|
|
1168
|
+
{{ navtree(nav) }}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
Note the `!= null` check: the homepage node's `url` is an empty string (a valid link — `relativePathPrefix` resolves it), while synthesized section nodes have no `url` at all. A plain `{% if item.url %}` would wrongly demote the homepage to a `<span>`. Node titles already have `navTitle` applied, so `{{ item.title }}` is all you need.
|
|
1172
|
+
|
|
1173
|
+
Liquid — a partial that recurses via `render` (save as `_partials/navtree.liquid`). Liquid treats empty strings as truthy, so the plain `if` is safe here:
|
|
1174
|
+
|
|
1175
|
+
```liquid
|
|
1176
|
+
<ul>
|
|
1177
|
+
{% for item in items %}
|
|
1178
|
+
<li>
|
|
1179
|
+
{% if item.url %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
|
|
1180
|
+
{% else %}<span>{{ item.title }}</span>{% endif %}
|
|
1181
|
+
{% if item.children %}{% render 'navtree', items: item.children, relativePathPrefix: relativePathPrefix %}{% endif %}
|
|
1182
|
+
</li>
|
|
1183
|
+
{% endfor %}
|
|
1184
|
+
</ul>
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
```liquid
|
|
1188
|
+
{% render 'navtree', items: nav, relativePathPrefix: relativePathPrefix %}
|
|
1189
|
+
```
|
|
1190
|
+
|
|
991
1191
|
### Images (optional)
|
|
992
1192
|
|
|
993
1193
|
Process and optimize images — compression, responsive size variants, format conversion (WebP/AVIF), crops and EXIF extraction — by running [poops-images](https://github.com/stamat/poops-images) as part of the build. This is what feeds the `{% image %}` tag, the `exif`/`images` filters and the `.poops-images-cache.json` compile cache described in [Custom Tags](#custom-tags) and [Custom Filters](#custom-filters).
|
package/lib/copy.js
CHANGED
|
@@ -95,7 +95,7 @@ export default class Copy {
|
|
|
95
95
|
// Watcher paths arrive with native separators, config `in` uses `/`
|
|
96
96
|
file = toPosix(file).replace(copyPaths.in, copyPaths.out)
|
|
97
97
|
|
|
98
|
-
const outputFilePath = path.
|
|
98
|
+
const outputFilePath = path.resolve(process.cwd(), file)
|
|
99
99
|
|
|
100
100
|
if (pathExists(outputFilePath)) {
|
|
101
101
|
if (pathIsDirectory(outputFilePath)) {
|
|
@@ -3,12 +3,12 @@ import { globSync } from 'glob'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import log from '../utils/log.js'
|
|
5
5
|
import { mkDir, toPosix } from '../utils/helpers.js'
|
|
6
|
-
import { replaceOutExtensions, getRelativePathPrefix,
|
|
6
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './helpers.js'
|
|
7
7
|
|
|
8
8
|
export function getSingleCollectionData(markupInDir, collectionName) {
|
|
9
9
|
const collectionData = []
|
|
10
10
|
// glob patterns must use `/` — on Windows `\` is an escape character
|
|
11
|
-
globSync(toPosix(path.
|
|
11
|
+
globSync(toPosix(path.resolve(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)')), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
|
|
12
12
|
let frontMatter = {}
|
|
13
13
|
|
|
14
14
|
let content = ''
|
|
@@ -47,7 +47,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function collectionAutoDiscovery(markupInDir) {
|
|
50
|
-
const indexFiles = globSync(toPosix(path.
|
|
50
|
+
const indexFiles = globSync(toPosix(path.resolve(process.cwd(), markupInDir, '**/index.+(html|njk|liquid|md)')))
|
|
51
51
|
|
|
52
52
|
const collectionData = {}
|
|
53
53
|
|
|
@@ -184,13 +184,13 @@ export function buildCollectionPaginationData(collectionData) {
|
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
export function getCollectionIndexFile(markupInDir, collectionName) {
|
|
187
|
-
const indexFiles = globSync(toPosix(path.
|
|
187
|
+
const indexFiles = globSync(toPosix(path.resolve(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)')))
|
|
188
188
|
if (indexFiles.length === 0) return null
|
|
189
189
|
return indexFiles[0]
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutDir, keepPages) {
|
|
193
|
-
const outDir = path.
|
|
193
|
+
const outDir = path.resolve(process.cwd(), markupOutDir, collectionName)
|
|
194
194
|
if (!fs.existsSync(outDir)) return
|
|
195
195
|
|
|
196
196
|
for (const entry of fs.readdirSync(outDir)) {
|
|
@@ -198,7 +198,7 @@ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutD
|
|
|
198
198
|
// pagination only ever writes out/<name>/2..totalPages/
|
|
199
199
|
if (String(pageNumber) !== entry || pageNumber < 2 || pageNumber <= keepPages) continue
|
|
200
200
|
// numeric dir mirrored from a real source dir — not pagination output
|
|
201
|
-
if (fs.existsSync(path.
|
|
201
|
+
if (fs.existsSync(path.resolve(process.cwd(), markupInDir, collectionName, entry))) continue
|
|
202
202
|
fs.rmSync(path.join(outDir, entry), { recursive: true, force: true })
|
|
203
203
|
}
|
|
204
204
|
}
|
|
@@ -245,15 +245,16 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
|
|
|
245
245
|
prevPageUrl
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
const markupOut = path.
|
|
249
|
-
const fromPath = path.
|
|
248
|
+
const markupOut = path.resolve(process.cwd(), markupOutDir, pageUrl, 'index.html')
|
|
249
|
+
const fromPath = path.resolve(process.cwd(), markupOutDir)
|
|
250
250
|
const markupOutDirFull = path.dirname(markupOut)
|
|
251
251
|
|
|
252
252
|
const context = {
|
|
253
253
|
...collectionData,
|
|
254
254
|
[collectionName]: pageSnapshot,
|
|
255
255
|
relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
|
|
256
|
-
|
|
256
|
+
// output-relative so page.url matches nav.json/index urls, same as compileDirectory
|
|
257
|
+
_url: getPageUrlRelativeToOutput(markupOut, markupOutDir)
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
// mkDir only when a page is actually written: no empty pagination dirs
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { Liquid } from 'liquidjs'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { discoverImageVariants, parseFrontMatter, groupby,
|
|
4
|
+
import { highlightCode } from '../highlight.js'
|
|
5
|
+
import { marked, renderMarkdown } from '../renderer.js'
|
|
6
|
+
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
|
|
7
7
|
import { getImageExif, listImages } from '../image-cache.js'
|
|
8
8
|
import { slugify } from 'book-of-spells'
|
|
9
9
|
import dayjs from 'dayjs'
|
|
10
10
|
|
|
11
|
-
const marked = new Marked({ renderer: highlightRenderer })
|
|
12
|
-
|
|
13
11
|
export default class LiquidEngine {
|
|
14
12
|
constructor(templatesDir, includePaths) {
|
|
15
13
|
const roots = [templatesDir]
|
|
16
14
|
for (const inc of includePaths || []) {
|
|
17
|
-
roots.push(path.
|
|
15
|
+
roots.push(path.resolve(templatesDir, inc))
|
|
18
16
|
}
|
|
19
17
|
// Also add any _* directories as include roots
|
|
20
18
|
try {
|
|
@@ -48,6 +46,7 @@ export default class LiquidEngine {
|
|
|
48
46
|
engine.registerFilter('slugify', (str) => slugify(str))
|
|
49
47
|
engine.registerFilter('jsonify', (obj) => JSON.stringify(obj))
|
|
50
48
|
engine.registerFilter('markdown', (str) => marked.parse(str))
|
|
49
|
+
engine.registerFilter('toc', (html) => renderToc(String(html || '')))
|
|
51
50
|
engine.registerFilter('date', (str, template) => {
|
|
52
51
|
const fmt = template || timeDateFormat
|
|
53
52
|
if (!fmt) return str
|
|
@@ -71,17 +70,17 @@ export default class LiquidEngine {
|
|
|
71
70
|
return content
|
|
72
71
|
})
|
|
73
72
|
engine.registerFilter('srcset', (imagePath) => {
|
|
74
|
-
const outputDir = path.
|
|
73
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
75
74
|
const { variants } = discoverImageVariants(imagePath, outputDir)
|
|
76
75
|
if (variants.length === 0) return ''
|
|
77
76
|
return variants.map(v => `${v.path} ${v.width}w`).join(', ')
|
|
78
77
|
})
|
|
79
78
|
engine.registerFilter('exif', (imagePath) => {
|
|
80
|
-
const outputDir = path.
|
|
79
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
81
80
|
return getImageExif(imagePath, outputDir)
|
|
82
81
|
})
|
|
83
82
|
engine.registerFilter('images', (dirPath) => {
|
|
84
|
-
const outputDir = path.
|
|
83
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
85
84
|
return listImages(dirPath, outputDir)
|
|
86
85
|
})
|
|
87
86
|
engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
@@ -95,6 +94,7 @@ export default class LiquidEngine {
|
|
|
95
94
|
registerTags(getOutputDir) {
|
|
96
95
|
registerGoogleFontsTag(this.engine)
|
|
97
96
|
registerImageTag(this.engine, getOutputDir)
|
|
97
|
+
registerPaginationTag(this.engine)
|
|
98
98
|
registerHighlightTag(this.engine)
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -112,7 +112,7 @@ export default class LiquidEngine {
|
|
|
112
112
|
source = frontMatterResult.content
|
|
113
113
|
|
|
114
114
|
if (path.extname(templateName) === '.md') {
|
|
115
|
-
source =
|
|
115
|
+
source = renderMarkdown(source)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const frontMatter = context.page || {}
|
|
@@ -207,6 +207,19 @@ function registerImageTag(engine, getOutputDir) {
|
|
|
207
207
|
})
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
function registerPaginationTag(engine) {
|
|
211
|
+
engine.registerTag('pagination', {
|
|
212
|
+
parse(tagToken) {
|
|
213
|
+
this.value = tagToken.args.trim()
|
|
214
|
+
},
|
|
215
|
+
* render(ctx) {
|
|
216
|
+
const pagination = yield this.liquid.evalValue(this.value, ctx)
|
|
217
|
+
const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
|
|
218
|
+
return buildPaginationTag(pagination, prefix)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
210
223
|
function registerHighlightTag(engine) {
|
|
211
224
|
engine.registerTag('highlight', {
|
|
212
225
|
parse(tagToken, remainTokens) {
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import { globSync } from 'glob'
|
|
3
|
-
import { Marked } from 'marked'
|
|
4
3
|
import nunjucks from 'nunjucks'
|
|
5
4
|
import path from 'node:path'
|
|
6
|
-
import { discoverImageVariants, parseFrontMatter, groupby,
|
|
5
|
+
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
|
|
7
6
|
import { getImageExif, listImages } from '../image-cache.js'
|
|
8
7
|
import { toPosix } from '../../utils/helpers.js'
|
|
9
|
-
import {
|
|
8
|
+
import { highlightCode } from '../highlight.js'
|
|
9
|
+
import { marked, renderMarkdown } from '../renderer.js'
|
|
10
10
|
import { slugify } from 'book-of-spells'
|
|
11
11
|
import dayjs from 'dayjs'
|
|
12
12
|
import log from '../../utils/log.js'
|
|
13
13
|
|
|
14
|
-
const marked = new Marked({ renderer: highlightRenderer })
|
|
15
|
-
|
|
16
14
|
class RelativeLoader extends nunjucks.Loader {
|
|
17
15
|
constructor(templatesDir, includePaths) {
|
|
18
16
|
super()
|
|
@@ -48,7 +46,7 @@ class RelativeLoader extends nunjucks.Loader {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
if (path.extname(fullPath) === '.md') {
|
|
51
|
-
source =
|
|
49
|
+
source = renderMarkdown(source)
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
if (frontMatter.layout) {
|
|
@@ -82,6 +80,12 @@ export default class NunjucksEngine {
|
|
|
82
80
|
env.addFilter('slugify', slugify)
|
|
83
81
|
env.addFilter('jsonify', (obj) => JSON.stringify(obj))
|
|
84
82
|
env.addFilter('markdown', (str) => marked.parse(str))
|
|
83
|
+
env.addFilter('toc', (html) => {
|
|
84
|
+
const toc = renderToc(String(html || ''))
|
|
85
|
+
// plain '' (falsy) when there are no headings, so `{% if x | toc %}` can
|
|
86
|
+
// skip an empty TOC column; SafeString otherwise to keep the markup raw
|
|
87
|
+
return toc ? new nunjucks.runtime.SafeString(toc) : ''
|
|
88
|
+
})
|
|
85
89
|
env.addFilter('concat', (arr, value) => {
|
|
86
90
|
if (!Array.isArray(arr)) return [value]
|
|
87
91
|
return arr.concat(value)
|
|
@@ -105,17 +109,17 @@ export default class NunjucksEngine {
|
|
|
105
109
|
return dayjs(date).format(fmt)
|
|
106
110
|
})
|
|
107
111
|
env.addFilter('srcset', (imagePath) => {
|
|
108
|
-
const outputDir = path.
|
|
112
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
109
113
|
const { variants } = discoverImageVariants(imagePath, outputDir)
|
|
110
114
|
if (variants.length === 0) return ''
|
|
111
115
|
return variants.map(v => `${v.path} ${v.width}w`).join(', ')
|
|
112
116
|
})
|
|
113
117
|
env.addFilter('exif', (imagePath) => {
|
|
114
|
-
const outputDir = path.
|
|
118
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
115
119
|
return getImageExif(imagePath, outputDir)
|
|
116
120
|
})
|
|
117
121
|
env.addFilter('images', (dirPath) => {
|
|
118
|
-
const outputDir = path.
|
|
122
|
+
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
119
123
|
return listImages(dirPath, outputDir)
|
|
120
124
|
})
|
|
121
125
|
env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
@@ -129,6 +133,7 @@ export default class NunjucksEngine {
|
|
|
129
133
|
registerTags(getOutputDir) {
|
|
130
134
|
this.env.addExtension('GoogleFontsExtension', new GoogleFontsExtension())
|
|
131
135
|
this.env.addExtension('ImageExtension', new ImageExtension(getOutputDir))
|
|
136
|
+
this.env.addExtension('PaginationExtension', new PaginationExtension())
|
|
132
137
|
this.env.addExtension('HighlightExtension', new HighlightExtension())
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -247,3 +252,19 @@ export class HighlightExtension {
|
|
|
247
252
|
)
|
|
248
253
|
}
|
|
249
254
|
}
|
|
255
|
+
|
|
256
|
+
export class PaginationExtension {
|
|
257
|
+
constructor() { this.tags = ['pagination'] }
|
|
258
|
+
|
|
259
|
+
parse(parser, nodes) {
|
|
260
|
+
const tok = parser.nextToken()
|
|
261
|
+
const args = parser.parseSignature(null, true)
|
|
262
|
+
parser.advanceAfterBlockEnd(tok.value)
|
|
263
|
+
return new nodes.CallExtension(this, 'run', args)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
run(context, pagination) {
|
|
267
|
+
const prefix = context.lookup('relativePathPrefix') || ''
|
|
268
|
+
return new nunjucks.runtime.SafeString(buildPaginationTag(pagination, prefix))
|
|
269
|
+
}
|
|
270
|
+
}
|
package/lib/markup/helpers.js
CHANGED
|
@@ -83,6 +83,25 @@ export function decodeTemplateEntities(html) {
|
|
|
83
83
|
)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Builds a flat H2/H3 table of contents from rendered HTML, reading the ids
|
|
87
|
+
// the markdown heading renderer emits so TOC links always match the anchors.
|
|
88
|
+
// H3s carry a `toc-h3` class for indentation — no nested <ul>, so leading H3s
|
|
89
|
+
// (no parent H2) stay valid. Heading text is already entity-encoded by marked,
|
|
90
|
+
// so it's spliced in as-is (re-escaping would double-encode &).
|
|
91
|
+
export function renderToc(html) {
|
|
92
|
+
if (!html) return ''
|
|
93
|
+
const re = /<h([23])\b[^>]*\sid="([^"]*)"[^>]*>([\s\S]*?)<\/h\1>/gi
|
|
94
|
+
let items = ''
|
|
95
|
+
let match
|
|
96
|
+
while ((match = re.exec(html)) !== null) {
|
|
97
|
+
const text = match[3].replace(/<[^>]*>/g, '').trim()
|
|
98
|
+
if (!match[2] || !text) continue
|
|
99
|
+
items += `<li class="toc-h${match[1]}"><a href="#${match[2]}">${text}</a></li>`
|
|
100
|
+
}
|
|
101
|
+
if (!items) return ''
|
|
102
|
+
return `<nav class="toc" aria-label="On this page"><ul>${items}</ul></nav>`
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
export function groupby(arr, key, datePart) {
|
|
87
106
|
if (!Array.isArray(arr)) return []
|
|
88
107
|
|
|
@@ -280,6 +299,18 @@ export function escapeAttr(value) {
|
|
|
280
299
|
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
281
300
|
}
|
|
282
301
|
|
|
302
|
+
export function buildPaginationTag(pagination, prefix = '') {
|
|
303
|
+
const totalPages = Number(pagination && pagination.totalPages) || 1
|
|
304
|
+
if (totalPages <= 1) return ''
|
|
305
|
+
|
|
306
|
+
const pageNumber = Number(pagination && pagination.pageNumber) || 1
|
|
307
|
+
const parts = []
|
|
308
|
+
if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">Previous</a>`)
|
|
309
|
+
parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
|
|
310
|
+
if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">Next</a>`)
|
|
311
|
+
return parts.join('\n')
|
|
312
|
+
}
|
|
313
|
+
|
|
283
314
|
// Shared by the nunjucks and liquid `image` tags — attribute values may come
|
|
284
315
|
// from front-matter/user data, so they are escaped here, once for both engines.
|
|
285
316
|
export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
|