poops 1.5.3 → 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 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
- Pages with `published: false` in their front matter are excluded from both outputs.
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)
@@ -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 &amp; 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
+ }
@@ -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
- nav: this.navConfig
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
 
@@ -650,7 +668,11 @@ export default class Markups {
650
668
  generateIndexFiles(this.lastPageEntries, this.markupOut, this.siteData.url, {
651
669
  searchIndex: this.searchIndexConfig,
652
670
  sitemap: this.sitemapConfig,
653
- nav: this.navConfig
671
+ llms: this.resolveLlmsConfig(),
672
+ robots: this.robotsConfig,
673
+ nav: this.navConfig,
674
+ feed: this.feedConfig,
675
+ site: this.siteData
654
676
  })
655
677
  }
656
678
 
@@ -662,6 +684,6 @@ export default class Markups {
662
684
  // Everything except the fields that change on every content edit — used to
663
685
  // detect front matter drift that would invalidate the shared nav global
664
686
  function entrySignature(entry) {
665
- const { content, wordcount, ...rest } = entry
687
+ const { content, wordcount, excerpt, ...rest } = entry
666
688
  return JSON.stringify(rest)
667
689
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poops",
3
3
  "description": "Straightforward, no-bullshit bundler for the web.",
4
- "version": "1.5.3",
4
+ "version": "1.6.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
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,33 +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)
160
165
  // goes incremental; real data files reload globals + full compile.
161
- modules.markups.compileDataChange(file).then(() => reload()).catch(err => console.error(err))
166
+ modules.markups.compileDataChange(file).then(() => { hook('markup'); reload() }).catch(err => console.error(err))
162
167
  }
163
168
  }
164
169
 
@@ -174,13 +179,13 @@ function setupWatchers(config, modules) {
174
179
  modules.images.compile()
175
180
  .then(() => modules.markups.compile())
176
181
  .then(() => modules.postcss.compile())
177
- .then(() => reload())
182
+ .then(() => { hook('images'); hook('markup'); reload() })
178
183
  .catch(err => console.error(err))
179
184
  }
180
185
  // A copied .css (e.g. the styles compiler's own output landing in a copy
181
186
  // source) stays a hot-swap; any other copied file needs a full reload.
182
187
  doesFileBelongToPath(file, config.copy) && modules.copy.execute()
183
- .then(() => reload(/\.css$/i.test(file) ? file : undefined))
188
+ .then(() => { hook('copy'); reload(/\.css$/i.test(file) ? file : undefined) })
184
189
  .catch(err => console.error(err))
185
190
  }
186
191
 
@@ -207,7 +212,7 @@ function setupWatchers(config, modules) {
207
212
  modules.images.remove(file)
208
213
  .then(() => modules.markups.compile())
209
214
  .then(() => modules.postcss.compile())
210
- .then(() => reload())
215
+ .then(() => { hook('images'); hook('markup'); reload() })
211
216
  .catch(err => console.error(err))
212
217
  }
213
218
  rebuild(file)
@@ -218,7 +223,7 @@ function setupWatchers(config, modules) {
218
223
  modules.markups.invalidate(dirPath) // prefix match drops every template under it
219
224
  modules.markups.removeOutput(dirPath)
220
225
  if (doesFileBelongToPath(dirPath, config.markup)) {
221
- 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))
222
227
  }
223
228
  modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
224
229
  }
@@ -259,12 +264,19 @@ async function poops() {
259
264
 
260
265
  await step(() => styles.compile())
261
266
  await step(() => reactor.compile())
267
+ failed = hook('reactor') || failed
262
268
  config.reactorData = reactor.getRendered()
263
269
  await step(() => scripts.compile())
270
+ failed = hook('scripts') || failed
264
271
  await step(() => images.compile()) // before markups: engines read the poops-images cache
272
+ failed = hook('images') || failed
265
273
  await step(() => markups.compile())
274
+ failed = hook('markup') || failed
266
275
  await step(() => postcss.compile())
276
+ failed = hook('styles') || failed // after PostCSS so the CSS is final
267
277
  await step(() => copy.execute())
278
+ failed = hook('copy') || failed
279
+ failed = hook('build') || failed
268
280
 
269
281
  if (build || (!config.watch && !config.livereload && !config.serve)) {
270
282
  process.exit(failed || hasLoggedErrors() ? 1 : 0)