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/helpers.js
CHANGED
|
@@ -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 & 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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
325
354
|
}
|
|
326
355
|
|
|
327
|
-
|
|
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)}"
|
|
334
|
-
parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
|
|
335
|
-
if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}"
|
|
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
|
+
}
|