- 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
79 lines
3.0 KiB
Vue
79 lines
3.0 KiB
Vue
<script setup lang="ts">
|
|
// The post body. v-html only ever receives HTML that went through the Journal
|
|
// 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.
|
|
//
|
|
// 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).
|
|
import { expandEmbeds } from '../../utils/embeds.ts'
|
|
import { renderJournalBody, type JournalBodyLinkSteps } from '../../utils/htmlGate.ts'
|
|
|
|
const props = defineProps<{ html: string | null | undefined; demoteH1?: boolean }>()
|
|
|
|
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>
|
|
<div class="journal-post-body" v-html="html" />
|
|
</template>
|