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
This commit is contained in:
331
app/utils/bodyLinks.ts
Normal file
331
app/utils/bodyLinks.ts
Normal file
@@ -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
|
||||
// <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('')
|
||||
}
|
||||
173
tests/bodyLinks.test.ts
Normal file
173
tests/bodyLinks.test.ts
Normal file
@@ -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 <a href>', () => {
|
||||
const html = '<p>Lwia grzywa to grzyb. Lwia grzywa rośnie na drzewach.</p>'
|
||||
assert.equal(autolink(html, LINKS, 5, '/blog/inne'), '<p><a href="/blog/lwia-grzywa">Lwia grzywa</a> to grzyb. Lwia grzywa rośnie na drzewach.</p>')
|
||||
})
|
||||
|
||||
test('autolink: whole words only', () => {
|
||||
assert.equal(autolink('<p>reishiowy napar</p>', LINKS, 5, '/x'), '<p>reishiowy napar</p>')
|
||||
assert.equal(autolink('<p>superreishi</p>', LINKS, 5, '/x'), '<p>superreishi</p>')
|
||||
assert.equal(autolink('<p>(reishi)</p>', LINKS, 5, '/x'), '<p>(<a href="https://example.test/blog/reishi">reishi</a>)</p>')
|
||||
assert.equal(autolink('<p>Żółć reishi.</p>', LINKS, 5, '/x'), '<p>Żółć <a href="https://example.test/blog/reishi">reishi</a>.</p>')
|
||||
})
|
||||
|
||||
test('autolink: at most max links in the whole text, each phrase once', () => {
|
||||
const html = '<p>reishi i lwia grzywa</p><p>reishi znowu</p>'
|
||||
assert.equal(autolink(html, LINKS, 1, '/x'), '<p><a href="https://example.test/blog/reishi">reishi</a> i lwia grzywa</p><p>reishi znowu</p>')
|
||||
assert.equal(autolink(html, LINKS, 0, '/x'), html)
|
||||
assert.equal(
|
||||
autolink(html, LINKS, 5, '/x'),
|
||||
'<p><a href="https://example.test/blog/reishi">reishi</a> i <a href="/blog/lwia-grzywa">lwia grzywa</a></p><p>reishi znowu</p>',
|
||||
)
|
||||
})
|
||||
|
||||
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 <strong>reishi</strong></${name}><p>reishi</p>`
|
||||
assert.equal(autolink(html, LINKS, 5, '/x'), `<${tag}>reishi <strong>reishi</strong></${name}><p><a href="https://example.test/blog/reishi">reishi</a></p>`, name)
|
||||
}
|
||||
})
|
||||
|
||||
test('autolink: never links the page to itself', () => {
|
||||
const html = '<p>lwia grzywa</p>'
|
||||
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('<p>reishi</p>', self, 5, '/blog/reishi'), '<p>reishi</p>')
|
||||
assert.equal(autolink('<p>reishi</p>', self, 5, 'https://other.test/blog/reishi'), '<p><a href="https://example.test/blog/reishi">reishi</a></p>')
|
||||
})
|
||||
|
||||
test('autolink: regex characters in a phrase match literally', () => {
|
||||
const links = [{ phrase: 'C++ (beta).', url: '/c' }, { phrase: 'a.b', url: '/ab' }]
|
||||
assert.equal(autolink('<p>We use C++ (beta). today, axb too</p>', links, 5, '/x'), '<p>We use <a href="/c">C++ (beta).</a> today, axb too</p>')
|
||||
})
|
||||
|
||||
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('<p>Tom & Jerry <3</p>', links, 5, '/x'), '<p><a href="/tj">Tom & Jerry</a> <3</p>')
|
||||
assert.equal(autolink('<p>&amp <lt</p>', links, 5, '/x'), '<p>&<a href="/amp">amp</a> <<a href="/lt">lt</a></p>')
|
||||
})
|
||||
|
||||
test('autolink: markup is only added between tags, never inside an attribute', () => {
|
||||
const html = '<p title="reishi">x</p><img src="/reishi.png" alt="reishi"><p>reishi</p>'
|
||||
assert.equal(autolink(html, LINKS, 5, '/x'), '<p title="reishi">x</p><img src="/reishi.png" alt="reishi"><p><a href="https://example.test/blog/reishi">reishi</a></p>')
|
||||
const two = [{ phrase: 'reishi', url: '/r' }, { phrase: 'href', url: '/h' }]
|
||||
assert.equal(autolink('<p>reishi href</p>', two, 5, '/x'), '<p><a href="/r">reishi</a> <a href="/h">href</a></p>')
|
||||
})
|
||||
|
||||
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('<p>one two three four</p>', bad, 5, '/x'), '<p>one two three four</p>')
|
||||
const quoted = [{ phrase: 'q', url: '/a"><script>alert(1)</script>?b=1&c=2' }]
|
||||
assert.equal(autolink('<p>q</p>', quoted, 5, '/x'), '<p><a href="/a"><script>alert(1)</script>?b=1&c=2">q</a></p>')
|
||||
})
|
||||
|
||||
test('autolink: no links or empty HTML returns the input', () => {
|
||||
assert.equal(autolink('', LINKS, 5, '/x'), '')
|
||||
assert.equal(autolink('<p>reishi</p>', [], 5, '/x'), '<p>reishi</p>')
|
||||
assert.equal(autolink('<p>reishi</p>', null as never, 5, '/x'), '<p>reishi</p>')
|
||||
})
|
||||
|
||||
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('<p><a href="https://other.test/x">Other</a></p>', { ...OUT, newTab: true, hint: HINT }),
|
||||
'<p><a href="https://other.test/x" target="_blank" rel="noopener">Other<span class="seo-visually-hidden"> (opens in a new tab)</span></a></p>',
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOutbound: new tab on with the hint off adds target and noopener only', () => {
|
||||
assert.equal(
|
||||
applyOutbound('<a href="https://other.test/x">Other</a>', { ...OUT, newTab: true, hint: '' }),
|
||||
'<a href="https://other.test/x" target="_blank" rel="noopener">Other</a>',
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOutbound: new tab off keeps editor rel and domain-list rel, no target, noopener or hint', () => {
|
||||
assert.equal(applyOutbound('<a rel="ugc" href="https://other.test/x">Other</a>', { ...OUT, newTab: false, hint: HINT }), '<a rel="ugc" href="https://other.test/x">Other</a>')
|
||||
assert.equal(applyOutbound('<a href="https://partner.test/x">P</a>', { ...OUT, newTab: false, hint: HINT }), '<a href="https://partner.test/x" rel="nofollow">P</a>')
|
||||
assert.equal(applyOutbound('<a href="https://shop.ads.test/x">A</a>', { ...OUT, newTab: false, hint: HINT }), '<a href="https://shop.ads.test/x" rel="sponsored">A</a>')
|
||||
})
|
||||
|
||||
test('applyOutbound: editor rel kept and merged with the domain lists and noopener', () => {
|
||||
assert.equal(
|
||||
applyOutbound('<a rel="nofollow sponsored" href="https://partner.test/x">P</a>', { ...OUT, newTab: true, hint: '' }),
|
||||
'<a rel="nofollow sponsored noopener" href="https://partner.test/x" target="_blank">P</a>',
|
||||
)
|
||||
assert.equal(
|
||||
applyOutbound('<a href="https://www.partner.test/x" rel="ugc">P</a>', { ...OUT, newTab: true, hint: '' }),
|
||||
'<a href="https://www.partner.test/x" rel="ugc nofollow noopener" target="_blank">P</a>',
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOutbound: a domain list matches the domain and its subdomains, not look-alikes', () => {
|
||||
assert.equal(applyOutbound('<a href="https://notpartner.test/">N</a>', { ...OUT, newTab: false }), '<a href="https://notpartner.test/">N</a>')
|
||||
assert.equal(applyOutbound('<a href="https://PARTNER.test/">N</a>', { ...OUT, newTab: false }), '<a href="https://PARTNER.test/" rel="nofollow">N</a>')
|
||||
})
|
||||
|
||||
test('applyOutbound: internal, relative and non-http links are unchanged', () => {
|
||||
const opts = { ...OUT, newTab: true, hint: HINT }
|
||||
for (const html of [
|
||||
'<a href="/blog/x">In</a>',
|
||||
'<a href="#notes">In</a>',
|
||||
'<a href="https://example.test/blog">In</a>',
|
||||
'<a href="http://EXAMPLE.test:443/blog" rel="nofollow">In</a>',
|
||||
'<a href="mailto:a@example.test">Mail</a>',
|
||||
'<a href="relative">R</a>',
|
||||
'<a>No href</a>',
|
||||
]) {
|
||||
assert.equal(applyOutbound(html, opts), html, html)
|
||||
}
|
||||
})
|
||||
|
||||
test('applyOutbound: protocol-relative links to another host are outbound', () => {
|
||||
assert.equal(applyOutbound('<a href="//other.test/x">O</a>', { ...OUT, newTab: true, hint: '' }), '<a href="//other.test/x" target="_blank" rel="noopener">O</a>')
|
||||
})
|
||||
|
||||
test('applyOutbound: the hint text is escaped and lands before the anchor closes', () => {
|
||||
assert.equal(
|
||||
applyOutbound('<a href="https://o.test/"><strong>B</strong> t</a><p>after</p>', { ...OUT, newTab: true, hint: '<b>&' }),
|
||||
'<a href="https://o.test/" target="_blank" rel="noopener"><strong>B</strong> t<span class="seo-visually-hidden"> <b>&</span></a><p>after</p>',
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOutbound: entity-encoded hrefs are read decoded and kept as written', () => {
|
||||
assert.equal(
|
||||
applyOutbound('<a href="https://partner.test/?a=1&b=2">P</a>', { ...OUT, newTab: false }),
|
||||
'<a href="https://partner.test/?a=1&b=2" rel="nofollow">P</a>',
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOutbound: the site host may carry a port and www is not stripped', () => {
|
||||
assert.equal(applyOutbound('<a href="http://localhost:3000/x">L</a>', { siteHost: 'localhost:3000', newTab: true }), '<a href="http://localhost:3000/x">L</a>')
|
||||
assert.equal(applyOutbound('<a href="https://www.example.test/x">W</a>', { ...OUT, newTab: true }), '<a href="https://www.example.test/x" target="_blank" rel="noopener">W</a>')
|
||||
})
|
||||
|
||||
test('imageAttrs: lazy loading and async decoding, stored attributes and alt="" kept', () => {
|
||||
assert.equal(imageAttrs('<p><img src="/a.png" alt="" width="640" height="480" /></p>'), '<p><img src="/a.png" alt="" width="640" height="480" loading="lazy" decoding="async" /></p>')
|
||||
assert.equal(imageAttrs('<img src="/a.png" alt="A cat">'), '<img src="/a.png" alt="A cat" loading="lazy" decoding="async">')
|
||||
assert.equal(imageAttrs('<img src="/a.png" loading="eager">'), '<img src="/a.png" loading="eager" decoding="async">')
|
||||
assert.equal(imageAttrs('<p title="<img>">x</p>'), '<p title="<img>">x</p>')
|
||||
assert.equal(imageAttrs(''), '')
|
||||
})
|
||||
Reference in New Issue
Block a user