Files
vue-journal-layer/README.md
Jakub Zych 102da43f76 feat: opt-in Journal search page and JournalSearchBox
- journal.routes.search (off by default) registers journal-search, the
  results page for ?q= in the API's relevance order (no sort is sent),
  server-rendered with a Polish-aware result count.
- JournalSearchBox: an icon button and a WAI-ARIA combobox with debounced
  ?fields=suggest suggestions, stale answers dropped, query words marked
  ignoring diacritics; a plain GET form without JS.
- The module keeps the search path out of the route cache and the sitemap
  and marks it noindex, follow.
- Client: suggestPosts and the fields option; useJournalLinks.searchLink.
2026-10-30 18:14:05 +01:00

252 lines
29 KiB
Markdown

# vue-journal-layer
A Nuxt 4 layer that renders a Journal blog (post list, post, category, tag and author pages, RSS link, SEO and sitemap) from the Journal API of a SummerCMS application.
```bash
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0
```
## Overview
The layer is consumed with `extends: ['vue-journal-layer']`. It reads content only from the anonymous GET routes of the Journal API (`/_journal/api/v1`) and never calls a write route. Pages render on the server (SSR), so every URL answers with full HTML.
The layer is neutral: it ships no site colours and no site copy. Its look comes from `--journal-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
Route paths are configuration, not files. The layer's page components live in `app/journal-pages/` and a local module registers them at the paths in `journal.routes`, so an app moves the blog to any URL shape, or switches a view off, without copying pages. Route names stay stable, and links are always built from the names.
## Features
- **Post list** (`journal-index`): paginated with `?page=N`. A non-numeric page or a page past the last one answers 404.
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead.
- **Category** (`journal-category`): name, description and the category's rich HTML content above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL.
- **Search** (`journal-search`, opt-in): a results page for `?q=` and the `JournalSearchBox` header component with suggestions as the visitor types. Off until the app sets `journal.routes.search`. See [Search](#search).
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
- **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`.
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img`, and an integer `start` on `ol`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
- **Embeds**: YouTube videos added in the Journal Blog Editor render as privacy-enhanced iframes (see [Embeds](#embeds)).
- **i18n**: English and Polish UI strings, registered only for the locales the app enables.
## Embeds
The Journal Blog Editor never stores an iframe. It stores a placeholder:
```html
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
```
`expandEmbeds(html)` in `app/utils/embeds.ts` turns it into:
```html
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
```
- **Allowlist:** `EMBED_PROVIDERS` holds one provider, `youtube`, whose id must match `^[A-Za-z0-9_-]{11}$`. The iframe `src` is always `https://www.youtube-nocookie.com/embed/` plus that id. `EMBED_PLACEHOLDER` matches the placeholder exactly as the editor writes it (`data-embed` then `data-id`, double quotes, empty figure). Any other figure, provider or id is left unchanged and renders as an empty figure.
- **Order:** sanitize first, expand last. `JournalPostBody` renders `expandEmbeds(sanitizeJournalHtml(html))` and `JournalCategoryHeader` does the same with the category content. The payload transforms (`toPostView`, the category page) only sanitize, so the payload carries the placeholder. The expansion's output is never sanitized again, because the sanitizer removes every iframe, including this one. A raw `<iframe>` in stored HTML is removed by the sanitizer before the expansion runs, so the only iframe on a page is one built from a validated placeholder.
- **Styles:** `.journal-embed` is a full-width block with a 16 / 9 aspect ratio; its iframe fills it without a border. `.journal-embed--youtube` marks the provider for app styles.
- **Overrides:** an app that replaces `JournalPostBody` or `JournalCategoryHeader` and wants embeds calls `expandEmbeds` on the sanitized string, never before sanitizing.
- **Content-Security-Policy:** a site that sends a CSP needs `frame-src https://www.youtube-nocookie.com`.
## Usage
```ts
// nuxt.config.ts of the app
export default defineNuxtConfig({
extends: ['vue-journal-layer'],
site: { url: 'https://blog.acme.test', name: 'Acme Blog' },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [{ code: 'en', language: 'en-US' }],
},
journal: {
routes: {
index: '/blog',
post: '/blog/:slug',
category: '/:category', // category pages at the root
author: '/authors/:author',
tag: false, // no tag pages
},
perPage: { index: 12, category: 10 },
},
// SSR cache, so a new post shows up without a rebuild.
routeRules: { '/**': { swr: 300 } },
})
```
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `@nuxtjs/seo` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route.
Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` at it.
## Configuration
Options under the `journal` key of `nuxt.config`:
| Key | Default | Meaning |
|-----|---------|---------|
| `journal.routes.index` | `/blog` | Post list path, or `false` |
| `journal.routes.post` | `/blog/:slug` | Post path; must contain `:slug`, or `false` |
| `journal.routes.category` | `/blog/category/:category` | Category path; must contain `:category`, or `false` |
| `journal.routes.tag` | `/blog/tag/:tag` | Tag path; must contain `:tag`, or `false` |
| `journal.routes.author` | `/blog/author/:author` | Author path; must contain `:author`, or `false` |
| `journal.routes.search` | `false` | Search results path, such as `/search`, or `false`; takes no param (the term is `?q=`) |
| `journal.perPage.index` | `9` | Posts per list page (the API caps at 30) |
| `journal.perPage.category` | `9` | Posts per category page |
| `journal.perPage.tag` | `9` | Posts per tag page |
| `journal.perPage.author` | `9` | Posts per author page |
| `journal.perPage.search` | `9` | Results per search page |
| `journal.sort` | `published_at desc` | `<column> <asc\|desc>`; columns `published_at`, `created_at`, `updated_at`, `title`, `id` |
| `journal.relatedCount` | `3` | Related posts under a post |
| `journal.rss` | `true` | Serve and link `/rss.xml` |
A route path must be `false` or start with `/`; a missing param fails the build with an error naming `journal.routes.<key>`.
Runtime config, overridable by environment variables at start:
| Key | Env | Default | Meaning |
|-----|-----|---------|---------|
| `runtimeConfig.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. |
| `runtimeConfig.public.journal.apiBase` | `NUXT_PUBLIC_JOURNAL_API_BASE` | `/_journal/api/v1` | Browser API base for client-side navigation; the reverse proxy routes it to the application. |
| `runtimeConfig.cachePurge.token` | `NUXT_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the shared [site cache purge](#site-cache-purge) route `/api/__site__/cache/purge`. With fewer than 32 characters the route answers 404. |
| `runtimeConfig.cachePurge.allowProxied` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` | `false` | `true` lets the shared purge route accept requests that came through a reverse proxy, for a backend that reaches the site at an https URL on another host; the bearer token is then the only gate. |
| `runtimeConfig.siteCache.purgeToken` | `NUXT_SITE_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): the earlier name of `cachePurge.token`, read when that is empty. See [Upgrading from 0.5](#upgrading-from-05). |
| `runtimeConfig.siteCache.allowProxied` | `NUXT_SITE_CACHE_ALLOW_PROXIED` | `false` | Deprecated (one release): the earlier name of `cachePurge.allowProxied`; either flag set to `true` turns it on. |
| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): token of the old route `/api/__journal__/cache/purge`. With fewer than 32 characters that route answers 404. |
The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`.
Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. Under route caching (`swr` or `cache` route rules), Nitro renders a page from a copy of the request that carries no headers, so the layer's Nitro plugin `server/plugins/journal-client-address.ts` records the address in `event.context.journalForwardedFor` on the request hook, and `useJournalApi`, `/rss.xml` and the sitemap source forward that value. The API base always comes from runtime config, never from request input.
## Site cache purge
An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before an admin edit until the cache entry expires. The layer adds the shared purge route that the SummerCMS backend calls after an admin save commits (the framework's `site_cache.purge_url` and `site_cache.purge_token` settings), so the edit shows on the next page view. The Journal plugin purges after post and category saves, the menu plugin after menu saves and reorders, the FAQ plugin after FAQ saves.
**Shared route.** `POST /api/__site__/cache/purge` with `Authorization: Bearer <runtimeConfig.cachePurge.token>` (`NUXT_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"<kind>","slug":"<slug>"}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed <n> route cache entries (<kind> <slug>)`; a kind other than lower-case letters and underscores (up to 40) logs as `unknown`, and the slug loses control characters and is cut to 200 characters first. vue-journal-layer, vue-menu-layer and vue-faq-layer ship `server/lib/siteCachePurge.ts`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` byte for byte (`tests/sitePurge.test.ts` pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
**Old route (one release).** `POST /api/__journal__/cache/purge` with the token in `runtimeConfig.journal.cachePurgeToken` (`NUXT_JOURNAL_CACHE_PURGE_TOKEN`) and the body `{"kind":"post"|"category","slug":"<slug>"}` keeps working unchanged, with the log line `[journal] cache purge: …`, so a backend still configured with the Journal plugin's deprecated keys keeps purging. Move to the shared route and `NUXT_CACHE_PURGE_TOKEN` before the next release removes it.
**Guards** (both routes).
- POST only.
- `404` when the route's token holds fewer than 32 characters, so an app without a token has no purge route.
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy. The shared route makes one exception, below.
- `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }` and `'/api/__site__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
**What it removes.** Every entry of Nitro's route cache (the `nitro:routes` group of the `cache` storage): pages, their `_payload.json` and feeds. Other cache groups stay. One save can change many pages (the menu lists every category on every page, and the home, category, author and related-post lists show posts), and Nitro keys entries by a hash of the path, so a full purge is the reliable choice; it costs one fresh render per page on its next view.
**Browser revalidation.** Nitro answers a route-cached page with `s-maxage=<n>, stale-while-revalidate`, which gives browsers no `max-age`, so they guess a freshness lifetime from `Last-Modified`; its 304 answer carries `public, max-age=<n>, s-maxage=<n>`. Either way a browser could keep its copy past a purge. The layer's Nitro plugin `server/plugins/journal-revalidate.ts` rewrites every `cache-control` value that carries `s-maxage` to `max-age=0` for browsers (`privateRevalidate` in `app/utils/siteCache.ts`), so a browser asks on each view and gets a cheap 304 while the page is unchanged. Shared caches keep `s-maxage`. Static assets carry no `s-maxage` and are untouched.
**Deployment.**
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files. Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config publishes those in every page's payload.
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__site__/cache/purge`), not through the public site.
- A backend on another host calls an https `site_cache.purge_url` through the site's TLS-terminating proxy, which adds forwarding headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`) and the shared route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret, and let the proxy forward POST to this one path only. Unset, proxied requests stay refused. The old `/api/__journal__/cache/purge` route never accepts them.
- The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks.
- The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries.
**Site config stripping.** nuxt-site-config (through `@nuxtjs/seo`) copies every `NUXT_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 layer's Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from the site config on nuxt-site-config's `site-config:init` hook, whatever names the site uses, and logs one `[site-cache]` deprecation warning per process when a setting comes from an old name (it names the variables, never a value). In an app without nuxt-site-config the plugin does nothing.
### Upgrading from 0.5
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.6.x (a non-empty new token wins) and are removed in the next minor release.
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so up to 0.5 a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.6 the layer strips both keys from the site config even under the old names.
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
## Search
Search is off by default: an app that does not set `journal.routes.search` gets no search page, and `JournalSearchBox` renders nothing. To turn it on:
```ts
journal: { routes: { search: '/szukaj' } },
```
The Journal API answers `GET /posts?search=` from Typesense in relevance order when the backend has it on, and from SQL otherwise; the layer sends no `sort` with a search, so the API's order is kept. Terms under 3 characters are never sent: the API would ignore them.
**Results page** (`journal-search`). Reads `?q=` and `?page=N`, renders the matches with `JournalPostList` and `JournalPagination` under a search form and a result count (plural forms through `Intl.PluralRules`: Polish one, few and many), and shows its own message for an empty term, a term under 3 characters and no results. It is fetched with `useAsyncData` on the server, so it works without JS. The title includes the term; the term is rendered as text everywhere.
**Header box** (`<JournalSearchBox />`). An icon button opens a search input; once the term has 3 or more characters, a suggestion request (`GET /posts?search=…&fields=suggest&per_page=5`) goes out 250 ms after the last keystroke, and a newer term aborts the running request and drops its answer. Up to `limit` suggestions show the title, the excerpt and a small featured image, with the query words in `<mark>` ignoring case and diacritics ("grzybow" marks "grzybów"); the marking splits text and never inserts API HTML. Under them, "see all results" links to the search page with `q`. Enter opens the active suggestion, or the search page when none is active. The input is a WAI-ARIA combobox: ArrowDown and ArrowUp move through the options (`aria-activedescendant`), Escape closes the box and returns focus to the button, and a click outside closes it. A spinner shows after 300 ms, "no results" when nothing matched, and a quiet message when the request failed. Without JS the box is a plain GET form to the search page, always shown. Reopening selects the previous term.
| Prop | Default | Meaning |
|------|---------|---------|
| `limit` | `5` | Suggestions shown |
| `minLength` | `3` | Shortest term that sends a request |
| `debounce` | `250` | Wait after the last keystroke, in ms |
| `placeholder`, `buttonLabel`, `seeAllLabel`, `emptyLabel` | the `journal.search.*` strings | Text overrides |
The `icon` slot replaces the magnifier. The root gets `journal-search--open` while open and `journal-search--enhanced` once mounted. The panel (`.journal-search__panel`) is inline next to the button, with the suggestions (`.journal-search__dropdown`) positioned below its left edge; an app makes it a full-width panel under its header, or anchors the suggestions on the right (`left: auto; right: 0`), in its own CSS.
**Caching, robots and sitemap.** Nitro's route cache keys by path, so under an app rule such as `'/**': { swr: 300 }` every `?q=` would get the first query's cached page. The journal module therefore adds `{ cache: false, robots: 'noindex, follow' }` route rules for the search path and its locale-prefixed copies (`/pl/szukaj`), and excludes them from `sitemap.xml`. An app's own rule for the same path wins key by key. The search page is not route-cached: every search renders on request.
## Theming
Every visual value is a token in `app/assets/css/journal-tokens.css`:
- Colour: `--journal-color-text`, `--journal-color-muted`, `--journal-color-bg`, `--journal-color-surface`, `--journal-color-border`, `--journal-color-link`, `--journal-color-link-hover`, `--journal-color-accent`, `--journal-color-on-accent`, `--journal-color-mark` (search term highlight)
- Shadow: `--journal-shadow-dropdown` (search suggestions)
- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height`
- Spacing: `--journal-space-1` to `--journal-space-6`
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
- Layout: `--journal-content-width`, `--journal-wide-width`, `--journal-card-min`, `--journal-image-ratio`
Override tokens in the app's CSS. The app's `css` entries load after the layer's; a `:root:root` selector wins regardless of order:
```css
:root:root {
--journal-color-link: #b4232a;
--journal-font-heading: 'Acme Serif', serif;
}
```
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink` and `JournalSearchBox`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`, `searchLink`), which return `null` for a disabled view. The client's `suggestPosts(term, { perPage, signal })` returns the suggestion rows (`JournalPostSuggestion`).
## i18n
The layer ships `i18n/locales/en.json` and `pl.json` under the `journal.*` key tree. It declares no locales itself: its module registers a string file only for a locale the app enables, so an app that enables only `pl` gets no `/en` routes. An app that enables another locale adds its own `journal.*` strings in its locale file. Dates are formatted with `Intl.DateTimeFormat` for the active locale's `language`, in UTC.
## SEO and sitemap
Set `site.url` and `site.name` in the app: titles take the form `<page title> | <site.name>`, and canonical and Open Graph URLs are absolute against `site.url`. Canonical URLs keep only the `page` query parameter (`seo.canonicalQueryWhitelist`).
`sitemap.xml` includes the app's static pages plus a dynamic source at `/api/__sitemap__/journal`, which pages through the posts (30 per request) and adds every category, building each URL from `journal.routes`. A disabled view adds no URLs.
The feed's item links come from the Journal API, which links posts under `/blog/<slug>` on the application's configured URL. An app that moves `journal.routes.post` away from `/blog/:slug` should keep that in mind.
## Local development
```bash
pnpm install
pnpm dev # the .playground app, extending this layer
pnpm build:playground # production build of the playground
```
To work on the layer inside an app, link it instead of installing from git: `pnpm link ../vue-journal-layer`, or an `overrides` entry `vue-journal-layer: link:../vue-journal-layer` in `pnpm-workspace.yaml`. Never commit the link; the app's committed dependency stays the git tag.
## Releasing
A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.com. Apps pin it in `package.json` as `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, and `pnpm-lock.yaml` records the tag's commit. To upgrade, change the tag and run `pnpm install`. The repository is public, so installing needs no credentials. The layer has no install-time build or `prepare` script, which a git-hosted dependency would need to run during `pnpm install`.
## Testing
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts` for the old route's helpers and browser revalidation, `tests/sitePurge.test.ts` for the shared route's helpers, the purge settings under their new and deprecated names, the site config stripping plugin and the sha256 pins of the shared files), and pl/en key and placeholder parity. They need no build, network or database.
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
- `testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok (<n> checks)`.
- `testing/purge-token-check.mjs --output <.output> --fixtures <dir> --path <page path>` proves on a built server that the purge token never reaches a page. It starts the server three times with fresh random tokens: under `NUXT_CACHE_PURGE_TOKEN`, under the deprecated `NUXT_SITE_CACHE_PURGE_TOKEN` with `NUXT_SITE_CACHE_ALLOW_PROXIED=true`, and under both names with different values. Each time `/`, the page and its `_payload.json` (when served) must contain no token value and neither `cachePurgeToken` nor `cacheAllowProxied`, the shared purge route must accept the expected bearer and refuse the others, and the deprecation warning must appear once with the old names only. The last line is `purge-token-check: ok (<n> checks)`.
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and `reload()` re-reads them; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server and returns its `url`, `close()` and `output()`, the server's captured stdout and stderr so far. Apps can import both from `node_modules/vue-journal-layer/testing/`.
```bash
pnpm build:playground
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
```
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host, enables the search page at `/search` and places `JournalSearchBox` above the layout, so the smoke covers the search page, its cache bypass, robots and sitemap rules.