29 Commits

Author SHA1 Message Date
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
Jakub Zych
e0cc87118c fix: keep the site cache purge token out of the page payload (NUXT_CACHE_PURGE_TOKEN)
nuxt-site-config copies every NUXT_SITE_* variable into the public site
config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as
cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied).

- The purge settings move to runtimeConfig cachePurge.token
  (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
  (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix.
- The old siteCache.* names keep working for one release: a non-empty
  new token wins, either allowProxied flag turns it on, and one
  deprecation warning per process names the variables (never a value).
- The shared Nitro plugin server/plugins/site-cache-purge.ts strips
  cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on
  site-config:init, so a site on the old names leaks nothing either; it
  does nothing without nuxt-site-config.
- The shared lib, route and plugin and tests/sitePurge.test.ts are
  byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer,
  with three sha256 pins.
- README: new names, the deprecation, the stripping and the advice to
  rotate a token ever set under the old name.
2026-10-20 20:49:34 +02:00
Jakub Zych
f5d7aacf11 feat(19.3-06): purge route accepts proxied requests on siteCache.allowProxied (WR-03)
- runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED, default
  false) lets the shared purge route accept a request that came through a
  TLS-terminating proxy; the bearer token is then the only gate
- shared lib, route and spec stay byte-identical across the three layers,
  with updated sha256 pins and two new sitePurge specs

- version 0.5.0
2026-10-15 22:42:09 +02:00
Jakub Zych
5936f6611d test(19.3-05): site purge route handler specs and edge cases 2026-10-15 19:56:16 +02:00
Jakub Zych
3e0b37756e feat(19.3-04): shared site cache purge route POST /api/__site__/cache/purge
- server/lib/siteCachePurge.ts and the route, byte for byte from
  vue-faq-layer (sha256 pinned in tests/sitePurge.test.ts)
- runtimeConfig.siteCache.purgeToken (NUXT_SITE_CACHE_PURGE_TOKEN) and the
  '/api/__site__/**': { cache: false } route rule
- /api/__journal__/cache/purge, app/utils/siteCache.ts and
  journal.cachePurgeToken unchanged, documented as aliases for one release
- version 0.4.0
2026-10-15 18:35:47 +02:00
25e9bfe8da chore: release v0.3.0 2026-10-10 19:28:05 +02:00
03a90e952d fix: forward the visitor address on route-cached renders
Nitro's cached handler renders from a header-less copy of the request, so
under swr route rules useJournalApi, /rss.xml and the sitemap source
forwarded only the Nuxt peer and every cache miss shared one Journal API
rate-limit bucket (deferred in Phase 16). A Nitro plugin records
x-forwarded-for plus the peer in event.context.journalForwardedFor on the
request hook; the three callers forward it. Same key as the host app's
workaround, which keeps working. The acme SSR smoke's x-forwarded-for check
on the now route-cached playground is the test.
2026-10-10 18:40:25 +02:00
4f55511ece test: site cache purge authorization, purge, body parsing and revalidation
- exact names: purge auth disabled, proxied before the token, missing or
  wrong bearer; purge removes every route cache entry and only those
- parsePurgeEvent kinds, control characters, 200-character cap, non-objects
- privateRevalidate with and without max-age, the 304 form, static values
2026-10-10 18:36:13 +02:00
0a01bfeae4 feat: browsers revalidate route-cached pages; document the site cache purge
- privateRevalidate sets max-age=0 on cache-control values with s-maxage
- server/plugins/journal-revalidate.ts wraps res.setHeader per request, so
  Nitro's 200 and h3's 304 answers both carry it
- purge-check: max-age=0 on 200 and 304, purged _payload.json, a second
  purge after re-caching, X-Real-IP refused, 404 on a server without a token
- README: cachePurgeToken row and a Site cache purge section
2026-10-10 18:27:12 +02:00
a03e076c62 feat: token-protected site cache purge route
- POST /api/__journal__/cache/purge removes every Nitro route-cache entry
- 404 without a 32-character token or behind a proxy, 401 on a wrong bearer
  token (constant-time over sha256 digests)
- routeRules keep /api/__journal__/** out of the route cache
- runtimeConfig.journal.cachePurgeToken (NUXT_JOURNAL_CACHE_PURGE_TOKEN)
- fixture API reload(); testing/purge-check.mjs proves a cached page shows
  a change after a purge; the playground caches with swr 300
2026-10-10 18:19:58 +02:00
782aa4da7d test: pin embed expansion order and the editor policy mirror
- expansion runs last at the sinks, never in payload transforms
- only the exact placeholder serialization expands; stable across calls
- the Go policy negatives are stripped the same way in the mirror
2026-10-10 16:03:08 +02:00
75 changed files with 4482 additions and 2245 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> <template>
<header class="playground-header">
<JournalSearchBox />
</header>
<div v-if="layoutTrail" class="playground-trail"><SeoBreadcrumbs /></div>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>

View File

@@ -3,7 +3,17 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
extends: ['..'], extends: ['..'],
compatibilityDate: '2025-07-15', 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: { i18n: {
strategy: 'prefix_except_default', strategy: 'prefix_except_default',
defaultLocale: 'en', defaultLocale: 'en',

187
README.md
View File

@@ -1,28 +1,36 @@
# vue-journal-layer # 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 ```bash
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0 pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.8.0
``` ```
## Overview ## Overview
The layer is consumed with `extends: ['vue-journal-layer']`. It reads content only from the anonymous GET routes of the Journal API (`/_journal/api/v1`) and never calls a write route. Pages render on the server (SSR), so every URL answers with full HTML. The layer is 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. 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. 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 ## Features
- **Post list** (`journal-index`): paginated with `?page=N`. A non-numeric page or a page past the last one answers 404. - **Post list** (`journal-index`): paginated with `?page=N`, or `<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. - **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 above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`. - **Category** (`journal-category`): name, description and the category's rich HTML content (the pillar text, on page 1 only; the description shows on every page) above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL. - **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. - **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`. - **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).
- **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`. - **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)). - **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. - **i18n**: English and Polish UI strings, registered only for the locales the app enables.
@@ -53,8 +61,6 @@ The Journal Blog Editor never stores an iframe. It stores a placeholder:
export default defineNuxtConfig({ export default defineNuxtConfig({
extends: ['vue-journal-layer'], extends: ['vue-journal-layer'],
site: { url: 'https://blog.acme.test', name: 'Acme Blog' },
i18n: { i18n: {
strategy: 'prefix_except_default', strategy: 'prefix_except_default',
defaultLocale: 'en', defaultLocale: 'en',
@@ -77,9 +83,9 @@ export default defineNuxtConfig({
}) })
``` ```
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `@nuxtjs/seo` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route. The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `sanitize-html` and `vue-seo-layer` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route.
Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` at it. Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` (and vue-seo-layer's `NUXT_SEO_API_BASE`) at it. A site without the SEO plugin sets `seo: { cms: false }`; its pages then keep the journal's fallback titles and descriptions.
## Configuration ## Configuration
@@ -92,13 +98,17 @@ Options under the `journal` key of `nuxt.config`:
| `journal.routes.category` | `/blog/category/:category` | Category path; must contain `:category`, or `false` | | `journal.routes.category` | `/blog/category/:category` | Category path; must contain `:category`, or `false` |
| `journal.routes.tag` | `/blog/tag/:tag` | Tag path; must contain `:tag`, or `false` | | `journal.routes.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.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.index` | `9` | Posts per list page (the API caps at 30) |
| `journal.perPage.category` | `9` | Posts per category page | | `journal.perPage.category` | `9` | Posts per category page |
| `journal.perPage.tag` | `9` | Posts per tag page | | `journal.perPage.tag` | `9` | Posts per tag page |
| `journal.perPage.author` | `9` | Posts per author 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.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.relatedCount` | `3` | Related posts under a post |
| `journal.rss` | `true` | Serve and link `/rss.xml` | | `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>`. A route path must be `false` or start with `/`; a missing param fails the build with an error naming `journal.routes.<key>`.
@@ -106,19 +116,86 @@ Runtime config, overridable by environment variables at start:
| Key | Env | Default | Meaning | | 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.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.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. 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 (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.
**Guards** (both routes).
- POST only.
- `404` when the route's token holds fewer than 32 characters, so an app without a token has no purge route.
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy. The shared route makes one exception, below.
- `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }` and `'/api/__site__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
**What it removes.** Every entry of Nitro's route cache (the `nitro:routes` group of the `cache` storage): pages, their `_payload.json` and feeds. Other cache groups stay. One save can change many pages (the menu lists every category on every page, and the home, category, author and related-post lists show posts), and Nitro keys entries by a hash of the path, so a full purge is the reliable choice; it costs one fresh render per page on its next view.
**Browser revalidation.** Nitro answers a route-cached page with `s-maxage=<n>, stale-while-revalidate`, which gives browsers no `max-age`, so they guess a freshness lifetime from `Last-Modified`; its 304 answer carries `public, max-age=<n>, s-maxage=<n>`. Either way a browser could keep its copy past a purge. The layer's Nitro plugin `server/plugins/journal-revalidate.ts` rewrites every `cache-control` value that carries `s-maxage` to `max-age=0` for browsers (`privateRevalidate` in `app/utils/siteCache.ts`), so a browser asks on each view and gets a cheap 304 while the page is unchanged. Shared caches keep `s-maxage`. Static assets carry no `s-maxage` and are untouched.
**Deployment.**
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files. Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config publishes those in every page's payload.
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__site__/cache/purge`), not through the public site.
- A backend on another host calls an https `site_cache.purge_url` through the site's TLS-terminating proxy, which adds forwarding headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`) and the shared route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret, and let the proxy forward POST to this one path only. Unset, proxied requests stay refused. The old `/api/__journal__/cache/purge` route never accepts them.
- The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks.
- The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries.
**Site config stripping (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
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.6.x (a non-empty new token wins) and are removed in the next minor release.
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so up to 0.5 a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.6 the layer strips both keys from the site config even under the old names.
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
## Search
Search is off by default: an app that does not set `journal.routes.search` gets no search page, and `JournalSearchBox` renders nothing. To turn it on:
```ts
journal: { routes: { search: '/szukaj' } },
```
The Journal API answers `GET /posts?search=` from Typesense in relevance order when the backend has it on, and from SQL otherwise; the layer sends no `sort` with a search, so the API's order is kept. Terms under 3 characters are never sent: the API would ignore them.
**Results page** (`journal-search`). Reads `?q=` and `?page=N`, renders the matches with `JournalPostList` and `JournalPagination` under a search form and a result count (plural forms through `Intl.PluralRules`: Polish one, few and many), and shows its own message for an empty term, a term under 3 characters and no results. It is fetched with `useAsyncData` on the server, so it works without JS. The title includes the term; the term is rendered as text everywhere.
**Header box** (`<JournalSearchBox />`). An icon button opens a search input; once the term has 3 or more characters, a suggestion request (`GET /posts?search=…&fields=suggest&per_page=5`) goes out 250 ms after the last keystroke, and a newer term aborts the running request and drops its answer. Up to `limit` suggestions show the title, the excerpt and a small featured image, with the query words in `<mark>` ignoring case and diacritics ("grzybow" marks "grzybów"); the marking splits text and never inserts API HTML. Under them, "see all results" links to the search page with `q`. Enter opens the active suggestion, or the search page when none is active. The input is a WAI-ARIA combobox: ArrowDown and ArrowUp move through the options (`aria-activedescendant`), Escape closes the box and returns focus to the button, and a click outside closes it. A spinner shows after 300 ms, "no results" when nothing matched, and a quiet message when the request failed. Without JS the box is a plain GET form to the search page, always shown. Reopening selects the previous term.
| Prop | Default | Meaning |
|------|---------|---------|
| `limit` | `5` | Suggestions shown |
| `minLength` | `3` | Shortest term that sends a request |
| `debounce` | `250` | Wait after the last keystroke, in ms |
| `placeholder`, `buttonLabel`, `seeAllLabel`, `emptyLabel` | the `journal.search.*` strings | Text overrides |
The `icon` slot replaces the magnifier. The root gets `journal-search--open` while open and `journal-search--enhanced` once mounted. The panel (`.journal-search__panel`) is inline next to the button, with the suggestions (`.journal-search__dropdown`) positioned below its left edge; an app makes it a full-width panel under its header, or anchors the suggestions on the right (`left: auto; right: 0`), in its own CSS.
**Caching, robots and sitemap.** Nitro's route cache keys by path, so under an app rule such as `'/**': { swr: 300 }` every `?q=` would get the first query's cached page. The journal module therefore adds `{ cache: false, robots: 'noindex, follow' }` route rules for the search path and its locale-prefixed copies (`/pl/szukaj`), and 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 ## Theming
Every visual value is a token in `app/assets/css/journal-tokens.css`: 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` - 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)
- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height` - 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` - Spacing: `--journal-space-1` to `--journal-space-6`
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg` - Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
- Layout: `--journal-content-width`, `--journal-wide-width`, `--journal-card-min`, `--journal-image-ratio` - Layout: `--journal-content-width`, `--journal-wide-width`, `--journal-card-min`, `--journal-image-ratio`
@@ -132,22 +209,66 @@ 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 ## 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. 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. 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
- **`@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 ## Local development
```bash ```bash
@@ -162,14 +283,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 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 through its `vue-seo-layer` dependency, which points at the local checkout (`link:../vue-seo-layer`) during development. A tag cut with that link breaks every git install: the path resolves next to the pnpm store entry, not to a package. Before tagging any content layer:
1. Tag vue-seo-layer first, then in each content layer replace the link with the tag pin (`"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"`) 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 ## 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, and pl/en key and placeholder parity. They need no build, network or database. - `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the H1 demotion and the body pipeline order, the 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 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). - `scripts/check-git-install.sh <tag>` installs the layer from the git tag 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>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`. - `testing/smoke-ssr.mjs --output <.output> --fixtures <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/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`. - `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 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 ```bash
pnpm build:playground pnpm build:playground
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt pnpm smoke:ssr
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
``` ```
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host, enables the search page at `/search` and places `JournalSearchBox` above the layout, so the smoke covers the search page, its cache bypass 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; 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 { .journal-post__title {
margin: 0 0 var(--journal-space-3); 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 { .journal-post-meta {
@@ -84,6 +90,10 @@
margin: 0 0 var(--journal-space-4); margin: 0 0 var(--journal-space-4);
} }
.journal-featured-image picture {
display: block;
}
.journal-featured-image img { .journal-featured-image img {
display: block; display: block;
width: 100%; width: 100%;
@@ -197,7 +207,10 @@
color: var(--journal-color-muted); 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 { .journal-post-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--journal-space-2); gap: var(--journal-space-2);
@@ -207,6 +220,11 @@
background: var(--journal-color-bg); 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 { .journal-post-card .journal-featured-image {
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2); margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
} }
@@ -224,6 +242,13 @@
text-decoration: none; text-decoration: none;
} }
.journal-post-card__title a::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
.journal-post-card__date { .journal-post-card__date {
color: var(--journal-color-muted); color: var(--journal-color-muted);
font-size: 0.85em; font-size: 0.85em;
@@ -233,10 +258,6 @@
margin: 0; margin: 0;
} }
.journal-post-card__more {
margin-top: auto;
}
.journal-related { .journal-related {
margin-top: var(--journal-space-5); margin-top: var(--journal-space-5);
} }
@@ -273,6 +294,12 @@
color: var(--journal-color-on-accent); 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 */ /* Author */
.journal-author-card { .journal-author-card {
margin-bottom: var(--journal-space-5); margin-bottom: var(--journal-space-5);
@@ -287,3 +314,187 @@
margin-top: var(--journal-space-4); margin-top: var(--journal-space-4);
font-size: 0.9em; 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-link-hover: #0550ae; /* hovered links */
--journal-color-accent: #0969da; /* current page, focus ring */ --journal-color-accent: #0969da; /* current page, focus ring */
--journal-color-on-accent: #ffffff; /* text on the accent colour */ --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 */ /* Type */
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */ --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-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
--journal-font-size-base: 1.0625rem; /* body size */ --journal-font-size-base: 1.0625rem; /* body size */
--journal-line-height: 1.7; /* body leading */ --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 */ /* Spacing scale */
--journal-space-1: 0.25rem; /* hairline gaps */ --journal-space-1: 0.25rem; /* hairline gaps */

View File

@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
// Heading of a category page: name, description and the category's rich // 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 // payload keeps embed placeholders; expandEmbeds turns them into iframes as
// the last step, after sanitizing, and its output is never sanitized again. // the last step, after sanitizing, and its output is never sanitized again.
import type { JournalCategory } from '../../utils/types.ts' import type { JournalCategory } from '../../utils/types.ts'
@@ -8,7 +11,9 @@ import { expandEmbeds } from '../../utils/embeds.ts'
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts' import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number }>() 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> </script>
<template> <template>

View File

@@ -1,11 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
// A post's featured image. The intrinsic width/height reserve space before // A post's featured image. With the API's variants (D-85) it renders a
// the image loads; the rendered size and crop come from --journal-image-ratio. // <picture>: a WebP <source> and the <img> in the original format, both
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>() // 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> </script>
<template> <template>
<figure v-if="src" class="journal-featured-image"> <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> </figure>
</template> </template>

View File

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

View File

@@ -3,14 +3,74 @@
// sanitizer, which removes what the policy does not allow and keeps the rest. // 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 // The page passes an already sanitized body (with any embed placeholder still
// a placeholder); sanitizing again here guards a caller that does not, and // 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, // turns the Blog Editor's embed placeholders into privacy-enhanced iframes,
// and its output is never sanitized again (the sanitizer would strip them). // and its output is never sanitized again (the sanitizer would strip them).
import { expandEmbeds } from '../../utils/embeds.ts' 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 props = defineProps<{ html: string | null | undefined; demoteH1?: boolean }>()
const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html)))
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> </script>
<template> <template>

