From 289d0312015be41e30041e329399b5d4124e487a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 13:37:11 +0100 Subject: [PATCH] feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer) - extend vue-seo-layer (link dependency during development) and drop @nuxtjs/seo: the page head, canonical and the one JSON-LD graph come from the CMS resolve answer; the post's own title, excerpt and image apply only while there is no CMS answer - remove the journal sitemap source and the copied shared purge route, library and plugin (the SEO layer serves POST /api/__site__/cache/purge); the deprecated POST /api/__journal__/cache/purge keeps working - post title is h1 while the Journal API reports content_h1_to_h2 on (or absent) and p.journal-post__title while off; journal.postTitleTag ('auto' | 'h1' | 'p') overrides it; body h1 headings are demoted to h2 while the setting is on (gateJournalHtml/sanitizeJournalHtml demoteH1) - journal.breadcrumbs option and post-title CSS tokens - smoke harness: count=, header=, noheader= and resolves lines; the fixture API also answers the SEO resolve and site endpoints BREAKING CHANGE: the layer no longer installs @nuxtjs/seo (no nuxt-site-config, nuxt-schema-org, robots or sitemap module, no /api/__sitemap__/journal source, no useSiteConfig()); apps take their head, sitemaps and robots from the SEO plugin through vue-seo-layer. --- .playground/nuxt.config.ts | 1 - app/assets/css/journal-base.css | 6 + app/assets/css/journal-tokens.css | 3 + app/components/Journal/PostBody.vue | 6 +- app/journal-pages/AuthorPage.vue | 7 +- app/journal-pages/PostPage.vue | 36 +- app/journal-pages/SearchPage.vue | 7 +- app/utils/htmlGate.ts | 38 +- app/utils/postView.ts | 16 +- app/utils/seo.ts | 39 +- app/utils/sitemap.ts | 40 - app/utils/types.ts | 21 + lib/journal-module.ts | 19 +- nuxt.config.ts | 61 +- package.json | 4 +- pnpm-lock.yaml | 1969 ++--------------------- server/api/__site__/cache/purge.post.ts | 48 - server/api/__sitemap__/journal.ts | 40 - server/lib/siteCachePurge.ts | 208 --- server/plugins/site-cache-purge.ts | 48 - testing/expect-acme.txt | 30 +- testing/fixture-api.mjs | 40 +- testing/fixtures/acme/posts.json | 46 + testing/fixtures/acme/seo/resolve.json | 412 +++++ testing/fixtures/acme/seo/site.json | 38 + testing/nuxt-server.mjs | 4 +- testing/smoke-ssr.mjs | 106 +- tests/htmlGate.test.ts | 22 + tests/seo.test.ts | 29 +- tests/sitePurge.test.ts | 438 ----- tests/sitemap.test.ts | 98 -- 31 files changed, 1034 insertions(+), 2846 deletions(-) delete mode 100644 app/utils/sitemap.ts delete mode 100644 server/api/__site__/cache/purge.post.ts delete mode 100644 server/api/__sitemap__/journal.ts delete mode 100644 server/lib/siteCachePurge.ts delete mode 100644 server/plugins/site-cache-purge.ts create mode 100644 testing/fixtures/acme/seo/resolve.json create mode 100644 testing/fixtures/acme/seo/site.json delete mode 100644 tests/sitePurge.test.ts delete mode 100644 tests/sitemap.test.ts diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index 3298c98..e8d5999 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -3,7 +3,6 @@ export default defineNuxtConfig({ extends: ['..'], compatibilityDate: '2025-07-15', - site: { url: 'https://acme.test', name: 'Acme Blog' }, // Route-cached like a real host, so testing/purge-check.mjs can prove a // purge clears cached pages. routeRules: { '/**': { swr: 300 } }, diff --git a/app/assets/css/journal-base.css b/app/assets/css/journal-base.css index c0b45f5..8561a2b 100644 --- a/app/assets/css/journal-base.css +++ b/app/assets/css/journal-base.css @@ -58,8 +58,14 @@ margin: 0 auto; } +/* The title is an h1 or a p (the Journal H1 setting, UI-SPEC L6); the class + carries its look, so both elements render the same. */ .journal-post__title { margin: 0 0 var(--journal-space-3); + font-family: var(--journal-font-heading); + font-size: var(--journal-post-title-size); + font-weight: var(--journal-post-title-weight); + line-height: var(--journal-post-title-line-height); } .journal-post-meta { diff --git a/app/assets/css/journal-tokens.css b/app/assets/css/journal-tokens.css index 229c538..7718049 100644 --- a/app/assets/css/journal-tokens.css +++ b/app/assets/css/journal-tokens.css @@ -20,6 +20,9 @@ --journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */ --journal-font-size-base: 1.0625rem; /* body size */ --journal-line-height: 1.7; /* body leading */ + --journal-post-title-size: 2em; /* post title, h1 or p (browser h1 look) */ + --journal-post-title-weight: 700; /* post title weight */ + --journal-post-title-line-height: 1.2; /* post title leading */ /* Spacing scale */ --journal-space-1: 0.25rem; /* hairline gaps */ diff --git a/app/components/Journal/PostBody.vue b/app/components/Journal/PostBody.vue index 4cd7eae..fc71513 100644 --- a/app/components/Journal/PostBody.vue +++ b/app/components/Journal/PostBody.vue @@ -6,11 +6,13 @@ // gives the same string on server and client. 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' -const props = defineProps<{ html: string | null | undefined }>() -const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html))) +const props = defineProps<{ html: string | null | undefined; demoteH1?: boolean }>() +const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html, { demoteH1: props.demoteH1 === true })))