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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import yaml from 'yaml'
4
+ import { humanize } from 'book-of-spells'
4
5
  import { toPosix } from '../utils/helpers.js'
5
6
  import { getImageEntry } from './image-cache.js'
6
7
 
@@ -68,6 +69,26 @@ export function wordcount(text) {
68
69
  return words ? words.length : 0
69
70
  }
70
71
 
72
+ // First prose paragraph of content as plain text, for a meta-description
73
+ // fallback when front matter has none. Splits on blank lines, skips leading
74
+ // headings and HTML comments, strips markdown/HTML like wordcount, then caps at
75
+ // ~160 chars (search-snippet size) on a word boundary.
76
+ // ponytail: content-level split; a leading code fence or raw template block
77
+ // could still slip in — refine the skip list if a snippet looks wrong.
78
+ export function excerpt(text, max = 160) {
79
+ if (!text) return ''
80
+ for (const block of text.split(/\n\s*\n/)) {
81
+ const trimmed = block.trim()
82
+ if (!trimmed || /^#{1,6}\s/.test(trimmed) || trimmed.startsWith('<!--')) continue
83
+ // eslint-disable-next-line no-useless-escape
84
+ const stripped = trimmed.replace(/<[^>]*>/g, ' ').replace(/[#*_`~\[\]()>|{}\\]/g, ' ').replace(/\s+/g, ' ').trim()
85
+ if (!stripped) continue
86
+ if (stripped.length <= max) return stripped
87
+ return stripped.slice(0, max).replace(/\s+\S*$/, '').trim() + '…'
88
+ }
89
+ return ''
90
+ }
91
+
71
92
  // marked HTML-encodes quotes/brackets in template tags it treats as prose,
72
93
  // breaking string args like groupby("date"). Decode entities inside {{ }} / {% %}
73
94
  // after markdown, before the template engine parses. Decode &amp; last so
@@ -111,8 +132,18 @@ export function groupby(arr, key, datePart) {
111
132
  if (!Array.isArray(arr)) return []
112
133
 
113
134
  const map = new Map()
135
+ const put = (groupKey, item) => {
136
+ if (!map.has(groupKey)) map.set(groupKey, [])
137
+ map.get(groupKey).push(item)
138
+ }
114
139
  for (const item of arr) {
115
140
  let value = item[key]
141
+ // Array value (e.g. `tags: [a, b]`) → item lands in each element's bucket,
142
+ // so one post appears under every tag it carries.
143
+ if (Array.isArray(value)) {
144
+ for (const v of value) put(v != null ? String(v) : '', item)
145
+ continue
146
+ }
116
147
  if (datePart && value) {
117
148
  const date = new Date(value)
118
149
  if (!isNaN(date)) {
@@ -123,9 +154,7 @@ export function groupby(arr, key, datePart) {
123
154
  }
124
155
  }
125
156
  }
126
- const groupKey = value != null ? String(value) : ''
127
- if (!map.has(groupKey)) map.set(groupKey, [])
128
- map.get(groupKey).push(item)
157
+ put(value != null ? String(value) : '', item)
129
158
  }
130
159
 
131
160
  return Array.from(map, ([key, items]) => ({ key, items }))
@@ -324,15 +353,21 @@ export function escapeAttr(value) {
324
353
  return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
325
354
  }
326
355
 
327
- export function buildPaginationTag(pagination, prefix = '') {
356
+ // `labels` (site.pagination) localizes the prev/next/of wording; English
357
+ // defaults. Author-supplied, but escaped anyway since it lands in HTML.
358
+ export function buildPaginationTag(pagination, prefix = '', labels = {}) {
328
359
  const totalPages = Number(pagination && pagination.totalPages) || 1
329
360
  if (totalPages <= 1) return ''
330
361
 
362
+ const prev = escapeAttr(labels.prev || 'Previous')
363
+ const next = escapeAttr(labels.next || 'Next')
364
+ const of = escapeAttr(labels.of || 'of')
365
+
331
366
  const pageNumber = Number(pagination && pagination.pageNumber) || 1
332
367
  const parts = []
333
- if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">Previous</a>`)
334
- parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
335
- if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">Next</a>`)
368
+ if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">${prev}</a>`)
369
+ parts.push(`<span>${pageNumber} ${of} ${totalPages}</span>`)
370
+ if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">${next}</a>`)
336
371
  return parts.join('\n')
337
372
  }
338
373
 
@@ -371,3 +406,275 @@ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
371
406
  }
372
407
  return `<img ${attrs.join(' ')}>`
373
408
  }
409
+
410
+ // Serializes a JSON-LD data object into a <script> block. Drops empty top-level
411
+ // keys (GEO parsers ignore them) and neutralizes `<`, `>`, `&` so front-matter
412
+ // values can't break out of the <script> or inject entities.
413
+ function jsonLdScript(data) {
414
+ for (const k of Object.keys(data)) {
415
+ if (data[k] === undefined || data[k] === null || data[k] === '') delete data[k]
416
+ }
417
+ const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
418
+ return `<script type="application/ld+json">${json}</script>`
419
+ }
420
+
421
+ // Turns a URL segment (or a filename) into a human title for a breadcrumb crumb
422
+ // that has no page object of its own — same rule the nav tree uses for virtual
423
+ // parents. Strips a trailing extension, then dash/underscore → spaced Title Case.
424
+ function humanizeSegment(seg) {
425
+ return humanize(String(seg).replace(/\.[a-z0-9]+$/i, ''))
426
+ }
427
+
428
+ // Builds a breadcrumb trail — a { name, path } array from the site root down to
429
+ // the page — derived purely from URL depth (no nav tree needed). Intermediate
430
+ // path segments are humanized (they have no page object); the last crumb is the
431
+ // page's own title. The home crumb (site root) leads unless disabled.
432
+ // `path` is the site-root-relative URL (page.url form: '' for home, no domain);
433
+ // each consumer prefixes it — the JSON-LD with `site.url` (absolute, required by
434
+ // Google), the visible filter with `relativePathPrefix` (so local-dev links stay
435
+ // on localhost instead of jumping to the production domain).
436
+ // Config: `site.breadcrumb` overlaid by front-matter `page.breadcrumb` (object
437
+ // with `home` boolean / `homeLabel` string); `breadcrumb: false` on either
438
+ // disables it. Returns [] for the homepage, a single-crumb trail, or when
439
+ // disabled — every caller renders nothing on [].
440
+ export function breadcrumbCrumbs(page, site = {}) {
441
+ page = page || {}
442
+ site = site || {}
443
+ if (page.breadcrumb === false || site.breadcrumb === false) return []
444
+
445
+ const cfg = { home: true, homeLabel: 'Home' }
446
+ if (site.breadcrumb && typeof site.breadcrumb === 'object') Object.assign(cfg, site.breadcrumb)
447
+ if (page.breadcrumb && typeof page.breadcrumb === 'object') Object.assign(cfg, page.breadcrumb)
448
+
449
+ // Taxonomy term page: its URL segments (e.g. changelog/tag/feature) are not a
450
+ // real page hierarchy — the `tag` segment has no page, and the last segment is
451
+ // a slug, not the page title. Build the trail from the term hint instead:
452
+ // Home > collection landing > term.
453
+ if (page._taxonomy) {
454
+ const t = page._taxonomy
455
+ const crumbs = []
456
+ if (cfg.home) crumbs.push({ name: cfg.homeLabel, path: '' })
457
+ crumbs.push({ name: humanizeSegment(t.collection), path: t.collection })
458
+ // prefix with the taxonomy label ("Tag: Feature") — the dropped `tag/` path
459
+ // segment isn't a crumb, so this restores the "it's an archive" context
460
+ const label = t.path ? `${humanizeSegment(t.path)}: ` : ''
461
+ crumbs.push({ name: `${label}${humanizeSegment(t.term)}`, path: t.termUrl })
462
+ return crumbs.length >= 2 ? crumbs : []
463
+ }
464
+
465
+ const rawUrl = page.url != null ? String(page.url) : ''
466
+ if (rawUrl === '') return [] // homepage — you're already home
467
+
468
+ const parts = rawUrl.split('/').filter(Boolean)
469
+ const crumbs = []
470
+ if (cfg.home) crumbs.push({ name: cfg.homeLabel, path: '' })
471
+
472
+ // Every part but the last is an ancestor directory; accumulate its path.
473
+ let acc = ''
474
+ for (let i = 0; i < parts.length - 1; i++) {
475
+ acc = acc ? `${acc}/${parts[i]}` : parts[i]
476
+ crumbs.push({ name: humanizeSegment(parts[i]), path: acc })
477
+ }
478
+ crumbs.push({ name: page.title || humanizeSegment(parts[parts.length - 1]), path: rawUrl })
479
+
480
+ // A lone crumb (e.g. home off, top-level page) is not a trail.
481
+ return crumbs.length >= 2 ? crumbs : []
482
+ }
483
+
484
+ // BreadcrumbList JSON-LD <script> for a page's position in the site hierarchy —
485
+ // a Google rich result. Absolute item URLs are mandatory, so it's skipped
486
+ // without `site.url` (like canonical). Names come from front matter / segments,
487
+ // so jsonLdScript escapes them for the <script> context.
488
+ function breadcrumbJsonLd(page, site) {
489
+ if (!(site && site.url)) return ''
490
+ const crumbs = breadcrumbCrumbs(page, site)
491
+ if (crumbs.length < 2) return ''
492
+ const baseUrl = String(site.url).replace(/\/+$/, '')
493
+ const abs = (p) => {
494
+ const rel = String(p).replace(/^\/+/, '')
495
+ return rel ? `${baseUrl}/${rel}` : baseUrl
496
+ }
497
+ return jsonLdScript({
498
+ '@context': 'https://schema.org',
499
+ '@type': 'BreadcrumbList',
500
+ itemListElement: crumbs.map((c, i) => ({
501
+ '@type': 'ListItem',
502
+ position: i + 1,
503
+ name: c.name,
504
+ item: abs(c.path)
505
+ }))
506
+ })
507
+ }
508
+
509
+ // Builds a visible <nav> breadcrumb trail for display in the page body (blog
510
+ // posts, nested pages). Same crumbs as the JSON-LD, but hrefs are relative:
511
+ // `prefix` is the page's `relativePathPrefix`, so links resolve against the
512
+ // current output location (localhost in dev, the deployed path in prod) exactly
513
+ // like the nav/header links — never the absolute production domain. The last
514
+ // crumb renders as aria-current text (the current page is not a link). Returns
515
+ // '' when there's no trail. Names/paths land in attributes, so they're escaped.
516
+ export function buildBreadcrumb(page, site = {}, prefix = '') {
517
+ const crumbs = breadcrumbCrumbs(page, site)
518
+ if (crumbs.length < 2) return ''
519
+ const items = crumbs.map((c, i) => {
520
+ const name = escapeAttr(c.name)
521
+ if (i === crumbs.length - 1) return `<li><span aria-current="page">${name}</span></li>`
522
+ return `<li><a href="${escapeAttr(prefix + c.path)}">${name}</a></li>`
523
+ })
524
+ return `<nav class="breadcrumb" aria-label="Breadcrumb"><ol>${items.join('')}</ol></nav>`
525
+ }
526
+
527
+ // Builds a schema.org JSON-LD <script> block from a page's front matter + site
528
+ // data, for GEO / structured data (generative engines lean on schema.org to
529
+ // understand a page). @type auto-detects — BlogPosting when the page has a
530
+ // `date`, else WebPage. Article-only fields (headline, dates, author,
531
+ // wordCount) are added only for the article case. The publisher Organization
532
+ // gains a `logo` ImageObject when `site.logo` is set — Google Article rich
533
+ // results require it. On the homepage (page has no `url`) a second `WebSite`
534
+ // block is emitted, declaring the site name for search results.
535
+ // Anything in `page.jsonld` (an object) is merged over the generated defaults,
536
+ // so a page can add or replace any field — including @type — as a full escape
537
+ // hatch. Values come from front matter, so the JSON is escaped for a <script>
538
+ // context to prevent a `</script>` break-out (XSS).
539
+ export function buildJsonLd(page, site = {}) {
540
+ page = page || {}
541
+ site = site || {}
542
+
543
+ const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
544
+ const absUrl = (u) => {
545
+ if (!u) return undefined
546
+ if (/^https?:\/\//i.test(u)) return u
547
+ return baseUrl ? `${baseUrl}/${String(u).replace(/^\/+/, '')}` : u
548
+ }
549
+
550
+ const author = (page.author && typeof page.author === 'object' ? page.author.name : page.author) || site.author
551
+ const publisherName = site.title || site.name
552
+
553
+ let publisher
554
+ if (publisherName) {
555
+ publisher = { '@type': 'Organization', name: publisherName }
556
+ const logo = absUrl(site.logo)
557
+ if (logo) publisher.logo = { '@type': 'ImageObject', url: logo }
558
+ }
559
+
560
+ const isArticle = !!page.date
561
+ const data = {
562
+ '@context': 'https://schema.org',
563
+ '@type': isArticle ? 'BlogPosting' : 'WebPage',
564
+ name: page.title,
565
+ description: page.description || page.excerpt || site.description,
566
+ url: absUrl(page.url),
567
+ inLanguage: page.lang || site.lang,
568
+ image: absUrl(page.image || site.image),
569
+ publisher
570
+ }
571
+
572
+ if (isArticle) {
573
+ data.headline = page.title
574
+ data.datePublished = page.date
575
+ data.dateModified = page.updated || page.date
576
+ if (author) data.author = { '@type': 'Person', name: author }
577
+ if (page.wordcount) data.wordCount = page.wordcount
578
+ }
579
+
580
+ // page.jsonld extends/overrides. Shallow merge — nested schema is rare and
581
+ // the escape hatch is meant for whole-key replacement.
582
+ if (page.jsonld && typeof page.jsonld === 'object') Object.assign(data, page.jsonld)
583
+
584
+ const blocks = [jsonLdScript(data)]
585
+
586
+ // Homepage (no page.url) gets a site-level WebSite block — defines the site
587
+ // name for search results.
588
+ if (!page.url && baseUrl) {
589
+ blocks.push(jsonLdScript({
590
+ '@context': 'https://schema.org',
591
+ '@type': 'WebSite',
592
+ name: publisherName,
593
+ url: baseUrl,
594
+ inLanguage: page.lang || site.lang
595
+ }))
596
+ }
597
+
598
+ // Nested pages get a BreadcrumbList block (zero-config rich result).
599
+ const breadcrumb = breadcrumbJsonLd(page, site)
600
+ if (breadcrumb) blocks.push(breadcrumb)
601
+
602
+ return blocks.join('\n')
603
+ }
604
+
605
+ // Builds Open Graph (+ a Twitter card) <meta> tags from a page's front matter
606
+ // and site data, for link previews on social/chat platforms. og:type is
607
+ // `article` when the page has a `date`, else `website`. Twitter tags fall back
608
+ // to the og:* values on most crawlers, so only `twitter:card` is emitted. A
609
+ // `page.og` object merges over (and overrides) the defaults — set any extra
610
+ // property there, e.g. `og:image:alt` or a fixed `twitter:card`. Values come
611
+ // from front matter and land in attributes, so they're escaped (escapeAttr).
612
+ export function buildOpenGraph(page, site = {}) {
613
+ page = page || {}
614
+ site = site || {}
615
+
616
+ const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
617
+ const absUrl = (u) => {
618
+ if (!u) return undefined
619
+ if (/^https?:\/\//i.test(u)) return u
620
+ return baseUrl ? `${baseUrl}/${String(u).replace(/^\/+/, '')}` : u
621
+ }
622
+
623
+ const author = (page.author && typeof page.author === 'object' ? page.author.name : page.author) || site.author
624
+ const isArticle = !!page.date
625
+ const image = absUrl(page.image || site.image)
626
+
627
+ const tags = {
628
+ 'og:title': page.title || site.title,
629
+ 'og:description': page.description || page.excerpt || site.description,
630
+ 'og:type': isArticle ? 'article' : 'website',
631
+ 'og:url': absUrl(page.url),
632
+ 'og:site_name': site.title,
633
+ 'og:locale': page.lang || site.lang,
634
+ 'og:image': image,
635
+ 'twitter:card': image ? 'summary_large_image' : 'summary'
636
+ }
637
+
638
+ if (isArticle) {
639
+ tags['article:published_time'] = page.date
640
+ tags['article:modified_time'] = page.updated || page.date
641
+ if (author) tags['article:author'] = author
642
+ }
643
+
644
+ // page.og extends/overrides. Shallow — one flat property:content map.
645
+ if (page.og && typeof page.og === 'object') Object.assign(tags, page.og)
646
+
647
+ const lines = []
648
+ for (const [prop, val] of Object.entries(tags)) {
649
+ if (val === undefined || val === null || val === '') continue
650
+ // Twitter's spec uses name=, Open Graph uses property=.
651
+ const attr = prop.startsWith('twitter:') ? 'name' : 'property'
652
+ lines.push(`<meta ${attr}="${escapeAttr(prop)}" content="${escapeAttr(val)}">`)
653
+ }
654
+ return lines.join('\n')
655
+ }
656
+
657
+ // Builds a `<link rel="canonical">` tag — the dedup signal that names a page's
658
+ // authoritative URL. The href is absolute (needs `site.url`): front matter
659
+ // `canonical` wins (an absolute URL as-is, or a path resolved against
660
+ // `site.url`), else the page's own `url`. Homepage (`url` === '') canonicals to
661
+ // the site root. Returns '' when no absolute URL can be formed (no site.url).
662
+ export function buildCanonical(page, site = {}) {
663
+ page = page || {}
664
+ site = site || {}
665
+
666
+ const baseUrl = site.url ? String(site.url).replace(/\/+$/, '') : ''
667
+ const abs = (u) => {
668
+ if (u == null) return ''
669
+ if (/^https?:\/\//i.test(u)) return u
670
+ if (!baseUrl) return ''
671
+ const rel = String(u).replace(/^\/+/, '')
672
+ return rel ? `${baseUrl}/${rel}` : baseUrl
673
+ }
674
+
675
+ // The root index page has no `url` (poops leaves it unset), so a nullish url
676
+ // means the homepage → canonical to the site root.
677
+ const target = page.canonical != null ? page.canonical : (page.url != null ? page.url : '')
678
+ const href = abs(target)
679
+ return href ? `<link rel="canonical" href="${escapeAttr(href)}">` : ''
680
+ }