poops 1.5.3 → 1.7.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.
@@ -2,6 +2,9 @@ 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'
7
+ import { humanize } from 'book-of-spells'
5
8
  import log from '../utils/log.js'
6
9
 
7
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -37,6 +40,12 @@ function normalizeConfig(config) {
37
40
  return { ...DEFAULTS, ...config }
38
41
  }
39
42
 
43
+ // A page front-matter `robots: noindex` (or `none`) opts it out of the
44
+ // crawler/discovery artifacts — the sitemap and llms.txt.
45
+ function isNoindex(entry) {
46
+ return entry.robots ? /\b(noindex|none)\b/i.test(String(entry.robots)) : false
47
+ }
48
+
40
49
  export function extractKeywords(htmlContent, options = {}) {
41
50
  const { minWordLength = DEFAULTS.minWordLength, stopWords = new Set() } = options
42
51
 
@@ -163,6 +172,7 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
163
172
  xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
164
173
 
165
174
  for (const entry of pageEntries) {
175
+ if (isNoindex(entry)) continue
166
176
  const loc = baseUrl ? `${baseUrl}/${entry.url}` : entry.url
167
177
  xml += ' <url>\n'
168
178
  xml += ` <loc>${escapeXml(loc)}</loc>\n`
@@ -182,12 +192,185 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
182
192
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
183
193
  }
184
194
 
185
- function humanizeSegment(seg) {
186
- return seg
187
- .replace(/[-_]+/g, ' ')
188
- .replace(/\s+/g, ' ')
189
- .trim()
190
- .replace(/\b\w/g, c => c.toUpperCase())
195
+ // Generates an llms.txt (https://llmstxt.org): a markdown index of the site's
196
+ // pages that LLMs / generative engines ingest to understand the site (GEO).
197
+ // Structure: `# title`, a `> description` blockquote, then one `## section` per
198
+ // collection (uncollected pages go under a lead "Pages" section) with
199
+ // `- [title](absolute url): description` links. `title`/`description` are
200
+ // resolved from site data by the caller. Mirrors generateSitemap's page set —
201
+ // isIndex (collection landing/pagination) pages are skipped.
202
+ export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
203
+ config = normalizeConfig(config)
204
+ if (!config) return
205
+
206
+ const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
207
+ const absUrl = (url) => baseUrl ? `${baseUrl}/${url}` : url
208
+
209
+ let out = `# ${config.title || 'Site'}\n`
210
+ if (config.description) out += `\n> ${config.description}\n`
211
+
212
+ // Optional free-form body (llmstxt.org allows markdown between the blockquote
213
+ // and the link sections). `intro` is a path to a markdown file authored for
214
+ // LLM context — inserted verbatim. Avoid H2s in it; they'd read as sections.
215
+ if (config.intro) {
216
+ try {
217
+ const body = fs.readFileSync(path.resolve(process.cwd(), config.intro), 'utf-8').trim()
218
+ if (body) out += `\n${body}\n`
219
+ } catch {
220
+ log({ tag: 'indexer', warn: true, text: 'llms.txt intro file not found:', link: config.intro })
221
+ }
222
+ }
223
+
224
+ // Group into two levels from the URL path, first-seen order preserved: first
225
+ // folder = `## section`, second folder = `### subsection` nested under it,
226
+ // root-level pages under the lead section. A collection's items already live
227
+ // under `collectionName/…`, so the folder segments double as its grouping.
228
+ const lead = config.sectionTitle || 'Pages'
229
+ const sections = new Map() // name -> { direct: [entry], subs: Map<name, [entry]> }
230
+ const sectionOf = (name) => {
231
+ if (!sections.has(name)) sections.set(name, { direct: [], subs: new Map() })
232
+ return sections.get(name)
233
+ }
234
+
235
+ for (const e of pageEntries) {
236
+ if (e.isIndex || isNoindex(e)) continue
237
+ const slash = e.url.lastIndexOf('/')
238
+ const segs = slash === -1 ? [] : e.url.slice(0, slash).split('/').filter(Boolean)
239
+ if (segs.length === 0) {
240
+ sectionOf(lead).direct.push(e)
241
+ } else if (segs.length === 1) {
242
+ sectionOf(humanize(segs[0])).direct.push(e)
243
+ } else {
244
+ const subs = sectionOf(humanize(segs[0])).subs
245
+ const key = humanize(segs[1])
246
+ if (!subs.has(key)) subs.set(key, [])
247
+ subs.get(key).push(e)
248
+ }
249
+ }
250
+
251
+ const linkLine = (e) => {
252
+ const link = `[${e.title || e.url}](${absUrl(e.url)})`
253
+ return e.description ? `- ${link}: ${e.description}\n` : `- ${link}\n`
254
+ }
255
+
256
+ // Collection buckets are chronological (newest first); other sections (docs)
257
+ // keep their file/order sequence.
258
+ const ordered = (entries) => {
259
+ if (!entries.length || !entries.every(e => e.collection != null)) return entries
260
+ return [...entries].sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
261
+ }
262
+
263
+ for (const [name, group] of sections) {
264
+ out += `\n## ${name}\n\n`
265
+ for (const e of ordered(group.direct)) out += linkLine(e)
266
+ for (const [subName, entries] of group.subs) {
267
+ out += `\n### ${subName}\n\n`
268
+ for (const e of ordered(entries)) out += linkLine(e)
269
+ }
270
+ }
271
+
272
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
273
+ fs.writeFileSync(outputPath, out)
274
+ log({ tag: 'indexer', text: 'Generated llms.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
275
+ }
276
+
277
+ // Generates llms-full.txt: every page's full content concatenated, the
278
+ // companion to llms.txt's link index (llmstxt.org). Enabled by `llms.full`
279
+ // (string path, or `true` → the index filename with a `-full` suffix). The content comes from each
280
+ // page's markdown SOURCE (entry._src), not entry.content — that's the rendered
281
+ // HTML page (layout + chrome). Only markdown sources qualify: an .njk/.liquid
282
+ // source is template code, not prose.
283
+ // ponytail: raw markdown, so unrendered {% %} tags / shortcodes in a body leak
284
+ // through verbatim. Upgrade path if that bites: render + HTML→markdown the body.
285
+ export function generateLlmsFull(pageEntries, outputDir, siteUrl, config) {
286
+ config = normalizeConfig(config)
287
+ if (!config || !config.full) return
288
+ // Default filename pairs with the index: llms.txt → llms-full.txt, ai.txt →
289
+ // ai-full.txt (suffix the index's `output`, dir/ext preserved). A string
290
+ // `full` overrides the path outright.
291
+ let output = config.full
292
+ if (typeof output !== 'string') {
293
+ const p = path.parse(config.output || 'llms.txt')
294
+ output = path.join(p.dir, `${p.name}-full${p.ext}`)
295
+ }
296
+
297
+ const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
298
+ const absUrl = (url) => baseUrl ? `${baseUrl}/${url}` : url
299
+ const isMarkdown = (src) => /\.(md|markdown)$/i.test(src || '')
300
+
301
+ const blocks = []
302
+ for (const e of pageEntries) {
303
+ if (e.isIndex || isNoindex(e) || !isMarkdown(e._src)) continue
304
+ const body = parseFrontMatter(e._src).content.trim()
305
+ if (!body) continue
306
+ const url = `URL: ${absUrl(e.url)}`
307
+ // If the body already opens with its own ATX H1, reuse it as the block
308
+ // heading and slot the URL beneath — otherwise the wrapper `# title` plus
309
+ // the body's H1 would emit two H1s per block (often exact duplicates).
310
+ const h1 = body.match(/^# .*(?:\r?\n|$)/)
311
+ blocks.push(h1
312
+ ? `${h1[0].trim()}\n${url}\n\n${body.slice(h1[0].length).trimStart()}`
313
+ : `# ${e.title || e.url}\n${url}\n\n${body}`)
314
+ }
315
+ if (!blocks.length) return
316
+
317
+ // Corpus header: names the file and its purpose so a whole-file ingest opens
318
+ // with context instead of the first page's H1. Reuses the index title/desc.
319
+ const name = config.title || 'this site'
320
+ let head = `# Full Documentation Archive for ${name}\n`
321
+ head += `\nThis file contains the complete Markdown documentation for ${name}.`
322
+ if (config.description) head += `\n\n> ${config.description}`
323
+ // Optional author-written preamble after the header — the full-file counterpart
324
+ // to `intro` for the index. Inserted verbatim; a missing file warns and skips.
325
+ if (config.fullIntro) {
326
+ try {
327
+ const body = fs.readFileSync(path.resolve(process.cwd(), config.fullIntro), 'utf-8').trim()
328
+ if (body) head += `\n\n${body}`
329
+ } catch {
330
+ log({ tag: 'indexer', warn: true, text: 'llms-full intro file not found:', link: config.fullIntro })
331
+ }
332
+ }
333
+ blocks.unshift(head)
334
+
335
+ const outputPath = path.resolve(process.cwd(), outputDir, output)
336
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
337
+ fs.writeFileSync(outputPath, blocks.join('\n\n---\n\n') + '\n')
338
+ log({ tag: 'indexer', text: 'Generated llms-full.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
339
+ }
340
+
341
+ // Generates a robots.txt. Defaults to allow-all with a `Sitemap:` line pointing
342
+ // at the generated sitemap (absolute, needs `site.url`). The object form takes
343
+ // `userAgent`, `allow`/`disallow` (string or array of paths) and `sitemap`
344
+ // (an explicit URL, or `false` to omit the line). `sitemapOutput` is the
345
+ // sitemap's filename, threaded in by generateIndexFiles.
346
+ // ponytail: single user-agent group; add per-agent groups if a real need shows up.
347
+ export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
348
+ config = normalizeConfig(config)
349
+ if (!config) return
350
+
351
+ const arr = (v) => v == null ? [] : (Array.isArray(v) ? v : [v])
352
+ const allow = arr(config.allow)
353
+ const disallow = arr(config.disallow)
354
+
355
+ const lines = [`User-agent: ${config.userAgent || '*'}`]
356
+ for (const p of allow) lines.push(`Allow: ${p}`)
357
+ for (const p of disallow) lines.push(`Disallow: ${p}`)
358
+ // Empty `Disallow:` is the canonical "allow everything" when nothing is set.
359
+ if (allow.length === 0 && disallow.length === 0) lines.push('Disallow:')
360
+
361
+ let sitemapUrl = null
362
+ if (config.sitemap === false) {
363
+ sitemapUrl = null
364
+ } else if (typeof config.sitemap === 'string') {
365
+ sitemapUrl = config.sitemap
366
+ } else if (sitemapOutput && siteUrl) {
367
+ sitemapUrl = `${siteUrl.replace(/\/+$/, '')}/${sitemapOutput}`
368
+ }
369
+ if (sitemapUrl) lines.push('', `Sitemap: ${sitemapUrl}`)
370
+
371
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
372
+ fs.writeFileSync(outputPath, lines.join('\n') + '\n')
373
+ log({ tag: 'indexer', text: 'Generated robots.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
191
374
  }
192
375
 
193
376
  function navNodeTitle(entry) {
@@ -211,7 +394,7 @@ function navFilterEntries(pageEntries, collectionsOpt) {
211
394
  // pagination pages (url has a slash) always dropped. Landing titles are
212
395
  // the raw collection name ("blog"), so humanize for display.
213
396
  if (mode === 'index' && !e.url.includes('/')) {
214
- result.push({ ...e, title: humanizeSegment(e.title) })
397
+ result.push({ ...e, title: humanize(e.title) })
215
398
  }
216
399
  continue
217
400
  }
@@ -264,7 +447,7 @@ function serializeNavNode(node) {
264
447
  const children = [...node.children.values()].map(serializeNavNode)
265
448
  sortNavSiblings(children)
266
449
 
267
- const out = { title: node.title != null ? node.title : humanizeSegment(node.segment) }
450
+ const out = { title: node.title != null ? node.title : humanize(node.segment) }
268
451
  if (node.url != null) out.url = node.url
269
452
 
270
453
  let order = node.order
@@ -333,8 +516,188 @@ export function generateNav(pageEntries, outputDir, config) {
333
516
  log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
334
517
  }
335
518
 
519
+ // RFC-822 date for RSS pubDate/lastBuildDate (toUTCString is RFC-822 compliant).
520
+ function rssDate(d) {
521
+ const date = new Date(d)
522
+ return isNaN(date) ? '' : date.toUTCString()
523
+ }
524
+
525
+ // ISO-8601 (RFC-3339) date for Atom updated timestamps.
526
+ function isoDate(d) {
527
+ const date = new Date(d)
528
+ return isNaN(date) ? new Date().toISOString() : date.toISOString()
529
+ }
530
+
531
+ function feedAuthorName(author) {
532
+ if (!author) return ''
533
+ return String(author.name || author)
534
+ }
535
+
536
+ // Normalizes the `feed` option into concrete per-collection specs. Accepts:
537
+ // true | "feed.xml" → RSS for every collection
538
+ // { output, type, limit, … } → those options for every collection
539
+ // { collection, … } → one named collection
540
+ // [ {…}, {…} ] → an explicit list
541
+ // `output` containing a slash is a full path under the output dir; a bare
542
+ // filename is placed in the collection's own folder (default "feed.xml").
543
+ function resolveFeedSpecs(config, collectionNames, site) {
544
+ if (!config) return []
545
+ const raw = Array.isArray(config) ? config : [config]
546
+ const specs = []
547
+ for (let item of raw) {
548
+ if (item === true) item = {}
549
+ else if (typeof item === 'string') item = { output: item }
550
+ if (!item || typeof item !== 'object') continue
551
+
552
+ const targets = item.collection ? [item.collection] : collectionNames
553
+ for (const name of targets) {
554
+ const outName = item.output || 'feed.xml'
555
+ specs.push({
556
+ collection: name,
557
+ output: outName.includes('/') ? outName : `${name}/${outName}`,
558
+ type: item.type === 'atom' ? 'atom' : 'rss',
559
+ limit: Number(item.limit) > 0 ? Number(item.limit) : 20,
560
+ title: item.title || (site.title ? `${humanize(name)} | ${site.title}` : humanize(name)),
561
+ description: item.description || site.description || '',
562
+ author: feedAuthorName(item.author || site.author),
563
+ lang: item.lang || site.lang || '',
564
+ content: item.content === true // full article HTML in <content:encoded>
565
+ })
566
+ }
567
+ }
568
+ return specs
569
+ }
570
+
571
+ // Article-body HTML for a feed item's <content:encoded> / Atom <content>. Renders
572
+ // the page's markdown SOURCE (entry._src) with the shared marked instance — the
573
+ // same extensions the site uses — so it's clean article HTML, not entry.content
574
+ // (the whole rendered page, layout + chrome). Markdown sources only; the render
575
+ // cache is warm from the compile, so this is a lookup, not a re-render.
576
+ // ponytail: raw markdown render, so unrendered {% %} tags / shortcodes in a body
577
+ // leak; same ceiling as llms-full. Returns '' when there's no clean body to emit.
578
+ function feedContentHtml(e) {
579
+ if (!e._src || !/\.(md|markdown)$/i.test(e._src)) return ''
580
+ return renderMarkdownCached(e._src, parseFrontMatter(e._src).content).trim()
581
+ }
582
+
583
+ // `]]>` would close the CDATA section early; split it so the payload survives.
584
+ function cdata(html) {
585
+ return `<![CDATA[${html.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`
586
+ }
587
+
588
+ function buildRssFeed(spec, items, { selfUrl, channelLink, abs }) {
589
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
590
+ xml += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"'
591
+ xml += spec.content ? ' xmlns:content="http://purl.org/rss/1.0/modules/content/">\n' : '>\n'
592
+ xml += '<channel>\n'
593
+ xml += ` <title>${escapeXml(spec.title)}</title>\n`
594
+ xml += ` <link>${escapeXml(channelLink)}</link>\n`
595
+ if (spec.description) xml += ` <description>${escapeXml(spec.description)}</description>\n`
596
+ if (selfUrl) xml += ` <atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml"/>\n`
597
+ if (spec.lang) xml += ` <language>${escapeXml(spec.lang)}</language>\n`
598
+ if (items[0] && items[0].date) xml += ` <lastBuildDate>${rssDate(items[0].date)}</lastBuildDate>\n`
599
+ for (const e of items) {
600
+ const link = abs(e.url)
601
+ const desc = e.description || e.excerpt
602
+ xml += ' <item>\n'
603
+ xml += ` <title>${escapeXml(e.title || e.url)}</title>\n`
604
+ xml += ` <link>${escapeXml(link)}</link>\n`
605
+ xml += ` <guid isPermaLink="true">${escapeXml(link)}</guid>\n`
606
+ if (desc) xml += ` <description>${escapeXml(desc)}</description>\n`
607
+ if (e.date) xml += ` <pubDate>${rssDate(e.date)}</pubDate>\n`
608
+ if (spec.content) {
609
+ const html = feedContentHtml(e)
610
+ if (html) xml += ` <content:encoded>${cdata(html)}</content:encoded>\n`
611
+ }
612
+ xml += ' </item>\n'
613
+ }
614
+ xml += '</channel>\n</rss>\n'
615
+ return xml
616
+ }
617
+
618
+ function buildAtomFeed(spec, items, { selfUrl, channelLink, abs }) {
619
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
620
+ xml += '<feed xmlns="http://www.w3.org/2005/Atom">\n'
621
+ xml += ` <title>${escapeXml(spec.title)}</title>\n`
622
+ if (spec.description) xml += ` <subtitle>${escapeXml(spec.description)}</subtitle>\n`
623
+ xml += ` <id>${escapeXml(selfUrl || channelLink)}</id>\n`
624
+ xml += ` <link href="${escapeXml(channelLink)}"/>\n`
625
+ if (selfUrl) xml += ` <link href="${escapeXml(selfUrl)}" rel="self" type="application/atom+xml"/>\n`
626
+ xml += ` <updated>${isoDate(items[0] && items[0].date)}</updated>\n`
627
+ if (spec.author) xml += ` <author><name>${escapeXml(spec.author)}</name></author>\n`
628
+ for (const e of items) {
629
+ const link = abs(e.url)
630
+ const desc = e.description || e.excerpt
631
+ const author = feedAuthorName(e.author)
632
+ xml += ' <entry>\n'
633
+ xml += ` <title>${escapeXml(e.title || e.url)}</title>\n`
634
+ xml += ` <link href="${escapeXml(link)}"/>\n`
635
+ xml += ` <id>${escapeXml(link)}</id>\n`
636
+ xml += ` <updated>${isoDate(e.date)}</updated>\n`
637
+ if (desc) xml += ` <summary>${escapeXml(desc)}</summary>\n`
638
+ if (author) xml += ` <author><name>${escapeXml(author)}</name></author>\n`
639
+ if (spec.content) {
640
+ const html = feedContentHtml(e)
641
+ if (html) xml += ` <content type="html">${cdata(html)}</content>\n`
642
+ }
643
+ xml += ' </entry>\n'
644
+ }
645
+ xml += '</feed>\n'
646
+ return xml
647
+ }
648
+
649
+ // Generates RSS/Atom feeds from collections — one file per resolved spec, its
650
+ // items the collection's posts newest-first (by date), capped at `limit`.
651
+ // Items carry description/excerpt by default; `content: true` on a spec adds
652
+ // full article HTML (<content:encoded> / Atom <content>) via feedContentHtml,
653
+ // which renders the markdown source rather than reusing the whole-page HTML.
654
+ export function generateFeeds(pageEntries, outputDir, siteUrl, config, site = {}) {
655
+ if (!config) return
656
+
657
+ // Distinct collections present, first-seen order.
658
+ const collectionNames = []
659
+ const byCollection = new Map()
660
+ for (const e of pageEntries) {
661
+ if (e.isIndex || e.collection == null) continue
662
+ if (!byCollection.has(e.collection)) {
663
+ byCollection.set(e.collection, [])
664
+ collectionNames.push(e.collection)
665
+ }
666
+ byCollection.get(e.collection).push(e)
667
+ }
668
+
669
+ const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
670
+ const abs = (u) => {
671
+ const rel = String(u == null ? '' : u).replace(/^\/+/, '')
672
+ if (/^https?:\/\//i.test(u)) return u
673
+ return baseUrl ? (rel ? `${baseUrl}/${rel}` : baseUrl) : `/${rel}`
674
+ }
675
+
676
+ for (const spec of resolveFeedSpecs(config, collectionNames, site)) {
677
+ const items = (byCollection.get(spec.collection) || [])
678
+ .filter(e => !isNoindex(e))
679
+ .sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
680
+ .slice(0, spec.limit)
681
+
682
+ const ctx = { selfUrl: abs(spec.output), channelLink: abs(spec.collection), abs }
683
+ const xml = spec.type === 'atom' ? buildAtomFeed(spec, items, ctx) : buildRssFeed(spec, items, ctx)
684
+
685
+ const outputPath = path.resolve(process.cwd(), outputDir, spec.output)
686
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
687
+ fs.writeFileSync(outputPath, xml)
688
+ log({ tag: 'indexer', text: 'Generated feed:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
689
+ }
690
+ }
691
+
336
692
  export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
337
693
  generateSearchIndex(pageEntries, outputDir, config.searchIndex)
338
694
  generateSitemap(pageEntries, outputDir, siteUrl, config.sitemap)
695
+ generateLlmsTxt(pageEntries, outputDir, siteUrl, config.llms)
696
+ generateLlmsFull(pageEntries, outputDir, siteUrl, config.llms)
697
+ const sitemapOutput = config.sitemap
698
+ ? (typeof config.sitemap === 'string' ? config.sitemap : config.sitemap.output)
699
+ : null
700
+ generateRobotsTxt(outputDir, siteUrl, config.robots, sitemapOutput)
339
701
  generateNav(pageEntries, outputDir, config.nav)
702
+ generateFeeds(pageEntries, outputDir, siteUrl, config.feed, config.site)
340
703
  }
package/lib/markups.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './markup/helpers.js'
3
- import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
2
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount, excerpt } from './markup/helpers.js'
3
+ import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages, buildTaxonomyData, generateTaxonomyPages } from './markup/collections.js'
4
4
  import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
5
5
  import NunjucksEngine from './markup/engines/nunjucks.js'
6
6
  import LiquidEngine from './markup/engines/liquid.js'
@@ -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
 
@@ -212,11 +225,18 @@ export default class Markups {
212
225
  const context = { page: {} }
213
226
  let pageUrl
214
227
 
228
+ let pageExtra
215
229
  if (additionalContext) {
216
230
  if (additionalContext._url) {
217
231
  pageUrl = additionalContext._url
218
232
  delete additionalContext._url
219
233
  }
234
+ // extra props to stamp on page.* after front-matter parse (e.g. taxonomy
235
+ // pages inject the breadcrumb hint the index template's front matter lacks)
236
+ if (additionalContext._page) {
237
+ pageExtra = additionalContext._page
238
+ delete additionalContext._page
239
+ }
220
240
  Object.assign(context, additionalContext)
221
241
  }
222
242
 
@@ -225,12 +245,14 @@ export default class Markups {
225
245
  context.page = frontMatterResult.frontMatter
226
246
  context.page.content = frontMatterResult.content
227
247
  context.page.wordcount = wordcount(frontMatterResult.content)
248
+ context.page.excerpt = excerpt(frontMatterResult.content)
228
249
  } catch (err) {
229
250
  log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
230
251
  console.error(err)
231
252
  }
232
253
 
233
254
  if (pageUrl) context.page.url = pageUrl
255
+ if (pageExtra) Object.assign(context.page, pageExtra)
234
256
 
235
257
  const frontMatter = context.page
236
258
 
@@ -502,16 +524,22 @@ export default class Markups {
502
524
  ...getCollectionDataBasedOnConfig(this.markupIn, this.collectionsConfig)
503
525
  }
504
526
 
505
- const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig
527
+ const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig || this.feedConfig
506
528
  const pageEntries = shouldIndex ? [] : null
507
529
 
508
530
  buildCollectionPaginationData(collectionData)
531
+ // attach collection.taxonomies before any render so index/listing templates
532
+ // can link tag/category pages on page 1
533
+ buildTaxonomyData(collectionData)
509
534
 
510
535
  // must precede any rendering (incl. pagination pages) so every template
511
536
  // sees the current build's nav
512
537
  this.buildNavGlobal(markupIn, collectionData)
513
538
 
514
- const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
539
+ const collectionPromises = [
540
+ ...generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData),
541
+ ...generateTaxonomyPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL, this.siteData)
542
+ ]
515
543
 
516
544
  await Promise.all(collectionPromises)
517
545
 
@@ -528,6 +556,20 @@ export default class Markups {
528
556
  isIndex: true
529
557
  })
530
558
  }
559
+
560
+ // taxonomy term pages: crawlable (sitemap) but isIndex, so out of the
561
+ // search index, llms.txt and nav — same as pagination pages
562
+ for (const tax of collection.taxonomies || []) {
563
+ for (const term of tax.terms) {
564
+ for (let p = 0; p < term.totalPages; p++) {
565
+ pageEntries.push({
566
+ url: p === 0 ? term.url : `${term.url}/${p + 1}`,
567
+ title: term.term,
568
+ isIndex: true
569
+ })
570
+ }
571
+ }
572
+ }
531
573
  }
532
574
  }
533
575
 
@@ -541,7 +583,11 @@ export default class Markups {
541
583
  generateIndexFiles(pageEntries, this.markupOut, this.siteData.url, {
542
584
  searchIndex: this.searchIndexConfig,
543
585
  sitemap: this.sitemapConfig,
544
- nav: this.navConfig
586
+ llms: this.resolveLlmsConfig(),
587
+ robots: this.robotsConfig,
588
+ nav: this.navConfig,
589
+ feed: this.feedConfig,
590
+ site: this.siteData
545
591
  })
546
592
  }
547
593
 
@@ -650,7 +696,11 @@ export default class Markups {
650
696
  generateIndexFiles(this.lastPageEntries, this.markupOut, this.siteData.url, {
651
697
  searchIndex: this.searchIndexConfig,
652
698
  sitemap: this.sitemapConfig,
653
- nav: this.navConfig
699
+ llms: this.resolveLlmsConfig(),
700
+ robots: this.robotsConfig,
701
+ nav: this.navConfig,
702
+ feed: this.feedConfig,
703
+ site: this.siteData
654
704
  })
655
705
  }
656
706
 
@@ -662,6 +712,6 @@ export default class Markups {
662
712
  // Everything except the fields that change on every content edit — used to
663
713
  // detect front matter drift that would invalidate the shared nav global
664
714
  function entrySignature(entry) {
665
- const { content, wordcount, ...rest } = entry
715
+ const { content, wordcount, excerpt, ...rest } = entry
666
716
  return JSON.stringify(rest)
667
717
  }
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.7.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "argoyle": "^1.0.0",
55
- "book-of-spells": "^1.0.32",
55
+ "book-of-spells": "^1.3.0",
56
56
  "chokidar": "^3.5.3",
57
57
  "connect": "^3.7.0",
58
58
  "dayjs": "^1.11.19",