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

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

View File

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

View File

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

View File

@@ -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 })))
</script>
<template>

View File

@@ -2,9 +2,9 @@
// journal-author: an author and their published posts, paginated with
// ?page=N. {author} is the author slug, or a login with published posts. An
// unknown author or an out-of-range page is a real 404. When the visitor came
// in by login, the canonical points at the slug URL.
// in by login, the canonical points at the slug URL (a CMS canonical wins).
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { absoluteUrl, listTitle } from '../utils/seo.ts'
import { absoluteUrl, journalSiteOrigin, listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
@@ -13,7 +13,6 @@ definePageMeta({ key: (route) => route.fullPath })
const route = useRoute()
const api = useJournalApi()
const links = useJournalLinks()
const site = useSiteConfig()
const { t } = useI18n()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const param = String(route.params.author ?? '')
@@ -44,7 +43,7 @@ useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
if (author.slug !== param) {
const path = links.authorLink(author.slug)
if (path) {
const href = absoluteUrl(page > 1 ? `${path}?page=${page}` : path, site.url || useRequestURL().origin)
const href = absoluteUrl(page > 1 ? `${path}?page=${page}` : path, journalSiteOrigin(useSeoAnswer().value?.url, useRequestURL().origin))
useHead({ link: [{ rel: 'canonical', href, key: 'canonical' }] })
}
}

View File

@@ -3,8 +3,17 @@
// is a real 404. The transform sanitizes the body before it reaches the payload.
// Related posts fall back to the latest posts when the API has none (it
// returns none for a post without a category).
import { postSeoMeta } from '../utils/seo.ts'
import { toPostView } from '../utils/postView.ts'
//
// One H1 per page in both Journal H1 modes (UI-SPEC L6): while the Journal
// API reports content_h1_to_h2 on (or leaves it out), the title is the h1 and
// the body's h1 headings become h2; while it is off, the title is a
// p.journal-post__title (same class, same look) and the body's own h1 is the
// page's H1. journal.postTitleTag 'h1' or 'p' forces the title's element.
//
// The head comes from the CMS through vue-seo-layer; the post's own title,
// excerpt and featured image apply only while there is no CMS answer.
import { cmsHeadPresent, journalPostTitleTag, journalSiteOrigin, postSeoMeta } from '../utils/seo.ts'
import { demotesH1, toPostView } from '../utils/postView.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
@@ -37,17 +46,34 @@ if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin))
const answer = useSeoAnswer()
const requestOrigin = useRequestURL().origin
const fallback = computed(() =>
post.value && !cmsHeadPresent(answer.value) ? postSeoMeta(post.value, journalSiteOrigin(answer.value?.url, requestOrigin)) : null,
)
useSeoMeta({
title: () => fallback.value?.title,
ogTitle: () => fallback.value?.ogTitle,
description: () => fallback.value?.description,
ogDescription: () => fallback.value?.ogDescription,
ogType: () => fallback.value?.ogType,
ogImage: () => fallback.value?.ogImage,
articlePublishedTime: () => fallback.value?.articlePublishedTime,
articleModifiedTime: () => fallback.value?.articleModifiedTime,
})
const titleTag = computed(() => journalPostTitleTag(cfg.postTitleTag, post.value?.content_h1_to_h2))
const demoteH1 = computed(() => demotesH1(post.value))
</script>
<template>
<article v-if="post" class="journal-post">
<header class="journal-post__header">
<h1 class="journal-post__title">{{ post.title }}</h1>
<component :is="titleTag" class="journal-post__title">{{ post.title }}</component>
<JournalPostMeta :post="post" />
</header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
<JournalPostBody :html="post.content_html" />
<JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
<NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)"

View File

@@ -2,8 +2,9 @@
// journal-search: the posts matching ?q, paginated with ?page=N, in the API's
// relevance order (no sort is sent). Rendered on the server, so the form and
// the results work without JS. The module keeps the page out of the route
// cache and the sitemap and marks it noindex, follow. An invalid or
// out-of-range page is a real 404.
// cache; the page meta marks it noindex, follow (the SEO layer's route
// robots, which win over the CMS's and also leave it out of the route
// report). An invalid or out-of-range page is a real 404.
import { JOURNAL_SEARCH_MIN_LENGTH } from '../utils/client.ts'
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { pluralForm, searchTermFromQuery, searchTermState } from '../utils/search.ts'
@@ -11,7 +12,7 @@ import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
definePageMeta({ key: (route) => route.fullPath, seo: { robots: 'noindex, follow' } })
const route = useRoute()
const api = useJournalApi()

View File