View File

@@ -1,26 +1,31 @@
<script setup lang="ts"> <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 type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.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 { localeProperties } = useI18n()
const links = useJournalLinks() const links = useJournalLinks()
const href = computed(() => links.postLink(props.post.slug)) const href = computed(() => links.postLink(props.post.slug))
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en')) const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const heading = computed(() => (props.headingLevel === 3 ? 'h3' : 'h2'))
</script> </script>
<template> <template>
<article class="journal-post-card"> <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"> <div v-if="post.featured_image_url" class="journal-post-card__image">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" /> <JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 48rem) 33vw, 100vw" />
</NuxtLink> </div>
<h2 class="journal-post-card__title"> <component :is="heading" class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink> <NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
<template v-else>{{ post.title }}</template> <template v-else>{{ post.title }}</template>
</h2> </component>
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time> <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> <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> </article>
</template> </template>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
// A grid of post cards, or the empty-state text when there are none. // 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' import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[]; empty?: string }>() defineProps<{ posts: JournalPost[]; empty?: string; headingLevel?: 2 | 3 }>()
</script> </script>
<template> <template>
<div class="journal-post-list"> <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> <p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
</div> </div>
</template> </template>

View File

@@ -11,9 +11,15 @@ const links = useJournalLinks()
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en')) const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const authorName = computed(() => { const authorName = computed(() => {
const a = props.post.author 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> </script>
<template> <template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// Related posts under a post. The page passes the API's related_posts, or // 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' import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[] }>() defineProps<{ posts: JournalPost[] }>()
@@ -9,6 +10,6 @@ defineProps<{ posts: JournalPost[] }>()
<template> <template>
<section v-if="posts.length" class="journal-related"> <section v-if="posts.length" class="journal-related">
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2> <h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
<JournalPostList :posts="posts" /> <JournalPostList :posts="posts" :heading-level="3" />
</section> </section>
</template> </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

