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

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

View File

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

View File

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

View File

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