From ed50826bf76e9cbcd9a8e1e15b68ff20b3f6c6d2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 14:07:54 +0100 Subject: [PATCH] docs: README for the SEO layer The layer on vue-seo-layer: the CMS head and fallback meta, ItemList nodes, the body link pipeline, breadcrumbs and pagination, the heading convention in both Journal H1 modes, the postTitleTag and breadcrumbs options, the post-title tokens, the purge route's new home, the smoke grammar and expectation files, and upgrade notes from 0.7. --- README.md | 89 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 007079a..13f9e43 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. +A Nuxt 4 layer that renders a Journal blog (post list, post, category, tag and author pages, RSS link) from the Journal API of a SummerCMS application, with its SEO output from the CMS through vue-seo-layer. ```bash pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0 @@ -10,20 +10,26 @@ pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0 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 extends [vue-seo-layer](https://git.golem15.com/golem15/vue-seo-layer): every page's head (title, description, canonical, robots, Open Graph, hreflang and one JSON-LD `@graph`), the sitemaps, robots.txt, breadcrumbs, pagination links and the shared site cache purge route come from the SummerCMS SEO and sitemap plugins through it. The journal layer adds its own nodes to that graph (an `ItemList` on listing pages) and applies the CMS's link rules to post bodies. See [SEO](#seo). + 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 list** (`journal-index`): paginated with `?page=N`, or `//N` when vue-seo-layer's `seo.pagination.form` is `path`. 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`. +- **Category** (`journal-category`): name, description and the category's rich HTML content (the pillar text, on page 1 only; the description shows on every page) 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. Post meta links to the author by `author.slug` (sm-journal-plugin v0.3.1+), falling back to the login on older APIs. - **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`. +- **SEO**: the head, canonical and JSON-LD come from the CMS through vue-seo-layer; the post's own title, excerpt and featured image are a fallback for a page without a CMS answer. Listing pages add an `ItemList` node. See [SEO](#seo). +- **Headings**: exactly one H1 per page in both Journal H1 modes (the post title is an `h1` while the Journal API reports `content_h1_to_h2` on and a `p.journal-post__title` while it is off; `journal.postTitleTag` overrides it), post cards at `h2` (`h3` under the related posts' `h2`). See [Headings](#headings). +- **Post cards**: one link per card, the title; the whole card is clickable through a CSS stretched link, with the focus ring on the card. +- **Body links and images**: the CMS's phrase auto-links, the outbound link rules (nofollow or sponsored from the domain lists; new tab, `noopener` and a visually hidden hint while the CMS's links_new_tab is on) and lazy, async body images, applied at SSR on the sanitized body. +- **Breadcrumbs and pagination**: `` at the top of every journal page (`journal.breadcrumbs`), and `JournalPagination` as `` with the journal's class names. +- **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. Five 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` (`alt=""` of a decorative image included), an integer `start` on `ol`, and a `rel` of only `nofollow`, `sponsored` and `ugc` on `a`. `target` is never kept; the outbound link rules add it. With `{ demoteH1: true }`, `h1` headings come out as `h2`. 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. @@ -54,8 +60,6 @@ The Journal Blog Editor never stores an iframe. It stores a placeholder: export default defineNuxtConfig({ extends: ['vue-journal-layer'], - site: { url: 'https://blog.acme.test', name: 'Acme Blog' }, - i18n: { strategy: 'prefix_except_default', defaultLocale: 'en', @@ -78,9 +82,9 @@ export default defineNuxtConfig({ }) ``` -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. +The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `sanitize-html` and `vue-seo-layer` 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. +Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` (and vue-seo-layer's `NUXT_SEO_API_BASE`) at it. A site without the SEO plugin sets `seo: { cms: false }`; its pages then keep the journal's fallback titles and descriptions. ## Configuration @@ -102,6 +106,8 @@ Options under the `journal` key of `nuxt.config`: | `journal.sort` | `published_at 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` | +| `journal.postTitleTag` | `'auto'` | The post title's element: `'auto'` follows the Journal H1 setting (`h1` while `content_h1_to_h2` is on or absent, `p` while it is off), `'h1'` or `'p'` force it. See [Headings](#headings) | +| `journal.breadcrumbs` | `true` | Render `` at the top of the journal pages; `false` for a site whose layout places them (the `BreadcrumbList` data is unchanged) | A route path must be `false` or start with `/`; a missing param fails the build with an error naming `journal.routes.`. @@ -109,23 +115,23 @@ 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.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR 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.public.journal.postTitleTag` | `NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG` | `journal.postTitleTag` | Overrides the post title's element at start (`auto`, `h1` or `p`) | +| `runtimeConfig.public.journal.breadcrumbs` | `NUXT_PUBLIC_JOURNAL_BREADCRUMBS` | `journal.breadcrumbs` | `false` at start turns the journal pages' breadcrumbs off | | `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`. +The module also copies `routes`, `perPage`, `sort`, `relatedCount`, `rss`, `postTitleTag` and `breadcrumbs` 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. +The shared purge route's settings (`runtimeConfig.cachePurge.token`, `NUXT_CACHE_PURGE_TOKEN`; `cachePurge.allowProxied`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`; the deprecated `siteCache.*` names) and every SEO setting (`seo.*`, `NUXT_SEO_API_BASE`, `NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT` and the rest) belong to vue-seo-layer; see its README. + +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` and `/rss.xml` 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 ` (`NUXT_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"","slug":""}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed route cache entries ( )`; 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. +**Shared route (vue-seo-layer).** `POST /api/__site__/cache/purge` with `Authorization: Bearer ` (`NUXT_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"","slug":""}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed route cache entries ( )`; 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. The route, its library and its site-config plugin live in vue-seo-layer only (the journal, menu and FAQ layers ship no copy since this release), and a purge also empties the SEO layer's resolve cache. **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":""}` 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. @@ -149,7 +155,7 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth - 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. +**Site config stripping (vue-seo-layer).** nuxt-site-config (in an app that still adds it) 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). vue-seo-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 @@ -180,7 +186,7 @@ The Journal API answers `GET /posts?search=` from Typesense in relevance order w 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. +**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 a sitemap exclude for an app that adds `@nuxtjs/sitemap` itself. The page's meta (`seo: { robots: 'noindex, follow' }`) makes vue-seo-layer send the robots meta tag and `X-Robots-Tag`, over the CMS's robots, and leaves the page out of its route report. 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 @@ -188,7 +194,7 @@ 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` +- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height`; the post title (an `h1` or a `p`, styled by its class): `--journal-post-title-size` (`2em`), `--journal-post-title-weight` (`700`), `--journal-post-title-line-height` (`1.2`) - 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` @@ -210,14 +216,41 @@ Pages and components can use the `useJournalApi()` client and the `useJournalLin 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 +## SEO -Set `site.url` and `site.name` in the app: titles take the form ` | `, and canonical and Open Graph URLs are absolute against `site.url`. Canonical URLs keep only the `page` query parameter (`seo.canonicalQueryWhitelist`). +The page head comes from the CMS: vue-seo-layer resolves every page through the SEO plugin and sets the title, description, canonical, robots, Open Graph, hreflang and the one JSON-LD `@graph` from the answer, with priority over the page's own tags. The layer no longer installs `@nuxtjs/seo` (no nuxt-site-config, schema.org, robots or sitemap module, no `site.url`/`site.name` title template). The sitemaps and robots.txt come from the SEO and sitemap plugins through vue-seo-layer's root files. -`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. +- **Fallback meta.** While a post page has no CMS answer (the SEO layer off with `seo: { cms: false }`, the CMS unreachable, or a page the CMS does not know), it sets the post title, the excerpt (or the first 160 characters of the body) as description, the featured image as `og:image` and the article times. Listing pages set their title (`· Page N` after page 1). An app that wants a site-name suffix there sets `app.head.titleTemplate`. +- **Canonical.** From the CMS. An author page reached by login points its canonical at the slug URL when the CMS answer has none. +- **ItemList.** The index, category, tag and author pages add an `ItemList` node (`@id` `#itemlist`, one `ListItem` with the absolute URL of each post shown, positions continuing across pages) through vue-seo-layer's `useSchemaNode` (`useJournalItemList`), so the page keeps one JSON-LD script. +- **Body links.** `JournalPostBody` renders the body through `renderJournalBody` in the UI-SPEC L7 order: the sanitizer (with H1 demotion), vue-seo-layer's `autolink` with the resolve answer's `autolinks` and `autolink_max` (first occurrence, whole words, never in headings, links, code, figure captions or buttons, never to the page itself), `applyOutbound` with the answer's `outbound` (the editor's `rel` kept, `nofollow` or `sponsored` from the domain lists; `target="_blank"`, `rel="noopener"` and a `` hint only while `new_tab` is on, the hint while vue-seo-layer's `seo.links.newTabHint` is on, its default) and `imageAttrs` (`loading="lazy"`, `decoding="async"`, stored width, height and `alt=""` kept). Internal links are untouched. +- **Breadcrumbs.** Post, index, category, tag and author pages render `` at the top of their content while `journal.breadcrumbs` is on. A site that places the trail in its layout sets it to `false` and renders one `` there; vue-seo-layer renders one trail per page either way, and the `BreadcrumbList` node is the CMS's. +- **Pagination.** `JournalPagination` renders `` with the journal's labels: the classes `journal-pagination`, `__prev`, `__pages`, `__current` and `__next` stay, plus page 1, the last page and `__gap` items, `aria-label`s and head `rel="prev"`/`rel="next"` links. Links follow the CMS's pagination form; with `seo.pagination.form: 'path'` in the app's config the module registers `//:page` routes for the index, category, tag and author pages. + +### Headings + +| Page or component | Element | +|-------------------|---------| +| Post title (`.journal-post__title`) | `h1` while the Journal API reports `content_h1_to_h2` on (or leaves it out); `p` while it is off. Same class, same look | +| Post body | Setting on: the body's `h1` headings become `h2`. Setting off: as written, so the body's own `h1` is the page's H1 | +| Category pillar text | The same rule from the category row's `content_h1_to_h2` (absent counts as on). With the setting off and an `h1` in the pillar text the page has two `h1` (the header and the pillar's) | +| Index, category, tag, author and search headers | `h1`; page 2 and later add `· Page N` | +| Post card title | `h2` in listings (`headingLevel` 2), `h3` under the related posts' `h2` (`headingLevel` 3) | + +`journal.postTitleTag: 'h1'` with the setting off gives two `h1`, and `'p'` with it on gives none; both are the documented consequence of forcing the element, which the SEO plugin's crawler reports. The feed's item links come from the Journal API, which links posts under `/blog/` on the application's configured URL. An app that moves `journal.routes.post` away from `/blog/:slug` should keep that in mind. +## Upgrading from 0.7 + +- **`@nuxtjs/seo` is gone.** The layer extends vue-seo-layer instead. Remove `site: { url, name }` and the `ogImage`, `seo.canonicalQueryWhitelist`, `sitemap`, `robots` and `schemaOrg` keys that only `@nuxtjs/seo` read; set `NUXT_SEO_API_BASE` (the SEO plugin, default `http://127.0.0.1:8096/_seo/api/v1`) or `seo: { cms: false }` for a site without the SEO plugin. `useSiteConfig()`, `useSchemaOrg()` and the other `@nuxtjs/seo` composables are no longer available to the app through this layer; an app that uses them adds `@nuxtjs/seo` itself (then it emits its own canonical and graph next to the CMS's) or moves to the CMS answer. +- **The journal sitemap source is gone** (`/api/__sitemap__/journal`, `buildJournalSitemapEntries`): the sitemap plugin builds the sitemaps, served through vue-seo-layer. +- **The shared purge route moved** to vue-seo-layer, with the same path, contract and variables; `server/lib/siteCachePurge.ts`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` are no longer in this layer. The deprecated `POST /api/__journal__/cache/purge` keeps working. +- **Post cards have one link.** `.journal-post-card__more` and the image link are removed; `.journal-post-card__image` is a `div`. App CSS for the image link or the read-more link has nothing to style; the whole card is clickable. +- **The post title may be a `p`.** With the Journal H1 setting off it renders as `p.journal-post__title`; app CSS that styles `h1` for the title styles `.journal-post__title` or sets the `--journal-post-title-*` tokens. +- **Breadcrumbs appear** at the top of journal pages. A layout that already renders breadcrumbs sets `journal.breadcrumbs: false`. +- **Related post cards are `h3`.** CSS that targets `h2.journal-post-card__title` targets the class instead. + ## Local development ```bash @@ -234,18 +267,18 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co ## 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. +- `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 H1 demotion and the body pipeline order, the embed expansion and its sanitize-first order, the post page view, SEO values (fallback meta, the title element, ItemList), path-form routes, the old purge route's helpers and browser revalidation (`tests/siteCache.test.ts`), and pl/en key and placeholder parity. The shared purge route and the body link steps are tested in vue-seo-layer. They need no build, network or database. - `scripts/check-git-install.sh ` 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 --expect ` 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/smoke-ssr.mjs --output <.output> --fixtures --expect [--env KEY=VALUE]...` boots a fixture Journal and SEO API and the built server and checks each line of the expectations file: `STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX] [header=NAME: VALUE] [noheader=NAME] [headings=strict] [cardlinks=N]`, every option repeatable, and `resolves PATH N` (the SEO resolve calls so far for a path, `*` for all). `headings=strict` wants exactly one ` --fixtures --kind post|category --slug --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 ( checks)`. - `testing/purge-token-check.mjs --output <.output> --fixtures --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 ( 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/`. +- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and the SEO plugin's `GET /_seo/api/v1/resolve` and `/site` from `testing/fixtures/acme/seo/`; `resolveCount(path?)` counts resolve calls and `reload()` re-reads the fixtures; `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 +pnpm smoke:ssr 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. +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 and robots. It shows one post per category page (`perPage.category: 1`), so the news category has twelve pages with gap items, and its layout renders `` only while `journal.breadcrumbs` is off.