feat: body links, outbound rules and image attributes in the post body
- renderJournalBody runs the UI-SPEC L7 order on the sanitized body: h1 demotion, the CMS phrase auto-links, the outbound rules and lazy, async images, with vue-seo-layer's autolink, applyOutbound and imageAttrs; PostBody feeds it the page's resolve answer (autolinks, autolink_max, outbound, url) and the seo.links.newTabHint option - the sanitizer keeps a link rel of only nofollow, sponsored and ugc (the Blog Editor's link form, as the Journal plugin's policy does); target is never kept, the outbound rules add it - editor-corpus.json copied again from sm-journal-plugin (link-rel and image-decorative entries) - smoke:ssr script; expectations for auto-links, both new-tab modes and the hint switched off
This commit is contained in:
@@ -131,7 +131,9 @@ test('the sinks expand after sanitizing and the payload transforms never expand'
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
for (const sink of ['../app/components/Journal/PostBody.vue', '../app/components/Journal/CategoryHeader.vue']) {
|
||||
const source = read(sink)
|
||||
assert.match(source, /expandEmbeds\(sanitizeJournalHtml\(/, `${sink} must render expandEmbeds(sanitizeJournalHtml(...))`)
|
||||
// renderJournalBody sanitizes first, then adds only escaped link and
|
||||
// image markup (htmlGate.test.ts), so it counts as the sanitizer here.
|
||||
assert.match(source, /expandEmbeds\(\s*(sanitizeJournalHtml|renderJournalBody)\(/, `${sink} must render expandEmbeds(sanitizeJournalHtml(...)) or expandEmbeds(renderJournalBody(...))`)
|
||||
assert.equal((source.match(/expandEmbeds\(/g) ?? []).length, 1, `${sink} expands once`)
|
||||
}
|
||||
for (const transform of ['../app/utils/postView.ts', '../app/journal-pages/CategoryPage.vue']) {
|
||||
|
||||
10
tests/fixtures/editor-corpus.json
vendored
10
tests/fixtures/editor-corpus.json
vendored
@@ -55,8 +55,16 @@
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "link-rel",
|
||||
"html": "<p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "image-decorative",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p><p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
||||
import { gateJournalHtml, isSafeJournalHtml, renderJournalBody, sanitizeJournalHtml, type JournalBodyLinkSteps } from '../app/utils/htmlGate.ts'
|
||||
|
||||
// Signs that something executable or navigating survived.
|
||||
const DANGER = [
|
||||
@@ -223,9 +223,9 @@ const CORPUS = JSON.parse(
|
||||
const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
|
||||
test('the editor corpus has the entries the mirror rules are for', () => {
|
||||
assert.equal(CORPUS.length, 15)
|
||||
assert.equal(CORPUS.length, 17)
|
||||
const all = CORPUS.map((e) => e.html).join('\n')
|
||||
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to "grzyb odporności"?"']) {
|
||||
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to "grzyb odporności"?"', 'rel="nofollow sponsored"', 'rel="ugc"', 'alt=""']) {
|
||||
assert.ok(all.includes(part), `corpus lacks ${part}`)
|
||||
}
|
||||
})
|
||||
@@ -355,3 +355,60 @@ test('the demotion is not a removal: safe HTML stays safe while demoted', () =>
|
||||
const html = '<h1>Start</h1><p>Text</p>'
|
||||
assert.deepEqual(gateJournalHtml(html, { demoteH1: true }), { safe: true, html: '<h2>Start</h2><p>Text</p>' })
|
||||
})
|
||||
|
||||
// The editor's link rel (33-02, format_html.go): only nofollow, sponsored and
|
||||
// ugc, single spaces; anything else drops the attribute. target never stays.
|
||||
test('link rel keeps only the nofollow, sponsored and ugc tokens', () => {
|
||||
for (const rel of ['nofollow', 'sponsored', 'ugc', 'nofollow sponsored', 'ugc nofollow sponsored']) {
|
||||
const html = `<a rel="${rel}" href="https://partner.test/">x</a>`
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
|
||||
}
|
||||
for (const rel of ['noopener', 'nofollow noopener', 'NOFOLLOW', 'nofollow ugc', ' nofollow', 'external']) {
|
||||
assert.equal(sanitizeJournalHtml(`<a rel="${rel}" href="https://partner.test/">x</a>`), '<a href="https://partner.test/">x</a>', rel)
|
||||
}
|
||||
assert.equal(sanitizeJournalHtml('<a rel="nofollow" target="_blank" href="https://partner.test/">x</a>'), '<a rel="nofollow" href="https://partner.test/">x</a>')
|
||||
assert.equal(sanitizeJournalHtml('<p rel="nofollow">x</p>'), '<p>x</p>')
|
||||
})
|
||||
|
||||
test('a decorative image keeps alt=""', () => {
|
||||
assert.deepEqual(gateJournalHtml('<p><img src="/a.png" alt=""></p>'), { safe: true, html: '<p><img src="/a.png" alt="" /></p>' })
|
||||
})
|
||||
|
||||
// renderJournalBody runs the UI-SPEC L7 order on the sanitized HTML.
|
||||
function recordingSteps(calls: string[]): JournalBodyLinkSteps {
|
||||
return {
|
||||
autolink: (html, links, max, self) => {
|
||||
calls.push(`autolink ${links.length} ${max} ${self} ${html}`)
|
||||
return `${html}[a]`
|
||||
},
|
||||
applyOutbound: (html, opts) => {
|
||||
calls.push(`outbound ${opts.siteHost} ${opts.newTab} ${opts.hint} ${html}`)
|
||||
return `${html}[o]`
|
||||
},
|
||||
imageAttrs: (html) => {
|
||||
calls.push(`images ${html}`)
|
||||
return `${html}[i]`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('renderJournalBody: demotion, auto-links, outbound rules, then image attributes', () => {
|
||||
const calls: string[] = []
|
||||
const out = renderJournalBody(
|
||||
'<h1>T</h1><script>x</script>',
|
||||
{ demoteH1: true, autolinks: [{ phrase: 'T', url: '/t' }], autolinkMax: 3, selfPath: '/self', outbound: { siteHost: 'acme.test', newTab: true, hint: 'h' } },
|
||||
recordingSteps(calls),
|
||||
)
|
||||
assert.equal(out, '<h2>T</h2>[a][o][i]')
|
||||
assert.deepEqual(calls, ['autolink 1 3 /self <h2>T</h2>', 'outbound acme.test true h <h2>T</h2>[a]', 'images <h2>T</h2>[a][o]'])
|
||||
})
|
||||
|
||||
test('renderJournalBody: steps without CMS data are skipped, no steps means sanitize only', () => {
|
||||
const calls: string[] = []
|
||||
assert.equal(renderJournalBody('<h1>T</h1>', { autolinks: [], outbound: null }, recordingSteps(calls)), '<h1>T</h1>[i]')
|
||||
assert.deepEqual(calls, ['images <h1>T</h1>'])
|
||||
const none: string[] = []
|
||||
assert.equal(renderJournalBody('<p>x</p>', { autolinks: [{ phrase: 'x', url: '/x' }], autolinkMax: 0 }, recordingSteps(none)), '<p>x</p>[i]')
|
||||
assert.equal(renderJournalBody('<h1>T</h1><script>x</script>', { demoteH1: true }), '<h2>T</h2>')
|
||||
assert.equal(renderJournalBody('', {}, recordingSteps([])), '')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user