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
174 lines
9.9 KiB
TypeScript
174 lines
9.9 KiB
TypeScript
// 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(''), '')
|
|
})
|