poops 1.5.2 → 1.6.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 +120 -7
- package/lib/exec.js +29 -0
- package/lib/markup/collections.js +2 -1
- package/lib/markup/engines/liquid.js +5 -1
- package/lib/markup/engines/nunjucks.js +5 -1
- package/lib/markup/helpers.js +281 -0
- package/lib/markup/indexer.js +370 -0
- package/lib/markups.js +49 -5
- package/package.json +1 -1
- package/poops.js +23 -9
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ It uses a simple config file where you define your input and output paths and it
|
|
|
45
45
|
- [googleFonts](#googlefonts)
|
|
46
46
|
- [highlight](#highlight)
|
|
47
47
|
- [Custom Filters](#custom-filters)
|
|
48
|
-
- [Search Index, Sitemap & Navigation](#search-index-sitemap--navigation)
|
|
48
|
+
- [Search Index, Sitemap, llms.txt, robots.txt & Navigation](#search-index-sitemap-llmstxt-robotstxt--navigation)
|
|
49
49
|
- [Images (optional)](#images-optional)
|
|
50
50
|
- [Copy](#copy)
|
|
51
51
|
- [Banner (optional)](#banner-optional)
|
|
@@ -703,7 +703,7 @@ sort: date
|
|
|
703
703
|
- `url` - the item's output path relative to the site root (e.g. `changelog/my-post.html`)
|
|
704
704
|
- `title` - falls back to the file name if not set in front matter
|
|
705
705
|
- `date` - falls back to the file's modification time if not set, with a build warning. Set a real `date` in front matter — mtime is meaningless on CI checkouts (git clone resets it), so undated posts will reshuffle between deploys.
|
|
706
|
-
- `wordcount`, `fileName`, `filePath`, `collection`
|
|
706
|
+
- `wordcount`, `excerpt` (first paragraph, plain text — a meta-description fallback), `fileName`, `filePath`, `collection`
|
|
707
707
|
|
|
708
708
|
An item with `published: false` in its front matter is excluded from the collection and its page is not built.
|
|
709
709
|
|
|
@@ -930,6 +930,54 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
930
930
|
- Nunjucks: `{{ someCodeVariable | highlight('javascript') }}`
|
|
931
931
|
- Liquid: `{{ someCodeVariable | highlight: 'javascript' }}`
|
|
932
932
|
|
|
933
|
+
- `og` — generates Open Graph (and a Twitter card) `<meta>` tags from a page's front matter and your `site` data, for link previews on social/chat platforms. Put it in your layout `<head>`. `og:type` auto-detects: `article` when the page has a `date`, otherwise `website`.
|
|
934
|
+
- Nunjucks: `{{ page | og(site) }}`
|
|
935
|
+
- Liquid: `{{ page | og: site }}`
|
|
936
|
+
|
|
937
|
+
Emits `og:title`, `og:description` (a missing `description` falls back to the page's auto-`excerpt`, then `site.description`), `og:type`, `og:url` (made absolute with `site.url`), `og:site_name` (from `site.title`), `og:locale` (`page.lang`/`site.lang`), `og:image` (`page.image`/`site.image`, made absolute), and `twitter:card` (`summary_large_image` when there's an image, else `summary`). For articles it adds `article:published_time`, `article:modified_time` and `article:author`. Attribute values are escaped. Set an `og` object in front matter to add or override any tag (e.g. `og:image:alt`, a fixed `twitter:card`):
|
|
938
|
+
|
|
939
|
+
```yaml
|
|
940
|
+
---
|
|
941
|
+
title: My post
|
|
942
|
+
date: 2026-01-01
|
|
943
|
+
image: static/cover.jpg
|
|
944
|
+
og:
|
|
945
|
+
"og:image:alt": Cover illustration
|
|
946
|
+
---
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
- `canonical` — generates a `<link rel="canonical">` tag pointing at a page's authoritative absolute URL (`site.url` + the page's `url`), the dedup signal that stops query-string and duplicate URLs splitting your ranking. Put it in your layout `<head>`. Front matter `canonical` overrides the target — an absolute URL as-is, or a path resolved against `site.url` (for cross-domain or hand-picked canonicals). The homepage canonicals to the site root. Returns nothing without `site.url`.
|
|
950
|
+
- Nunjucks: `{{ page | canonical(site) }}`
|
|
951
|
+
- Liquid: `{{ page | canonical: site }}`
|
|
952
|
+
|
|
953
|
+
- `jsonld` — generates a schema.org JSON-LD `<script type="application/ld+json">` block from a page's front matter and your `site` data, for GEO (Generative Engine Optimization) and structured data. Put it in your layout `<head>`. The `@type` auto-detects: `BlogPosting` when the page has a `date`, otherwise `WebPage`.
|
|
954
|
+
- Nunjucks: `{{ page | jsonld(site) }}`
|
|
955
|
+
- Liquid: `{{ page | jsonld: site }}`
|
|
956
|
+
|
|
957
|
+
It reads these front-matter fields when present: `title`, `description` (falls back to the page's auto-`excerpt`, then `site.description`), `url` (made absolute with `site.url`), `date` → `datePublished`, `updated` → `dateModified`, `author` (string or `{ name }`, falls back to `site.author`), `image`, `lang` → `inLanguage`, and `wordcount`. `publisher` comes from `site.title`; set `site.logo` to add a `publisher.logo` ImageObject (made absolute) — Google Article rich results require it. Front-matter values are escaped so they can't break out of the `<script>` tag.
|
|
958
|
+
|
|
959
|
+
On the homepage (a page with no `url`) it also emits a site-level `WebSite` block with `name` + `url`, which declares the site name for search results. On nested pages (a `url` with at least one folder) it auto-appends a `BreadcrumbList` block derived from URL depth — a Google breadcrumb rich result, no extra markup (needs `site.url` for the absolute item URLs). See the `breadcrumb` filter below for a visible trail from the same data.
|
|
960
|
+
|
|
961
|
+
For full control, set a `jsonld` object in front matter — its keys are merged over (and override) the generated defaults, including `@type`:
|
|
962
|
+
|
|
963
|
+
```yaml
|
|
964
|
+
---
|
|
965
|
+
title: How to brew coffee
|
|
966
|
+
date: 2026-01-01
|
|
967
|
+
jsonld:
|
|
968
|
+
"@type": HowTo
|
|
969
|
+
totalTime: PT5M
|
|
970
|
+
---
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
poops auto-picks `BlogPosting` (page has a `date`) or `WebPage`. Override `@type` with the `jsonld` object for any schema.org type — common ones search/generative engines act on: `Article`, `NewsArticle`, `HowTo`, `FAQPage`, `QAPage`, `Product`, `Recipe`, `Event`, `Course`, `VideoObject`, `SoftwareApplication`, `Organization`, `Person`, `BreadcrumbList`, `WebSite`. Full list at [schema.org/docs/full](https://schema.org/docs/full.html); validate with the [Rich Results Test](https://search.google.com/test/rich-results). A per-`@type` table with the notable fields is in the [Templating docs](example/src/markup/docs/quick-start/templating-html.md).
|
|
974
|
+
|
|
975
|
+
- `breadcrumb` — generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` → *Static Site*), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) — not the absolute domain.
|
|
976
|
+
- Nunjucks: `{{ page | breadcrumb(site, relativePathPrefix) }}`
|
|
977
|
+
- Liquid: `{{ page | breadcrumb: site, relativePathPrefix }}`
|
|
978
|
+
|
|
979
|
+
The home crumb is optional: set `breadcrumb: { home: false }` (or `{ homeLabel: "Start" }` to rename it) under `site` or in a page's front matter — front matter wins. With the home crumb off, top-level pages fall to a single crumb and render nothing, while nested pages still show their folder trail. `breadcrumb: false` on a page or on `site` disables both the visible trail and the auto `BreadcrumbList` JSON-LD. Returns nothing on the homepage or any single-crumb page.
|
|
980
|
+
|
|
933
981
|
- `groupby` — groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too.
|
|
934
982
|
- Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
|
|
935
983
|
- Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
|
|
@@ -1026,11 +1074,11 @@ Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo
|
|
|
1026
1074
|
{% endfor %}
|
|
1027
1075
|
```
|
|
1028
1076
|
|
|
1029
|
-
#### Search Index, Sitemap & Navigation
|
|
1077
|
+
#### Search Index, Sitemap, llms.txt, robots.txt & Navigation
|
|
1030
1078
|
|
|
1031
|
-
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.
|
|
1079
|
+
Poops can automatically generate a JSON search index, an XML sitemap, an `llms.txt`, a `robots.txt` and a navigation tree from your compiled pages. All are generated in a single pass during the markup compilation phase.
|
|
1032
1080
|
|
|
1033
|
-
To enable, add `searchIndex`, `sitemap` and/or `nav` to your markup config:
|
|
1081
|
+
To enable, add `searchIndex`, `sitemap`, `llms`, `robots` and/or `nav` to your markup config:
|
|
1034
1082
|
|
|
1035
1083
|
```json
|
|
1036
1084
|
{
|
|
@@ -1039,7 +1087,9 @@ To enable, add `searchIndex`, `sitemap` and/or `nav` to your markup config:
|
|
|
1039
1087
|
"out": "dist",
|
|
1040
1088
|
"options": {
|
|
1041
1089
|
"searchIndex": "search-index.json",
|
|
1042
|
-
"sitemap": "sitemap.xml"
|
|
1090
|
+
"sitemap": "sitemap.xml",
|
|
1091
|
+
"llms": "llms.txt",
|
|
1092
|
+
"robots": "robots.txt"
|
|
1043
1093
|
}
|
|
1044
1094
|
}
|
|
1045
1095
|
}
|
|
@@ -1058,6 +1108,13 @@ The string shorthand sets the output filename with default options. For more con
|
|
|
1058
1108
|
},
|
|
1059
1109
|
"sitemap": {
|
|
1060
1110
|
"output": "sitemap.xml"
|
|
1111
|
+
},
|
|
1112
|
+
"llms": {
|
|
1113
|
+
"output": "llms.txt",
|
|
1114
|
+
"title": "My Site",
|
|
1115
|
+
"description": "One-line summary of the site.",
|
|
1116
|
+
"intro": "src/llms-intro.md",
|
|
1117
|
+
"full": true
|
|
1061
1118
|
}
|
|
1062
1119
|
}
|
|
1063
1120
|
```
|
|
@@ -1094,7 +1151,29 @@ All front matter fields are passed through to the index automatically. Internal
|
|
|
1094
1151
|
|
|
1095
1152
|
**Sitemap** generates a standard `sitemap.xml` with `<loc>` and `<lastmod>` (from front matter `date`). If `site.url` is set in your markup config, it is prepended to all URLs. Collection index/pagination pages are included in the sitemap but excluded from the search index.
|
|
1096
1153
|
|
|
1097
|
-
|
|
1154
|
+
**llms.txt** generates an [`llms.txt`](https://llmstxt.org) — a Markdown index of your pages that LLMs and generative engines (GEO) read to understand your site. It has an `# H1` title, a `> ` blockquote summary, then `- [title](url): description` links grouped by URL path: the first folder is a `## section`, a second folder nests as a `### subsection` under it, and root-level pages fall under a lead "Pages" section. So `docs/config-reference.html` lands directly under `## Docs` while `docs/quick-start/x.html` lands under `### Quick Start` inside it. Collection items (which live under `collection/…`) group the same way and are ordered newest-first by their `date`; other sections keep file order. Set `intro` to a Markdown file path (relative to the project root) to insert free-form context between the blockquote and the link sections — a file authored for LLMs, e.g. `llms-intro.md`. Avoid `##` headings in it; they read as sections. (A raw README is a poor fit — badges, install noise and its own headings collide.) `title` and `description` default to your `site.title`/`site.description`; override them (and the lead section name via `sectionTitle`) with the object form. `site.url` makes the links absolute. Collection index/pagination pages are skipped, like the search index.
|
|
1155
|
+
|
|
1156
|
+
Set `full` to also write a companion full-content file — every page's Markdown body concatenated into one file an LLM can ingest whole (the index is the link map; this is the corpus). `true` names it after `output` with a `-full` suffix (`llms.txt` → `llms-full.txt`, `ai.txt` → `ai-full.txt`); pass a string to set the path yourself. The file opens with a `# Full Documentation Archive for {title}` header, a one-line intro naming the site and a `> ` blockquote of the `description` so a whole-file ingest starts with context, then each page becomes an `# title` (its own leading H1 if it has one) + `URL:` line + body, joined by `---`. Set `fullIntro` to a Markdown file path (from the project root) to insert your own preamble after that header — the `full` counterpart to `intro` (inserted verbatim; a missing file warns and is skipped). Only `.md`/`.markdown` sources qualify (a `.njk`/`.liquid` source is template code, not prose); `noindex` and collection-index pages are dropped. Content is the Markdown **source**, so unrendered `{% raw %}{% … %}{% endraw %}` tags or shortcodes in a body pass through verbatim.
|
|
1157
|
+
|
|
1158
|
+
**robots.txt** generates a `robots.txt`. The string shorthand writes an allow-all file (`User-agent: *`, empty `Disallow:`) with a `Sitemap:` line pointing at your generated sitemap — absolute when `site.url` is set. The object form takes `output`, `userAgent`, `allow`/`disallow` (a path or array of paths), and `sitemap` (an explicit URL, or `false` to omit the line):
|
|
1159
|
+
|
|
1160
|
+
```json
|
|
1161
|
+
{
|
|
1162
|
+
"robots": {
|
|
1163
|
+
"output": "robots.txt",
|
|
1164
|
+
"disallow": ["/admin", "/drafts"],
|
|
1165
|
+
"sitemap": false
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
Pages with `published: false` in their front matter are excluded from all outputs.
|
|
1171
|
+
|
|
1172
|
+
A page's front matter `robots: noindex` (or `none`) drops it from the **sitemap and llms.txt** — for drafts, thin or utility pages (a 404, say) you don't want crawled or fed to LLMs. It stays in the search index (that's your own on-site search). Emit the matching crawler directive in your layout `<head>` so the page itself carries it:
|
|
1173
|
+
|
|
1174
|
+
```html
|
|
1175
|
+
{% if page.robots %}<meta name="robots" content="{{ page.robots }}">{% endif %}
|
|
1176
|
+
```
|
|
1098
1177
|
|
|
1099
1178
|
**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:
|
|
1100
1179
|
|
|
@@ -1206,6 +1285,40 @@ Liquid — a partial that recurses via `render` (save as `_partials/navtree.liqu
|
|
|
1206
1285
|
{% render 'navtree', items: nav, relativePathPrefix: relativePathPrefix %}
|
|
1207
1286
|
```
|
|
1208
1287
|
|
|
1288
|
+
#### RSS / Atom feeds
|
|
1289
|
+
|
|
1290
|
+
Generate a subscription feed for a [collection](#collections) — no hand-authored feed template. Each feed lists the collection's posts newest-first (by `date`), with the channel metadata taken from your `site` data.
|
|
1291
|
+
|
|
1292
|
+
```json
|
|
1293
|
+
{
|
|
1294
|
+
"markup": {
|
|
1295
|
+
"in": "src/markup",
|
|
1296
|
+
"out": "dist",
|
|
1297
|
+
"options": {
|
|
1298
|
+
"feed": { "collection": "blog", "output": "blog/feed.rss" }
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
**Feed options:**
|
|
1305
|
+
|
|
1306
|
+
- `collection` — the collection to build the feed from. Omit it to emit a feed for **every** collection.
|
|
1307
|
+
- `output` — the file to write. A bare filename (`feed.xml`, the default) is placed inside the collection's own folder (`blog/feed.xml`); a value with a slash is used as-is under the output directory.
|
|
1308
|
+
- `type` — `"rss"` (default) or `"atom"`.
|
|
1309
|
+
- `limit` — max items, newest first (default `20`).
|
|
1310
|
+
- `title` — channel title (default `"<Collection> | <site.title>"`).
|
|
1311
|
+
- `description` — channel description (default `site.description`).
|
|
1312
|
+
- `author`, `lang` — default to `site.author` / `site.lang`.
|
|
1313
|
+
|
|
1314
|
+
Shorthand forms: `"feed": true` (or a filename string) emits an RSS feed for every collection; an array of the objects above generates several feeds at once (e.g. an RSS and an Atom for the same collection). Item `<description>` uses each post's `description`, falling back to its auto-`excerpt`; links, `guid`s and `<atom:link rel="self">` are made absolute with `site.url`. `robots: noindex` posts are excluded, matching the sitemap.
|
|
1315
|
+
|
|
1316
|
+
Point browsers and readers at it from your layout `<head>`:
|
|
1317
|
+
|
|
1318
|
+
```html
|
|
1319
|
+
<link rel="alternate" type="application/rss+xml" href="{{ site.url }}/blog/feed.rss">
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1209
1322
|
### Images (optional)
|
|
1210
1323
|
|
|
1211
1324
|
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/exec.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import log, { styledLog } from './utils/log.js'
|
|
3
|
+
|
|
4
|
+
// Post-stage shell hooks. `config.exec` maps a pipeline stage to a command (or
|
|
5
|
+
// array of commands) run after that stage compiles — in both build and watch,
|
|
6
|
+
// so a post-processor (e.g. stripping CSS comments, regenerating a reference)
|
|
7
|
+
// stays in sync during dev instead of only running behind `poops -b && cmd`.
|
|
8
|
+
// Sync + stdio:inherit so ordering is deterministic and output streams live;
|
|
9
|
+
// cwd is the project root, matching how the compilers resolve their paths.
|
|
10
|
+
// Returns true if any command failed, so build can fail the exit code; in watch
|
|
11
|
+
// the failure is logged and swallowed so the watcher survives.
|
|
12
|
+
// Stages: reactor, scripts, images, markup, styles (fires after PostCSS, so the
|
|
13
|
+
// CSS is final), copy, and build (once, after the full initial pipeline).
|
|
14
|
+
// `run` is injectable so tests exercise the branching without forking a shell.
|
|
15
|
+
export default function runExec(config, cwd, stage, run = execSync) {
|
|
16
|
+
const hooks = config.exec && config.exec[stage]
|
|
17
|
+
if (!hooks) return false
|
|
18
|
+
let failed = false
|
|
19
|
+
for (const cmd of [hooks].flat()) {
|
|
20
|
+
try {
|
|
21
|
+
styledLog(`⚡ {dim}exec ${stage}:{/} ${cmd}`)
|
|
22
|
+
run(cmd, { cwd, stdio: 'inherit' })
|
|
23
|
+
} catch (err) {
|
|
24
|
+
failed = true
|
|
25
|
+
log({ tag: 'error', text: `exec ${stage} failed: ${cmd}` })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return failed
|
|
29
|
+
}
|
|
@@ -3,7 +3,7 @@ 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, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './helpers.js'
|
|
6
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './helpers.js'
|
|
7
7
|
|
|
8
8
|
export function getSingleCollectionData(markupInDir, collectionName) {
|
|
9
9
|
const collectionData = []
|
|
@@ -30,6 +30,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
|
|
|
30
30
|
log({ tag: 'markup', warn: true, text: 'No date in front matter, falling back to file mtime:', link: file })
|
|
31
31
|
}
|
|
32
32
|
frontMatter.wordcount = wordcount(content)
|
|
33
|
+
frontMatter.excerpt = excerpt(content)
|
|
33
34
|
frontMatter.fileName = path.basename(file)
|
|
34
35
|
frontMatter.filePath = path.relative(process.cwd(), file)
|
|
35
36
|
frontMatter.collection = collectionName
|
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { Liquid } from 'liquidjs'
|
|
4
4
|
import { highlightCode } from '../highlight.js'
|
|
5
5
|
import { marked, renderMarkdownCached } from '../renderer.js'
|
|
6
|
-
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
|
|
6
|
+
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc, buildJsonLd, buildOpenGraph, buildCanonical, buildBreadcrumb } from '../helpers.js'
|
|
7
7
|
import { getImageExif, listImages } from '../image-cache.js'
|
|
8
8
|
import { slugify } from 'book-of-spells'
|
|
9
9
|
import dayjs from 'dayjs'
|
|
@@ -115,6 +115,10 @@ export default class LiquidEngine {
|
|
|
115
115
|
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
116
116
|
return listImages(dirPath, outputDir)
|
|
117
117
|
})
|
|
118
|
+
engine.registerFilter('jsonld', (page, site) => buildJsonLd(page, site))
|
|
119
|
+
engine.registerFilter('og', (page, site) => buildOpenGraph(page, site))
|
|
120
|
+
engine.registerFilter('canonical', (page, site) => buildCanonical(page, site))
|
|
121
|
+
engine.registerFilter('breadcrumb', (page, site, prefix) => buildBreadcrumb(page, site, prefix))
|
|
118
122
|
engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
119
123
|
engine.registerFilter('highlight', (code, lang) => {
|
|
120
124
|
const highlighted = highlightCode(code, lang)
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import { globSync } from 'glob'
|
|
3
3
|
import nunjucks from 'nunjucks'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
|
|
5
|
+
import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc, buildJsonLd, buildOpenGraph, buildCanonical, buildBreadcrumb } from '../helpers.js'
|
|
6
6
|
import { getImageExif, listImages } from '../image-cache.js'
|
|
7
7
|
import { toPosix } from '../../utils/helpers.js'
|
|
8
8
|
import { highlightCode } from '../highlight.js'
|
|
@@ -177,6 +177,10 @@ export default class NunjucksEngine {
|
|
|
177
177
|
const outputDir = path.resolve(process.cwd(), markupOut)
|
|
178
178
|
return listImages(dirPath, outputDir)
|
|
179
179
|
})
|
|
180
|
+
env.addFilter('jsonld', (page, site) => new nunjucks.runtime.SafeString(buildJsonLd(page, site)))
|
|
181
|
+
env.addFilter('og', (page, site) => new nunjucks.runtime.SafeString(buildOpenGraph(page, site)))
|
|
182
|
+
env.addFilter('canonical', (page, site) => new nunjucks.runtime.SafeString(buildCanonical(page, site)))
|
|
183
|
+
env.addFilter('breadcrumb', (page, site, prefix) => new nunjucks.runtime.SafeString(buildBreadcrumb(page, site, prefix)))
|
|
180
184
|
env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
181
185
|
env.addFilter('highlight', (code, lang) => {
|
|
182
186
|
const highlighted = highlightCode(code, lang)
|
package/lib/markup/helpers.js
CHANGED
|
@@ -68,6 +68,26 @@ export function wordcount(text) {
|
|
|
68
68
|
return words ? words.length : 0
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// First prose paragraph of content as plain text, for a meta-description
|
|
72
|
+
// fallback when front matter has none. Splits on blank lines, skips leading
|
|
73
|
+
// headings and HTML comments, strips markdown/HTML like wordcount, then caps at
|
|
74
|
+
// ~160 chars (search-snippet size) on a word boundary.
|
|
75
|
+
// ponytail: content-level split; a leading code fence or raw template block
|
|
76
|
+
// could still slip in — refine the skip list if a snippet looks wrong.
|
|
77
|
+
export function excerpt(text, max = 160) {
|
|
78
|
+
if (!text) return ''
|
|
79
|
+
for (const block of text.split(/\n\s*\n/)) {
|
|
80
|
+
const trimmed = block.trim()
|
|
81
|
+
if (!trimmed || /^#{1,6}\s/.test(trimmed) || trimmed.startsWith('<!--')) continue
|
|
82
|
+
// eslint-disable-next-line no-useless-escape
|
|
83
|
+
const stripped = trimmed.replace(/<[^>]*>/g, ' ').replace(/[#*_`~\[\]()>|{}\\]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
84
|
+
if (!stripped) continue
|
|
85
|
+
if (stripped.length <= max) return stripped
|
|
86
|
+
return stripped.slice(0, max).replace(/\s+\S*$/, '').trim() + '…'
|
|
87
|
+
}
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
// marked HTML-encodes quotes/brackets in template tags it treats as prose,
|
|
72
92
|
// breaking string args like groupby("date"). Decode entities inside {{ }} / {% %}
|
|
73
93
|
// after markdown, before the template engine parses. Decode & last so
|
|
@@ -371,3 +391,264 @@ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
|
|
|
371
391
|
}
|
|
372
392
|
return `<img ${attrs.join(' ')}>`
|
|
373
393
|
}
|
|
394
|
+
|
|
395
|
+
// Serializes a JSON-LD data object into a <script> block. Drops empty top-level
|
|
396
|
+
// keys (GEO parsers ignore them) and neutralizes `<`, `>`, `&` so front-matter
|
|
397
|
+
// values can't break out of the <script> or inject entities.
|
|
398
|
+
function jsonLdScript(data) {
|
|
399
|
+
for (const k of Object.keys(data)) {
|
|
400
|
+
if (data[k] === undefined || data[k] === null || data[k] === '') delete data[k]
|
|
401
|
+
}
|
|
402
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
|
|
403
|
+
return `<script type="application/ld+json">${json}</script>`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Turns a URL segment (or a filename) into a human title for a breadcrumb crumb
|
|
407
|
+
// that has no page object of its own — same rule the nav tree uses for virtual
|
|
408
|
+
// parents. Strips a trailing extension, then dash/underscore → spaced Title Case.
|
|
409
|
+
function humanizeSegment(seg) {
|
|
410
|
+
return String(seg)
|
|
411
|
+
.replace(/\.[a-z0-9]+$/i, '')
|
|
412
|
+
.replace(/[-_]+/g, ' ')
|
|
413
|
+
.replace(/\s+/g, ' ')
|
|
414
|
+
.trim()
|
|
415
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Builds a breadcrumb trail — a { name, path } array from the site root down to
|
|
419
|
+
// the page — derived purely from URL depth (no nav tree needed). Intermediate
|
|
420
|
+
// path segments are humanized (they have no page object); the last crumb is the
|
|
421
|
+
// page's own title. The home crumb (site root) leads unless disabled.
|
|
422
|
+
// `path` is the site-root-relative URL (page.url form: '' for home, no domain);
|
|
423
|
+
// each consumer prefixes it — the JSON-LD with `site.url` (absolute, required by
|
|
424
|
+
// Google), the visible filter with `relativePathPrefix` (so local-dev links stay
|
|
425
|
+
// on localhost instead of jumping to the production domain).
|
|
426
|
+
// Config: `site.breadcrumb` overlaid by front-matter `page.breadcrumb` (object
|
|
427
|
+
// with `home` boolean / `homeLabel` string); `breadcrumb: false` on either
|
|
428
|
+
// disables it. Returns [] for the homepage, a single-crumb trail, or when
|
|
429
|
+
// disabled — every caller renders nothing on [].
|
|
430
|
+
export function breadcrumbCrumbs(page, site = {}) {
|
|
431
|
+
page = page || {}
|
|
432
|
+
site = site || {}
|
|
433
|
+
if (page.breadcrumb === false || site.breadcrumb === false) return []
|
|
434
|
+
|
|
435
|
+
const cfg = { home: true, homeLabel: 'Home' }
|
|
436
|
+
if (site.breadcrumb && typeof site.breadcrumb === 'object') Object.assign(cfg, site.breadcrumb)
|
|
437
|
+
if (page.breadcrumb && typeof page.breadcrumb === 'object') Object.assign(cfg, page.breadcrumb)
|
|
438
|
+
|
|
439
|
+
const rawUrl = page.url != null ? String(page.url) : ''
|
|
440
|
+
if (rawUrl === '') return [] // homepage — you're already home
|
|
441
|
+
|
|
442
|
+
const parts = rawUrl.split('/').filter(Boolean)
|
|
443
|
+
const crumbs = []
|
|
444
|
+
if (cfg.home) crumbs.push({ name: cfg.homeLabel, path: '' })
|
|
445
|
+
|
|
446
|
+
// Every part but the last is an ancestor directory; accumulate its path.
|
|
447
|
+
let acc = ''
|
|
448
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
449
|
+
acc = acc ? `${acc}/${parts[i]}` : parts[i]
|
|
450
|
+
crumbs.push({ name: humanizeSegment(parts[i]), path: acc })
|
|
451
|
+
}
|
|
452
|
+
crumbs.push({ name: page.title || humanizeSegment(parts[parts.length - 1]), path: rawUrl })
|
|
453
|
+
|
|
454
|
+
// A lone crumb (e.g. home off, top-level page) is not a trail.
|
|
455
|
+
return crumbs.length >= 2 ? crumbs : []
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// BreadcrumbList JSON-LD <script> for a page's position in the site hierarchy —
|
|
459
|
+
// a Google rich result. Absolute item URLs are mandatory, so it's skipped
|
|
460
|
+
// without `site.url` (like canonical). Names come from front matter / segments,
|
|
461
|
+
// so jsonLdScript escapes them for the <script> context.
|
|
462
|
+
function breadcrumbJsonLd(page, site) {
|
|
463
|
+
if (!(site && site.url)) return ''
|
|
464
|
+
const crumbs = breadcrumbCrumbs(page, site)
|
|
465
|
+
if (crumbs.length < 2) return ''
|
|
466
|
+
const baseUrl = String(site.url).replace(/\/+$/, '')
|
|
467
|
+
const abs = (p) => {
|
|
468
|
+
const rel = String(p).replace(/^\/+/, '')
|
|
469
|
+
return rel ? `${baseUrl}/${rel}` : baseUrl
|
|
470
|
+
}
|
|
471
|
+
return jsonLdScript({
|
|
472
|
+
'@context': 'https://schema.org',
|
|
473
|
+
'@type': 'BreadcrumbList',
|
|
474
|
+
itemListElement: crumbs.map((c, i) => ({
|
|
475
|
+
'@type': 'ListItem',
|
|
476
|
+
position: i + 1,
|
|
477
|
+
name: c.name,
|
|
478
|
+
item: abs(c.path)
|
|
479
|
+
}))
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Builds a visible <nav> breadcrumb trail for display in the page body (blog
|
|
484
|
+
// posts, nested pages). Same crumbs as the JSON-LD, but hrefs are relative:
|
|
485
|
+
// `prefix` is the page's `relativePathPrefix`, so links resolve against the
|
|
486
|
+
// current output location (localhost in dev, the deployed path in prod) exactly
|
|
487
|
+
// like the nav/header links — never the absolute production domain. The last
|
|
488
|
+
// crumb renders as aria-current text (the current page is not a link). Returns
|
|
489
|
+
// '' when there's no trail. Names/paths land in attributes, so they're escaped.
|
|
490
|
+
export function buildBreadcrumb(page, site = {}, prefix = '') {
|
|
491
|
+
const crumbs = breadcrumbCrumbs(page, site)
|
|
492
|
+
if (crumbs.length < 2) return ''
|
|
493
|
+
const items = crumbs.map((c, i) => {
|
|
494
|
+
const name = escapeAttr(c.name)
|
|
495
|
+
if (i === crumbs.length - 1) return `<li><span aria-current="page">${name}</span></li>`
|
|
496
|
+
return `<li><a href="${escapeAttr(prefix + c.path)}">${name}</a></li>`
|
|
497
|
+
})
|
|
498
|
+
return `<nav class="breadcrumb" aria-label="Breadcrumb"><ol>${items.join('')}</ol></nav>`
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Builds a schema.org JSON-LD <script> block from a page's front matter + site
|
|
502
|
+
// data, for GEO / structured data (generative engines lean on schema.org to
|
|
503
|
+
// understand a page). @type auto-detects — BlogPosting when the page has a
|
|
504
|
+
// `date`, else WebPage. Article-only fields (headline, dates, author,
|
|
505
|
+
// wordCount) are added only for the article case. The publisher Organization
|
|
506
|
+
// gains a `logo` ImageObject when `site.logo` is set — Google Article rich
|
|
507
|
+
// results require it. On the homepage (page has no `url`) a second `WebSite`
|
|
508
|
+
// block is emitted, declaring the site name for search results.
|
|
509
|
+
// Anything in `page.jsonld` (an object) is merged over the generated defaults,
|
|
510
|
+
// so a page can add or replace any field — including @type — as a full escape
|
|
511
|
+
// hatch. Values come from front matter, so the JSON is escaped for a <script>
|
|
512
|
+
// context to prevent a `</script>` break-out (XSS).
|
|
513
|
+
export function buildJsonLd(page, site = {}) {
|
|
514
|
+
page = page || {}
|
|
515
|
+
site = site || {}
|
|
516
|
+
|
|
517
|
+
const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
|
|
518
|
+
const absUrl = (u) => {
|
|
519
|
+
if (!u) return undefined
|
|
520
|
+
if (/^https?:\/\//i.test(u)) return u
|
|
521
|
+
return baseUrl ? `${baseUrl}/${String(u).replace(/^\/+/, '')}` : u
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const author = (page.author && typeof page.author === 'object' ? page.author.name : page.author) || site.author
|
|
525
|
+
const publisherName = site.title || site.name
|
|
526
|
+
|
|
527
|
+
let publisher
|
|
528
|
+
if (publisherName) {
|
|
529
|
+
publisher = { '@type': 'Organization', name: publisherName }
|
|
530
|
+
const logo = absUrl(site.logo)
|
|
531
|
+
if (logo) publisher.logo = { '@type': 'ImageObject', url: logo }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const isArticle = !!page.date
|
|
535
|
+
const data = {
|
|
536
|
+
'@context': 'https://schema.org',
|
|
537
|
+
'@type': isArticle ? 'BlogPosting' : 'WebPage',
|
|
538
|
+
name: page.title,
|
|
539
|
+
description: page.description || page.excerpt || site.description,
|
|
540
|
+
url: absUrl(page.url),
|
|
541
|
+
inLanguage: page.lang || site.lang,
|
|
542
|
+
image: absUrl(page.image),
|
|
543
|
+
publisher
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (isArticle) {
|
|
547
|
+
data.headline = page.title
|
|
548
|
+
data.datePublished = page.date
|
|
549
|
+
data.dateModified = page.updated || page.date
|
|
550
|
+
if (author) data.author = { '@type': 'Person', name: author }
|
|
551
|
+
if (page.wordcount) data.wordCount = page.wordcount
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// page.jsonld extends/overrides. Shallow merge — nested schema is rare and
|
|
555
|
+
// the escape hatch is meant for whole-key replacement.
|
|
556
|
+
if (page.jsonld && typeof page.jsonld === 'object') Object.assign(data, page.jsonld)
|
|
557
|
+
|
|
558
|
+
const blocks = [jsonLdScript(data)]
|
|
559
|
+
|
|
560
|
+
// Homepage (no page.url) gets a site-level WebSite block — defines the site
|
|
561
|
+
// name for search results.
|
|
562
|
+
if (!page.url && baseUrl) {
|
|
563
|
+
blocks.push(jsonLdScript({
|
|
564
|
+
'@context': 'https://schema.org',
|
|
565
|
+
'@type': 'WebSite',
|
|
566
|
+
name: publisherName,
|
|
567
|
+
url: baseUrl,
|
|
568
|
+
inLanguage: page.lang || site.lang
|
|
569
|
+
}))
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Nested pages get a BreadcrumbList block (zero-config rich result).
|
|
573
|
+
const breadcrumb = breadcrumbJsonLd(page, site)
|
|
574
|
+
if (breadcrumb) blocks.push(breadcrumb)
|
|
575
|
+
|
|
576
|
+
return blocks.join('\n')
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Builds Open Graph (+ a Twitter card) <meta> tags from a page's front matter
|
|
580
|
+
// and site data, for link previews on social/chat platforms. og:type is
|
|
581
|
+
// `article` when the page has a `date`, else `website`. Twitter tags fall back
|
|
582
|
+
// to the og:* values on most crawlers, so only `twitter:card` is emitted. A
|
|
583
|
+
// `page.og` object merges over (and overrides) the defaults — set any extra
|
|
584
|
+
// property there, e.g. `og:image:alt` or a fixed `twitter:card`. Values come
|
|
585
|
+
// from front matter and land in attributes, so they're escaped (escapeAttr).
|
|
586
|
+
export function buildOpenGraph(page, site = {}) {
|
|
587
|
+
page = page || {}
|
|
588
|
+
site = site || {}
|
|
589
|
+
|
|
590
|
+
const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
|
|
591
|
+
const absUrl = (u) => {
|
|
592
|
+
if (!u) return undefined
|
|
593
|
+
if (/^https?:\/\//i.test(u)) return u
|
|
594
|
+
return baseUrl ? `${baseUrl}/${String(u).replace(/^\/+/, '')}` : u
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const author = (page.author && typeof page.author === 'object' ? page.author.name : page.author) || site.author
|
|
598
|
+
const isArticle = !!page.date
|
|
599
|
+
const image = absUrl(page.image || site.image)
|
|
600
|
+
|
|
601
|
+
const tags = {
|
|
602
|
+
'og:title': page.title || site.title,
|
|
603
|
+
'og:description': page.description || page.excerpt || site.description,
|
|
604
|
+
'og:type': isArticle ? 'article' : 'website',
|
|
605
|
+
'og:url': absUrl(page.url),
|
|
606
|
+
'og:site_name': site.title,
|
|
607
|
+
'og:locale': page.lang || site.lang,
|
|
608
|
+
'og:image': image,
|
|
609
|
+
'twitter:card': image ? 'summary_large_image' : 'summary'
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (isArticle) {
|
|
613
|
+
tags['article:published_time'] = page.date
|
|
614
|
+
tags['article:modified_time'] = page.updated || page.date
|
|
615
|
+
if (author) tags['article:author'] = author
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// page.og extends/overrides. Shallow — one flat property:content map.
|
|
619
|
+
if (page.og && typeof page.og === 'object') Object.assign(tags, page.og)
|
|
620
|
+
|
|
621
|
+
const lines = []
|
|
622
|
+
for (const [prop, val] of Object.entries(tags)) {
|
|
623
|
+
if (val === undefined || val === null || val === '') continue
|
|
624
|
+
// Twitter's spec uses name=, Open Graph uses property=.
|
|
625
|
+
const attr = prop.startsWith('twitter:') ? 'name' : 'property'
|
|
626
|
+
lines.push(`<meta ${attr}="${escapeAttr(prop)}" content="${escapeAttr(val)}">`)
|
|
627
|
+
}
|
|
628
|
+
return lines.join('\n')
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Builds a `<link rel="canonical">` tag — the dedup signal that names a page's
|
|
632
|
+
// authoritative URL. The href is absolute (needs `site.url`): front matter
|
|
633
|
+
// `canonical` wins (an absolute URL as-is, or a path resolved against
|
|
634
|
+
// `site.url`), else the page's own `url`. Homepage (`url` === '') canonicals to
|
|
635
|
+
// the site root. Returns '' when no absolute URL can be formed (no site.url).
|
|
636
|
+
export function buildCanonical(page, site = {}) {
|
|
637
|
+
page = page || {}
|
|
638
|
+
site = site || {}
|
|
639
|
+
|
|
640
|
+
const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
|
|
641
|
+
const abs = (u) => {
|
|
642
|
+
if (u == null) return ''
|
|
643
|
+
if (/^https?:\/\//i.test(u)) return u
|
|
644
|
+
if (!baseUrl) return ''
|
|
645
|
+
const rel = String(u).replace(/^\/+/, '')
|
|
646
|
+
return rel ? `${baseUrl}/${rel}` : baseUrl
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The root index page has no `url` (poops leaves it unset), so a nullish url
|
|
650
|
+
// means the homepage → canonical to the site root.
|
|
651
|
+
const target = page.canonical != null ? page.canonical : (page.url != null ? page.url : '')
|
|
652
|
+
const href = abs(target)
|
|
653
|
+
return href ? `<link rel="canonical" href="${escapeAttr(href)}">` : ''
|
|
654
|
+
}
|
package/lib/markup/indexer.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { readJsonFile, fileSize } from '../utils/helpers.js'
|
|
5
|
+
import { parseFrontMatter } from './helpers.js'
|
|
6
|
+
import { renderMarkdownCached } from './renderer.js'
|
|
5
7
|
import log from '../utils/log.js'
|
|
6
8
|
|
|
7
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -37,6 +39,12 @@ function normalizeConfig(config) {
|
|
|
37
39
|
return { ...DEFAULTS, ...config }
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// A page front-matter `robots: noindex` (or `none`) opts it out of the
|
|
43
|
+
// crawler/discovery artifacts — the sitemap and llms.txt.
|
|
44
|
+
function isNoindex(entry) {
|
|
45
|
+
return entry.robots ? /\b(noindex|none)\b/i.test(String(entry.robots)) : false
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
export function extractKeywords(htmlContent, options = {}) {
|
|
41
49
|
const { minWordLength = DEFAULTS.minWordLength, stopWords = new Set() } = options
|
|
42
50
|
|
|
@@ -163,6 +171,7 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
|
|
|
163
171
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
164
172
|
|
|
165
173
|
for (const entry of pageEntries) {
|
|
174
|
+
if (isNoindex(entry)) continue
|
|
166
175
|
const loc = baseUrl ? `${baseUrl}/${entry.url}` : entry.url
|
|
167
176
|
xml += ' <url>\n'
|
|
168
177
|
xml += ` <loc>${escapeXml(loc)}</loc>\n`
|
|
@@ -182,6 +191,187 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
|
|
|
182
191
|
log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
183
192
|
}
|
|
184
193
|
|
|
194
|
+
// Generates an llms.txt (https://llmstxt.org): a markdown index of the site's
|
|
195
|
+
// pages that LLMs / generative engines ingest to understand the site (GEO).
|
|
196
|
+
// Structure: `# title`, a `> description` blockquote, then one `## section` per
|
|
197
|
+
// collection (uncollected pages go under a lead "Pages" section) with
|
|
198
|
+
// `- [title](absolute url): description` links. `title`/`description` are
|
|
199
|
+
// resolved from site data by the caller. Mirrors generateSitemap's page set —
|
|
200
|
+
// isIndex (collection landing/pagination) pages are skipped.
|
|
201
|
+
export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
|
|
202
|
+
config = normalizeConfig(config)
|
|
203
|
+
if (!config) return
|
|
204
|
+
|
|
205
|
+
const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
|
|
206
|
+
const absUrl = (url) => baseUrl ? `${baseUrl}/${url}` : url
|
|
207
|
+
|
|
208
|
+
let out = `# ${config.title || 'Site'}\n`
|
|
209
|
+
if (config.description) out += `\n> ${config.description}\n`
|
|
210
|
+
|
|
211
|
+
// Optional free-form body (llmstxt.org allows markdown between the blockquote
|
|
212
|
+
// and the link sections). `intro` is a path to a markdown file authored for
|
|
213
|
+
// LLM context — inserted verbatim. Avoid H2s in it; they'd read as sections.
|
|
214
|
+
if (config.intro) {
|
|
215
|
+
try {
|
|
216
|
+
const body = fs.readFileSync(path.resolve(process.cwd(), config.intro), 'utf-8').trim()
|
|
217
|
+
if (body) out += `\n${body}\n`
|
|
218
|
+
} catch {
|
|
219
|
+
log({ tag: 'indexer', warn: true, text: 'llms.txt intro file not found:', link: config.intro })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Group into two levels from the URL path, first-seen order preserved: first
|
|
224
|
+
// folder = `## section`, second folder = `### subsection` nested under it,
|
|
225
|
+
// root-level pages under the lead section. A collection's items already live
|
|
226
|
+
// under `collectionName/…`, so the folder segments double as its grouping.
|
|
227
|
+
const lead = config.sectionTitle || 'Pages'
|
|
228
|
+
const sections = new Map() // name -> { direct: [entry], subs: Map<name, [entry]> }
|
|
229
|
+
const sectionOf = (name) => {
|
|
230
|
+
if (!sections.has(name)) sections.set(name, { direct: [], subs: new Map() })
|
|
231
|
+
return sections.get(name)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const e of pageEntries) {
|
|
235
|
+
if (e.isIndex || isNoindex(e)) continue
|
|
236
|
+
const slash = e.url.lastIndexOf('/')
|
|
237
|
+
const segs = slash === -1 ? [] : e.url.slice(0, slash).split('/').filter(Boolean)
|
|
238
|
+
if (segs.length === 0) {
|
|
239
|
+
sectionOf(lead).direct.push(e)
|
|
240
|
+
} else if (segs.length === 1) {
|
|
241
|
+
sectionOf(humanizeSegment(segs[0])).direct.push(e)
|
|
242
|
+
} else {
|
|
243
|
+
const subs = sectionOf(humanizeSegment(segs[0])).subs
|
|
244
|
+
const key = humanizeSegment(segs[1])
|
|
245
|
+
if (!subs.has(key)) subs.set(key, [])
|
|
246
|
+
subs.get(key).push(e)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const linkLine = (e) => {
|
|
251
|
+
const link = `[${e.title || e.url}](${absUrl(e.url)})`
|
|
252
|
+
return e.description ? `- ${link}: ${e.description}\n` : `- ${link}\n`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Collection buckets are chronological (newest first); other sections (docs)
|
|
256
|
+
// keep their file/order sequence.
|
|
257
|
+
const ordered = (entries) => {
|
|
258
|
+
if (!entries.length || !entries.every(e => e.collection != null)) return entries
|
|
259
|
+
return [...entries].sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const [name, group] of sections) {
|
|
263
|
+
out += `\n## ${name}\n\n`
|
|
264
|
+
for (const e of ordered(group.direct)) out += linkLine(e)
|
|
265
|
+
for (const [subName, entries] of group.subs) {
|
|
266
|
+
out += `\n### ${subName}\n\n`
|
|
267
|
+
for (const e of ordered(entries)) out += linkLine(e)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const outputPath = path.resolve(process.cwd(), outputDir, config.output)
|
|
272
|
+
fs.writeFileSync(outputPath, out)
|
|
273
|
+
log({ tag: 'indexer', text: 'Generated llms.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Generates llms-full.txt: every page's full content concatenated, the
|
|
277
|
+
// companion to llms.txt's link index (llmstxt.org). Enabled by `llms.full`
|
|
278
|
+
// (string path, or `true` → the index filename with a `-full` suffix). The content comes from each
|
|
279
|
+
// page's markdown SOURCE (entry._src), not entry.content — that's the rendered
|
|
280
|
+
// HTML page (layout + chrome). Only markdown sources qualify: an .njk/.liquid
|
|
281
|
+
// source is template code, not prose.
|
|
282
|
+
// ponytail: raw markdown, so unrendered {% %} tags / shortcodes in a body leak
|
|
283
|
+
// through verbatim. Upgrade path if that bites: render + HTML→markdown the body.
|
|
284
|
+
export function generateLlmsFull(pageEntries, outputDir, siteUrl, config) {
|
|
285
|
+
config = normalizeConfig(config)
|
|
286
|
+
if (!config || !config.full) return
|
|
287
|
+
// Default filename pairs with the index: llms.txt → llms-full.txt, ai.txt →
|
|
288
|
+
// ai-full.txt (suffix the index's `output`, dir/ext preserved). A string
|
|
289
|
+
// `full` overrides the path outright.
|
|
290
|
+
let output = config.full
|
|
291
|
+
if (typeof output !== 'string') {
|
|
292
|
+
const p = path.parse(config.output || 'llms.txt')
|
|
293
|
+
output = path.join(p.dir, `${p.name}-full${p.ext}`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
|
|
297
|
+
const absUrl = (url) => baseUrl ? `${baseUrl}/${url}` : url
|
|
298
|
+
const isMarkdown = (src) => /\.(md|markdown)$/i.test(src || '')
|
|
299
|
+
|
|
300
|
+
const blocks = []
|
|
301
|
+
for (const e of pageEntries) {
|
|
302
|
+
if (e.isIndex || isNoindex(e) || !isMarkdown(e._src)) continue
|
|
303
|
+
const body = parseFrontMatter(e._src).content.trim()
|
|
304
|
+
if (!body) continue
|
|
305
|
+
const url = `URL: ${absUrl(e.url)}`
|
|
306
|
+
// If the body already opens with its own ATX H1, reuse it as the block
|
|
307
|
+
// heading and slot the URL beneath — otherwise the wrapper `# title` plus
|
|
308
|
+
// the body's H1 would emit two H1s per block (often exact duplicates).
|
|
309
|
+
const h1 = body.match(/^# .*(?:\r?\n|$)/)
|
|
310
|
+
blocks.push(h1
|
|
311
|
+
? `${h1[0].trim()}\n${url}\n\n${body.slice(h1[0].length).trimStart()}`
|
|
312
|
+
: `# ${e.title || e.url}\n${url}\n\n${body}`)
|
|
313
|
+
}
|
|
314
|
+
if (!blocks.length) return
|
|
315
|
+
|
|
316
|
+
// Corpus header: names the file and its purpose so a whole-file ingest opens
|
|
317
|
+
// with context instead of the first page's H1. Reuses the index title/desc.
|
|
318
|
+
const name = config.title || 'this site'
|
|
319
|
+
let head = `# Full Documentation Archive for ${name}\n`
|
|
320
|
+
head += `\nThis file contains the complete Markdown documentation for ${name}.`
|
|
321
|
+
if (config.description) head += `\n\n> ${config.description}`
|
|
322
|
+
// Optional author-written preamble after the header — the full-file counterpart
|
|
323
|
+
// to `intro` for the index. Inserted verbatim; a missing file warns and skips.
|
|
324
|
+
if (config.fullIntro) {
|
|
325
|
+
try {
|
|
326
|
+
const body = fs.readFileSync(path.resolve(process.cwd(), config.fullIntro), 'utf-8').trim()
|
|
327
|
+
if (body) head += `\n\n${body}`
|
|
328
|
+
} catch {
|
|
329
|
+
log({ tag: 'indexer', warn: true, text: 'llms-full intro file not found:', link: config.fullIntro })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
blocks.unshift(head)
|
|
333
|
+
|
|
334
|
+
const outputPath = path.resolve(process.cwd(), outputDir, output)
|
|
335
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
336
|
+
fs.writeFileSync(outputPath, blocks.join('\n\n---\n\n') + '\n')
|
|
337
|
+
log({ tag: 'indexer', text: 'Generated llms-full.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Generates a robots.txt. Defaults to allow-all with a `Sitemap:` line pointing
|
|
341
|
+
// at the generated sitemap (absolute, needs `site.url`). The object form takes
|
|
342
|
+
// `userAgent`, `allow`/`disallow` (string or array of paths) and `sitemap`
|
|
343
|
+
// (an explicit URL, or `false` to omit the line). `sitemapOutput` is the
|
|
344
|
+
// sitemap's filename, threaded in by generateIndexFiles.
|
|
345
|
+
// ponytail: single user-agent group; add per-agent groups if a real need shows up.
|
|
346
|
+
export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
|
|
347
|
+
config = normalizeConfig(config)
|
|
348
|
+
if (!config) return
|
|
349
|
+
|
|
350
|
+
const arr = (v) => v == null ? [] : (Array.isArray(v) ? v : [v])
|
|
351
|
+
const allow = arr(config.allow)
|
|
352
|
+
const disallow = arr(config.disallow)
|
|
353
|
+
|
|
354
|
+
const lines = [`User-agent: ${config.userAgent || '*'}`]
|
|
355
|
+
for (const p of allow) lines.push(`Allow: ${p}`)
|
|
356
|
+
for (const p of disallow) lines.push(`Disallow: ${p}`)
|
|
357
|
+
// Empty `Disallow:` is the canonical "allow everything" when nothing is set.
|
|
358
|
+
if (allow.length === 0 && disallow.length === 0) lines.push('Disallow:')
|
|
359
|
+
|
|
360
|
+
let sitemapUrl = null
|
|
361
|
+
if (config.sitemap === false) {
|
|
362
|
+
sitemapUrl = null
|
|
363
|
+
} else if (typeof config.sitemap === 'string') {
|
|
364
|
+
sitemapUrl = config.sitemap
|
|
365
|
+
} else if (sitemapOutput && siteUrl) {
|
|
366
|
+
sitemapUrl = `${siteUrl.replace(/\/+$/, '')}/${sitemapOutput}`
|
|
367
|
+
}
|
|
368
|
+
if (sitemapUrl) lines.push('', `Sitemap: ${sitemapUrl}`)
|
|
369
|
+
|
|
370
|
+
const outputPath = path.resolve(process.cwd(), outputDir, config.output)
|
|
371
|
+
fs.writeFileSync(outputPath, lines.join('\n') + '\n')
|
|
372
|
+
log({ tag: 'indexer', text: 'Generated robots.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
373
|
+
}
|
|
374
|
+
|
|
185
375
|
function humanizeSegment(seg) {
|
|
186
376
|
return seg
|
|
187
377
|
.replace(/[-_]+/g, ' ')
|
|
@@ -333,8 +523,188 @@ export function generateNav(pageEntries, outputDir, config) {
|
|
|
333
523
|
log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
334
524
|
}
|
|
335
525
|
|
|
526
|
+
// RFC-822 date for RSS pubDate/lastBuildDate (toUTCString is RFC-822 compliant).
|
|
527
|
+
function rssDate(d) {
|
|
528
|
+
const date = new Date(d)
|
|
529
|
+
return isNaN(date) ? '' : date.toUTCString()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ISO-8601 (RFC-3339) date for Atom updated timestamps.
|
|
533
|
+
function isoDate(d) {
|
|
534
|
+
const date = new Date(d)
|
|
535
|
+
return isNaN(date) ? new Date().toISOString() : date.toISOString()
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function feedAuthorName(author) {
|
|
539
|
+
if (!author) return ''
|
|
540
|
+
return String(author.name || author)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Normalizes the `feed` option into concrete per-collection specs. Accepts:
|
|
544
|
+
// true | "feed.xml" → RSS for every collection
|
|
545
|
+
// { output, type, limit, … } → those options for every collection
|
|
546
|
+
// { collection, … } → one named collection
|
|
547
|
+
// [ {…}, {…} ] → an explicit list
|
|
548
|
+
// `output` containing a slash is a full path under the output dir; a bare
|
|
549
|
+
// filename is placed in the collection's own folder (default "feed.xml").
|
|
550
|
+
function resolveFeedSpecs(config, collectionNames, site) {
|
|
551
|
+
if (!config) return []
|
|
552
|
+
const raw = Array.isArray(config) ? config : [config]
|
|
553
|
+
const specs = []
|
|
554
|
+
for (let item of raw) {
|
|
555
|
+
if (item === true) item = {}
|
|
556
|
+
else if (typeof item === 'string') item = { output: item }
|
|
557
|
+
if (!item || typeof item !== 'object') continue
|
|
558
|
+
|
|
559
|
+
const targets = item.collection ? [item.collection] : collectionNames
|
|
560
|
+
for (const name of targets) {
|
|
561
|
+
const outName = item.output || 'feed.xml'
|
|
562
|
+
specs.push({
|
|
563
|
+
collection: name,
|
|
564
|
+
output: outName.includes('/') ? outName : `${name}/${outName}`,
|
|
565
|
+
type: item.type === 'atom' ? 'atom' : 'rss',
|
|
566
|
+
limit: Number(item.limit) > 0 ? Number(item.limit) : 20,
|
|
567
|
+
title: item.title || (site.title ? `${humanizeSegment(name)} | ${site.title}` : humanizeSegment(name)),
|
|
568
|
+
description: item.description || site.description || '',
|
|
569
|
+
author: feedAuthorName(item.author || site.author),
|
|
570
|
+
lang: item.lang || site.lang || '',
|
|
571
|
+
content: item.content === true // full article HTML in <content:encoded>
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return specs
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Article-body HTML for a feed item's <content:encoded> / Atom <content>. Renders
|
|
579
|
+
// the page's markdown SOURCE (entry._src) with the shared marked instance — the
|
|
580
|
+
// same extensions the site uses — so it's clean article HTML, not entry.content
|
|
581
|
+
// (the whole rendered page, layout + chrome). Markdown sources only; the render
|
|
582
|
+
// cache is warm from the compile, so this is a lookup, not a re-render.
|
|
583
|
+
// ponytail: raw markdown render, so unrendered {% %} tags / shortcodes in a body
|
|
584
|
+
// leak; same ceiling as llms-full. Returns '' when there's no clean body to emit.
|
|
585
|
+
function feedContentHtml(e) {
|
|
586
|
+
if (!e._src || !/\.(md|markdown)$/i.test(e._src)) return ''
|
|
587
|
+
return renderMarkdownCached(e._src, parseFrontMatter(e._src).content).trim()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// `]]>` would close the CDATA section early; split it so the payload survives.
|
|
591
|
+
function cdata(html) {
|
|
592
|
+
return `<![CDATA[${html.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function buildRssFeed(spec, items, { selfUrl, channelLink, abs }) {
|
|
596
|
+
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
597
|
+
xml += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"'
|
|
598
|
+
xml += spec.content ? ' xmlns:content="http://purl.org/rss/1.0/modules/content/">\n' : '>\n'
|
|
599
|
+
xml += '<channel>\n'
|
|
600
|
+
xml += ` <title>${escapeXml(spec.title)}</title>\n`
|
|
601
|
+
xml += ` <link>${escapeXml(channelLink)}</link>\n`
|
|
602
|
+
if (spec.description) xml += ` <description>${escapeXml(spec.description)}</description>\n`
|
|
603
|
+
if (selfUrl) xml += ` <atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml"/>\n`
|
|
604
|
+
if (spec.lang) xml += ` <language>${escapeXml(spec.lang)}</language>\n`
|
|
605
|
+
if (items[0] && items[0].date) xml += ` <lastBuildDate>${rssDate(items[0].date)}</lastBuildDate>\n`
|
|
606
|
+
for (const e of items) {
|
|
607
|
+
const link = abs(e.url)
|
|
608
|
+
const desc = e.description || e.excerpt
|
|
609
|
+
xml += ' <item>\n'
|
|
610
|
+
xml += ` <title>${escapeXml(e.title || e.url)}</title>\n`
|
|
611
|
+
xml += ` <link>${escapeXml(link)}</link>\n`
|
|
612
|
+
xml += ` <guid isPermaLink="true">${escapeXml(link)}</guid>\n`
|
|
613
|
+
if (desc) xml += ` <description>${escapeXml(desc)}</description>\n`
|
|
614
|
+
if (e.date) xml += ` <pubDate>${rssDate(e.date)}</pubDate>\n`
|
|
615
|
+
if (spec.content) {
|
|
616
|
+
const html = feedContentHtml(e)
|
|
617
|
+
if (html) xml += ` <content:encoded>${cdata(html)}</content:encoded>\n`
|
|
618
|
+
}
|
|
619
|
+
xml += ' </item>\n'
|
|
620
|
+
}
|
|
621
|
+
xml += '</channel>\n</rss>\n'
|
|
622
|
+
return xml
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function buildAtomFeed(spec, items, { selfUrl, channelLink, abs }) {
|
|
626
|
+
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
627
|
+
xml += '<feed xmlns="http://www.w3.org/2005/Atom">\n'
|
|
628
|
+
xml += ` <title>${escapeXml(spec.title)}</title>\n`
|
|
629
|
+
if (spec.description) xml += ` <subtitle>${escapeXml(spec.description)}</subtitle>\n`
|
|
630
|
+
xml += ` <id>${escapeXml(selfUrl || channelLink)}</id>\n`
|
|
631
|
+
xml += ` <link href="${escapeXml(channelLink)}"/>\n`
|
|
632
|
+
if (selfUrl) xml += ` <link href="${escapeXml(selfUrl)}" rel="self" type="application/atom+xml"/>\n`
|
|
633
|
+
xml += ` <updated>${isoDate(items[0] && items[0].date)}</updated>\n`
|
|
634
|
+
if (spec.author) xml += ` <author><name>${escapeXml(spec.author)}</name></author>\n`
|
|
635
|
+
for (const e of items) {
|
|
636
|
+
const link = abs(e.url)
|
|
637
|
+
const desc = e.description || e.excerpt
|
|
638
|
+
const author = feedAuthorName(e.author)
|
|
639
|
+
xml += ' <entry>\n'
|
|
640
|
+
xml += ` <title>${escapeXml(e.title || e.url)}</title>\n`
|
|
641
|
+
xml += ` <link href="${escapeXml(link)}"/>\n`
|
|
642
|
+
xml += ` <id>${escapeXml(link)}</id>\n`
|
|
643
|
+
xml += ` <updated>${isoDate(e.date)}</updated>\n`
|
|
644
|
+
if (desc) xml += ` <summary>${escapeXml(desc)}</summary>\n`
|
|
645
|
+
if (author) xml += ` <author><name>${escapeXml(author)}</name></author>\n`
|
|
646
|
+
if (spec.content) {
|
|
647
|
+
const html = feedContentHtml(e)
|
|
648
|
+
if (html) xml += ` <content type="html">${cdata(html)}</content>\n`
|
|
649
|
+
}
|
|
650
|
+
xml += ' </entry>\n'
|
|
651
|
+
}
|
|
652
|
+
xml += '</feed>\n'
|
|
653
|
+
return xml
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Generates RSS/Atom feeds from collections — one file per resolved spec, its
|
|
657
|
+
// items the collection's posts newest-first (by date), capped at `limit`.
|
|
658
|
+
// Items carry description/excerpt by default; `content: true` on a spec adds
|
|
659
|
+
// full article HTML (<content:encoded> / Atom <content>) via feedContentHtml,
|
|
660
|
+
// which renders the markdown source rather than reusing the whole-page HTML.
|
|
661
|
+
export function generateFeeds(pageEntries, outputDir, siteUrl, config, site = {}) {
|
|
662
|
+
if (!config) return
|
|
663
|
+
|
|
664
|
+
// Distinct collections present, first-seen order.
|
|
665
|
+
const collectionNames = []
|
|
666
|
+
const byCollection = new Map()
|
|
667
|
+
for (const e of pageEntries) {
|
|
668
|
+
if (e.isIndex || e.collection == null) continue
|
|
669
|
+
if (!byCollection.has(e.collection)) {
|
|
670
|
+
byCollection.set(e.collection, [])
|
|
671
|
+
collectionNames.push(e.collection)
|
|
672
|
+
}
|
|
673
|
+
byCollection.get(e.collection).push(e)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
|
|
677
|
+
const abs = (u) => {
|
|
678
|
+
const rel = String(u == null ? '' : u).replace(/^\/+/, '')
|
|
679
|
+
if (/^https?:\/\//i.test(u)) return u
|
|
680
|
+
return baseUrl ? (rel ? `${baseUrl}/${rel}` : baseUrl) : `/${rel}`
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
for (const spec of resolveFeedSpecs(config, collectionNames, site)) {
|
|
684
|
+
const items = (byCollection.get(spec.collection) || [])
|
|
685
|
+
.filter(e => !isNoindex(e))
|
|
686
|
+
.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
|
|
687
|
+
.slice(0, spec.limit)
|
|
688
|
+
|
|
689
|
+
const ctx = { selfUrl: abs(spec.output), channelLink: abs(spec.collection), abs }
|
|
690
|
+
const xml = spec.type === 'atom' ? buildAtomFeed(spec, items, ctx) : buildRssFeed(spec, items, ctx)
|
|
691
|
+
|
|
692
|
+
const outputPath = path.resolve(process.cwd(), outputDir, spec.output)
|
|
693
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
694
|
+
fs.writeFileSync(outputPath, xml)
|
|
695
|
+
log({ tag: 'indexer', text: 'Generated feed:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
336
699
|
export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
|
|
337
700
|
generateSearchIndex(pageEntries, outputDir, config.searchIndex)
|
|
338
701
|
generateSitemap(pageEntries, outputDir, siteUrl, config.sitemap)
|
|
702
|
+
generateLlmsTxt(pageEntries, outputDir, siteUrl, config.llms)
|
|
703
|
+
generateLlmsFull(pageEntries, outputDir, siteUrl, config.llms)
|
|
704
|
+
const sitemapOutput = config.sitemap
|
|
705
|
+
? (typeof config.sitemap === 'string' ? config.sitemap : config.sitemap.output)
|
|
706
|
+
: null
|
|
707
|
+
generateRobotsTxt(outputDir, siteUrl, config.robots, sitemapOutput)
|
|
339
708
|
generateNav(pageEntries, outputDir, config.nav)
|
|
709
|
+
generateFeeds(pageEntries, outputDir, siteUrl, config.feed, config.site)
|
|
340
710
|
}
|
package/lib/markups.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
|
|
2
|
-
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './markup/helpers.js'
|
|
2
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './markup/helpers.js'
|
|
3
3
|
import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
|
|
4
4
|
import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
|
|
5
5
|
import NunjucksEngine from './markup/engines/nunjucks.js'
|
|
@@ -37,6 +37,9 @@ export default class Markups {
|
|
|
37
37
|
this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
|
|
38
38
|
this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
|
|
39
39
|
this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
|
|
40
|
+
this.llmsConfig = moduleConfig.options.llms || moduleConfig.llms
|
|
41
|
+
this.robotsConfig = moduleConfig.options.robots || moduleConfig.robots
|
|
42
|
+
this.feedConfig = moduleConfig.options.feed || moduleConfig.feed
|
|
40
43
|
this.navConfig = moduleConfig.options.nav || moduleConfig.nav
|
|
41
44
|
this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
|
|
42
45
|
|
|
@@ -107,6 +110,16 @@ export default class Markups {
|
|
|
107
110
|
this.loadDataFiles(this.dataConfig)
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
// Normalizes the llms config (string shorthand or object) and fills in the
|
|
114
|
+
// title/description from site data so generateLlmsTxt gets ready values.
|
|
115
|
+
resolveLlmsConfig() {
|
|
116
|
+
if (!this.llmsConfig) return undefined
|
|
117
|
+
const base = typeof this.llmsConfig === 'string' ? { output: this.llmsConfig } : { ...this.llmsConfig }
|
|
118
|
+
base.title = base.title || this.siteData.title
|
|
119
|
+
base.description = base.description || this.siteData.description
|
|
120
|
+
return base
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
loadDataFiles(files) {
|
|
111
124
|
if (!files) return
|
|
112
125
|
|
|
@@ -225,6 +238,7 @@ export default class Markups {
|
|
|
225
238
|
context.page = frontMatterResult.frontMatter
|
|
226
239
|
context.page.content = frontMatterResult.content
|
|
227
240
|
context.page.wordcount = wordcount(frontMatterResult.content)
|
|
241
|
+
context.page.excerpt = excerpt(frontMatterResult.content)
|
|
228
242
|
} catch (err) {
|
|
229
243
|
log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
|
|
230
244
|
console.error(err)
|
|
@@ -502,7 +516,7 @@ export default class Markups {
|
|
|
502
516
|
...getCollectionDataBasedOnConfig(this.markupIn, this.collectionsConfig)
|
|
503
517
|
}
|
|
504
518
|
|
|
505
|
-
const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig
|
|
519
|
+
const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig || this.feedConfig
|
|
506
520
|
const pageEntries = shouldIndex ? [] : null
|
|
507
521
|
|
|
508
522
|
buildCollectionPaginationData(collectionData)
|
|
@@ -541,7 +555,11 @@ export default class Markups {
|
|
|
541
555
|
generateIndexFiles(pageEntries, this.markupOut, this.siteData.url, {
|
|
542
556
|
searchIndex: this.searchIndexConfig,
|
|
543
557
|
sitemap: this.sitemapConfig,
|
|
544
|
-
|
|
558
|
+
llms: this.resolveLlmsConfig(),
|
|
559
|
+
robots: this.robotsConfig,
|
|
560
|
+
nav: this.navConfig,
|
|
561
|
+
feed: this.feedConfig,
|
|
562
|
+
site: this.siteData
|
|
545
563
|
})
|
|
546
564
|
}
|
|
547
565
|
|
|
@@ -551,6 +569,20 @@ export default class Markups {
|
|
|
551
569
|
this.lastPageEntries = pageEntries
|
|
552
570
|
}
|
|
553
571
|
|
|
572
|
+
// Watch hook for .json/.yaml changes. Data extensions are ambiguous: a
|
|
573
|
+
// .json may be a data file feeding the globals, or engine-owned markup
|
|
574
|
+
// (Shopify templates/*.json, section-group JSON). Engines that can tell
|
|
575
|
+
// claim theirs via isMarkupSource() and get the incremental path;
|
|
576
|
+
// everything else reloads data globals and full-compiles, as before.
|
|
577
|
+
async compileDataChange(file) {
|
|
578
|
+
await this.init()
|
|
579
|
+
if (this.engine && typeof this.engine.isMarkupSource === 'function' &&
|
|
580
|
+
this.engine.isMarkupSource(path.resolve(process.cwd(), file))) {
|
|
581
|
+
return this.compileIncremental(file)
|
|
582
|
+
}
|
|
583
|
+
return this.reloadDataFiles().then(() => this.compile())
|
|
584
|
+
}
|
|
585
|
+
|
|
554
586
|
// Watch-mode entry point: rebuild only the pages whose last render touched
|
|
555
587
|
// the changed file. Falls back to a full compile() whenever the result
|
|
556
588
|
// isn't provably identical to a full build — unsupported engine, no prior
|
|
@@ -593,6 +625,14 @@ export default class Markups {
|
|
|
593
625
|
if (this.isCollectionIndexOverride(rel.split(path.sep), collectionData)) return this.compile()
|
|
594
626
|
}
|
|
595
627
|
|
|
628
|
+
// A page's own dep set always contains itself, so an edited page is in
|
|
629
|
+
// `affected` (exact match, no glob needed). If it isn't, but the file IS a
|
|
630
|
+
// page source, this is a new page whose basename collides with a recorded
|
|
631
|
+
// dep — rendering only the collided pages would leave it unbuilt.
|
|
632
|
+
if (!affected.includes(abs) && this.getMarkupFiles(markupIn).some((f) => path.resolve(f) === abs)) {
|
|
633
|
+
return this.compile()
|
|
634
|
+
}
|
|
635
|
+
|
|
596
636
|
const markupStart = performance.now()
|
|
597
637
|
const newEntries = this.lastPageEntries ? [] : null
|
|
598
638
|
|
|
@@ -628,7 +668,11 @@ export default class Markups {
|
|
|
628
668
|
generateIndexFiles(this.lastPageEntries, this.markupOut, this.siteData.url, {
|
|
629
669
|
searchIndex: this.searchIndexConfig,
|
|
630
670
|
sitemap: this.sitemapConfig,
|
|
631
|
-
|
|
671
|
+
llms: this.resolveLlmsConfig(),
|
|
672
|
+
robots: this.robotsConfig,
|
|
673
|
+
nav: this.navConfig,
|
|
674
|
+
feed: this.feedConfig,
|
|
675
|
+
site: this.siteData
|
|
632
676
|
})
|
|
633
677
|
}
|
|
634
678
|
|
|
@@ -640,6 +684,6 @@ export default class Markups {
|
|
|
640
684
|
// Everything except the fields that change on every content edit — used to
|
|
641
685
|
// detect front matter drift that would invalidate the shared nav global
|
|
642
686
|
function entrySignature(entry) {
|
|
643
|
-
const { content, wordcount, ...rest } = entry
|
|
687
|
+
const { content, wordcount, excerpt, ...rest } = entry
|
|
644
688
|
return JSON.stringify(rest)
|
|
645
689
|
}
|
package/package.json
CHANGED
package/poops.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import chokidar from 'chokidar'
|
|
4
4
|
import connect from 'connect'
|
|
5
5
|
import Copy from './lib/copy.js'
|
|
6
|
+
import runExec from './lib/exec.js'
|
|
6
7
|
import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs } from './lib/utils/helpers.js'
|
|
7
8
|
import http from 'node:http'
|
|
8
9
|
import os from 'node:os'
|
|
@@ -98,6 +99,10 @@ function styleOutputs(config) {
|
|
|
98
99
|
: path.join(entry.out, path.basename(entry.in).replace(/\.(sass|scss)$/i, '.css'))))
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Per-stage shell hooks (config.exec). Runs after a stage compiles in both
|
|
103
|
+
// build and watch; see lib/exec.js. `hook(stage)` binds config + cwd here.
|
|
104
|
+
const hook = (stage) => runExec(config, cwd, stage)
|
|
105
|
+
|
|
101
106
|
function setupLiveReloadServer(config) {
|
|
102
107
|
if (!config.livereload) return
|
|
103
108
|
liveReloadServer = livereload.createServer({ port: config.livereload_port })
|
|
@@ -132,31 +137,33 @@ function setupWatchers(config, modules) {
|
|
|
132
137
|
// exactly this file's entries; shared by change/add/unlink via rebuild.
|
|
133
138
|
modules.markups.invalidate(file)
|
|
134
139
|
if (/(\.m?jsx?|\.tsx?)$/i.test(file) && !isBuildOutput(file)) {
|
|
135
|
-
modules.scripts.compile().then(() => reload()).catch(err => console.error(err))
|
|
140
|
+
modules.scripts.compile().then(() => { hook('scripts'); reload() }).catch(err => console.error(err))
|
|
136
141
|
|
|
137
142
|
if (modules.reactor.belongsToReactor(file)) {
|
|
138
143
|
modules.reactor.compile().then(() => {
|
|
139
144
|
if (modules.reactor.renderedChanged) {
|
|
140
145
|
config.reactorData = modules.reactor.getRendered()
|
|
141
|
-
modules.markups.compile().then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
|
|
146
|
+
modules.markups.compile().then(() => modules.postcss.compile()).then(() => { hook('markup'); reload() }).catch(err => console.error(err))
|
|
142
147
|
}
|
|
143
148
|
}).catch(err => console.error(err))
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
if (/(\.sass|\.scss|\.css)$/i.test(file) && !isBuildOutput(file)) {
|
|
147
152
|
modules.styles.compile().then(() => modules.postcss.compile())
|
|
148
|
-
.then(() => styleOutputs(config).forEach((out) => reload(out)))
|
|
153
|
+
.then(() => { hook('styles'); styleOutputs(config).forEach((out) => reload(out)) })
|
|
149
154
|
.catch(err => console.error(err))
|
|
150
155
|
}
|
|
151
156
|
if (/(\.html|\.xml|\.rss|\.atom|\.njk|\.liquid|\.md)$/i.test(file)) {
|
|
152
157
|
// Incremental: re-render only the pages whose last render touched this
|
|
153
158
|
// file; falls back to a full compile for anything it can't prove safe
|
|
154
159
|
// (deletions, new files, collection members, engines without dep info).
|
|
155
|
-
modules.markups.compileIncremental(file).then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
|
|
160
|
+
modules.markups.compileIncremental(file).then(() => modules.postcss.compile()).then(() => { hook('markup'); reload() }).catch(err => console.error(err))
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
if (/(\.json|\.ya?ml)$/i.test(file)) {
|
|
159
|
-
|
|
164
|
+
// Engine-owned markup with a data extension (Shopify templates/*.json)
|
|
165
|
+
// goes incremental; real data files reload globals + full compile.
|
|
166
|
+
modules.markups.compileDataChange(file).then(() => { hook('markup'); reload() }).catch(err => console.error(err))
|
|
160
167
|
}
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -172,13 +179,13 @@ function setupWatchers(config, modules) {
|
|
|
172
179
|
modules.images.compile()
|
|
173
180
|
.then(() => modules.markups.compile())
|
|
174
181
|
.then(() => modules.postcss.compile())
|
|
175
|
-
.then(() => reload())
|
|
182
|
+
.then(() => { hook('images'); hook('markup'); reload() })
|
|
176
183
|
.catch(err => console.error(err))
|
|
177
184
|
}
|
|
178
185
|
// A copied .css (e.g. the styles compiler's own output landing in a copy
|
|
179
186
|
// source) stays a hot-swap; any other copied file needs a full reload.
|
|
180
187
|
doesFileBelongToPath(file, config.copy) && modules.copy.execute()
|
|
181
|
-
.then(() => reload(/\.css$/i.test(file) ? file : undefined))
|
|
188
|
+
.then(() => { hook('copy'); reload(/\.css$/i.test(file) ? file : undefined) })
|
|
182
189
|
.catch(err => console.error(err))
|
|
183
190
|
}
|
|
184
191
|
|
|
@@ -205,7 +212,7 @@ function setupWatchers(config, modules) {
|
|
|
205
212
|
modules.images.remove(file)
|
|
206
213
|
.then(() => modules.markups.compile())
|
|
207
214
|
.then(() => modules.postcss.compile())
|
|
208
|
-
.then(() => reload())
|
|
215
|
+
.then(() => { hook('images'); hook('markup'); reload() })
|
|
209
216
|
.catch(err => console.error(err))
|
|
210
217
|
}
|
|
211
218
|
rebuild(file)
|
|
@@ -216,7 +223,7 @@ function setupWatchers(config, modules) {
|
|
|
216
223
|
modules.markups.invalidate(dirPath) // prefix match drops every template under it
|
|
217
224
|
modules.markups.removeOutput(dirPath)
|
|
218
225
|
if (doesFileBelongToPath(dirPath, config.markup)) {
|
|
219
|
-
modules.markups.compile().then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
|
|
226
|
+
modules.markups.compile().then(() => modules.postcss.compile()).then(() => { hook('markup'); reload() }).catch(err => console.error(err))
|
|
220
227
|
}
|
|
221
228
|
modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
|
|
222
229
|
}
|
|
@@ -257,12 +264,19 @@ async function poops() {
|
|
|
257
264
|
|
|
258
265
|
await step(() => styles.compile())
|
|
259
266
|
await step(() => reactor.compile())
|
|
267
|
+
failed = hook('reactor') || failed
|
|
260
268
|
config.reactorData = reactor.getRendered()
|
|
261
269
|
await step(() => scripts.compile())
|
|
270
|
+
failed = hook('scripts') || failed
|
|
262
271
|
await step(() => images.compile()) // before markups: engines read the poops-images cache
|
|
272
|
+
failed = hook('images') || failed
|
|
263
273
|
await step(() => markups.compile())
|
|
274
|
+
failed = hook('markup') || failed
|
|
264
275
|
await step(() => postcss.compile())
|
|
276
|
+
failed = hook('styles') || failed // after PostCSS so the CSS is final
|
|
265
277
|
await step(() => copy.execute())
|
|
278
|
+
failed = hook('copy') || failed
|
|
279
|
+
failed = hook('build') || failed
|
|
266
280
|
|
|
267
281
|
if (build || (!config.watch && !config.livereload && !config.serve)) {
|
|
268
282
|
process.exit(failed || hasLoggedErrors() ? 1 : 0)
|