poops 1.2.4 → 1.3.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
@@ -511,7 +511,7 @@ Then use Tailwind utility classes directly in your markup templates. Tailwind v4
511
511
 
512
512
  ### Markups
513
513
 
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.
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, sitemap, and navigation tree features documented below.
515
515
  - `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
516
  - `out` - the output path, can be only a directory path (for now)
517
517
  - `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 +561,7 @@ If your project doesn't have markups, you can remove the `markup` property from
561
561
 
562
562
  #### Nunjucks vs Liquid
563
563
 
564
- Both engines support the same feature set (collections, pagination, search index, sitemap, custom tags, and filters). The main differences are in template syntax:
564
+ 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
565
 
566
566
  | Feature | Nunjucks | Liquid |
567
567
  | -------------- | ----------------------------- | ------------------------------------- |
@@ -574,6 +574,52 @@ Both engines support the same feature set (collections, pagination, search index
574
574
 
575
575
  Both engines process `.html` and `.md` files in addition to their native extension.
576
576
 
577
+ #### Custom Engines
578
+
579
+ 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:
580
+
581
+ ```json
582
+ {
583
+ "markup": {
584
+ "in": "src/markup",
585
+ "out": "dist",
586
+ "engine": "poops-shopify"
587
+ }
588
+ }
589
+ ```
590
+
591
+ An engine class implements this contract (see [`lib/markup/engines/`](lib/markup/engines/) for the two built-in reference implementations):
592
+
593
+ ```js
594
+ export default class MyEngine {
595
+ constructor(templatesDir, includePaths, options) {} // options: { autoescape }
596
+ get fileExtension() { return '.liquid' } // native template extension
597
+ get indexableExtensions() { return new Set(['.html']) } // extensions eligible for search index/nav
598
+ get markupExtensions() { return 'html|liquid|md' } // glob alternation of processed extensions
599
+ registerFilters({ timeDateFormat, markupOut }) {}
600
+ registerTags(getOutputDir) {}
601
+ setGlobal(key, value) {}
602
+ removeGlobal(key) {}
603
+ async render(templatePath, context) { return 'html' } // templatePath is an absolute file path
604
+ async renderString(source, context) { return 'html' }
605
+ }
606
+ ```
607
+
608
+ Optionally, an engine may implement `replaceOutExtensions(outputPath)` to control how source extensions map to output extensions (the default maps `.md`/`.njk`/`.liquid` to `.html`).
609
+
610
+ The easiest starting point is extending a built-in engine — deep imports are intentionally supported for this:
611
+
612
+ ```js
613
+ import LiquidEngine from 'poops/lib/markup/engines/liquid.js'
614
+
615
+ export default class MyEngine extends LiquidEngine {
616
+ registerFilters(opts) {
617
+ super.registerFilters(opts)
618
+ this.engine.registerFilter('shout', (str) => String(str).toUpperCase())
619
+ }
620
+ }
621
+ ```
622
+
577
623
  #### Collections & Pagination
578
624
 
579
625
  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 +705,12 @@ From the example site's `changelog/index.html`:
659
705
  {% endif %}
660
706
  ```
661
707
 
708
+ 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:
709
+
710
+ ```nunjucks
711
+ {% pagination changelog %}
712
+ ```
713
+
662
714
  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
715
 
664
716
  #### Custom Tags
@@ -918,11 +970,11 @@ Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo
918
970
  {% endfor %}
919
971
  ```
920
972
 
921
- #### Search Index & Sitemap
973
+ #### Search Index, Sitemap & Navigation
922
974
 
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.
975
+ 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
976
 
925
- To enable, add `searchIndex` and/or `sitemap` to your markup config:
977
+ To enable, add `searchIndex`, `sitemap` and/or `nav` to your markup config:
926
978
 
927
979
  ```json
928
980
  {
@@ -988,6 +1040,105 @@ All front matter fields are passed through to the index automatically. Internal
988
1040
 
989
1041
  Pages with `published: false` in their front matter are excluded from both outputs.
990
1042
 
1043
+ **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:
1044
+
1045
+ ```json
1046
+ {
1047
+ "markup": {
1048
+ "in": "src/markup",
1049
+ "out": "dist",
1050
+ "options": {
1051
+ "nav": "nav.json"
1052
+ }
1053
+ }
1054
+ }
1055
+ ```
1056
+
1057
+ The string shorthand sets the output filename. For docs sites, use the object form:
1058
+
1059
+ ```json
1060
+ {
1061
+ "nav": {
1062
+ "output": "nav.json",
1063
+ "root": "docs",
1064
+ "collections": "index",
1065
+ "home": false
1066
+ }
1067
+ }
1068
+ ```
1069
+
1070
+ **Navigation options:**
1071
+
1072
+ - `output` — output filename, written to the markup output directory
1073
+ - `collections` — how to treat collection pages (default `true`):
1074
+ - `true` — include every collection page, nested under its collection
1075
+ - `false` — exclude all collection pages (drops a blog's posts from the sidebar)
1076
+ - `["docs", ...]` — allowlist; only these collections' pages are included (non-collection pages are always kept)
1077
+ - `"index"` — include only each collection's landing page as a single leaf, not its posts
1078
+ - `home` — `false` drops the site's root index page (url `""`) from the tree (default `true`)
1079
+ - `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
1080
+
1081
+ **Front matter fields** that shape the tree:
1082
+
1083
+ - `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`.
1084
+ - `nav: false` — hide a page from the sidebar (it stays in the search index and sitemap).
1085
+ - `navTitle` — a sidebar label that overrides `title`.
1086
+
1087
+ **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:
1088
+
1089
+ ```json
1090
+ [
1091
+ { "title": "Guide", "url": "guide", "order": 1, "children": [
1092
+ { "title": "Getting Started", "url": "guide/getting-started", "order": 1 },
1093
+ { "title": "Advanced", "url": "guide/advanced", "children": [
1094
+ { "title": "Config", "url": "guide/advanced/config" }
1095
+ ]}
1096
+ ]}
1097
+ ]
1098
+ ```
1099
+
1100
+ 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.
1101
+
1102
+ **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.
1103
+
1104
+ Nunjucks — a self-recursing macro:
1105
+
1106
+ ```njk
1107
+ {% macro navtree(items) %}
1108
+ <ul>
1109
+ {% for item in items %}
1110
+ <li>
1111
+ {% if item.url != null %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
1112
+ {% else %}<span>{{ item.title }}</span>{% endif %}
1113
+ {% if item.children %}{{ navtree(item.children) }}{% endif %}
1114
+ </li>
1115
+ {% endfor %}
1116
+ </ul>
1117
+ {% endmacro %}
1118
+
1119
+ {{ navtree(nav) }}
1120
+ ```
1121
+
1122
+ 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.
1123
+
1124
+ 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:
1125
+
1126
+ ```liquid
1127
+ <ul>
1128
+ {% for item in items %}
1129
+ <li>
1130
+ {% if item.url %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
1131
+ {% else %}<span>{{ item.title }}</span>{% endif %}
1132
+ {% if item.children %}{% render 'navtree', items: item.children, relativePathPrefix: relativePathPrefix %}{% endif %}
1133
+ </li>
1134
+ {% endfor %}
1135
+ </ul>
1136
+ ```
1137
+
1138
+ ```liquid
1139
+ {% render 'navtree', items: nav, relativePathPrefix: relativePathPrefix %}
1140
+ ```
1141
+
991
1142
  ### Images (optional)
992
1143
 
993
1144
  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) {
@@ -152,7 +152,159 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
152
152
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
153
153
  }
