- --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
41 KiB
vue-journal-layer
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.
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0
Overview
The layer is consumed with extends: ['vue-journal-layer']. It reads content only from the anonymous GET routes of the Journal API (/_journal/api/v1) and never calls a write route. Pages render on the server (SSR), so every URL answers with full HTML.
The layer extends 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.
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, or<listing>/<segment>/Nwhen vue-seo-layer'sseo.pagination.formispath. A non-numeric page or a page past the last one answers 404. - Post (
journal-post): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead. With the Journal's opt-ingolem15.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-wideprevious_postandnext_post. - Category (
journal-category): name, description and the category's rich HTML content (the pillar text, on page 1 only; the description shows on every page) above its posts. Categories are always requested 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. Post meta links to the author byauthor.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 theJournalSearchBoxheader component with suggestions as the visitor types. Off until the app setsjournal.routes.search. See Search. - RSS:
/rss.xmlproxies the API feed, and list and post pages link it in the head. - 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
ItemListnode. See SEO. - Headings: exactly one H1 per page in both Journal H1 modes (the post title is an
h1while the Journal API reportscontent_h1_to_h2on and ap.journal-post__titlewhile it is off;journal.postTitleTagoverrides it), post cards ath2(h3under the related posts'h2). See 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 withsrcsetandsizes(the original itself is the widest candidate), the original's width and height, andalt. Cards load it lazily; the post page loads its main image eagerly withfetchpriority="high". An image without variants renders the single<img>as before. See 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,
noopenerand 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), andJournalPaginationas<SeoPagination>with the journal's class names. - 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. Five 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(alt=""of a decorative image included), an integerstartonol, and arelof onlynofollow,sponsoredandugcona.targetis never kept; the outbound link rules add it. With{ demoteH1: true },h1headings come out ash2. 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'],
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, 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 (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
Options under the journal key of nuxt.config:
| Key | Default | Meaning |
|---|---|---|
journal.routes.index |
/blog |
Post list path, or false |
journal.routes.post |
/blog/:slug |
Post path; must contain :slug, or false |
journal.routes.category |
/blog/category/:category |
Category path; must contain :category, or false |
journal.routes.tag |
/blog/tag/:tag |
Tag path; must contain :tag, or false |
journal.routes.author |
/blog/author/:author |
Author path; must contain :author, or false |
journal.routes.search |
false |
Search results path, such as /search, or false; takes no param (the term is ?q=) |
journal.perPage.index |
9 |
Posts per list page (the API caps at 30) |
journal.perPage.category |
9 |
Posts per category page |
journal.perPage.tag |
9 |
Posts per tag page |
journal.perPage.author |
9 |
Posts per author page |
journal.perPage.search |
9 |
Results per search page |
journal.sort |
published_at desc |
<column> <asc|desc>; columns published_at, created_at, updated_at, title, id |
journal.relatedCount |
3 |
Related posts under a post |
journal.rss |
true |
Serve and link /rss.xml |
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 |
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>.
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 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.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, rss, postTitleTag and breadcrumbs into runtimeConfig.public.journal.
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.
404when the route's token holds 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. The shared route makes one exception, below.401for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.- The layer's
routeRulesset'/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_TOKENand in the backend'ssite_cache.purge_token. Keep it out of committed files. Never put a secret in aNUXT_SITE_*orNUXT_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_urlthrough the site's TLS-terminating proxy, which adds forwarding headers. SetNUXT_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/purgeroute 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
cachemount, 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_TOKENtoNUXT_CACHE_PURGE_TOKENandNUXT_SITE_CACHE_ALLOW_PROXIEDtoNUXT_CACHE_PURGE_ALLOW_PROXIEDin 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 underNUXT_SITE_CACHE_PURGE_TOKENappeared in every page ascachePurgeToken. 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'sNUXT_CACHE_PURGE_TOKENand in the backend'ssite_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:
journal: { routes: { search: '/szukaj' } },
The Journal API answers GET /posts?search= from Typesense in relevance order when the backend has it on, and from SQL otherwise; the layer sends no sort with a search, so the API's order is kept. Terms under 3 characters are never sent: the API would ignore them.
Results page (journal-search). Reads ?q= and ?page=N, renders the matches with JournalPostList and JournalPagination under a search form and a result count (plural forms through Intl.PluralRules: Polish one, few and many), and shows its own message for an empty term, a term under 3 characters and no results. It is fetched with useAsyncData on the server, so it works without JS. The title includes the term; the term is rendered as text everywhere.
Header box (<JournalSearchBox />). An icon button opens a search input; once the term has 3 or more characters, a suggestion request (GET /posts?search=…&fields=suggest&per_page=5) goes out 250 ms after the last keystroke, and a newer term aborts the running request and drops its answer. Up to limit suggestions show the title, the excerpt and a small featured image, with the query words in <mark> ignoring case and diacritics ("grzybow" marks "grzybów"); the marking splits text and never inserts API HTML. Under them, "see all results" links to the search page with q. Enter opens the active suggestion, or the search page when none is active. The input is a WAI-ARIA combobox: ArrowDown and ArrowUp move through the options (aria-activedescendant), Escape closes the box and returns focus to the button, and a click outside closes it. A spinner shows after 300 ms, "no results" when nothing matched, and a quiet message when the request failed. Without JS the box is a plain GET form to the search page, always shown. Reopening selects the previous term.
| Prop | Default | Meaning |
|---|---|---|
limit |
5 |
Suggestions shown |
minLength |
3 |
Shortest term that sends a request |
debounce |
250 |
Wait after the last keystroke, in ms |
placeholder, buttonLabel, seeAllLabel, emptyLabel |
the journal.search.* strings |
Text overrides |
The icon slot replaces the magnifier. The root gets journal-search--open while open and journal-search--enhanced once mounted. The panel (.journal-search__panel) is inline next to the button, with the suggestions (.journal-search__dropdown) positioned below its left edge; an app makes it a full-width panel under its header, or anchors the suggestions on the right (left: auto; right: 0), in its own CSS.
Caching, robots and sitemap. Nitro's route cache keys by path, so under an app rule such as '/**': { swr: 300 } every ?q= would get the first query's cached page. The journal module therefore adds { cache: false, robots: 'noindex, follow' } route rules for the search path and its locale-prefixed copies (/pl/szukaj), and a sitemap exclude for an app that adds @nuxtjs/sitemap itself. The page's meta (seo: { robots: 'noindex, follow' }) makes vue-seo-layer send the robots meta tag and X-Robots-Tag, over the CMS's robots, and leaves the page out of its route report. An app's own rule for the same path wins key by key. The search page is not route-cached: every search renders on request.
Theming
Every visual value is a token in app/assets/css/journal-tokens.css:
- Colour:
--journal-color-text,--journal-color-muted,--journal-color-bg,--journal-color-surface,--journal-color-border,--journal-color-link,--journal-color-link-hover,--journal-color-accent,--journal-color-on-accent,--journal-color-mark(search term highlight) - Shadow:
--journal-shadow-dropdown(search suggestions) - Type:
--journal-font-body,--journal-font-heading,--journal-font-mono,--journal-font-size-base,--journal-line-height; the post title (anh1or ap, styled by its class):--journal-post-title-size(2em),--journal-post-title-weight(700),--journal-post-title-line-height(1.2) - 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, JournalRssLink and JournalSearchBox. A replacement that renders HTML from the API must pass it through sanitizeJournalHtml (or gateJournalHtml, whose html is the same sanitized string) before v-html. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under .journal-post-body: the sanitizer removes <style> elements from the content.
Pages and components can use the useJournalApi() client and the useJournalLinks() link helpers (postLink, categoryLink, tagLink, authorLink, indexLink, searchLink), which return null for a disabled view. The client's suggestPosts(term, { perPage, signal }) returns the suggestion rows (JournalPostSuggestion).
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:
<figure class="journal-featured-image"><picture>
<source type="image/webp" srcset="…_480_288_0_0_exact.webp 480w, …_800_480_0_0_exact.webp 800w" sizes="…">
<img src="…/cover.jpg" srcset="…_480_288_0_0_exact.jpg 480w, …_800_480_0_0_exact.jpg 800w, …/cover.jpg 1000w" sizes="…" width="1000" height="600" alt="…" loading="eager" fetchpriority="high" decoding="async">
</picture></figure>
imageSrcsets(variants, originalUrl, originalWidth?) in app/utils/srcset.ts builds the two srcsets (narrowest first, each width once, addresses escaped for srcset) and the reserved size from the variants (source_width, source_height when the API sends them, else the largest variant); no usable variant answers null. The CSS is unchanged: .journal-featured-image img keeps the ratio and the crop, and the picture element is a block.
i18n
The layer ships i18n/locales/en.json and pl.json under the journal.* key tree. It declares no locales itself: its module registers a string file only for a locale the app enables, so an app that enables only pl gets no /en routes. An app that enables another locale adds its own journal.* strings in its locale file. Dates are formatted with Intl.DateTimeFormat for the active locale's language, in UTC.
SEO
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.
- 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 asog:image. Listing pages set their title (· Page Nafter page 1). An app that wants a site-name suffix there setsapp.head.titleTemplate. - Article meta. Every post page sets
og:typearticle,article:published_time(published_at) andarticle: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
ItemListnode (@id<page URL>#itemlist, oneListItemwith the absolute URL of each post shown, positions continuing across pages) through vue-seo-layer'suseSchemaNode(useJournalItemList), so the page keeps one JSON-LD script. - Body links.
JournalPostBodyrenders the body throughrenderJournalBodyin the UI-SPEC L7 order: the sanitizer (with H1 demotion), vue-seo-layer'sautolinkwith the resolve answer'sautolinksandautolink_max(first occurrence, whole words, never in headings, links, code, figure captions or buttons, never to the page itself),applyOutboundwith the answer'soutbound(the editor'srelkept,nofolloworsponsoredfrom the domain lists;target="_blank",rel="noopener"and a<span class="seo-visually-hidden">hint only whilenew_tabis on, the hint while vue-seo-layer'sseo.links.newTabHintis on, its default) andimageAttrs(loading="lazy",decoding="async", stored width, height andalt=""kept). Internal links are untouched. - Breadcrumbs. Post, index, category, tag and author pages render
<SeoBreadcrumbs>at the top of their content whilejournal.breadcrumbsis on. A site that places the trail in its layout sets it tofalseand renders one<SeoBreadcrumbs>there; vue-seo-layer renders one trail per page either way, and theBreadcrumbListnode is the CMS's. - Pagination.
JournalPaginationrenders<SeoPagination class-prefix="journal-pagination">with the journal's labels: the classesjournal-pagination,__prev,__pages,__currentand__nextstay, plus page 1, the last page and__gapitems,aria-labels and headrel="prev"/rel="next"links. Links follow the CMS's pagination form; withseo.pagination.form: 'path'in the app's config the module registers<listing>/<segment>/:pageroutes for the index, category, tag and author pages. The segments follow vue-seo-layer's resolvedseo.paginationoption, defaults included (plstrona,enpage), the same ones its pagination links use, so an app that sets onlyform: 'path'gets/blog/strona/:pageand/blog/page/:page.
Headings
| Page or component | Element |
|---|---|
Post title (.journal-post__title) |
h1 while the Journal API reports content_h1_to_h2 on (or leaves it out); p while it is off. Same class, same look |
| Post body | Setting on: the body's h1 headings become h2. Setting off: as written, so the body's own h1 is the page's H1 |
| Category pillar text | The same rule from the category row's content_h1_to_h2 (absent counts as on). With the setting off and an h1 in the pillar text the page has two h1 (the header and the pillar's) |
| Index, category, tag, author and search headers | h1; page 2 and later add · Page N |
| Post card title | h2 in listings (headingLevel 2), h3 under the related posts' h2 (headingLevel 3) |
journal.postTitleTag: 'h1' with the setting off gives two h1, and 'p' with it on gives none; both are the documented consequence of forcing the element, which the SEO plugin's crawler reports.
The feed's item links come from the Journal API, which links posts under /blog/<slug> on the application's configured URL. An app that moves journal.routes.post away from /blog/:slug should keep that in mind.
Upgrading from 0.7
@nuxtjs/seois gone. The layer extends vue-seo-layer instead. Removesite: { url, name }and theogImage,seo.canonicalQueryWhitelist,sitemap,robotsandschemaOrgkeys that only@nuxtjs/seoread; setNUXT_SEO_API_BASE(the SEO plugin, defaulthttp://127.0.0.1:8096/_seo/api/v1) orseo: { cms: false }for a site without the SEO plugin.useSiteConfig(),useSchemaOrg()and the other@nuxtjs/seocomposables are no longer available to the app through this layer; an app that uses them adds@nuxtjs/seoitself (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.tsandserver/plugins/site-cache-purge.tsare no longer in this layer. The deprecatedPOST /api/__journal__/cache/purgekeeps working. - Post cards have one link.
.journal-post-card__moreand the image link are removed;.journal-post-card__imageis adiv. 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 asp.journal-post__title; app CSS that stylesh1for the title styles.journal-post__titleor 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 targetsh2.journal-post-card__titletargets the class instead.
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.
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:
- 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 runpnpm install, sopnpm-lock.yamlrecords the seo layer's commit. - 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 thosepackage.jsonfiles has alink:,file:,workspace:orportal:dependency, and names each one. - Tag and push, then run
bash scripts/check-git-install.sh <tag>, which repeats the check on the installed tag before building.
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 (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 intests/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 built from the layer's playground config (.playground/nuxt.config.tsextending the installed layer, and itsapp.vue), checks thatpnpm-lock.yamlpins the tag's commit for vue-journal-layer (its own git dependencies, such as vue-seo-layer, are ignored), checks that the installedpackage.jsonhas no local dependency, runsnuxt build, and runs the SSR smoke against the build.--check-links DIR...checks thepackage.jsonof each directory forlink:,file:,workspace:andportal:dependencies (the release guard, see Releasing).--self-testchecks the lockfile reading and the link check on planted files. 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> [--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, andresolves PATH N(the SEO resolve calls so far for a path,*for all).headings=strictwants exactly one<h1and no skipped level,cardlinks=Nexactly N links in each post card. It also checks that SSR requests forwardedx-forwarded-forand that categories were requested withinclude_empty=1.pnpm smoke:ssrrunsexpect-acme.txtand the expectation files forpostTitleTagforced toh1andp, the new-tab hint off andjournal.breadcrumbsoff.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/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: underNUXT_CACHE_PURGE_TOKEN, under the deprecatedNUXT_SITE_CACHE_PURGE_TOKENwithNUXT_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 neithercachePurgeTokennorcacheAllowProxied, 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 ispurge-token-check: ok (<n> checks).testing/fixture-api.mjs(startFixtureApi) serves the anonymous Journal routes from JSON fixtures intesting/fixtures/acme/, and the SEO plugin'sGET /_seo/api/v1/resolveand/sitefromtesting/fixtures/acme/seo/;resolveCount(path?)counts resolve calls andreload()re-reads the fixtures;testing/nuxt-server.mjs(startNuxtServer) starts a built server and returns itsurl,close()andoutput(), the server's captured stdout and stderr so far. Apps can import both fromnode_modules/vue-journal-layer/testing/.
pnpm build:playground
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.