From 1010a603f0ff465c3f2de6937423ebec6ad4dbc8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 13:46:54 +0100 Subject: [PATCH] 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 --- app/components/Journal/PostBody.vue | 68 ++++++++++++++++++++++++-- app/utils/htmlGate.ts | 66 ++++++++++++++++++++++--- package.json | 1 + testing/expect-acme-hint-off.txt | 5 ++ testing/expect-acme.txt | 12 +++++ testing/fixtures/acme/posts.json | 4 +- testing/fixtures/acme/seo/resolve.json | 43 +++++++++++++++- tests/embeds.test.ts | 4 +- tests/fixtures/editor-corpus.json | 10 +++- tests/htmlGate.test.ts | 63 ++++++++++++++++++++++-- 10 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 testing/expect-acme-hint-off.txt diff --git a/app/components/Journal/PostBody.vue b/app/components/Journal/PostBody.vue index fc71513..5d7cf40 100644 --- a/app/components/Journal/PostBody.vue +++ b/app/components/Journal/PostBody.vue @@ -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, + ), + ) +})