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.
- package/README.md +191 -9
- package/lib/exec.js +44 -0
- package/lib/markup/collections.js +240 -42
- package/lib/markup/engines/liquid.js +9 -3
- package/lib/markup/engines/nunjucks.js +9 -3
- package/lib/markup/helpers.js +314 -7
- package/lib/markup/indexer.js +371 -8
- package/lib/markups.js +57 -7
- package/package.json +2 -2
- package/poops.js +22 -9
package/lib/markup/indexer.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|