20 Commits

Author SHA1 Message Date
Jakub Zych
f5dc9b2c6d fix: vue-seo-layer as a peer dependency
pnpm refuses a git dependency inside another package by default
(blockExoticSubdeps), so an app could not install v0.8.0. vue-seo-layer
is now a peer dependency (^0.2.0) and a development dependency pinned
to v0.2.0; apps list vue-seo-layer#v0.2.0 next to the layer.
2026-11-05 01:34:21 +01:00
Jakub Zych
0a77e6e85c chore: pin vue-seo-layer v0.2.0 2026-11-05 01:21:46 +01:00
Jakub Zych
761c7fc0ed chore: version 0.8.0 and install pin
- package.json version 0.8.0, README install line #v0.8.0 (L4 CR-01)
- Upgrading from 0.7: the HTML sitemap pages, the catch-all rule and
  seo.htmlSitemap.overCatchAll (L4 WR-04), the post page's article meta
  and the path-form listing segments
- the link:../vue-seo-layer dependency stays until the release swap
2026-11-04 22:54:59 +01:00
Jakub Zych
d4ba3e2ddf feat: check-git-install --check-links refuses local dependencies
- --check-links DIR... reports every link:, file:, workspace: or portal:
  dependency in each DIR/package.json and exits 1 (L4 CR-01)
- the tag mode runs the same check on the installed layer before building
- --self-test adds planted package.json cases next to the lockfile case
- README Releasing: swap the link for the seo tag pin, run --check-links,
  then the tag mode after pushing
2026-11-04 22:54:18 +01:00
Jakub Zych
d64fe47adf fix: path-form listing routes use the SEO module's resolved pagination
- journalPaginationSource answers runtimeConfig.public.seo.pagination (the
  SEO module's option with its defaults), else the raw seo.pagination
- the module computes the paged routes inside its extendPages callback, so
  an app with only form: 'path' gets /blog/strona/:page and
  /blog/page/:page (L4 WR-03)
2026-11-04 22:53:13 +01:00
Jakub Zych
839db5156c fix: post pages keep og:type article and their article times
- journalArticleMeta(post) answers og:type article and the publish and
  update times; PostPage always sets them, the CMS-owned keys keep the
  no-answer fallback (L4 WR-02)
- expect-acme.txt: one og:type article and the article times with a CMS
  answer (heading-on) and without one (uncategorised-note)
2026-11-04 22:51:31 +01:00
Jakub Zych
1e05efcd25 test: the playground's HTML sitemap paths per locale
- seo.htmlSitemap.paths en /sitemap, pl /pl/mapa-strony, the full-path
  rule for i18n apps; the build now holds exactly seo-sitemap-en___en,
  seo-sitemap-group-en___en, seo-sitemap-pl___pl and
  seo-sitemap-group-pl___pl (L4 WR-05 evidence)
2026-11-04 22:49:58 +01:00
Jakub Zych
e8f307b248 fix: check-git-install pins only the layer's commit and builds the playground config
- the lockfile check counts only vue-journal-layer's commit, so the layer's
  own git dependency on vue-seo-layer no longer fails it
- the scratch app takes the playground's nuxt.config.ts and app.vue (the
  search route, one post per category page, the layout trail), which
  testing/expect-acme.txt is written against
- --self-test checks the lockfile reading
2026-11-04 19:52:22 +01:00
Jakub Zych
9e66cbed42 test: the post title element for every postTitleTag and H1 setting
auto follows content_h1_to_h2 (p only when it is false, h1 when the key is
absent), h1 and p force the element, any other option value is auto.
2026-11-04 19:48:57 +01:00
Jakub Zych
c6ffa6ca13 fix: export JournalImageVariant from one auto-imported file
- types.ts re-exported the interface srcset.ts declares, so every build of
  an app with the layer warned Duplicated imports "JournalImageVariant"
- a test checks that every name of app/utils and app/composables has one
  home
2026-11-04 19:28:38 +01:00
Jakub Zych
1080a3f0c4 feat: WebP and srcset for featured images
- JournalFeaturedImage renders <picture> from featured_images[].variants:
  a WebP source and the original format as srcset fallback, the
  original's width and height, alt; lazy in cards, eager with
  fetchpriority high on the post page; unchanged without variants
- app/utils/srcset.ts (imageSrcsets) with node tests
- acme fixtures and SSR expectations for both cases
2026-11-04 17:10:30 +01:00
Jakub Zych
adcea4d538 feat: category neighbours on the post page
The post page links previous_in_category and next_in_category (the
Journal's opt-in scored related mode) as server-rendered links with the
title in the anchor text when the Journal answers one, else the
site-wide previous_post and next_post (postNeighbours). Types, a fixture
post and expectation lines for both cases.
2026-11-04 14:15:22 +01:00
Jakub Zych
ed50826bf7 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.
2026-11-04 14:07:54 +01:00
Jakub Zych
642c25a0d0 feat: ItemList nodes on listing pages
The index, category, tag and author pages add an ItemList node
(@id <page URL>#itemlist, a ListItem with the absolute URL of each post
shown, positions continuing across pages) through vue-seo-layer's
useSchemaNode, so the page keeps one JSON-LD script with one @graph
(useJournalItemList, journalItemList).
2026-11-04 13:55:55 +01:00
Jakub Zych
98904b8fbd feat: breadcrumbs, CMS pagination and pillar content on page one
- post, index, category, tag and author pages render <SeoBreadcrumbs>
  at the top of their content while journal.breadcrumbs is on (default);
  false leaves the trail to the site's layout, the BreadcrumbList data
  is unchanged
- JournalPagination renders <SeoPagination class-prefix="journal-pagination">
  with the journal's labels: the same class names, plus page 1, the last
  page, gap items, aria labels and head rel prev/next, with links in the
  CMS's pagination form
- with vue-seo-layer's seo.pagination.form 'path' the module registers
  <listing>/<segment>/:page routes (journalPagedRoutes), and the listing
  pages read the page from the :page param or ?page (pageFromRoute)
- the category pillar text renders on page 1 only, the description on
  every page; an h1 in it becomes h2 while the category row's
  content_h1_to_h2 is on (absent counts as on)
2026-11-04 13:54:46 +01:00
Jakub Zych
51e5d67576 feat!: one link per post card and the heading convention
- JournalPostCard: the title link is the card's only anchor; the image is
  no longer a link and the separate read-more link is gone; the whole card
  stays clickable through a CSS stretched link with the focus ring on the
  card; prop headingLevel (2 | 3, default 2)
- related posts keep their h2 and render their cards at h3; JournalPostList
  passes headingLevel through
- smoke checks headings=strict (one h1, no skipped level) and cardlinks=N
  on every fixture page; expectation files for postTitleTag forced to h1
  and p (the documented two-h1 and no-h1 consequences)

BREAKING CHANGE: .journal-post-card__more and the image link
(.journal-post-card__image is now a div) are removed from post cards.
2026-11-04 13:49:36 +01:00
Jakub Zych
1010a603f0 feat: body links, outbound rules and image attributes in the post body
- renderJournalBody runs the UI-SPEC L7 order on the sanitized body:
  h1 demotion, the CMS phrase auto-links, the outbound rules and lazy,
  async images, with vue-seo-layer's autolink, applyOutbound and
  imageAttrs; PostBody feeds it the page's resolve answer (autolinks,
  autolink_max, outbound, url) and the seo.links.newTabHint option
- the sanitizer keeps a link rel of only nofollow, sponsored and ugc
  (the Blog Editor's link form, as the Journal plugin's policy does);
  target is never kept, the outbound rules add it
- editor-corpus.json copied again from sm-journal-plugin (link-rel and
  image-decorative entries)
- smoke:ssr script; expectations for auto-links, both new-tab modes and
  the hint switched off
2026-11-04 13:49:36 +01:00
Jakub Zych
289d031201 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.
2026-11-04 13:37:11 +01:00
Jakub Zych
1dd3176060 fix: link post authors by slug, not by login 2026-10-31 13:13:38 +01:00
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
71 changed files with 3607 additions and 3030 deletions

View File

@@ -1,4 +1,16 @@
<script setup lang="ts">
// A site that places breadcrumbs in its layout turns the journal pages' own
// off (journal.breadcrumbs false, here NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false)
// and renders one <SeoBreadcrumbs> itself.
const journal = useRuntimeConfig().public.journal as { breadcrumbs?: unknown }
const layoutTrail = journal.breadcrumbs === false || journal.breadcrumbs === 'false'
</script>
<template>
<header class="playground-header">
<JournalSearchBox />
</header>
<div v-if="layoutTrail" class="playground-trail"><SeoBreadcrumbs /></div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>

View File

@@ -3,10 +3,17 @@
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 } },
// The search page is opt-in; the playground turns it on for the smoke.
// One post per category page, so the news category has 12 pages and the
// smoke sees first, last and gap items.
journal: { routes: { search: '/search' }, perPage: { category: 1 } },
// The HTML sitemap paths as an i18n app gives them: each locale's full
// path, the prefixed one included (vue-seo-layer README). The layer
// registers each locale's pages once, at that path, named for the locale.
seo: { htmlSitemap: { paths: { en: '/sitemap', pl: '/pl/mapa-strony' } } },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',

157
README.md
View File

