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:
@@ -3,16 +3,74 @@
|
||||
// sanitizer, which removes what the policy does not allow and keeps the rest.
|
||||
// The page passes an already sanitized body (with any embed placeholder still
|
||||
// a placeholder); sanitizing again here guards a caller that does not, and
|
||||
// gives the same string on server and client. expandEmbeds runs last: it
|
||||
// gives the same string on server and client.
|
||||
//
|
||||
// Then the UI-SPEC L7 steps run on the sanitized HTML (renderJournalBody):
|
||||
// h1 demoted to h2 while the Journal H1 setting is on (the page passes the
|
||||
// post's content_h1_to_h2 as demoteH1), the CMS phrase auto-links, the
|
||||
// outbound link rules (nofollow or sponsored from the domain lists; target,
|
||||
// noopener and the hidden new-tab hint while the CMS's links_new_tab is on
|
||||
// and the SEO-layer option seo.links.newTabHint allows the hint) and lazy,
|
||||
// async body images. The link steps are vue-seo-layer's (auto-imported) and
|
||||
// read the page's resolve answer, which the server hands to the client in
|
||||
// the payload, so both render the same string. expandEmbeds runs last: it
|
||||
// turns the Blog Editor's embed placeholders into privacy-enhanced iframes,
|
||||
// and its output is never sanitized again (the sanitizer would strip them).
|
||||
// demoteH1 turns body h1 headings into h2 while the Journal H1 setting is on
|
||||
// (the page passes the post's content_h1_to_h2).
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
import { renderJournalBody, type JournalBodyLinkSteps } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ html: string | null | undefined; demoteH1?: boolean }>()
|
||||
const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html, { demoteH1: props.demoteH1 === true })))
|
||||
|
||||
const answer = useSeoAnswer()
|
||||
const route = useRoute()
|
||||
const labels = useSeoLabels()
|
||||
const config = useRuntimeConfig()
|
||||
const request = useRequestURL()
|
||||
const steps: JournalBodyLinkSteps = { autolink, applyOutbound, imageAttrs }
|
||||
|
||||
const page = computed(() => {
|
||||
const a = answer.value?.action === 'ok' ? answer.value : null
|
||||
try {
|
||||
if (a?.url && /^https?:\/\//i.test(a.url)) {
|
||||
const u = new URL(a.url)
|
||||
return { answer: a, host: u.host, self: `${u.pathname}${u.search}` }
|
||||
}
|
||||
} catch {
|
||||
// not a URL: the request's own host and path
|
||||
}
|
||||
return { answer: a, host: request.host, self: route.fullPath }
|
||||
})
|
||||
|
||||
const hintOn = computed(() => {
|
||||
const v = (config.public.seo as { links?: { newTabHint?: unknown } } | undefined)?.links?.newTabHint
|
||||
return v !== false && v !== 'false'
|
||||
})
|
||||
|
||||
const html = computed(() => {
|
||||
const { answer: a, host, self } = page.value
|
||||
const out = a?.outbound
|
||||
return expandEmbeds(
|
||||
renderJournalBody(
|
||||
props.html,
|
||||
{
|
||||
demoteH1: props.demoteH1 === true,
|
||||
autolinks: a?.autolinks ?? null,
|
||||
autolinkMax: a?.autolink_max ?? 0,
|
||||
selfPath: self,
|
||||
outbound: out
|
||||
? {
|
||||
siteHost: host,
|
||||
newTab: out.new_tab === true,
|
||||
nofollowDomains: out.nofollow_domains ?? [],
|
||||
sponsoredDomains: out.sponsored_domains ?? [],
|
||||
hint: hintOn.value ? labels.value.links.newTab : '',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
steps,
|
||||
),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
// inline styles, goldmark's footnote roles, dir="auto" and two chat
|
||||
// transcript data attributes. URLs must be http, https, mailto or relative.
|
||||
//
|
||||
// The Blog Editor's own output adds exactly four rules, the same four the Go
|
||||
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id
|
||||
// (an 11-character video id), which carry the embed placeholder that
|
||||
// embeds.ts expands after sanitizing; img alt with any text (the editor's
|
||||
// free-text alt field); and ol start (an integer). tests/fixtures/
|
||||
// editor-corpus.json, a byte copy of the editor's corpus, pins them.
|
||||
// The Blog Editor's own output adds exactly five rules, the same five the Go
|
||||
// policy has: figure data-embed (youtube only) and data-id (an 11-character
|
||||
// video id), which carry the embed placeholder that embeds.ts expands after
|
||||
// sanitizing; img alt with any text (the editor's free-text alt field, ""
|
||||
// for a decorative image); ol start (an integer); and a rel holding only the
|
||||
// tokens nofollow, sponsored and ugc (the editor's link form, Phase 33).
|
||||
// tests/fixtures/editor-corpus.json, a byte copy of the editor's corpus,
|
||||
// pins them. target is never kept: the outbound rules add it after
|
||||
// sanitizing (renderJournalBody).
|
||||
//
|
||||
// The Journal API already sanitizes what FormatHTML writes. This is defence
|
||||
// in depth for rows written another way (direct SQL, legacy imports) and for
|
||||
@@ -62,6 +65,8 @@ const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i
|
||||
const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i
|
||||
const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/
|
||||
const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/
|
||||
// The editor's link rel (format_html.go): the three tokens, single spaces.
|
||||
const LINK_REL = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
|
||||
// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':',
|
||||
// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt")
|
||||
// does; sanitize-html escapes attribute values on output and alt carries no URL.
|
||||
@@ -105,7 +110,7 @@ const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
|
||||
details: { open: /^(|open)$/i },
|
||||
blockquote: { cite: null },
|
||||
q: { cite: null },
|
||||
a: { href: null, role: FOOTNOTE_ROLE },
|
||||
a: { href: null, role: FOOTNOTE_ROLE, rel: LINK_REL },
|
||||
div: {
|
||||
role: FOOTNOTE_ROLE,
|
||||
'data-message-author-role': /^[A-Za-z0-9_-]+$/,
|
||||
@@ -290,3 +295,50 @@ export function gateJournalHtml(html: string | null | undefined, options: Journa
|
||||
const clean = sanitizeHtml(html, demote ? POLICY_DEMOTE_H1 : POLICY)
|
||||
return { safe: clean === sanitizeHtml(html, demote ? KEEP_ALL_DEMOTE_H1 : KEEP_ALL), html: clean }
|
||||
}
|
||||
|
||||
/** The body link steps of vue-seo-layer (app/utils/bodyLinks.ts), passed in by the component. */
|
||||
export interface JournalBodyLinkSteps {
|
||||
autolink: (html: string, links: ReadonlyArray<{ phrase: string; url: string }>, max: number, self: string) => string
|
||||
applyOutbound: (
|
||||
html: string,
|
||||
opts: { siteHost: string; newTab: boolean; nofollowDomains?: readonly string[] | null; sponsoredDomains?: readonly string[] | null; hint?: string | null },
|
||||
) => string
|
||||
imageAttrs: (html: string) => string
|
||||
}
|
||||
|
||||
/** What the CMS resolve answer and the layer options give the body pipeline. */
|
||||
export interface JournalBodyOptions extends JournalHtmlGateOptions {
|
||||
/** The resolve answer's autolinks; none adds no link. */
|
||||
autolinks?: ReadonlyArray<{ phrase: string; url: string }> | null
|
||||
/** The resolve answer's autolink_max. */
|
||||
autolinkMax?: number | null
|
||||
/** The page's own path (or URL): never auto-linked. */
|
||||
selfPath?: string
|
||||
/** The outbound rules (the resolve answer's outbound); null leaves links as they are. */
|
||||
outbound?: {
|
||||
siteHost: string
|
||||
newTab: boolean
|
||||
nofollowDomains?: readonly string[] | null
|
||||
sponsoredDomains?: readonly string[] | null
|
||||
/** The new-tab hint text; '' adds none. */
|
||||
hint?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A post body for its v-html sink, in the UI-SPEC L7 order: sanitize (h1 to
|
||||
* h2 while demoteH1), phrase auto-links, outbound rules, image attributes.
|
||||
* The link steps are vue-seo-layer's, passed in by the caller (they are
|
||||
* auto-imported in components); without them the sanitized HTML is
|
||||
* returned, and a step without CMS data is skipped. Every step only adds
|
||||
* escaped markup to sanitized output, never re-reads the raw input.
|
||||
*/
|
||||
export function renderJournalBody(html: string | null | undefined, options: JournalBodyOptions = {}, steps?: JournalBodyLinkSteps | null): string {
|
||||
let out = sanitizeJournalHtml(html, { demoteH1: options.demoteH1 === true })
|
||||
if (!out || !steps) return out
|
||||
const links = Array.isArray(options.autolinks) ? options.autolinks : []
|
||||
const max = Number(options.autolinkMax) || 0
|
||||
if (links.length > 0 && max > 0) out = steps.autolink(out, links, max, options.selfPath ?? '')
|
||||
if (options.outbound) out = steps.applyOutbound(out, options.outbound)
|
||||
return steps.imageAttrs(out)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nuxi dev .playground",
|
||||
"build:playground": "nuxt build .playground",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts",
|
||||
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-hint-off.txt --env NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false",
|
||||
"dev:prepare": "nuxt prepare .playground",
|
||||
"check:git-install": "bash scripts/check-git-install.sh"
|
||||
},
|
||||
|
||||
5
testing/expect-acme-hint-off.txt
Normal file
5
testing/expect-acme-hint-off.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# SSR expectations with the SEO-layer option seo.links.newTabHint off
|
||||
# (NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false): outbound links still open in a
|
||||
# new tab with noopener, without the visually hidden hint (UI-SPEC L7).
|
||||
200 /blog/hello-acme contains=<a href="https://partner.test/review" rel="ugc nofollow noopener" target="_blank">the review</a> notcontains=(opens in a new tab)
|
||||
200 /blog/hello-acme contains=<a href="https://shop.ads.test/deal" target="_blank" rel="sponsored noopener">a deal</a>
|
||||
@@ -75,3 +75,15 @@
|
||||
# The header box renders its no-JS form to the search route on every page.
|
||||
200 /blog contains=action="/search"
|
||||
200 /pl/blog contains=action="/pl/search"
|
||||
|
||||
# Body links, outbound rules and images (UI-SPEC L7). hello-acme: new tab on
|
||||
# with the default hint; auto-links in document order up to autolink_max 2,
|
||||
# never in a heading, never to the page itself.
|
||||
200 /blog/hello-acme contains=<a href="/blog/acme-note-07">anvils</a> contains=<a href="/blog/acme-note-05">rockets</a> notcontains=<a href="/blog/acme-note-03"> notcontains=<a href="/blog/acme-note-06"> notcontains=<a href="https://acme.test/blog/hello-acme"
|
||||
200 /blog/hello-acme contains=<a href="https://partner.test/review" rel="ugc nofollow noopener" target="_blank">the review<span class="seo-visually-hidden"> (opens in a new tab)</span></a>
|
||||
200 /blog/hello-acme contains=<a href="https://shop.ads.test/deal" target="_blank" rel="sponsored noopener">a deal<span class="seo-visually-hidden"> (opens in a new tab)</span></a>
|
||||
200 /blog/hello-acme contains=<a href="/blog/acme-note-04">an older note</a>
|
||||
200 /blog/hello-acme contains=<img src="/storage/app/uploads/public/acme/anvil.png" alt="" width="640" height="480" loading="lazy" decoding="async" />
|
||||
# heading-off: new tab off keeps the editor rel and the domain-list nofollow,
|
||||
# without target, noopener or the hint.
|
||||
200 /blog/heading-off contains=<a href="https://partner.test/x" rel="ugc nofollow">the partner</a> contains=<a href="https://other.test/y">another site</a> notcontains=target="_blank" notcontains=noopener notcontains=(opens in a new tab)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"slug": "hello-acme",
|
||||
"excerpt": "Acme says hello and introduces its blog.",
|
||||
"content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n<p>Read <a href=\"https://partner.test/review\" rel=\"ugc\">the review</a>, <a href=\"https://shop.ads.test/deal\">a deal</a> and <a href=\"/blog/acme-note-04\">an older note</a>.</p>\n<p><img src=\"/storage/app/uploads/public/acme/anvil.png\" alt=\"\" width=\"640\" height=\"480\"></p>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2026-01-14T09:00:00+00:00",
|
||||
@@ -505,7 +505,7 @@
|
||||
"slug": "heading-off",
|
||||
"excerpt": "Heading Off Sample shows the Journal H1 setting off.",
|
||||
"content": null,
|
||||
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n",
|
||||
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n<p>See <a href=\"https://partner.test/x\" rel=\"ugc\">the partner</a> and <a href=\"https://other.test/y\">another site</a>.</p>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2025-12-19T09:00:00+00:00",
|
||||
|
||||
@@ -71,7 +71,39 @@
|
||||
"url": "https://acme.test/blog/hello-acme",
|
||||
"path": "/blog/hello-acme"
|
||||
}
|
||||
]
|
||||
],
|
||||
"autolinks": [
|
||||
{
|
||||
"phrase": "Acme",
|
||||
"url": "https://acme.test/blog/hello-acme"
|
||||
},
|
||||
{
|
||||
"phrase": "Welcome",
|
||||
"url": "/blog/acme-note-06"
|
||||
},
|
||||
{
|
||||
"phrase": "roller skates",
|
||||
"url": "/blog/acme-note-03"
|
||||
},
|
||||
{
|
||||
"phrase": "rockets",
|
||||
"url": "/blog/acme-note-05"
|
||||
},
|
||||
{
|
||||
"phrase": "anvils",
|
||||
"url": "/blog/acme-note-07"
|
||||
}
|
||||
],
|
||||
"autolink_max": 2,
|
||||
"outbound": {
|
||||
"new_tab": true,
|
||||
"nofollow_domains": [
|
||||
"partner.test"
|
||||
],
|
||||
"sponsored_domains": [
|
||||
"ads.test"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/blog/heading-on": {
|
||||
"action": "ok",
|
||||
@@ -219,7 +251,14 @@
|
||||
"url": "https://acme.test/blog/heading-off",
|
||||
"path": "/blog/heading-off"
|
||||
}
|
||||
]
|
||||
],
|
||||
"outbound": {
|
||||
"new_tab": false,
|
||||
"nofollow_domains": [
|
||||
"partner.test"
|
||||
],
|
||||
"sponsored_domains": []
|
||||
}
|
||||
},
|
||||
"/blog": {
|
||||
"action": "ok",
|
||||
|
||||
@@ -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