154
154
 
155
+ function humanizeSegment(seg) {
156
+ return seg
157
+ .replace(/[-_]+/g, ' ')
158
+ .replace(/\s+/g, ' ')
159
+ .trim()
160
+ .replace(/\b\w/g, c => c.toUpperCase())
161
+ }
162
+
163
+ function navNodeTitle(entry) {
164
+ return entry.navTitle || entry.title
165
+ }
166
+
167
+ // Applies the `collections` option (true | false | ["name"] | "index") on top
168
+ // of the base exclusions (nav:false, and isIndex pages which are collection
169
+ // landing/pagination). "index" is the exception that re-admits each
170
+ // collection's first landing page as a single leaf.
171
+ function navFilterEntries(pageEntries, collectionsOpt) {
172
+ const mode = collectionsOpt === undefined ? true : collectionsOpt
173
+ const allowlist = Array.isArray(mode) ? new Set(mode) : null
174
+ const result = []
175
+
176
+ for (const e of pageEntries) {
177
+ if (e.nav === false) continue
178
+
179
+ if (e.isIndex) {
180
+ // collection landing (url === name, no slash) kept only in "index" mode;
181
+ // pagination pages (url has a slash) always dropped. Landing titles are
182
+ // the raw collection name ("blog"), so humanize for display.
183
+ if (mode === 'index' && !e.url.includes('/')) {
184
+ result.push({ ...e, title: humanizeSegment(e.title) })
185
+ }
186
+ continue
187
+ }
188
+
189
+ if (e.collection != null) {
190
+ if (mode === false || mode === 'index') continue
191
+ if (allowlist && !allowlist.has(e.collection)) continue
192
+ }
193
+ result.push(e)
194
+ }
195
+
196
+ return result
197
+ }
198
+
199
+ function insertNavNode(root, entry) {
200
+ let cursor = root
201
+ for (const seg of entry.url.split('/')) {
202
+ if (!cursor.children.has(seg)) {
203
+ cursor.children.set(seg, { segment: seg, children: new Map() })
204
+ }
205
+ cursor = cursor.children.get(seg)
206
+ }
207
+ cursor.hasPage = true
208
+ cursor.url = entry.url
209
+ cursor.title = navNodeTitle(entry)
210
+ if (entry.order != null) cursor.order = entry.order
211
+ }
212
+
213
+ function getNavNode(root, urlPath) {
214
+ let cursor = root
215
+ for (const seg of urlPath.split('/')) {
216
+ cursor = cursor.children.get(seg)
217
+ if (!cursor) return null
218
+ }
219
+ return cursor
220
+ }
221
+
222
+ function sortNavSiblings(nodes) {
223
+ nodes.sort((a, b) => {
224
+ const oa = a.order != null ? a.order : Infinity
225
+ const ob = b.order != null ? b.order : Infinity
226
+ if (oa !== ob) return oa - ob
227
+ return String(a.title).localeCompare(String(b.title))
228
+ })
229
+ }
230
+
231
+ // Post-order: children are serialized (and thus order-resolved) before the
232
+ // parent, so a virtual parent can borrow its first child's order.
233
+ function serializeNavNode(node) {
234
+ const children = [...node.children.values()].map(serializeNavNode)
235
+ sortNavSiblings(children)
236
+
237
+ const out = { title: node.title != null ? node.title : humanizeSegment(node.segment) }
238
+ if (node.url != null) out.url = node.url
239
+
240
+ let order = node.order
241
+ if (order == null && !node.hasPage && children.length) order = children[0].order
242
+ if (order != null) out.order = order
243
+
244
+ if (children.length) out.children = children
245
+ return out
246
+ }
247
+
248
+ export function buildNavTree(pageEntries, config = {}) {
249
+ const { collections, home, root } = config
250
+ let entries = navFilterEntries(pageEntries, collections)
251
+
252
+ if (root != null) {
253
+ const prefix = root + '/'
254
+ entries = entries.filter(e => e.url === root || e.url.startsWith(prefix))
255
+ } else if (home === false) {
256
+ entries = entries.filter(e => e.url !== '')
257
+ }
258
+
259
+ const tree = { children: new Map() }
260
+ let homeEntry = null
261
+ for (const entry of entries) {
262
+ // root index page (url '') can't be segment-split — it would corrupt the
263
+ // tree; handle it as a top-level leaf instead
264
+ if (entry.url === '') { homeEntry = entry; continue }
265
+ insertNavNode(tree, entry)
266
+ }
267
+
268
+ // root scoping: emit the section's children unwrapped to the top level, with
269
+ // the section's own index page (if any) pinned first as the overview link
270
+ if (root != null) {
271
+ const rootNode = getNavNode(tree, root)
272
+ if (!rootNode) return []
273
+ const top = [...rootNode.children.values()].map(serializeNavNode)
274
+ sortNavSiblings(top)
275
+ if (rootNode.hasPage) {
276
+ const overview = { title: rootNode.title, url: rootNode.url }
277
+ if (rootNode.order != null) overview.order = rootNode.order
278
+ top.unshift(overview)
279
+ }
280
+ return top
281
+ }
282
+
283
+ const top = [...tree.children.values()].map(serializeNavNode)
284
+ if (homeEntry) {
285
+ const node = { title: navNodeTitle(homeEntry), url: '' }
286
+ if (homeEntry.order != null) node.order = homeEntry.order
287
+ top.push(node)
288
+ }
289
+ sortNavSiblings(top)
290
+ return top
291
+ }
292
+
293
+ export function generateNav(pageEntries, outputDir, config) {
294
+ config = normalizeConfig(config)
295
+ if (!config) return
296
+
297
+ const tree = buildNavTree(pageEntries, config)
298
+
299
+ // resolve, not join: outputDir may be absolute (join would mangle it,
300
+ // e.g. cross-drive temp dirs on Windows)
301
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
302
+ fs.writeFileSync(outputPath, JSON.stringify(tree, null, 2))
303
+ log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
304
+ }
305
+
155
306
  export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
