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 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, but please just use one file path per script
178
- - `out` - the output path, can be a directory or a file path, but please just use it as a filename
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, accepts only a path to a file
312
- - `out` - the output path, can be a directory or a file path, but please just use it as a filename
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
- The `example-tailwind/` directory demonstrates using Tailwind CSS v4 with Poops. To run it:
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": "example-tailwind/src/css/main.css",
481
- "out": "example-tailwind/dist/css/main.css",
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": "example-tailwind/src/markup",
489
- "out": "example-tailwind/dist",
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": 4041, "base": "/example-tailwind/dist" },
524
+ "serve": { "port": 4040, "base": "/dist" },
497
525
  "livereload": true,
498
- "watch": ["example-tailwind/src"]
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 sitemap features documented below.
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 & Sitemap
1011
+ #### Search Index, Sitemap & Navigation
922
1012
 
923
- Poops can automatically generate a JSON search index and/or an XML sitemap from your compiled pages. Both are generated in a single pass during the markup compilation phase.
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 `sitemap` to your markup config:
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.join(process.cwd(), file)
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, getPageUrl, parseFrontMatter, wordcount } from './helpers.js'
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.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)')), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
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.join(process.cwd(), markupInDir, '/**/index.+(html|njk|liquid|md)')))
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.join(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)')))
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.join(process.cwd(), markupOutDir, collectionName)
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.join(process.cwd(), markupInDir, collectionName, entry))) continue
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.join(process.cwd(), markupOutDir, pageUrl, 'index.html')
249
- const fromPath = path.join(process.cwd(), markupOutDir)
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
- _url: getPageUrl(markupOut)
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 { Marked } from 'marked'
5
- import { highlightRenderer, highlightCode } from '../highlight.js'
6
- import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities, buildImageTag } from '../helpers.js'
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.join(templatesDir, inc))
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.join(process.cwd(), markupOut)
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.join(process.cwd(), markupOut)
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.join(process.cwd(), markupOut)
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 = decodeTemplateEntities(marked.parse(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, decodeTemplateEntities, buildImageTag } from '../helpers.js'
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 { highlightRenderer, highlightCode } from '../highlight.js'
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 = decodeTemplateEntities(marked.parse(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.join(process.cwd(), markupOut)
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.join(process.cwd(), markupOut)
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.join(process.cwd(), markupOut)
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
+ }
@@ -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 &amp;).
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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) {