@@ -1,28 +1,38 @@
# 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
pnpm add git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0 git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.8.1
```
vue-seo-layer is a peer dependency (`^0.2.0`): the app lists it next to the layer, pinned to a tag as above, and needs no `extends` entry for it (the layer extends it). The layer cannot bring it itself: pnpm refuses a git dependency inside another package (`blockExoticSubdeps`, on by default).
## 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 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** (`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.
- **Post list** (`journal-index`): paginated with `?page=N`, or `<listing>/<segment>/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. With the Journal's opt-in `golem15.journal.related_mode: scored`, the related posts are the Journal's ranked ones and the previous and next links are the post's neighbours within its first category (`previous_in_category`, `next_in_category`, server-rendered links with the title in the anchor text); without them, the site-wide `previous_post` and `next_post`.
- **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).
- **Featured images**: with the Journal API's `featured_images[].variants` (sm-journal-plugin's image variants), a `<picture>` with a WebP `<source>` and the `<img>` in the original format, both with `srcset` and `sizes` (the original itself is the widest candidate), the original's width and height, and `alt`. Cards load it lazily; the post page loads its main image eagerly with `fetchpriority="high"`. An image without variants renders the single `<img>` as before. See [Featured images](#featured-images).
- **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**: `<SeoBreadcrumbs>` at the top of every journal page (`journal.breadcrumbs`), and `JournalPagination` as `<SeoPagination>` 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.
@@ -53,8 +63,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',
@@ -77,9 +85,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` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. `vue-seo-layer` is a peer dependency: the app lists it (`"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"`). 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
@@ -92,13 +100,17 @@ Options under the `journal` key of `nuxt.config`:
| `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` |
| `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 `<SeoBreadcrumbs>` 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.<key>`.
@@ -106,23 +118,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 <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.
**Shared route (vue-seo-layer).** `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. 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":"<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.
@@ -146,7 +158,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
@@ -154,12 +166,38 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth
- 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 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
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`
- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height`
- 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`; 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`
@@ -173,22 +211,67 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's
}
```
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` and `JournalRssLink`. 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.
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`), which return `null` for a disabled view.
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`).
## Featured images
`JournalFeaturedImage` takes `src` (the original's URL), `alt`, `eager`, and optionally `image` (the `featured_images` entry) and `sizes` (`100vw` by default). `JournalPostCard` passes the post's first featured image with `sizes="(min-width: 48rem) 33vw, 100vw"`, the post page with `sizes="(min-width: 46rem) 46rem, 100vw"` and `eager`. An app whose grid or reading column differs overrides the component, or wraps it with its own `sizes`. With variants the component renders:
```html
<figure class="journal-featured-image"><picture>
<source type="image/webp" srcset="…_480_288_0_0_exact.webp 480w, …_800_480_0_0_exact.webp 800w" sizes="…">
<img src="…/cover.jpg" srcset="…_480_288_0_0_exact.jpg 480w, …_800_480_0_0_exact.jpg 800w, …/cover.jpg 1000w" sizes="…" width="1000" height="600" alt="…" loading="eager" fetchpriority="high" decoding="async">
</picture></figure>
```
`imageSrcsets(variants, originalUrl, originalWidth?)` in `app/utils/srcset.ts` builds the two srcsets (narrowest first, each width once, addresses escaped for srcset) and the reserved size from the variants (`source_width`, `source_height` when the API sends them, else the largest variant); no usable variant answers `null`. The CSS is unchanged: `.journal-featured-image img` keeps the ratio and the crop, and the `picture` element is a block.
## 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
## SEO
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`).
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 and the featured image as `og:image`. Listing pages set their title (`· Page N` after page 1). An app that wants a site-name suffix there sets `app.head.titleTemplate`.
- **Article meta.** Every post page sets `og:type` `article`, `article:published_time` (`published_at`) and `article:modified_time` (`updated_at`) from the post, whether or not the CMS answers: the CMS head never carries them (`journalArticleMeta`). The CMS-owned keys (title, description, `og:title`, `og:description`, `og:image`) come from the CMS when it answers.
- **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` `<page URL>#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 `<span class="seo-visually-hidden">` 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 `<SeoBreadcrumbs>` 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 `<SeoBreadcrumbs>` there; vue-seo-layer renders one trail per page either way, and the `BreadcrumbList` node is the CMS's.
- **Pagination.** `JournalPagination` renders `<SeoPagination class-prefix="journal-pagination">` 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 `<listing>/<segment>/:page` routes for the index, category, tag and author pages. The segments follow vue-seo-layer's resolved `seo.pagination` option, defaults included (`pl` `strona`, `en` `page`), the same ones its pagination links use, so an app that sets only `form: 'path'` gets `/blog/strona/:page` and `/blog/page/:page`.
### 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/<slug>` 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
- **List vue-seo-layer in the app** (from 0.8.1): `"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` next to `vue-journal-layer#v0.8.1`. It is a peer dependency of the layer (`^0.2.0`). 0.8.0 listed it as its own git dependency, which pnpm refuses by default (`ERR_PNPM_EXOTIC_SUBDEP`), so use 0.8.1.
- **`@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.
- **HTML sitemap pages.** The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default, set in `seo.htmlSitemap.paths`). An app that renders its pages through a root catch-all (`pages/[...slug].vue`, as with the pages plugin) gets none, so a CMS page at such a path is not shadowed, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps its page; `seo: { htmlSitemap: false }` turns them off. An app without pages of its own (only `app.vue`) gets none and needs no setting.
- **Post pages keep their article meta.** `og:type` `article`, `article:published_time` and `article:modified_time` come from the post on every post page, also while the CMS answers (the CMS head never carries them); the title, description and `og:image` come from the CMS when it answers.
- **Path-form listing routes follow vue-seo-layer's segments.** With `seo.pagination.form: 'path'` the listings get `<listing>/strona/:page` and `<listing>/page/:page` from the SEO layer's resolved option (its defaults included), the same segments its pagination links use.
## Local development
```bash
@@ -203,20 +286,26 @@ To work on the layer inside an app, link it instead of installing from git: `pnp
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`.
A content layer (this one, vue-menu-layer, vue-faq-layer) extends vue-seo-layer, declared as a peer dependency (`"vue-seo-layer": "^0.2.0"`, which the app provides) and as a development dependency for the playground and the tests. The development dependency may point at the local checkout (`link:../vue-seo-layer`) while both change together; a tag cut with that link breaks every git install, because the path resolves next to the pnpm store entry, not to a package. vue-seo-layer is never a regular dependency of a content layer: pnpm refuses a git dependency inside another package (`blockExoticSubdeps`). Before tagging any content layer:
1. Tag vue-seo-layer first, then in each content layer set the development dependency to the tag pin (`"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"`), the peer range to match, and run `pnpm install`, so `pnpm-lock.yaml` records the seo layer's commit.
2. Run `bash scripts/check-git-install.sh --check-links ../vue-seo-layer ../vue-journal-layer ../vue-menu-layer ../vue-faq-layer`. It fails while any of those `package.json` files has a `link:`, `file:`, `workspace:` or `portal:` dependency, and names each one.
3. Tag and push, then run `bash scripts/check-git-install.sh <tag>`, which repeats the check on the installed tag before building.
## 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, 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`.
- `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 featured image srcsets (`tests/srcset.test.ts`), 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 <tag>` installs the layer from the git tag, next to vue-seo-layer at the tag's development pin (`SEO_LAYER_SPEC` overrides it), with pnpm's default settings, into a scratch app built from the layer's playground config (`.playground/nuxt.config.ts` extending the installed layer, and its `app.vue`), checks that `pnpm-lock.yaml` pins the tag's commit for vue-journal-layer (its own git dependencies, such as vue-seo-layer, are ignored), checks that the installed `package.json` has no local dependency, runs `nuxt build`, and runs the SSR smoke against the build. `--check-links DIR...` checks the `package.json` of each directory for `link:`, `file:`, `workspace:` and `portal:` dependencies (the release guard, see [Releasing](#releasing)). `--self-test` checks the lockfile reading and the link check on planted files. 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> [--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 `<h1` and no skipped level, `cardlinks=N` exactly N links in each post card. It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`. `pnpm smoke:ssr` runs `expect-acme.txt` and the expectation files for `postTitleTag` forced to `h1` and `p`, the new-tab hint off and `journal.breadcrumbs` off.
- `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/`.
- `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.
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 `<SeoBreadcrumbs>` only while `journal.breadcrumbs` is off.

View File

@@ -58,8 +58,14 @@
margin: 0 auto;
}
/* The title is an h1 or a p (the Journal H1 setting, UI-SPEC L6); the class
carries its look, so both elements render the same. */
.journal-post__title {
margin: 0 0 var(--journal-space-3);
font-family: var(--journal-font-heading);
font-size: var(--journal-post-title-size);
font-weight: var(--journal-post-title-weight);
line-height: var(--journal-post-title-line-height);
}
.journal-post-meta {
@@ -84,6 +90,10 @@
margin: 0 0 var(--journal-space-4);
}
.journal-featured-image picture {
display: block;
}
.journal-featured-image img {
display: block;
width: 100%;
@@ -197,7 +207,10 @@
color: var(--journal-color-muted);
}
/* The card is one link (UI-SPEC L5): the title anchor stretches over the
whole card, and the card shows the keyboard focus. */
.journal-post-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--journal-space-2);
@@ -207,6 +220,11 @@
background: var(--journal-color-bg);
}
.journal-post-card:has(.journal-post-card__title a:focus-visible) {
outline: 2px solid var(--journal-color-accent);
outline-offset: 2px;
}
.journal-post-card .journal-featured-image {
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
}
@@ -224,6 +242,13 @@
text-decoration: none;
}
.journal-post-card__title a::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
.journal-post-card__date {
color: var(--journal-color-muted);
font-size: 0.85em;
@@ -233,10 +258,6 @@
margin: 0;
}
.journal-post-card__more {
margin-top: auto;
}
.journal-related {
margin-top: var(--journal-space-5);
}
@@ -273,6 +294,12 @@
color: var(--journal-color-on-accent);
}
.journal-pagination__gap {
display: inline-block;
padding: var(--journal-space-1) var(--journal-space-2);
color: var(--journal-color-muted);
}
/* Author */
.journal-author-card {
margin-bottom: var(--journal-space-5);
@@ -287,3 +314,187 @@
margin-top: var(--journal-space-4);
font-size: 0.9em;
}
/* Search box (header). Outside .journal-layout, so it sets its own type. The
panel is inline; an app turns it into a drop-down panel by positioning
.journal-search__panel. Without JS the panel is always shown. */
.journal-search {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--journal-space-2);
font-family: var(--journal-font-body);
color: var(--journal-color-text);
}
.journal-search__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--journal-space-2);
border: 0;
border-radius: var(--journal-radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
}
.journal-search__toggle:focus-visible,
.journal-search__input:focus-visible,
.journal-search-page__input:focus-visible,
.journal-search-page__submit:focus-visible {
outline: 2px solid var(--journal-color-accent);
outline-offset: 2px;
}
.journal-search--enhanced:not(.journal-search--open) .journal-search__panel {
display: none;
}
.journal-search__panel {
position: relative;
}
.journal-search__input,
.journal-search-page__input {
box-sizing: border-box;
width: 16rem;
max-width: 100%;
padding: var(--journal-space-2) var(--journal-space-3);
border: 1px solid var(--journal-color-border);
border-radius: var(--journal-radius-sm);
background: var(--journal-color-bg);
color: var(--journal-color-text);
font: inherit;
}
.journal-search__dropdown {
position: absolute;
z-index: 20;
top: calc(100% + var(--journal-space-1));
left: 0; /* a right-aligned box sets left: auto; right: 0 */
box-sizing: border-box;
width: min(26rem, calc(100vw - 2 * var(--journal-space-3)));
padding: var(--journal-space-2) 0;
border: 1px solid var(--journal-color-border);
border-radius: var(--journal-radius-md);
background: var(--journal-color-bg);
box-shadow: var(--journal-shadow-dropdown);
}
.journal-search__list {
margin: 0;
padding: 0;
list-style: none;
}
.journal-search__link {
display: flex;
gap: var(--journal-space-3);
padding: var(--journal-space-2) var(--journal-space-3);
color: inherit;
text-decoration: none;
}
.journal-search__option--active .journal-search__link {
background: var(--journal-color-surface);
}
.journal-search__thumb {
flex: none;
width: 3rem;
height: 3rem;
border-radius: var(--journal-radius-sm);
object-fit: cover;
}
.journal-search__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.journal-search__title {
font-weight: 600;
}
.journal-search__excerpt {
color: var(--journal-color-muted);
font-size: 0.875em;
line-height: 1.4;
}
.journal-search mark,
.journal-search-page mark {
background: var(--journal-color-mark);
color: inherit;
}
.journal-search__status {
margin: 0;
padding: var(--journal-space-2) var(--journal-space-3);
color: var(--journal-color-muted);
font-size: 0.875em;
}
.journal-search__status--loading::before {
content: '';
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-right: var(--journal-space-2);
border: 2px solid var(--journal-color-border);
border-top-color: var(--journal-color-accent);
border-radius: 50%;
vertical-align: -0.1em;
animation: journal-search-spin 0.8s linear infinite;
}
@keyframes journal-search-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.journal-search__status--loading::before {
animation: none;
}
}
.journal-search__all {
display: block;
padding: var(--journal-space-2) var(--journal-space-3);
border-top: 1px solid var(--journal-color-border);
color: var(--journal-color-link);
font-size: 0.875em;
}
/* Search page */
.journal-search-page__form {
display: flex;
flex-wrap: wrap;
gap: var(--journal-space-2);
margin-bottom: var(--journal-space-4);
}
.journal-search-page__input {
flex: 1 1 16rem;
width: auto;
}
.journal-search-page__submit {
padding: var(--journal-space-2) var(--journal-space-4);
border: 0;
border-radius: var(--journal-radius-sm);
background: var(--journal-color-accent);
color: var(--journal-color-on-accent);
font: inherit;
cursor: pointer;
}
.journal-search-page__count,
.journal-search-page__message {
margin: 0 0 var(--journal-space-4);
color: var(--journal-color-muted);
}

View File

@@ -11,6 +11,8 @@
--journal-color-link-hover: #0550ae; /* hovered links */
--journal-color-accent: #0969da; /* current page, focus ring */
--journal-color-on-accent: #ffffff; /* text on the accent colour */
--journal-color-mark: #fff8c5; /* search term highlight */
--journal-shadow-dropdown: 0 8px 24px rgb(0 0 0 / 0.12); /* search suggestions */
/* Type */
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
@@ -18,6 +20,9 @@
--journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
--journal-font-size-base: 1.0625rem; /* body size */
--journal-line-height: 1.7; /* body leading */
--journal-post-title-size: 2em; /* post title, h1 or p (browser h1 look) */
--journal-post-title-weight: 700; /* post title weight */
--journal-post-title-line-height: 1.2; /* post title leading */
/* Spacing scale */
--journal-space-1: 0.25rem; /* hairline gaps */

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
// Heading of a category page: name, description and the category's rich
// content, which goes through the Journal sanitizer before v-html. The page
// content (the pillar text), which goes through the Journal sanitizer before
// v-html. The rich content renders on page 1 only, the description on every
// page (D-41). An h1 in it becomes h2 while the category row's
// content_h1_to_h2 is on (absent counts as on), as for post bodies. The page
// payload keeps embed placeholders; expandEmbeds turns them into iframes as
// the last step, after sanitizing, and its output is never sanitized again.
import type { JournalCategory } from '../../utils/types.ts'
@@ -8,7 +11,9 @@ import { expandEmbeds } from '../../utils/embeds.ts'
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number }>()
const content = computed(() => expandEmbeds(sanitizeJournalHtml(props.category.content)))
const content = computed(() =>
props.page && props.page > 1 ? '' : expandEmbeds(sanitizeJournalHtml(props.category.content, { demoteH1: props.category.content_h1_to_h2 !== false })),
)
</script>
<template>

View File

@@ -1,11 +1,37 @@
<script setup lang="ts">
// A post's featured image. The intrinsic width/height reserve space before
// the image loads; the rendered size and crop come from --journal-image-ratio.
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
// A post's featured image. With the API's variants (D-85) it renders a
// <picture>: a WebP <source> and the <img> in the original format, both
// with srcset and sizes; without them, the single <img> as before. The
// intrinsic width/height reserve space before the image loads; the
// rendered size and crop come from --journal-image-ratio. eager is the
// post's main image: loaded at once, with fetchpriority high when the
// variants are there; cards load lazily.
import type { JournalFeaturedImage } from '../../utils/types.ts'
import { imageSrcsets } from '../../utils/srcset.ts'
const props = withDefaults(
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean; image?: JournalFeaturedImage | null; sizes?: string }>(),
{ eager: false, image: null, sizes: '100vw' },
)
const sets = computed(() => (props.src ? imageSrcsets(props.image?.variants, props.src) : null))
</script>
<template>
<figure v-if="src" class="journal-featured-image">
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
<picture v-if="sets">
<source type="image/webp" :srcset="sets.webp" :sizes="sizes" />
<img
:src="src"
:srcset="sets.original"
:sizes="sizes"
:width="sets.width"
:height="sets.height"
:alt="alt"
:loading="eager ? 'eager' : 'lazy'"
:fetchpriority="eager ? 'high' : undefined"
decoding="async"
/>
</picture>
<img v-else :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
</figure>
</template>

View File

@@ -1,33 +1,19 @@
<script setup lang="ts">
// Previous/next and a window of page links for the current listing. Links
// keep the current path and use ?page=N; page 1 drops the query.
import { paginationWindow } from '../../utils/pagination.ts'
const props = defineProps<{ current: number; last: number }>()
const route = useRoute()
const to = (page: number) => {
const query = { ...route.query }
if (page > 1) query.page = String(page)
else delete query.page
return { path: route.path, query }
}
const pages = computed(() => paginationWindow(props.current, props.last))
// Previous/next, page 1, the last page and a window of page links for the
// current listing: vue-seo-layer's <SeoPagination> (UI-SPEC L2) with the
// journal's labels and class names (journal-pagination, __prev, __pages,
// __current, __gap, __next), so app CSS keeps applying. Links follow the
// CMS's pagination form (?page=N or the path form) and page 1 is the
// unnumbered URL; the head gets rel prev/next.
defineProps<{ current: number; last: number }>()
const { t } = useI18n()
const labels = computed(() => ({
label: t('journal.pagination.label'),
previous: t('journal.pagination.previous'),
next: t('journal.pagination.next'),
}))
</script>
<template>
<nav v-if="last > 1" class="journal-pagination" :aria-label="$t('journal.pagination.label')">
<NuxtLink v-if="current > 1" :to="to(current - 1)" rel="prev" class="journal-pagination__prev">
{{ $t('journal.pagination.previous') }}
</NuxtLink>
<ol class="journal-pagination__pages">
<li v-for="page in pages" :key="page">
<span v-if="page === current" class="journal-pagination__current" aria-current="page">{{ page }}</span>
<NuxtLink v-else :to="to(page)">{{ page }}</NuxtLink>
</li>
</ol>
<NuxtLink v-if="current < last" :to="to(current + 1)" rel="next" class="journal-pagination__next">
{{ $t('journal.pagination.next') }}
</NuxtLink>
</nav>
<SeoPagination class-prefix="journal-pagination" :current="current" :last="last" :labels="labels" />
</template>

View File

@@ -3,14 +3,74 @@
// sanitizer, which removes what the policy does not allow and keeps the rest.
// The page passes an already sanitized body (with any embed placeholder still
// a placeholder); sanitizing again here guards a caller that does not, and
// gives the same string on server and client. expandEmbeds runs last: it
// gives the same string on server and client.
//
// Then the UI-SPEC L7 steps run on the sanitized HTML (renderJournalBody):
// h1 demoted to h2 while the Journal H1 setting is on (the page passes the
// post's content_h1_to_h2 as demoteH1), the CMS phrase auto-links, the
// outbound link rules (nofollow or sponsored from the domain lists; target,
// noopener and the hidden new-tab hint while the CMS's links_new_tab is on
// and the SEO-layer option seo.links.newTabHint allows the hint) and lazy,
// async body images. The link steps are vue-seo-layer's (auto-imported) and
// read the page's resolve answer, which the server hands to the client in
// the payload, so both render the same string. 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).
import { expandEmbeds } from '../../utils/embeds.ts'
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
import { renderJournalBody, type JournalBodyLinkSteps } 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 answer = useSeoAnswer()
const route = useRoute()
const labels = useSeoLabels()
const config = useRuntimeConfig()
const request = useRequestURL()
const steps: JournalBodyLinkSteps = { autolink, applyOutbound, imageAttrs }
const page = computed(() => {
const a = answer.value?.action === 'ok' ? answer.value : null
try {
if (a?.url && /^https?:\/\//i.test(a.url)) {
const u = new URL(a.url)
return { answer: a, host: u.host, self: `${u.pathname}${u.search}` }
}
} catch {
// not a URL: the request's own host and path
}
return { answer: a, host: request.host, self: route.fullPath }
})
const hintOn = computed(() => {
const v = (config.public.seo as { links?: { newTabHint?: unknown } } | undefined)?.links?.newTabHint
return v !== false && v !== 'false'
})
const html = computed(() => {
const { answer: a, host, self } = page.value
const out = a?.outbound
return expandEmbeds(
renderJournalBody(
props.html,
{
demoteH1: props.demoteH1 === true,
autolinks: a?.autolinks ?? null,
autolinkMax: a?.autolink_max ?? 0,
selfPath: self,
outbound: out
? {
siteHost: host,
newTab: out.new_tab === true,
nofollowDomains: out.nofollow_domains ?? [],
sponsoredDomains: out.sponsored_domains ?? [],
hint: hintOn.value ? labels.value.links.newTab : '',
}
: null,
},
steps,
),
)
})
</script>
<template>

View File

@@ -1,26 +1,31 @@
<script setup lang="ts">
// One post in a listing: image, title link, date and excerpt.
// One post in a listing: image, title link, date and excerpt (UI-SPEC L5).
// The title link is the card's only anchor (D-80): the image is not a link
// and there is no separate "read more" link; the whole card is clickable
// through a CSS stretched link on the title (journal-base.css), with the
// focus ring on the card. Post links carry no rel. headingLevel is 2 in a
// listing and 3 under the related posts' own h2 (D-52).
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const props = withDefaults(defineProps<{ post: JournalPost; headingLevel?: 2 | 3 }>(), { headingLevel: 2 })
const { localeProperties } = useI18n()
const links = useJournalLinks()
const href = computed(() => links.postLink(props.post.slug))
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const heading = computed(() => (props.headingLevel === 3 ? 'h3' : 'h2'))
</script>
<template>
<article class="journal-post-card">
<NuxtLink v-if="href && post.featured_image_url" :to="href" class="journal-post-card__image" tabindex="-1" aria-hidden="true">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
</NuxtLink>
<h2 class="journal-post-card__title">
<div v-if="post.featured_image_url" class="journal-post-card__image">
<JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 48rem) 33vw, 100vw" />
</div>
<component :is="heading" class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
<template v-else>{{ post.title }}</template>
</h2>
</component>
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time>
<p v-if="post.excerpt" class="journal-post-card__excerpt">{{ post.excerpt }}</p>
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
</article>
</template>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
// A grid of post cards, or the empty-state text when there are none.
// headingLevel goes to every card (3 under the related posts' h2).
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[]; empty?: string }>()
defineProps<{ posts: JournalPost[]; empty?: string; headingLevel?: 2 | 3 }>()
</script>
<template>
<div class="journal-post-list">
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" :heading-level="headingLevel ?? 2" />
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
</div>
</template>

View File

@@ -11,9 +11,15 @@ const links = useJournalLinks()
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const authorName = computed(() => {
const a = props.post.author
return a ? `${a.first_name} ${a.last_name}`.trim() || a.login : ''
return a ? `${a.first_name} ${a.last_name}`.trim() || a.slug || a.login : ''
})
// By slug, so the link does not carry the login (often an email address).
// An API before sm-journal-plugin v0.3.1 sends no slug; the login still
// resolves there.
const authorHref = computed(() => {
const a = props.post.author
return a ? links.authorLink(a.slug || a.login) : null
})
const authorHref = computed(() => (props.post.author ? links.authorLink(props.post.author.login) : null))
</script>
<template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
// Related posts under a post. The page passes the API's related_posts, or
// the latest posts when the post has no category.
// the latest posts when the post has no category. The section heading is an
// h2 and the cards' titles h3 (D-52).
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[] }>()
@@ -9,6 +10,6 @@ defineProps<{ posts: JournalPost[] }>()
<template>
<section v-if="posts.length" class="journal-related">
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
<JournalPostList :posts="posts" />
<JournalPostList :posts="posts" :heading-level="3" />
</section>
</template>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
// The header search: an icon button opens a search input, up to `limit`
// suggestions show as the visitor types, and "see all" opens the search page.
// The input is a WAI-ARIA combobox: arrows move through the suggestions,
// Enter opens the active one or the search page, Escape closes and returns
// focus to the button, a click outside closes. Without JS it is a plain GET
// form to the search page, always open. It renders nothing when
// journal.routes.search is false.
//
// Layout hooks: .journal-search--open on the root while open, and the panel
// (.journal-search__panel) for an inline input or a drop-down; the icon is
// the `icon` slot.
import { JOURNAL_SEARCH_MIN_LENGTH } from '../../utils/client.ts'
import { createSuggestController, markTerms, moveActive, searchTermState, type SuggestController, type SuggestState } from '../../utils/search.ts'
import type { JournalPostSuggestion } from '../../utils/types.ts'
const props = withDefaults(
defineProps<{
limit?: number
minLength?: number
debounce?: number
/** Overrides journal.search.placeholder. */
placeholder?: string
/** Overrides journal.search.button, the icon button's accessible label. */
buttonLabel?: string
/** Overrides journal.search.seeAll. */
seeAllLabel?: string
/** Overrides journal.search.empty. */
emptyLabel?: string
}>(),
{ limit: 5, minLength: JOURNAL_SEARCH_MIN_LENGTH, debounce: 250 },
)
const { t } = useI18n()
const links = useJournalLinks()
const api = useJournalApi()
const action = computed(() => links.searchLink())
const id = useId()
const panelId = `${id}-panel`
const listId = `${id}-list`
const optionId = (i: number) => `${id}-option-${i}`
const root = ref<HTMLElement | null>(null)
const toggleEl = ref<HTMLButtonElement | null>(null)
const inputEl = ref<HTMLInputElement | null>(null)
const enhanced = ref(false)
const open = ref(false)
const term = ref('')
const active = ref(-1)
const state = shallowRef<SuggestState<JournalPostSuggestion>>({ term: '', status: 'idle', loading: false, rows: [], total: 0 })
const rows = computed(() => state.value.rows)
const listOpen = computed(
() => enhanced.value && open.value && searchTermState(term.value, props.minLength) === 'ready' && state.value.status !== 'idle',
)
const seeAll = computed(() => links.searchLink(term.value.trim()))
let suggest: SuggestController | null = null
const onOutside = (e: PointerEvent) => {
if (open.value && root.value && !root.value.contains(e.target as Node)) close(false)
}
onMounted(() => {
enhanced.value = true
suggest = createSuggestController<JournalPostSuggestion>({
fetch: (q, signal) => api.suggestPosts(q, { perPage: props.limit, signal }),
onChange: (s) => {
state.value = s
active.value = -1
},
debounce: props.debounce,
minLength: props.minLength,
})
document.addEventListener('pointerdown', onOutside)
})
onBeforeUnmount(() => {
suggest?.dispose()
document.removeEventListener('pointerdown', onOutside)
})
watch(term, (value) => suggest?.input(value))
// Opening selects the previous term, so typing replaces it.
function openPanel() {
open.value = true
nextTick(() => {
inputEl.value?.focus()
inputEl.value?.select()
})
}
function close(focusToggle: boolean) {
open.value = false
active.value = -1
if (focusToggle) toggleEl.value?.focus()
}
function toggle() {
if (open.value) close(false)
else openPanel()
}
function go(to: string | null) {
if (!to) return
close(false)
return navigateTo(to)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
close(true)
return
}
if (e.key === 'Enter' && listOpen.value && active.value >= 0) {
const row = rows.value[active.value]
if (row) {
e.preventDefault()
go(links.postLink(row.slug))
}
return
}
if (!listOpen.value) return
const next = moveActive(active.value, rows.value.length, e.key)
if (next !== null) {
e.preventDefault()
active.value = next
}
}
function onSubmit(e: Event) {
if (!enhanced.value) return
e.preventDefault()
const q = term.value.trim()
if (q) go(links.searchLink(q))
}
</script>
<template>
<div
v-if="action"
ref="root"
class="journal-search"
:class="{ 'journal-search--enhanced': enhanced, 'journal-search--open': open }"
>
<button
v-if="enhanced"
ref="toggleEl"
type="button"
class="journal-search__toggle"
:aria-label="buttonLabel ?? t('journal.search.button')"
:aria-expanded="open"
:aria-controls="panelId"
@click="toggle"
>
<slot name="icon">
<svg class="journal-search__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M15.5 15.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</slot>
</button>
<form :id="panelId" class="journal-search__panel" role="search" method="get" :action="action" @submit="onSubmit">
<input
ref="inputEl"
v-model="term"
class="journal-search__input"
type="search"
name="q"
autocomplete="off"
:placeholder="placeholder ?? t('journal.search.placeholder')"
:aria-label="t('journal.search.input')"
role="combobox"
aria-autocomplete="list"
:aria-expanded="listOpen"
:aria-controls="listId"
:aria-activedescendant="listOpen && active >= 0 ? optionId(active) : undefined"
@keydown="onKeydown"
/>
<div v-show="listOpen" class="journal-search__dropdown">
<ul :id="listId" role="listbox" class="journal-search__list" :aria-label="t('journal.search.suggestions')">
<li
v-for="(row, i) in rows"
:id="optionId(i)"
:key="row.id"
role="option"
class="journal-search__option"
:class="{ 'journal-search__option--active': i === active }"
:aria-selected="i === active"
@mousedown.prevent
@mouseenter="active = i"
>
<NuxtLink :to="links.postLink(row.slug) ?? undefined" tabindex="-1" class="journal-search__link" @click="close(false)">
<img v-if="row.featured_image_url" :src="row.featured_image_url" alt="" class="journal-search__thumb" width="48" height="48" loading="lazy" />
<span class="journal-search__text">
<span class="journal-search__title">
<template v-for="(seg, k) in markTerms(row.title, state.term)" :key="k">
<mark v-if="seg.mark">{{ seg.text }}</mark>
<template v-else>{{ seg.text }}</template>
</template>
</span>
<span v-if="row.excerpt" class="journal-search__excerpt">
<template v-for="(seg, k) in markTerms(row.excerpt, state.term)" :key="k">
<mark v-if="seg.mark">{{ seg.text }}</mark>
<template v-else>{{ seg.text }}</template>
</template>
</span>
</span>
</NuxtLink>
</li>
</ul>
<p v-if="state.loading" class="journal-search__status journal-search__status--loading" role="status">
{{ t('journal.search.loading') }}
</p>
<p v-else-if="state.status === 'error'" class="journal-search__status" role="status">{{ t('journal.search.error') }}</p>
<p v-else-if="state.status === 'done' && rows.length === 0" class="journal-search__status" role="status">
{{ emptyLabel ?? t('journal.search.empty') }}
</p>
<NuxtLink v-if="seeAll && state.status !== 'error'" :to="seeAll" class="journal-search__all" @mousedown.prevent @click="close(false)">
{{ seeAllLabel ?? t('journal.search.seeAll') }}
</NuxtLink>
</div>
</form>
</div>
</template>

View File

@@ -20,11 +20,11 @@ export function useJournalApi(): JournalClient {
const peer = event?.node?.req?.socket?.remoteAddress
const forwarded = recorded || [incoming, peer].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 fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, headers, signal: init?.signal })
return createJournalClient(fetcher)
}
const base = (config.public.journal as { apiBase: string }).apiBase
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query })
const fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, signal: init?.signal })
return createJournalClient(fetcher)
}

View File

@@ -0,0 +1,23 @@
// The ItemList node of a listing page (index, category, tag, author; D-43):
// the posts shown, added to the page's one JSON-LD @graph through
// vue-seo-layer's useSchemaNode, so the page still has one JSON-LD script.
// The URLs are absolute on the CMS's site URL when the page has a resolve
// answer, else on the request's origin.
import type { JournalPageMeta, JournalPost } from '../utils/types.ts'
import { journalItemList, journalSiteOrigin } from '../utils/seo.ts'
export function useJournalItemList(posts: () => readonly JournalPost[] | null | undefined, meta: () => JournalPageMeta | null | undefined): void {
const answer = useSeoAnswer()
const route = useRoute()
const links = useJournalLinks()
const request = useRequestURL()
useSchemaNode(() => {
const a = answer.value?.action === 'ok' ? answer.value : null
const origin = journalSiteOrigin(a?.url, request.origin)
const pageUrl = a?.url && /^https?:\/\//i.test(a.url) ? a.url : `${origin}${route.fullPath}`
const m = meta()
const first = m ? (Math.max(1, m.current_page) - 1) * Math.max(1, m.per_page) + 1 : 1
return journalItemList(pageUrl, origin, (posts() ?? []).map((p) => links.postLink(p.slug)), first)
})
}

View File

@@ -19,5 +19,7 @@ export function useJournalLinks() {
tagLink: (slug: string) => link('tag', { tag: slug }),
authorLink: (slug: string) => link('author', { author: slug }),
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
/** The search page for `q`, or null when journal.routes.search is false. */
searchLink: (q?: string) => link('search', undefined, q ? { q } : undefined),
}
}

View File

@@ -2,10 +2,10 @@
// 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.
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { absoluteUrl, listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
// in by login, the canonical points at the slug URL (a CMS canonical wins).
import { pageFromRoute, pageOutOfRange } from '../utils/pagination.ts'
import { absoluteUrl, journalSiteOrigin, listTitle } from '../utils/seo.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
@@ -13,12 +13,11 @@ 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 ?? '')
const page = parsePageParam(route.query.page)
const page = pageFromRoute(route)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-author-${param}-${page}`, async () => {
@@ -40,11 +39,13 @@ const author = data.value.author
const name = `${author.first_name} ${author.last_name}`.trim() || author.slug
const title = listTitle(t('journal.author.title', { name }), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
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' }] })
}
}
@@ -52,6 +53,7 @@ if (author.slug !== param) {
<template>
<section v-if="data" class="journal-author">
<SeoBreadcrumbs v-if="crumbs" />
<JournalAuthorCard :author="data.author" :page="page" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.author.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />

View File

@@ -2,11 +2,14 @@
// journal-category: one category's header and rich content above its posts.
// The slug resolves against GET /categories?include_empty=1, so an empty
// category answers 200 with its content; an unknown slug is a real 404 (this
// route may be a root-level catch-all in the app).
// route may be a root-level catch-all in the app). The rich content (the
// pillar text) shows on page 1 only, the short description on every page
// (D-41); an h1 in it becomes h2 while the category row's content_h1_to_h2
// is on (absent counts as on), like a post body.
import { sanitizeJournalHtml } from '../utils/htmlGate.ts'
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { pageFromRoute, pageOutOfRange } from '../utils/pagination.ts'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
@@ -17,7 +20,7 @@ const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const { t } = useI18n()
const slug = String(route.params.category ?? '')
const page = parsePageParam(route.query.page)
const page = pageFromRoute(route)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => {
@@ -27,7 +30,7 @@ const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, a
// Only this category reaches the payload, with its content already sanitized.
const { children: _children, ...category } = found
return {
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
category: { ...category, content: sanitizeJournalHtml(found.content, { demoteH1: found.content_h1_to_h2 !== false }) || null },
posts: { ...posts, data: posts.data.map(toListPost) },
}
})
@@ -42,10 +45,13 @@ if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
const title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
const description = data.value.category.description ?? undefined
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script>
<template>
<section v-if="data" class="journal-category">
<SeoBreadcrumbs v-if="crumbs" />
<JournalCategoryHeader :category="data.category" :page="page" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
// journal-index: the post list, paginated with ?page=N. A non-numeric page
// or a page past the last one is a real 404.
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
// journal-index: the post list, paginated with ?page=N (or the path form).
// A non-numeric page or a page past the last one is a real 404.
import { pageFromRoute, pageOutOfRange } from '../utils/pagination.ts'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
// Re-run setup (and its 404 checks) on every query change.
@@ -14,7 +14,7 @@ const api = useJournalApi()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const { t } = useI18n()
const page = parsePageParam(route.query.page)
const page = pageFromRoute(route)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(
@@ -31,10 +31,13 @@ if (pageOutOfRange(page, data.value.meta.last_page)) {
const title = listTitle(t('journal.index.title'), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.data, () => data.value?.meta)
</script>
<template>
<section v-if="data" class="journal-index">
<SeoBreadcrumbs v-if="crumbs" />
<JournalIndexHeader :page="page" />
<JournalPostList :posts="data.data" />
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />

View File

@@ -3,9 +3,20 @@
// 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'
import { toListPost } from '../utils/view.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.
// og:type article and the article times, which the CMS never sets, are
// always the post's (L4 WR-02).
import { cmsHeadPresent, journalArticleMeta, journalPostTitleTag, journalSiteOrigin, postSeoMeta } from '../utils/seo.ts'
import { demotesH1, postNeighbours, toPostView } from '../utils/postView.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
const route = useRoute()
@@ -37,33 +48,58 @@ 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,
)
const article = computed(() => (post.value ? journalArticleMeta(post.value) : null))
useSeoMeta({
// CMS-owned keys: the post's values only while the CMS has no answer.
title: () => fallback.value?.title,
ogTitle: () => fallback.value?.ogTitle,
description: () => fallback.value?.description,
ogDescription: () => fallback.value?.ogDescription,
ogImage: () => fallback.value?.ogImage,
// Keys the CMS never sets: always the post's.
ogType: () => article.value?.ogType,
articlePublishedTime: () => article.value?.articlePublishedTime,
articleModifiedTime: () => article.value?.articleModifiedTime,
})
const titleTag = computed(() => journalPostTitleTag(cfg.postTitleTag, post.value?.content_h1_to_h2))
const demoteH1 = computed(() => demotesH1(post.value))
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
// previous_in_category / next_in_category (the Journal's opt-in scored
// mode) win over the site-wide neighbours when the Journal answers one.
const siblings = computed(() => (post.value ? postNeighbours(post.value) : { previous: null, next: null, inCategory: false }))
</script>
<template>
<article v-if="post" class="journal-post">
<SeoBreadcrumbs v-if="crumbs" />
<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" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
<JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 46rem) 46rem, 100vw" eager />
<JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="siblings.previous || siblings.next" class="journal-post__siblings">
<NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
:to="links.postLink(post.previous_post.slug)!"
v-if="siblings.previous && links.postLink(siblings.previous.slug)"
:to="links.postLink(siblings.previous.slug)!"
rel="prev"
class="journal-post__previous"
>
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
<span>{{ $t('journal.post.previous') }}</span> {{ siblings.previous.title }}
</NuxtLink>
<NuxtLink
v-if="post.next_post && links.postLink(post.next_post.slug)"
:to="links.postLink(post.next_post.slug)!"
v-if="siblings.next && links.postLink(siblings.next.slug)"
:to="links.postLink(siblings.next.slug)!"
rel="next"
class="journal-post__next"
>
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
<span>{{ $t('journal.post.next') }}</span> {{ siblings.next.title }}
</NuxtLink>
</nav>
<JournalRelatedPosts :posts="post.related_posts" />

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
// 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; 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'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath, seo: { robots: 'noindex, follow' } })
const route = useRoute()
const api = useJournalApi()
const { t, localeProperties } = useI18n()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const q = searchTermFromQuery(route.query.q)
const termState = searchTermState(q)
const page = parsePageParam(route.query.page)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-search-${q}-${page}`, async () => {
if (termState !== 'ready') return null
const posts = await api.listPosts({ search: q, page, perPage: cfg.perPage.search ?? cfg.perPage.index })
return { ...posts, data: posts.data.map(toListPost) }
})
if (error.value) {
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
}
if (data.value && pageOutOfRange(page, data.value.meta.last_page)) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const total = data.value?.meta.total ?? 0
const count = t(`journal.search.count.${pluralForm(localeProperties.value.language ?? 'en', total)}`, { count: total })
const heading = q ? t('journal.search.titleQuery', { query: q }) : t('journal.search.title')
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title })
const term = ref(q)
const onSubmit = () => {
const value = term.value.trim()
return navigateTo({ path: route.path, query: value ? { q: value } : {} })
}
</script>
<template>
<section class="journal-search-page">
<header class="journal-index-header">
<h1 class="journal-index-header__title">{{ title }}</h1>
</header>
<form class="journal-search-page__form" role="search" method="get" :action="route.path" @submit.prevent="onSubmit">
<input
v-model="term"
class="journal-search-page__input"
type="search"
name="q"
:aria-label="$t('journal.search.input')"
:placeholder="$t('journal.search.placeholder')"
/>
<button type="submit" class="journal-search-page__submit">{{ $t('journal.search.submit') }}</button>
</form>
<p v-if="termState === 'empty'" class="journal-search-page__message">{{ $t('journal.search.prompt') }}</p>
<p v-else-if="termState === 'short'" class="journal-search-page__message">
{{ $t('journal.search.short', { min: JOURNAL_SEARCH_MIN_LENGTH }) }}
</p>
<template v-else-if="data">
<p v-if="total > 0" class="journal-search-page__count">{{ count }}</p>
<JournalPostList :posts="data.data" :empty="$t('journal.search.none', { query: q })" />
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
</template>
</section>
</template>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
// journal-tag: posts with one tag, paginated with ?page=N. An unknown tag or
// an out-of-range page is a real 404.
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { pageFromRoute, pageOutOfRange } from '../utils/pagination.ts'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
@@ -14,7 +14,7 @@ const { t } = useI18n()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const slug = String(route.params.tag ?? '')
const page = parsePageParam(route.query.page)
const page = pageFromRoute(route)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-tag-${slug}-${page}`, async () => {
@@ -34,10 +34,13 @@ if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
const heading = t('journal.tag.title', { name: data.value.tag.name })
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script>
<template>
<section v-if="data" class="journal-tag">
<SeoBreadcrumbs v-if="crumbs" />
<header class="journal-index-header">
<h1 class="journal-index-header__title">{{ title }}</h1>
</header>

View File

@@ -9,6 +9,7 @@ import type {
JournalPage,
JournalPost,
JournalPostDetail,
JournalPostSuggestion,
JournalTag,
} from './types.ts'
@@ -18,8 +19,18 @@ export const JOURNAL_MAX_PER_PAGE = 30
/** The Journal API's per_page default. */
export const JOURNAL_DEFAULT_PER_PAGE = 9
/** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
/** Search terms shorter than this are ignored by the API, so the layer never sends them. */
export const JOURNAL_SEARCH_MIN_LENGTH = 3
/**
* Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed
* JSON. `init.signal` aborts the request.
*/
export type JournalFetcher = (
path: string,
query?: Record<string, string | number>,
init?: { signal?: AbortSignal },
) => Promise<unknown>
export interface PostListOptions {
page?: number
@@ -30,6 +41,13 @@ export interface PostListOptions {
category?: string
tag?: string
search?: string
/** "suggest" asks for JournalPostSuggestion rows instead of full posts. */
fields?: 'suggest'
}
export interface SuggestOptions {
perPage?: number
signal?: AbortSignal
}
export interface AuthorPostListOptions {
@@ -56,6 +74,7 @@ export function buildPostListQuery(opts: PostListOptions = {}): Record<string, s
if (opts.category) q.category = opts.category
if (opts.tag) q.tag = opts.tag
if (opts.search) q.search = opts.search
if (opts.fields) q.fields = opts.fields
return q
}
@@ -90,6 +109,11 @@ export function isNotFound(err: unknown): boolean {
export interface JournalClient {
getPost(slug: string): Promise<JournalPostDetail | null>
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>>
/**
* Suggestion rows for `term` in relevance order (no sort is sent). A term
* shorter than JOURNAL_SEARCH_MIN_LENGTH sends nothing and gives an empty page.
*/
suggestPosts(term: string, opts?: SuggestOptions): Promise<JournalPage<JournalPostSuggestion>>
listCategories(): Promise<JournalCategory[]>
findCategory(slug: string): Promise<JournalCategory | null>
listTags(): Promise<JournalTag[]>
@@ -123,6 +147,17 @@ export function createJournalClient(fetcher: JournalFetcher): JournalClient {
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost>
},
async suggestPosts(term, opts = {}) {
const search = term.trim()
const perPage = clampPerPage(opts.perPage ?? 5)
if ([...search].length < JOURNAL_SEARCH_MIN_LENGTH) {
return { data: [], meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 } }
}
const query = buildPostListQuery({ search, fields: 'suggest', perPage })
const init = opts.signal ? { signal: opts.signal } : undefined
return (await fetcher('/posts', query, init)) as JournalPage<JournalPostSuggestion>
},
listCategories,
async findCategory(slug) {

View File

@@ -5,12 +5,15 @@
// inline styles, goldmark's footnote roles, dir="auto" and two chat
// transcript data attributes. URLs must be http, https, mailto or relative.
//
// The Blog Editor's own output adds exactly four rules, the same four the Go
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id
// (an 11-character video id), which carry the embed placeholder that
// embeds.ts expands after sanitizing; img alt with any text (the editor's
// free-text alt field); and ol start (an integer). tests/fixtures/
// editor-corpus.json, a byte copy of the editor's corpus, pins them.
// The Blog Editor's own output adds exactly five rules, the same five the Go
// policy has: figure data-embed (youtube only) and data-id (an 11-character
// video id), which carry the embed placeholder that embeds.ts expands after
// sanitizing; img alt with any text (the editor's free-text alt field, ""
// for a decorative image); ol start (an integer); and a rel holding only the
// tokens nofollow, sponsored and ugc (the editor's link form, Phase 33).
// tests/fixtures/editor-corpus.json, a byte copy of the editor's corpus,
// pins them. target is never kept: the outbound rules add it after
// sanitizing (renderJournalBody).
//
// The Journal API already sanitizes what FormatHTML writes. This is defence
// in depth for rows written another way (direct SQL, legacy imports) and for
@@ -62,6 +65,8 @@ const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i
const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i
const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/
const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/
// The editor's link rel (format_html.go): the three tokens, single spaces.
const LINK_REL = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':',
// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt")
// does; sanitize-html escapes attribute values on output and alt carries no URL.
@@ -105,7 +110,7 @@ const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
details: { open: /^(|open)$/i },
blockquote: { cite: null },
q: { cite: null },
a: { href: null, role: FOOTNOTE_ROLE },
a: { href: null, role: FOOTNOTE_ROLE, rel: LINK_REL },
div: {
role: FOOTNOTE_ROLE,
'data-message-author-role': /^[A-Za-z0-9_-]+$/,
@@ -238,16 +243,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 +285,60 @@ 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 }
}
/** The body link steps of vue-seo-layer (app/utils/bodyLinks.ts), passed in by the component. */
export interface JournalBodyLinkSteps {
autolink: (html: string, links: ReadonlyArray<{ phrase: string; url: string }>, max: number, self: string) => string
applyOutbound: (
html: string,
opts: { siteHost: string; newTab: boolean; nofollowDomains?: readonly string[] | null; sponsoredDomains?: readonly string[] | null; hint?: string | null },
) => string
imageAttrs: (html: string) => string
}
/** What the CMS resolve answer and the layer options give the body pipeline. */
export interface JournalBodyOptions extends JournalHtmlGateOptions {
/** The resolve answer's autolinks; none adds no link. */
autolinks?: ReadonlyArray<{ phrase: string; url: string }> | null
/** The resolve answer's autolink_max. */
autolinkMax?: number | null
/** The page's own path (or URL): never auto-linked. */
selfPath?: string
/** The outbound rules (the resolve answer's outbound); null leaves links as they are. */
outbound?: {
siteHost: string
newTab: boolean
nofollowDomains?: readonly string[] | null
sponsoredDomains?: readonly string[] | null
/** The new-tab hint text; '' adds none. */
hint?: string | null
} | null
}
/**
* A post body for its v-html sink, in the UI-SPEC L7 order: sanitize (h1 to
* h2 while demoteH1), phrase auto-links, outbound rules, image attributes.
* The link steps are vue-seo-layer's, passed in by the caller (they are
* auto-imported in components); without them the sanitized HTML is
* returned, and a step without CMS data is skipped. Every step only adds
* escaped markup to sanitized output, never re-reads the raw input.
*/
export function renderJournalBody(html: string | null | undefined, options: JournalBodyOptions = {}, steps?: JournalBodyLinkSteps | null): string {
let out = sanitizeJournalHtml(html, { demoteH1: options.demoteH1 === true })
if (!out || !steps) return out
const links = Array.isArray(options.autolinks) ? options.autolinks : []
const max = Number(options.autolinkMax) || 0
if (links.length > 0 && max > 0) out = steps.autolink(out, links, max, options.selfPath ?? '')
if (options.outbound) out = steps.applyOutbound(out, options.outbound)
return steps.imageAttrs(out)
}

View File

@@ -1,6 +1,7 @@
// Page-number rules for Journal listings. Pages use ?page=N (never a path
// segment). Anything but a positive integer, and any page past the last one,
// is a real 404.
// Page-number rules for Journal listings. Pages use ?page=N, or the path
// form <listing>/<segment>/N when the site's pagination form is path (the
// page route's :page param). Anything but a positive integer, and any page
// past the last one, is a real 404.
/**
* Parses the ?page query value: 1 when absent, the integer for a string of
@@ -15,6 +16,16 @@ export function parsePageParam(raw: unknown): number | null {
return n
}
/**
* The page of a listing route: its :page param in the path form, else the
* ?page query value, parsed as parsePageParam does (null is a 404).
*/
export function pageFromRoute(route: { params?: Record<string, unknown>; query?: Record<string, unknown> }): number | null {
const param = route.params?.page
if (param !== undefined && param !== null && param !== '') return parsePageParam(param)
return parsePageParam(route.query?.page)
}
/**
* True when `page` lies past the last page. Page 1 is always in range, so an
* empty listing renders its empty state instead of a 404.

View File

@@ -1,12 +1,38 @@
// 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
}
/**
* The previous and next links of a post page: the neighbours within the
* post's first category when the Journal answers them (its opt-in scored
* mode) and at least one exists, else the site-wide previous_post and
* next_post.
*/
export function postNeighbours(post: {
previous_post?: { slug: string; title: string } | null
next_post?: { slug: string; title: string } | null
previous_in_category?: { slug: string; title: string } | null
next_in_category?: { slug: string; title: string } | null
}): { previous: { slug: string; title: string } | null; next: { slug: string; title: string } | null; inCategory: boolean } {
if (post.previous_in_category || post.next_in_category) {
return { previous: post.previous_in_category ?? null, next: post.next_in_category ?? null, inCategory: true }
}
return { previous: post.previous_post ?? null, next: post.next_post ?? null, inCategory: false }
}
/** A post page's data: the post with its body sanitized and no source or translations. */
export type JournalPostView = JournalPostDetail
@@ -15,7 +41,7 @@ export function toPostView(post: JournalPostDetail): JournalPostView {
...post,
content: null,
translations: null,
content_html: sanitizeJournalHtml(post.content_html),
content_html: sanitizeJournalHtml(post.content_html, { demoteH1: demotesH1(post) }),
related_posts: (post.related_posts ?? []).map(toListPost),
}
}

View File

@@ -11,6 +11,7 @@ export const DEFAULT_JOURNAL_ROUTES: Readonly<JournalRoutes> = Object.freeze({
category: '/blog/category/:category',
tag: '/blog/tag/:tag',
author: '/blog/author/:author',
search: false,
})
/** Route names stay stable whatever the paths are, so links never hard-code a path. */
@@ -20,6 +21,7 @@ export const JOURNAL_ROUTE_NAMES: Readonly<Record<keyof JournalRoutes, string>>
category: 'journal-category',
tag: 'journal-tag',
author: 'journal-author',
search: 'journal-search',
})
/** The route param each path must carry. */
@@ -29,6 +31,7 @@ const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
category: 'category',
tag: 'tag',
author: 'author',
search: null,
}
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes>
@@ -76,3 +79,52 @@ export function fillJournalPath(pattern: string, params: Record<string, string |
.join('/')
})
}
/** The listing views that page (the search page keeps ?page with its ?q). */
export const JOURNAL_PAGED_VIEWS: ReadonlyArray<keyof JournalRoutes> = Object.freeze(['index', 'category', 'tag', 'author'])
/** The route name of a listing's path-form page route for one segment. */
export function journalPagedRouteName(key: keyof JournalRoutes, segment: string): string {
return `${JOURNAL_ROUTE_NAMES[key]}-page-${segment}`
}
/**
* The pagination option the journal's path-form routes follow (P33-71, L4
* WR-03): vue-seo-layer's resolved option, which its module's setup writes
* into runtimeConfig.public.seo.pagination with its defaults (segments pl
* strona, en page), so the routes match the links usePagination builds;
* else the raw seo.pagination of the app's config. Read inside the
* extendPages callback, which runs after every module's setup. No import
* from vue-seo-layer: a git-installed layer's .ts is not type-stripped by
* Node under node_modules.
*/
export function journalPaginationSource(nuxtOptions: unknown): unknown {
if (nuxtOptions === null || typeof nuxtOptions !== 'object') return undefined
const o = nuxtOptions as { runtimeConfig?: { public?: { seo?: { pagination?: unknown } } }; seo?: { pagination?: unknown } }
const resolved = o.runtimeConfig?.public?.seo?.pagination
if (resolved !== null && typeof resolved === 'object') return resolved
return o.seo?.pagination
}
/**
* The extra listing routes of the path pagination form (D-40): with the
* vue-seo-layer build option seo.pagination.form 'path', each listing gets
* `<listing>/<segment>/:page` for every distinct locale segment
* (seo.pagination.segments, else the param name), rendering the same page
* as the listing. The query form, or no option, adds none.
*/
export function journalPagedRoutes(routes: JournalRoutes, pagination: unknown): Array<{ key: keyof JournalRoutes; name: string; path: string }> {
const p = pagination !== null && typeof pagination === 'object' ? (pagination as Record<string, unknown>) : {}
if (p.form !== 'path') return []
const segment = /^[\p{L}\p{N}_-]+$/u
const raw = p.segments !== null && typeof p.segments === 'object' ? Object.values(p.segments as Record<string, unknown>) : []
const segments = [...new Set(raw.filter((v): v is string => typeof v === 'string' && segment.test(v)))]
if (segments.length === 0) segments.push(typeof p.param === 'string' && segment.test(p.param) ? p.param : 'page')
const out: Array<{ key: keyof JournalRoutes; name: string; path: string }> = []
for (const key of JOURNAL_PAGED_VIEWS) {
const base = routes[key]
if (base === false) continue
for (const seg of segments) out.push({ key, name: journalPagedRouteName(key, seg), path: `${base.replace(/\/+$/, '')}/${seg}/:page(\\d+)` })
}
return out
}

