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 +156 -5
- 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 +60 -0
- package/lib/markups.js +153 -47
- package/package.json +1 -1
- package/poops.js +14 -5
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
|
|
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 &
|
|
973
|
+
#### Search Index, Sitemap & Navigation
|
|
922
974
|
|
|
923
|
-
Poops can automatically generate a JSON search index
|
|
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 `
|
|
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.
|
|
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) {
|
package/lib/markup/indexer.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
81
|
+
const templatesDir = path.resolve(process.cwd(), this.markupIn)
|
|
48
82
|
this.engine = new EngineClass(templatesDir, this.includePaths, {
|
|
49
|
-
autoescape:
|
|
83
|
+
autoescape: this.autoescape
|
|
50
84
|
})
|
|
51
85
|
|
|
52
86
|
this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
|
|
53
|
-
this.engine.registerTags(() => path.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
135
|
-
const markupIn = path.
|
|
136
|
-
const rel = path.relative(markupIn, path.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
207
|
-
const markupStart = performance.now()
|
|
244
|
+
getMarkupFiles(markupIn) {
|
|
208
245
|
// glob patterns must use `/` — on Windows `\` is an escape character
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
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)) {
|