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.
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0
Overview
The layer is consumed with extends: ['vue-journal-layer']. It reads content only from the anonymous GET routes of the Journal API (/_journal/api/v1) and never calls a write route. Pages render on the server (SSR), so every URL answers with full HTML.
The layer is neutral: it ships no site colours and no site copy. Its look comes from --journal-* CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
Route paths are configuration, not files. The layer's page components live in app/journal-pages/ and a local module registers them at the paths in journal.routes, so an app moves the blog to any URL shape, or switches a view off, without copying pages. Route names stay stable, and links are always built from the names.
Features
- Post list (
journal-index): paginated with?page=N. A non-numeric page or a page past the last one answers 404. - Post (
journal-post): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead. - Category (
journal-category): name, description and the category's rich HTML content above its posts. Categories are always requested withinclude_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. - RSS:
/rss.xmlproxies the API feed, and list and post pages link it in the head. - SEO: per-page title and description, Open Graph tags (
og:imagefrom the featured image, article times), canonical URLs that keep?page=N, robots andsitemap.xmlthrough@nuxtjs/seo. - HTML sanitizer: post bodies and category content go through
sanitizeJournalHtmlbeforev-htmland before they reach the SSR payload. It is sanitize-html with the Journal API's own allow-list: the usual content elements,footer,class, schema.org microdata, thecolor,font-size,font-weightandtext-aligninline styles, andhttp,https,mailtoor relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy:data-embed(youtube) anddata-id(an 11-character video id) onfigure,altwith any text onimg, and an integerstartonol. 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. Seedocs/decisions/0001-sanitize-html.md. - Embeds: YouTube videos added in the Journal Blog Editor render as privacy-enhanced iframes (see Embeds).
- i18n: English and Polish UI strings, registered only for the locales the app enables.
Embeds
The Journal Blog Editor never stores an iframe. It stores a placeholder:
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
expandEmbeds(html) in app/utils/embeds.ts turns it into:
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
- Allowlist:
EMBED_PROVIDERSholds one provider,youtube, whose id must match^[A-Za-z0-9_-]{11}$. The iframesrcis alwayshttps://www.youtube-nocookie.com/embed/plus that id.EMBED_PLACEHOLDERmatches the placeholder exactly as the editor writes it (data-embedthendata-id, double quotes, empty figure). Any other figure, provider or id is left unchanged and renders as an empty figure. - Order: sanitize first, expand last.
JournalPostBodyrendersexpandEmbeds(sanitizeJournalHtml(html))andJournalCategoryHeaderdoes the same with the category content. The payload transforms (toPostView, the category page) only sanitize, so the payload carries the placeholder. The expansion's output is never sanitized again, because the sanitizer removes every iframe, including this one. A raw<iframe>in stored HTML is removed by the sanitizer before the expansion runs, so the only iframe on a page is one built from a validated placeholder. - Styles:
.journal-embedis a full-width block with a 16 / 9 aspect ratio; its iframe fills it without a border..journal-embed--youtubemarks the provider for app styles. - Overrides: an app that replaces
JournalPostBodyorJournalCategoryHeaderand wants embeds callsexpandEmbedson the sanitized string, never before sanitizing. - Content-Security-Policy: a site that sends a CSP needs
frame-src https://www.youtube-nocookie.com.
Usage
// nuxt.config.ts of the app
export default defineNuxtConfig({
extends: ['vue-journal-layer'],
site: { url: 'https://blog.acme.test', name: 'Acme Blog' },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [{ code: 'en', language: 'en-US' }],
},
journal: {
routes: {
index: '/blog',
post: '/blog/:slug',
category: '/:category', // category pages at the root
author: '/authors/:author',
tag: false, // no tag pages
},
perPage: { index: 12, category: 10 },
},
// SSR cache, so a new post shows up without a rebuild.
routeRules: { '/**': { swr: 300 } },
})
The layer declares @nuxt/fonts, @nuxtjs/i18n, @nuxtjs/seo and sanitize-html itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as /about take precedence over a /:category route.
Run the built app with node .output/server/index.mjs next to the SummerCMS application and point NUXT_JOURNAL_API_BASE at it.
Configuration
Options under the journal key of nuxt.config:
| Key | Default | Meaning |
|---|---|---|
journal.routes.index |
/blog |
Post list path, or false |
journal.routes.post |
/blog/:slug |
Post path; must contain :slug, or false |
journal.routes.category |
/blog/category/:category |
Category path; must contain :category, or false |
journal.routes.tag |
/blog/tag/:tag |
Tag path; must contain :tag, or false |
journal.routes.author |
/blog/author/:author |
Author path; must contain :author, or false |
journal.perPage.index |
9 |
Posts per list page (the API caps at 30) |
journal.perPage.category |
9 |
Posts per category page |
journal.perPage.tag |
9 |
Posts per tag page |
journal.perPage.author |
9 |
Posts per author page |
journal.sort |
published_at desc |
<column> <asc|desc>; columns published_at, created_at, updated_at, title, id |
journal.relatedCount |
3 |
Related posts under a post |
journal.rss |
true |
Serve and link /rss.xml |
A route path must be false or start with /; a missing param fails the build with an error naming journal.routes.<key>.
Runtime config, overridable by environment variables at start:
| Key | Env | Default | Meaning |
|---|---|---|---|
runtimeConfig.journal.apiBase |
NUXT_JOURNAL_API_BASE |
http://127.0.0.1:8096/_journal/api/v1 |
Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. |
runtimeConfig.public.journal.apiBase |
NUXT_PUBLIC_JOURNAL_API_BASE |
/_journal/api/v1 |
Browser API base for client-side navigation; the reverse proxy routes it to the application. |
runtimeConfig.journal.cachePurgeToken |
NUXT_JOURNAL_CACHE_PURGE_TOKEN |
empty | Server-only bearer token of the site cache purge route. With fewer than 32 characters the route answers 404. |
The module also copies routes, perPage, sort, relatedCount and rss into runtimeConfig.public.journal.
Server-side requests forward the visitor's x-forwarded-for and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. Under route caching (swr or cache route rules), Nitro renders a page from a copy of the request that carries no headers, so the layer's Nitro plugin server/plugins/journal-client-address.ts records the address in event.context.journalForwardedFor on the request hook, and useJournalApi, /rss.xml and the sitemap source forward that value. The API base always comes from runtime config, never from request input.
Site cache purge
An app that caches its pages with Nitro route rules (swr or cache) would otherwise serve a page rendered before a Journal edit until the cache entry expires. The layer adds a purge route that the SummerCMS Journal plugin calls after an admin save of a post or category commits (its golem15.journal.site_cache.purge_url and purge_token settings), so the edit shows on the next page view.
Request. POST /api/__journal__/cache/purge with Authorization: Bearer <token>, Content-Type: application/json and the body {"kind":"post"|"category","slug":"<slug>"}. The answer is 200 with {"purged":<n>}, the number of cache entries removed, and cache-control: no-store. The route logs one line, [journal] cache purge: removed <n> route cache entries (<kind> <slug>); the slug loses control characters and is cut to 200 characters first.
Guards.
- POST only.
404whenruntimeConfig.journal.cachePurgeTokenholds fewer than 32 characters, so an app without a token has no purge route.404for any request that carriesX-Forwarded-For,X-Real-IPorForwarded: the route is for a caller on the same host, never for traffic through the reverse proxy.401for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.- The layer's
routeRulesset'/api/__journal__/**': { 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_JOURNAL_CACHE_PURGE_TOKENand in the backend's purge token setting. Keep it out of committed files. - The backend calls the route on the Nuxt server's loopback address (for example
http://127.0.0.1:3000/api/__journal__/cache/purge), not through the public site. - 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
cachemount, or every process keeps its own entries.
Theming
Every visual value is a token in app/assets/css/journal-tokens.css:
- Colour:
--journal-color-text,--journal-color-muted,--journal-color-bg,--journal-color-surface,--journal-color-border,--journal-color-link,--journal-color-link-hover,--journal-color-accent,--journal-color-on-accent - Type:
--journal-font-body,--journal-font-heading,--journal-font-mono,--journal-font-size-base,--journal-line-height - Spacing:
--journal-space-1to--journal-space-6 - Radii:
--journal-radius-sm,--journal-radius-md,--journal-radius-lg - Layout:
--journal-content-width,--journal-wide-width,--journal-card-min,--journal-image-ratio
Override tokens in the app's CSS. The app's css entries load after the layer's; a :root:root selector wins regardless of order:
:root:root {
--journal-color-link: #b4232a;
--journal-font-heading: 'Acme Serif', serif;
}
Override a component by creating the same file in the app, for example app/components/Journal/PostCard.vue. The overridable components are JournalPostBody, JournalPostCard, JournalPostList, JournalPostMeta, JournalFeaturedImage, JournalPagination, JournalRelatedPosts, JournalIndexHeader, JournalCategoryHeader, JournalAuthorCard 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.
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.
i18n
The layer ships i18n/locales/en.json and pl.json under the journal.* key tree. It declares no locales itself: its module registers a string file only for a locale the app enables, so an app that enables only pl gets no /en routes. An app that enables another locale adds its own journal.* strings in its locale file. Dates are formatted with Intl.DateTimeFormat for the active locale's language, in UTC.
SEO and sitemap
Set site.url and site.name in the app: titles take the form <page title> | <site.name>, and canonical and Open Graph URLs are absolute against site.url. Canonical URLs keep only the page query parameter (seo.canonicalQueryWhitelist).
sitemap.xml includes the app's static pages plus a dynamic source at /api/__sitemap__/journal, which pages through the posts (30 per request) and adds every category, building each URL from journal.routes. A disabled view adds no URLs.
The feed's item links come from the Journal API, which links posts under /blog/<slug> on the application's configured URL. An app that moves journal.routes.post away from /blog/:slug should keep that in mind.
Local development
pnpm install
pnpm dev # the .playground app, extending this layer
pnpm build:playground # production build of the playground
To work on the layer inside an app, link it instead of installing from git: pnpm link ../vue-journal-layer, or an overrides entry vue-journal-layer: link:../vue-journal-layer in pnpm-workspace.yaml. Never commit the link; the app's committed dependency stays the git tag.
Releasing
A release is an annotated git tag (v0.1.0) on main, pushed to git.golem15.com. Apps pin it in package.json as git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>, and pnpm-lock.yaml records the tag's commit. To upgrade, change the tag and run pnpm install. The repository is public, so installing needs no credentials. The layer has no install-time build or prepare script, which a git-hosted dependency would need to run during pnpm install.
Testing
pnpm testruns the unit tests intests/*.test.tswithnode --test(Node strips the TypeScript types; no test framework dependency). They cover the pure helpers inapp/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 intests/fixtures/editor-corpus.json, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (tests/siteCache.test.ts: authorization, the route-cache purge, the purge body and browser revalidation), and pl/en key and placeholder parity. They need no build, network or database.scripts/check-git-install.sh <tag>installs the layer from the git tag into a scratch app, checks thatpnpm-lock.yamlpins the tag's commit, runsnuxt build, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory namedvue-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 forwardedx-forwarded-forand that categories were requested withinclude_empty=1.testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>proves the 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 checksmax-age=0on the page and its 304, the purged_payload.json, a second purge after re-caching,401without or with a wrong token,404behind a proxy and404on a server without a token. The last line ispurge-check: ok (<n> checks).testing/fixture-api.mjs(startFixtureApi) serves the anonymous Journal routes from JSON fixtures intesting/fixtures/acme/, andreload()re-reads them;testing/nuxt-server.mjs(startNuxtServer) starts a built server. Apps can import both fromnode_modules/vue-journal-layer/testing/.
pnpm build:playground
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
The playground caches every page with routeRules: { '/**': { swr: 300 } }, like a real host.