# 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.
```bash
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](https://git.golem15.com/golem15/vue-seo-layer): every page's head (title, description, canonical, robots, Open Graph, hreflang and one JSON-LD `@graph`), the sitemaps, robots.txt, breadcrumbs, pagination links and the shared site cache purge route come from the SummerCMS SEO and sitemap plugins through it. The journal layer adds its own nodes to that graph (an `ItemList` on listing pages) and applies the CMS's link rules to post bodies. See [SEO](#seo).
The layer is neutral: it ships no site colours and no site copy. Its look comes from `--journal-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
Route paths are configuration, not files. The layer's page components live in `app/journal-pages/` and a local module registers them at the paths in `journal.routes`, so an app moves the blog to any URL shape, or switches a view off, without copying pages. Route names stay stable, and links are always built from the names.
## Features
- **Post list** (`journal-index`): paginated with `?page=N`, or `
//N` when vue-seo-layer's `seo.pagination.form` is `path`. A non-numeric page or a page past the last one answers 404.
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead.
- **Category** (`journal-category`): name, description and the category's rich HTML content (the pillar text, on page 1 only; the description shows on every page) above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL. Post meta links to the author by `author.slug` (sm-journal-plugin v0.3.1+), falling back to the login on older APIs.
- **Search** (`journal-search`, opt-in): a results page for `?q=` and the `JournalSearchBox` header component with suggestions as the visitor types. Off until the app sets `journal.routes.search`. See [Search](#search).
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
- **SEO**: the head, canonical and JSON-LD come from the CMS through vue-seo-layer; the post's own title, excerpt and featured image are a fallback for a page without a CMS answer. Listing pages add an `ItemList` node. See [SEO](#seo).
- **Headings**: exactly one H1 per page in both Journal H1 modes (the post title is an `h1` while the Journal API reports `content_h1_to_h2` on and a `p.journal-post__title` while it is off; `journal.postTitleTag` overrides it), post cards at `h2` (`h3` under the related posts' `h2`). See [Headings](#headings).
- **Post cards**: one link per card, the title; the whole card is clickable through a CSS stretched link, with the focus ring on the card.
- **Body links and images**: the CMS's phrase auto-links, the outbound link rules (nofollow or sponsored from the domain lists; new tab, `noopener` and a visually hidden hint while the CMS's links_new_tab is on) and lazy, async body images, applied at SSR on the sanitized body.
- **Breadcrumbs and pagination**: `` at the top of every journal page (`journal.breadcrumbs`), and `JournalPagination` as `` with the journal's class names.
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Five rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img` (`alt=""` of a decorative image included), an integer `start` on `ol`, and a `rel` of only `nofollow`, `sponsored` and `ugc` on `a`. `target` is never kept; the outbound link rules add it. With `{ demoteH1: true }`, `h1` headings come out as `h2`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
- **Embeds**: YouTube videos added in the Journal Blog Editor render as privacy-enhanced iframes (see [Embeds](#embeds)).
- **i18n**: English and Polish UI strings, registered only for the locales the app enables.
## Embeds
The Journal Blog Editor never stores an iframe. It stores a placeholder:
```html
```
`expandEmbeds(html)` in `app/utils/embeds.ts` turns it into:
```html
```
- **Allowlist:** `EMBED_PROVIDERS` holds one provider, `youtube`, whose id must match `^[A-Za-z0-9_-]{11}$`. The iframe `src` is always `https://www.youtube-nocookie.com/embed/` plus that id. `EMBED_PLACEHOLDER` matches the placeholder exactly as the editor writes it (`data-embed` then `data-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. `JournalPostBody` renders `expandEmbeds(sanitizeJournalHtml(html))` and `JournalCategoryHeader` does 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 `