156
307
  generateSearchIndex(pageEntries, outputDir, config.searchIndex)
157
308
  generateSitemap(pageEntries, outputDir, siteUrl, config.sitemap)
309
+ generateNav(pageEntries, outputDir, config.nav)
158
310
  }
@@ -0,0 +1,60 @@
1
+ import { Marked } from 'marked'
2
+ import { slugify } from 'book-of-spells'
3
+ import { highlightRenderer, highlightCode } from './highlight.js'
4
+ import { decodeTemplateEntities } from './helpers.js'
5
+
6
+ const RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g
7
+
8
+ // Applies `outside` to the text between {% raw %} blocks and `inside` to each
9
+ // block's inner content, re-emitting the delimiters untouched so the template
10
+ // engine still sees them. The two places that would otherwise mangle raw
11
+ // content route through this: fence highlighting and entity decoding.
12
+ function mapRawSegments(str, outside, inside) {
13
+ RAW_BLOCK_RE.lastIndex = 0
14
+ let out = ''
15
+ let last = 0
16
+ for (const m of str.matchAll(RAW_BLOCK_RE)) {
17
+ out += outside(str.slice(last, m.index))
18
+ out += `{% raw %}${inside(m[1])}{% endraw %}`
19
+ last = m.index + m[0].length
20
+ }
21
+ return out + outside(str.slice(last))
22
+ }
23
+
24
+ // The marked renderer used across the markup engines: syntax highlighting
25
+ // (from highlight.js) plus heading slug ids + permalink anchors.
26
+ export const markdownRenderer = {
27
+ ...highlightRenderer,
28
+ // Fenced code goes through hljs, which wraps `{`/`%` in their own spans —
29
+ // splitting a {% raw %} tag so the template engine never sees it and
30
+ // evaluates the "raw" content (e.g. a {{ name }} in a config sample rendered
31
+ // as empty). Highlight around/inside each raw block instead, keeping the
32
+ // fence's language, and pass the delimiters through intact. Inline code
33
+ // never splits the delimiters, so it needs no special casing.
34
+ code(code, lang) {
35
+ const highlighted = mapRawSegments(code, (s) => s && highlightCode(s, lang), (s) => highlightCode(s, lang))
36
+ const langClass = lang ? ` language-${lang.replace(/[^\w-]/g, '')}` : ''
37
+ return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>\n`
38
+ },
39
+ // Give every heading a slug id + a permalink anchor. The anchor is empty on
40
+ // purpose — themes reveal a "#" via `.heading-anchor::before`, so a site with
41
+ // no such CSS renders an invisible anchor instead of a stray "#".
42
+ // ponytail: no slug dedup — two identical headings on one page share an id;
43
+ // add a per-parse counter if that ever bites.
44
+ heading(text, level, raw) {
45
+ const id = slugify(raw || '')
46
+ if (!id) return `<h${level}>${text}</h${level}>\n`
47
+ return `<h${level} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Permalink" aria-hidden="true"></a></h${level}>\n`
48
+ }
49
+ }
50
+
51
+ // One shared instance so both engines render markdown identically.
52
+ export const marked = new Marked({ renderer: markdownRenderer })
53
+
54
+ // Renders a markdown page source for the template engines. Entity decoding
55
+ // (which un-escapes inside {{ }} / {% %} so template args parse) must skip
56
+ // raw blocks — their content is for display, so its entities have to survive
57
+ // to the browser.
58
+ export function renderMarkdown(source) {
59
+ return mapRawSegments(marked.parse(source), decodeTemplateEntities, (s) => s)
60
+ }
package/lib/markups.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
2
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
3
3
  import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
