diff --git a/app/utils/bodyLinks.ts b/app/utils/bodyLinks.ts new file mode 100644 index 0000000..6c4c1a1 --- /dev/null +++ b/app/utils/bodyLinks.ts @@ -0,0 +1,331 @@ +// 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 +// , 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, '>') +} + +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('$/.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 + * 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() + 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)}${part.slice(h.start, h.end)}` + 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 + * 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 : 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 ? ` ${escapeText(hint)}${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('') +} diff --git a/tests/bodyLinks.test.ts b/tests/bodyLinks.test.ts new file mode 100644 index 0000000..0c69812 --- /dev/null +++ b/tests/bodyLinks.test.ts @@ -0,0 +1,173 @@ +// Body link utilities (UI-SPEC L7 steps 2 to 4, D-82, D-83, D-71). The input +// is always sanitize-html output (normalized tags, escaped text and +// attribute values), as the journal layer's gate hands it over. +import assert from 'node:assert/strict' +import test from 'node:test' +import { applyOutbound, autolink, imageAttrs } from '../app/utils/bodyLinks.ts' + +const LINKS = [ + { phrase: 'lwia grzywa', url: '/blog/lwia-grzywa' }, + { phrase: 'reishi', url: 'https://example.test/blog/reishi' }, +] + +test('autolink: first occurrence only, case-insensitive, a plain ', () => { + const html = '

Lwia grzywa to grzyb. Lwia grzywa rośnie na drzewach.

' + assert.equal(autolink(html, LINKS, 5, '/blog/inne'), '

Lwia grzywa to grzyb. Lwia grzywa rośnie na drzewach.

') +}) + +test('autolink: whole words only', () => { + assert.equal(autolink('

reishiowy napar

', LINKS, 5, '/x'), '

reishiowy napar

') + assert.equal(autolink('

superreishi

', LINKS, 5, '/x'), '

superreishi

') + assert.equal(autolink('

(reishi)

', LINKS, 5, '/x'), '

(reishi)

') + assert.equal(autolink('

Żółć reishi.

', LINKS, 5, '/x'), '

Żółć reishi.

') +}) + +test('autolink: at most max links in the whole text, each phrase once', () => { + const html = '

reishi i lwia grzywa

reishi znowu

' + assert.equal(autolink(html, LINKS, 1, '/x'), '

reishi i lwia grzywa

reishi znowu

') + assert.equal(autolink(html, LINKS, 0, '/x'), html) + assert.equal( + autolink(html, LINKS, 5, '/x'), + '

reishi i lwia grzywa

reishi znowu