231
app/utils/search.ts Normal file
View File

@@ -0,0 +1,231 @@
// Search helpers for JournalSearchBox and the search page: the term rules,
// query-term marking that ignores case and diacritics, plural forms, listbox
// keys and the suggestion request controller. Pure TypeScript, so
// `node --test` imports it directly.
import { JOURNAL_SEARCH_MIN_LENGTH } from './client.ts'
/** Longer search terms are cut to this many characters. */
export const JOURNAL_SEARCH_MAX_LENGTH = 200
/** The ?q value as a trimmed term: the first value of a repeated parameter, "" when absent. */
export function searchTermFromQuery(raw: unknown): string {
const value = Array.isArray(raw) ? raw[0] : raw
if (typeof value !== 'string') return ''
return [...value.trim()].slice(0, JOURNAL_SEARCH_MAX_LENGTH).join('').trim()
}
export type SearchTermState = 'empty' | 'short' | 'ready'
/** Whether `term` is empty, too short for the API, or ready to search. */
export function searchTermState(term: string, minLength: number = JOURNAL_SEARCH_MIN_LENGTH): SearchTermState {
const n = [...term.trim()].length
if (n === 0) return 'empty'
return n < minLength ? 'short' : 'ready'
}
// Letters NFD does not decompose into a base letter and a mark.
const FOLD_EXTRA: Readonly<Record<string, string>> = {
ł: 'l',
Ł: 'l',
ø: 'o',
Ø: 'o',
đ: 'd',
Đ: 'd',
ß: 'ss',
æ: 'ae',
Æ: 'ae',
œ: 'oe',
Œ: 'oe',
}
function foldChar(ch: string): string {
return FOLD_EXTRA[ch] ?? ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
/**
* Folds `text` to lower case without diacritics. `start[i]` and `end[i]` are
* the bounds, in `text`, of the character that produced folded[i].
*/
function fold(text: string): { folded: string; start: number[]; end: number[] } {
let folded = ''
const start: number[] = []
const end: number[] = []
let pos = 0
for (const ch of text) {
const f = foldChar(ch)
for (let k = 0; k < f.length; k++) {
start.push(pos)
end.push(pos + ch.length)
}
folded += f
pos += ch.length
}
return { folded, start, end }
}
export interface TextSegment {
text: string
mark: boolean
}
/**
* Splits `text` into plain and marked segments, marking every occurrence of
* each word of `query` (2 or more characters) while ignoring case and
* diacritics, so "grzybow" marks "grzybów". The segments are text: render
* them as text nodes, never as HTML.
*/
export function markTerms(text: string | null | undefined, query: string | null | undefined): TextSegment[] {
if (!text) return []
const words = [...new Set((query ?? '').split(/\s+/).map((w) => fold(w).folded))].filter((w) => w.length >= 2)
if (words.length === 0) return [{ text, mark: false }]
const { folded, start, end } = fold(text)
const ranges: Array<[number, number]> = []
for (const word of words) {
for (let i = folded.indexOf(word); i !== -1; i = folded.indexOf(word, i + 1)) {
ranges.push([start[i]!, end[i + word.length - 1]!])
}
}
if (ranges.length === 0) return [{ text, mark: false }]
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1])
const merged: Array<[number, number]> = []
for (const r of ranges) {
const last = merged[merged.length - 1]
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
else merged.push([r[0], r[1]])
}
const out: TextSegment[] = []
let pos = 0
for (const [a, b] of merged) {
if (a > pos) out.push({ text: text.slice(pos, a), mark: false })
out.push({ text: text.slice(a, b), mark: true })
pos = b
}
if (pos < text.length) out.push({ text: text.slice(pos), mark: false })
return out
}
export type PluralForm = 'one' | 'few' | 'many' | 'other'
/**
* The plural form of `n` in `language` (a BCP 47 tag), from Intl.PluralRules:
* Polish gives one (1), few (2-4, 22-24) and many (0, 5-21). Forms a message
* file does not carry (zero, two) fall back to other.
*/
export function pluralForm(language: string, n: number): PluralForm {
let form: string
try {
form = new Intl.PluralRules(language || 'en').select(n)
} catch {
form = new Intl.PluralRules('en').select(n)
}
return form === 'one' || form === 'few' || form === 'many' ? form : 'other'
}
/**
* The option a combobox key moves to among `count` options, from `current`
* (-1 for none): ArrowDown and ArrowUp move one step and wrap. Any other key,
* or no options, gives null.
*/
export function moveActive(current: number, count: number, key: string): number | null {
if (count <= 0) return null
if (key === 'ArrowDown') return current < 0 || current >= count - 1 ? 0 : current + 1
if (key === 'ArrowUp') return current <= 0 ? count - 1 : current - 1
return null
}
export type SuggestStatus = 'idle' | 'loading' | 'done' | 'error'
export interface SuggestState<T> {
/** The term the rows (or the pending request) belong to. */
term: string
status: SuggestStatus
/** True once a request has run longer than the spinner delay. */
loading: boolean
rows: T[]
total: number
}
export interface SuggestControllerOptions<T> {
/** One suggestion request; it must stop when `signal` aborts. */
fetch: (term: string, signal: AbortSignal) => Promise<{ data: T[]; meta?: { total?: number } }>
onChange: (state: SuggestState<T>) => void
/** Wait after the last keystroke before a request, in ms. Default 250. */
debounce?: number
/** Shorter terms send nothing. Default JOURNAL_SEARCH_MIN_LENGTH. */
minLength?: number
/** A request shows `loading` only after this many ms, to avoid flicker. Default 300. */
spinnerDelay?: number
}
export interface SuggestController {
/** The input's current value. */
input(value: string): void
/** Cancels the pending and running request. */
dispose(): void
}
/**
* Debounced suggestion requests. Each new term cancels the pending timer,
* aborts the running request and ignores any answer that still arrives, so
* the rows always belong to the latest term. A failed request ends in status
* "error" and never throws.
*/
export function createSuggestController<T>(opts: SuggestControllerOptions<T>): SuggestController {
const debounce = opts.debounce ?? 250
const minLength = opts.minLength ?? JOURNAL_SEARCH_MIN_LENGTH
const spinnerDelay = opts.spinnerDelay ?? 300
let state: SuggestState<T> = { term: '', status: 'idle', loading: false, rows: [], total: 0 }
let timer: ReturnType<typeof setTimeout> | undefined
let spinner: ReturnType<typeof setTimeout> | undefined
let abort: AbortController | null = null
let seq = 0
const emit = (patch: Partial<SuggestState<T>>) => {
state = { ...state, ...patch }
opts.onChange(state)
}
const cancel = () => {
clearTimeout(timer)
clearTimeout(spinner)
abort?.abort()
abort = null
seq++
}
const run = async (term: string) => {
const id = ++seq
const ctl = new AbortController()
abort = ctl
emit({ status: 'loading', loading: false })
spinner = setTimeout(() => {
if (id === seq) emit({ loading: true })
}, spinnerDelay)
try {
const page = await opts.fetch(term, ctl.signal)
if (id !== seq) return
clearTimeout(spinner)
emit({ status: 'done', loading: false, rows: page.data ?? [], total: page.meta?.total ?? 0 })
} catch {
if (id !== seq) return
clearTimeout(spinner)
emit({ status: 'error', loading: false, rows: [], total: 0 })
} finally {
if (abort === ctl) abort = null
}
}
return {
input(value) {
const term = value.trim()
if (term === state.term && (state.status === 'loading' || state.status === 'done')) return
cancel()
if (searchTermState(term, minLength) !== 'ready') {
emit({ term, status: 'idle', loading: false, rows: [], total: 0 })
return
}
emit({ term })
timer = setTimeout(() => void run(term), debounce)
},
dispose: cancel,
}
}

