176 lines
14 KiB
Markdown
176 lines
14 KiB
Markdown
# 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.
|
|
|
|
```bash
|
|
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 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.
|
|
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
|
|
- **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`.
|
|
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img`, and an integer `start` on `ol`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
|
|
- **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
|
|
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
|
```
|
|
|
|
`expandEmbeds(html)` in `app/utils/embeds.ts` turns it into:
|
|
|
|
```html
|
|
<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_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 `<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-embed` is a full-width block with a 16 / 9 aspect ratio; its iframe fills it without a border. `.journal-embed--youtube` marks the provider for app styles.
|
|
- **Overrides:** an app that replaces `JournalPostBody` or `JournalCategoryHeader` and wants embeds calls `expandEmbeds` on the sanitized string, never before sanitizing.
|
|
- **Content-Security-Policy:** a site that sends a CSP needs `frame-src https://www.youtube-nocookie.com`.
|
|
|
|
## Usage
|
|
|
|
```ts
|
|
// 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. |
|
|
|
|
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. The API base always comes from runtime config, never from request input.
|
|
|
|
## 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-1` to `--journal-space-6`
|
|
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
|
|
- Layout: `--journal-content-width`, `--journal-wide-width`, `--journal-card-min`, `--journal-image-ratio`
|
|
|
|
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:
|
|
|
|
```css
|
|
: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
|
|
|
|
```bash
|
|
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 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.
|
|
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
|
|
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
|
|
- `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/`.
|
|
|
|
```bash
|
|
pnpm build:playground
|
|
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
|
|
```
|