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.
This commit is contained in:
Jakub Zych
2026-11-04 13:37:11 +01:00
parent 1dd3176060
commit 289d031201
31 changed files with 1034 additions and 2846 deletions

View File

@@ -1,48 +0,0 @@
// POST /api/__site__/cache/purge: removes every Nitro route-cache entry
// (pages, _payload.json and feeds), so an admin save shows on the next page
// view. The backend calls it on loopback after the save commits, with
// `Authorization: Bearer <runtimeConfig.cachePurge.token>`
// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"<kind>","slug":"<slug>"};
// the answer is {"purged":<n>}. The kind and slug only reach the log line.
//
// 404 without a token of at least 32 characters, and for any request that
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
// unless runtimeConfig.cachePurge.allowProxied
// (NUXT_CACHE_PURGE_ALLOW_PROXIED) is true or "true": a backend that reaches
// the site through a TLS-terminating proxy opts in, and the bearer token is
// then the only gate. 401 for a missing or wrong bearer token. The
// '/api/__site__/**' route rule keeps this route out of the route cache.
//
// Deprecated for one release: siteCache.purgeToken
// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see
// resolveSitePurgeConfig).
//
// Shared: several layers ship this file byte for byte, and Nitro keeps one
// handler per route and method.
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts'
const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store')
const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event))
const verdict = authorizeSitePurge({
token,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied,
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
if (verdict === 'unauthorized') {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null))
const purged = await purgeSiteRouteCache(useStorage('cache'))
console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`)
return { purged }
})

View File

@@ -1,40 +0,0 @@
// Sitemap source for Journal content (sitemap.sources in the layer config).
// It pages GET /posts at per_page=30 until meta.last_page, fetches the
// category tree with include_empty=1, and builds every loc from
// journal.routes. The API base is server runtime config, never request input.
import { createJournalClient, type JournalFetcher } from '../../../app/utils/client.ts'
import { buildJournalSitemapEntries } from '../../../app/utils/sitemap.ts'
import type { JournalPost, JournalRoutes } from '../../../app/utils/types.ts'
/** Hard stop for a runaway last_page (30 000 posts). */
const MAX_PAGES = 1000
export default defineSitemapEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const base = (config.journal as { apiBase: string }).apiBase
const routes = (config.public.journal as { routes: JournalRoutes }).routes
// Recorded by server/plugins/journal-client-address.ts before any route
// cache hides the request headers.
const forwarded =
(event.context.journalForwardedFor as string | undefined) ||
[getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
.filter((v): v is string => typeof v === 'string' && v !== '')
.join(', ')
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
const api = createJournalClient(fetcher)
const posts: JournalPost[] = []
if (routes.post !== false) {
for (let page = 1; page <= MAX_PAGES; page++) {
const res = await api.listPosts({ page, perPage: 30 })
posts.push(...res.data)
if (page >= res.meta.last_page) break
}
}
const categories = routes.category !== false ? await api.listCategories() : []
return buildJournalSitemapEntries(posts, categories, routes)
})