View File

@@ -1,5 +1,10 @@
// 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.
// The keys the CMS never sets (og:type, article:*) come from
// journalArticleMeta on every post page.
import type { JournalPost } from './types.ts'
@@ -64,6 +69,74 @@ export function postSeoMeta(post: JournalPost, siteUrl?: string | null): Journal
return meta
}
/**
* The post-page head keys the CMS head never carries (vue-seo-layer writes
* no og:type and no article:* tags): og:type article and the publish and
* update times, set on every post page whether or not the CMS answers
* (L4 WR-02). A missing time is left out.
*/
export function journalArticleMeta(post: Pick<JournalPost, 'published_at' | 'updated_at'>): Pick<JournalPostSeo, 'ogType' | 'articlePublishedTime' | 'articleModifiedTime'> {
const meta: Pick<JournalPostSeo, 'ogType' | 'articlePublishedTime' | 'articleModifiedTime'> = { ogType: 'article' }
if (post.published_at) meta.articlePublishedTime = post.published_at
if (post.updated_at) meta.articleModifiedTime = post.updated_at
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 schema.org ItemList node of a listing page (D-43). */
export interface JournalItemListNode {
'@type': 'ItemList'
'@id': string
itemListElement: Array<{ '@type': 'ListItem'; position: number; url: string }>
[key: string]: unknown
}
/**
* The ItemList node of a listing page for its one JSON-LD graph (D-43):
* @id <pageUrl>#itemlist and a ListItem with the absolute URL of each post
* shown, positions continuing across pages from `first` (1 on page 1). Posts
* without a link (a disabled post view) are left out; none gives null.
*/
export function journalItemList(pageUrl: string, origin: string, paths: ReadonlyArray<string | null | undefined>, first: number = 1): JournalItemListNode | null {
const items: JournalItemListNode['itemListElement'] = []
for (const path of paths) {
if (typeof path === 'string' && path !== '') items.push({ '@type': 'ListItem', position: first + items.length, url: absoluteUrl(path, origin) })
}
if (items.length === 0) return null
return { '@type': 'ItemList', '@id': `${pageUrl.split('#')[0]}#itemlist`, itemListElement: items }
}
/** A listing title; pages after the first append the "Page N" string. */
export function listTitle(base: string, page: number, pageTitle: string): string {
return page > 1 ? `${base} · ${pageTitle}` : base

View File

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

81
app/utils/srcset.ts Normal file
View File

@@ -0,0 +1,81 @@
// srcset strings of a featured image from the Journal API's
// featured_images[].variants (D-85, D-71): one for the WebP <source> and
// one for the <img> in the original format, the original itself added
// when it is wider than every variant, plus the size the <img> reserves.
// Pure, no Vue or Nuxt imports, so `node --test` drives it.
/** One size of a featured image (featured_images[].variants). */
export interface JournalImageVariant {
width: number
height: number
/** The thumbnail in the original's format. */
url: string
/** Its WebP copy. */
webp_url: string
/** The original's size. */
source_width?: number
source_height?: number
}
/** What <picture> renders from the variants. */
export interface JournalImageSrcsets {
/** srcset of the type="image/webp" <source>. */
webp: string
/** srcset of the <img>, in the original format. */
original: string
/** The size the <img> reserves: the original's, else the largest variant's. */
width: number
height: number
}
/** A URL safe inside a srcset candidate (no spaces or commas). */
function candidateURL(url: string): string {
return url.replace(/ /g, '%20').replace(/,/g, '%2C')
}
function validVariant(v: unknown): v is JournalImageVariant {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return (
Number.isInteger(o.width) && (o.width as number) > 0 &&
Number.isInteger(o.height) && (o.height as number) > 0 &&
typeof o.url === 'string' && o.url !== '' &&
typeof o.webp_url === 'string' && o.webp_url !== ''
)
}
/**
* The srcsets of an image from its variants, narrowest first and each
* width once. originalUrl and originalWidth (the variants' source_width
* when not given) add the original to the <img> srcset when it is wider
* than every variant. No usable variant answers null: render the original
* as before.
*/
export function imageSrcsets(
variants: readonly unknown[] | null | undefined,
originalUrl?: string | null,
originalWidth?: number | null,
): JournalImageSrcsets | null {
if (!Array.isArray(variants)) return null
const byWidth = new Map<number, JournalImageVariant>()
for (const v of variants) {
if (validVariant(v) && !byWidth.has(v.width)) byWidth.set(v.width, v)
}
const list = [...byWidth.values()].sort((a, b) => a.width - b.width)
const largest = list[list.length - 1]
if (!largest) return null
const webp = list.map((v) => `${candidateURL(v.webp_url)} ${v.width}w`)
const original = list.map((v) => `${candidateURL(v.url)} ${v.width}w`)
const sourceWidth = originalWidth ?? largest.source_width ?? null
const sourceHeight = largest.source_height ?? null
if (originalUrl && sourceWidth && Number.isInteger(sourceWidth) && sourceWidth > largest.width) {
original.push(`${candidateURL(originalUrl)} ${sourceWidth}w`)
}
const hasSource = !!sourceWidth && !!sourceHeight && Number.isInteger(sourceWidth) && Number.isInteger(sourceHeight)
return {
webp: webp.join(', '),
original: original.join(', '),
width: hasSource ? (sourceWidth as number) : largest.width,
height: hasSource ? (sourceHeight as number) : largest.height,
}
}

View File

@@ -2,12 +2,16 @@
// mirror the Go serializers exactly (snake_case); a field is nullable where
// Go can emit null. Types only, so `node --test` can import any util.
import type { JournalImageVariant } from './srcset.ts'
/** The author object embedded in a post row. `null` when the post has none. */
export interface JournalAuthorRef {
id: number
first_name: string
last_name: string
login: string
/** The author slug, else the login (sm-journal-plugin v0.3.1+). */
slug?: string
}
/** A category or tag reference embedded in a post row. */
@@ -22,6 +26,8 @@ export interface JournalFeaturedImage {
url: string
file_name: string
file_size: number
/** Its sizes with WebP copies, narrowest first; [] until they are made (Journal API, D-85). */
variants?: JournalImageVariant[]
}
/** One post row, as GET /posts and GET /authors/{author}/posts list it. */
@@ -49,6 +55,19 @@ export interface JournalPost {
updated_at: string | null
}
/**
* One row of GET /posts?fields=suggest: a subset of JournalPost for a search
* box. `excerpt` is plain text of about 160 characters.
*/
export interface JournalPostSuggestion {
id: number
title: string
slug: string
excerpt: string
featured_image_url: string | null
published_at: string | null
}
/** previous_post / next_post on GET /posts/{slug}. */
export interface JournalPostSibling {
id: number
@@ -60,8 +79,25 @@ export interface JournalPostSibling {
export interface JournalPostDetail extends JournalPost {
previous_post: JournalPostSibling | null
next_post: JournalPostSibling | null
/** Latest 3 in the same categories; empty when the post has no category. */
/**
* Latest 3 in the same categories (empty when the post has no category);
* with the Journal's related_mode scored, 3 to 6 ranked by shared tags
* and categories.
*/
related_posts: JournalPost[]
/**
* The neighbours by publish time within the post's first category, in
* the direction of previous_post and next_post. Only with the Journal's
* opt-in related_mode scored; absent otherwise.
*/
previous_in_category?: JournalPostSibling | null
next_in_category?: JournalPostSibling | null
/**
* 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. */
@@ -72,6 +108,12 @@ export interface JournalCategory {
description: string | null
/** Rich HTML body of the category page, stored as is; null when unset. */
content: string | null
/**
* The Journal H1 setting on this row (newer Journal APIs): true while an
* h1 in content becomes h2. Absent on an older Journal, which counts as
* true.
*/
content_h1_to_h2?: boolean
parent_id: number | null
nest_depth: number | null
post_count: number
@@ -121,6 +163,8 @@ export interface JournalRoutes {
category: string | false
tag: string | false
author: string | false
/** Search results page; the term is the `q` query parameter. Off by default. */
search: string | false
}
export interface JournalPerPage {
@@ -128,6 +172,7 @@ export interface JournalPerPage {
category: number
tag: number
author: number
search: number
}
/** Options of the `journal` config key in nuxt.config. */
@@ -140,4 +185,19 @@ export interface JournalModuleOptions {
relatedCount: number
/** Whether /rss.xml is served and linked. */
rss: boolean
/**
* The post page title's element: 'auto' (default) follows the Journal H1
* setting (h1 while content_h1_to_h2 is on, p while it is off), 'h1' or
* 'p' force it. NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG at runtime.
*/
postTitleTag: JournalPostTitleTag
/**
* Whether the journal pages render <SeoBreadcrumbs> at the top of their
* content (default true). false for a site whose layout places them.
* NUXT_PUBLIC_JOURNAL_BREADCRUMBS at runtime.
*/
breadcrumbs: boolean
}
/** journal.postTitleTag. */
export type JournalPostTitleTag = 'auto' | 'h1' | 'p'

View File

@@ -26,3 +26,11 @@ export function formatJournalDate(iso: string | null | undefined, language: stri
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
}
}
/**
* True while the journal pages render <SeoBreadcrumbs> at the top of their
* content (journal.breadcrumbs, NUXT_PUBLIC_JOURNAL_BREADCRUMBS; default on).
*/
export function journalBreadcrumbsOn(option: unknown): boolean {
return option !== false && option !== 'false'
}

View File

@@ -33,6 +33,28 @@
"readMore": "Read more",
"rss": {
"title": "RSS feed"
},
"search": {
"title": "Search",
"titleQuery": "Search results: {query}",
"placeholder": "Search posts…",
"button": "Search",
"input": "Search the posts",
"submit": "Search",
"suggestions": "Suggestions",
"seeAll": "See all results",
"empty": "No results",
"none": "Nothing found for “{query}”.",
"short": "Type at least {min} characters.",
"prompt": "Type what you are looking for.",
"loading": "Searching…",
"error": "Search is unavailable right now.",
"count": {
"one": "{count} result",
"few": "{count} results",
"many": "{count} results",
"other": "{count} results"
}
}
}
}

View File

@@ -33,6 +33,28 @@
"readMore": "Czytaj dalej",
"rss": {
"title": "Kanał RSS"
},
"search": {
"title": "Szukaj",
"titleQuery": "Wyniki wyszukiwania: {query}",
"placeholder": "Szukaj wpisów…",
"button": "Szukaj",
"input": "Szukaj we wpisach",
"submit": "Szukaj",
"suggestions": "Podpowiedzi",
"seeAll": "Zobacz więcej",
"empty": "Brak wyników",
"none": "Nic nie znaleźliśmy dla „{query}”.",
"short": "Wpisz co najmniej {min} znaki.",
"prompt": "Wpisz, czego szukasz.",
"loading": "Szukam…",
"error": "Wyszukiwanie jest chwilowo niedostępne.",
"count": {
"one": "{count} wynik",
"few": "{count} wyniki",
"many": "{count} wyników",
"other": "{count} wyniku"
}
}
}
}

View File

@@ -7,8 +7,8 @@
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 { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, journalPagedRoutes, journalPaginationSource, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalModuleOptions, JournalPostTitleTag, JournalRoutes } from '../app/utils/types.ts'
const PAGE_FILES: Record<keyof JournalRoutes, string> = {
index: 'PostIndexPage',
@@ -16,6 +16,7 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
category: 'CategoryPage',
tag: 'TagPage',
author: 'AuthorPage',
search: 'SearchPage',
}
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
@@ -32,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',
@@ -39,22 +45,48 @@ export default defineNuxtModule<JournalModuleOptions>({
},
defaults: {
routes: { ...DEFAULT_JOURNAL_ROUTES },
perPage: { index: 9, category: 9, tag: 9, author: 9 },
perPage: { index: 9, category: 9, tag: 9, author: 9, search: 9 },
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>)
extendPages((pages) => {
// The path pagination form (vue-seo-layer's seo.pagination.form 'path',
// D-40) adds <listing>/<segment>/:page routes rendering the same pages,
// which read the page from the :page param. The segments come from the
// SEO module's resolved option (its defaults included), written in its
// setup, which has run by now (L4 WR-03).
const paged = journalPagedRoutes(routes, journalPaginationSource(nuxt.options))
for (const key of Object.keys(PAGE_FILES) as Array<keyof JournalRoutes>) {
const path = routes[key]
if (path === false) continue
pages.push({ name: JOURNAL_ROUTE_NAMES[key], path, file: `${pagesDir}${PAGE_FILES[key]}.vue` })
}
for (const route of paged) pages.push({ name: route.name, path: route.path, file: `${pagesDir}${PAGE_FILES[route.key]}.vue` })
})
// 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 (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}`)]
const options = nuxt.options as { routeRules?: Record<string, Record<string, unknown>> }
const rules = (options.routeRules ??= {})
for (const path of paths) rules[path] = { cache: false, robots: 'noindex, follow', ...(rules[path] ?? {}) }
const opts = nuxt.options as { sitemap?: { exclude?: Array<string | RegExp> } }
opts.sitemap = { ...(opts.sitemap ?? {}), exclude: [...(opts.sitemap?.exclude ?? []), ...paths] }
}
// Register the layer's strings only for locales the app enables. The
// layer config declares no locales: @nuxtjs/i18n merges every locale a
// layer declares into the app, which would add /en routes to an app that
@@ -79,6 +111,8 @@ export default defineNuxtModule<JournalModuleOptions>({
sort: options.sort,
relatedCount: options.relatedCount,
rss: options.rss,
postTitleTag: postTitleTag(options.postTitleTag),
breadcrumbs: options.breadcrumbs !== false,
}
},
})

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "vue-journal-layer",
"version": "0.6.0",
"version": "0.8.1",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",
@@ -8,6 +8,7 @@
"dev": "nuxi dev .playground",
"build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-title-h1.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-title-p.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=p && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-hint-off.txt --env NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-breadcrumbs-off.txt --env NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false",
"dev:prepare": "nuxt prepare .playground",
"check:git-install": "bash scripts/check-git-install.sh"
},
@@ -17,16 +18,17 @@
"dependencies": {
"@nuxt/fonts": "0.14.0",
"@nuxtjs/i18n": "10.4.0",
"@nuxtjs/seo": "5.2.1",
"sanitize-html": "2.18.0"
},
"peerDependencies": {
"nuxt": "4.4.8"
"nuxt": "4.4.8",
"vue-seo-layer": "^0.2.0"
},
"devDependencies": {
"@types/sanitize-html": "2.16.2",
"nuxt": "4.4.8",
"typescript": "5.9.3",
"vue": "3.5.35"
"vue": "3.5.35",
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"
}
}