@@ -2,7 +2,7 @@
// the internal base (runtimeConfig.journal.apiBase) and forwards the visitor's // the internal base (runtimeConfig.journal.apiBase) and forwards the visitor's
// x-forwarded-for, plus the address of the peer that reached Nuxt, so the Go // x-forwarded-for, plus the address of the peer that reached Nuxt, so the Go
// rate limiter keeps one bucket per visitor instead of one for the Nuxt // rate limiter keeps one bucket per visitor instead of one for the Nuxt
// server. In the browser it calls the same-origin public base. The base is // server, also on route-cached renders. In the browser it calls the same-origin public base. The base is
// always runtime config, never request input. // always runtime config, never request input.
import { createJournalClient, type JournalClient, type JournalFetcher } from '../utils/client.ts' import { createJournalClient, type JournalClient, type JournalFetcher } from '../utils/client.ts'
@@ -12,15 +12,19 @@ export function useJournalApi(): JournalClient {
if (import.meta.server) { if (import.meta.server) {
const base = (config.journal as { apiBase: string }).apiBase const base = (config.journal as { apiBase: string }).apiBase
// Under route caching the request copy has no headers; the address the
// layer's server/plugins/journal-client-address.ts recorded comes first.
const event = useRequestEvent()
const recorded = event?.context.journalForwardedFor as string | undefined
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for'] const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
const peer = useRequestEvent()?.node?.req?.socket?.remoteAddress const peer = event?.node?.req?.socket?.remoteAddress
const forwarded = [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ') 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 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) return createJournalClient(fetcher)
} }
const base = (config.public.journal as { apiBase: string }).apiBase 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) 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 }), tagLink: (slug: string) => link('tag', { tag: slug }),
authorLink: (slug: string) => link('author', { author: slug }), authorLink: (slug: string) => link('author', { author: slug }),
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined), 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 // journal-author: an author and their published posts, paginated with
// ?page=N. {author} is the author slug, or a login with published posts. An // ?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 // unknown author or an out-of-range page is a real 404. When the visitor came
// in by login, the canonical points at the slug URL. // in by login, the canonical points at the slug URL (a CMS canonical wins).
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts' import { pageFromRoute, pageOutOfRange } from '../utils/pagination.ts'
import { absoluteUrl, listTitle } from '../utils/seo.ts' import { absoluteUrl, journalSiteOrigin, listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts' import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts' import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath }) definePageMeta({ key: (route) => route.fullPath })
@@ -13,12 +13,11 @@ definePageMeta({ key: (route) => route.fullPath })
const route = useRoute() const route = useRoute()
const api = useJournalApi() const api = useJournalApi()
const links = useJournalLinks() const links = useJournalLinks()
const site = useSiteConfig()
const { t } = useI18n() const { t } = useI18n()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const param = String(route.params.author ?? '') 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 }) if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-author-${param}-${page}`, async () => { 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 name = `${author.first_name} ${author.last_name}`.trim() || author.slug
const title = listTitle(t('journal.author.title', { name }), page, t('journal.pagination.pageTitle', { page })) const title = listTitle(t('journal.author.title', { name }), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title, ogType: 'profile' }) useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
if (author.slug !== param) { if (author.slug !== param) {
const path = links.authorLink(author.slug) const path = links.authorLink(author.slug)
if (path) { 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' }] }) useHead({ link: [{ rel: 'canonical', href, key: 'canonical' }] })
} }
} }
@@ -52,6 +53,7 @@ if (author.slug !== param) {
<template> <template>
<section v-if="data" class="journal-author"> <section v-if="data" class="journal-author">
<SeoBreadcrumbs v-if="crumbs" />
<JournalAuthorCard :author="data.author" :page="page" /> <JournalAuthorCard :author="data.author" :page="page" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.author.empty')" /> <JournalPostList :posts="data.posts.data" :empty="$t('journal.author.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" /> <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. // journal-category: one category's header and rich content above its posts.
// The slug resolves against GET /categories?include_empty=1, so an empty // 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 // 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 { 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 { 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' import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath }) definePageMeta({ key: (route) => route.fullPath })
@@ -17,7 +20,7 @@ const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const { t } = useI18n() const { t } = useI18n()
const slug = String(route.params.category ?? '') 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 }) if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => { 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. // Only this category reaches the payload, with its content already sanitized.
const { children: _children, ...category } = found const { children: _children, ...category } = found
return { 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) }, 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 title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
const description = data.value.category.description ?? undefined const description = data.value.category.description ?? undefined
useSeoMeta({ title, ogTitle: title, description, ogDescription: description }) useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script> </script>
<template> <template>
<section v-if="data" class="journal-category"> <section v-if="data" class="journal-category">
<SeoBreadcrumbs v-if="crumbs" />
<JournalCategoryHeader :category="data.category" :page="page" /> <JournalCategoryHeader :category="data.category" :page="page" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" /> <JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" /> <JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
// journal-index: the post list, paginated with ?page=N. A non-numeric page // journal-index: the post list, paginated with ?page=N (or the path form).
// or a page past the last one is a real 404. // A non-numeric page or a page past the last one 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 { 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' import type { JournalModuleOptions } from '../utils/types.ts'
// Re-run setup (and its 404 checks) on every query change. // 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 cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const { t } = useI18n() 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 }) if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData( 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 })) const title = listTitle(t('journal.index.title'), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title }) useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.data, () => data.value?.meta)
</script> </script>
<template> <template>
<section v-if="data" class="journal-index"> <section v-if="data" class="journal-index">
<SeoBreadcrumbs v-if="crumbs" />
<JournalIndexHeader :page="page" /> <JournalIndexHeader :page="page" />
<JournalPostList :posts="data.data" /> <JournalPostList :posts="data.data" />
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" /> <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. // 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 // Related posts fall back to the latest posts when the API has none (it
// returns none for a post without a category). // returns none for a post without a category).
import { postSeoMeta } from '../utils/seo.ts' //
import { toPostView } from '../utils/postView.ts' // One H1 per page in both Journal H1 modes (UI-SPEC L6): while the Journal
import { toListPost } from '../utils/view.ts' // 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' import type { JournalModuleOptions } from '../utils/types.ts'
const route = useRoute() const route = useRoute()
@@ -37,33 +48,58 @@ if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true }) 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> </script>
<template> <template>
<article v-if="post" class="journal-post"> <article v-if="post" class="journal-post">
<SeoBreadcrumbs v-if="crumbs" />
<header class="journal-post__header"> <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" /> <JournalPostMeta :post="post" />
</header> </header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager /> <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" /> <JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings"> <nav v-if="siblings.previous || siblings.next" class="journal-post__siblings">
<NuxtLink <NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)" v-if="siblings.previous && links.postLink(siblings.previous.slug)"
:to="links.postLink(post.previous_post.slug)!" :to="links.postLink(siblings.previous.slug)!"
rel="prev" rel="prev"
class="journal-post__previous" 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>
<NuxtLink <NuxtLink
v-if="post.next_post && links.postLink(post.next_post.slug)" v-if="siblings.next && links.postLink(siblings.next.slug)"
:to="links.postLink(post.next_post.slug)!" :to="links.postLink(siblings.next.slug)!"
rel="next" rel="next"
class="journal-post__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> </NuxtLink>
</nav> </nav>
<JournalRelatedPosts :posts="post.related_posts" /> <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"> <script setup lang="ts">
// journal-tag: posts with one tag, paginated with ?page=N. An unknown tag or // journal-tag: posts with one tag, paginated with ?page=N. An unknown tag or
// an out-of-range page is a real 404. // 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 { 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' import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath }) definePageMeta({ key: (route) => route.fullPath })
@@ -14,7 +14,7 @@ const { t } = useI18n()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const slug = String(route.params.tag ?? '') 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 }) if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-tag-${slug}-${page}`, async () => { 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 heading = t('journal.tag.title', { name: data.value.tag.name })
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page })) const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title }) useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script> </script>
<template> <template>
<section v-if="data" class="journal-tag"> <section v-if="data" class="journal-tag">
<SeoBreadcrumbs v-if="crumbs" />
<header class="journal-index-header"> <header class="journal-index-header">
<h1 class="journal-index-header__title">{{ title }}</h1> <h1 class="journal-index-header__title">{{ title }}</h1>
</header> </header>

View File

