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:
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
@@ -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
1969
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
48
server/api/__site__/cache/purge.post.ts
vendored
48
server/api/__site__/cache/purge.post.ts
vendored
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: <script>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"
|
||||
|
||||
@@ -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())),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
412
testing/fixtures/acme/seo/resolve.json
Normal file
412
testing/fixtures/acme/seo/resolve.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
38
testing/fixtures/acme/seo/site.json
Normal file
38
testing/fixtures/acme/seo/site.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>' })
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user