1980
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,24 @@
#!/usr/bin/env bash
# Usage: scripts/check-git-install.sh <tag>
# scripts/check-git-install.sh --check-links DIR...
# scripts/check-git-install.sh --self-test (the lockfile and link checks only)
#
# Proves the layer's distribution end to end: a scratch app installs
# vue-journal-layer from git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>
# anonymously, the lockfile pins the tag's commit, `nuxt build` succeeds, and
# the built server passes testing/expect-acme.txt against the fixture API.
# anonymously, next to vue-seo-layer (a peer dependency, listed by the app at
# the tag's devDependencies pin) with pnpm's default settings, the installed package.json has no local dependency, the
# lockfile pins the tag's commit, `nuxt build` of the layer's playground
# config succeeds, and the built server passes testing/expect-acme.txt
# against the fixture API.
#
# --check-links is the release guard to run before tagging any content
# layer (README, Releasing): it reads DIR/package.json for each DIR and
# prints `check-git-install: <dir>: depends on <name> through <spec>` for
# every dependencies, devDependencies, peerDependencies or
# optionalDependencies entry whose spec starts with link:, file:,
# workspace: or portal: (a path that resolves outside the package once it
# is installed from git), exiting 1 when it finds any or cannot read a
# package.json.
# Nothing outside a temporary directory is touched.
set -euo pipefail
@@ -12,6 +26,101 @@ REPO_URL="https://git.golem15.com/golem15/vue-journal-layer.git"
die() { echo "check-git-install: $*" >&2; exit 1; }
# lock_commits FILE prints the commits FILE pins for vue-journal-layer, one
# per line: the #<commit> of its git+https entries and the commit of a
# resolution whose repo is the layer's. Other git dependencies are ignored.
lock_commits() {
{
grep -oE 'vue-journal-layer\.git#[0-9a-f]{40}' "$1" | cut -d'#' -f2
grep -oE 'commit: [0-9a-f]{40}, repo: [^,}]*/vue-journal-layer\.git' "$1" | grep -oE '[0-9a-f]{40}'
} | sort -u
}
# local_links LABEL DIR... prints one line per local dependency of each
# DIR/package.json (prefixed with LABEL, else the dir) and fails when there
# is any, or when a package.json cannot be read.
local_links() {
local label="$1"
shift
node -e '
const fs = require("node:fs")
const path = require("node:path")
const [label, ...dirs] = process.argv.slice(1)
const LOCAL = /^(link|file|workspace|portal):/
let bad = false
for (const dir of dirs) {
const who = label || dir
let pkg
try {
pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"))
} catch (err) {
console.error(`check-git-install: ${who}: cannot read package.json (${err.message})`)
bad = true
continue
}
for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
const deps = pkg[section] && typeof pkg[section] === "object" ? pkg[section] : {}
for (const [name, spec] of Object.entries(deps)) {
if (typeof spec === "string" && LOCAL.test(spec)) {
console.error(`check-git-install: ${who}: depends on ${name} through ${spec} (${section})`)
bad = true
}
}
}
}
process.exit(bad ? 1 : 0)
' "$label" "$@"
}
if [[ "${1:-}" == "--check-links" ]]; then
shift
[[ $# -gt 0 ]] || die "usage: $0 --check-links DIR..."
command -v node >/dev/null 2>&1 || die "node is not installed"
local_links "" "$@" || exit 1
echo "check-git-install: no local dependencies in $*"
exit 0
fi
if [[ "${1:-}" == "--self-test" ]]; then
lock="$(mktemp)"
pkgs="$(mktemp -d)"
trap 'rm -f "$lock"; rm -rf "$pkgs"' EXIT
j=1111111111111111111111111111111111111111
s=2222222222222222222222222222222222222222
cat >"$lock" <<LOCK
vue-journal-layer:
specifier: git+https://git.golem15.com/golem15/vue-journal-layer.git#v1.0.0
version: git+https://git.golem15.com/golem15/vue-journal-layer.git#$j(abcdef0123456789abcdef0123456789)
vue-journal-layer@git+https://git.golem15.com/golem15/vue-journal-layer.git#$j:
resolution: {commit: $j, repo: https://git.golem15.com/golem15/vue-journal-layer.git, type: git}
vue-seo-layer@git+https://git.golem15.com/golem15/vue-seo-layer.git#$s:
resolution: {commit: $s, repo: https://git.golem15.com/golem15/vue-seo-layer.git, type: git}
LOCK
[[ "$(lock_commits "$lock")" == "$j" ]] || die "self-test: lock_commits gave $(lock_commits "$lock" | tr '\n' ' '), want only $j"
# A local link: dependency is reported and fails; a git+https tag pin passes.
mkdir -p "$pkgs/linked" "$pkgs/pinned" "$pkgs/dev"
cat >"$pkgs/linked/package.json" <<'JSON'
{ "name": "vue-menu-layer", "dependencies": { "vue-seo-layer": "link:../vue-seo-layer", "nuxt": "4.4.8" } }
JSON
cat >"$pkgs/pinned/package.json" <<'JSON'
{ "name": "vue-menu-layer", "dependencies": { "vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0", "nuxt": "4.4.8" } }
JSON
cat >"$pkgs/dev/package.json" <<'JSON'
{ "name": "x", "devDependencies": { "a": "file:../a" }, "optionalDependencies": { "b": "workspace:*" }, "peerDependencies": { "c": "portal:../c" } }
JSON
if out="$(local_links "" "$pkgs/linked" 2>&1)"; then die "self-test: a link: dependency passed"; fi
[[ "$out" == "check-git-install: $pkgs/linked: depends on vue-seo-layer through link:../vue-seo-layer (dependencies)" ]] \
|| die "self-test: the link: report was: $out"
out="$(local_links "" "$pkgs/pinned" 2>&1)" || die "self-test: a git+https pin failed: $out"
[[ -z "$out" ]] || die "self-test: a git+https pin printed: $out"
if out="$(local_links "" "$pkgs/dev" 2>&1)"; then die "self-test: file:, workspace: and portal: dependencies passed"; fi
[[ "$(grep -c 'depends on' <<<"$out")" -eq 3 ]] || die "self-test: want three reports for file:, workspace: and portal:, got: $out"
if out="$(local_links "the tag's package.json" "$pkgs/missing" 2>&1)"; then die "self-test: a missing package.json passed"; fi
if out="$(local_links "" "$pkgs/pinned" "$pkgs/linked" 2>&1)"; then die "self-test: one linked package among several passed"; fi
echo "check-git-install: self-test ok"
exit 0
fi
TAG="${1:-}"
[[ -n "$TAG" ]] || die "usage: $0 <tag>"
@@ -36,20 +145,36 @@ TAG_SHA="$(awk -v ref="refs/tags/$TAG^{}" '$2 == ref { print $1 }' <<<"$REMOTE_R
[[ -n "$TAG_SHA" ]] || TAG_SHA="$(awk -v ref="refs/tags/$TAG" '$2 == ref { print $1 }' <<<"$REMOTE_REFS")"
[[ -n "$TAG_SHA" ]] || die "tag $TAG not found on $REPO_URL"
# vue-seo-layer is a peer dependency of the layer, so an app lists it next to
# the layer, as the README's Installation says. The scratch app takes the
# spec the tag pins for its own development (devDependencies), or
# SEO_LAYER_SPEC when set. A tag that still lists vue-seo-layer under
# dependencies (v0.8.0) gets none: pnpm then refuses it as an exotic
# subdependency, which is what this check is for.
SEO_SPEC="${SEO_LAYER_SPEC:-}"
if [[ -z "$SEO_SPEC" ]]; then
SRC="$(mktemp -d "${TMPDIR:-/tmp}/journal-git-install-src.XXXXXX")"
GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 --branch "$TAG" "$REPO_URL" "$SRC/layer" 2>/dev/null \
|| { rm -rf "$SRC"; die "git clone of $TAG failed"; }
SEO_SPEC="$(node -e '
const pkg = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))
const peer = (pkg.peerDependencies || {})["vue-seo-layer"]
const dev = (pkg.devDependencies || {})["vue-seo-layer"]
process.stdout.write(peer && dev ? dev : "")
' "$SRC/layer/package.json")" || { rm -rf "$SRC"; die "cannot read the tag's package.json"; }
rm -rf "$SRC"
fi
cd "$WORK"
cat >package.json <<JSON
{
"name": "acme-blog",
"private": true,
"type": "module",
"dependencies": {
"nuxt": "4.4.8",
"vue": "3.5.35",
"vue-journal-layer": "git+$REPO_URL#$TAG"
}
}
JSON
node -e '
const [repo, tag, seo] = process.argv.slice(1)
const deps = { nuxt: "4.4.8", vue: "3.5.35" }
if (seo) deps["vue-seo-layer"] = seo
deps["vue-journal-layer"] = `git+${repo}#${tag}`
const pkg = { name: "acme-blog", private: true, type: "module", dependencies: deps }
require("node:fs").writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n")
' "$REPO_URL" "$TAG" "$SEO_SPEC"
cat >pnpm-workspace.yaml <<'YAML'
allowBuilds:
@@ -58,30 +183,33 @@ allowBuilds:
vue-demi: true
YAML
cat >nuxt.config.ts <<'TS'
export default defineNuxtConfig({
extends: ['vue-journal-layer'],
compatibilityDate: '2025-07-15',
site: { url: 'https://acme.test', name: 'Acme Blog' },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'pl', language: 'pl-PL' },
],
},
})
TS
GIT_TERMINAL_PROMPT=0 pnpm install || die "pnpm install failed (a link: or file: dependency in the tag's package.json fails here too; run --check-links before tagging)"
GIT_TERMINAL_PROMPT=0 pnpm install || die "pnpm install failed"
# The tag must not carry a local dependency (link:, file:, workspace:,
# portal:): it would resolve next to the pnpm store entry, not to a package.
local_links "the tag's package.json" node_modules/vue-journal-layer \
|| die "the tag's package.json still has a local dependency (see above); pin it to a git+https tag before tagging"
# The lockfile must pin the tag's commit, not a branch.
LOCK_SHA="$(grep -oE "vue-journal-layer[^[:space:]]*[0-9a-f]{40}|commit: [0-9a-f]{40}" pnpm-lock.yaml | grep -oE '[0-9a-f]{40}' | sort -u)"
# The lockfile must pin the tag's commit, not a branch. Only the
# vue-journal-layer entries count: the layer's own git dependencies
# (vue-seo-layer) are pinned in the same file.
LOCK_SHA="$(lock_commits pnpm-lock.yaml)"
[[ -n "$LOCK_SHA" ]] || die "pnpm-lock.yaml records no commit for vue-journal-layer"
[[ "$(wc -l <<<"$LOCK_SHA")" -eq 1 ]] || die "pnpm-lock.yaml records more than one commit: $LOCK_SHA"
[[ "$(wc -l <<<"$LOCK_SHA")" -eq 1 ]] || die "pnpm-lock.yaml records more than one vue-journal-layer commit: $LOCK_SHA"
[[ "$LOCK_SHA" == "$TAG_SHA" ]] || die "pnpm-lock.yaml pins $LOCK_SHA, tag $TAG is $TAG_SHA"
# The scratch app is the layer's playground (its journal options, such as
# the search route and one post per category page, and its app.vue with the
# search box and the layout trail), which testing/expect-acme.txt is
# written against, extending the installed layer instead of '..'.
PLAYGROUND=node_modules/vue-journal-layer/.playground
[[ -f "$PLAYGROUND/nuxt.config.ts" && -f "$PLAYGROUND/app/app.vue" ]] \
|| die "the installed layer has no .playground/nuxt.config.ts and .playground/app/app.vue"
sed "s#extends: \['\.\.'\]#extends: ['vue-journal-layer']#" "$PLAYGROUND/nuxt.config.ts" >nuxt.config.ts
grep -q "extends: \['vue-journal-layer'\]" nuxt.config.ts || die "the playground's nuxt.config.ts does not extend '..'"
mkdir -p app
cp "$PLAYGROUND/app/app.vue" app/app.vue
pnpm exec nuxt build || die "nuxt build failed"
node node_modules/vue-journal-layer/testing/smoke-ssr.mjs \

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
# SSR expectations with journal.breadcrumbs off
# (NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false, UI-SPEC L1 journal placement
# switch): journal pages render no <SeoBreadcrumbs>; the playground's layout
# renders the one trail; the BreadcrumbList data is unchanged.
200 /blog/hello-acme count=<nav class="seo-breadcrumbs":1 contains=<div class="playground-trail"><nav class="seo-breadcrumbs" notcontains=<article class="journal-post"><nav contains=BreadcrumbList count=application/ld+json:1
200 /blog count=<nav class="seo-breadcrumbs":1 contains=<div class="playground-trail"><nav class="seo-breadcrumbs" notcontains=<section class="journal-index"><nav