@@ -238,16 +238,34 @@ const KEEP_ALL: Options = {
nonBooleanAttributes: [],
}
// H1 demotion (the Journal H1 setting, UI-SPEC L7 step 1): every h1 becomes
// an h2 with the same attributes. A tag's own transform replaces the '*'
// one in sanitize-html, so the policy's demotion still filters the values.
const demoted = (filter: (tag: string, attribs: sanitizeHtml.Attributes) => sanitizeHtml.Tag) => (_tag: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag => ({ tagName: 'h2', attribs: filter('h1', attribs).attribs })
const POLICY_DEMOTE_H1: Options = { ...POLICY, transformTags: { ...POLICY.transformTags, h1: demoted(filterAttributeValues) } }
const KEEP_ALL_DEMOTE_H1: Options = { ...KEEP_ALL, transformTags: { h1: demoted((tagName, attribs) => ({ tagName, attribs })) } }
/** Options of the gate. */
export interface JournalHtmlGateOptions {
/**
* Turn every h1 into an h2: true while the Journal API reports its H1
* setting on (content_h1_to_h2), so the page title is the page's only
* H1. Default false, the HTML as written.
*/
demoteH1?: boolean
}
/**
* Sanitizes journal HTML with the Journal policy. Whatever the policy does
* not allow is removed (the text of a removed element is kept, except for
* script, style and similar), so the result is never blank because of one
* stray attribute. Null or empty input gives "". Pure and deterministic: the
* same input gives the same string on the server and in the browser.
* stray attribute. With demoteH1, h1 headings come out as h2. Null or empty
* input gives "". Pure and deterministic: the same input gives the same
* string on the server and in the browser.
*/
export function sanitizeJournalHtml(html: string | null | undefined): string {
export function sanitizeJournalHtml(html: string | null | undefined, options: JournalHtmlGateOptions = {}): string {
if (!html) return ''
return sanitizeHtml(html, POLICY)
return sanitizeHtml(html, options.demoteH1 ? POLICY_DEMOTE_H1 : POLICY)
}
/**
@@ -262,11 +280,13 @@ export function isSafeJournalHtml(html: string | null | undefined): boolean {
/**
* Gate result for a v-html sink. `html` is always the sanitized value and is
* what the sink renders; `safe` reports whether the policy kept the input
* whole (isSafeJournalHtml). It never blanks: unsafe parts are removed, not
* the value. Null or empty input is safe and "".
* whole (isSafeJournalHtml; the H1 demotion is not a removal and never makes
* it false). It never blanks: unsafe parts are removed, not the value. Null
* or empty input is safe and "".
*/
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
export function gateJournalHtml(html: string | null | undefined, options: JournalHtmlGateOptions = {}): { safe: boolean; html: string } {
if (!html) return { safe: true, html: '' }
const clean = sanitizeHtml(html, POLICY)
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
const demote = options.demoteH1 === true
const clean = sanitizeHtml(html, demote ? POLICY_DEMOTE_H1 : POLICY)
return { safe: clean === sanitizeHtml(html, demote ? KEEP_ALL_DEMOTE_H1 : KEEP_ALL), html: clean }
}

View File

@@ -1,12 +1,20 @@
// The post page's SSR payload: the post with its body sanitized, without the
// raw `content` source or `translations`, and with related posts as list
// rows. Kept apart from view.ts because it loads the sanitizer, which list
// The post page's SSR payload: the post with its body sanitized (h1 demoted
// to h2 while the Journal H1 setting is on), without the raw `content`
// source or `translations`, and with related posts as list rows. Kept apart from view.ts because it loads the sanitizer, which list
// pages and cards do not need.
import { sanitizeJournalHtml } from './htmlGate.ts'
import type { JournalPostDetail } from './types.ts'
import { toListPost } from './view.ts'
/**
* True while the post's body h1 headings become h2 (the Journal API's
* content_h1_to_h2; absent on an older Journal, which counts as on).
*/
export function demotesH1(post: { content_h1_to_h2?: boolean | null } | null | undefined): boolean {
return post?.content_h1_to_h2 !== false
}
/** A post page's data: the post with its body sanitized and no source or translations. */
export type JournalPostView = JournalPostDetail
@@ -15,7 +23,7 @@ export function toPostView(post: JournalPostDetail): JournalPostView {
...post,
content: null,
translations: null,
content_html: sanitizeJournalHtml(post.content_html),
content_html: sanitizeJournalHtml(post.content_html, { demoteH1: demotesH1(post) }),
related_posts: (post.related_posts ?? []).map(toListPost),
}
}

View File

@@ -1,5 +1,8 @@
// SEO values for Journal pages, fed to useSeoMeta. The site name suffix comes
// from the app's site.name through the SEO title template, not from here.
// SEO values for Journal pages. The page head comes from the CMS through
// vue-seo-layer (useCmsSeo); these values are the fallback a page applies
// only while there is no CMS answer for it (the SEO layer off with seo.cms
// false, the CMS unreachable, or a page the CMS does not know), so a site
// without the SEO plugin keeps its titles and no page gets two of them.
import type { JournalPost } from './types.ts'
@@ -64,6 +67,38 @@ export function postSeoMeta(post: JournalPost, siteUrl?: string | null): Journal
return meta
}
/** True when the resolve answer sets the page head (an ok answer with head values). */
export function cmsHeadPresent(answer: { action?: string; seo?: unknown } | null | undefined): boolean {
return answer?.action === 'ok' && !!answer.seo && typeof answer.seo === 'object'
}
/**
* The origin to make page URLs absolute on: the CMS's page URL (the site
* URL the backend knows) when there is an answer, else `fallback` (the
* request's origin).
*/
export function journalSiteOrigin(answerUrl: string | null | undefined, fallback: string): string {
if (typeof answerUrl === 'string' && /^https?:\/\//i.test(answerUrl)) {
try {
return new URL(answerUrl).origin
} catch {
// fall through
}
}
return fallback
}
/**
* The post title's element (UI-SPEC L6): the option journal.postTitleTag
* when it is 'h1' or 'p', otherwise ('auto') h1 while the Journal H1 setting
* is on (content_h1_to_h2 true or absent) and p while it is off, so the
* page keeps exactly one H1 either way.
*/
export function journalPostTitleTag(option: unknown, contentH1ToH2: boolean | null | undefined): 'h1' | 'p' {
if (option === 'h1' || option === 'p') return option
return contentH1ToH2 === false ? 'p' : 'h1'
}
/** A listing title; pages after the first append the "Page N" string. */
export function listTitle(base: string, page: number, pageTitle: string): string {
return page > 1 ? `${base} · ${pageTitle}` : base

View File

@@ -1,40 +0,0 @@
// Sitemap entries for Journal content. Every loc is built from journal.routes,
// so moving or disabling a view moves or drops its URLs; nothing hard-codes
// a path.
import { flattenCategories } from './client.ts'
import { fillJournalPath } from './routes.ts'
import type { JournalCategory, JournalPost, JournalRoutes } from './types.ts'
export interface JournalSitemapEntry {
loc: string
lastmod?: string
}
/**
* One entry per published post (lastmod = updated_at) at routes.post, and one
* per category of the flattened tree at routes.category. A disabled route
* contributes nothing.
*/
export function buildJournalSitemapEntries(
posts: readonly JournalPost[],
categories: readonly JournalCategory[],
routes: JournalRoutes,
): JournalSitemapEntry[] {
const entries: JournalSitemapEntry[] = []
if (routes.post !== false) {
for (const post of posts) {
if (!post.published || !post.slug) continue
const entry: JournalSitemapEntry = { loc: fillJournalPath(routes.post, { slug: post.slug }) }
if (post.updated_at) entry.lastmod = post.updated_at
entries.push(entry)
}
}
if (routes.category !== false) {
for (const category of flattenCategories(categories)) {
if (!category.slug) continue
entries.push({ loc: fillJournalPath(routes.category, { category: category.slug }) })
}
}
return entries
}

View File

@@ -77,6 +77,12 @@ export interface JournalPostDetail extends JournalPost {
next_post: JournalPostSibling | null
/** Latest 3 in the same categories; empty when the post has no category. */
related_posts: JournalPost[]
/**
* The Journal H1 setting (newer Journal APIs): true while stored
* content has its H1 headings converted to H2, so the post title is the
* page's H1. Absent on an older Journal, which counts as true.
*/
content_h1_to_h2?: boolean
}
/** One node of the GET /categories tree. */
@@ -158,4 +164,19 @@ export interface JournalModuleOptions {
relatedCount: number
/** Whether /rss.xml is served and linked. */
rss: boolean
/**
* The post page title's element: 'auto' (default) follows the Journal H1
* setting (h1 while content_h1_to_h2 is on, p while it is off), 'h1' or
* 'p' force it. NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG at runtime.
*/
postTitleTag: JournalPostTitleTag
/**
* Whether the journal pages render <SeoBreadcrumbs> at the top of their
* content (default true). false for a site whose layout places them.
* NUXT_PUBLIC_JOURNAL_BREADCRUMBS at runtime.
*/
breadcrumbs: boolean
}
/** journal.postTitleTag. */
export type JournalPostTitleTag = 'auto' | 'h1' | 'p'

View File

@@ -8,7 +8,7 @@
import { fileURLToPath } from 'node:url'
import { defineNuxtModule, extendPages } from 'nuxt/kit'
import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalModuleOptions, JournalRoutes } from '../app/utils/types.ts'
import type { JournalModuleOptions, JournalPostTitleTag, JournalRoutes } from '../app/utils/types.ts'
const PAGE_FILES: Record<keyof JournalRoutes, string> = {
index: 'PostIndexPage',
@@ -33,6 +33,11 @@ function enabledLocaleCodes(locales: unknown): string[] {
.filter((c): c is string => typeof c === 'string')
}
/** journal.postTitleTag: 'h1' or 'p' as given, anything else 'auto'. */
function postTitleTag(value: unknown): JournalPostTitleTag {
return value === 'h1' || value === 'p' ? value : 'auto'
}
export default defineNuxtModule<JournalModuleOptions>({
meta: {
name: 'vue-journal-layer',
@@ -44,6 +49,8 @@ export default defineNuxtModule<JournalModuleOptions>({
sort: 'published_at desc',
relatedCount: 3,
rss: true,
postTitleTag: 'auto',
breadcrumbs: true,
},
setup(options, nuxt) {
const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>)
@@ -58,9 +65,11 @@ export default defineNuxtModule<JournalModuleOptions>({
// The search page renders per ?q, so it is never route-cached: Nitro's
// cache keys by path, and an app rule such as '/**': { swr } would serve
// one query's results for every query. It is noindex, follow and left
// out of the sitemap. The locale-prefixed copies get the same rules. An
// app's own rule for the path wins key by key.
// one query's results for every query. It is noindex, follow (the
// page's own meta sets that through vue-seo-layer; the robots rule and
// the sitemap exclude serve an app that adds nuxt-robots or
// @nuxtjs/sitemap itself). The locale-prefixed copies get the same
// rules. An app's own rule for the path wins key by key.
if (routes.search !== false) {
const i18n = (nuxt.options as { i18n?: { locales?: unknown } }).i18n
const paths = [routes.search, ...enabledLocaleCodes(i18n?.locales).map((code) => `/${code}${routes.search}`)]
@@ -95,6 +104,8 @@ export default defineNuxtModule<JournalModuleOptions>({
sort: options.sort,
relatedCount: options.relatedCount,
rss: options.rss,
postTitleTag: postTitleTag(options.postTitleTag),
breadcrumbs: options.breadcrumbs !== false,
}
},
})

View File

@@ -12,13 +12,20 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
// The SEO layer owns the page head (title, description, canonical,
// robots, Open Graph, hreflang and the one JSON-LD @graph) from the CMS
// resolve answer, the shared site cache purge route and the visible
// breadcrumbs and pagination. Resolved from this layer's own
// node_modules.
extends: ['vue-seo-layer'],
// The consuming app may have no app/pages directory; the Journal pages are
// registered by the journal module, so the router must be on regardless.
pages: true,
// Declared once here, so the app never re-lists them (two copies of
// @nuxtjs/i18n split locale state).
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
modules: ['@nuxt/fonts', '@nuxtjs/i18n', `${currentDir}lib/journal-module.ts`],
// Tokens first, then the neutral styles that use them. The app's own css
// array loads after these, so its token overrides win.
@@ -31,35 +38,11 @@ export default defineNuxtConfig({
apiBase: 'http://127.0.0.1:8096/_journal/api/v1',
// Server-only bearer token of the old Journal purge route
// POST /api/__journal__/cache/purge (NUXT_JOURNAL_CACHE_PURGE_TOKEN),
// kept for one release; new sites use cachePurge.token. Shorter
// than 32 characters, the route answers 404.
// kept for one release; new sites use cachePurge.token, the SEO
// layer's POST /api/__site__/cache/purge. Shorter than 32 characters,
// the route answers 404.
cachePurgeToken: '',
},
cachePurge: {
// Server-only bearer token of the shared site cache purge route
// POST /api/__site__/cache/purge (NUXT_CACHE_PURGE_TOKEN), the
// backend's site_cache.purge_token. Shorter than 32 characters, the
// route answers 404. Not under NUXT_SITE_: nuxt-site-config copies
// every NUXT_SITE_* variable into the site config, which is published
// in every page's payload.
token: '',
// NUXT_CACHE_PURGE_ALLOW_PROXIED: set it to true when the backend
// reaches the site through a TLS-terminating proxy (an https
// site_cache.purge_url on another host), which adds X-Forwarded-For,
// X-Real-IP or Forwarded. The route then accepts proxied requests and
// the bearer token is the only gate; unset, it answers 404 to any
// proxied request.
allowProxied: false,
},
// Deprecated (one release): the earlier names of cachePurge.token
// (NUXT_SITE_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new ones are unset. The
// layer's server/plugins/site-cache-purge.ts strips them from the site
// config and logs a deprecation warning.
siteCache: {
purgeToken: '',
allowProxied: false,
},
public: {
journal: {
// Same-origin base for browser fetches (NUXT_PUBLIC_JOURNAL_API_BASE).
@@ -68,27 +51,15 @@ export default defineNuxtConfig({
},
},
// The Journal's internal routes and the shared site routes are never
// route-cached. Nitro wraps every handler matching an app rule such as
// '/**': { swr } in its cached handler, which keys by path, does not skip
// POST and hides request headers, so the purge routes would answer from the
// cache without running.
// The Journal's internal routes are never route-cached. Nitro wraps every
// handler matching an app rule such as '/**': { swr } in its cached
// handler, which keys by path, does not skip POST and hides request
// headers, so the purge route would answer from the cache without
// running. The SEO layer keeps '/api/__site__/**' out of the cache.
routeRules: {
'/api/__journal__/**': { cache: false },
'/api/__site__/**': { cache: false },
},
// No OG image rendering at runtime.
ogImage: { enabled: false },
// Paginated listings keep ?page=N in their canonical URL. nuxt-seo-utils
// already whitelists page by default; set explicitly to lock the behaviour.
seo: { canonicalQueryWhitelist: ['page'] },
// Journal posts and categories come from the API through the layer's
// sitemap source; locs are built from journal.routes. One flat sitemap.
sitemap: { sources: ['/api/__sitemap__/journal'], autoI18n: false },
// i18n: the layer declares no locales. The journal module registers the
// layer's pl/en strings only for locales the app enables.
})

View File

@@ -17,8 +17,8 @@
"dependencies": {
"@nuxt/fonts": "0.14.0",
"@nuxtjs/i18n": "10.4.0",
"@nuxtjs/seo": "5.2.1",
"sanitize-html": "2.18.0"
"sanitize-html": "2.18.0",
"vue-seo-layer": "link:../vue-seo-layer"
},
"peerDependencies": {
"nuxt": "4.4.8"

1969
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,208 +0,0 @@
// Shared site cache purge: the authorization rules of the purge route
// POST /api/__site__/cache/purge, the purge of Nitro's route cache, the
// parsing of the purge request body, the purge settings read from runtime
// config (new and deprecated names) and the removal of the purge keys from
// nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's
// auto-import directories: vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship this file byte for byte, and `node --test`, the route
// and the Nitro plugin import it directly.
import { createHash, timingSafeEqual } from 'node:crypto'
/** The shortest purge token the route accepts; with a shorter one it is off. */
export const MIN_SITE_PURGE_TOKEN_LENGTH = 32
/** The cache storage base of Nitro's route cache (routeRules cache and swr). */
export const SITE_ROUTE_CACHE_BASE = 'nitro:routes'
/** Longest slug the purge log line carries. */
const MAX_SLUG_LENGTH = 200
/** A purge kind: lower-case letters and underscores, up to 40 characters. */
const KIND_PATTERN = /^[a-z_]{1,40}$/
export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
export interface SitePurgeAuthInput {
/** The configured token (see resolveSitePurgeConfig). */
token: unknown
/** The request's Authorization header, if any. */
authorization?: string | null
/** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */
proxied: boolean
/**
* The allowProxied setting (see resolveSitePurgeConfig): when true a
* proxied request is not refused, and the bearer token is the only gate.
* For a backend that reaches the site through a TLS-terminating proxy,
* which adds forwarding headers.
*/
allowProxied?: boolean
}
function digest(value: string): Buffer {
return createHash('sha256').update(value, 'utf8').digest()
}
/**
* Decides a purge request: `disabled` when no token of at least
* MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token) unless
* allowProxied is true, `ok` when the Bearer value equals the token (compared
* in constant time over sha256 digests) and `unauthorized` otherwise. With
* allowProxied the token is the only gate, so it must stay long and secret.
*/
export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict {
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
if (proxied && allowProxied !== true) return 'proxied'
const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '')
if (!m) return 'unauthorized'
return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized'
}
/** The part of a Nitro/unstorage storage the purge uses. */
export interface SitePurgeStorage {
getKeys(base?: string): Promise<string[]> | string[]
removeItem(key: string): Promise<unknown> | unknown
}
/**
* Removes every entry of Nitro's route cache (pages, their _payload.json and
* feeds) from storage, which is `useStorage('cache')`, and returns how many
* it removed. Other cache groups (cached functions, handlers) stay.
*/
export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise<number> {
const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE)
let removed = 0
for (const key of keys) {
if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue
await storage.removeItem(key)
removed++
}
return removed
}
export interface SitePurgeEvent {
kind: string
slug: string
}
/**
* Reads the kind and slug of a purge request body for the log line: kind is
* kept when it is lower-case letters and underscores up to 40 characters
* (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug
* loses control characters and is cut to 200 characters.
*/
export function parseSitePurgeEvent(body: unknown): SitePurgeEvent {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' }
const raw = body as Record<string, unknown>
const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown'
// eslint-disable-next-line no-control-regex
const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : ''
return { kind, slug }
}
/**
* The site config keys nuxt-site-config derives from the deprecated
* variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it
* copies every NUXT_SITE_* variable into the site config, camel-cased, and
* Nuxt serializes the site config into every page's payload.
*/
export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied'])
/**
* The globalThis flag that keeps the deprecation warning to one per process,
* however many layers ship the Nitro plugin.
*/
export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned')
export type SitePurgeDeprecated = 'token' | 'allowProxied'
export interface SitePurgeConfig {
/** The token the route checks; authorizeSitePurge decides whether it is usable. */
token: unknown
/** True when a proxied request may reach the token check. */
allowProxied: boolean
/** The settings that came from a deprecated name. */
deprecated: SitePurgeDeprecated[]
}
function section(config: unknown, name: string): Record<string, unknown> {
if (config === null || typeof config !== 'object') return {}
const value = (config as Record<string, unknown>)[name]
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
const isTrue = (value: unknown) => value === true || value === 'true'
/**
* Reads the purge settings from runtime config. The current names are
* `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied`
* (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release:
* `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and
* `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new
* token wins over the old one; allowProxied is on when either flag is true or
* "true". `deprecated` lists the settings taken from an old name.
*/
export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig {
const current = section(config, 'cachePurge')
const old = section(config, 'siteCache')
const deprecated: SitePurgeDeprecated[] = []
let token: unknown = current.token
if (typeof token !== 'string' || token === '') {
if (typeof old.purgeToken === 'string' && old.purgeToken !== '') {
token = old.purgeToken
deprecated.push('token')
} else if (token === undefined) {
token = old.purgeToken
}
}
let allowProxied = isTrue(current.allowProxied)
if (!allowProxied && isTrue(old.allowProxied)) {
allowProxied = true
deprecated.push('allowProxied')
}
return { token, allowProxied, deprecated }
}
/**
* Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack
* (the array of partial site configs it merges per request) and returns how
* many keys it removed. Anything that is not an array of objects is left
* alone and counts 0.
*/
export function redactSiteConfigStack(stack: unknown): number {
if (!Array.isArray(stack)) return 0
let removed = 0
for (const entry of stack) {
if (entry === null || typeof entry !== 'object') continue
for (const key of SITE_CONFIG_PURGE_KEYS) {
if (Object.prototype.hasOwnProperty.call(entry, key)) {
delete (entry as Record<string, unknown>)[key]
removed++
}
}
}
return removed
}
const DEPRECATED_NAMES: Record<SitePurgeDeprecated, [string, string]> = {
token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'],
allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'],
}
/**
* The one-line deprecation warning for the settings resolveSitePurgeConfig
* took from an old name, or '' when there are none. It names variables
* only, never a value.
*/
export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string {
const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`)
if (names.length === 0) return ''
return (
`[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` +
'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' +
'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.'
)
}

View File

@@ -1,48 +0,0 @@
// Keeps the site cache purge settings out of the public site config.
//
// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and
// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased,
// and Nuxt serializes the site config into every page's payload (and into
// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's
// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as
// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied.
// The settings now live under NUXT_CACHE_PURGE_TOKEN and
// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep
// working for one release, so this plugin also removes the two keys from
// every entry of the site config stack on nuxt-site-config's
// `site-config:init` hook, before anything reads it. The stripping stays
// with the new names too: a site that still sets an old variable, or sets
// both, must not publish it.
//
// When a setting comes from an old name, the plugin logs one deprecation
// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending
// several layers that ship this file warns once). The warning names
// variables, never a value.
//
// Without nuxt-site-config the hook is never called and the plugin does
// nothing else. Shared: several layers ship this file byte for byte;
// running it more than once is harmless.
import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts'
interface SiteConfigInitContext {
siteConfig?: { stack?: unknown }
}
type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown }
export default defineNitroPlugin((nitroApp) => {
const g = globalThis as Record<symbol, unknown>
if (!g[SITE_PURGE_WARNED]) {
const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated)
if (message) {
g[SITE_PURGE_WARNED] = true
console.warn(message)
}
}
// The hook name is untyped in an app without nuxt-site-config.
;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => {
redactSiteConfigStack(ctx?.siteConfig?.stack)
})
})

View File

@@ -1,7 +1,19 @@
# SSR expectations against the acme fixtures (testing/fixtures/acme).
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets
# site.url to https://acme.test.
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N]
# [type=PREFIX] [header=NAME: VALUE] [noheader=NAME]; resolves PATH N
# The consumer enables en (default, no prefix) and pl (/pl prefix). The
# fixture SEO API (fixtures/acme/seo) answers the site URL https://acme.test.
# The post page on the shared SEO layer: exactly one H1 in both Journal H1
# modes, one canonical and one JSON-LD script (UI-SPEC L6, D-97).
200 /blog/heading-on count=<h1:1 contains=<h1 class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
200 /blog/heading-on contains=<h2>Body heading one</h2> notcontains=<p class="journal-post__title"
200 /blog/heading-off count=<h1:1 contains=<p class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
200 /blog/heading-off contains=<h1>Body heading one</h1>
# The CMS head wins: its title, no second title.
200 /blog/heading-on contains=<title>Heading On Sample | Acme Blog</title> count=<title>:1
# Without a CMS answer the post's own title and description are the fallback.
200 /blog/uncategorised-note contains=<title>Uncategorised Note</title> contains=Uncategorised Note: a short summary.
# Single post
200 /blog/hello-acme contains=Hello Acme
@@ -34,23 +46,20 @@
200 /blog/tag/release contains=Hello Acme
404 /blog/tag/nope
# RSS, sitemap, canonical and meta
# RSS, canonical and meta (sitemaps come from the CMS, not the layer)
200 /rss.xml type=application/rss+xml
200 /blog contains=href="/rss.xml"
200 /sitemap.xml contains=/blog/hello-acme
200 /sitemap.xml contains=/blog/category/quiet
200 /sitemap.xml notcontains=/blog/draft-note
200 /blog?page=2 contains=?page=2
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?page=2">
200 /blog/hello-acme contains=og:title
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
# Search (the playground sets journal.routes.search: '/search'). 11 posts
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**),
# noindex, follow, and left out of the sitemap.
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**)
# and noindex, follow.
200 /search?q=acme contains=Hello Acme
200 /search?q=acme contains=11 results
200 /search?q=acme contains=noindex, follow
200 /search?q=acme contains=noindex, follow header=x-robots-tag: noindex, follow
200 /search?q=acme&page=2 contains=Acme Note 12
404 /search?q=acme&page=3
404 /search?q=acme&page=abc
@@ -63,7 +72,6 @@
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: &lt;script&gt;alert(1) notcontains=>Search results: <script>
200 /pl/search?q=acme contains=11 wyników
200 /pl/search?q=unsafe contains=1 wynik
200 /sitemap.xml notcontains=/search
# 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"

View File

@@ -4,6 +4,17 @@
// nested children, tag, search, fields=suggest, include_empty) and the author routes, and it
// records every request so a smoke run can assert header forwarding.
//
// It also stands in for the SEO plugin's public API (/_seo/api/v1), which
// vue-seo-layer calls for every page:
//
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
// <dir>/seo/resolve.json, else not_found
// GET /_seo/api/v1/site <dir>/seo/site.json, else 404 (an older
// backend)
//
// Anything else under /_seo answers 404 (no 404 reports or route reports:
// the smoke sets no token).
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps import this file from there.
@@ -12,13 +23,14 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const PREFIX = '/_journal/api/v1'
const SEO_PREFIX = '/_seo/api/v1'
const DEFAULT_PER_PAGE = 9
const MAX_PER_PAGE = 30
const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id'])
/**
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise<void> }} FixtureApi
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
*/
/** @param {string} dir @param {string} name @param {any} fallback */
@@ -139,6 +151,10 @@ export function startFixtureApi({ dir, port = 0 }) {
/** @type {any[]} */
let authors = []
let rss = ''
/** @type {Record<string, unknown>} */
let seoAnswers = {}
/** @type {unknown} */
let seoSite = null
// Reads (or re-reads) the fixtures, so a check can change a fixture file
// while the API runs.
const load = () => {
@@ -146,6 +162,8 @@ export function startFixtureApi({ dir, port = 0 }) {
categories = readJson(dir, 'categories.json', [])
tags = readJson(dir, 'tags.json', [])
authors = readJson(dir, 'authors.json', [])
seoAnswers = readJson(join(dir, 'seo'), 'resolve.json', {})
seoSite = readJson(join(dir, 'seo'), 'site.json', null)
try {
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
} catch {
@@ -201,6 +219,17 @@ export function startFixtureApi({ dir, port = 0 }) {
body: JSON.stringify(value),
type: 'application/json',
})
if (url.pathname.startsWith(SEO_PREFIX + '/')) {
const seoPath = url.pathname.slice(SEO_PREFIX.length)
if (req.method === 'GET' && seoPath === '/resolve') {
const target = url.searchParams.get('url') ?? ''
if (!target.startsWith('/')) return json(422, { error: 'bad_url' })
const hit = seoAnswers[target]
return json(200, hit ?? { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null })
}
if (req.method === 'GET' && seoPath === '/site' && seoSite) return json(200, seoSite)
return json(404, { error: 'not_found' })
}
if (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' })
if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' })
const path = decodeURIComponent(url.pathname.slice(PREFIX.length))
@@ -257,6 +286,14 @@ export function startFixtureApi({ dir, port = 0 }) {
res.end(req.method === 'HEAD' ? undefined : out.body)
})
/** Resolve calls so far, for one target url or (without one) all of them. @param {string} [target] */
const resolveCount = (target) =>
requests.filter((r) => {
if (r.method !== 'GET') return false
const u = new URL(r.url, 'http://fixture.local')
return u.pathname === `${SEO_PREFIX}/resolve` && (target === undefined || u.searchParams.get('url') === target)
}).length
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
@@ -264,6 +301,7 @@ export function startFixtureApi({ dir, port = 0 }) {
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
resolveCount,
reload: load,
close: () => new Promise((done) => server.close(() => done())),
})

View File

@@ -475,5 +475,51 @@
"translations": {},
"created_at": "2026-01-20T09:00:00+00:00",
"updated_at": "2026-01-20T09:00:00+00:00"
},
{
"id": 15,
"title": "Heading On Sample",
"slug": "heading-on",
"excerpt": "Heading On Sample shows the Journal H1 setting on.",
"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",
"reading_time": 1,
"published": true,
"published_at": "2025-12-20T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2025-12-20T09:00:00+00:00",
"updated_at": "2025-12-20T09:00:00+00:00",
"content_h1_to_h2": true
},
{
"id": 16,
"title": "Heading Off Sample",
"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",
"reading_time": 1,
"published": true,
"published_at": "2025-12-19T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2025-12-19T09:00:00+00:00",
"updated_at": "2025-12-19T09:00:00+00:00",
"content_h1_to_h2": false
}
]

View File

@@ -0,0 +1,412 @@
{
"/blog/hello-acme": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/hello-acme",
"locale": "en",
"staging": false,
"seo": {
"title": "Hello Acme | Acme Blog",
"description": "Acme says hello and introduces its blog.",
"canonical": "https://acme.test/blog/hello-acme",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Hello Acme",
"description": "Acme says hello and introduces its blog.",
"image": "",
"url": "https://acme.test/blog/hello-acme",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/hello-acme#webpage",
"url": "https://acme.test/blog/hello-acme",
"name": "Hello Acme",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/hello-acme#article",
"headline": "Hello Acme",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/hello-acme#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Hello Acme",
"url": "https://acme.test/blog/hello-acme",
"path": "/blog/hello-acme"
}
]
},
"/blog/heading-on": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/heading-on",
"locale": "en",
"staging": false,
"seo": {
"title": "Heading On Sample | Acme Blog",
"description": "Heading On Sample shows the Journal H1 setting on.",
"canonical": "https://acme.test/blog/heading-on",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Heading On Sample",
"description": "Heading On Sample shows the Journal H1 setting on.",
"image": "",
"url": "https://acme.test/blog/heading-on",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/heading-on#webpage",
"url": "https://acme.test/blog/heading-on",
"name": "Heading On Sample",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/heading-on#article",
"headline": "Heading On Sample",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/heading-on#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Heading On Sample",
"url": "https://acme.test/blog/heading-on",
"path": "/blog/heading-on"
}
]
},
"/blog/heading-off": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/heading-off",
"locale": "en",
"staging": false,
"seo": {
"title": "Heading Off Sample | Acme Blog",
"description": "Heading Off Sample shows the Journal H1 setting off.",
"canonical": "https://acme.test/blog/heading-off",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Heading Off Sample",
"description": "Heading Off Sample shows the Journal H1 setting off.",
"image": "",
"url": "https://acme.test/blog/heading-off",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/heading-off#webpage",
"url": "https://acme.test/blog/heading-off",
"name": "Heading Off Sample",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/heading-off#article",
"headline": "Heading Off Sample",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/heading-off#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Heading Off Sample",
"url": "https://acme.test/blog/heading-off",
"path": "/blog/heading-off"
}
]
},
"/blog": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog.",
"canonical": "https://acme.test/blog",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog.",
"image": "",
"url": "https://acme.test/blog",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog#webpage",
"url": "https://acme.test/blog",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
},
"/blog?page=2": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog?page=2",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog, page 2.",
"canonical": "https://acme.test/blog?page=2",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog, page 2.",
"image": "",
"url": "https://acme.test/blog?page=2",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog?page=2#webpage",
"url": "https://acme.test/blog?page=2",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
],
"pagination": {
"form": "query",
"param": "page",
"page": 2
}
},
"/blog/author/jdoe": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/author/jdoe",
"locale": "en",
"staging": false,
"seo": {
"title": "Jane Doe | Acme Blog",
"description": "Posts by Jane Doe.",
"canonical": "",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Jane Doe",
"description": "Posts by Jane Doe.",
"image": "",
"url": "https://acme.test/blog/author/jdoe",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/author/jdoe#webpage",
"url": "https://acme.test/blog/author/jdoe",
"name": "Jane Doe",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
}
}

View File

@@ -0,0 +1,38 @@
{
"site_url": "https://acme.test",
"default_locale": "en",
"locales": [
"en",
"pl"
],
"unprefixed_locale": "en",
"links": {
"new_tab": true
},
"html_sitemap": {
"enabled": false,
"paths": {},
"page_size": 100
},
"routes": [
{
"type": "post",
"pattern": "/blog/:slug"
},
{
"type": "category",
"pattern": "/blog/category/:category"
}
],
"root_files": [],
"llms": false,
"pagination": {
"form": "query",
"param": "page",
"segments": {}
},
"home_crumb": {
"name": "Home",
"url": "https://acme.test/"
}
}

View File

@@ -63,7 +63,9 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
while (Date.now() < deadline) {
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
// A Nuxt-internal path: it touches no page, so the boot probe never
// resolves through the SEO API or fills a route cache.
await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
return { url, close, output: () => log }
} catch {
await new Promise((r) => setTimeout(r, 200))

View File

@@ -1,17 +1,29 @@
#!/usr/bin/env node
// SSR smoke check: boots the fixture Journal API and a built Nuxt server,
// then checks each line of an expectations file.
// SSR smoke check: boots the fixture Journal and SEO API and a built Nuxt
// server, then checks each line of an expectations file.
//
// node smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
// end of the line, so it may contain spaces. Requests are plain GETs that do
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// [header=NAME: VALUE] [noheader=NAME]
// resolves PATH N the fixture SEO API has seen exactly N resolve calls for
// PATH so far (PATH * counts every resolve call)
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
// ` type=`, ` header=`, ` noheader=` or the end of the line, so it may
// contain spaces. Every option may repeat on a line and every occurrence
// applies. count= wants exactly N occurrences of TEXT in the body (N is the
// number after the last colon); header= wants the response header NAME to
// equal VALUE (both trimmed); noheader= wants it absent. The same grammar
// as vue-seo-layer's testing/smoke-ssr.mjs. Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
// lines, at least one fixture API request must have carried that address
// (the SSR fetches forward the visitor's x-forwarded-for), and every
// categories request must have carried include_empty=1.
//
// The server gets the fixture API as NUXT_JOURNAL_API_BASE and, for the
// vue-seo-layer resolve calls, NUXT_SEO_API_BASE (<fixtures>/seo/resolve.json
// and site.json); --env overrides either.
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -45,17 +57,21 @@ function parseArgs(argv) {
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
}
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader']
/**
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
*/
/**
* @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
* @returns {ExpectCheck}
*/
export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type)=/g
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
@@ -64,13 +80,57 @@ export function parseExpectLine(line) {
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
}
/** @type {Record<string, string>} */
const options = {}
marks.forEach((mark, i) => {
const options = marks.map((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim()
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
})
return { ...check, ...options }
return { status: Number(m[1]), path: m[2], options }
}
/** @param {string} body @param {string} text */
function occurrences(body, text) {
if (text === '') return 0
let n = 0
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
return n
}
/**
* The failures of one response against its check.
* @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res
* @returns {string[]}
*/
export function checkResponse(check, res) {
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
/** @type {string[]} */
const out = []
const type = res.headers.get('content-type') ?? ''
for (const { key, value } of check.options) {
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
else if (key === 'count') {
const colon = value.lastIndexOf(':')
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
if (!Number.isInteger(want)) {
out.push(`count=${value}: want TEXT:N`)
continue
}
const text = value.slice(0, colon)
const got = occurrences(res.body, text)
if (got !== want) out.push(`body has ${got} of "${text}", want ${want}`)
} else if (key === 'type' && !type.startsWith(value)) out.push(`content-type "${type}", want prefix "${value}"`)
else if (key === 'header') {
const colon = value.indexOf(':')
const name = colon > 0 ? value.slice(0, colon).trim() : value.trim()
const want = colon > 0 ? value.slice(colon + 1).trim() : ''
const got = res.headers.get(name)
if (got === null || got.trim() !== want) out.push(`header ${name} is ${JSON.stringify(got)}, want "${want}"`)
} else if (key === 'noheader') {
const got = res.headers.get(value)
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
}
}
return out
}
async function main() {
@@ -85,8 +145,18 @@ async function main() {
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: args.env })
nuxt = await startNuxtServer({
outputDir: args.output,
apiBase: `${api.url}/_journal/api/v1`,
env: { NUXT_SEO_API_BASE: `${api.url}/_seo/api/v1`, ...args.env },
})
for (const line of lines) {
const resolves = /^resolves\s+(\S+)\s+(\d+)$/.exec(line)
if (resolves) {
const got = api.resolveCount(resolves[1] === '*' ? undefined : resolves[1])
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
continue
}
let check
try {
check = parseExpectLine(line)
@@ -106,11 +176,7 @@ async function main() {
continue
}
const body = await res.text()
const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`)
}
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: x-forwarded-for not forwarded')

View File

@@ -333,3 +333,25 @@ test('every corpus entry stays free of danger after the sink pipeline', () => {
for (const pattern of DANGER) assert.doesNotMatch(out, pattern, `${entry.name}: ${pattern}`)
}
})
// The Journal H1 setting (UI-SPEC L7 step 1): with demoteH1 every h1 comes
// out as an h2 with its filtered attributes; without it the h1 stays.
test('demoteH1 true turns body h1 headings into h2 and keeps the rest', () => {
const html = '<h1 id="start" class="lead" onclick="x()">Start</h1><h2>Two</h2><p>Text <strong>h1</strong></p><h1>Again</h1>'
const out = sanitizeJournalHtml(html, { demoteH1: true })
assert.equal(out, '<h2 id="start" class="lead">Start</h2><h2>Two</h2><p>Text <strong>h1</strong></p><h2>Again</h2>')
assert.doesNotMatch(out, /<h1/)
assert.deepEqual(gateJournalHtml(html, { demoteH1: true }), { safe: false, html: out })
})
test('demoteH1 false or absent keeps body h1 headings as written', () => {
const html = '<h1 id="start">Start</h1><h2>Two</h2>'
assert.equal(sanitizeJournalHtml(html), html)
assert.equal(sanitizeJournalHtml(html, { demoteH1: false }), html)
assert.deepEqual(gateJournalHtml(html, { demoteH1: false }), { safe: true, html })
})
test('the demotion is not a removal: safe HTML stays safe while demoted', () => {
const html = '<h1>Start</h1><p>Text</p>'
assert.deepEqual(gateJournalHtml(html, { demoteH1: true }), { safe: true, html: '<h2>Start</h2><p>Text</p>' })
})

View File

@@ -2,7 +2,7 @@
// list titles.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { absoluteUrl, htmlToText, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import { absoluteUrl, cmsHeadPresent, htmlToText, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import type { JournalPost } from '../app/utils/types.ts'
function post(extra: Partial<JournalPost> = {}): JournalPost {
@@ -92,3 +92,30 @@ test('listTitle adds the page title only after page 1', () => {
assert.equal(listTitle('Artykuły', 1, 'Strona 1'), 'Artykuły')
assert.equal(listTitle('Artykuły', 2, 'Strona 2'), 'Artykuły · Strona 2')
})
test('journalPostTitleTag follows the Journal H1 setting unless the option forces a tag', () => {
assert.equal(journalPostTitleTag('auto', true), 'h1')
assert.equal(journalPostTitleTag('auto', undefined), 'h1')
assert.equal(journalPostTitleTag('auto', null), 'h1')
assert.equal(journalPostTitleTag('auto', false), 'p')
assert.equal(journalPostTitleTag(undefined, false), 'p')
assert.equal(journalPostTitleTag('h2', false), 'p')
assert.equal(journalPostTitleTag('h1', false), 'h1')
assert.equal(journalPostTitleTag('p', true), 'p')
})
test('cmsHeadPresent is true only for an ok answer with head values', () => {
assert.equal(cmsHeadPresent({ action: 'ok', seo: { title: 'x' } }), true)
assert.equal(cmsHeadPresent({ action: 'ok', seo: null }), false)
assert.equal(cmsHeadPresent({ action: 'not_found', seo: null }), false)
assert.equal(cmsHeadPresent(null), false)
assert.equal(cmsHeadPresent(undefined), false)
})
test('journalSiteOrigin prefers the CMS page URL origin over the request origin', () => {
assert.equal(journalSiteOrigin('https://acme.test/blog/x', 'http://127.0.0.1:3000'), 'https://acme.test')
assert.equal(journalSiteOrigin('/blog/x', 'http://127.0.0.1:3000'), 'http://127.0.0.1:3000')
assert.equal(journalSiteOrigin('', 'http://127.0.0.1:3000'), 'http://127.0.0.1:3000')
assert.equal(journalSiteOrigin(null, 'http://127.0.0.1:3000'), 'http://127.0.0.1:3000')
assert.equal(journalSiteOrigin('javascript:alert(1)', 'http://h.test'), 'http://h.test')
})

View File

@@ -1,438 +0,0 @@
// Shared site cache purge: route authorization, the route-cache purge, the
// purge body parsing, the purge settings under their new and deprecated
// names, the site config stripping plugin and the byte-identity of the files
// every layer ships.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import {
MIN_SITE_PURGE_TOKEN_LENGTH,
SITE_CONFIG_PURGE_KEYS,
SITE_PURGE_WARNED,
SITE_ROUTE_CACHE_BASE,
authorizeSitePurge,
parseSitePurgeEvent,
purgeSiteRouteCache,
redactSiteConfigStack,
resolveSitePurgeConfig,
sitePurgeDeprecationMessage,
} from '../server/lib/siteCachePurge.ts'
// The sha256 of the three shared files. vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship them byte for byte; a change lands in all three with
// new values here.
const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a'
const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93'
const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6'
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
function sha256(relative: string): string {
return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex')
}
test('sitePurge: the shared files match the pinned hashes', () => {
assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts')
assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts')
assert.equal(sha256('../server/plugins/site-cache-purge.ts'), SHARED_PLUGIN_SHA256, 'server/plugins/site-cache-purge.ts')
})
test('sitePurge: a missing or short token disables the route', () => {
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
}
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32)
})
test('sitePurge: a proxied request is refused before the token is checked', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
})
test('sitePurge: authorize ok with the token and unauthorized otherwise', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
}
})
/** A fake unstorage prefix storage over a plain key list. */
function fakeStorage(keys: string[]) {
const live = new Set(keys)
const removed: string[] = []
return {
removed,
live,
async getKeys(base?: string) {
return [...live].filter((k) => !base || k === base || k.startsWith(base + ':'))
},
async removeItem(key: string) {
live.delete(key)
removed.push(key)
},
}
}
test('sitePurge: purge removes only nitro:routes: keys', async () => {
const routes = [
`${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`,
`${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`,
`${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
]
const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
const storage = fakeStorage([...routes, ...others])
assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes')
assert.equal(await purgeSiteRouteCache(storage), routes.length)
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
assert.deepEqual([...storage.live].sort(), [...others].sort())
assert.equal(await purgeSiteRouteCache(storage), 0)
})
test('sitePurge: purge event kinds and slugs', () => {
for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) {
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' })
}
for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) {
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind))
}
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' })
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200)
for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) {
assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
}
})
test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => {
// timingSafeEqual on raw strings throws on a length mismatch; over digests
// every length compares in constant time and answers unauthorized.
for (const length of [1, 31, 33, 64, 4096]) {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`)
}
const long = 'k'.repeat(200)
assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized')
})
test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => {
const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json'])
const removed: string[] = []
const storage = {
getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')),
removeItem: (key: string) => {
live.delete(key)
removed.push(key)
},
}
assert.equal(await purgeSiteRouteCache(storage), 1)
assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`])
assert.ok(live.has(SITE_ROUTE_CACHE_BASE))
assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0)
})
test('sitePurge: purge event edge cases', () => {
assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' })
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200)
// Control characters are stripped before the cut.
const slug = '\u0001'.repeat(50) + 'z'.repeat(200)
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200))
assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' })
})
/** One call of the shared route with Nitro's auto-imports stubbed. */
async function callRoute(opts: {
token?: unknown
allowProxied?: unknown
cachePurge?: { token?: unknown; allowProxied?: unknown }
headers?: Record<string, string>
body?: unknown
bodyError?: boolean
keys?: string[]
}) {
const responseHeaders: Record<string, string> = {}
const logs: string[] = []
const live = new Set(opts.keys ?? [])
const g = globalThis as Record<string, unknown>
g.defineEventHandler = (fn: unknown) => fn
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
responseHeaders[name] = value
}
g.useRuntimeConfig = () => ({
siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied },
cachePurge: opts.cachePurge,
})
g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name]
g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input)
g.readBody = async () => {
if (opts.bodyError) throw new Error('invalid JSON')
return opts.body
}
g.useStorage = (base: string) => {
assert.equal(base, 'cache')
return {
getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')),
removeItem: async (key: string) => {
live.delete(key)
},
}
}
const info = console.info
console.info = (line: string) => {
logs.push(line)
}
try {
const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise<unknown>
try {
return { result: await route({}), status: 200, responseHeaders, logs, live }
} catch (err) {
return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live }
}
} finally {
console.info = info
}
}
test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
for (const token of [undefined, '', 'short']) {
const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys })
assert.equal(r.status, 404, `token ${JSON.stringify(token)}`)
assert.equal(r.live.size, 1)
}
for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) {
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys })
assert.equal(r.status, 404, name)
assert.equal(r.live.size, 1)
}
for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) {
const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys })
assert.equal(r.status, 401, String(authorization))
assert.equal(r.live.size, 1)
assert.equal(r.responseHeaders['cache-control'], 'no-store')
}
})
test('sitePurge: route purges, answers the count and logs kind and slug only', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json']
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys })
assert.equal(r.status, 200)
assert.deepEqual(r.result, { purged: 2 })
assert.deepEqual([...r.live], ['nitro:functions:_:x.json'])
assert.equal(r.responseHeaders['cache-control'], 'no-store')
assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)'])
assert.ok(!r.logs.join('').includes(TOKEN))
const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] })
assert.equal(bad.status, 200)
assert.deepEqual(bad.result, { purged: 0 })
assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )'])
})
test('sitePurge: allowProxied lets a proxied request reach the token check', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled')
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled')
// Without the flag, or with anything but true, a proxied request stays refused.
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
})
test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`]
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' }
for (const allowProxied of [true, 'true']) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys })
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.deepEqual(r.result, { purged: 2 })
assert.equal(r.live.size, 0)
}
const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
assert.equal(wrong.status, 401)
assert.equal(wrong.live.size, 2)
for (const allowProxied of [false, undefined, 'false', 1]) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, keys })
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.equal(r.live.size, 2)
}
})
test('sitePurge: resolve config prefers a non-empty new token over the deprecated one', () => {
const A = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A }, siteCache: { purgeToken: B } }), { token: A, allowProxied: false, deprecated: [] })
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A, allowProxied: false }, siteCache: { purgeToken: '' } }), { token: A, allowProxied: false, deprecated: [] })
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: null, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: '' } }), { token: '', allowProxied: false, deprecated: [] })
})
test('sitePurge: resolve config turns allowProxied on for either flag, true or "true" only', () => {
for (const value of [true, 'true']) {
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).allowProxied, true, `new ${JSON.stringify(value)}`)
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).deprecated, [])
const old = resolveSitePurgeConfig({ siteCache: { allowProxied: value } })
assert.equal(old.allowProxied, true, `old ${JSON.stringify(value)}`)
assert.deepEqual(old.deprecated, ['allowProxied'])
// The new flag on: the old one is not counted as in use.
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }).deprecated, [])
}
for (const value of [false, undefined, 'false', 1, null, 'TRUE']) {
const r = resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } })
assert.equal(r.allowProxied, false, JSON.stringify(value))
assert.deepEqual(r.deprecated, [])
}
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B, allowProxied: 'true' } }).deprecated, ['token', 'allowProxied'])
})
test('sitePurge: resolve config tolerates a missing or malformed config', () => {
for (const config of [undefined, null, 'x', 42, {}, { cachePurge: 'x', siteCache: 7 }]) {
assert.deepEqual(resolveSitePurgeConfig(config), { token: undefined, allowProxied: false, deprecated: [] }, JSON.stringify(config))
}
})
test('sitePurge: route reads the new token, falls back to the deprecated one, and the new one wins', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
const NEW = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
const r = await callRoute({ cachePurge: { token: TOKEN }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
assert.equal(r.status, 200)
assert.deepEqual(r.result, { purged: 1 })
const old = await callRoute({ token: TOKEN, cachePurge: { token: '' }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
assert.equal(old.status, 200)
const both = { token: TOKEN, cachePurge: { token: NEW } }
assert.equal((await callRoute({ ...both, headers: { authorization: `Bearer ${NEW}` }, keys })).status, 200)
const refused = await callRoute({ ...both, headers: { authorization: `Bearer ${TOKEN}` }, keys })
assert.equal(refused.status, 401)
assert.equal(refused.live.size, 1)
const short = await callRoute({ cachePurge: { token: 'short' }, headers: { authorization: 'Bearer short' }, keys })
assert.equal(short.status, 404)
})
test('sitePurge: route lets a proxied request reach the token check with cachePurge.allowProxied', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9' }
for (const allowProxied of [true, 'true']) {
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
const wrong = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
assert.equal(wrong.status, 401)
}
// The deprecated flag with the new token still opens it.
assert.equal((await callRoute({ token: '', allowProxied: true, cachePurge: { token: TOKEN }, headers, keys })).status, 200)
for (const allowProxied of [false, undefined, 'false', 1]) {
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
}
})
test('sitePurge: redactSiteConfigStack removes the purge keys from every stack entry', () => {
assert.deepEqual([...SITE_CONFIG_PURGE_KEYS], ['cachePurgeToken', 'cacheAllowProxied'])
const stack: Record<string, unknown>[] = [
{ url: 'https://acme.test', name: 'Acme', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' },
{ _context: 'x' },
]
assert.equal(redactSiteConfigStack(stack), 2)
assert.deepEqual(stack, [{ url: 'https://acme.test', name: 'Acme' }, { _context: 'x' }])
assert.equal(redactSiteConfigStack(stack), 0)
const spread = [{ cachePurgeToken: TOKEN }, { cachePurgeToken: TOKEN, indexable: true }]
assert.equal(redactSiteConfigStack(spread), 2)
assert.deepEqual(spread, [{}, { indexable: true }])
for (const value of [undefined, null, 'stack', 7, {}, { cachePurgeToken: TOKEN }]) {
assert.equal(redactSiteConfigStack(value), 0, JSON.stringify(value))
}
assert.equal(redactSiteConfigStack([null, undefined, 'x', 3]), 0)
})
test('sitePurge: the deprecation message names variables, never a value', () => {
assert.equal(sitePurgeDeprecationMessage([]), '')
const token = sitePurgeDeprecationMessage(['token'])
assert.match(token, /^\[site-cache\] /)
assert.ok(token.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
assert.ok(!token.includes('NUXT_SITE_CACHE_ALLOW_PROXIED'))
assert.match(token, /rotate/)
assert.ok(!token.includes('\n'))
const both = sitePurgeDeprecationMessage(['token', 'allowProxied'])
assert.ok(both.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
assert.ok(both.includes('NUXT_SITE_CACHE_ALLOW_PROXIED -> NUXT_CACHE_PURGE_ALLOW_PROXIED'))
assert.ok(sitePurgeDeprecationMessage(['allowProxied']).includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
})
type SiteConfigHook = (ctx: unknown) => void
/** Runs the shared Nitro plugin on a fake nitroApp with the given runtime config. */
async function runPlugin(config: unknown) {
const g = globalThis as Record<string, unknown>
g.defineNitroPlugin = (fn: unknown) => fn
g.useRuntimeConfig = () => config
const hooks: Record<string, SiteConfigHook[]> = {}
const nitroApp = {
hooks: {
hook(name: string, fn: SiteConfigHook) {
;(hooks[name] ??= []).push(fn)
},
},
}
const warnings: string[] = []
const warn = console.warn
console.warn = (line: string) => {
warnings.push(line)
}
try {
const plugin = (await import('../server/plugins/site-cache-purge.ts')).default as (app: unknown) => void
plugin(nitroApp)
} finally {
console.warn = warn
}
return { hooks, warnings }
}
function resetWarned() {
delete (globalThis as Record<symbol, unknown>)[SITE_PURGE_WARNED]
}
test('sitePurge: plugin strips the purge keys on site-config:init', async () => {
resetWarned()
const { hooks, warnings } = await runPlugin({ cachePurge: { token: TOKEN } })
assert.equal(hooks['site-config:init']?.length, 1)
assert.deepEqual(warnings, [])
const stack = [{ url: 'https://acme.test', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, { _context: 'x' }]
hooks['site-config:init'][0]({ siteConfig: { stack }, event: {} })
assert.deepEqual(stack, [{ url: 'https://acme.test' }, { _context: 'x' }])
for (const ctx of [undefined, null, {}, { siteConfig: {} }, { siteConfig: { stack: 'x' } }]) {
hooks['site-config:init'][0](ctx)
}
resetWarned()
})
test('sitePurge: plugin warns once per process about a deprecated name, without the value', async () => {
resetWarned()
const config = { siteCache: { purgeToken: TOKEN, allowProxied: 'true' } }
const first = await runPlugin(config)
assert.equal(first.warnings.length, 1)
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_TOKEN'))
assert.ok(first.warnings[0].includes('NUXT_SITE_CACHE_PURGE_TOKEN'))
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
assert.ok(!first.warnings[0].includes(TOKEN))
// Another layer's copy of the plugin in the same process stays quiet.
const second = await runPlugin(config)
assert.deepEqual(second.warnings, [])
assert.equal(second.hooks['site-config:init']?.length, 1)
resetWarned()
const fresh = await runPlugin({ cachePurge: { token: TOKEN }, siteCache: { purgeToken: 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) } })
assert.deepEqual(fresh.warnings, [], 'the new name wins: no warning')
const none = await runPlugin({})
assert.deepEqual(none.warnings, [])
resetWarned()
})

View File

@@ -1,98 +0,0 @@
// Sitemap entries are built from journal.routes, never from fixed paths.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildJournalSitemapEntries } from '../app/utils/sitemap.ts'
import { DEFAULT_JOURNAL_ROUTES, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalCategory, JournalPost } from '../app/utils/types.ts'
function post(id: number, slug: string, extra: Partial<JournalPost> = {}): JournalPost {
return {
id,
title: slug,
slug,
excerpt: null,
content: null,
content_html: null,
reading_time: 1,
published: true,
published_at: '2024-06-12T06:00:00+00:00',
is_pinned: false,
metadata: null,
sources: null,
author: null,
categories: [],
tags: [],
featured_images: [],
featured_image_url: null,
translations: null,
created_at: '2024-06-12T06:00:00+00:00',
updated_at: '2024-06-13T06:00:00+00:00',
...extra,
}
}
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
const row: JournalCategory = {
id,
name: slug,
slug,
description: null,
content: null,
parent_id: null,
nest_depth: 0,
post_count: 0,
}
if (children) row.children = children
return row
}
const POSTS = [post(1, 'blogbadania-nad-cordycepsem'), post(2, 'bez-daty', { updated_at: null })]
const CATEGORIES = [category(1, 'cordyceps', [category(2, 'militaris')]), category(3, 'amanita-muscaria')]
test('default routes give /blog/:slug and /blog/category/:category', () => {
assert.deepEqual(buildJournalSitemapEntries(POSTS, CATEGORIES, { ...DEFAULT_JOURNAL_ROUTES }), [
{ loc: '/blog/blogbadania-nad-cordycepsem', lastmod: '2024-06-13T06:00:00+00:00' },
{ loc: '/blog/bez-daty' },
{ loc: '/blog/category/cordyceps' },
{ loc: '/blog/category/militaris' },
{ loc: '/blog/category/amanita-muscaria' },
])
})
test('a custom root category route gives /cordyceps', () => {
const routes = normalizeJournalRoutes({ category: '/:category', tag: false })
const locs = buildJournalSitemapEntries(POSTS, CATEGORIES, routes).map((e) => e.loc)
assert.ok(locs.includes('/cordyceps'))
assert.ok(locs.includes('/militaris'), 'nested categories are flattened')
assert.ok(locs.includes('/amanita-muscaria'), 'empty categories are listed')
})
test('a disabled route contributes nothing', () => {
const noCategories = normalizeJournalRoutes({ category: false })
assert.deepEqual(
buildJournalSitemapEntries(POSTS, CATEGORIES, noCategories).map((e) => e.loc),
['/blog/blogbadania-nad-cordycepsem', '/blog/bez-daty'],
)
const noPosts = normalizeJournalRoutes({ post: false })
assert.equal(buildJournalSitemapEntries(POSTS, CATEGORIES, noPosts).filter((e) => e.loc.startsWith('/blog/b')).length, 0)
})
test('unpublished posts and empty slugs are skipped', () => {
const rows = [post(1, 'draft', { published: false }), post(2, ''), post(3, 'live')]
assert.deepEqual(
buildJournalSitemapEntries(rows, [category(9, '')], { ...DEFAULT_JOURNAL_ROUTES }).map((e) => e.loc),
['/blog/live'],
)
})
test('lastmod comes from updated_at', () => {
const [entry] = buildJournalSitemapEntries([post(1, 'x', { updated_at: '2025-01-02T03:04:05+00:00' })], [], {
...DEFAULT_JOURNAL_ROUTES,
})
assert.equal(entry?.lastmod, '2025-01-02T03:04:05+00:00')
})
test('slugs are encoded per segment', () => {
const [entry] = buildJournalSitemapEntries([post(1, 'a b/c')], [], { ...DEFAULT_JOURNAL_ROUTES })
assert.equal(entry?.loc, '/blog/a%20b/c')
})