Files
vue-seo-layer/app/utils/bodyLinks.ts
Jakub Zych db44ca4d72 feat: body link utilities (auto-links, outbound rules, image attributes)
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
2026-11-04 13:40:47 +01:00

332 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function escapeAttr(value: string): string {
return escapeText(value).replace(/"/g, '&quot;')
}
/** Attribute values as sanitize-html writes them, decoded for reading. */
function decodeAttr(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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('')
}