View File

@@ -0,0 +1,5 @@
# SSR expectations with the SEO-layer option seo.links.newTabHint off
# (NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false): outbound links still open in a
# new tab with noopener, without the visually hidden hint (UI-SPEC L7).
200 /blog/hello-acme contains=<a href="https://partner.test/review" rel="ugc nofollow noopener" target="_blank">the review</a> notcontains=(opens in a new tab)
200 /blog/hello-acme contains=<a href="https://shop.ads.test/deal" target="_blank" rel="sponsored noopener">a deal</a>

View File

@@ -0,0 +1,7 @@
# SSR expectations with journal.postTitleTag forced to h1
# (NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1, UI-SPEC L6 override). The title is
# an h1 in both Journal H1 modes; with the setting off the body keeps its h1,
# so the page has two: the documented consequence of the override, which
# the crawler reports.
200 /blog/heading-on count=<h1:1 contains=<h1 class="journal-post__title"
200 /blog/heading-off count=<h1:2 contains=<h1 class="journal-post__title" contains=<h1>Body heading one</h1>

View File

@@ -0,0 +1,7 @@
# SSR expectations with journal.postTitleTag forced to p
# (NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=p, UI-SPEC L6 override). With the
# Journal H1 setting on, the body's h1 is demoted, so the page has no h1:
# the documented consequence of the override, which the crawler reports as
# h1_missing.
200 /blog/heading-on count=<h1:0 contains=<p class="journal-post__title"
200 /blog/heading-off count=<h1:1 contains=<p class="journal-post__title" contains=<h1>Body heading one</h1>

View File

@@ -1,7 +1,24 @@
# 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.
# og:type article and the article times on every post page, with a CMS
# answer (heading-on) and without one (uncategorised-note): the CMS head
# never carries them (L4 WR-02).
200 /blog/heading-on count=property="og:type":1 contains=<meta property="og:type" content="article"> contains=<meta property="article:published_time" content="2025-12-20T09:00:00+00:00"> contains=<meta property="article:modified_time" content="2025-12-20T09:00:00+00:00">
200 /blog/uncategorised-note count=property="og:type":1 contains=<meta property="og:type" content="article"> contains=<meta property="article:published_time" content="2026-01-02T09:00:00+00:00">
# Single post
200 /blog/hello-acme contains=Hello Acme
@@ -29,17 +46,112 @@
# Author (slug and login resolve) and tag pages
200 /blog/author/jane-doe contains=Jane Doe
200 /blog/author/jdoe contains=<link rel="canonical" href="https://acme.test/blog/author/jane-doe"
200 /blog/hello-acme contains=href="/blog/author/jane-doe" notcontains=/blog/author/jdoe
404 /blog/author/nobody
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 /**)
# and noindex, follow.
200 /search?q=acme contains=Hello Acme
200 /search?q=acme contains=11 results
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
200 /search?q=unsafe contains=Unsafe Sample notcontains=Hello Acme
200 /search?q=unsafe contains=1 result
200 /search?q=zzzzzz contains=Nothing found for “zzzzzz”.
200 /search?q=ac contains=Type at least 3 characters.
200 /search contains=Type what you are looking for.
# The term is text everywhere it is rendered (attributes may keep a bare <).
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: &lt;script&gt;alert(1) notcontains=>Search results: <script>
200 /pl/search?q=acme contains=11 wyników
200 /pl/search?q=unsafe contains=1 wynik
# 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"
# Body links, outbound rules and images (UI-SPEC L7). hello-acme: new tab on
# with the default hint; auto-links in document order up to autolink_max 2,
# never in a heading, never to the page itself.
200 /blog/hello-acme contains=<a href="/blog/acme-note-07">anvils</a> contains=<a href="/blog/acme-note-05">rockets</a> notcontains=<a href="/blog/acme-note-03"> notcontains=<a href="/blog/acme-note-06"> notcontains=<a href="https://acme.test/blog/hello-acme"
200 /blog/hello-acme contains=<a href="https://partner.test/review" rel="ugc nofollow noopener" target="_blank">the review<span class="seo-visually-hidden"> (opens in a new tab)</span></a>
200 /blog/hello-acme contains=<a href="https://shop.ads.test/deal" target="_blank" rel="sponsored noopener">a deal<span class="seo-visually-hidden"> (opens in a new tab)</span></a>
200 /blog/hello-acme contains=<a href="/blog/acme-note-04">an older note</a>
200 /blog/hello-acme contains=<img src="/storage/app/uploads/public/acme/anvil.png" alt="" width="640" height="480" loading="lazy" decoding="async" />
# heading-off: new tab off keeps the editor rel and the domain-list nofollow,
# without target, noopener or the hint.
200 /blog/heading-off contains=<a href="https://partner.test/x" rel="ugc nofollow">the partner</a> contains=<a href="https://other.test/y">another site</a> notcontains=target="_blank" notcontains=noopener notcontains=(opens in a new tab)
# Heading convention (UI-SPEC L6) and one link per post card (L5) on every
# fixture page: exactly one <h1, no skipped level, each card one <a, post
# cards h2 in listings and h3 under the related posts' h2, no read-more link.
200 /blog headings=strict cardlinks=1 count=<h1:1 notcontains=journal-post-card__more
200 /blog?page=2 headings=strict cardlinks=1 count=<h1:1 contains=· Page 2
200 /pl/blog?page=2 headings=strict cardlinks=1 count=<h1:1 contains=· Strona 2
200 /blog/category/news headings=strict cardlinks=1 count=<h1:1
200 /blog/category/quiet headings=strict count=<h1:1
200 /blog/tag/release headings=strict cardlinks=1 count=<h1:1
200 /blog/author/jane-doe headings=strict cardlinks=1 count=<h1:1
200 /search?q=acme headings=strict cardlinks=1 count=<h1:1
200 /blog/hello-acme headings=strict cardlinks=1 count=<h1:1 contains=<h2 class="journal-related__title" contains=<h3 class="journal-post-card__title" notcontains=<h2 class="journal-post-card__title" notcontains=journal-post-card__more
200 /blog/heading-on headings=strict cardlinks=1 count=<h1:1
200 /blog/heading-off headings=strict cardlinks=1 count=<h1:1
200 /blog/uncategorised-note headings=strict cardlinks=1 count=<h1:1
# Breadcrumbs at the top of journal pages (journal.breadcrumbs, default on;
# D-86) from the resolve answer, one per page; the BreadcrumbList stays in
# the one JSON-LD graph.
200 /blog/hello-acme count=<nav class="seo-breadcrumbs":1 contains=<article class="journal-post"><nav class="seo-breadcrumbs" notcontains=playground-trail contains=BreadcrumbList count=application/ld+json:1
200 /blog count=<nav class="seo-breadcrumbs":1 contains=<section class="journal-index"><nav class="seo-breadcrumbs"
# Category pillar text (D-41, the Journal H1 setting per category row): on
# page 1 only, h1 demoted while content_h1_to_h2 is on, kept while off (two
# h1 on that page: the setting's documented consequence); the description
# on every page.
200 /blog/category/news contains=<h2>About Acme news</h2> notcontains=<h1>About Acme news count=<h1:1 headings=strict
200 /blog/category/news?page=2 notcontains=Acme news marker contains=Company news from Acme. count=<h1:1 contains=· Page 2
200 /blog/category/plain contains=<h1>Plain pillar heading</h1> count=<h1:2 contains=A category whose site keeps H1 headings as written.
# Pagination through <SeoPagination class-prefix="journal-pagination"> (L2):
# 12 one-post pages of the news category, page 1 and the last page with gap
# items around the window, rel prev/next links and head links.
200 /blog/category/news?page=6 contains=<nav class="journal-pagination" aria-label="Pagination"> count=journal-pagination__gap:2 contains=<a href="/blog/category/news" aria-label="Page 1">1</a> contains=<a href="/blog/category/news?page=12" aria-label="Page 12">12</a> contains=<span class="journal-pagination__current" aria-current="page">6</span>
200 /blog/category/news?page=6 contains=<a class="journal-pagination__prev" href="/blog/category/news?page=5" rel="prev">Previous page</a> contains=<a class="journal-pagination__next" href="/blog/category/news?page=7" rel="next">Next page</a> contains=<link rel="prev" href="/blog/category/news?page=5" contains=<link rel="next" href="/blog/category/news?page=7"
200 /blog/category/news?page=2 contains=<a class="journal-pagination__prev" href="/blog/category/news" rel="prev">
404 /blog/category/news?page=13
# Listing pages add an ItemList node to the one JSON-LD graph (D-43): with
# the CMS graph on /blog, from scratch on pages without a resolve answer.
200 /blog count=application/ld+json:1 contains="@id":"https://acme.test/blog#itemlist" contains={"@type":"ListItem","position":1,"url":"https://acme.test/blog/hello-acme"} contains="@id":"https://acme.test/blog#webpage"
200 /blog?page=2 count=application/ld+json:1 contains="@id":"https://acme.test/blog?page=2#itemlist" contains="position":10,
200 /blog/category/news count=application/ld+json:1 contains=#itemlist" contains=ItemList
200 /blog/tag/release count=application/ld+json:1 contains=ItemList
200 /blog/author/jane-doe count=application/ld+json:1 contains=ItemList
200 /blog/hello-acme notcontains=ItemList
# Category neighbours (the Journal's opt-in related_mode scored): the post
# answer's previous_in_category and next_in_category render as server-side
# links instead of the site-wide neighbours; without them the site-wide
# previous_post and next_post stay.
200 /blog/heading-on contains=href="/blog/acme-note-04" class="journal-post__previous" rel="prev"><span>Previous post</span> Acme Note 04</a> contains=href="/blog/acme-note-06" class="journal-post__next" rel="next"><span>Next post</span> Acme Note 06</a>
200 /blog/heading-off contains=href="/blog/heading-on" class="journal-post__next" rel="next"><span>Next post</span> Heading On Sample</a> notcontains=journal-post__previous
# Featured images with the API's variants (D-85, D-71): <picture> with a
# WebP source and the original format as the srcset fallback (the
# original itself as the widest candidate), the original's width and
# height, alt; the post's main image eager with fetchpriority high, cards
# lazy. An image without variants renders the single <img> as before.
200 /blog/hello-acme contains=<figure class="journal-featured-image"><picture><source contains=image/webp" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp 800w" sizes="(min-width: 46rem) 46rem, 100vw"><img src="/storage/app/uploads/public/acme/hello.png" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.png 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.png 800w, /storage/app/uploads/public/acme/hello.png 1200w" sizes="(min-width: 46rem) 46rem, 100vw" width="1200" height="630" alt="Hello Acme" loading="eager" fetchpriority="high" decoding="async"></picture></figure> count=fetchpriority="high":1
200 /blog contains=image/webp" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp 800w" sizes="(min-width: 48rem) 33vw, 100vw"> contains=alt="Hello Acme" loading="lazy" decoding="async"></picture> notcontains=fetchpriority
200 /blog/acme-note-03 contains=<figure class="journal-featured-image"><img src="/storage/app/uploads/public/acme/note-03.jpg" alt="Acme Note 03" width="1200" height="630" loading="eager" decoding="async"></figure> notcontains=note-03.jpg 1200w notcontains=fetchpriority

View File

@@ -1,9 +1,20 @@
// A small node:http stand-in for the anonymous Journal API
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
// handlers' query rules (page, per_page clamp, sort allow-list, category with
// nested children, tag, search, include_empty) and the author routes, and it
// 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 */
@@ -106,6 +118,19 @@ function paginate(rows, page, perPage) {
}
}
/** The ?fields=suggest row of the Go serializer. @param {any} post */
function suggestion(post) {
const text = String(post.excerpt || post.content_html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: text.length > 160 ? `${text.slice(0, 160).replace(/\s+\S*$/, '')}…` : text,
featured_image_url: post.featured_image_url ?? null,
published_at: post.published_at,
}
}
/** @param {any} post */
function sibling(post) {
return post ? { id: post.id, title: post.title, slug: post.slug } : null
@@ -126,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 = () => {
@@ -133,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 {
@@ -172,7 +203,9 @@ export function startFixtureApi({ dir, port = 0 }) {
rows = rows.filter((p) => p.title.toLowerCase().includes(s))
}
rows = sortRows(rows, parseSort(q.get('sort')))
return paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
const out = paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
if (q.get('fields') !== 'suggest') return out
return { ...out, data: out.data.map(suggestion) }
}
/**
@@ -186,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))
@@ -242,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', () => {
@@ -249,6 +301,7 @@ export function startFixtureApi({ dir, port = 0 }) {
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
resolveCount,
reload: load,
close: () => new Promise((done) => server.close(() => done())),
})

View File

@@ -4,10 +4,11 @@
"name": "News",
"slug": "news",
"description": "Company news from Acme.",
"content": "<h2>About Acme news</h2>\n<p>Acme news marker: every launch, recall and anvil delivery.</p>\n",
"content": "<h1>About Acme news</h1>\n<p>Acme news marker: every launch, recall and anvil delivery.</p>\n",
"parent_id": null,
"nest_depth": 0,
"post_count": 12
"post_count": 12,
"content_h1_to_h2": true
},
{
"id": 2,
@@ -17,6 +18,18 @@
"content": null,
"parent_id": null,
"nest_depth": 0,
"post_count": 0,
"content_h1_to_h2": true
},
{
"id": 3,
"name": "Plain",
"slug": "plain",
"description": "A category whose site keeps H1 headings as written.",
"content": "<h1>Plain pillar heading</h1>\n<p>Plain pillar marker.</p>\n",
"content_h1_to_h2": false,
"parent_id": null,
"nest_depth": 0,
"post_count": 0
}
]

View File

@@ -5,7 +5,7 @@
"slug": "hello-acme",
"excerpt": "Acme says hello and introduces its blog.",
"content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n",
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n",
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n<p>Read <a href=\"https://partner.test/review\" rel=\"ugc\">the review</a>, <a href=\"https://shop.ads.test/deal\">a deal</a> and <a href=\"/blog/acme-note-04\">an older note</a>.</p>\n<p><img src=\"/storage/app/uploads/public/acme/anvil.png\" alt=\"\" width=\"640\" height=\"480\"></p>\n",
"reading_time": 1,
"published": true,
"published_at": "2026-01-14T09:00:00+00:00",
@@ -16,7 +16,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -37,7 +38,25 @@
"id": 1,
"url": "/storage/app/uploads/public/acme/hello.png",
"file_name": "hello.png",
"file_size": 1024
"file_size": 1024,
"variants": [
{
"width": 480,
"height": 252,
"url": "/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.png",
"webp_url": "/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp",
"source_width": 1200,
"source_height": 630
},
{
"width": 800,
"height": 420,
"url": "/storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.png",
"webp_url": "/storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp",
"source_width": 1200,
"source_height": 630
}
]
}
],
"featured_image_url": "/storage/app/uploads/public/acme/hello.png",
@@ -62,7 +81,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -95,7 +115,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -105,8 +126,15 @@
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"featured_images": [
{
"id": 3,
"url": "/storage/app/uploads/public/acme/note-03.jpg",
"file_name": "note-03.jpg",
"file_size": 2048
}
],
"featured_image_url": "/storage/app/uploads/public/acme/note-03.jpg",
"translations": {},
"created_at": "2026-01-12T09:00:00+00:00",
"updated_at": "2026-01-12T09:00:00+00:00"
@@ -128,7 +156,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -161,7 +190,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -194,7 +224,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -227,7 +258,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -260,7 +292,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -293,7 +326,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -326,7 +360,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -359,7 +394,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -392,7 +428,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -447,7 +484,8 @@
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
"login": "jdoe",
"slug": "jane-doe"
},
"categories": [
{
@@ -462,5 +500,61 @@
"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,
"previous_in_category": {
"id": 4,
"title": "Acme Note 04",
"slug": "acme-note-04"
},
"next_in_category": {
"id": 6,
"title": "Acme Note 06",
"slug": "acme-note-06"
}
},
{
"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<p>See <a href=\"https://partner.test/x\" rel=\"ugc\">the partner</a> and <a href=\"https://other.test/y\">another site</a>.</p>\n",
"reading_time": 1,
"published": true,
"published_at": "2025-12-19T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2025-12-19T09:00:00+00:00",
"updated_at": "2025-12-19T09:00:00+00:00",
"content_h1_to_h2": false
}
]

View File

@@ -0,0 +1,474 @@
{
"/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"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://acme.test/blog/hello-acme#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://acme.test/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Blog",
"item": "https://acme.test/blog"
},
{
"@type": "ListItem",
"position": 3,
"name": "Hello Acme"
}
]
}
]
},
"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"
}
],
"autolinks": [
{
"phrase": "Acme",
"url": "https://acme.test/blog/hello-acme"
},
{
"phrase": "Welcome",
"url": "/blog/acme-note-06"
},
{
"phrase": "roller skates",
"url": "/blog/acme-note-03"
},
{
"phrase": "rockets",
"url": "/blog/acme-note-05"
},
{
"phrase": "anvils",
"url": "/blog/acme-note-07"
}
],
"autolink_max": 2,
"outbound": {
"new_tab": true,
"nofollow_domains": [
"partner.test"
],
"sponsored_domains": [
"ads.test"
]
}
},
"/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"
}
],
"outbound": {
"new_tab": false,
"nofollow_domains": [
"partner.test"
],
"sponsored_domains": []
}
},
"/blog": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog.",
"canonical": "https://acme.test/blog",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog.",
"image": "",
"url": "https://acme.test/blog",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog#webpage",
"url": "https://acme.test/blog",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
},
"/blog?page=2": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog?page=2",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog, page 2.",
"canonical": "https://acme.test/blog?page=2",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog, page 2.",
"image": "",
"url": "https://acme.test/blog?page=2",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog?page=2#webpage",
"url": "https://acme.test/blog?page=2",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
],
"pagination": {
"form": "query",
"param": "page",
"page": 2
}
},
"/blog/author/jdoe": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/author/jdoe",
"locale": "en",
"staging": false,
"seo": {
"title": "Jane Doe | Acme Blog",
"description": "Posts by Jane Doe.",
"canonical": "",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Jane Doe",
"description": "Posts by Jane Doe.",
"image": "",
"url": "https://acme.test/blog/author/jdoe",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/author/jdoe#webpage",
"url": "https://acme.test/blog/author/jdoe",
"name": "Jane Doe",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
}
}

View File

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

View File

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

View File

@@ -1,17 +1,35 @@
#!/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] [headings=strict]
// [cardlinks=N]
// 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, plus two checks of the
// heading and card rules (UI-SPEC L5, L6), both on the markup outside
// <script> elements: headings=strict wants exactly one <h1 and no skipped
// level in document order (h2 then h4 fails; going back up is fine);
// cardlinks=N wants at least one .journal-post-card and exactly N <a in
// each. 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 +63,47 @@ 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', 'headings', 'cardlinks']
/** The page markup without <script> elements (the payload, JSON-LD). @param {string} body */
function markup(body) {
return body.replace(/<script\b[\s\S]*?<\/script>/gi, '')
}
/** The heading rule's failures (UI-SPEC L6). @param {string} body @returns {string[]} */
export function headingFailures(body) {
const levels = [...markup(body).matchAll(/<h([1-6])(?=[\s>])/gi)].map((m) => Number(m[1]))
/** @type {string[]} */
const out = []
const h1 = levels.filter((l) => l === 1).length
if (h1 !== 1) out.push(`${h1} <h1 elements, want 1`)
let previous = 0
for (const level of levels) {
if (level > previous + 1) out.push(`heading h${level} after ${previous === 0 ? 'no heading' : `h${previous}`} skips a level (order ${levels.join(',')})`)
previous = level
}
return out
}
/** The anchors per post card. @param {string} body @returns {number[]} */
export function cardLinkCounts(body) {
const cards = markup(body).split(/<article class="journal-post-card"/).slice(1)
return cards.map((card) => (card.slice(0, card.indexOf('</article>')).match(/<a[\s>]/g) ?? []).length)
}
/**
* @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 +112,66 @@ 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`)
} else if (key === 'headings') {
if (value !== 'strict') out.push(`headings=${value}: want headings=strict`)
else out.push(...headingFailures(res.body))
} else if (key === 'cardlinks') {
const want = Number(value)
const counts = cardLinkCounts(res.body)
if (!Number.isInteger(want)) out.push(`cardlinks=${value}: want a number`)
else if (counts.length === 0) out.push('no .journal-post-card on the page')
else if (counts.some((n) => n !== want)) out.push(`post cards hold ${counts.join(',')} links, want ${want} each`)
}
}
return out
}
async function main() {
@@ -85,8 +186,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 +217,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')

67
tests/autoImports.test.ts Normal file
View File

@@ -0,0 +1,67 @@
// Nuxt auto-imports every export of app/utils and app/composables. A name
// exported from two of those files is a "Duplicated imports" warning on
// every build of an app with the layer (an interface re-exported next to
// its declaration counts), so each name has one home.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = new URL('..', import.meta.url).pathname
/** The names a module exports: declarations and export lists. */
function exportedNames(source: string): string[] {
const names: string[] = []
const decl = /^export\s+(?:declare\s+)?(?:async\s+)?(?:interface|type|function\*?|const|let|var|class|enum)\s+([A-Za-z_$][\w$]*)/gm
for (const m of source.matchAll(decl)) names.push(m[1]!)
const list = /^export\s+(?:type\s+)?\{([^}]*)\}/gm
for (const m of source.matchAll(list)) {
for (const part of m[1]!.split(',')) {
const name = part.trim().split(/\s+as\s+/).pop()?.replace(/^type\s+/, '').trim()
if (name) names.push(name)
}
}
return names
}
function autoImportedFiles(): string[] {
const out: string[] = []
for (const dir of ['app/utils', 'app/composables']) {
for (const f of readdirSync(join(root, dir))) {
if (/\.(ts|mts|js|mjs)$/.test(f) && !f.endsWith('.d.ts')) out.push(join(dir, f))
}
}
return out
}
test('exportedNames reads declarations and export lists', () => {
assert.deepEqual(
exportedNames("export interface A {}\nexport type B = 1\nexport function c() {}\nexport const d = 1\nexport type { E }\nexport { f as g, type H }\n"),
['A', 'B', 'c', 'd', 'E', 'g', 'H'],
)
})
test('a name re-exported next to its declaration is a duplicate', () => {
const files = {
'app/utils/srcset.ts': 'export interface JournalImageVariant { width: number }',
'app/utils/types.ts': "import type { JournalImageVariant } from './srcset.ts'\nexport type { JournalImageVariant }",
}
const seen = new Map<string, string[]>()
for (const [file, src] of Object.entries(files)) {
for (const name of exportedNames(src)) seen.set(name, [...(seen.get(name) ?? []), file])
}
assert.deepEqual(seen.get('JournalImageVariant'), ['app/utils/srcset.ts', 'app/utils/types.ts'])
})
test('every auto-imported name is exported from one file only', () => {
const homes = new Map<string, string[]>()
for (const file of autoImportedFiles()) {
for (const name of exportedNames(readFileSync(join(root, file), 'utf8'))) {
homes.set(name, [...(homes.get(name) ?? []), file])
}
}
const duplicates = [...homes].filter(([, files]) => files.length > 1).map(([name, files]) => `${name}: ${files.join(', ')}`)
assert.deepEqual(duplicates, [])
assert.ok(homes.has('JournalImageVariant'), 'JournalImageVariant is still auto-imported')
})

View File

@@ -183,3 +183,38 @@ test('getAuthor and listAuthorPosts encode the author and map 404 to null', asyn
assert.equal(await client.getAuthor('nobody'), null)
assert.equal(await client.listAuthorPosts('nobody'), null)
})
test('buildPostListQuery passes fields through only when set', () => {
assert.deepEqual(buildPostListQuery({ search: 'kurki', fields: 'suggest', perPage: 5 }), {
per_page: 5,
search: 'kurki',
fields: 'suggest',
})
assert.equal('fields' in buildPostListQuery({ search: 'kurki' }), false)
})
test('suggestPosts sends search, fields=suggest and per_page, no sort, with the signal', async () => {
const page = { data: [{ id: 1, title: 'Kurki', slug: 'kurki', excerpt: 'x', featured_image_url: null, published_at: null }], meta: { current_page: 1, last_page: 1, per_page: 5, total: 1 } }
const inits: unknown[] = []
const calls: Call[] = []
const fetcher = async (path: string, query?: Record<string, string | number>, init?: { signal?: AbortSignal }) => {
calls.push({ path, query })
inits.push(init)
return page
}
const signal = new AbortController().signal
const client = createJournalClient(fetcher)
assert.deepEqual(await client.suggestPosts(' kurki ', { signal }), page)
assert.deepEqual(calls[0], { path: '/posts', query: { per_page: 5, search: 'kurki', fields: 'suggest' } })
assert.equal((inits[0] as { signal: AbortSignal }).signal, signal)
await client.suggestPosts('kurki', { perPage: 99 })
assert.equal(calls[1]!.query!.per_page, JOURNAL_MAX_PER_PAGE)
})
test('suggestPosts sends nothing for a term under 3 characters', async () => {
const { fetcher, calls } = fakeFetcher({})
const res = await createJournalClient(fetcher).suggestPosts('ąę')
assert.equal(calls.length, 0)
assert.deepEqual(res.data, [])
assert.equal(res.meta.total, 0)
})

