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:
Jakub Zych
2026-11-04 13:46:54 +01:00
parent 289d031201
commit 1010a603f0
10 changed files with 255 additions and 21 deletions

View File

@@ -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>

View File

@@ -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)
}