Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 783426c5d8 | |||
| c408eeb2ca |
@@ -3,6 +3,7 @@
|
||||
export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
site: { url: 'https://acme.test', name: 'Acme Blog' },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
|
||||
153
README.md
Normal file
153
README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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.1.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 gate**: post bodies and category content pass the Journal API's three-pattern gate (raw script, iframe, object or embed tags; event-handler attributes; javascript, vbscript or data URLs) before `v-html`. A value that fails it renders a neutral notice and never reaches the page or the SSR payload.
|
||||
- **i18n**: English and Polish UI strings, registered only for the locales the app enables.
|
||||
|
||||
## 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` and `@nuxtjs/seo` 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 keep the gate (`gateJournalHtml`) in front of `v-html`.
|
||||
|
||||
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
|
||||
|
||||
- `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
|
||||
```
|
||||
281
app/assets/css/journal-base.css
Normal file
281
app/assets/css/journal-base.css
Normal file
@@ -0,0 +1,281 @@
|
||||
/* Neutral Journal styles. Only --journal-* tokens; no site colours. */
|
||||
|
||||
.journal-layout {
|
||||
box-sizing: border-box;
|
||||
max-width: var(--journal-wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--journal-space-5) var(--journal-space-4) var(--journal-space-6);
|
||||
color: var(--journal-color-text);
|
||||
background: var(--journal-color-bg);
|
||||
font-family: var(--journal-font-body);
|
||||
font-size: var(--journal-font-size-base);
|
||||
line-height: var(--journal-line-height);
|
||||
}
|
||||
|
||||
.journal-layout a {
|
||||
color: var(--journal-color-link);
|
||||
}
|
||||
|
||||
.journal-layout a:hover {
|
||||
color: var(--journal-color-link-hover);
|
||||
}
|
||||
|
||||
.journal-layout a:focus-visible {
|
||||
outline: 2px solid var(--journal-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.journal-layout h1,
|
||||
.journal-layout h2,
|
||||
.journal-layout h3 {
|
||||
font-family: var(--journal-font-heading);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
.journal-index-header,
|
||||
.journal-category-header {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-index-header__title,
|
||||
.journal-category-header__title {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-category-header__description {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-category-header__content {
|
||||
max-width: var(--journal-content-width);
|
||||
}
|
||||
|
||||
/* Post page */
|
||||
.journal-post {
|
||||
max-width: var(--journal-content-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.journal-post__title {
|
||||
margin: 0 0 var(--journal-space-3);
|
||||
}
|
||||
|
||||
.journal-post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2) var(--journal-space-3);
|
||||
margin-bottom: var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-meta__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-featured-image {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-featured-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: var(--journal-image-ratio);
|
||||
object-fit: cover;
|
||||
border-radius: var(--journal-radius-lg);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post__siblings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--journal-space-3);
|
||||
margin: var(--journal-space-5) 0;
|
||||
padding-top: var(--journal-space-4);
|
||||
border-top: 1px solid var(--journal-color-border);
|
||||
}
|
||||
|
||||
.journal-post__siblings span {
|
||||
display: block;
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post__next {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Rendered HTML bodies (posts and category content) */
|
||||
.journal-post-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.journal-post-body img,
|
||||
.journal-post-body video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--journal-radius-md);
|
||||
}
|
||||
|
||||
.journal-post-body ul,
|
||||
.journal-post-body ol {
|
||||
padding-left: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-body blockquote {
|
||||
margin: var(--journal-space-4) 0;
|
||||
padding: var(--journal-space-3) var(--journal-space-4);
|
||||
border-left: 4px solid var(--journal-color-border);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body code {
|
||||
padding: 0 var(--journal-space-1);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-surface);
|
||||
font-family: var(--journal-font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-body pre {
|
||||
overflow-x: auto;
|
||||
padding: var(--journal-space-3);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body pre code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.journal-post-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--journal-space-4) 0;
|
||||
}
|
||||
|
||||
.journal-post-body th,
|
||||
.journal-post-body td {
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.journal-post-body--blocked {
|
||||
padding: var(--journal-space-3);
|
||||
border: 1px dashed var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
/* Listings */
|
||||
.journal-post-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--journal-card-min), 1fr));
|
||||
gap: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-list__empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--journal-space-2);
|
||||
padding: var(--journal-space-4);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-bg);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image {
|
||||
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image img {
|
||||
border-radius: var(--journal-radius-md) var(--journal-radius-md) 0 0;
|
||||
}
|
||||
|
||||
.journal-post-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.journal-post-card__title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.journal-post-card__date {
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post-card__excerpt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.journal-post-card__more {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.journal-related {
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.journal-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--journal-space-3);
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-pagination__pages {
|
||||
display: flex;
|
||||
gap: var(--journal-space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-pagination__pages a,
|
||||
.journal-pagination__current {
|
||||
display: inline-block;
|
||||
min-width: 2.25em;
|
||||
padding: var(--journal-space-1) var(--journal-space-2);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.journal-pagination__current {
|
||||
background: var(--journal-color-accent);
|
||||
color: var(--journal-color-on-accent);
|
||||
}
|
||||
|
||||
/* Author */
|
||||
.journal-author-card {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-author-card__name {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-rss-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--journal-space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
40
app/assets/css/journal-tokens.css
Normal file
40
app/assets/css/journal-tokens.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* Journal design tokens. Every visual value in the layer comes from these.
|
||||
An app overrides any of them in its own CSS, e.g. on :root:root. */
|
||||
:root {
|
||||
/* Colour */
|
||||
--journal-color-text: #1f2328; /* body text */
|
||||
--journal-color-muted: #59636e; /* dates, meta, captions */
|
||||
--journal-color-bg: #ffffff; /* page background */
|
||||
--journal-color-surface: #f6f8fa; /* cards, quotes, code blocks */
|
||||
--journal-color-border: #d1d9e0; /* card and table borders, rules */
|
||||
--journal-color-link: #0969da; /* links */
|
||||
--journal-color-link-hover: #0550ae; /* hovered links */
|
||||
--journal-color-accent: #0969da; /* current page, focus ring */
|
||||
--journal-color-on-accent: #ffffff; /* text on the accent colour */
|
||||
|
||||
/* Type */
|
||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
||||
--journal-font-heading: var(--journal-font-body); /* headings */
|
||||
--journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
|
||||
--journal-font-size-base: 1.0625rem; /* body size */
|
||||
--journal-line-height: 1.7; /* body leading */
|
||||
|
||||
/* Spacing scale */
|
||||
--journal-space-1: 0.25rem; /* hairline gaps */
|
||||
--journal-space-2: 0.5rem; /* inline gaps */
|
||||
--journal-space-3: 1rem; /* paragraph gap */
|
||||
--journal-space-4: 1.5rem; /* card padding */
|
||||
--journal-space-5: 2.5rem; /* section gap */
|
||||
--journal-space-6: 4rem; /* page padding */
|
||||
|
||||
/* Radii */
|
||||
--journal-radius-sm: 4px; /* code, tags */
|
||||
--journal-radius-md: 8px; /* cards, images */
|
||||
--journal-radius-lg: 16px; /* featured image */
|
||||
|
||||
/* Layout */
|
||||
--journal-content-width: 46rem; /* reading column */
|
||||
--journal-wide-width: 72rem; /* listings */
|
||||
--journal-card-min: 16rem; /* narrowest card in a grid */
|
||||
--journal-image-ratio: 1200 / 630; /* featured image crop */
|
||||
}
|
||||
18
app/components/Journal/AuthorCard.vue
Normal file
18
app/components/Journal/AuthorCard.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// The author heading of an author page: the author's name from the public
|
||||
// author endpoint. An app adds profile details by overriding this component
|
||||
// or filling the default slot.
|
||||
import type { JournalAuthor } from '../../utils/types.ts'
|
||||
|
||||
const props = defineProps<{ author: JournalAuthor; page?: number }>()
|
||||
const name = computed(() => `${props.author.first_name} ${props.author.last_name}`.trim() || props.author.slug)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-author-card">
|
||||
<h1 class="journal-author-card__name">
|
||||
{{ $t('journal.author.title', { name }) }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
20
app/components/Journal/CategoryHeader.vue
Normal file
20
app/components/Journal/CategoryHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of a category page: name, description and the category's rich
|
||||
// content, which passes the Journal HTML gate before v-html.
|
||||
import type { JournalCategory } from '../../utils/types.ts'
|
||||
import { gateJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ category: JournalCategory; page?: number; blocked?: boolean }>()
|
||||
const content = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.category.content)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-category-header">
|
||||
<h1 class="journal-category-header__title">
|
||||
{{ category.name }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
<p v-if="category.description" class="journal-category-header__description">{{ category.description }}</p>
|
||||
<div v-if="content.safe && content.html" class="journal-category-header__content journal-post-body" v-html="content.html" />
|
||||
<p v-else-if="!content.safe" class="journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
|
||||
</header>
|
||||
</template>
|
||||
11
app/components/Journal/FeaturedImage.vue
Normal file
11
app/components/Journal/FeaturedImage.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// A post's featured image. The intrinsic width/height reserve space before
|
||||
// the image loads; the rendered size and crop come from --journal-image-ratio.
|
||||
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure v-if="src" class="journal-featured-image">
|
||||
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
|
||||
</figure>
|
||||
</template>
|
||||
12
app/components/Journal/IndexHeader.vue
Normal file
12
app/components/Journal/IndexHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of the post index; pages after the first say which page they are.
|
||||
defineProps<{ page: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">
|
||||
{{ $t('journal.index.title') }}<template v-if="page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
</header>
|
||||
</template>
|
||||
33
app/components/Journal/Pagination.vue
Normal file
33
app/components/Journal/Pagination.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// Previous/next and a window of page links for the current listing. Links
|
||||
// keep the current path and use ?page=N; page 1 drops the query.
|
||||
import { paginationWindow } from '../../utils/pagination.ts'
|
||||
|
||||
const props = defineProps<{ current: number; last: number }>()
|
||||
const route = useRoute()
|
||||
|
||||
const to = (page: number) => {
|
||||
const query = { ...route.query }
|
||||
if (page > 1) query.page = String(page)
|
||||
else delete query.page
|
||||
return { path: route.path, query }
|
||||
}
|
||||
const pages = computed(() => paginationWindow(props.current, props.last))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="last > 1" class="journal-pagination" :aria-label="$t('journal.pagination.label')">
|
||||
<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>
|
||||
@@ -9,5 +9,5 @@ const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJo
|
||||
|
||||
<template>
|
||||
<div v-if="gated.safe" class="journal-post-body" v-html="gated.html" />
|
||||
<p v-else class="journal-post-body journal-post-body--blocked">This content cannot be displayed.</p>
|
||||
<p v-else class="journal-post-body journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
|
||||
</template>
|
||||
|
||||
26
app/components/Journal/PostCard.vue
Normal file
26
app/components/Journal/PostCard.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// One post in a listing: image, title link, date and excerpt.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
import { formatJournalDate } from '../../utils/view.ts'
|
||||
|
||||
const props = defineProps<{ post: JournalPost }>()
|
||||
const { localeProperties } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
const href = computed(() => links.postLink(props.post.slug))
|
||||
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
|
||||
</NuxtLink>
|
||||
<h2 class="journal-post-card__title">
|
||||
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
|
||||
<template v-else>{{ post.title }}</template>
|
||||
</h2>
|
||||
<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>
|
||||
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
|
||||
</article>
|
||||
</template>
|
||||
13
app/components/Journal/PostList.vue
Normal file
13
app/components/Journal/PostList.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// A grid of post cards, or the empty-state text when there are none.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
|
||||
defineProps<{ posts: JournalPost[]; empty?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-post-list">
|
||||
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
39
app/components/Journal/PostMeta.vue
Normal file
39
app/components/Journal/PostMeta.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Date, reading time, categories and author of a post. Links go through
|
||||
// route names, so a disabled view renders its label as plain text.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
import { formatJournalDate } from '../../utils/view.ts'
|
||||
|
||||
const props = defineProps<{ post: JournalPost }>()
|
||||
const { localeProperties } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
|
||||
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
|
||||
const authorName = computed(() => {
|
||||
const a = props.post.author
|
||||
return a ? `${a.first_name} ${a.last_name}`.trim() || a.login : ''
|
||||
})
|
||||
const authorHref = computed(() => (props.post.author ? links.authorLink(props.post.author.login) : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-post-meta">
|
||||
<time v-if="date" class="journal-post-meta__date" :datetime="post.published_at ?? undefined">
|
||||
{{ $t('journal.post.publishedOn', { date }) }}
|
||||
</time>
|
||||
<span v-if="post.reading_time > 0" class="journal-post-meta__reading">
|
||||
{{ $t('journal.post.readingTime', { minutes: post.reading_time }) }}
|
||||
</span>
|
||||
<span v-if="authorName" class="journal-post-meta__author">
|
||||
{{ $t('journal.post.by') }}
|
||||
<NuxtLink v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLink>
|
||||
<template v-else>{{ authorName }}</template>
|
||||
</span>
|
||||
<ul v-if="post.categories.length" class="journal-post-meta__categories">
|
||||
<li v-for="cat in post.categories" :key="cat.id">
|
||||
<NuxtLink v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLink>
|
||||
<span v-else>{{ cat.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
14
app/components/Journal/RelatedPosts.vue
Normal file
14
app/components/Journal/RelatedPosts.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Related posts under a post. The page passes the API's related_posts, or
|
||||
// the latest posts when the post has no category.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
|
||||
defineProps<{ posts: JournalPost[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="posts.length" class="journal-related">
|
||||
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
|
||||
<JournalPostList :posts="posts" />
|
||||
</section>
|
||||
</template>
|
||||
16
app/components/Journal/RssLink.vue
Normal file
16
app/components/Journal/RssLink.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// Advertises /rss.xml in the head and shows a visible feed link. Renders
|
||||
// nothing when journal.rss is false.
|
||||
const rss = (useRuntimeConfig().public.journal as { rss?: boolean }).rss !== false
|
||||
const { t } = useI18n()
|
||||
|
||||
if (rss) {
|
||||
useHead({
|
||||
link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('journal.rss.title'), href: '/rss.xml', key: 'journal-rss' }],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="rss" href="/rss.xml" class="journal-rss-link" type="application/rss+xml">{{ $t('journal.rss.title') }}</a>
|
||||
</template>
|
||||
23
app/composables/useJournalLinks.ts
Normal file
23
app/composables/useJournalLinks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Links to Journal views, built from stable route names through
|
||||
// useLocalePath() (i18n gives every route a per-locale name), never from
|
||||
// concatenated paths. A link to a disabled view (journal.routes.<key>: false)
|
||||
// is null; components then render plain text.
|
||||
|
||||
import { JOURNAL_ROUTE_NAMES } from '../utils/routes.ts'
|
||||
import type { JournalRoutes } from '../utils/types.ts'
|
||||
|
||||
export function useJournalLinks() {
|
||||
const localePath = useLocalePath()
|
||||
const routes = (useRuntimeConfig().public.journal as { routes: JournalRoutes }).routes
|
||||
|
||||
const link = (key: keyof JournalRoutes, params?: Record<string, string>, query?: Record<string, string>) =>
|
||||
routes[key] === false ? null : localePath({ name: JOURNAL_ROUTE_NAMES[key], params, query })
|
||||
|
||||
return {
|
||||
postLink: (slug: string) => link('post', { slug }),
|
||||
categoryLink: (slug: string) => link('category', { category: slug }),
|
||||
tagLink: (slug: string) => link('tag', { tag: slug }),
|
||||
authorLink: (slug: string) => link('author', { author: slug }),
|
||||
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// journal-author: an author and their posts; an unknown author is a 404.
|
||||
// journal-author: an author and their published posts, paginated with
|
||||
// ?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
|
||||
// in by login, the canonical points at the slug URL.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { absoluteUrl, listTitle } from '../utils/seo.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const slug = String(route.params.author ?? '')
|
||||
const { data } = await useAsyncData(`journal-author-${slug}`, async () => {
|
||||
const author = await api.getAuthor(slug)
|
||||
const links = useJournalLinks()
|
||||
const site = useSiteConfig()
|
||||
const { t } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const param = String(route.params.author ?? '')
|
||||
|
||||
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-author-${param}-${page}`, async () => {
|
||||
const author = await api.getAuthor(param)
|
||||
if (!author) return null
|
||||
return { author, page: await api.listAuthorPosts(slug) }
|
||||
const posts = await api.listAuthorPosts(param, { page, perPage: cfg.perPage.author, sort: cfg.sort })
|
||||
if (!posts) return null
|
||||
return { author, posts: { ...posts, data: posts.data.map(toListPost) } }
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Author not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const author = data.value.author
|
||||
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 }))
|
||||
useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
|
||||
|
||||
if (author.slug !== param) {
|
||||
const path = links.authorLink(author.slug)
|
||||
if (path) {
|
||||
const href = absoluteUrl(page > 1 ? `${path}?page=${page}` : path, site.url || useRequestURL().origin)
|
||||
useHead({ link: [{ rel: 'canonical', href, key: 'canonical' }] })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-author">
|
||||
<h1>{{ data.author.first_name }} {{ data.author.last_name }}</h1>
|
||||
<ul v-if="data.page">
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<JournalAuthorCard :author="data.author" :page="page" />
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.author.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// journal-category: posts in one category; an unknown category is a 404.
|
||||
// journal-category: one category's header and rich content above its posts.
|
||||
// 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
|
||||
// route may be a root-level catch-all in the app).
|
||||
import { gateJournalHtml } from '../utils/htmlGate.ts'
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.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 })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const { t } = useI18n()
|
||||
const slug = String(route.params.category ?? '')
|
||||
const { data } = await useAsyncData(`journal-category-${slug}`, async () => {
|
||||
const category = await api.findCategory(slug)
|
||||
if (!category) return null
|
||||
return { category, page: await api.listPosts({ category: category.slug }) }
|
||||
|
||||
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-category-${slug}-${page}`, async () => {
|
||||
const found = await api.findCategory(slug)
|
||||
if (!found) return null
|
||||
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
|
||||
// Only this category reaches the payload, with its content already gated.
|
||||
const gated = gateJournalHtml(found.content)
|
||||
const { children: _children, ...category } = found
|
||||
return {
|
||||
category: { ...category, content: gated.html || null },
|
||||
contentBlocked: !gated.safe,
|
||||
posts: { ...posts, data: posts.data.map(toListPost) },
|
||||
}
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
|
||||
const description = data.value.category.description ?? undefined
|
||||
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-category">
|
||||
<h1>{{ data.category.name }}</h1>
|
||||
<ul>
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<JournalCategoryHeader :category="data.category" :page="page" :blocked="data.contentBlocked" />
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// journal-index: the latest posts.
|
||||
// journal-index: the post list, paginated with ?page=N. A non-numeric page
|
||||
// or a page past the last one is a real 404.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { listTitle } from '../utils/seo.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
// Re-run setup (and its 404 checks) on every query change.
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { data: page } = await useAsyncData('journal-index', () => api.listPosts({ perPage: 9 }))
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const { t } = useI18n()
|
||||
|
||||
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-index-${page}`,
|
||||
() => api.listPosts({ page, perPage: cfg.perPage.index, sort: cfg.sort }),
|
||||
{ transform: (res) => ({ ...res, data: res.data.map(toListPost) }) },
|
||||
)
|
||||
if (error.value || !data.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number } | undefined)?.statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (pageOutOfRange(page, data.value.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = listTitle(t('journal.index.title'), page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="journal-index">
|
||||
<ul v-if="page">
|
||||
<li v-for="post in page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<section v-if="data" class="journal-index">
|
||||
<JournalIndexHeader :page="page" />
|
||||
<JournalPostList :posts="data.data" />
|
||||
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
|
||||
<JournalRssLink />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// journal-post: one published post by slug. An unknown, draft or future post
|
||||
// is a real 404. The transform gates the body before it reaches the payload.
|
||||
import { toPostView } from '../utils/view.ts'
|
||||
// Related posts fall back to the latest posts when the API has none (it
|
||||
// returns none for a post without a category).
|
||||
import { postSeoMeta } from '../utils/seo.ts'
|
||||
import { toListPost, toPostView } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const links = useJournalLinks()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = computed(() => String(route.params.slug ?? ''))
|
||||
|
||||
const { data: post, error } = await useAsyncData(
|
||||
() => `journal-post-${slug.value}`,
|
||||
() => api.getPost(slug.value),
|
||||
async () => {
|
||||
const found = await api.getPost(slug.value)
|
||||
if (!found) return null
|
||||
const count = Math.max(0, cfg.relatedCount)
|
||||
let related = found.related_posts ?? []
|
||||
if (related.length === 0 && count > 0) {
|
||||
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
|
||||
related = latest.data.filter((p) => p.slug !== found.slug)
|
||||
}
|
||||
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
|
||||
},
|
||||
{ transform: (p) => (p ? toPostView(p) : null) },
|
||||
)
|
||||
|
||||
@@ -19,11 +35,37 @@ if (error.value) {
|
||||
if (!post.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
|
||||
}
|
||||
|
||||
useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="post" class="journal-post">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<header class="journal-post__header">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<JournalPostMeta :post="post" />
|
||||
</header>
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
|
||||
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
|
||||
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
|
||||
<NuxtLink
|
||||
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
|
||||
:to="links.postLink(post.previous_post.slug)!"
|
||||
rel="prev"
|
||||
class="journal-post__previous"
|
||||
>
|
||||
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="post.next_post && links.postLink(post.next_post.slug)"
|
||||
:to="links.postLink(post.next_post.slug)!"
|
||||
rel="next"
|
||||
class="journal-post__next"
|
||||
>
|
||||
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<JournalRelatedPosts :posts="post.related_posts" />
|
||||
<JournalRssLink />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// journal-tag: posts with one tag; an unknown tag is a 404.
|
||||
// journal-tag: posts with one tag, paginated with ?page=N. An unknown tag or
|
||||
// an out-of-range page is a real 404.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.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 })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { t } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = String(route.params.tag ?? '')
|
||||
const { data } = await useAsyncData(`journal-tag-${slug}`, async () => {
|
||||
const tag = (await api.listTags()).find((t) => t.slug === slug)
|
||||
|
||||
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-tag-${slug}-${page}`, async () => {
|
||||
const tag = (await api.listTags()).find((row) => row.slug === slug)
|
||||
if (!tag) return null
|
||||
return { tag, page: await api.listPosts({ tag: tag.slug }) }
|
||||
const posts = await api.listPosts({ tag: tag.slug, page, perPage: cfg.perPage.tag, sort: cfg.sort })
|
||||
return { tag, posts: { ...posts, data: posts.data.map(toListPost) } }
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Tag not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const heading = t('journal.tag.title', { name: data.value.tag.name })
|
||||
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-tag">
|
||||
<h1>{{ data.tag.name }}</h1>
|
||||
<ul>
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">{{ title }}</h1>
|
||||
</header>
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.tag.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
36
app/utils/pagination.ts
Normal file
36
app/utils/pagination.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Page-number rules for Journal listings. Pages use ?page=N (never a path
|
||||
// segment). 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
|
||||
* digits greater than 0, and null (a 404) for anything else: non-numeric,
|
||||
* empty, 0, negative, decimal, repeated (array) or beyond the safe range.
|
||||
*/
|
||||
export function parsePageParam(raw: unknown): number | null {
|
||||
if (raw === undefined || raw === null) return 1
|
||||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
|
||||
const n = Number(raw)
|
||||
if (!Number.isSafeInteger(n) || n < 1) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function pageOutOfRange(page: number, lastPage: number): boolean {
|
||||
return page > 1 && page > lastPage
|
||||
}
|
||||
|
||||
/**
|
||||
* The page numbers to render around `current`: at most `size` consecutive
|
||||
* pages, clipped to 1..last and kept full-width near either end.
|
||||
*/
|
||||
export function paginationWindow(current: number, last: number, size: number = 5): number[] {
|
||||
if (last < 1 || size < 1) return []
|
||||
const width = Math.min(size, last)
|
||||
let start = Math.max(1, current - Math.floor(width / 2))
|
||||
if (start + width - 1 > last) start = last - width + 1
|
||||
return Array.from({ length: width }, (_, i) => start + i)
|
||||
}
|
||||
70
app/utils/seo.ts
Normal file
70
app/utils/seo.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// SEO values for Journal pages, fed to useSeoMeta. The site name suffix comes
|
||||
// from the app's site.name through the SEO title template, not from here.
|
||||
|
||||
import type { JournalPost } from './types.ts'
|
||||
|
||||
/** Plain text of an HTML fragment: tags dropped, common entities decoded, whitespace collapsed. */
|
||||
export function htmlToText(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Cuts `text` to at most `max` characters, at a word boundary when one is near, with an ellipsis. */
|
||||
export function truncateText(text: string, max: number = 160): string {
|
||||
if (text.length <= max) return text
|
||||
const cut = text.slice(0, max - 1)
|
||||
const space = cut.lastIndexOf(' ')
|
||||
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
|
||||
}
|
||||
|
||||
/** Makes `url` absolute against `siteUrl`; already absolute URLs and a missing siteUrl pass through. */
|
||||
export function absoluteUrl(url: string, siteUrl?: string | null): string {
|
||||
if (/^https?:\/\//i.test(url) || !siteUrl) return url
|
||||
return `${siteUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export interface JournalPostSeo {
|
||||
title: string
|
||||
ogTitle: string
|
||||
description: string
|
||||
ogDescription: string
|
||||
ogType: 'article'
|
||||
ogImage?: string
|
||||
articlePublishedTime?: string
|
||||
articleModifiedTime?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta for a post page: the post title, the excerpt (or the first 160
|
||||
* characters of the body text) as description, the featured image as
|
||||
* og:image, and the publish and update times.
|
||||
*/
|
||||
export function postSeoMeta(post: JournalPost, siteUrl?: string | null): JournalPostSeo {
|
||||
const description = truncateText(htmlToText(post.excerpt) || htmlToText(post.content_html), 160)
|
||||
const meta: JournalPostSeo = {
|
||||
title: post.title,
|
||||
ogTitle: post.title,
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogType: 'article',
|
||||
}
|
||||
if (post.featured_image_url) meta.ogImage = absoluteUrl(post.featured_image_url, siteUrl)
|
||||
if (post.published_at) meta.articlePublishedTime = post.published_at
|
||||
if (post.updated_at) meta.articleModifiedTime = post.updated_at
|
||||
return meta
|
||||
}
|
||||
|
||||
/** A listing title; pages after the first append the "Page N" string. */
|
||||
export function listTitle(base: string, page: number, pageTitle: string): string {
|
||||
return page > 1 ? `${base} · ${pageTitle}` : base
|
||||
}
|
||||
40
app/utils/sitemap.ts
Normal file
40
app/utils/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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
|
||||
}
|
||||
@@ -16,6 +16,22 @@ export interface JournalPostView extends JournalPostDetail {
|
||||
content_blocked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an API timestamp as a long date for `language` (a BCP 47 tag such
|
||||
* as "pl-PL"). UTC on both server and browser, so hydration matches. Returns
|
||||
* "" for a missing or invalid value.
|
||||
*/
|
||||
export function formatJournalDate(iso: string | null | undefined, language: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(language || 'en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
}
|
||||
}
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
const gated = gateJournalHtml(post.content_html)
|
||||
return {
|
||||
|
||||
39
i18n/locales/en.json
Normal file
39
i18n/locales/en.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Latest posts",
|
||||
"empty": "There are no posts yet."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page",
|
||||
"pageTitle": "Page {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Published on {date}",
|
||||
"readingTime": "{minutes} min read",
|
||||
"by": "By",
|
||||
"related": "Related posts",
|
||||
"previous": "Previous post",
|
||||
"next": "Next post",
|
||||
"contentUnavailable": "This content cannot be displayed."
|
||||
},
|
||||
"category": {
|
||||
"empty": "There are no posts in this category yet."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Posts tagged {name}",
|
||||
"empty": "There are no posts with this tag yet."
|
||||
},
|
||||
"author": {
|
||||
"title": "Posts by {name}",
|
||||
"posts": "Posts",
|
||||
"empty": "This author has not published any posts yet."
|
||||
},
|
||||
"readMore": "Read more",
|
||||
"rss": {
|
||||
"title": "RSS feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
i18n/locales/pl.json
Normal file
39
i18n/locales/pl.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Najnowsze wpisy",
|
||||
"empty": "Nie ma jeszcze żadnych wpisów."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Stronicowanie",
|
||||
"previous": "Poprzednia strona",
|
||||
"next": "Następna strona",
|
||||
"pageTitle": "Strona {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Opublikowano {date}",
|
||||
"readingTime": "{minutes} min czytania",
|
||||
"by": "Autor:",
|
||||
"related": "Powiązane wpisy",
|
||||
"previous": "Poprzedni wpis",
|
||||
"next": "Następny wpis",
|
||||
"contentUnavailable": "Nie można wyświetlić tej treści."
|
||||
},
|
||||
"category": {
|
||||
"empty": "W tej kategorii nie ma jeszcze wpisów."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Wpisy z tagiem {name}",
|
||||
"empty": "Nie ma jeszcze wpisów z tym tagiem."
|
||||
},
|
||||
"author": {
|
||||
"title": "Wpisy autora {name}",
|
||||
"posts": "Wpisy",
|
||||
"empty": "Ten autor nie opublikował jeszcze żadnych wpisów."
|
||||
},
|
||||
"readMore": "Czytaj dalej",
|
||||
"rss": {
|
||||
"title": "Kanał RSS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,18 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
|
||||
}
|
||||
|
||||
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
||||
const langDir = fileURLToPath(new URL('../i18n/locales/', import.meta.url))
|
||||
|
||||
/** UI string locales the layer ships, each in i18n/locales/<code>.json. */
|
||||
const LAYER_LOCALES = ['pl', 'en'] as const
|
||||
|
||||
/** Locale codes the project enables in its i18n config (strings or objects). */
|
||||
function enabledLocaleCodes(locales: unknown): string[] {
|
||||
if (!Array.isArray(locales)) return []
|
||||
return locales
|
||||
.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
}
|
||||
|
||||
export default defineNuxtModule<JournalModuleOptions>({
|
||||
meta: {
|
||||
@@ -43,6 +55,20 @@ export default defineNuxtModule<JournalModuleOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
// Register the layer's strings only for locales the app enables. The
|
||||
// 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
|
||||
// enables only pl.
|
||||
const hook = nuxt.hook as unknown as (
|
||||
name: 'i18n:registerModule',
|
||||
fn: (register: (config: { langDir: string; locales: Array<{ code: string; file: string }> }) => void) => void,
|
||||
) => void
|
||||
hook('i18n:registerModule', (register) => {
|
||||
const enabled = enabledLocaleCodes((nuxt.options as { i18n?: { locales?: unknown } }).i18n?.locales)
|
||||
const locales = LAYER_LOCALES.filter((code) => enabled.includes(code)).map((code) => ({ code, file: `${code}.json` }))
|
||||
if (locales.length > 0) register({ langDir, locales })
|
||||
})
|
||||
|
||||
// The public runtime config carries the resolved options next to apiBase,
|
||||
// so pages, links and the sitemap source read one source of truth.
|
||||
const pub = nuxt.options.runtimeConfig.public as Record<string, any>
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineNuxtConfig({
|
||||
// @nuxtjs/i18n split locale state).
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
|
||||
|
||||
// Tokens first, then the neutral styles that use them. The app's own css
|
||||
// array loads after these, so its token overrides win.
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
|
||||
runtimeConfig: {
|
||||
journal: {
|
||||
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
|
||||
@@ -37,6 +41,14 @@ export default defineNuxtConfig({
|
||||
// No OG image rendering at runtime.
|
||||
ogImage: { enabled: false },
|
||||
|
||||
// Paginated listings keep ?page=N in their canonical URL. nuxt-seo-utils
|
||||
// already whitelists page by default; set explicitly to lock the behaviour.
|
||||
seo: { canonicalQueryWhitelist: ['page'] },
|
||||
|
||||
// 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
|
||||
// layer's pl/en strings only for locales the app enables.
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
@@ -20,6 +20,12 @@ for tool in git pnpm node; do
|
||||
done
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/journal-git-install.XXXXXX")"
|
||||
# Nuxt applies its auto-import transform to any path with a
|
||||
# /vue-journal-layer/ segment (how it finds a layer inside node_modules), so a
|
||||
# scratch app below such a directory would transform every dependency.
|
||||
case "$WORK/" in
|
||||
*/vue-journal-layer/*) rm -rf "$WORK"; die "TMPDIR must not be inside a directory named vue-journal-layer" ;;
|
||||
esac
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -56,6 +62,7 @@ cat >nuxt.config.ts <<'TS'
|
||||
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',
|
||||
|
||||
36
server/api/__sitemap__/journal.ts
Normal file
36
server/api/__sitemap__/journal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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)
|
||||
})
|
||||
32
server/routes/rss.xml.ts
Normal file
32
server/routes/rss.xml.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// /rss.xml: the Journal API's RSS 2.0 feed, served from the site origin.
|
||||
// The visitor's x-forwarded-for is forwarded (plus the peer address) so the
|
||||
// API's rate limiter keeps one bucket per visitor. 404 when journal.rss is
|
||||
// false, 502 when the API does not answer.
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
if ((config.public.journal as { rss?: boolean }).rss === false) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not found' })
|
||||
}
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
|
||||
let body: string
|
||||
try {
|
||||
body = await $fetch<string>('/rss', {
|
||||
baseURL: base,
|
||||
responseType: 'text',
|
||||
headers: forwarded ? { 'x-forwarded-for': forwarded } : {},
|
||||
})
|
||||
} catch {
|
||||
setResponseStatus(event, 502)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
return 'The feed is unavailable.'
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'content-type', 'application/rss+xml; charset=utf-8')
|
||||
return body
|
||||
})
|
||||
@@ -1,9 +1,44 @@
|
||||
# SSR expectations against the acme fixtures (testing/fixtures/acme).
|
||||
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix).
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets
|
||||
# site.url to https://acme.test.
|
||||
|
||||
# Single post
|
||||
200 /blog/hello-acme contains=Hello Acme
|
||||
404 /blog/missing-post
|
||||
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
|
||||
404 /blog/draft-note
|
||||
|
||||
# Post index and pagination (9 per page, 13 published posts: 2 pages)
|
||||
200 /blog contains=Hello Acme
|
||||
200 /blog?page=2
|
||||
404 /blog?page=3
|
||||
404 /blog?page=abc
|
||||
404 /blog?page=0
|
||||
200 /pl/blog contains=Najnowsze wpisy
|
||||
|
||||
# Categories (include_empty=1; content renders above the list)
|
||||
200 /blog/category/news contains=Acme news marker
|
||||
200 /blog/category/quiet contains=There are no posts in this category yet.
|
||||
404 /blog/category/nope
|
||||
|
||||
# Related posts fall back to the latest posts for a post without a category
|
||||
200 /blog/uncategorised-note contains=Hello Acme
|
||||
|
||||
# Author (slug and login resolve) and tag pages
|
||||
200 /blog/author/jane-doe contains=Jane Doe
|
||||
200 /blog/author/jdoe contains=<link rel="canonical" href="https://acme.test/blog/author/jane-doe"
|
||||
404 /blog/author/nobody
|
||||
200 /blog/tag/release contains=Hello Acme
|
||||
404 /blog/tag/nope
|
||||
|
||||
# RSS, sitemap, canonical and meta
|
||||
200 /rss.xml type=application/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=<link rel="canonical" href="https://acme.test/blog?page=2">
|
||||
200 /blog/hello-acme contains=og:title
|
||||
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// 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
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for).
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for), and every
|
||||
// categories request must have carried include_empty=1.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -114,6 +115,13 @@ async function main() {
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
|
||||
}
|
||||
// Categories are always requested with include_empty=1, so empty ones resolve.
|
||||
for (const r of api.requests) {
|
||||
const u = new URL(r.url, 'http://fixture.local')
|
||||
if (u.pathname.endsWith('/categories') && u.searchParams.get('include_empty') !== '1') {
|
||||
failures.push(`include_empty: ${r.url} requested without include_empty=1`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
|
||||
Reference in New Issue
Block a user