View File

@@ -131,7 +131,9 @@ test('the sinks expand after sanitizing and the payload transforms never expand'
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8')
for (const sink of ['../app/components/Journal/PostBody.vue', '../app/components/Journal/CategoryHeader.vue']) {
const source = read(sink)
assert.match(source, /expandEmbeds\(sanitizeJournalHtml\(/, `${sink} must render expandEmbeds(sanitizeJournalHtml(...))`)
// renderJournalBody sanitizes first, then adds only escaped link and
// image markup (htmlGate.test.ts), so it counts as the sanitizer here.
assert.match(source, /expandEmbeds\(\s*(sanitizeJournalHtml|renderJournalBody)\(/, `${sink} must render expandEmbeds(sanitizeJournalHtml(...)) or expandEmbeds(renderJournalBody(...))`)
assert.equal((source.match(/expandEmbeds\(/g) ?? []).length, 1, `${sink} expands once`)
}
for (const transform of ['../app/utils/postView.ts', '../app/journal-pages/CategoryPage.vue']) {

View File

@@ -55,8 +55,16 @@
"name": "hard-break",
"html": "<p>Pierwsza linia<br>Druga linia</p>"
},
{
"name": "link-rel",
"html": "<p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p>"
},
{
"name": "image-decorative",
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
},
{
"name": "combined",
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to &quot;grzyb odporności&quot;?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to &quot;grzyb odporności&quot;?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p><p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
}
]

View File

@@ -6,7 +6,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
import { gateJournalHtml, isSafeJournalHtml, renderJournalBody, sanitizeJournalHtml, type JournalBodyLinkSteps } from '../app/utils/htmlGate.ts'
// Signs that something executable or navigating survived.
const DANGER = [
@@ -223,9 +223,9 @@ const CORPUS = JSON.parse(
const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
test('the editor corpus has the entries the mirror rules are for', () => {
assert.equal(CORPUS.length, 15)
assert.equal(CORPUS.length, 17)
const all = CORPUS.map((e) => e.html).join('\n')
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to &quot;grzyb odporności&quot;?"']) {
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to &quot;grzyb odporności&quot;?"', 'rel="nofollow sponsored"', 'rel="ugc"', 'alt=""']) {
assert.ok(all.includes(part), `corpus lacks ${part}`)
}
})
@@ -333,3 +333,82 @@ 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>' })
})
// The editor's link rel (33-02, format_html.go): only nofollow, sponsored and
// ugc, single spaces; anything else drops the attribute. target never stays.
test('link rel keeps only the nofollow, sponsored and ugc tokens', () => {
for (const rel of ['nofollow', 'sponsored', 'ugc', 'nofollow sponsored', 'ugc nofollow sponsored']) {
const html = `<a rel="${rel}" href="https://partner.test/">x</a>`
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
}
for (const rel of ['noopener', 'nofollow noopener', 'NOFOLLOW', 'nofollow ugc', ' nofollow', 'external']) {
assert.equal(sanitizeJournalHtml(`<a rel="${rel}" href="https://partner.test/">x</a>`), '<a href="https://partner.test/">x</a>', rel)
}
assert.equal(sanitizeJournalHtml('<a rel="nofollow" target="_blank" href="https://partner.test/">x</a>'), '<a rel="nofollow" href="https://partner.test/">x</a>')
assert.equal(sanitizeJournalHtml('<p rel="nofollow">x</p>'), '<p>x</p>')
})
test('a decorative image keeps alt=""', () => {
assert.deepEqual(gateJournalHtml('<p><img src="/a.png" alt=""></p>'), { safe: true, html: '<p><img src="/a.png" alt="" /></p>' })
})
// renderJournalBody runs the UI-SPEC L7 order on the sanitized HTML.
function recordingSteps(calls: string[]): JournalBodyLinkSteps {
return {
autolink: (html, links, max, self) => {
calls.push(`autolink ${links.length} ${max} ${self} ${html}`)
return `${html}[a]`
},
applyOutbound: (html, opts) => {
calls.push(`outbound ${opts.siteHost} ${opts.newTab} ${opts.hint} ${html}`)
return `${html}[o]`
},
imageAttrs: (html) => {
calls.push(`images ${html}`)
return `${html}[i]`
},
}
}
test('renderJournalBody: demotion, auto-links, outbound rules, then image attributes', () => {
const calls: string[] = []
const out = renderJournalBody(
'<h1>T</h1><script>x</script>',
{ demoteH1: true, autolinks: [{ phrase: 'T', url: '/t' }], autolinkMax: 3, selfPath: '/self', outbound: { siteHost: 'acme.test', newTab: true, hint: 'h' } },
recordingSteps(calls),
)
assert.equal(out, '<h2>T</h2>[a][o][i]')
assert.deepEqual(calls, ['autolink 1 3 /self <h2>T</h2>', 'outbound acme.test true h <h2>T</h2>[a]', 'images <h2>T</h2>[a][o]'])
})
test('renderJournalBody: steps without CMS data are skipped, no steps means sanitize only', () => {
const calls: string[] = []
assert.equal(renderJournalBody('<h1>T</h1>', { autolinks: [], outbound: null }, recordingSteps(calls)), '<h1>T</h1>[i]')
assert.deepEqual(calls, ['images <h1>T</h1>'])
const none: string[] = []
assert.equal(renderJournalBody('<p>x</p>', { autolinks: [{ phrase: 'x', url: '/x' }], autolinkMax: 0 }, recordingSteps(none)), '<p>x</p>[i]')
assert.equal(renderJournalBody('<h1>T</h1><script>x</script>', { demoteH1: true }), '<h2>T</h2>')
assert.equal(renderJournalBody('', {}, recordingSteps([])), '')
})

View File

@@ -1,7 +1,7 @@
// ?page=N parsing, out-of-range detection and the page window.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { pageOutOfRange, paginationWindow, parsePageParam } from '../app/utils/pagination.ts'
import { pageFromRoute, pageOutOfRange, paginationWindow, parsePageParam } from '../app/utils/pagination.ts'
test('a missing page is page 1', () => {
assert.equal(parsePageParam(undefined), 1)
@@ -45,3 +45,12 @@ test('paginationWindow clips to the page count and handles empty input', () => {
assert.deepEqual(paginationWindow(1, 0), [])
assert.deepEqual(paginationWindow(1, 5, 0), [])
})
test('pageFromRoute reads the path-form :page param, else ?page', () => {
assert.equal(pageFromRoute({ params: { page: '3' }, query: { page: '5' } }), 3)
assert.equal(pageFromRoute({ params: {}, query: { page: '5' } }), 5)
assert.equal(pageFromRoute({ params: {}, query: {} }), 1)
assert.equal(pageFromRoute({ params: { page: '0' } }), null)
assert.equal(pageFromRoute({ params: { page: 'x' } }), null)
assert.equal(pageFromRoute({}), 1)
})

38
tests/postTitle.test.ts Normal file
View File

@@ -0,0 +1,38 @@
// The post title's element (UI-SPEC L6, D-50): journal.postTitleTag x the
// Journal H1 setting (content_h1_to_h2 on the post answer), so a post page
// keeps exactly one H1. 'auto' (and any other value, as the module reads
// the option) follows the setting: h1 while body H1s become H2s or when
// the answer does not carry the key (a backend before the setting), p
// while the body keeps its own H1; 'h1' and 'p' force the element.
import assert from 'node:assert/strict'
import test from 'node:test'
import { journalPostTitleTag } from '../app/utils/seo.ts'
import type { JournalPost } from '../app/utils/types.ts'
const settings = [true, false, undefined, null] as const
test('auto follows content_h1_to_h2: p only when it is false', () => {
const want = { true: 'h1', false: 'p', undefined: 'h1', null: 'h1' } as const
for (const s of settings) assert.equal(journalPostTitleTag('auto', s), want[String(s) as keyof typeof want], `auto x ${s}`)
})
test('h1 and p force the element whatever the setting', () => {
for (const s of settings) {
assert.equal(journalPostTitleTag('h1', s), 'h1', `h1 x ${s}`)
assert.equal(journalPostTitleTag('p', s), 'p', `p x ${s}`)
}
})
test('any other option value is auto', () => {
for (const option of [undefined, null, '', 'H1', 'h2', 'span', 1, true, {}]) {
assert.equal(journalPostTitleTag(option, true), 'h1', `${JSON.stringify(option)} x true`)
assert.equal(journalPostTitleTag(option, false), 'p', `${JSON.stringify(option)} x false`)
}
})
test('an answer without the key renders the title as the H1', () => {
const post = { title: 'Chaga', slug: 'chaga' } as Partial<JournalPost>
assert.equal(journalPostTitleTag('auto', (post as { content_h1_to_h2?: boolean }).content_h1_to_h2), 'h1')
const off = { ...post, content_h1_to_h2: false } as Partial<JournalPost>
assert.equal(journalPostTitleTag('auto', (off as { content_h1_to_h2?: boolean }).content_h1_to_h2), 'p')
})

View File

@@ -2,7 +2,7 @@
// source, translations and related post bodies stay out of it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { toPostView } from '../app/utils/postView.ts'
import { postNeighbours, toPostView } from '../app/utils/postView.ts'
import type { JournalPost, JournalPostDetail } from '../app/utils/types.ts'
function post(overrides: Partial<JournalPost> = {}): JournalPost {
@@ -53,3 +53,12 @@ test('source, translations and related post bodies stay out of the payload', ()
test('a missing body becomes an empty string', () => {
assert.equal(toPostView(detail({ content_html: null })).content_html, '')
})
test('postNeighbours prefers the in-category neighbours when the Journal answers one', () => {
const site = { previous_post: { slug: 'p', title: 'P' }, next_post: { slug: 'n', title: 'N' } }
assert.deepEqual(postNeighbours(site), { previous: site.previous_post, next: site.next_post, inCategory: false })
assert.deepEqual(postNeighbours({ ...site, previous_in_category: null, next_in_category: null }), { previous: site.previous_post, next: site.next_post, inCategory: false })
const cat = { slug: 'c', title: 'C' }
assert.deepEqual(postNeighbours({ ...site, previous_in_category: null, next_in_category: cat }), { previous: null, next: cat, inCategory: true })
assert.deepEqual(postNeighbours({}), { previous: null, next: null, inCategory: false })
})

View File

@@ -5,6 +5,8 @@ import {
DEFAULT_JOURNAL_ROUTES,
JOURNAL_ROUTE_NAMES,
fillJournalPath,
journalPagedRoutes,
journalPaginationSource,
normalizeJournalRoutes,
} from '../app/utils/routes.ts'
@@ -35,7 +37,7 @@ test('an override replaces one key and keeps the others', () => {
})
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false, search: '/szukaj' }
assert.deepEqual(normalizeJournalRoutes(input), input)
})
@@ -85,9 +87,17 @@ test('route names are stable', () => {
category: 'journal-category',
tag: 'journal-tag',
author: 'journal-author',
search: 'journal-search',
})
})
test('the search route is off by default and takes a plain path', () => {
assert.equal(DEFAULT_JOURNAL_ROUTES.search, false)
assert.equal(normalizeJournalRoutes().search, false)
assert.equal(normalizeJournalRoutes({ search: '/szukaj' }).search, '/szukaj')
assert.throws(() => normalizeJournalRoutes({ search: 'szukaj' }), /journal\.routes\.search/)
})
test('fillJournalPath encodes a value', () => {
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')
@@ -109,3 +119,45 @@ test('fillJournalPath drops a custom matcher and a modifier with the param', ()
test('fillJournalPath leaves unknown params empty', () => {
assert.equal(fillJournalPath('/autor/:author', {}), '/autor/')
})
test('journalPagedRoutes: the path form adds <listing>/<segment>/:page per segment', () => {
const routes = normalizeJournalRoutes({ search: '/search' })
assert.deepEqual(journalPagedRoutes(routes, { form: 'path', param: 'page', segments: { pl: 'strona', en: 'page' } }), [
{ key: 'index', name: 'journal-index-page-strona', path: '/blog/strona/:page(\\d+)' },
{ key: 'index', name: 'journal-index-page-page', path: '/blog/page/:page(\\d+)' },
{ key: 'category', name: 'journal-category-page-strona', path: '/blog/category/:category/strona/:page(\\d+)' },
{ key: 'category', name: 'journal-category-page-page', path: '/blog/category/:category/page/:page(\\d+)' },
{ key: 'tag', name: 'journal-tag-page-strona', path: '/blog/tag/:tag/strona/:page(\\d+)' },
{ key: 'tag', name: 'journal-tag-page-page', path: '/blog/tag/:tag/page/:page(\\d+)' },
{ key: 'author', name: 'journal-author-page-strona', path: '/blog/author/:author/strona/:page(\\d+)' },
{ key: 'author', name: 'journal-author-page-page', path: '/blog/author/:author/page/:page(\\d+)' },
])
})
test('journalPagedRoutes: the query form, no option and disabled views add nothing', () => {
const routes = normalizeJournalRoutes({ tag: false, author: false, category: false })
assert.deepEqual(journalPagedRoutes(routes, { form: 'query' }), [])
assert.deepEqual(journalPagedRoutes(routes, undefined), [])
assert.deepEqual(journalPagedRoutes(routes, { form: 'path' }), [{ key: 'index', name: 'journal-index-page-page', path: '/blog/page/:page(\\d+)' }])
assert.deepEqual(journalPagedRoutes(normalizeJournalRoutes({ index: '/news/' }), { form: 'path', param: 'p', segments: { pl: 'bad/seg' } })[0], {
key: 'index',
name: 'journal-index-page-p',
path: '/news/p/:page(\\d+)',
})
})
test('journalPaginationSource: the SEO module\'s resolved option, else the raw seo.pagination (L4 WR-03)', () => {
const resolved = { form: 'path', param: 'page', segments: { pl: 'strona', en: 'page' } }
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: { seo: { pagination: resolved } } }, seo: { pagination: { form: 'path' } } }), resolved)
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: {} }, seo: { pagination: { form: 'path' } } }), { form: 'path' })
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: { seo: { cms: true } } }, seo: { pagination: { form: 'query' } } }), { form: 'query' })
assert.equal(journalPaginationSource({}), undefined)
assert.equal(journalPaginationSource(null), undefined)
})
test('journalPagedRoutes: an app that set only form path gets the default pl and en segments through the resolved option', () => {
// vue-seo-layer's setup writes runtimeConfig.public.seo.pagination with its defaults
const nuxtOptions = { seo: { pagination: { form: 'path' } }, runtimeConfig: { public: { seo: { pagination: { form: 'path', param: 'page', segments: { pl: 'strona', en: 'page' } } } } } }
const paths = journalPagedRoutes(normalizeJournalRoutes({ category: false, tag: false, author: false }), journalPaginationSource(nuxtOptions)).map((r) => r.path)
assert.deepEqual(paths, ['/blog/strona/:page(\\d+)', '/blog/page/:page(\\d+)'])
})