4
- import { generateIndexFiles } from './markup/indexer.js'
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'
7
7
  import fs from 'node:fs'
8
8
  import { globSync } from 'glob'
9
9
  import path from 'node:path'
10
+ import { createRequire } from 'node:module'
11
+ import { pathToFileURL } from 'node:url'
10
12
  import log from './utils/log.js'
11
13
 
12
14
  const ENGINES = {
@@ -22,8 +24,8 @@ export default class Markups {
22
24
  if (!moduleConfig || !moduleConfig.in) return
23
25
  if (!moduleConfig.options) moduleConfig.options = {}
24
26
 
25
- // Determine engine
26
- const engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
27
+ // Determine engine — resolved in init(), builtin name or importable module
28
+ this.engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
27
29
  this.logTag = 'markup'
28
30
 
29
31
  // Normalize config
@@ -35,22 +37,54 @@ export default class Markups {
35
37
  this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
36
38
  this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
37
39
  this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
40
+ this.navConfig = moduleConfig.options.nav || moduleConfig.nav
38
41
  this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
39
42
 
40
- // Instantiate engine
41
- const EngineClass = ENGINES[engineName]
43
+ this.autoescape = moduleConfig.autoescape || moduleConfig.options.autoescape || false
44
+ this.dataConfig = moduleConfig.data || moduleConfig.options.data
45
+
46
+ if (!moduleConfig.out) {
47
+ moduleConfig.out = this.markupOut
48
+ }
49
+ }
50
+
51
+ // Engine instantiation is async because non-builtin engines are loaded via
52
+ // dynamic import. Idempotent; compile() calls it lazily, so a failed engine
53
+ // resolution logs once per compile and the module stays inert.
54
+ async init() {
55
+ if (!this.markupIn || this.engine) return
56
+
57
+ let EngineClass = ENGINES[this.engineName]
42
58
  if (!EngineClass) {
43
- log({ tag: 'error', text: `Unknown markup engine: ${engineName}` })
59
+ try {
60
+ // Relative/absolute specifiers resolve against cwd (the user's
61
+ // project), bare specifiers against the module graph (node_modules)
62
+ const spec = /^[./]/.test(this.engineName) || path.isAbsolute(this.engineName)
63
+ ? pathToFileURL(path.resolve(process.cwd(), this.engineName)).href
64
+ : this.engineName
65
+ EngineClass = (await import(spec)).default
66
+ } catch {
67
+ // Bare specifier not reachable from poops's own module graph (e.g.
68
+ // poops installed via a file: link) — resolve from the user's project
69
+ try {
70
+ const projectRequire = createRequire(path.join(process.cwd(), 'package.json'))
71
+ EngineClass = (await import(pathToFileURL(projectRequire.resolve(this.engineName)).href)).default
72
+ } catch { /* handled below */ }
73
+ }
74
+ }
75
+
76
+ if (typeof EngineClass !== 'function') {
77
+ log({ tag: 'error', text: `Unknown markup engine: ${this.engineName}` })
44
78
  return
45
79
  }
46
80
 
47
- const templatesDir = path.join(process.cwd(), this.markupIn)
81
+ const templatesDir = path.resolve(process.cwd(), this.markupIn)
48
82
  this.engine = new EngineClass(templatesDir, this.includePaths, {
49
- autoescape: moduleConfig.autoescape || moduleConfig.options.autoescape || false
83
+ autoescape: this.autoescape
50
84
  })
51
85
 
52
86
  this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
53
- this.engine.registerTags(() => path.join(process.cwd(), this.markupOut))
87
+ this.engine.registerTags(() => path.resolve(process.cwd(), this.markupOut))
54
88
 
55
89
  // Load global variables
56
90
  const pkgPath = path.join(process.cwd(), 'package.json')
@@ -70,12 +104,7 @@ export default class Markups {
70
104
  }
71
105
  }
72
106
 
73
- this.dataConfig = moduleConfig.data || moduleConfig.options.data
74
107
  this.loadDataFiles(this.dataConfig)
75
-
76
- if (!moduleConfig.out) {
77
- moduleConfig.out = this.markupOut
78
- }
79
108
  }
80
109
 
81
110
  loadDataFiles(files) {
@@ -89,11 +118,11 @@ export default class Markups {
89
118
  const dataDir = pathIsDirectory(this.markupIn) ? this.markupIn : path.dirname(this.markupIn)
90
119
  const resolved = []
91
120
  for (const file of files) {
92
- const fullPath = path.join(process.cwd(), dataDir, file)
121
+ const fullPath = path.resolve(process.cwd(), dataDir, file)
93
122
  if (pathIsDirectory(fullPath)) {
94
123
  const dirFiles = globSync(toPosix(path.join(fullPath, '**/*.+(json|yml|yaml)')))
95
124
  for (const f of dirFiles) {
96
- resolved.push(path.relative(path.join(process.cwd(), dataDir), f))
125
+ resolved.push(path.relative(path.resolve(process.cwd(), dataDir), f))
97
126
  }
98
127
  } else {
99
128
  resolved.push(file)
@@ -103,7 +132,7 @@ export default class Markups {
103
132
  const loadedKeys = new Set()
104
133
  for (const dataFile of resolved) {
105
134
  try {
106
- const data = readDataFile(path.join(process.cwd(), dataDir, dataFile))
135
+ const data = readDataFile(path.resolve(process.cwd(), dataDir, dataFile))
107
136
  const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
108
137
  this.engine.setGlobal(globalKeyName, data)
109
138
  loadedKeys.add(globalKeyName)
@@ -123,20 +152,29 @@ export default class Markups {
123
152
  }
124
153
 
125
154
  reloadDataFiles() {
126
- this.loadDataFiles(this.dataConfig)
155
+ if (this.engine) this.loadDataFiles(this.dataConfig)
127
156
  return Promise.resolve()
128
157
  }
129
158
 
159
+ // Engines can override output extension mapping (e.g. a template format
160
+ // whose extension isn't in the default map); falls back to the shared helper
161
+ mapOutputPath(outputPath) {
162
+ if (this.engine && typeof this.engine.replaceOutExtensions === 'function') {
163
+ return this.engine.replaceOutExtensions(outputPath)
164
+ }
165
+ return replaceOutExtensions(outputPath)
166
+ }
167
+
130
168
  // Maps a deleted source path to its build output and removes it.
131
169
  // compile() only globs existing sources, so it can never clean up
132
170
  // after a deletion — this is the only place stale output gets removed.
133
171
  removeOutput(sourcePath) {
134
- if (!this.engine) return
135
- const markupIn = path.join(process.cwd(), this.markupIn)
136
- const rel = path.relative(markupIn, path.join(process.cwd(), sourcePath))
172
+ if (!this.markupIn) return
173
+ const markupIn = path.resolve(process.cwd(), this.markupIn)
174
+ const rel = path.relative(markupIn, path.resolve(process.cwd(), sourcePath))
137
175
  if (rel.startsWith('..') || path.isAbsolute(rel)) return
138
176
 
139
- const mapped = path.join(process.cwd(), this.markupOut, rel)
177
+ const mapped = path.resolve(process.cwd(), this.markupOut, rel)
140
178
 
141
179
  // rel === '' with a directory output would be the whole out dir —
142
180
  // never remove that, it also holds css/js from other modules
@@ -146,7 +184,7 @@ export default class Markups {
146
184
  return
147
185
  }
148
186
 
149
- const outFile = replaceOutExtensions(mapped)
187
+ const outFile = this.mapOutputPath(mapped)
150
188
  if (fs.existsSync(outFile) && !fs.statSync(outFile).isDirectory()) {
151
189
  fs.unlinkSync(outFile)
152
190
  log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), outFile) })
@@ -203,13 +241,72 @@ export default class Markups {
203
241
  return { result, frontMatter }
204
242
  }
205
243
 
206
- async compileDirectory(markupIn, collectionData, pageEntries) {
207
- const markupStart = performance.now()
244
+ getMarkupFiles(markupIn) {
208
245
  // glob patterns must use `/` — on Windows `\` is an escape character
209
- const markupFiles = [
246
+ return [
210
247
  ...globSync(toPosix(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths)))),
211
248
  ...globSync(toPosix(path.join(markupIn, `*.+(${this.engine.markupExtensions})`)))
212
249
  ]
250
+ }
251
+
252
+ // True for collection index templates that compileDirectory skips —
253
+ // pagination renders them instead
254
+ isCollectionIndexOverride(relativePathParts, collectionData) {
255
+ return relativePathParts.length > 1 &&
256
+ collectionData[relativePathParts[0]] &&
257
+ relativePathParts[1].startsWith('index.') &&
258
+ this.engine.indexableExtensions.has(path.extname(relativePathParts[1])) &&
259
+ collectionData[relativePathParts[0]].items.length > 0
260
+ }
261
+
262
+ // Pre-pass: builds the nav tree from front matter alone (no rendering) and
263
+ // exposes it as the `nav` template global, so sidebars can render during the
264
+ // same compile instead of reading the previous build's nav.json.
265
+ // parseFrontMatter caches by mtime, so the compile pass re-reads nothing.
266
+ buildNavGlobal(markupIn, collectionData) {
267
+ if (!this.navConfig) return
268
+
269
+ const entries = []
270
+ for (const collectionName of Object.keys(collectionData)) {
271
+ entries.push({ url: collectionName, title: collectionName, isIndex: true })
272
+ }
273
+
274
+ const files = pathIsDirectory(markupIn) ? this.getMarkupFiles(markupIn) : [markupIn]
275
+ for (const file of files) {
276
+ if (!this.engine.indexableExtensions.has(path.extname(file))) continue
277
+ const relativePath = path.relative(markupIn, file)
278
+ const relativePathParts = relativePath.split(path.sep)
279
+ if (this.isCollectionIndexOverride(relativePathParts, collectionData)) continue
280
+
281
+ let frontMatter
282
+ try {
283
+ frontMatter = parseFrontMatter(file).frontMatter
284
+ } catch (err) {
285
+ continue
286
+ }
287
+ if (frontMatter.published === false) continue
288
+ if (!frontMatter.title) frontMatter.title = path.basename(file, path.extname(file))
289
+
290
+ const fileCollection = relativePathParts.length > 1 && collectionData[relativePathParts[0]]
291
+ ? relativePathParts[0]
292
+ : null
293
+ if (fileCollection && !frontMatter.collection) frontMatter.collection = fileCollection
294
+
295
+ const outPath = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
296
+ entries.push({
297
+ ...frontMatter,
298
+ url: getPageUrlRelativeToOutput(outPath, this.markupOut),
299
+ isIndex: false
300
+ })
301
+ }
302
+
303
+ const navOptions = typeof this.navConfig === 'object' ? this.navConfig : {}
304
+ this.engine.setGlobal('nav', buildNavTree(entries, navOptions))
305
+ }
306
+
307
+ async compileDirectory(markupIn, collectionData, pageEntries) {
308
+ const markupStart = performance.now()
309
+ const markupFiles = this.getMarkupFiles(markupIn)
213
310
  const compilePromises = []
214
311
  const indexableExtensions = this.engine.indexableExtensions
215
312
 
@@ -217,15 +314,14 @@ export default class Markups {
217
314
  const relativePath = path.relative(markupIn, file)
218
315
  const relativePathParts = relativePath.split(path.sep)
219
316
 
220
- if (relativePathParts.length > 1 &&
221
- collectionData[relativePathParts[0]] &&
222
- relativePathParts[1].startsWith('index.') && indexableExtensions.has(path.extname(relativePathParts[1])) &&
223
- collectionData[relativePathParts[0]].items.length > 0) {
317
+ if (this.isCollectionIndexOverride(relativePathParts, collectionData)) {
224
318
  continue
225
319
  }
226
320
 
227
- let markupOut = path.join(process.cwd(), this.markupOut, relativePath)
228
- const fromPath = path.join(process.cwd(), this.markupOut)
321
+ // Map before deriving dir/prefix: engines may relocate output (e.g.
322
+ // Shopify's templates/ flattening), and links must match the final path.
323
+ const markupOut = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
324
+ const fromPath = path.resolve(process.cwd(), this.markupOut)
229
325
  const markupOutDir = path.dirname(markupOut)
230
326
 
231
327
  mkDir(markupOutDir)
@@ -233,7 +329,9 @@ export default class Markups {
233
329
  const fileContext = {
234
330
  ...collectionData,
235
331
  relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
236
- _url: getPageUrl(markupOut)
332
+ // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
333
+ // otherwise `item.url == page.url` sidebar/active checks never fire
334
+ _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
237
335
  }
238
336
 
239
337
  const shouldIndex = pageEntries && indexableExtensions.has(path.extname(file))
@@ -243,14 +341,12 @@ export default class Markups {
243
341
 
244
342
  const compilePromise = this.compileEntry(file, fileContext).then(({ result, frontMatter, skipped }) => {
245
343
  if (skipped) {
246
- const outFile = replaceOutExtensions(markupOut)
247
- if (fs.existsSync(outFile)) {
248
- fs.unlinkSync(outFile)
249
- log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), outFile) })
344
+ if (fs.existsSync(markupOut)) {
345
+ fs.unlinkSync(markupOut)
346
+ log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), markupOut) })
250
347
  }
251
348
  return
252
349
  }
253
- markupOut = replaceOutExtensions(markupOut)
254
350
  fs.writeFileSync(markupOut, result)
255
351
 
256
352
  if (shouldIndex && frontMatter.published !== false) {
@@ -283,7 +379,7 @@ export default class Markups {
283
379
 
284
380
  async compileSingleFile(markupIn, collectionData, pageEntries) {
285
381
  const markupStart = performance.now()
286
- let markupOut = path.join(process.cwd(), this.markupOut)
382
+ let markupOut = path.resolve(process.cwd(), this.markupOut)
287
383
  const markupOutDir = path.dirname(markupOut)
288
384
  const indexableExtensions = this.engine.indexableExtensions
289
385
  mkDir(markupOutDir)
@@ -291,14 +387,15 @@ export default class Markups {
291
387
  const fileContext = {
292
388
  ...collectionData,
293
389
  relativePathPrefix: getRelativePathPrefix(markupOutDir, null, this.baseURL),
294
- _url: getPageUrl(markupOut)
390
+ // output-relative so page.url matches nav.json/index urls, same as compileDirectory
391
+ _url: getPageUrlRelativeToOutput(this.mapOutputPath(markupOut), this.markupOut)
295
392
  }
296
393
 
297
394
  const shouldIndex = pageEntries && indexableExtensions.has(path.extname(markupIn))
298
395
 
299
396
  try {
300
397
  const { result, frontMatter, skipped } = await this.compileEntry(markupIn, fileContext)
301
- markupOut = replaceOutExtensions(markupOut)
398
+ markupOut = this.mapOutputPath(markupOut)
302
399
 
303
400
  if (skipped) {
304
401
  if (fs.existsSync(markupOut)) {
@@ -321,9 +418,9 @@ export default class Markups {
321
418
  }
322
419
 
323
420
  const markupEnd = performance.now()
324
- log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.join(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
421
+ log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
325
422
  } catch (err) {
326
- log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.join(process.cwd(), this.markupOut, path.basename(markupIn))) })
423
+ log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))) })
327
424
  console.error(err)
328
425
  throw err
329
426
  } finally {
@@ -335,6 +432,9 @@ export default class Markups {
335
432
  const moduleConfig = this.config.markup
336
433
  if (!moduleConfig || !moduleConfig.in) return
337
434
 
435
+ await this.init()
436
+ if (!this.engine) return
437
+
338
438
  if (this.config.reactorData) {
339
439
  // A removed reactor component must also drop its injected global,
340
440
  // same staleness rule as data files in loadDataFiles()
@@ -351,7 +451,7 @@ export default class Markups {
351
451
  }
352
452
  }
353
453
 
354
- const markupIn = path.join(process.cwd(), this.markupIn)
454
+ const markupIn = path.resolve(process.cwd(), this.markupIn)
355
455
 
356
456
  if (!pathExists(markupIn)) {
357
457
  log({ tag: 'error', text: 'Markup path does not exist:', link: markupIn })
@@ -363,10 +463,15 @@ export default class Markups {
363
463
  ...getCollectionDataBasedOnConfig(this.markupIn, this.collectionsConfig)
364
464
  }
365
465
 
366
- const shouldIndex = this.searchIndexConfig || this.sitemapConfig
466
+ const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig
367
467
  const pageEntries = shouldIndex ? [] : null
368
468
 
369
469
  buildCollectionPaginationData(collectionData)
470
+
471
+ // must precede any rendering (incl. pagination pages) so every template
472
+ // sees the current build's nav
473
+ this.buildNavGlobal(markupIn, collectionData)
474
+
370
475
  const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
371
476
 
372
477
  await Promise.all(collectionPromises)
@@ -396,7 +501,8 @@ export default class Markups {
396
501
  if (shouldIndex && pageEntries) {
397
502
  generateIndexFiles(pageEntries, this.markupOut, this.siteData.url, {
398
503
  searchIndex: this.searchIndexConfig,
399
- sitemap: this.sitemapConfig
504
+ sitemap: this.sitemapConfig,
505
+ nav: this.navConfig
400
506
  })
401
507
  }
402
508
  }
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.2.4",
4
+ "version": "1.3.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
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 { pathExists, doesFileBelongToPath } from './lib/utils/helpers.js'
6
+ import { pathExists, doesFileBelongToPath, pathContainsPathSegment } from './lib/utils/helpers.js'
7
7
  import http from 'node:http'
8
8
  import os from 'node:os'
9
9
  import fs from 'node:fs'
@@ -85,8 +85,6 @@ function setupLiveReloadServer(config) {
85
85
  function setupWatchers(config, modules) {
86
86
  if (!config.watch) return
87
87
 
88
- // TODO: think about watching the updates of the config file itself, we can reload the config and recompile everything.
89
- // TODO: ability to automatically create a watch list of directories if watch is set to true. The list will be generated from the `in` property of each task.
90
88
  // awaitWriteFinish: wait for saves to finish writing before recompiling, so a
91
89
  // mid-write (truncated/partial) file is never read. Fixes intermittent broken
92
90
  // builds on editor save. Bump thresholds if slow disks flake.
@@ -95,8 +93,19 @@ function setupWatchers(config, modules) {
95
93
  // rebuilds. 'add' also covers genuinely new files (e.g. a new markup page).
96
94
  // Rebuild branches shared by change/add/deletion: a deletion needs the same
97
95
  // rebuilds (a deleted-but-still-imported file must surface the error).
96
+ // Scripts/styles outputs can land inside a watched dir (e.g. a Shopify
97
+ // theme's assets/): the compiler's own write must not retrigger that
98
+ // compiler, or watch loops forever. Zones are the dirs the compilers write
99
+ // into; only their own extensions are skipped there, so e.g. a hand-edited
100
+ // .liquid asset in the same dir still rebuilds markup.
101
+ const outputZones = [config.scripts, config.styles].flat()
102
+ .filter((entry) => entry && entry.out)
103
+ .map((entry) => (path.extname(entry.out) ? path.dirname(entry.out) : entry.out))
104
+ .filter((zone) => zone && zone !== '.')
105
+ const isBuildOutput = (file) => outputZones.some((zone) => pathContainsPathSegment(file, zone))
106
+
98
107
  const rebuild = (file) => {
99
- if (/(\.m?jsx?|\.tsx?)$/i.test(file)) {
108
+ if (/(\.m?jsx?|\.tsx?)$/i.test(file) && !isBuildOutput(file)) {
100
109
  modules.scripts.compile().catch(err => console.error(err))
101
110
 
102
111
  if (modules.reactor.belongsToReactor(file)) {
@@ -108,7 +117,7 @@ function setupWatchers(config, modules) {
108
117
  }).catch(err => console.error(err))
109
118
  }
110
119
  }
111
- if (/(\.sass|\.scss|\.css)$/i.test(file)) {
120
+ if (/(\.sass|\.scss|\.css)$/i.test(file) && !isBuildOutput(file)) {
112
121
  modules.styles.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
113
122
  }
114
123
  if (/(\.html|\.xml|\.rss|\.atom|\.njk|\.liquid|\.md)$/i.test(file)) {