@@ -9,6 +9,7 @@ import type {
JournalPage, JournalPage,
JournalPost, JournalPost,
JournalPostDetail, JournalPostDetail,
JournalPostSuggestion,
JournalTag, JournalTag,
} from './types.ts' } from './types.ts'
@@ -18,8 +19,18 @@ export const JOURNAL_MAX_PER_PAGE = 30
/** The Journal API's per_page default. */ /** The Journal API's per_page default. */
export const JOURNAL_DEFAULT_PER_PAGE = 9 export const JOURNAL_DEFAULT_PER_PAGE = 9
/** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */ /** Search terms shorter than this are ignored by the API, so the layer never sends them. */
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown> 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 { export interface PostListOptions {
page?: number page?: number
@@ -30,6 +41,13 @@ export interface PostListOptions {
category?: string category?: string
tag?: string tag?: string
search?: string search?: string
/** "suggest" asks for JournalPostSuggestion rows instead of full posts. */
fields?: 'suggest'
}
export interface SuggestOptions {
perPage?: number
signal?: AbortSignal
} }
export interface AuthorPostListOptions { export interface AuthorPostListOptions {
@@ -56,6 +74,7 @@ export function buildPostListQuery(opts: PostListOptions = {}): Record<string, s
if (opts.category) q.category = opts.category if (opts.category) q.category = opts.category
if (opts.tag) q.tag = opts.tag if (opts.tag) q.tag = opts.tag
if (opts.search) q.search = opts.search if (opts.search) q.search = opts.search
if (opts.fields) q.fields = opts.fields
return q return q
} }
@@ -90,6 +109,11 @@ export function isNotFound(err: unknown): boolean {
export interface JournalClient { export interface JournalClient {
getPost(slug: string): Promise<JournalPostDetail | null> getPost(slug: string): Promise<JournalPostDetail | null>
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>> 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[]> listCategories(): Promise<JournalCategory[]>
findCategory(slug: string): Promise<JournalCategory | null> findCategory(slug: string): Promise<JournalCategory | null>
listTags(): Promise<JournalTag[]> listTags(): Promise<JournalTag[]>
@@ -123,6 +147,17 @@ export function createJournalClient(fetcher: JournalFetcher): JournalClient {
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost> 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, listCategories,
async findCategory(slug) { async findCategory(slug) {

View File

@@ -5,12 +5,15 @@
// inline styles, goldmark's footnote roles, dir="auto" and two chat // inline styles, goldmark's footnote roles, dir="auto" and two chat
// transcript data attributes. URLs must be http, https, mailto or relative. // 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 // The Blog Editor's own output adds exactly five rules, the same five the Go
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id // policy has: figure data-embed (youtube only) and data-id (an 11-character
// (an 11-character video id), which carry the embed placeholder that // video id), which carry the embed placeholder that embeds.ts expands after
// embeds.ts expands after sanitizing; img alt with any text (the editor's // sanitizing; img alt with any text (the editor's free-text alt field, ""
// free-text alt field); and ol start (an integer). tests/fixtures/ // for a decorative image); ol start (an integer); and a rel holding only the
// editor-corpus.json, a byte copy of the editor's corpus, pins them. // 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 // The Journal API already sanitizes what FormatHTML writes. This is defence
// in depth for rows written another way (direct SQL, legacy imports) and for // 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 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 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)$/ 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 ':', // 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") // '?', '"' 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. // 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 }, details: { open: /^(|open)$/i },
blockquote: { cite: null }, blockquote: { cite: null },
q: { cite: null }, q: { cite: null },
a: { href: null, role: FOOTNOTE_ROLE }, a: { href: null, role: FOOTNOTE_ROLE, rel: LINK_REL },
div: { div: {
role: FOOTNOTE_ROLE, role: FOOTNOTE_ROLE,
'data-message-author-role': /^[A-Za-z0-9_-]+$/, 'data-message-author-role': /^[A-Za-z0-9_-]+$/,
@@ -238,16 +243,34 @@ const KEEP_ALL: Options = {
nonBooleanAttributes: [], 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 * 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 * 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 * script, style and similar), so the result is never blank because of one
* stray attribute. Null or empty input gives "". Pure and deterministic: the * stray attribute. With demoteH1, h1 headings come out as h2. Null or empty
* same input gives the same string on the server and in the browser. * 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 '' 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 * 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 * what the sink renders; `safe` reports whether the policy kept the input
* whole (isSafeJournalHtml). It never blanks: unsafe parts are removed, not * whole (isSafeJournalHtml; the H1 demotion is not a removal and never makes
* the value. Null or empty input is safe and "". * 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: '' } if (!html) return { safe: true, html: '' }
const clean = sanitizeHtml(html, POLICY) const demote = options.demoteH1 === true
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean } 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 // Page-number rules for Journal listings. Pages use ?page=N, or the path
// segment). Anything but a positive integer, and any page past the last one, // form <listing>/<segment>/N when the site's pagination form is path (the
// is a real 404. // 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 * 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 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 * 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. * 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 // The post page's SSR payload: the post with its body sanitized (h1 demoted
// raw `content` source or `translations`, and with related posts as list // to h2 while the Journal H1 setting is on), without the raw `content`
// rows. Kept apart from view.ts because it loads the sanitizer, which list // 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. // pages and cards do not need.
import { sanitizeJournalHtml } from './htmlGate.ts' import { sanitizeJournalHtml } from './htmlGate.ts'
import type { JournalPostDetail } from './types.ts' import type { JournalPostDetail } from './types.ts'
import { toListPost } from './view.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. */ /** A post page's data: the post with its body sanitized and no source or translations. */
export type JournalPostView = JournalPostDetail export type JournalPostView = JournalPostDetail
@@ -15,7 +41,7 @@ export function toPostView(post: JournalPostDetail): JournalPostView {
...post, ...post,
content: null, content: null,
translations: 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), 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', category: '/blog/category/:category',
tag: '/blog/tag/:tag', tag: '/blog/tag/:tag',
author: '/blog/author/:author', author: '/blog/author/:author',
search: false,
}) })
/** Route names stay stable whatever the paths are, so links never hard-code a path. */ /** 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', category: 'journal-category',
tag: 'journal-tag', tag: 'journal-tag',
author: 'journal-author', author: 'journal-author',
search: 'journal-search',
}) })
/** The route param each path must carry. */ /** The route param each path must carry. */
@@ -29,6 +31,7 @@ const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
category: 'category', category: 'category',
tag: 'tag', tag: 'tag',
author: 'author', author: 'author',
search: null,
} }
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes> 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('/') .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 // SEO values for Journal pages. The page head comes from the CMS through
// from the app's site.name through the SEO title template, not from here. // 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' import type { JournalPost } from './types.ts'
@@ -64,6 +69,74 @@ export function postSeoMeta(post: JournalPost, siteUrl?: string | null): Journal
return meta 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. */ /** A listing title; pages after the first append the "Page N" string. */
export function listTitle(base: string, page: number, pageTitle: string): string { export function listTitle(base: string, page: number, pageTitle: string): string {
return page > 1 ? `${base} · ${pageTitle}` : base return page > 1 ? `${base} · ${pageTitle}` : base

100
app/utils/siteCache.ts Normal file
View File

@@ -0,0 +1,100 @@
// Site cache purge: the authorization rules of the purge route, the purge of
// Nitro's route cache and the parsing of the purge request body. Pure
// TypeScript, so `node --test` and the server route both 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_PURGE_TOKEN_LENGTH = 32
/** The cache storage base of Nitro's route cache (routeRules cache and swr). */
export const ROUTE_CACHE_BASE = 'nitro:routes'
/** Longest slug the purge log line carries. */
const MAX_SLUG_LENGTH = 200
export type PurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
export interface PurgeAuthInput {
/** The configured token (runtimeConfig.journal.cachePurgeToken). */
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
}
function digest(value: string): Buffer {
return createHash('sha256').update(value, 'utf8').digest()
}
/**
* Decides a purge request: `disabled` when no token of at least
* MIN_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token), `ok` when
* the Bearer value equals the token (compared in constant time over sha256
* digests) and `unauthorized` otherwise.
*/
export function authorizePurge({ token, authorization, proxied }: PurgeAuthInput): PurgeVerdict {
if (typeof token !== 'string' || token.length < MIN_PURGE_TOKEN_LENGTH) return 'disabled'
if (proxied) 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 PurgeStorage {
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 purgeRouteCache(storage: PurgeStorage): Promise<number> {
const keys = await storage.getKeys(ROUTE_CACHE_BASE)
let removed = 0
for (const key of keys) {
if (!key.startsWith(ROUTE_CACHE_BASE + ':')) continue
await storage.removeItem(key)
removed++
}
return removed
}
export interface PurgeEvent {
kind: 'post' | 'category' | 'unknown'
slug: string
}
/**
* Reads the kind and slug of a purge request body for the log line: kind is
* post, category or unknown; the slug loses control characters and is cut
* to 200 characters.
*/
export function parsePurgeEvent(body: unknown): PurgeEvent {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' }
const raw = body as Record<string, unknown>
const kind = raw.kind === 'post' || raw.kind === 'category' ? 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 }
}
/**
* Makes browsers revalidate a shared-cache response: for a cache-control
* value that carries s-maxage it sets max-age=0, replacing any max-age=<n>
* or prepending it, and keeps every other directive (shared caches keep
* s-maxage). Other values, such as a static asset's
* "public, max-age=31536000, immutable", are returned unchanged.
*/
export function privateRevalidate(cacheControl: string): string {
if (typeof cacheControl !== 'string' || !/(^|[\s,])s-maxage\s*=/i.test(cacheControl)) return cacheControl
if (/(^|[\s,])max-age\s*=\s*\d*/i.test(cacheControl)) {
return cacheControl.replace(/(^|[\s,])max-age\s*=\s*\d*/i, '$1max-age=0')
}
return `max-age=0, ${cacheControl}`
}

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 // 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. // 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. */ /** The author object embedded in a post row. `null` when the post has none. */
export interface JournalAuthorRef { export interface JournalAuthorRef {
id: number id: number
first_name: string first_name: string
last_name: string last_name: string
login: 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. */ /** A category or tag reference embedded in a post row. */
@@ -22,6 +26,8 @@ export interface JournalFeaturedImage {
url: string url: string
file_name: string file_name: string
file_size: number 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. */ /** One post row, as GET /posts and GET /authors/{author}/posts list it. */
@@ -49,6 +55,19 @@ export interface JournalPost {
updated_at: string | null 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}. */ /** previous_post / next_post on GET /posts/{slug}. */
export interface JournalPostSibling { export interface JournalPostSibling {
id: number id: number
@@ -60,8 +79,25 @@ export interface JournalPostSibling {
export interface JournalPostDetail extends JournalPost { export interface JournalPostDetail extends JournalPost {
previous_post: JournalPostSibling | null previous_post: JournalPostSibling | null
next_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[] 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. */ /** One node of the GET /categories tree. */
@@ -72,6 +108,12 @@ export interface JournalCategory {
description: string | null description: string | null
/** Rich HTML body of the category page, stored as is; null when unset. */ /** Rich HTML body of the category page, stored as is; null when unset. */
content: string | null 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 parent_id: number | null
nest_depth: number | null nest_depth: number | null
post_count: number post_count: number
@@ -121,6 +163,8 @@ export interface JournalRoutes {
category: string | false category: string | false
tag: string | false tag: string | false
author: string | false author: string | false
/** Search results page; the term is the `q` query parameter. Off by default. */
search: string | false
} }
export interface JournalPerPage { export interface JournalPerPage {
@@ -128,6 +172,7 @@ export interface JournalPerPage {
category: number category: number
tag: number tag: number
author: number author: number
search: number
} }
/** Options of the `journal` config key in nuxt.config. */ /** Options of the `journal` config key in nuxt.config. */
@@ -140,4 +185,19 @@ export interface JournalModuleOptions {
relatedCount: number relatedCount: number
/** Whether /rss.xml is served and linked. */ /** Whether /rss.xml is served and linked. */
rss: boolean 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) 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", "readMore": "Read more",
"rss": { "rss": {
"title": "RSS feed" "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", "readMore": "Czytaj dalej",
"rss": { "rss": {
"title": "Kanał 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 { fileURLToPath } from 'node:url'
import { defineNuxtModule, extendPages } from 'nuxt/kit' import { defineNuxtModule, extendPages } from 'nuxt/kit'
import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes } from '../app/utils/routes.ts' import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, journalPagedRoutes, journalPaginationSource, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalModuleOptions, JournalRoutes } from '../app/utils/types.ts' import type { JournalModuleOptions, JournalPostTitleTag, JournalRoutes } from '../app/utils/types.ts'
const PAGE_FILES: Record<keyof JournalRoutes, string> = { const PAGE_FILES: Record<keyof JournalRoutes, string> = {
index: 'PostIndexPage', index: 'PostIndexPage',
@@ -16,6 +16,7 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
category: 'CategoryPage', category: 'CategoryPage',
tag: 'TagPage', tag: 'TagPage',
author: 'AuthorPage', author: 'AuthorPage',
search: 'SearchPage',
} }
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url)) 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') .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>({ export default defineNuxtModule<JournalModuleOptions>({
meta: { meta: {
name: 'vue-journal-layer', name: 'vue-journal-layer',
@@ -39,22 +45,48 @@ export default defineNuxtModule<JournalModuleOptions>({
}, },
defaults: { defaults: {
routes: { ...DEFAULT_JOURNAL_ROUTES }, 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', sort: 'published_at desc',
relatedCount: 3, relatedCount: 3,
rss: true, rss: true,
postTitleTag: 'auto',
breadcrumbs: true,
}, },
setup(options, nuxt) { setup(options, nuxt) {
const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>) const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>)
extendPages((pages) => { 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>) { for (const key of Object.keys(PAGE_FILES) as Array<keyof JournalRoutes>) {
const path = routes[key] const path = routes[key]
if (path === false) continue if (path === false) continue
pages.push({ name: JOURNAL_ROUTE_NAMES[key], path, file: `${pagesDir}${PAGE_FILES[key]}.vue` }) 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 // Register the layer's strings only for locales the app enables. The
// layer config declares no locales: @nuxtjs/i18n merges every locale a // 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 // 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, sort: options.sort,
relatedCount: options.relatedCount, relatedCount: options.relatedCount,
rss: options.rss, rss: options.rss,
postTitleTag: postTitleTag(options.postTitleTag),
breadcrumbs: options.breadcrumbs !== false,
} }
}, },
}) })

View File

@@ -12,13 +12,20 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: false }, 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 // 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. // registered by the journal module, so the router must be on regardless.
pages: true, pages: true,
// Declared once here, so the app never re-lists them (two copies of // Declared once here, so the app never re-lists them (two copies of
// @nuxtjs/i18n split locale state). // @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 // Tokens first, then the neutral styles that use them. The app's own css
// array loads after these, so its token overrides win. // array loads after these, so its token overrides win.
@@ -29,6 +36,12 @@ export default defineNuxtConfig({
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never // Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
// comes from request input. // comes from request input.
apiBase: 'http://127.0.0.1:8096/_journal/api/v1', 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, the SEO
// layer's POST /api/__site__/cache/purge. Shorter than 32 characters,
// the route answers 404.
cachePurgeToken: '',
}, },
public: { public: {
journal: { journal: {
@@ -38,16 +51,14 @@ export default defineNuxtConfig({
}, },
}, },
// No OG image rendering at runtime. // The Journal's internal routes are never route-cached. Nitro wraps every
ogImage: { enabled: false }, // handler matching an app rule such as '/**': { swr } in its cached
// handler, which keys by path, does not skip POST and hides request
// Paginated listings keep ?page=N in their canonical URL. nuxt-seo-utils // headers, so the purge route would answer from the cache without
// already whitelists page by default; set explicitly to lock the behaviour. // running. The SEO layer keeps '/api/__site__/**' out of the cache.
seo: { canonicalQueryWhitelist: ['page'] }, routeRules: {
'/api/__journal__/**': { cache: false },
// 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 // i18n: the layer declares no locales. The journal module registers the
// layer's pl/en strings only for locales the app enables. // layer's pl/en strings only for locales the app enables.

View File

@@ -1,6 +1,6 @@
{ {
"name": "vue-journal-layer", "name": "vue-journal-layer",
"version": "0.2.0", "version": "0.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./nuxt.config.ts", "main": "./nuxt.config.ts",
@@ -8,6 +8,7 @@
"dev": "nuxi dev .playground", "dev": "nuxi dev .playground",
"build:playground": "nuxt build .playground", "build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts", "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", "dev:prepare": "nuxt prepare .playground",
"check:git-install": "bash scripts/check-git-install.sh" "check:git-install": "bash scripts/check-git-install.sh"
}, },
@@ -17,8 +18,8 @@
"dependencies": { "dependencies": {
"@nuxt/fonts": "0.14.0", "@nuxt/fonts": "0.14.0",
"@nuxtjs/i18n": "10.4.0", "@nuxtjs/i18n": "10.4.0",
"@nuxtjs/seo": "5.2.1", "sanitize-html": "2.18.0",
"sanitize-html": "2.18.0" "vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"nuxt": "4.4.8" "nuxt": "4.4.8"

1980
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Usage: scripts/check-git-install.sh <tag> # 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 # 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> # 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 # anonymously, the installed package.json has no local dependency, the
# the built server passes testing/expect-acme.txt against the fixture API. # 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. # Nothing outside a temporary directory is touched.
set -euo pipefail set -euo pipefail
@@ -12,6 +25,101 @@ REPO_URL="https://git.golem15.com/golem15/vue-journal-layer.git"
die() { echo "check-git-install: $*" >&2; exit 1; } 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:-}" TAG="${1:-}"
[[ -n "$TAG" ]] || die "usage: $0 <tag>" [[ -n "$TAG" ]] || die "usage: $0 <tag>"
@@ -58,30 +166,33 @@ allowBuilds:
vue-demi: true vue-demi: true
YAML YAML
cat >nuxt.config.ts <<'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)"
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" # 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. # The lockfile must pin the tag's commit, not a branch. Only the
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)" # 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" [[ -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" [[ "$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" pnpm exec nuxt build || die "nuxt build failed"
node node_modules/vue-journal-layer/testing/smoke-ssr.mjs \ node node_modules/vue-journal-layer/testing/smoke-ssr.mjs \

View File

@@ -0,0 +1,36 @@
// POST /api/__journal__/cache/purge: removes every Nitro route-cache entry
// (pages, _payload.json and feeds), so a Journal save in the admin shows on
// the next page view. The backend calls it on loopback after the save
// commits, with `Authorization: Bearer <runtimeConfig.journal.cachePurgeToken>`
// and the body {"kind":"post"|"category","slug":"<slug>"}; the answer is
// {"purged":<n>}.
//
// 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);
// 401 for a missing or wrong bearer token. The layer's routeRules keep this
// route out of the route cache.
import { authorizePurge, parsePurgeEvent, purgeRouteCache } from '../../../../app/utils/siteCache.ts'
const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store')
const config = useRuntimeConfig(event)
const verdict = authorizePurge({
token: (config.journal as { cachePurgeToken?: unknown }).cachePurgeToken,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
if (verdict === 'unauthorized') {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const { kind, slug } = parsePurgeEvent(await readBody(event).catch(() => null))
const purged = await purgeRouteCache(useStorage('cache'))
console.info(`[journal] cache purge: removed ${purged} route cache entries (${kind} ${slug})`)
return { purged }
})

View File

@@ -1,36 +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
const forwarded = [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

@@ -0,0 +1,20 @@
// Keeps the visitor's address available to route-cached SSR renders.
//
// An app rule such as `'/**': { swr }` wraps the page renderer (and any
// matching server route) in Nitro's cached handler, which renders from a copy
// of the request that carries no headers (only `varies` headers survive, so a
// cached page never depends on one visitor's request). Reading
// x-forwarded-for there would forward only the Nuxt peer, and every cache
// miss would share the Journal API's rate-limit bucket. The event context is
// shared with that copy, so the incoming x-forwarded-for plus the peer
// address is recorded on the request hook, before the cached handler runs;
// useJournalApi, /rss.xml and the sitemap source forward it.
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress]
.filter((v): v is string => typeof v === 'string' && v !== '')
.join(', ')
event.context.journalForwardedFor = forwarded
})
})

View File

@@ -0,0 +1,29 @@
// Browser revalidation of route-cached pages. Nitro's route cache answers
// 200 with `cache-control: s-maxage=<n>, stale-while-revalidate`, which
// gives browsers no max-age, so they apply heuristic freshness from
// Last-Modified; a conditional request it answers 304 with
// `public, max-age=<n>, s-maxage=<n>`, so after one revalidation a browser
// could reuse its copy for the whole TTL, past any site cache purge.
//
// This plugin passes every cache-control value a response sets through
// privateRevalidate: values with s-maxage get max-age=0, so browsers ask on
// each view (a cheap 304 while the page is unchanged) and shared caches keep
// s-maxage. The 304 path ends the response before any beforeResponse hook
// runs, so the rewrite wraps the request's res.setHeader instead. Static
// assets carry no s-maxage and are untouched.
import { privateRevalidate } from '../../app/utils/siteCache.ts'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
const res = event.node.res
const setHeader = res.setHeader
res.setHeader = function (name, value) {
if (typeof name === 'string' && name.toLowerCase() === 'cache-control') {
if (typeof value === 'string') value = privateRevalidate(value)
else if (Array.isArray(value)) value = value.map((v) => privateRevalidate(String(v)))
}
return setHeader.call(this, name, value)
} as typeof res.setHeader
})
})

View File

@@ -10,9 +10,13 @@ export default defineEventHandler(async (event) => {
} }
const base = (config.journal as { apiBase: string }).apiBase const base = (config.journal as { apiBase: string }).apiBase
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)] // Recorded by server/plugins/journal-client-address.ts before any route
.filter((v): v is string => typeof v === 'string' && v !== '') // cache hides the request headers.
.join(', ') 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(', ')
let body: string let body: string
try { try {

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). # SSR expectations against the acme fixtures (testing/fixtures/acme).
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] # Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N]
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets # [type=PREFIX] [header=NAME: VALUE] [noheader=NAME]; resolves PATH N
# site.url to https://acme.test. # 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 # Single post
200 /blog/hello-acme contains=Hello Acme 200 /blog/hello-acme contains=Hello Acme
@@ -29,17 +46,112 @@
# Author (slug and login resolve) and tag pages # Author (slug and login resolve) and tag pages
200 /blog/author/jane-doe contains=Jane Doe 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/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 404 /blog/author/nobody
200 /blog/tag/release contains=Hello Acme 200 /blog/tag/release contains=Hello Acme
404 /blog/tag/nope 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 /rss.xml type=application/rss+xml
200 /blog contains=href="/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=?page=2
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?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=og:title
200 /blog/hello-acme contains=Acme says hello and introduces its blog. 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 // A small node:http stand-in for the anonymous Journal API
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go // (/_journal/api/v1), answering from JSON fixtures. It follows the Go
// handlers' query rules (page, per_page clamp, sort allow-list, category with // 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. // 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 // Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps import this file from there. // node_modules, and consuming apps import this file from there.
@@ -12,13 +23,14 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
const PREFIX = '/_journal/api/v1' const PREFIX = '/_journal/api/v1'
const SEO_PREFIX = '/_seo/api/v1'
const DEFAULT_PER_PAGE = 9 const DEFAULT_PER_PAGE = 9
const MAX_PER_PAGE = 30 const MAX_PER_PAGE = 30
const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id']) const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id'])
/** /**
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest * @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], 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 */ /** @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 */ /** @param {any} post */
function sibling(post) { function sibling(post) {
return post ? { id: post.id, title: post.title, slug: post.slug } : null return post ? { id: post.id, title: post.title, slug: post.slug } : null
@@ -118,19 +143,34 @@ function sibling(post) {
*/ */
export function startFixtureApi({ dir, port = 0 }) { export function startFixtureApi({ dir, port = 0 }) {
/** @type {any[]} */ /** @type {any[]} */
const posts = readJson(dir, 'posts.json', []) let posts = []
/** @type {any[]} */ /** @type {any[]} */
const categories = readJson(dir, 'categories.json', []) let categories = []
/** @type {any[]} */ /** @type {any[]} */
const tags = readJson(dir, 'tags.json', []) let tags = []
/** @type {any[]} */ /** @type {any[]} */
const authors = readJson(dir, 'authors.json', []) let authors = []
let rss = '' let rss = ''
try { /** @type {Record<string, unknown>} */
rss = readFileSync(join(dir, 'rss.xml'), 'utf8') let seoAnswers = {}
} catch { /** @type {unknown} */
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>' let seoSite = null
// Reads (or re-reads) the fixtures, so a check can change a fixture file
// while the API runs.
const load = () => {
posts = readJson(dir, 'posts.json', [])
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 {
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
}
} }
load()
/** @type {FixtureRequest[]} */ /** @type {FixtureRequest[]} */
const requests = [] const requests = []
@@ -163,7 +203,9 @@ export function startFixtureApi({ dir, port = 0 }) {
rows = rows.filter((p) => p.title.toLowerCase().includes(s)) rows = rows.filter((p) => p.title.toLowerCase().includes(s))
} }
rows = sortRows(rows, parseSort(q.get('sort'))) 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) }
} }
/** /**
@@ -177,6 +219,17 @@ export function startFixtureApi({ dir, port = 0 }) {
body: JSON.stringify(value), body: JSON.stringify(value),
type: 'application/json', 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 (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' })
if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' }) if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' })
const path = decodeURIComponent(url.pathname.slice(PREFIX.length)) const path = decodeURIComponent(url.pathname.slice(PREFIX.length))
@@ -233,6 +286,14 @@ export function startFixtureApi({ dir, port = 0 }) {
res.end(req.method === 'HEAD' ? undefined : out.body) 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) => { return new Promise((resolve, reject) => {
server.once('error', reject) server.once('error', reject)
server.listen(port, '127.0.0.1', () => { server.listen(port, '127.0.0.1', () => {
@@ -240,6 +301,8 @@ export function startFixtureApi({ dir, port = 0 }) {
resolve({ resolve({
url: `http://127.0.0.1:${addr.port}`, url: `http://127.0.0.1:${addr.port}`,
requests, requests,
resolveCount,
reload: load,
close: () => new Promise((done) => server.close(() => done())), close: () => new Promise((done) => server.close(() => done())),
}) })
}) })

View File

@@ -4,10 +4,11 @@
"name": "News", "name": "News",
"slug": "news", "slug": "news",
"description": "Company news from Acme.", "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, "parent_id": null,
"nest_depth": 0, "nest_depth": 0,
"post_count": 12 "post_count": 12,
"content_h1_to_h2": true
}, },
{ {
"id": 2, "id": 2,
@@ -17,6 +18,18 @@
"content": null, "content": null,
"parent_id": null, "parent_id": null,
"nest_depth": 0, "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 "post_count": 0
} }
] ]

View File

@@ -5,7 +5,7 @@
"slug": "hello-acme", "slug": "hello-acme",
"excerpt": "Acme says hello and introduces its blog.", "excerpt": "Acme says hello and introduces its blog.",
"content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n", "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, "reading_time": 1,
"published": true, "published": true,
"published_at": "2026-01-14T09:00:00+00:00", "published_at": "2026-01-14T09:00:00+00:00",
@@ -16,7 +16,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -37,7 +38,25 @@
"id": 1, "id": 1,
"url": "/storage/app/uploads/public/acme/hello.png", "url": "/storage/app/uploads/public/acme/hello.png",
"file_name": "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", "featured_image_url": "/storage/app/uploads/public/acme/hello.png",
@@ -62,7 +81,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -95,7 +115,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -105,8 +126,15 @@
} }
], ],
"tags": [], "tags": [],
"featured_images": [], "featured_images": [
"featured_image_url": null, {
"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": {}, "translations": {},
"created_at": "2026-01-12T09:00:00+00:00", "created_at": "2026-01-12T09:00:00+00:00",
"updated_at": "2026-01-12T09:00:00+00:00" "updated_at": "2026-01-12T09:00:00+00:00"
@@ -128,7 +156,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -161,7 +190,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -194,7 +224,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -227,7 +258,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -260,7 +292,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -293,7 +326,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -326,7 +360,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -359,7 +394,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -392,7 +428,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -447,7 +484,8 @@
"id": 1, "id": 1,
"first_name": "Jane", "first_name": "Jane",
"last_name": "Doe", "last_name": "Doe",
"login": "jdoe" "login": "jdoe",
"slug": "jane-doe"
}, },
"categories": [ "categories": [
{ {
@@ -462,5 +500,61 @@
"translations": {}, "translations": {},
"created_at": "2026-01-20T09:00:00+00:00", "created_at": "2026-01-20T09:00:00+00:00",
"updated_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

@@ -20,8 +20,11 @@ function freePort() {
} }
/** /**
* The returned `output()` gives the server's captured stdout and stderr so
* far, for checks on what the server logged.
*
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts * @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void> }>} * @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
*/ */
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) { export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
const entry = join(resolve(outputDir), 'server', 'index.mjs') const entry = join(resolve(outputDir), 'server', 'index.mjs')
@@ -60,8 +63,10 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`) if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
try { try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) }) // A Nuxt-internal path: it touches no page, so the boot probe never
return { url, close } // 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 { } catch {
await new Promise((r) => setTimeout(r, 200)) await new Promise((r) => setTimeout(r, 200))
} }

220
testing/purge-check.mjs Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env node
// Site cache purge check: proves on a built Nuxt server with swr route rules
// that a page cached before a content change shows the change on the first
// request after a purge, and that the purge route refuses unauthenticated,
// wrong-token and proxied requests.
//
// node purge-check.mjs --output <.output dir> --fixtures <dir> --kind post|category --slug <slug> --path <page path>
//
// It also checks that route-cached pages and their 304 revalidations tell
// browsers max-age=0, that a page's _payload.json (when the build serves
// one) is purged too, that a second purge after re-caching runs (the route
// is never cached itself) and that a server without a token answers 404.
// The fixtures are copied to a temporary directory, so the source fixtures
// never change. The last line is "purge-check: ok (<n> checks)" on success;
// every failure prints "purge-check: FAIL ..." and exits 1.
import { randomBytes } from 'node:crypto'
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const PURGE_PATH = '/api/__journal__/cache/purge'
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {Record<string, string>} */
const out = {}
const names = new Set(['--output', '--fixtures', '--kind', '--slug', '--path'])
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
out[arg.slice(2)] = v
}
for (const name of names) {
if (!out[name.slice(2)]) throw new Error('--output, --fixtures, --kind, --slug and --path are required')
}
if (out.kind !== 'post' && out.kind !== 'category') throw new Error(`--kind must be post or category, got ${out.kind}`)
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
return /** @type {{ output: string, fixtures: string, kind: 'post' | 'category', slug: string, path: string }} */ (out)
}
/** @param {any[]} nodes @returns {any[]} */
function flatten(nodes) {
return (nodes ?? []).flatMap((n) => [n, ...flatten(n.children)])
}
/**
* Appends a marker paragraph to the post's content and content_html, or to
* the category's content, in the fixture directory.
* @param {string} dir @param {'post' | 'category'} kind @param {string} slug @param {string} marker
*/
function appendMarker(dir, kind, slug, marker) {
const file = join(dir, kind === 'post' ? 'posts.json' : 'categories.json')
const rows = JSON.parse(readFileSync(file, 'utf8'))
const row = (kind === 'post' ? rows : flatten(rows)).find((/** @type {any} */ r) => r.slug === slug)
if (!row) throw new Error(`no ${kind} with slug ${slug} in ${file}`)
const para = `\n<p>${marker}</p>\n`
if (kind === 'post') {
row.content = (row.content ?? '') + `\n\n${marker}\n`
row.content_html = (row.content_html ?? '') + para
} else {
row.content = (row.content ?? '') + para
}
writeFileSync(file, JSON.stringify(rows, null, 2))
}
/** @param {string} base @param {string} path @param {Record<string, string>} [headers] */
async function get(base, path, headers = {}) {
const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
return { status: res.status, headers: res.headers, body: await res.text() }
}
/** @param {string} base @param {{ kind: string, slug: string }} event @param {Record<string, string>} headers */
async function purge(base, event, headers) {
const res = await fetch(base + PURGE_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(event),
signal: AbortSignal.timeout(20000),
})
const text = await res.text()
/** @type {any} */
let json = null
try {
json = JSON.parse(text)
} catch {
json = null
}
return { status: res.status, json, text }
}
/** The payload path Nuxt serves for a page path. @param {string} path */
function payloadPath(path) {
return `${path.replace(/\/+$/, '')}/_payload.json`
}
/** @param {string | null} value */
function revalidates(value) {
return /(^|[\s,])max-age=0(\s*,|\s*$)/.test(value ?? '') && /(^|[\s,])s-maxage=/.test(value ?? '')
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const event = { kind: args.kind, slug: args.slug }
const dir = mkdtempSync(join(tmpdir(), 'purge-check-'))
cpSync(resolve(args.fixtures), dir, { recursive: true })
const token = randomBytes(24).toString('hex')
const auth = { authorization: `Bearer ${token}` }
const marker = `purge-check-marker-${randomBytes(6).toString('hex')}`
const second = `purge-check-second-${randomBytes(6).toString('hex')}`
/** @type {string[]} */
const failures = []
let checks = 0
/** @param {boolean} ok @param {string} what */
const check = (ok, what) => {
checks++
if (!ok) failures.push(what)
return ok
}
const api = await startFixtureApi({ dir })
/** @type {{ url: string, close: () => Promise<void> } | null} */
let nuxt = null
// The steps stop at the first failure that makes the later ones
// meaningless; the failures are reported after the servers stop.
const steps = async () => {
nuxt = await startNuxtServer({
outputDir: args.output,
apiBase: `${api.url}/_journal/api/v1`,
env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: token },
})
const base = nuxt.url
const page = args.path
const payload = payloadPath(page)
// 1. The page (and its payload, when the build has one) render and are cached.
const first = await get(base, page)
if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return
const hasPayload = (await get(base, payload)).status === 200
// 2. Change the content behind the API; the cached page must not show it.
appendMarker(dir, args.kind, args.slug, marker)
api.reload()
const cached = await get(base, page)
check(cached.status === 200, `GET ${page} after the change: status ${cached.status}, want 200`)
if (!check(!cached.body.includes(marker), `GET ${page} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return
// 3. An authorized purge removes the cached entries.
const purged = await purge(base, event, auth)
check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`)
check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`)
// 4. The next request shows the change and tells browsers to revalidate.
const fresh = await get(base, page)
check(fresh.status === 200 && fresh.body.includes(marker), `GET ${page} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`)
const cc = fresh.headers.get('cache-control')
check(revalidates(cc), `GET ${page} after the purge: cache-control "${cc}", want max-age=0 and s-maxage`)
const etag = fresh.headers.get('etag')
if (check(!!etag, `GET ${page} after the purge: no ETag`)) {
const revalidated = await get(base, page, { 'if-none-match': /** @type {string} */ (etag) })
check(revalidated.status === 304, `conditional GET ${page}: status ${revalidated.status}, want 304`)
const cc304 = revalidated.headers.get('cache-control')
check(revalidates(cc304), `conditional GET ${page}: cache-control "${cc304}", want max-age=0 and s-maxage`)
}
if (hasPayload) {
const p = await get(base, payload)
check(p.status === 200 && p.body.includes(marker), `GET ${payload} after the purge: status ${p.status}, change shown: ${p.body.includes(marker)}`)
}
// 5. A second purge after the page was cached again runs too: the purge
// route itself is never route-cached.
appendMarker(dir, args.kind, args.slug, second)
api.reload()
const recached = await get(base, page)
check(!recached.body.includes(second), `GET ${page} after the second change: shown before the second purge (page not re-cached)`)
const again = await purge(base, event, auth)
check(again.status === 200 && typeof again.json?.purged === 'number' && again.json.purged >= 1, `second purge: status ${again.status}, answer ${again.text.slice(0, 200)}, want 200 with purged >= 1`)
const fresh2 = await get(base, page)
check(fresh2.body.includes(second), `GET ${page} after the second purge: the second change is not shown (the purge answer came from a cache)`)
// 6. Refused purges.
const none = await purge(base, event, {})
check(none.status === 401, `purge without a token: status ${none.status}, want 401`)
const wrong = await purge(base, event, { authorization: `Bearer ${randomBytes(24).toString('hex')}` })
check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`)
const proxied = await purge(base, event, { ...auth, 'x-forwarded-for': '203.0.113.7' })
check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`)
const realIP = await purge(base, event, { ...auth, 'x-real-ip': '203.0.113.7' })
check(realIP.status === 404, `purge with X-Real-IP and the right token: status ${realIP.status}, want 404`)
// 7. A server without a token has no purge route.
await nuxt.close()
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: '' } })
const disabled = await purge(nuxt.url, event, auth)
check(disabled.status === 404, `purge on a server without a token: status ${disabled.status}, want 404`)
}
try {
await steps()
} finally {
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
await api.close()
rmSync(dir, { recursive: true, force: true })
}
if (failures.length > 0) {
for (const f of failures) console.error(`purge-check: FAIL ${f}`)
process.exit(1)
}
console.log(`purge-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
// Purge token check: proves on a built Nuxt server that the site cache purge
// token never reaches a page, whichever environment variable carries it, and
// that the shared purge route reads the new and the deprecated names.
//
// node purge-token-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
//
// nuxt-site-config (through @nuxtjs/seo) copies every NUXT_SITE_* variable
// into the public site config, which Nuxt serializes into each page's
// payload. Three scenarios, each on a fresh server with fresh random tokens:
//
// A NUXT_CACHE_PURGE_TOKEN only
// B the deprecated NUXT_SITE_CACHE_PURGE_TOKEN and
// NUXT_SITE_CACHE_ALLOW_PROXIED=true only
// C both token names, with different values (the new one wins)
//
// In each, `/`, the page and its _payload.json (when the build serves one)
// must contain no token value and neither cachePurgeToken nor
// cacheAllowProxied, and neither must the server's log. The purge route must
// answer 200 to the expected bearer, 401 to the other or a wrong one, and a
// proxied request with the bearer 200 in B (allowProxied) and 404 in A. B
// logs the deprecation warning exactly once, A and C never.
//
// Failure messages name a key or say "token value"; they never print a
// value. The last line is "purge-token-check: ok (<n> checks)" on success;
// every failure prints "purge-token-check: FAIL ..." and exits 1.
import { randomBytes } from 'node:crypto'
import { resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const PURGE_PATH = '/api/__site__/cache/purge'
const PURGE_KEYS = ['cachePurgeToken', 'cacheAllowProxied']
const DEPRECATION_LINE = /^\[site-cache\].*NUXT_CACHE_PURGE_TOKEN/gm
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {Record<string, string>} */
const out = {}
const names = new Set(['--output', '--fixtures', '--path'])
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
out[arg.slice(2)] = v
}
for (const name of names) {
if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required')
}
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
}
const token = () => randomBytes(32).toString('hex')
/** @param {string} base @param {string} path */
async function get(base, path) {
const res = await fetch(base + path, { redirect: 'manual', signal: AbortSignal.timeout(20000) })
return { status: res.status, body: await res.text() }
}
/** @param {string} base @param {Record<string, string>} headers */
async function purge(base, headers) {
const res = await fetch(base + PURGE_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify({ kind: 'post', slug: 'purge-token-check' }),
signal: AbortSignal.timeout(20000),
})
await res.text()
return res.status
}
/** @param {string} value */
const bearer = (value) => ({ authorization: `Bearer ${value}` })
/** The payload path Nuxt serves for a page path. @param {string} path */
function payloadPath(path) {
return `${path.replace(/\/+$/, '')}/_payload.json`
}
async function main() {
const args = parseArgs(process.argv.slice(2))
/** @type {string[]} */
const failures = []
let checks = 0
/** @param {boolean} ok @param {string} what */
const check = (ok, what) => {
checks++
if (!ok) failures.push(what)
return ok
}
const a = token()
const b = token()
const cNew = token()
const cOld = token()
const scenarios = [
{
name: 'A (NUXT_CACHE_PURGE_TOKEN)',
env: { NUXT_CACHE_PURGE_TOKEN: a },
tokens: [a],
accepted: a,
refused: [token()],
proxied: 404,
warnings: 0,
},
{
name: 'B (deprecated NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED=true)',
env: { NUXT_SITE_CACHE_PURGE_TOKEN: b, NUXT_SITE_CACHE_ALLOW_PROXIED: 'true' },
tokens: [b],
accepted: b,
refused: [token()],
proxied: 200,
warnings: 1,
},
{
name: 'C (both names, different values)',
env: { NUXT_CACHE_PURGE_TOKEN: cNew, NUXT_SITE_CACHE_PURGE_TOKEN: cOld },
tokens: [cNew, cOld],
accepted: cNew,
refused: [cOld, token()],
proxied: 404,
warnings: 0,
},
]
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
let nuxt = null
try {
for (const s of scenarios) {
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: s.env })
const base = nuxt.url
// 1. No token value and no purge key in any page or payload.
const paths = ['/', args.path]
const page = await get(base, args.path)
check(page.status === 200, `${s.name}: GET ${args.path}: status ${page.status}, want 200`)
const payload = payloadPath(args.path)
if ((await get(base, payload)).status === 200) paths.push(payload)
for (const path of paths) {
const { body } = await get(base, path)
for (const [i, value] of s.tokens.entries()) {
check(!body.includes(value), `${s.name}: GET ${path} contains token value #${i + 1}`)
}
for (const key of PURGE_KEYS) {
check(!body.includes(key), `${s.name}: GET ${path} contains the key ${key}`)
}
}
// 2. The purge route reads the expected token.
const ok = await purge(base, bearer(s.accepted))
check(ok === 200, `${s.name}: purge with the expected bearer: status ${ok}, want 200`)
for (const [i, value] of s.refused.entries()) {
const status = await purge(base, bearer(value))
check(status === 401, `${s.name}: purge with refused bearer #${i + 1}: status ${status}, want 401`)
}
const none = await purge(base, {})
check(none === 401, `${s.name}: purge without a bearer: status ${none}, want 401`)
const proxied = await purge(base, { ...bearer(s.accepted), 'x-forwarded-for': '203.0.113.7' })
check(proxied === s.proxied, `${s.name}: proxied purge with the expected bearer: status ${proxied}, want ${s.proxied}`)
// 3. The log: the deprecation warning count, no token value, no key.
await new Promise((r) => setTimeout(r, 200))
const log = nuxt.output()
const warnings = (log.match(DEPRECATION_LINE) ?? []).length
check(warnings === s.warnings, `${s.name}: ${warnings} deprecation warnings in the server log, want ${s.warnings}`)
for (const [i, value] of s.tokens.entries()) {
check(!log.includes(value), `${s.name}: the server log contains token value #${i + 1}`)
}
await nuxt.close()
nuxt = null
}
} finally {
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
await api.close()
}
if (failures.length > 0) {
for (const f of failures) console.error(`purge-token-check: FAIL ${f}`)
process.exit(1)
}
console.log(`purge-token-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`purge-token-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -1,17 +1,35 @@
#!/usr/bin/env node #!/usr/bin/env node
// SSR smoke check: boots the fixture Journal API and a built Nuxt server, // SSR smoke check: boots the fixture Journal and SEO API and a built Nuxt
// then checks each line of an expectations file. // server, then checks each line of an expectations file.
// //
// node smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]... // node smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
// //
// Expectation grammar, one check per line ('#' starts a comment line): // Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] // STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the // [header=NAME: VALUE] [noheader=NAME] [headings=strict]
// end of the line, so it may contain spaces. Requests are plain GETs that do // [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 // 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 // lines, at least one fixture API request must have carried that address
// (the SSR fetches forward the visitor's x-forwarded-for), and every // (the SSR fetches forward the visitor's x-forwarded-for), and every
// categories request must have carried include_empty=1. // 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 { readFileSync } from 'node:fs'
import { resolve } from 'node:path' 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) 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 * @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} * @returns {ExpectCheck}
*/ */
export function parseExpectLine(line) { export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim()) const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"') 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 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 }>} */ /** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = [] const marks = []
let hit let hit
@@ -64,13 +112,66 @@ export function parseExpectLine(line) {
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') { if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${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.map((mark, i) => {
const options = {}
marks.forEach((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length 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() { async function main() {
@@ -85,8 +186,18 @@ async function main() {
let nuxt = null let nuxt = null
const failures = /** @type {string[]} */ ([]) const failures = /** @type {string[]} */ ([])
try { 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) { 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 let check
try { try {
check = parseExpectLine(line) check = parseExpectLine(line)
@@ -106,11 +217,7 @@ async function main() {
continue continue
} }
const body = await res.text() const body = await res.text()
const type = res.headers.get('content-type') ?? '' for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`)
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}"`)
} }
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) { if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: x-forwarded-for not forwarded') 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.getAuthor('nobody'), null)
assert.equal(await client.listAuthorPosts('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

@@ -118,3 +118,60 @@ test('the editor corpus embed entries expand after sanitizing', () => {
assert.equal(expandEmbeds(clean), clean, entry.name) assert.equal(expandEmbeds(clean), clean, entry.name)
} }
}) })
test('expansion is last: sanitizing the expanded output would strip the iframe', () => {
// Why the order matters: the sanitizer never keeps an iframe, so a sink that
// sanitized after expanding would lose every video.
const again = sanitizeJournalHtml(IFRAME)
assert.equal(iframeCount(again), 0)
assert.doesNotMatch(again, /youtube-nocookie/)
})
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)
// 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']) {
assert.doesNotMatch(read(transform), /expandEmbeds/, `${transform} must not expand`)
}
})
test('only the exact serialization expands: case, spacing and entities stay inert', () => {
const inert = [
`<figure data-embed="YouTube" data-id="${ID}"></figure>`,
`<figure data-embed="youtube " data-id="${ID}"></figure>`,
`<figure data-embed="youtube" data-id="${ID}"></figure>`,
`<figure data-embed="youtube" data-id="${ID}" ></figure>`,
`<FIGURE data-embed="youtube" data-id="${ID}"></FIGURE>`,
`<figure data-embed="youtube" data-id="javascript:1"></figure>`,
`<figure data-embed="youtube" data-id="dQw4w9WgXc&#81;"></figure>`,
`<figure data-embed="youtube" data-id="${ID}">\n</figure>`,
`<figure data-embed="youtube" data-id=""></figure>`,
]
for (const html of inert) {
assert.equal(expandEmbeds(html), html, html)
assert.equal(iframeCount(expandEmbeds(html)), 0, html)
}
})
test('expansion is stable across calls (the global pattern keeps no state)', () => {
const body = `<p>a</p>${PLACEHOLDER}<p>b</p>${PLACEHOLDER}`
const first = expandEmbeds(body)
assert.equal(expandEmbeds(body), first)
assert.equal(EMBED_PLACEHOLDER.lastIndex, 0)
assert.equal(iframeCount(first), 2)
})
test('the iframe src is a fixed host plus the validated id, never stored text', () => {
const out = sink(`<figure data-embed="youtube" data-id="${ID}"></figure>`)
const src = /<iframe src="([^"]+)"/.exec(out)?.[1]
assert.equal(src, `https://www.youtube-nocookie.com/embed/${ID}`)
assert.match(out, /referrerpolicy="strict-origin-when-cross-origin"/)
assert.match(out, /loading="lazy"/)
assert.doesNotMatch(out, /allow="[^"]*(autoplay|camera|microphone|geolocation)/)
})