203
tests/search.test.ts Normal file
View File

@@ -0,0 +1,203 @@
// Search helpers: term rules, diacritic-insensitive marking, plural forms,
// listbox keys, and the suggestion controller's debounce, abort and
// stale-answer handling under mocked timers. No network.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createSuggestController,
markTerms,
moveActive,
pluralForm,
searchTermFromQuery,
searchTermState,
type SuggestState,
} from '../app/utils/search.ts'
const marked = (text: string, q: string) =>
markTerms(text, q)
.map((s) => (s.mark ? `[${s.text}]` : s.text))
.join('')
test('searchTermFromQuery trims, takes the first repeated value and ignores non-strings', () => {
assert.equal(searchTermFromQuery(' borowik '), 'borowik')
assert.equal(searchTermFromQuery(['kurki', 'x']), 'kurki')
assert.equal(searchTermFromQuery(undefined), '')
assert.equal(searchTermFromQuery(null), '')
assert.equal(searchTermFromQuery([null]), '')
assert.equal([...searchTermFromQuery('ż'.repeat(500))].length, 200)
})
test('searchTermState counts characters, not UTF-16 units', () => {
assert.equal(searchTermState(''), 'empty')
assert.equal(searchTermState(' '), 'empty')
assert.equal(searchTermState('ab'), 'short')
assert.equal(searchTermState('żół'), 'ready')
assert.equal(searchTermState('ab', 2), 'ready')
})
test('markTerms ignores case and diacritics', () => {
assert.equal(marked('Sezon na grzybów zbieranie', 'grzybow'), 'Sezon na [grzybów] zbieranie')
assert.equal(marked('Łysiczki i ŁOSIE', 'lysiczki losie'), '[Łysiczki] i [ŁOSIE]')
assert.equal(marked('Kurki', 'KURKI'), '[Kurki]')
assert.equal(marked('Straße', 'strasse'), '[Straße]')
})
test('markTerms marks every occurrence and merges overlaps', () => {
assert.equal(marked('grzyb, grzyby, grzybów', 'grzyb'), '[grzyb], [grzyb]y, [grzyb]ów')
assert.equal(marked('borowik', 'bor borowik'), '[borowik]')
})
test('markTerms returns text segments only and leaves markup as text', () => {
const segs = markTerms('<b>kurki</b> & więcej', 'kurki')
assert.deepEqual(segs, [
{ text: '<b>', mark: false },
{ text: 'kurki', mark: true },
{ text: '</b> & więcej', mark: false },
])
})
test('markTerms skips one-letter words and handles empty input', () => {
assert.deepEqual(markTerms('a b c', 'a'), [{ text: 'a b c', mark: false }])
assert.deepEqual(markTerms('', 'kurki'), [])
assert.deepEqual(markTerms(null, 'kurki'), [])
assert.deepEqual(markTerms('kurki', ''), [{ text: 'kurki', mark: false }])
})
test('pluralForm gives the Polish one/few/many forms', () => {
assert.deepEqual(
[0, 1, 2, 4, 5, 12, 21, 22, 25, 102].map((n) => pluralForm('pl-PL', n)),
['many', 'one', 'few', 'few', 'many', 'many', 'many', 'few', 'many', 'few'],
)
assert.deepEqual([0, 1, 2].map((n) => pluralForm('en-US', n)), ['other', 'one', 'other'])
assert.equal(pluralForm('not a locale!!', 1), 'one')
})
test('moveActive wraps through the options', () => {
assert.equal(moveActive(-1, 3, 'ArrowDown'), 0)
assert.equal(moveActive(2, 3, 'ArrowDown'), 0)
assert.equal(moveActive(-1, 3, 'ArrowUp'), 2)
assert.equal(moveActive(0, 3, 'ArrowUp'), 2)
assert.equal(moveActive(1, 3, 'ArrowUp'), 0)
assert.equal(moveActive(0, 0, 'ArrowDown'), null)
assert.equal(moveActive(0, 3, 'Enter'), null)
})
type Row = { id: number; term: string }
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const flush = () => new Promise<void>((r) => setImmediate(r))
function harness(t: import('node:test').TestContext) {
t.mock.timers.enable({ apis: ['setTimeout'] })
const calls: Array<{ term: string; signal: AbortSignal; answer: ReturnType<typeof deferred<{ data: Row[]; meta: { total: number } }>> }> = []
const states: Array<SuggestState<Row>> = []
const ctl = createSuggestController<Row>({
fetch: (term, signal) => {
const answer = deferred<{ data: Row[]; meta: { total: number } }>()
calls.push({ term, signal, answer })
return answer.promise
},
onChange: (s) => states.push(s),
})
const last = () => states[states.length - 1]!
return { ctl, calls, last, tick: (ms: number) => t.mock.timers.tick(ms) }
}
test('suggest: one request per pause, after the debounce', async (t) => {
const h = harness(t)
h.ctl.input('bor')
h.tick(100)
h.ctl.input('boro')
h.tick(100)
h.ctl.input('borow')
h.tick(249)
assert.equal(h.calls.length, 0)
h.tick(1)
assert.deepEqual(
h.calls.map((c) => c.term),
['borow'],
)
assert.equal(h.last().status, 'loading')
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'borow' }], meta: { total: 7 } })
await flush()
assert.equal(h.last().status, 'done')
assert.deepEqual(h.last().rows, [{ id: 1, term: 'borow' }])
assert.equal(h.last().total, 7)
})
test('suggest: short terms send nothing and clear the rows', (t) => {
const h = harness(t)
h.ctl.input('bo')
h.tick(1000)
assert.equal(h.calls.length, 0)
assert.equal(h.last().status, 'idle')
})
test('suggest: a newer term aborts the running request and drops its answer', async (t) => {
const h = harness(t)
h.ctl.input('kurki')
h.tick(250)
h.ctl.input('kurkiw')
assert.equal(h.calls[0]!.signal.aborted, true)
h.tick(250)
h.calls[1]!.answer.resolve({ data: [{ id: 2, term: 'kurkiw' }], meta: { total: 1 } })
await flush()
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'kurki' }], meta: { total: 1 } })
await flush()
assert.deepEqual(h.last().rows, [{ id: 2, term: 'kurkiw' }])
assert.equal(h.last().term, 'kurkiw')
})
test('suggest: the spinner shows only after the delay', async (t) => {
const h = harness(t)
h.ctl.input('rydze')
h.tick(250)
h.tick(299)
assert.equal(h.last().loading, false)
h.tick(1)
assert.equal(h.last().loading, true)
h.calls[0]!.answer.resolve({ data: [], meta: { total: 0 } })
await flush()
assert.equal(h.last().loading, false)
assert.equal(h.last().status, 'done')
})
test('suggest: a failed request ends in error without throwing', async (t) => {
const h = harness(t)
h.ctl.input('opieńki')
h.tick(250)
h.calls[0]!.answer.reject(new Error('429'))
await flush()
assert.equal(h.last().status, 'error')
assert.deepEqual(h.last().rows, [])
})
test('suggest: the same term again sends no second request', async (t) => {
const h = harness(t)
h.ctl.input('maślaki')
h.tick(250)
h.ctl.input('maślaki ')
h.tick(1000)
assert.equal(h.calls.length, 1)
})
test('suggest: dispose aborts and ignores the answer', async (t) => {
const h = harness(t)
h.ctl.input('gąski')
h.tick(250)
h.ctl.dispose()
assert.equal(h.calls[0]!.signal.aborted, true)
const before = h.last()
h.calls[0]!.answer.resolve({ data: [{ id: 9, term: 'gąski' }], meta: { total: 1 } })
await flush()
assert.equal(h.last(), before)
})

View File

@@ -2,7 +2,7 @@
// list titles.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { absoluteUrl, htmlToText, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import { absoluteUrl, cmsHeadPresent, htmlToText, journalArticleMeta, journalItemList, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import type { JournalPost } from '../app/utils/types.ts'
function post(extra: Partial<JournalPost> = {}): JournalPost {
@@ -82,6 +82,16 @@ test('article times come from published_at and updated_at', () => {
assert.equal(bare.articleModifiedTime, undefined)
})
test('journalArticleMeta: og:type article and the post times, which the CMS never sets (L4 WR-02)', () => {
assert.deepEqual(journalArticleMeta(post()), {
ogType: 'article',
articlePublishedTime: '2024-06-12T06:00:00+00:00',
articleModifiedTime: '2024-06-13T06:00:00+00:00',
})
assert.deepEqual(journalArticleMeta(post({ published_at: null, updated_at: null })), { ogType: 'article' })
assert.deepEqual(journalArticleMeta(post({ updated_at: null })), { ogType: 'article', articlePublishedTime: '2024-06-12T06:00:00+00:00' })
})
test('absoluteUrl passes through absolute URLs and a missing site URL', () => {
assert.equal(absoluteUrl('a.png', 'https://x.test'), 'https://x.test/a.png')
assert.equal(absoluteUrl('HTTPS://y.test/a.png', 'https://x.test'), 'HTTPS://y.test/a.png')
@@ -92,3 +102,44 @@ 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')
})
test('journalItemList: one ListItem per linked post, absolute URLs, positions continuing across pages', () => {
assert.deepEqual(journalItemList('https://acme.test/blog?page=2', 'https://acme.test', ['/blog/a', null, '/blog/b'], 10), {
'@type': 'ItemList',
'@id': 'https://acme.test/blog?page=2#itemlist',
itemListElement: [
{ '@type': 'ListItem', position: 10, url: 'https://acme.test/blog/a' },
{ '@type': 'ListItem', position: 11, url: 'https://acme.test/blog/b' },
],
})
assert.equal(journalItemList('https://acme.test/blog', 'https://acme.test', []), null)
assert.equal(journalItemList('https://acme.test/blog', 'https://acme.test', [null, '']), null)
assert.equal(journalItemList('https://acme.test/blog#x', 'https://acme.test', ['/p'])?.['@id'], 'https://acme.test/blog#itemlist')
})

View File

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

View File

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

45
tests/srcset.test.ts Normal file
View File

@@ -0,0 +1,45 @@
// srcset strings of featured images from the API's variants (D-85, D-71).
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { imageSrcsets } from '../app/utils/srcset.ts'
const v = (width: number, height: number) => ({
width,
height,
url: `/u/thumb_1_${width}_${height}_0_0_exact.jpg`,
webp_url: `/u/thumb_1_${width}_${height}_0_0_exact.webp`,
source_width: 1000,
source_height: 600,
})
test('WebP and original srcsets from the variants, narrowest first, with the original wider than every variant', () => {
const sets = imageSrcsets([v(800, 480), v(480, 288)], '/u/cover.jpg')
assert.deepEqual(sets, {
webp: '/u/thumb_1_480_288_0_0_exact.webp 480w, /u/thumb_1_800_480_0_0_exact.webp 800w',
original: '/u/thumb_1_480_288_0_0_exact.jpg 480w, /u/thumb_1_800_480_0_0_exact.jpg 800w, /u/cover.jpg 1000w',
width: 1000,
height: 600,
})
})
test('the original is left out when a variant is as wide, and an explicit width wins', () => {
assert.equal(imageSrcsets([v(480, 288)], '/u/cover.jpg', 480)?.original, '/u/thumb_1_480_288_0_0_exact.jpg 480w')
assert.equal(imageSrcsets([v(480, 288)], null)?.original, '/u/thumb_1_480_288_0_0_exact.jpg 480w')
})
test('no variants give no srcset', () => {
assert.equal(imageSrcsets([], '/u/cover.jpg'), null)
assert.equal(imageSrcsets(undefined, '/u/cover.jpg'), null)
assert.equal(imageSrcsets(null), null)
assert.equal(imageSrcsets([{ width: 0, height: 1, url: 'a', webp_url: 'b' }, { width: 480 }], '/x'), null)
})
test('a width appears once, and without the source size the largest variant reserves the space', () => {
const plain = [{ width: 480, height: 300, url: '/a.jpg', webp_url: '/a.webp' }, { width: 480, height: 300, url: '/b.jpg', webp_url: '/b.webp' }]
assert.deepEqual(imageSrcsets(plain, '/o.jpg'), { webp: '/a.webp 480w', original: '/a.jpg 480w', width: 480, height: 300 })
})
test('spaces and commas in addresses cannot split a candidate', () => {
const sets = imageSrcsets([{ width: 480, height: 300, url: '/a b,c.jpg', webp_url: '/a b,c.webp' }])
assert.equal(sets?.webp, '/a%20b%2Cc.webp 480w')
})