app/utils/bodyLinks.ts, pure and run by content layers at SSR on
sanitized body HTML (UI-SPEC L7 steps 2 to 4):
- autolink(html, links, max, self): first occurrence of each CMS phrase,
whole words, case-insensitive, at most max per text, never inside a,
headings, code, pre, figcaption or button, never to the page itself;
only site paths and http(s) URLs, escaped, as a plain <a href>
- applyOutbound(html, {siteHost, newTab, nofollowDomains,
sponsoredDomains, hint}): editor rel kept, nofollow/sponsored from the
domain lists, target=_blank, rel noopener and the visually hidden hint
only while new tab is on; internal links untouched
- imageAttrs(html): loading=lazy and decoding=async, stored width,
height and alt="" kept
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
// Body link utilities for content layers (UI-SPEC L7 steps 2 to 4; D-82,
|
||
// D-83, D-71): phrase auto-links from the resolve answer, the outbound link
|
||
// rules and the body image attributes. A content layer (vue-journal-layer's
|
||
// gate) runs them at SSR, in this order, on sanitized HTML:
|
||
//
|
||
// html = imageAttrs(applyOutbound(autolink(clean, answer.autolinks, answer.autolink_max, selfPath), {...}))
|
||
//
|
||
// They work on sanitize-html's normalised output only: tags written as
|
||
// <name attr="value">, text and attribute values escaped (& < > and " as
|
||
// entities) and no comments, so a split into tags and text is exact.
|
||
// Markup is only ever added between tags (an auto-link inside a text node,
|
||
// the new-tab hint before an anchor closes) or as escaped attribute values
|
||
// built from validated input; nothing is taken from request input (T-33-39,
|
||
// T-33-40). Pure, no Vue or Nuxt imports, so `node --test` drives it and
|
||
// every layer may call it (app/utils is auto-imported).
|
||
|
||
/** One phrase of the CMS auto-link table (resolve answer `autolinks`). */
|
||
export interface SeoAutolink {
|
||
phrase: string
|
||
url: string
|
||
}
|
||
|
||
/** The outbound rules (resolve answer `outbound` plus the layer options). */
|
||
export interface SeoOutboundOptions {
|
||
/** The site's own host (with its port when it has one): links to it are internal. */
|
||
siteHost: string
|
||
/** links_new_tab: target="_blank" and rel="noopener" on outbound links. */
|
||
newTab: boolean
|
||
/** Domains whose links get rel="nofollow" (subdomains included). */
|
||
nofollowDomains?: readonly string[] | null
|
||
/** Domains whose links get rel="sponsored" (subdomains included). */
|
||
sponsoredDomains?: readonly string[] | null
|
||
/**
|
||
* The visually hidden new-tab hint, e.g. "(opens in a new tab)", added
|
||
* inside each link that opens in a new tab; '' or absent adds none.
|
||
*/
|
||
hint?: string | null
|
||
}
|
||
|
||
// Elements whose text is never auto-linked.
|
||
const SKIP = new Set(['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button'])
|
||
// Elements without an end tag.
|
||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
|
||
|
||
const TAG = /(<[^>]+>)/
|
||
const TAG_NAME = /^<\/?\s*([A-Za-z][A-Za-z0-9-]*)/
|
||
const ENTITY = /&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]*);/gi
|
||
const WORD_CHAR = /[\p{L}\p{N}_]/u
|
||
|
||
function escapeText(value: string): string {
|
||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
function escapeAttr(value: string): string {
|
||
return escapeText(value).replace(/"/g, '"')
|
||
}
|
||
|
||
/** Attribute values as sanitize-html writes them, decoded for reading. */
|
||
function decodeAttr(value: string): string {
|
||
return value
|
||
.replace(/"/g, '"')
|
||
.replace(/'|'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
}
|
||
|
||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
|
||
/** A tag's name in lower case and whether it opens, closes or stands alone. */
|
||
function tagInfo(tag: string): { name: string; kind: 'open' | 'close' | 'void' } | null {
|
||
const m = TAG_NAME.exec(tag)
|
||
if (!m || !m[1]) return null
|
||
const name = m[1].toLowerCase()
|
||
if (tag.startsWith('</')) return { name, kind: 'close' }
|
||
if (VOID.has(name) || /\/\s*>$/.test(tag)) return { name, kind: 'void' }
|
||
return { name, kind: 'open' }
|
||
}
|
||
|
||
/** Keeps the stack of open elements in step with one tag. */
|
||
function track(stack: string[], tag: string): void {
|
||
const info = tagInfo(tag)
|
||
if (!info || info.kind === 'void') return
|
||
if (info.kind === 'open') {
|
||
stack.push(info.name)
|
||
return
|
||
}
|
||
const at = stack.lastIndexOf(info.name)
|
||
if (at !== -1) stack.length = at
|
||
}
|
||
|
||
/** A URL an auto-link may point at: a site path ("/x", not "//x") or an http(s) URL. */
|
||
function linkableUrl(url: unknown): url is string {
|
||
if (typeof url !== 'string' || url === '' || /[\s\x00-\x1f]/.test(url)) return false
|
||
if (url.startsWith('/')) return !url.startsWith('//') && !url.startsWith('/\\')
|
||
try {
|
||
const u = new URL(url)
|
||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** The path and query of a URL or path, without a trailing slash; the host when absolute. */
|
||
function pageKey(url: string): { host: string | null; path: string } | null {
|
||
try {
|
||
const absolute = /^https?:\/\//i.test(url)
|
||
const u = new URL(url, 'https://self.invalid')
|
||
const path = (u.pathname.replace(/\/+$/, '') || '/') + u.search
|
||
return { host: absolute ? u.host.toLowerCase() : null, path }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** True when `url` is the page at `self` (a path or an absolute URL). */
|
||
function isSelf(url: string, self: string): boolean {
|
||
const a = pageKey(url)
|
||
const b = pageKey(self)
|
||
if (!a || !b) return false
|
||
if (a.host && b.host && a.host !== b.host) return false
|
||
return a.path === b.path
|
||
}
|
||
|
||
/** The first whole-word, case-insensitive match of `needle` in escaped `text`, never inside an entity. */
|
||
function findPhrase(text: string, needle: string): number {
|
||
const entities: Array<[number, number]> = []
|
||
for (const m of text.matchAll(ENTITY)) entities.push([m.index, m.index + m[0].length])
|
||
const re = new RegExp(escapeRegex(needle), 'giu')
|
||
for (const m of text.matchAll(re)) {
|
||
const start = m.index
|
||
const end = start + m[0].length
|
||
const before = text.slice(0, start)
|
||
const after = text.slice(end)
|
||
// A word character next to the match, or a match starting or ending on
|
||
// a character that cannot border a word, breaks the whole-word rule.
|
||
if (before !== '' && WORD_CHAR.test(before.at(-1)!) && WORD_CHAR.test(m[0][0]!)) continue
|
||
if (after !== '' && WORD_CHAR.test(after[0]!) && WORD_CHAR.test(m[0].at(-1)!)) continue
|
||
if (entities.some(([s, e]) => (start > s && start < e) || (end > s && end < e))) continue
|
||
return start
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* Phrase auto-links (C22, UI-SPEC L7 step 2): each phrase of `links` is
|
||
* linked at its first occurrence in the text, case-insensitive and as a
|
||
* whole word, and at most `max` links are added to the whole HTML. Never
|
||
* inside a, h1–h6, code, pre, figcaption or button, never to the page itself
|
||
* (`self`, its path or absolute URL) and only to a site path or an http(s)
|
||
* URL. A phrase's regex characters match literally. The anchor is a plain
|
||
* <a href="…"> with no class or rel, like an editor link.
|
||
*/
|
||
export function autolink(html: string, links: readonly SeoAutolink[] | null | undefined, max: number, self: string): string {
|
||
if (!html || !Array.isArray(links) || links.length === 0) return html
|
||
const limit = Math.max(0, Math.trunc(Number(max) || 0))
|
||
if (limit === 0) return html
|
||
const candidates = links.filter((l) => l && typeof l.phrase === 'string' && l.phrase.trim() !== '' && linkableUrl(l.url) && !isSelf(l.url, self))
|
||
if (candidates.length === 0) return html
|
||
|
||
const stack: string[] = []
|
||
const done = new Set<string>()
|
||
let used = 0
|
||
return html
|
||
.split(TAG)
|
||
.map((part) => {
|
||
if (part.startsWith('<')) {
|
||
track(stack, part)
|
||
return part
|
||
}
|
||
if (part === '' || used >= limit || stack.some((t) => SKIP.has(t))) return part
|
||
// Every remaining phrase's first match in this text, taken in document
|
||
// order (a longer phrase first at the same place) while none overlaps
|
||
// an earlier one and the limit allows.
|
||
const found: Array<{ start: number; end: number; url: string; key: string }> = []
|
||
for (const link of candidates) {
|
||
const key = link.phrase.trim().toLowerCase()
|
||
if (done.has(key) || done.has(`url:${link.url}`)) continue
|
||
const needle = escapeText(link.phrase.trim())
|
||
const start = findPhrase(part, needle)
|
||
if (start >= 0) found.push({ start, end: start + needle.length, url: link.url, key })
|
||
}
|
||
found.sort((a, b) => a.start - b.start || b.end - a.end)
|
||
const hits: typeof found = []
|
||
for (const f of found) {
|
||
if (used >= limit) break
|
||
if (done.has(f.key) || done.has(`url:${f.url}`)) continue
|
||
if (hits.some((h) => f.start < h.end && f.end > h.start)) continue
|
||
hits.push(f)
|
||
done.add(f.key)
|
||
done.add(`url:${f.url}`)
|
||
used++
|
||
}
|
||
if (hits.length === 0) return part
|
||
hits.sort((a, b) => a.start - b.start)
|
||
let out = ''
|
||
let at = 0
|
||
for (const h of hits) {
|
||
out += `${part.slice(at, h.start)}<a href="${escapeAttr(h.url)}">${part.slice(h.start, h.end)}</a>`
|
||
at = h.end
|
||
}
|
||
return out + part.slice(at)
|
||
})
|
||
.join('')
|
||
}
|
||
|
||
interface Attr {
|
||
name: string
|
||
value: string | null
|
||
}
|
||
|
||
const ATTR = /\s+([^\s"'>/=]+)(?:\s*=\s*"([^"]*)")?/g
|
||
|
||
function parseAttrs(tag: string, name: string): Attr[] {
|
||
const body = tag.slice(1 + name.length).replace(/\/?\s*>$/, '')
|
||
const out: Attr[] = []
|
||
for (const m of body.matchAll(ATTR)) out.push({ name: m[1]!.toLowerCase(), value: m[2] ?? null })
|
||
return out
|
||
}
|
||
|
||
function writeTag(name: string, attrs: readonly Attr[], selfClosing: boolean): string {
|
||
const parts = attrs.map((a) => (a.value === null ? ` ${a.name}` : ` ${a.name}="${a.value}"`))
|
||
return `<${name}${parts.join('')}${selfClosing ? ' />' : '>'}`
|
||
}
|
||
|
||
/** True when `host` is `domain` or one of its subdomains. */
|
||
function inDomains(host: string, domains: readonly string[] | null | undefined): boolean {
|
||
if (!Array.isArray(domains)) return false
|
||
return domains.some((d) => {
|
||
const domain = typeof d === 'string' ? d.trim().toLowerCase().replace(/^\.+/, '') : ''
|
||
return domain !== '' && (host === domain || host.endsWith(`.${domain}`))
|
||
})
|
||
}
|
||
|
||
/** The host of an outbound link (another host over http(s), or protocol-relative), else null. */
|
||
function outboundHost(href: string, siteHost: string): string | null {
|
||
let u: URL
|
||
try {
|
||
if (href.startsWith('//')) u = new URL(`https:${href}`)
|
||
else if (/^https?:\/\//i.test(href)) u = new URL(href)
|
||
else return null
|
||
} catch {
|
||
return null
|
||
}
|
||
const site = siteHost.trim().toLowerCase()
|
||
const host = u.host.toLowerCase()
|
||
const hostname = u.hostname.toLowerCase()
|
||
if (site !== '' && (host === site || (!site.includes(':') && hostname === site))) return null
|
||
return hostname
|
||
}
|
||
|
||
/**
|
||
* The outbound link rules (UI-SPEC L7 step 3, D-83): an http(s) link to
|
||
* another host keeps its editor rel values and gains nofollow or sponsored
|
||
* from the domain lists; while `newTab` is on it also gets target="_blank",
|
||
* rel noopener and, when `hint` is set, a trailing
|
||
* <span class="seo-visually-hidden"> hint. Internal, relative, fragment and
|
||
* non-http links are left exactly as they are. target is never read from
|
||
* the input: the sanitizer refuses it, and only this step adds it.
|
||
*/
|
||
export function applyOutbound(html: string, opts: SeoOutboundOptions): string {
|
||
if (!html) return html
|
||
const hint = typeof opts.hint === 'string' ? opts.hint.trim() : ''
|
||
// One entry per open <a>: whether its end tag takes the hint.
|
||
const anchors: boolean[] = []
|
||
return html
|
||
.split(TAG)
|
||
.map((part) => {
|
||
if (!part.startsWith('<')) return part
|
||
const info = tagInfo(part)
|
||
if (!info || info.name !== 'a') return part
|
||
if (info.kind === 'close') {
|
||
const withHint = anchors.pop()
|
||
return withHint ? `<span class="seo-visually-hidden"> ${escapeText(hint)}</span>${part}` : part
|
||
}
|
||
if (info.kind === 'void') return part
|
||
const attrs = parseAttrs(part, 'a')
|
||
const href = attrs.find((a) => a.name === 'href')
|
||
const host = href && href.value !== null ? outboundHost(decodeAttr(href.value).trim(), opts.siteHost) : null
|
||
if (!host) {
|
||
anchors.push(false)
|
||
return part
|
||
}
|
||
const relAttr = attrs.find((a) => a.name === 'rel')
|
||
const tokens = (relAttr?.value ? decodeAttr(relAttr.value) : '').split(/\s+/).filter(Boolean)
|
||
const add = (token: string) => {
|
||
if (!tokens.some((t) => t.toLowerCase() === token)) tokens.push(token)
|
||
}
|
||
if (inDomains(host, opts.nofollowDomains)) add('nofollow')
|
||
if (inDomains(host, opts.sponsoredDomains)) add('sponsored')
|
||
const out = attrs.filter((a) => a.name !== 'target')
|
||
if (opts.newTab) {
|
||
out.push({ name: 'target', value: '_blank' })
|
||
add('noopener')
|
||
}
|
||
if (tokens.length > 0) {
|
||
const value = escapeAttr(tokens.join(' '))
|
||
const at = out.findIndex((a) => a.name === 'rel')
|
||
if (at === -1) out.push({ name: 'rel', value })
|
||
else out[at] = { name: 'rel', value }
|
||
}
|
||
anchors.push(opts.newTab && hint !== '')
|
||
return writeTag('a', out, false)
|
||
})
|
||
.join('')
|
||
}
|
||
|
||
/**
|
||
* Body image attributes (UI-SPEC L7 step 4, D-71): every img gains
|
||
* loading="lazy" and decoding="async" unless it already sets them; stored
|
||
* width, height and alt (alt="" of a decorative image included) are kept
|
||
* as they are.
|
||
*/
|
||
export function imageAttrs(html: string): string {
|
||
if (!html) return html
|
||
return html
|
||
.split(TAG)
|
||
.map((part) => {
|
||
if (!part.startsWith('<')) return part
|
||
const info = tagInfo(part)
|
||
if (!info || info.name !== 'img' || info.kind === 'close') return part
|
||
const attrs = parseAttrs(part, 'img')
|
||
const has = (name: string) => attrs.some((a) => a.name === name)
|
||
if (has('loading') && has('decoding')) return part
|
||
const out = [...attrs]
|
||
if (!has('loading')) out.push({ name: 'loading', value: 'lazy' })
|
||
if (!has('decoding')) out.push({ name: 'decoding', value: 'async' })
|
||
return writeTag('img', out, /\/\s*>$/.test(part))
|
||
})
|
||
.join('')
|
||
}
|