View File

@@ -55,8 +55,16 @@
"name": "hard-break", "name": "hard-break",
"html": "<p>Pierwsza linia<br>Druga linia</p>" "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", "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 { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' 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. // Signs that something executable or navigating survived.
const DANGER = [ const DANGER = [
@@ -223,9 +223,9 @@ const CORPUS = JSON.parse(
const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>' const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
test('the editor corpus has the entries the mirror rules are for', () => { 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') 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}`) assert.ok(all.includes(part), `corpus lacks ${part}`)
} }
}) })
@@ -292,3 +292,123 @@ test('img alt keeps free text with a colon, a question mark and quotes', () => {
// area alt keeps the UGC pattern; only img alt is widened. // area alt keeps the UGC pattern; only img alt is widened.
assert.equal(sanitizeJournalHtml('<map name="m"><area alt="a: b?" href="/x" /></map>'), '<map name="m"><area href="/x" /></map>') assert.equal(sanitizeJournalHtml('<map name="m"><area alt="a: b?" href="/x" /></map>'), '<map name="m"><area href="/x" /></map>')
}) })
test('the four editor rules accept exactly the editor values (D-05 amended mirror)', () => {
const kept = [
PLACEHOLDER,
'<p><img src="/a.jpg" alt="any text: with ? and &quot;quotes&quot;" /></p>',
'<ol start="1"><li>a</li></ol>',
'<ol start="42"><li>a</li></ol>',
]
for (const html of kept) {
assert.equal(sanitizeJournalHtml(html), html, html)
assert.equal(isSafeJournalHtml(html), true, html)
}
})
test('the Go policy negatives are stripped the same way here', () => {
const cases: Array<[string, string, string]> = [
['upper-case provider', '<figure data-embed="YouTube" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
['provider with a suffix', '<figure data-embed="youtube2" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
['empty id', '<figure data-embed="youtube" data-id=""></figure>', '<figure data-embed="youtube"></figure>'],
['id with a URL', '<figure data-embed="youtube" data-id="javascript:1"></figure>', '<figure data-embed="youtube"></figure>'],
['id with a space', '<figure data-embed="youtube" data-id="dQw4w9 gXcQ"></figure>', '<figure data-embed="youtube"></figure>'],
['embed attributes on p', '<p data-embed="youtube" data-id="dQw4w9WgXcQ">x</p>', '<p>x</p>'],
['embed attributes on a', '<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>', '<a href="/x">x</a>'],
['data attribute alone on figure', '<figure data-url="https://evil.test/"></figure>', '<figure></figure>'],
['script inside the placeholder', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><script>alert(1)</script></figure>', PLACEHOLDER],
['iframe alone', '<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>', ''],
['start with a fraction', '<ol start="1.5"><li>a</li></ol>', '<ol><li>a</li></ol>'],
['alt on p', '<p alt="x">y</p>', '<p>y</p>'],
]
for (const [name, html, want] of cases) {
assert.equal(sanitizeJournalHtml(html), want, name)
assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`)
}
})
test('every corpus entry stays free of danger after the sink pipeline', () => {
for (const entry of CORPUS) {
const out = sanitizeJournalHtml(sanitizeJournalHtml(entry.html))
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. // ?page=N parsing, out-of-range detection and the page window.
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' 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', () => { test('a missing page is page 1', () => {
assert.equal(parsePageParam(undefined), 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, 0), [])
assert.deepEqual(paginationWindow(1, 5, 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. // source, translations and related post bodies stay out of it.
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' 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' import type { JournalPost, JournalPostDetail } from '../app/utils/types.ts'
function post(overrides: Partial<JournalPost> = {}): JournalPost { 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', () => { test('a missing body becomes an empty string', () => {
assert.equal(toPostView(detail({ content_html: null })).content_html, '') 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, DEFAULT_JOURNAL_ROUTES,
JOURNAL_ROUTE_NAMES, JOURNAL_ROUTE_NAMES,
fillJournalPath, fillJournalPath,
journalPagedRoutes,
journalPaginationSource,
normalizeJournalRoutes, normalizeJournalRoutes,
} from '../app/utils/routes.ts' } 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)', () => { 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) assert.deepEqual(normalizeJournalRoutes(input), input)
}) })
@@ -85,9 +87,17 @@ test('route names are stable', () => {
category: 'journal-category', category: 'journal-category',
tag: 'journal-tag', tag: 'journal-tag',
author: 'journal-author', 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', () => { test('fillJournalPath encodes a value', () => {
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b') 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') 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', () => { test('fillJournalPath leaves unknown params empty', () => {
assert.equal(fillJournalPath('/autor/:author', {}), '/autor/') 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. // list titles.
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' 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' import type { JournalPost } from '../app/utils/types.ts'
function post(extra: Partial<JournalPost> = {}): JournalPost { 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) 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', () => { 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('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') 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', 1, 'Strona 1'), 'Artykuły')
assert.equal(listTitle('Artykuły', 2, 'Strona 2'), 'Artykuły · Strona 2') 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')
})

129
tests/siteCache.test.ts Normal file
View File

@@ -0,0 +1,129 @@
// Site cache purge: route authorization, the route-cache purge, the purge
// body parsing and browser revalidation of route-cached responses.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
MIN_PURGE_TOKEN_LENGTH,
ROUTE_CACHE_BASE,
authorizePurge,
parsePurgeEvent,
privateRevalidate,
purgeRouteCache,
} from '../app/utils/siteCache.ts'
const TOKEN = 'a'.repeat(MIN_PURGE_TOKEN_LENGTH)
test('purge auth: a missing or short token disables the route', () => {
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
assert.equal(authorizePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
}
// Disabled wins over proxied: an app without a token has no route at all.
assert.equal(authorizePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
assert.equal(MIN_PURGE_TOKEN_LENGTH, 32)
})
test('purge auth: a proxied request is refused before the token is checked', () => {
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
assert.equal(authorizePurge({ token: TOKEN, authorization: 'Bearer wrong', proxied: true }), 'proxied')
assert.equal(authorizePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
})
test('purge auth: a missing or wrong bearer token is unauthorized', () => {
for (const authorization of [undefined, null, '', 'Bearer', 'Bearer ', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
assert.equal(authorizePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
}
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
assert.equal(authorizePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN} `, proxied: false }), 'ok')
})
/** 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('purge removes every route cache entry and only those', async () => {
const routes = [
`${ROUTE_CACHE_BASE}:_:cordyceps.abc123.json`,
`${ROUTE_CACHE_BASE}:_:cordyceps_payloadjson.def456.json`,
`${ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
`${ROUTE_CACHE_BASE}:_:rssxml.99.json`,
]
const others = ['nitro:functions:_:journal.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
const storage = fakeStorage([...routes, ...others])
assert.equal(ROUTE_CACHE_BASE, 'nitro:routes')
assert.equal(await purgeRouteCache(storage), routes.length)
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
assert.deepEqual([...storage.live].sort(), [...others].sort())
assert.equal(await purgeRouteCache(storage), 0)
})
test('purge ignores keys a storage returns outside the route cache', async () => {
const storage = {
removed: [] as string[],
getKeys: () => ['nitro:routes:_:a.json', 'nitro:functions:_:b.json'],
removeItem(key: string) {
this.removed.push(key)
},
}
assert.equal(await purgeRouteCache(storage), 1)
assert.deepEqual(storage.removed, ['nitro:routes:_:a.json'])
})
test('purge event: kinds', () => {
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'hello' }), { kind: 'post', slug: 'hello' })
assert.deepEqual(parsePurgeEvent({ kind: 'category', slug: 'cordyceps' }), { kind: 'category', slug: 'cordyceps' })
assert.deepEqual(parsePurgeEvent({ kind: 'tag', slug: 'x' }), { kind: 'unknown', slug: 'x' })
assert.deepEqual(parsePurgeEvent({ kind: 'POST', slug: 'x' }), { kind: 'unknown', slug: 'x' })
assert.deepEqual(parsePurgeEvent({ slug: 'x' }), { kind: 'unknown', slug: 'x' })
})
test('purge event: control characters are removed from the slug', () => {
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'a\nb\r\tc\u0000d\u001be\u007ff\u0085g' }), { kind: 'post', slug: 'abcdefg' })
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'zażółć-gęślą' }), { kind: 'post', slug: 'zażółć-gęślą' })
})
test('purge event: the slug is cut to 200 characters', () => {
assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(500) }).slug.length, 200)
assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(200) }).slug.length, 200)
})
test('purge event: a body that is not an object', () => {
for (const body of [null, undefined, 'post', 42, true, ['post', 'x']]) {
assert.deepEqual(parsePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
}
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 7 }), { kind: 'post', slug: '' })
})
test('revalidate: s-maxage without max-age gets max-age=0', () => {
assert.equal(privateRevalidate('s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate')
assert.equal(privateRevalidate('s-maxage=120'), 'max-age=0, s-maxage=120')
})
test('revalidate: s-maxage with max-age replaces max-age', () => {
assert.equal(privateRevalidate('max-age=300, s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate')
assert.equal(privateRevalidate('s-maxage=300,max-age=60'), 's-maxage=300,max-age=0')
})
test('revalidate: the 304 form of the route cache', () => {
assert.equal(privateRevalidate('public, max-age=300, s-maxage=300'), 'public, max-age=0, s-maxage=300')
})
test('revalidate: values without s-maxage are unchanged', () => {
assert.equal(privateRevalidate('public, max-age=31536000, immutable'), 'public, max-age=31536000, immutable')
assert.equal(privateRevalidate('no-store'), 'no-store')
assert.equal(privateRevalidate('no-cache'), 'no-cache')
assert.equal(privateRevalidate(''), '')
})

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