', + ) +}) + +test('autolink: never inside a, headings, code, pre, figcaption or button', () => { + for (const tag of ['a href="/y"', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button']) { + const name = tag.split(' ')[0] + const html = `<${tag}>reishi reishi

reishi

` + assert.equal(autolink(html, LINKS, 5, '/x'), `<${tag}>reishi reishi

reishi

`, name) + } +}) + +test('autolink: never links the page to itself', () => { + const html = '

lwia grzywa

' + assert.equal(autolink(html, LINKS, 5, '/blog/lwia-grzywa'), html) + assert.equal(autolink(html, LINKS, 5, '/blog/lwia-grzywa/'), html) + assert.equal(autolink(html, LINKS, 5, 'https://example.test/blog/lwia-grzywa'), html) + const self = [{ phrase: 'reishi', url: 'https://example.test/blog/reishi' }] + assert.equal(autolink('

reishi

', self, 5, '/blog/reishi'), '

reishi

') + assert.equal(autolink('

reishi

', self, 5, 'https://other.test/blog/reishi'), '

reishi

') +}) + +test('autolink: regex characters in a phrase match literally', () => { + const links = [{ phrase: 'C++ (beta).', url: '/c' }, { phrase: 'a.b', url: '/ab' }] + assert.equal(autolink('

We use C++ (beta). today, axb too

', links, 5, '/x'), '

We use C++ (beta). today, axb too

') +}) + +test('autolink: escaped text and entities are matched, never split', () => { + const links = [{ phrase: 'Tom & Jerry', url: '/tj' }, { phrase: 'amp', url: '/amp' }, { phrase: 'lt', url: '/lt' }] + assert.equal(autolink('

Tom & Jerry <3

', links, 5, '/x'), '

Tom & Jerry <3

') + assert.equal(autolink('

&amp <lt

', links, 5, '/x'), '

&amp <lt

') +}) + +test('autolink: markup is only added between tags, never inside an attribute', () => { + const html = '

x

reishi

reishi

' + assert.equal(autolink(html, LINKS, 5, '/x'), '

x

reishi

reishi

') + const two = [{ phrase: 'reishi', url: '/r' }, { phrase: 'href', url: '/h' }] + assert.equal(autolink('

reishi href

', two, 5, '/x'), '

reishi href

') +}) + +test('autolink: URLs are validated and escaped (T-33-39)', () => { + const bad = [ + { phrase: 'one', url: 'javascript:alert(1)' }, + { phrase: 'two', url: '//evil.test/x' }, + { phrase: 'three', url: 'data:text/html,x' }, + { phrase: 'four', url: 'relative/path' }, + { phrase: '', url: '/empty' }, + ] + assert.equal(autolink('

one two three four

', bad, 5, '/x'), '

one two three four

') + const quoted = [{ phrase: 'q', url: '/a">?b=1&c=2' }] + assert.equal(autolink('

q

', quoted, 5, '/x'), '

q

') +}) + +test('autolink: no links or empty HTML returns the input', () => { + assert.equal(autolink('', LINKS, 5, '/x'), '') + assert.equal(autolink('

reishi

', [], 5, '/x'), '

reishi

') + assert.equal(autolink('

reishi

', null as never, 5, '/x'), '

reishi

') +}) + +const OUT = { siteHost: 'example.test', nofollowDomains: ['partner.test'], sponsoredDomains: ['ads.test'] } +const HINT = '(opens in a new tab)' + +test('applyOutbound: new tab on adds target, noopener and the hidden hint', () => { + assert.equal( + applyOutbound('

Other

', { ...OUT, newTab: true, hint: HINT }), + '

Other (opens in a new tab)

', + ) +}) + +test('applyOutbound: new tab on with the hint off adds target and noopener only', () => { + assert.equal( + applyOutbound('Other', { ...OUT, newTab: true, hint: '' }), + 'Other', + ) +}) + +test('applyOutbound: new tab off keeps editor rel and domain-list rel, no target, noopener or hint', () => { + assert.equal(applyOutbound('Other', { ...OUT, newTab: false, hint: HINT }), 'Other') + assert.equal(applyOutbound('P', { ...OUT, newTab: false, hint: HINT }), 'P') + assert.equal(applyOutbound('A', { ...OUT, newTab: false, hint: HINT }), 'A') +}) + +test('applyOutbound: editor rel kept and merged with the domain lists and noopener', () => { + assert.equal( + applyOutbound('P', { ...OUT, newTab: true, hint: '' }), + 'P', + ) + assert.equal( + applyOutbound('P', { ...OUT, newTab: true, hint: '' }), + 'P', + ) +}) + +test('applyOutbound: a domain list matches the domain and its subdomains, not look-alikes', () => { + assert.equal(applyOutbound('N', { ...OUT, newTab: false }), 'N') + assert.equal(applyOutbound('N', { ...OUT, newTab: false }), 'N') +}) + +test('applyOutbound: internal, relative and non-http links are unchanged', () => { + const opts = { ...OUT, newTab: true, hint: HINT } + for (const html of [ + 'In', + 'In', + 'In', + 'In', + 'Mail', + 'R', + 'No href', + ]) { + assert.equal(applyOutbound(html, opts), html, html) + } +}) + +test('applyOutbound: protocol-relative links to another host are outbound', () => { + assert.equal(applyOutbound('O', { ...OUT, newTab: true, hint: '' }), 'O') +}) + +test('applyOutbound: the hint text is escaped and lands before the anchor closes', () => { + assert.equal( + applyOutbound('B t

after

', { ...OUT, newTab: true, hint: '&' }), + 'B t <b>&

after

', + ) +}) + +test('applyOutbound: entity-encoded hrefs are read decoded and kept as written', () => { + assert.equal( + applyOutbound('P', { ...OUT, newTab: false }), + 'P', + ) +}) + +test('applyOutbound: the site host may carry a port and www is not stripped', () => { + assert.equal(applyOutbound('L', { siteHost: 'localhost:3000', newTab: true }), 'L') + assert.equal(applyOutbound('W', { ...OUT, newTab: true }), 'W') +}) + +test('imageAttrs: lazy loading and async decoding, stored attributes and alt="" kept', () => { + assert.equal(imageAttrs('

'), '

') + assert.equal(imageAttrs('A cat'), 'A cat') + assert.equal(imageAttrs(''), '') + assert.equal(imageAttrs('

x

'), '

x

') + assert.equal(imageAttrs(''), '') +})