Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 759e684e86 | |||
| a6ee443885 | |||
| 294a8d8b19 | |||
| 5beb1c5634 | |||
| 3275a267f6 | |||
| b2069e10bd | |||
| a4dca42d77 | |||
| 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',
|
||||
|
||||
175
README.md
Normal file
175
README.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# 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
|
||||
```
|
||||
289
app/assets/css/journal-base.css
Normal file
289
app/assets/css/journal-base.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Embeds expanded from the Blog Editor's placeholders (app/utils/embeds.ts) */
|
||||
.journal-embed {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: var(--journal-space-4) 0;
|
||||
}
|
||||
|
||||
.journal-embed iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 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>
|
||||
22
app/components/Journal/CategoryHeader.vue
Normal file
22
app/components/Journal/CategoryHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of a category page: name, description and the category's rich
|
||||
// content, which goes through the Journal sanitizer before v-html. The page
|
||||
// payload keeps embed placeholders; expandEmbeds turns them into iframes as
|
||||
// the last step, after sanitizing, and its output is never sanitized again.
|
||||
import type { JournalCategory } from '../../utils/types.ts'
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ category: JournalCategory; page?: number }>()
|
||||
const content = computed(() => expandEmbeds(sanitizeJournalHtml(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" class="journal-category-header__content journal-post-body" v-html="content" />
|
||||
</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>
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// The post body. v-html only ever receives HTML that passed the Journal gate;
|
||||
// a blocked or unsafe body renders a neutral notice instead.
|
||||
import { gateJournalHtml } from '../../utils/htmlGate.ts'
|
||||
// The post body. v-html only ever receives HTML that went through the Journal
|
||||
// sanitizer, which removes what the policy does not allow and keeps the rest.
|
||||
// The page passes an already sanitized body (with any embed placeholder still
|
||||
// a placeholder); sanitizing again here guards a caller that does not, and
|
||||
// gives the same string on server and client. expandEmbeds runs last: it
|
||||
// turns the Blog Editor's embed placeholders into privacy-enhanced iframes,
|
||||
// and its output is never sanitized again (the sanitizer would strip them).
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ html: string | null | undefined; blocked?: boolean }>()
|
||||
const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.html)))
|
||||
const props = defineProps<{ html: string | null | undefined }>()
|
||||
const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html)))
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="journal-post-body" v-html="html" />
|
||||
</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,53 @@
|
||||
<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 { sanitizeJournalHtml } 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 sanitized.
|
||||
const { children: _children, ...category } = found
|
||||
return {
|
||||
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
|
||||
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" />
|
||||
<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,32 @@
|
||||
<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'
|
||||
// is a real 404. The transform sanitizes the body before it reaches the payload.
|
||||
// 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 { toPostView } from '../utils/postView.ts'
|
||||
import { toListPost } 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 +36,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>
|
||||
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
63
app/utils/embeds.ts
Normal file
63
app/utils/embeds.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Embed expansion for Journal HTML. The Blog Editor (Journal plugin,
|
||||
// assets/src/blogeditor/embed.ts) never stores an iframe: it writes the
|
||||
// placeholder <figure data-embed="youtube" data-id="ID"></figure>, and the
|
||||
// plugin's bluemonday policy (classes/format_html.go) keeps exactly that, with
|
||||
// data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$.
|
||||
// htmlGate.ts mirrors those two rules, so the placeholder survives the layer's
|
||||
// sanitizer too.
|
||||
//
|
||||
// expandEmbeds is the very last step before v-html, after sanitizeJournalHtml:
|
||||
// its input is always sanitizer output, and its output is never sanitized
|
||||
// again (the sanitizer strips every iframe, including the one built here). The
|
||||
// regex matches the serialization sanitize-html gives the placeholder:
|
||||
// attribute order kept, double quotes, empty figure. Only a known provider
|
||||
// with a valid id becomes an iframe, whose src is a fixed host plus that id;
|
||||
// anything else is returned unchanged and stays an inert empty figure.
|
||||
|
||||
/** One allowlisted embed provider. */
|
||||
export type EmbedProvider = {
|
||||
/** The id a placeholder must match to expand. */
|
||||
id: RegExp
|
||||
/** The iframe src for a validated id. */
|
||||
src: (id: string) => string
|
||||
/** The iframe's accessible title. */
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Providers a placeholder may name. YouTube only, on its privacy-enhanced host. */
|
||||
export const EMBED_PROVIDERS: Readonly<Record<string, EmbedProvider>> = Object.freeze({
|
||||
youtube: {
|
||||
id: /^[A-Za-z0-9_-]{11}$/,
|
||||
src: (id: string) => `https://www.youtube-nocookie.com/embed/${id}`,
|
||||
title: 'YouTube video',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The placeholder exactly as the editor writes it and sanitize-html
|
||||
* serializes it. Group 1 is the provider, group 2 the id; neither may hold a
|
||||
* quote or an angle bracket.
|
||||
*/
|
||||
export const EMBED_PLACEHOLDER = /<figure data-embed="([^"<>]*)" data-id="([^"<>]*)"><\/figure>/g
|
||||
|
||||
/**
|
||||
* Replaces every placeholder whose provider is in EMBED_PROVIDERS and whose
|
||||
* id passes the provider's pattern with a figure holding the provider's
|
||||
* iframe. Every other match is returned unchanged. Empty input gives "".
|
||||
* Call it on sanitizeJournalHtml output only, and never sanitize its result.
|
||||
*/
|
||||
export function expandEmbeds(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html.replace(EMBED_PLACEHOLDER, (match: string, provider: string, id: string) => {
|
||||
if (!Object.hasOwn(EMBED_PROVIDERS, provider)) return match
|
||||
const p = EMBED_PROVIDERS[provider]!
|
||||
if (!p.id.test(id)) return match
|
||||
return (
|
||||
`<figure class="journal-embed journal-embed--${provider}">` +
|
||||
`<iframe src="${p.src(id)}" title="${p.title}" loading="lazy" ` +
|
||||
'referrerpolicy="strict-origin-when-cross-origin" ' +
|
||||
'allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
||||
'</figure>'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,272 @@
|
||||
// HTML gate for every v-html sink (post body, category content, author bio).
|
||||
// The three patterns are the verbatim regex sources of the Journal plugin's
|
||||
// FormatHTML / CheckHTML gate (classes/format_html.go), with the i flag for
|
||||
// Go's (?i). Imported content bypasses the Go gate, so the layer checks again.
|
||||
// HTML sanitizer for every v-html sink (post body, category content, and
|
||||
// any HTML copy an app renders the same way). It mirrors the Journal
|
||||
// plugin's bluemonday policy (classes/format_html.go): the UGC element and
|
||||
// attribute set, plus footer, class, schema.org microdata, a few validated
|
||||
// inline styles, goldmark's footnote roles, dir="auto" and two chat
|
||||
// transcript data attributes. URLs must be http, https, mailto or relative.
|
||||
//
|
||||
// The Blog Editor's own output adds exactly four rules, the same four the Go
|
||||
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id
|
||||
// (an 11-character video id), which carry the embed placeholder that
|
||||
// embeds.ts expands after sanitizing; img alt with any text (the editor's
|
||||
// free-text alt field); and ol start (an integer). tests/fixtures/
|
||||
// editor-corpus.json, a byte copy of the editor's corpus, pins them.
|
||||
//
|
||||
// The Journal API already sanitizes what FormatHTML writes. This is defence
|
||||
// in depth for rows written another way (direct SQL, legacy imports) and for
|
||||
// the API itself: anything outside the policy is stripped, never the whole
|
||||
// value, so legitimate text such as "Data: 12 maja 2024" always renders.
|
||||
//
|
||||
// sanitize-html runs the same parser and serializer on the server (SSR) and
|
||||
// in the browser, so a body sanitizes to the same string on both and
|
||||
// hydration never sees a difference. The output is always the serializer's,
|
||||
// never the input bytes: the browser parses exactly what the policy checked.
|
||||
// See docs/decisions/0001-sanitize-html.md.
|
||||
|
||||
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
export const EVENT_HANDLER = /\son[a-z]+\s*=/i
|
||||
export const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
/** True when `html` matches none of the three Journal patterns. */
|
||||
export function isSafeJournalHtml(html: string | null | undefined): boolean {
|
||||
if (!html) return true
|
||||
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(html))
|
||||
/** Elements of bluemonday's UGC policy, plus footer. */
|
||||
export const JOURNAL_ALLOWED_TAGS: readonly string[] = [
|
||||
// Sectioning, headings, grouping
|
||||
'article', 'aside', 'details', 'figure', 'section', 'summary', 'footer',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
|
||||
'blockquote', 'br', 'div', 'hr', 'p', 'span', 'wbr',
|
||||
// Links and image maps
|
||||
'a', 'map', 'area',
|
||||
// Phrasing
|
||||
'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'figcaption', 'mark', 's', 'samp',
|
||||
'strong', 'sub', 'sup', 'var', 'q', 'time',
|
||||
'b', 'i', 'pre', 'small', 'strike', 'tt', 'u',
|
||||
'bdi', 'bdo', 'rp', 'rt', 'ruby', 'del', 'ins',
|
||||
// Lists and tables
|
||||
'ol', 'ul', 'li', 'dl', 'dt', 'dd',
|
||||
'table', 'caption', 'col', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
// Data display and images
|
||||
'meter', 'progress', 'img',
|
||||
]
|
||||
|
||||
/** URL schemes an href, src or cite may use; relative URLs are allowed too. */
|
||||
export const JOURNAL_ALLOWED_SCHEMES: readonly string[] = ['http', 'https', 'mailto']
|
||||
|
||||
// bluemonday's value patterns (helpers.go), as JavaScript regexes. Several
|
||||
// are unanchored in bluemonday (lang, id, scope, nowrap) and stay unanchored
|
||||
// here, so the layer never strips what the API keeps.
|
||||
const PARAGRAPH = /^[\p{L}\p{N}\s\-_',[\]!./\\()]*$/u
|
||||
const SPACE_SEPARATED_TOKENS = /^[\s\p{L}\p{N}_-]+$/u
|
||||
const INTEGER = /^[0-9]+$/
|
||||
const NUMBER = /^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/
|
||||
const NUMBER_OR_PERCENT = /^[0-9]+%?$/
|
||||
const LIST_TYPE = /^(circle|disc|square|a|A|i|I|1)$/i
|
||||
const CELL_ALIGN = /^(center|justify|left|right|char)$/i
|
||||
const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i
|
||||
const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i
|
||||
const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/
|
||||
const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/
|
||||
// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':',
|
||||
// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt")
|
||||
// does; sanitize-html escapes attribute values on output and alt carries no URL.
|
||||
const FREE_TEXT = /^[\s\S]*$/
|
||||
// The embed placeholder written by the Blog Editor (see embeds.ts).
|
||||
const EMBED_PROVIDER = /^youtube$/
|
||||
const EMBED_ID = /^[A-Za-z0-9_-]{11}$/
|
||||
|
||||
// A value pattern; null marks a URL attribute (checked by allowedUrl) or
|
||||
// style (filtered per property by allowedStyles).
|
||||
type ValueRule = RegExp | null
|
||||
|
||||
// Attributes allowed on every element.
|
||||
const GLOBAL_ATTRS: Record<string, ValueRule> = {
|
||||
dir: /^(ltr|rtl|auto)$/i,
|
||||
lang: /[a-zA-Z]{2,20}/,
|
||||
id: /[a-zA-Z0-9:\-_.]+/,
|
||||
title: PARAGRAPH,
|
||||
class: SPACE_SEPARATED_TOKENS,
|
||||
itemscope: /^(|itemscope)$/,
|
||||
itemtype: /^https?:\/\/schema\.org\/[A-Za-z]+$/,
|
||||
itemprop: SPACE_SEPARATED_TOKENS,
|
||||
style: null,
|
||||
}
|
||||
|
||||
const ALIGN_VALIGN: Record<string, ValueRule> = { align: CELL_ALIGN, valign: CELL_VERTICAL_ALIGN }
|
||||
const SIZE: Record<string, ValueRule> = { height: NUMBER_OR_PERCENT, width: NUMBER_OR_PERCENT }
|
||||
const CELL: Record<string, ValueRule> = {
|
||||
...ALIGN_VALIGN,
|
||||
...SIZE,
|
||||
abbr: PARAGRAPH,
|
||||
colspan: INTEGER,
|
||||
rowspan: INTEGER,
|
||||
headers: SPACE_SEPARATED_TOKENS,
|
||||
scope: /(?:row|col)(?:group)?/i,
|
||||
nowrap: /|nowrap/i,
|
||||
}
|
||||
|
||||
// Attributes allowed on one element, on top of GLOBAL_ATTRS.
|
||||
const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
|
||||
details: { open: /^(|open)$/i },
|
||||
blockquote: { cite: null },
|
||||
q: { cite: null },
|
||||
a: { href: null, role: FOOTNOTE_ROLE },
|
||||
div: {
|
||||
role: FOOTNOTE_ROLE,
|
||||
'data-message-author-role': /^[A-Za-z0-9_-]+$/,
|
||||
'data-message-id': /^[A-Za-z0-9_-]+$/,
|
||||
},
|
||||
map: { name: /^[\p{L}\p{N}_-]+$/u },
|
||||
area: {
|
||||
alt: PARAGRAPH,
|
||||
coords: /^([0-9]+,)+[0-9]+$/,
|
||||
href: null,
|
||||
rel: SPACE_SEPARATED_TOKENS,
|
||||
shape: /^(default|circle|rect|poly)$/i,
|
||||
},
|
||||
figure: { 'data-embed': EMBED_PROVIDER, 'data-id': EMBED_ID },
|
||||
img: { src: null, alt: FREE_TEXT, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu },
|
||||
time: { datetime: ISO8601 },
|
||||
del: { cite: PARAGRAPH, datetime: ISO8601 },
|
||||
ins: { cite: PARAGRAPH, datetime: ISO8601 },
|
||||
ol: { type: LIST_TYPE, start: INTEGER },
|
||||
ul: { type: LIST_TYPE },
|
||||
li: { type: LIST_TYPE, value: INTEGER },
|
||||
table: { ...SIZE, summary: PARAGRAPH },
|
||||
col: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
|
||||
colgroup: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
|
||||
thead: ALIGN_VALIGN,
|
||||
tbody: ALIGN_VALIGN,
|
||||
tfoot: ALIGN_VALIGN,
|
||||
tr: ALIGN_VALIGN,
|
||||
td: CELL,
|
||||
th: CELL,
|
||||
meter: { value: NUMBER, min: NUMBER, max: NUMBER, low: NUMBER, high: NUMBER, optimum: NUMBER },
|
||||
progress: { value: NUMBER, max: NUMBER },
|
||||
}
|
||||
|
||||
// Inline styles: bluemonday's css handlers for the four allowed properties.
|
||||
// A named colour is letters only, so it cannot carry url() or expression().
|
||||
const CSS_LENGTH = /^-?([0-9]+|[0-9]*\.[0-9]+)(%|cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|deg|rad|turn)?$/
|
||||
const RGB_PART = '(([0-9]{1,2}|100)%|([01]?[0-9]{1,2}|2[0-4][0-9]|25[0-5]))'
|
||||
const ALLOWED_STYLES: Record<string, RegExp[]> = {
|
||||
color: [
|
||||
/^[a-z]+$/i,
|
||||
/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
|
||||
new RegExp(`^rgb\\(( *${RGB_PART},){2} *${RGB_PART}\\)$`),
|
||||
new RegExp(`^rgba\\(( *${RGB_PART},){3} *(1(\\.0)?|0|0\\.[0-9]+)\\)$`),
|
||||
/^hsl\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%\)$/,
|
||||
/^hsla\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%, *(1|1\.0|0|0\.[0-9]+)\)$/,
|
||||
],
|
||||
'font-size': [CSS_LENGTH, /^(medium|xx-small|x-small|small|large|x-large|xx-large|smaller|larger|initial|inherit)$/i],
|
||||
'font-weight': [/^(normal|bold|bolder|lighter|[1-9]00|initial|inherit)$/i],
|
||||
'text-align': [/^(left|right|center|justify|initial|inherit)$/i],
|
||||
}
|
||||
|
||||
// Characters a browser drops from a URL before it reads the scheme.
|
||||
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||
|
||||
/**
|
||||
* True when `url` (entity-decoded by the parser) is relative or uses an
|
||||
* allowed scheme once the characters a browser ignores are removed, and
|
||||
* parses as a URL (bluemonday requires a parseable URL too). sanitize-html
|
||||
* checks the scheme as well; checking here keeps the layer's rule from
|
||||
* depending on sanitize-html's defaults.
|
||||
*/
|
||||
function allowedUrl(url: string): boolean {
|
||||
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
|
||||
if (scheme && !JOURNAL_ALLOWED_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
|
||||
// new URL, not URL.canParse, which Safari before 17 lacks.
|
||||
try {
|
||||
new URL(url, 'https://journal.invalid/')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Runs before sanitize-html's own attribute filter: drops every attribute
|
||||
// whose value fails its pattern. sanitize-html then drops attributes that
|
||||
// are not allowed on the element at all. Lookups are own-property only, so
|
||||
// a tag or attribute named "constructor" or "toString" finds no rule.
|
||||
function filterAttributeValues(tagName: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
|
||||
const own = Object.hasOwn(ELEMENT_ATTRS, tagName) ? ELEMENT_ATTRS[tagName] : undefined
|
||||
const kept: sanitizeHtml.Attributes = {}
|
||||
for (const [name, value] of Object.entries(attribs)) {
|
||||
let rule: ValueRule | undefined
|
||||
if (own && Object.hasOwn(own, name)) rule = own[name]
|
||||
else if (Object.hasOwn(GLOBAL_ATTRS, name)) rule = GLOBAL_ATTRS[name]
|
||||
if (rule === undefined) continue
|
||||
if (rule === null ? name === 'style' || allowedUrl(value) : rule.test(value)) kept[name] = value
|
||||
}
|
||||
return { tagName, attribs: kept }
|
||||
}
|
||||
|
||||
const allowedAttributes: Record<string, string[]> = { '*': Object.keys(GLOBAL_ATTRS) }
|
||||
for (const [tag, attrs] of Object.entries(ELEMENT_ATTRS)) allowedAttributes[tag] = Object.keys(attrs)
|
||||
|
||||
// allowedEmptyAttributes is a sanitize-html option that @types/sanitize-html
|
||||
// does not declare yet.
|
||||
type Options = sanitizeHtml.IOptions & { allowedEmptyAttributes: string[] }
|
||||
|
||||
// Attributes whose pattern accepts an empty value. Both configs below write
|
||||
// them as name="", so an empty value compares equal.
|
||||
const EMPTY_ATTRS = ['alt', 'title', 'summary', 'abbr', 'itemscope', 'open', 'nowrap']
|
||||
|
||||
const POLICY: Options = {
|
||||
allowedTags: [...JOURNAL_ALLOWED_TAGS],
|
||||
allowedAttributes,
|
||||
allowedStyles: { '*': ALLOWED_STYLES },
|
||||
allowedSchemes: [...JOURNAL_ALLOWED_SCHEMES],
|
||||
allowedSchemesByTag: {},
|
||||
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
|
||||
allowProtocolRelative: true,
|
||||
allowedEmptyAttributes: EMPTY_ATTRS,
|
||||
// A refused element is dropped and its text kept, except in these, whose
|
||||
// content is never shown as text (bluemonday skips the same content).
|
||||
disallowedTagsMode: 'discard',
|
||||
nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'title', 'iframe', 'noembed', 'noframes', 'object', 'xmp', 'template'],
|
||||
transformTags: { '*': filterAttributeValues },
|
||||
}
|
||||
|
||||
// The same parser and serializer with nothing refused: the reference that
|
||||
// isSafeJournalHtml compares the policy's output with.
|
||||
const KEEP_ALL: Options = {
|
||||
allowedTags: false,
|
||||
allowedAttributes: false,
|
||||
allowVulnerableTags: true,
|
||||
allowedSchemes: [],
|
||||
allowedSchemesAppliedToAttributes: [],
|
||||
nonTextTags: [],
|
||||
allowedEmptyAttributes: EMPTY_ATTRS,
|
||||
nonBooleanAttributes: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate result for a v-html sink: `html` is the input when safe and "" when
|
||||
* not, so the unsafe value never reaches the DOM. Null input is safe and "".
|
||||
* Sanitizes journal HTML with the Journal policy. Whatever the policy does
|
||||
* not allow is removed (the text of a removed element is kept, except for
|
||||
* script, style and similar), so the result is never blank because of one
|
||||
* stray attribute. Null or empty input gives "". Pure and deterministic: the
|
||||
* same input gives the same string on the server and in the browser.
|
||||
*/
|
||||
export function sanitizeJournalHtml(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return sanitizeHtml(html, POLICY)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the policy keeps `html` whole: sanitizing it removes no element,
|
||||
* attribute or text and only normalizes how the markup is written. The
|
||||
* counterpart of the plugin's CheckHTML. Null or empty input is safe.
|
||||
*/
|
||||
export function isSafeJournalHtml(html: string | null | undefined): boolean {
|
||||
return gateJournalHtml(html).safe
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate result for a v-html sink. `html` is always the sanitized value and is
|
||||
* what the sink renders; `safe` reports whether the policy kept the input
|
||||
* whole (isSafeJournalHtml). It never blanks: unsafe parts are removed, not
|
||||
* the value. Null or empty input is safe and "".
|
||||
*/
|
||||
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
|
||||
if (!html) return { safe: true, html: '' }
|
||||
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
|
||||
const clean = sanitizeHtml(html, POLICY)
|
||||
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
21
app/utils/postView.ts
Normal file
21
app/utils/postView.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// The post page's SSR payload: the post with its body sanitized, without the
|
||||
// raw `content` source or `translations`, and with related posts as list
|
||||
// rows. Kept apart from view.ts because it loads the sanitizer, which list
|
||||
// pages and cards do not need.
|
||||
|
||||
import { sanitizeJournalHtml } from './htmlGate.ts'
|
||||
import type { JournalPostDetail } from './types.ts'
|
||||
import { toListPost } from './view.ts'
|
||||
|
||||
/** A post page's data: the post with its body sanitized and no source or translations. */
|
||||
export type JournalPostView = JournalPostDetail
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
return {
|
||||
...post,
|
||||
content: null,
|
||||
translations: null,
|
||||
content_html: sanitizeJournalHtml(post.content_html),
|
||||
related_posts: (post.related_posts ?? []).map(toListPost),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
// View shaping for SSR payloads. Pages run API rows through these in
|
||||
// useAsyncData's transform, so the serialized payload carries only what the
|
||||
// page renders: no raw `content` source, no `translations`, and never an HTML
|
||||
// body that failed the Journal gate.
|
||||
// page renders: no raw `content` source and no `translations`. The post
|
||||
// page's view, which sanitizes the body, is in postView.ts, so list pages and
|
||||
// cards that import this file do not load the sanitizer.
|
||||
|
||||
import { gateJournalHtml } from './htmlGate.ts'
|
||||
import type { JournalPost, JournalPostDetail } from './types.ts'
|
||||
import type { JournalPost } from './types.ts'
|
||||
|
||||
/** A post row as a list or card renders it: without body, source or translations. */
|
||||
export function toListPost(post: JournalPost): JournalPost {
|
||||
return { ...post, content: null, content_html: null, translations: null }
|
||||
}
|
||||
|
||||
/** A post page's data: the gated body, with `content_blocked` set when the gate refused it. */
|
||||
export interface JournalPostView extends JournalPostDetail {
|
||||
content_blocked: boolean
|
||||
}
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
const gated = gateJournalHtml(post.content_html)
|
||||
return {
|
||||
...post,
|
||||
content: null,
|
||||
translations: null,
|
||||
content_html: gated.html,
|
||||
content_blocked: !gated.safe,
|
||||
related_posts: (post.related_posts ?? []).map(toListPost),
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
54
docs/decisions/0001-sanitize-html.md
Normal file
54
docs/decisions/0001-sanitize-html.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Decision: sanitize-html sanitizes Journal HTML in the layer
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Status:** Accepted
|
||||
**Supersedes:** the three-regex gate in `app/utils/htmlGate.ts` (`UNSAFE_TAG`, `EVENT_HANDLER`, `DANGEROUS_URL`)
|
||||
|
||||
## Decision
|
||||
|
||||
The layer depends on `sanitize-html` 2.18.0 (Apostrophe, MIT; released 2026-09-30, the latest version) and on `@types/sanitize-html` 2.16.2 for development. `app/utils/htmlGate.ts` exports `sanitizeJournalHtml(html): string`. Every `v-html` sink and every payload transform passes Journal HTML through it: `JournalPostBody`, `JournalCategoryHeader`, `toPostView` (now in `app/utils/postView.ts`) and the category page.
|
||||
|
||||
The exported names stay. `gateJournalHtml(html)` still returns `{ safe, html }`, but `html` is now always the sanitized string and is never blanked. `safe` (and `isSafeJournalHtml`) report whether the policy kept the input whole. The `blocked` props, the `content_blocked` view field, the `journal.post.contentUnavailable` string and the `.journal-post-body--blocked` style are removed, because nothing is blocked any more.
|
||||
|
||||
## Policy
|
||||
|
||||
The policy mirrors the Journal plugin's bluemonday policy (`classes/format_html.go`, decision note `journal-html-sanitizing-bluemonday.md` in the framework repository):
|
||||
|
||||
- **Elements:** bluemonday's `UGCPolicy` set plus `footer`. These are the sectioning, heading, grouping, phrasing, list, table, `img`, `map`/`area`, `meter` and `progress` elements. `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `base`, `meta`, `link`, `svg`, `math` and comments are removed. The text of a removed element is kept, except inside `script`, `style`, `textarea`, `option`, `noscript`, `title`, `iframe`, `noembed`, `noframes`, `object`, `xmp` and `template`.
|
||||
- **Attributes:** UGC's attributes with UGC's value patterns, ported from `helpers.go` (`dir`, `lang`, `id`, `title` globally; `href`, `src`, `cite`, `alt`, table and list attributes per element). The additions are `class`, `itemscope`, `itemtype` (`https://schema.org/<Type>`) and `itemprop` globally; `role` = `doc-noteref`, `doc-endnotes` or `doc-backlink` on `a` and `div`; `dir` = `ltr`, `rtl` or `auto`; and `data-message-author-role` and `data-message-id` on `div`. An attribute whose value fails its pattern is removed. Every `on*` attribute is removed, as are `target` and `rel` on `a`.
|
||||
- **Blog Editor output (Phase 16.2, amended 2026-10-10):** four rules for the Journal plugin's Blog Editor, the same four its Go policy gained: `data-embed` = `youtube` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder `<figure data-embed="youtube" data-id="ID"></figure>`, which `app/utils/embeds.ts` expands into a youtube-nocookie iframe after sanitizing), `alt` with any text on `img` (the editor's free-text alt field; UGC's pattern cuts it at `:`, `?` and `"`, and sanitize-html escapes the value), and `start` as an integer on `ol` (lists that do not begin at 1). They are the editor's own output, pinned by `tests/fixtures/editor-corpus.json`, a byte copy of the editor's corpus that the Go policy test reads too: every entry is safe here. No other data attribute, no `iframe`, no new URL scheme and no new style is allowed, and `alt` on `area` keeps UGC's pattern.
|
||||
- **Inline styles:** only `color`, `font-size`, `font-weight` and `text-align`, with the values that bluemonday's CSS handlers accept. A named colour must be letters only, so `url()` and `expression()` never pass.
|
||||
- **URLs** in `href`, `src` and `cite` must be `http`, `https`, `mailto`, relative or protocol-relative, and must parse as a URL. The scheme is read after entity decoding and after removing the characters a browser ignores (ASCII control characters, space and DEL). `javascript:`, `javascript:` and `java<TAB>script:` are all refused.
|
||||
|
||||
**Sanitize, don't blank.** A value is never refused as a whole. Text such as "Data: 12 maja 2024" or "metadata:" is ordinary text, not a URL, so it always renders.
|
||||
|
||||
**The output is always the sanitizer's own serialization, never the input bytes.** The Go side returns the input unchanged when the policy keeps it whole, which is safe there because `golang.org/x/net/html` tokenizes the way a browser does. htmlparser2 is not a spec parser, so returning the input could hand the browser markup it reads differently from how the policy read it. The browser only ever parses what the policy checked.
|
||||
|
||||
## Why sanitize-html
|
||||
|
||||
| | sanitize-html 2.18.0 | isomorphic-dompurify 4.5.0 (DOMPurify 3.4.16 + jsdom 30.1.2) |
|
||||
|---|---|---|
|
||||
| Server (Nitro) | Pure JavaScript (htmlparser2, postcss). No DOM. | Needs jsdom: about 5.5 MB unpacked plus its dependency tree, and a full window object in the Nitro process. jsdom 30 and isomorphic-dompurify 4.5 require Node `^22.22.2`, but `.nvmrc` pins 22.22.0. |
|
||||
| Server/client parity | The same parser and serializer run on both sides, so the same input gives the same string (checked in Chromium: the hydrated SSR body and the body after client-side navigation have identical `innerHTML`). | jsdom parses on the server and the browser's DOM on the client. The two can serialize edge cases and CSS differently (jsdom's `cssstyle` and the browser's CSSOM normalize colours differently). |
|
||||
| Policy fit | Declarative: per-element attribute lists, `allowedStyles` with a regex per CSS property, `allowedSchemes`, and `transformTags` to check attribute values. It maps onto bluemonday rule by rule. | No per-attribute value patterns and no CSS property filter. Mirroring the server policy would need hooks that the project writes and owns (`uponSanitizeAttribute` plus a hand-written style filter), which is exactly the security code the project prefers to leave to a library. |
|
||||
| Client bundle | About 157 KB minified, 59 KB gzipped in the playground build. It loads only on the post and category pages (see below). | About 30 KB minified, 11 KB gzipped (DOMPurify alone). |
|
||||
| Maintenance | Apostrophe; a release almost every month in 2026 (2.17.1 in February to 2.18.0 in September), including recent fixes such as the `meta refresh` URL check (GHSA-cv27-6wvh-8x7j). | Cure53 (DOMPurify) plus a single maintainer for the isomorphic wrapper. |
|
||||
|
||||
DOMPurify is the stronger choice for a browser-only sanitizer, and it costs about a fifth of the client bytes. For this layer, every page is rendered on the server first, so the server side decides. There, jsdom is a heavy runtime dependency that conflicts with the pinned Node version, and two different DOMs would sanitize the same body differently. sanitize-html is the same code everywhere and expresses the server policy directly.
|
||||
|
||||
**Bundle cost, contained.** `toPostView` lives in `app/utils/postView.ts`, apart from `view.ts`. List pages, cards and an app homepage that use `toListPost` or `formatJournalDate` therefore do not load the sanitizer. In the playground build, the chunk with sanitize-html is a dependency of `PostPage` and `CategoryPage` only. Most of its size is postcss (about 56 KB minified), which sanitize-html uses to parse `style` attributes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **isomorphic-dompurify:** see the table above.
|
||||
- **DOMPurify on the client and sanitize-html on the server:** this means two policies to keep in step and two different outputs for the same body. Rejected.
|
||||
- **xss (js-xss) 1.0.15:** small, but it has one maintainer, its tag-attribute whitelist cannot express per-value patterns without callbacks we would write, and its CSS filter is a separate package. Rejected for the same reason as hand-built hooks.
|
||||
- **Keep the regex gate and anchor it to attribute values:** this fixes the false refusal of "Data:" text but none of the bypasses. Rejected.
|
||||
- **Sanitize only on the server and proxy client-side API calls through Nitro:** this saves the client bytes, but it changes how client navigation reaches the API (today it goes straight to `/_journal/api/v1`). Worth revisiting if the 59 KB matters. It needs its own decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unsafe body now renders without its unsafe parts and with all of its safe text. The smoke expectation for `/blog/unsafe-sample` checks for `<p>Before</p><p>After</p>` instead of the blocked notice.
|
||||
- Content that relied on an embedded `<style>` block loses it, exactly as it does on the server's next save. Its CSS belongs in the app, scoped under `.journal-post-body` (the imported author footer of the first consuming application is an example).
|
||||
- An app that still renders `gateJournalHtml(...).html` keeps working and now gets sanitized HTML. An app test that expects `journal-post-body--blocked` or the `journal.post.contentUnavailable` string has to change when the app moves to this version.
|
||||
- `isSafeJournalHtml` compares the policy's output with the same serializer's output when nothing is refused. Comments are dropped by both, so a comment alone does not make a value unsafe.
|
||||
38
i18n/locales/en.json
Normal file
38
i18n/locales/en.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
i18n/locales/pl.json
Normal file
38
i18n/locales/pl.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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,12 +1,13 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev .playground",
|
||||
"build:playground": "nuxt build .playground",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts",
|
||||
"dev:prepare": "nuxt prepare .playground",
|
||||
"check:git-install": "bash scripts/check-git-install.sh"
|
||||
},
|
||||
@@ -16,12 +17,14 @@
|
||||
"dependencies": {
|
||||
"@nuxt/fonts": "0.14.0",
|
||||
"@nuxtjs/i18n": "10.4.0",
|
||||
"@nuxtjs/seo": "5.2.1"
|
||||
"@nuxtjs/seo": "5.2.1",
|
||||
"sanitize-html": "2.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nuxt": "4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.2",
|
||||
"nuxt": "4.4.8",
|
||||
"typescript": "5.9.3",
|
||||
"vue": "3.5.35"
|
||||
|
||||
101
pnpm-lock.yaml
generated
101
pnpm-lock.yaml
generated
@@ -17,7 +17,13 @@ importers:
|
||||
'@nuxtjs/seo':
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1(8fed33377b96602d2a15d56f31c71fb1)
|
||||
sanitize-html:
|
||||
specifier: 2.18.0
|
||||
version: 2.18.0
|
||||
devDependencies:
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.2
|
||||
version: 2.16.2
|
||||
nuxt:
|
||||
specifier: 4.4.8
|
||||
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.151.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(@types/node@26.6.4)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1))(yaml@2.9.1)
|
||||
@@ -2408,6 +2414,9 @@ packages:
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
'@types/sanitize-html@2.16.2':
|
||||
resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -2936,6 +2945,9 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
dayjs@1.11.23:
|
||||
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||
|
||||
db0@0.3.4:
|
||||
resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==}
|
||||
peerDependencies:
|
||||
@@ -3023,16 +3035,32 @@ packages:
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
dom-serializer@3.1.1:
|
||||
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domelementtype@3.0.0:
|
||||
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domhandler@6.0.1:
|
||||
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
domutils@4.0.2:
|
||||
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
dot-prop@10.2.0:
|
||||
resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3074,6 +3102,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@8.1.0:
|
||||
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
@@ -3399,6 +3431,10 @@ packages:
|
||||
hookified@2.2.0:
|
||||
resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}
|
||||
|
||||
htmlparser2@12.0.0:
|
||||
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3503,6 +3539,10 @@ packages:
|
||||
resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-object@5.1.0:
|
||||
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-reference@1.2.1:
|
||||
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
|
||||
|
||||
@@ -3589,6 +3629,9 @@ packages:
|
||||
launch-editor@2.14.2:
|
||||
resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==}
|
||||
|
||||
launder@1.7.2:
|
||||
resolution: {integrity: sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==}
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
@@ -4114,6 +4157,9 @@ packages:
|
||||
package-manager-detector@1.9.0:
|
||||
resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
|
||||
|
||||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4464,6 +4510,10 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
sanitize-html@2.18.0:
|
||||
resolution: {integrity: sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -7448,6 +7498,10 @@ snapshots:
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/sanitize-html@2.16.2':
|
||||
dependencies:
|
||||
htmlparser2: 12.0.0
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/project-service@8.71.1(typescript@5.9.3)':
|
||||
@@ -8073,6 +8127,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
dayjs@1.11.23: {}
|
||||
|
||||
db0@0.3.4: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -8116,18 +8172,36 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
dom-serializer@3.1.1:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
entities: 8.1.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domelementtype@3.0.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domhandler@6.0.1:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
domutils@4.0.2:
|
||||
dependencies:
|
||||
dom-serializer: 3.1.1
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
|
||||
dot-prop@10.2.0:
|
||||
dependencies:
|
||||
type-fest: 5.10.0
|
||||
@@ -8154,6 +8228,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.1.0: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
error-stack-parser-es@2.0.1: {}
|
||||
@@ -8605,6 +8681,13 @@ snapshots:
|
||||
|
||||
hookified@2.2.0: {}
|
||||
|
||||
htmlparser2@12.0.0:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
domutils: 4.0.2
|
||||
entities: 8.1.0
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -8706,6 +8789,8 @@ snapshots:
|
||||
|
||||
is-path-inside@4.0.0: {}
|
||||
|
||||
is-plain-object@5.1.0: {}
|
||||
|
||||
is-reference@1.2.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -8776,6 +8861,10 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
shell-quote: 1.12.0
|
||||
|
||||
launder@1.7.2:
|
||||
dependencies:
|
||||
dayjs: 1.11.23
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
@@ -9999,6 +10088,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.9.0: {}
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@@ -10340,6 +10431,16 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
sanitize-html@2.18.0:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
escape-string-regexp: 4.0.0
|
||||
htmlparser2: 12.0.0
|
||||
is-plain-object: 5.1.0
|
||||
launder: 1.7.2
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.29
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
@@ -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,45 @@
|
||||
# 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
|
||||
200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ
|
||||
404 /blog/missing-post
|
||||
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
|
||||
200 /blog/unsafe-sample contains=<p>Before</p><p>After</p> 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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"slug": "hello-acme",
|
||||
"excerpt": "Acme says hello and introduces its blog.",
|
||||
"content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2026-01-14T09:00:00+00:00",
|
||||
|
||||
@@ -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()
|
||||
|
||||
185
tests/client.test.ts
Normal file
185
tests/client.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// The Journal client against a fake fetcher: query building, include_empty,
|
||||
// category lookup and 404 handling. No network.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
JOURNAL_DEFAULT_PER_PAGE,
|
||||
JOURNAL_MAX_PER_PAGE,
|
||||
buildPostListQuery,
|
||||
clampPerPage,
|
||||
createJournalClient,
|
||||
encodeSlugPath,
|
||||
flattenCategories,
|
||||
isNotFound,
|
||||
} from '../app/utils/client.ts'
|
||||
import type { JournalCategory } from '../app/utils/types.ts'
|
||||
|
||||
type Call = { path: string; query?: Record<string, string | number> }
|
||||
|
||||
// A fetcher that records each call and answers from `routes` by path; a
|
||||
// function value may throw to simulate an HTTP error.
|
||||
function fakeFetcher(routes: Record<string, unknown | (() => unknown)>) {
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string | number>) => {
|
||||
calls.push(query === undefined ? { path } : { path, query })
|
||||
if (!(path in routes)) throw Object.assign(new Error(`no route ${path}`), { status: 404 })
|
||||
const value = routes[path]
|
||||
return typeof value === 'function' ? (value as () => unknown)() : value
|
||||
}
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
|
||||
const row: JournalCategory = {
|
||||
id,
|
||||
name: slug,
|
||||
slug,
|
||||
description: null,
|
||||
content: null,
|
||||
parent_id: null,
|
||||
nest_depth: 0,
|
||||
post_count: 0,
|
||||
}
|
||||
if (children) row.children = children
|
||||
return row
|
||||
}
|
||||
|
||||
const TREE = [category(1, 'grzyby', [category(2, 'cordyceps', [category(3, 'militaris')])]), category(4, 'chaga')]
|
||||
|
||||
test('the per_page limits match the API', () => {
|
||||
assert.equal(JOURNAL_MAX_PER_PAGE, 30)
|
||||
assert.equal(JOURNAL_DEFAULT_PER_PAGE, 9)
|
||||
})
|
||||
|
||||
test('clampPerPage clamps to 1..30 and defaults to 9', () => {
|
||||
assert.equal(clampPerPage(0), 1)
|
||||
assert.equal(clampPerPage(-5), 1)
|
||||
assert.equal(clampPerPage(31), 30)
|
||||
assert.equal(clampPerPage(12), 12)
|
||||
assert.equal(clampPerPage(12.7), 12)
|
||||
assert.equal(clampPerPage('12'), 12)
|
||||
assert.equal(clampPerPage('abc'), 9)
|
||||
assert.equal(clampPerPage(''), 9)
|
||||
assert.equal(clampPerPage(undefined), 9)
|
||||
assert.equal(clampPerPage(Number.NaN), 9)
|
||||
})
|
||||
|
||||
test('buildPostListQuery always sends per_page and the rest only when set', () => {
|
||||
assert.deepEqual(buildPostListQuery(), { per_page: 9 })
|
||||
assert.deepEqual(
|
||||
buildPostListQuery({ page: 2, perPage: 12, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' }),
|
||||
{ per_page: 12, page: 2, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' },
|
||||
)
|
||||
})
|
||||
|
||||
test('buildPostListQuery drops an invalid page and empty strings', () => {
|
||||
assert.deepEqual(buildPostListQuery({ page: 0, sort: '', category: '', tag: '' }), { per_page: 9 })
|
||||
assert.deepEqual(buildPostListQuery({ page: 1.5 }), { per_page: 9 })
|
||||
assert.deepEqual(buildPostListQuery({ perPage: 100 }), { per_page: 30 })
|
||||
})
|
||||
|
||||
test('encodeSlugPath keeps / as a separator', () => {
|
||||
assert.equal(encodeSlugPath('a b/c?d'), 'a%20b/c%3Fd')
|
||||
})
|
||||
|
||||
test('flattenCategories walks parents before children', () => {
|
||||
assert.deepEqual(flattenCategories(TREE).map((c) => c.slug), ['grzyby', 'cordyceps', 'militaris', 'chaga'])
|
||||
assert.deepEqual(flattenCategories(null), [])
|
||||
})
|
||||
|
||||
test('isNotFound reads status, statusCode and response.status', () => {
|
||||
assert.equal(isNotFound({ status: 404 }), true)
|
||||
assert.equal(isNotFound({ statusCode: 404 }), true)
|
||||
assert.equal(isNotFound({ response: { status: 404 } }), true)
|
||||
assert.equal(isNotFound({ status: 500 }), false)
|
||||
assert.equal(isNotFound(new Error('boom')), false)
|
||||
assert.equal(isNotFound(null), false)
|
||||
assert.equal(isNotFound('404'), false)
|
||||
})
|
||||
|
||||
test('listCategories always sends include_empty=1', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({ '/categories': { data: TREE } })
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.listCategories(), TREE)
|
||||
await client.findCategory('chaga')
|
||||
assert.equal(calls.length, 2)
|
||||
for (const call of calls) {
|
||||
assert.equal(call.path, '/categories')
|
||||
assert.deepEqual(call.query, { include_empty: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
test('listCategories gives [] for a malformed body', async () => {
|
||||
const { fetcher } = fakeFetcher({ '/categories': { data: 'nope' } })
|
||||
assert.deepEqual(await createJournalClient(fetcher).listCategories(), [])
|
||||
})
|
||||
|
||||
test('findCategory finds a nested child and null for an unknown slug', async () => {
|
||||
const { fetcher } = fakeFetcher({ '/categories': { data: TREE } })
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.equal((await client.findCategory('militaris'))?.id, 3)
|
||||
assert.equal(await client.findCategory('reishi'), null)
|
||||
})
|
||||
|
||||
test('listPosts sends the built query', async () => {
|
||||
const page = { data: [], meta: { current_page: 2, last_page: 2, per_page: 12, total: 13 } }
|
||||
const { fetcher, calls } = fakeFetcher({ '/posts': page })
|
||||
const res = await createJournalClient(fetcher).listPosts({ page: 2, perPage: 12, category: 'cordyceps' })
|
||||
assert.deepEqual(res, page)
|
||||
assert.deepEqual(calls, [{ path: '/posts', query: { per_page: 12, page: 2, category: 'cordyceps' } }])
|
||||
})
|
||||
|
||||
test('getPost returns the data and encodes the slug', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({ '/posts/a%20b': { data: { id: 1, slug: 'a b' } } })
|
||||
const post = await createJournalClient(fetcher).getPost('a b')
|
||||
assert.equal(post?.id, 1)
|
||||
assert.equal(calls[0]?.path, '/posts/a%20b')
|
||||
})
|
||||
|
||||
test('getPost returns null for status 404 and statusCode 404', async () => {
|
||||
const byStatus = fakeFetcher({
|
||||
'/posts/gone': () => {
|
||||
throw Object.assign(new Error('Not Found'), { status: 404 })
|
||||
},
|
||||
})
|
||||
assert.equal(await createJournalClient(byStatus.fetcher).getPost('gone'), null)
|
||||
const byStatusCode = fakeFetcher({
|
||||
'/posts/gone': () => {
|
||||
throw Object.assign(new Error('Not Found'), { statusCode: 404 })
|
||||
},
|
||||
})
|
||||
assert.equal(await createJournalClient(byStatusCode.fetcher).getPost('gone'), null)
|
||||
})
|
||||
|
||||
test('getPost rethrows a 500', async () => {
|
||||
const { fetcher } = fakeFetcher({
|
||||
'/posts/boom': () => {
|
||||
throw Object.assign(new Error('Server Error'), { status: 500 })
|
||||
},
|
||||
})
|
||||
await assert.rejects(createJournalClient(fetcher).getPost('boom'), /Server Error/)
|
||||
})
|
||||
|
||||
test('listTags returns the data or []', async () => {
|
||||
const tags = [{ id: 1, name: 'zdrowie', slug: 'zdrowie' }]
|
||||
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': { data: tags } }).fetcher).listTags(), tags)
|
||||
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': {} }).fetcher).listTags(), [])
|
||||
})
|
||||
|
||||
test('getAuthor and listAuthorPosts encode the author and map 404 to null', async () => {
|
||||
const author = { id: 7, slug: 'jan kowalski', first_name: 'Jan', last_name: 'Kowalski' }
|
||||
const page = { data: [], meta: { current_page: 1, last_page: 1, per_page: 10, total: 0 } }
|
||||
const { fetcher, calls } = fakeFetcher({
|
||||
'/authors/jan%20kowalski': { data: author },
|
||||
'/authors/jan%20kowalski/posts': page,
|
||||
})
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.getAuthor('jan kowalski'), author)
|
||||
assert.deepEqual(await client.listAuthorPosts('jan kowalski', { page: 2, perPage: 10, sort: 'published_at desc' }), page)
|
||||
assert.deepEqual(calls[1], {
|
||||
path: '/authors/jan%20kowalski/posts',
|
||||
query: { per_page: 10, page: 2, sort: 'published_at desc' },
|
||||
})
|
||||
assert.equal(await client.getAuthor('nobody'), null)
|
||||
assert.equal(await client.listAuthorPosts('nobody'), null)
|
||||
})
|
||||
120
tests/embeds.test.ts
Normal file
120
tests/embeds.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Embed expansion (Phase 16.2, D-17). The Blog Editor stores the placeholder
|
||||
// <figure data-embed="youtube" data-id="ID"></figure>; the sinks render
|
||||
// expandEmbeds(sanitizeJournalHtml(html)), so the sanitizer runs first and the
|
||||
// only iframe in the output is the one built from a validated placeholder.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { EMBED_PLACEHOLDER, EMBED_PROVIDERS, expandEmbeds } from '../app/utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
||||
|
||||
const ID = 'dQw4w9WgXcQ'
|
||||
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
||||
const IFRAME =
|
||||
'<figure class="journal-embed journal-embed--youtube">' +
|
||||
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}" title="YouTube video" loading="lazy" ` +
|
||||
'referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
||||
'</figure>'
|
||||
|
||||
// The sink: sanitize, then expand last.
|
||||
const sink = (html: string) => expandEmbeds(sanitizeJournalHtml(html))
|
||||
// The payload transform sanitizes, and the sink sanitizes again before expanding.
|
||||
const payloadThenSink = (html: string) => expandEmbeds(sanitizeJournalHtml(sanitizeJournalHtml(html)))
|
||||
|
||||
const iframeCount = (html: string) => (html.match(/<iframe/g) ?? []).length
|
||||
|
||||
test('the placeholder expands to the privacy-enhanced YouTube iframe', () => {
|
||||
assert.equal(expandEmbeds(PLACEHOLDER), IFRAME)
|
||||
assert.equal(expandEmbeds(`<p>a</p>${PLACEHOLDER}<p>b</p>`), `<p>a</p>${IFRAME}<p>b</p>`)
|
||||
})
|
||||
|
||||
test('the provider table holds YouTube only, on youtube-nocookie.com', () => {
|
||||
assert.deepEqual(Object.keys(EMBED_PROVIDERS), ['youtube'])
|
||||
assert.equal(EMBED_PROVIDERS.youtube!.src(ID), `https://www.youtube-nocookie.com/embed/${ID}`)
|
||||
assert.ok(EMBED_PLACEHOLDER.global)
|
||||
})
|
||||
|
||||
test('malformed or unknown placeholders stay unchanged and inert', () => {
|
||||
const inert = [
|
||||
`<figure data-embed="youtube" data-id="${ID.slice(0, 10)}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}Q"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="dQw4w9WgX/Q"></figure>`,
|
||||
`<figure data-embed="vimeo" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="constructor" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="__proto__" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`,
|
||||
`<figure class="x" data-embed="youtube" data-id="${ID}"></figure>`,
|
||||
`<figure data-id="${ID}" data-embed="youtube"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}"><figcaption>c</figcaption></figure>`,
|
||||
`<figure data-embed='youtube' data-id='${ID}'></figure>`,
|
||||
]
|
||||
for (const html of inert) assert.equal(expandEmbeds(html), html, html)
|
||||
})
|
||||
|
||||
test('empty input gives ""', () => {
|
||||
for (const html of [null, undefined, '']) assert.equal(expandEmbeds(html), '')
|
||||
})
|
||||
|
||||
test('the sink turns a stored placeholder into the iframe', () => {
|
||||
assert.equal(sink(PLACEHOLDER), IFRAME)
|
||||
assert.equal(sink(`<h2>Title</h2>\n${PLACEHOLDER}\n<p>After</p>`), `<h2>Title</h2>\n${IFRAME}\n<p>After</p>`)
|
||||
})
|
||||
|
||||
test('a raw iframe in stored HTML is stripped; only the placeholder becomes an iframe', () => {
|
||||
const stored =
|
||||
'<p>Before</p>' +
|
||||
'<iframe src="https://evil.test/"></iframe>' +
|
||||
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}"></iframe>` +
|
||||
PLACEHOLDER +
|
||||
'<p>After</p>'
|
||||
for (const run of [sink, payloadThenSink]) {
|
||||
const out = run(stored)
|
||||
assert.equal(out, `<p>Before</p>${IFRAME}<p>After</p>`)
|
||||
assert.equal(iframeCount(out), 1)
|
||||
assert.doesNotMatch(out, /evil\.test/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a placeholder smuggling an iframe or extra attributes expands only its validated parts', () => {
|
||||
const stored = `<figure data-embed="youtube" data-id="${ID}" data-src="https://evil.test/" onload="alert(1)"><iframe src="https://evil.test/"></iframe></figure>`
|
||||
assert.equal(sink(stored), IFRAME)
|
||||
assert.equal(payloadThenSink(stored), IFRAME)
|
||||
})
|
||||
|
||||
test('a malformed placeholder stays an empty figure after the sink', () => {
|
||||
assert.equal(sink(`<figure data-embed="youtube" data-id="short"></figure>`), '<figure data-embed="youtube"></figure>')
|
||||
assert.equal(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`), `<figure data-id="${ID}"></figure>`)
|
||||
assert.equal(iframeCount(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`)), 0)
|
||||
})
|
||||
|
||||
test('the payload-then-sink pipeline gives the same result as the sink', () => {
|
||||
const stored = `<p>x</p>${PLACEHOLDER}<script>alert(1)</script><p>y</p>`
|
||||
assert.equal(payloadThenSink(stored), sink(stored))
|
||||
assert.equal(sink(stored), `<p>x</p>${IFRAME}<p>y</p>`)
|
||||
})
|
||||
|
||||
test('two placeholders in one body both expand', () => {
|
||||
const other = 'abcdefghijk'
|
||||
const out = sink(`${PLACEHOLDER}<p>between</p><figure data-embed="youtube" data-id="${other}"></figure>`)
|
||||
assert.equal(iframeCount(out), 2)
|
||||
assert.equal(out, `${IFRAME}<p>between</p>${IFRAME.replace(ID, other)}`)
|
||||
})
|
||||
|
||||
test('the editor corpus embed entries expand after sanitizing', () => {
|
||||
const corpus = JSON.parse(
|
||||
readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'),
|
||||
) as Array<{ name: string; html: string }>
|
||||
const embed = corpus.find((e) => e.name === 'embed-youtube')
|
||||
assert.ok(embed)
|
||||
assert.equal(payloadThenSink(embed.html), IFRAME)
|
||||
const combined = corpus.find((e) => e.name === 'combined')
|
||||
assert.ok(combined)
|
||||
const out = payloadThenSink(combined.html)
|
||||
assert.ok(out.startsWith(IFRAME), out)
|
||||
assert.equal(iframeCount(out), 1)
|
||||
// Entries without a placeholder pass through the expansion unchanged.
|
||||
for (const entry of corpus.filter((e) => !e.html.includes('data-embed'))) {
|
||||
const clean = sanitizeJournalHtml(entry.html)
|
||||
assert.equal(expandEmbeds(clean), clean, entry.name)
|
||||
}
|
||||
})
|
||||
62
tests/fixtures/editor-corpus.json
vendored
Normal file
62
tests/fixtures/editor-corpus.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"name": "embed-youtube",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>"
|
||||
},
|
||||
{
|
||||
"name": "image-without-caption",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "image-with-caption",
|
||||
"html": "<figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>"
|
||||
},
|
||||
{
|
||||
"name": "table-header-row",
|
||||
"html": "<table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table>"
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"html": "<p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "headings",
|
||||
"html": "<h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4>"
|
||||
},
|
||||
{
|
||||
"name": "bullet-list",
|
||||
"html": "<ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list",
|
||||
"html": "<ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "blockquote",
|
||||
"html": "<blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list-start-3",
|
||||
"html": "<ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "inline-marks",
|
||||
"html": "<p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"html": "<pre><code>dawka = 2 * 500 // mg</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "horizontal-rule",
|
||||
"html": "<p>Przed linią</p><hr><p>Po linii</p>"
|
||||
},
|
||||
{
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
}
|
||||
]
|
||||
294
tests/htmlGate.test.ts
Normal file
294
tests/htmlGate.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// The Journal HTML sanitizer mirrors the Journal plugin's bluemonday policy
|
||||
// (classes/format_html.go). The unsafe cases are the six of
|
||||
// TestFormatHTMLRejectsUnsafeHTML plus every bypass from the Phase 16.1 code
|
||||
// review (CR-01), as rendered HTML. Each one must come out without the
|
||||
// dangerous part while the safe text around it stays.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
||||
|
||||
// Signs that something executable or navigating survived.
|
||||
const DANGER = [
|
||||
/<script/i,
|
||||
/<svg/i,
|
||||
/<math/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
/<base/i,
|
||||
/<meta/i,
|
||||
/<form/i,
|
||||
/<input/i,
|
||||
/<style/i,
|
||||
/\son[a-z]+\s*=/i,
|
||||
/javascript/i,
|
||||
/vbscript/i,
|
||||
/href="data:/i,
|
||||
/src="data:/i,
|
||||
/xlink:href/i,
|
||||
/evil\.test/i,
|
||||
]
|
||||
|
||||
function assertNeutralized(html: string, label: string): string {
|
||||
const out = sanitizeJournalHtml(html)
|
||||
for (const re of DANGER) assert.doesNotMatch(out, re, `${label}: ${out}`)
|
||||
assert.equal(isSafeJournalHtml(html), false, `${label} reported safe`)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: false, html: out }, label)
|
||||
return out
|
||||
}
|
||||
|
||||
const GO_CASES: Array<[string, string]> = [
|
||||
['script', '<p>keep</p><script>alert(1)</script>'],
|
||||
['iframe', '<p>keep</p><iframe src="https://evil.test"></iframe>'],
|
||||
['event', '<p>keep<img src=x onerror="alert(1)"></p>'],
|
||||
['javascript', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['vbscript', '<a href="vbscript:msgbox(1)">keep</a>'],
|
||||
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">keep</a>'],
|
||||
]
|
||||
|
||||
// CR-01: payloads the three-regex gate let through, and their neighbours.
|
||||
const REVIEW_BYPASSES: Array<[string, string]> = [
|
||||
['attribute without a space', '<p>keep<img src="x"onerror="alert(1)"></p>'],
|
||||
['svg with slash separator', '<div>\n<svg/onload=alert(1)>\n</div><p>keep</p>'],
|
||||
['numeric entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['named entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['entity in the scheme', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['tab in the scheme', '<a href="java\tscript:alert(1)">keep</a>'],
|
||||
['newline in the scheme', '<a href="java\nscript:alert(1)">keep</a>'],
|
||||
['leading control characters', '<a href="\u0001 javascript:alert(1)">keep</a>'],
|
||||
['upper case scheme', '<a href="JAVASCRIPT:alert(1)">keep</a>'],
|
||||
['MathML xlink:href', '<div>\n<math><maction actiontype=statusline xlink:href="javascript:alert(1)">keep</maction></math>\n</div>'],
|
||||
['base', '<base href="https://evil.test/"><p>keep</p>'],
|
||||
['meta refresh', '<meta http-equiv="refresh" content="0;url=https://evil.test"><p>keep</p>'],
|
||||
['form phish', '<form action="https://evil.test"><input type=password name=p></form><p>keep</p>'],
|
||||
['embed', '<embed src="https://evil.test/x.swf"><p>keep</p>'],
|
||||
['object', '<object data="https://evil.test/x"></object><p>keep</p>'],
|
||||
['data image src', '<p>keep<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="></p>'],
|
||||
['style element', '<style>body{background:url(https://evil.test/x)}</style><p>keep</p>'],
|
||||
['css url in style attribute', '<p style="background:url(https://evil.test/x)">keep</p>'],
|
||||
['css expression in colour', '<p style="color: expression(alert(1))">keep</p>'],
|
||||
['noscript title breakout', '<noscript><p title="</noscript><img src=x onerror=alert(1)>"></p></noscript><p>keep</p>'],
|
||||
['handler in upper case', '<P ONMOUSEOVER="alert(1)">keep</P>'],
|
||||
]
|
||||
|
||||
for (const [name, html] of [...GO_CASES, ...REVIEW_BYPASSES]) {
|
||||
test(`${name} is neutralized and the rest is kept`, () => {
|
||||
const out = assertNeutralized(html, name)
|
||||
assert.match(out, /keep/, `${name} lost the safe text: ${out}`)
|
||||
})
|
||||
}
|
||||
|
||||
test('the Go cases in upper case are neutralized', () => {
|
||||
for (const [name, html] of GO_CASES) assertNeutralized(html.toUpperCase(), `${name} upper`)
|
||||
})
|
||||
|
||||
test('comments are dropped; a comment is not content, so the input still counts as safe', () => {
|
||||
const html = '<!--<img src=x onerror=alert(1)>--><p>keep</p>'
|
||||
assert.equal(sanitizeJournalHtml(html), '<p>keep</p>')
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '<p>keep</p>' })
|
||||
})
|
||||
|
||||
test('unsafe parts are removed, not the whole value', () => {
|
||||
assert.equal(sanitizeJournalHtml('<p>Before</p><script>alert(1)</script><p>After</p>'), '<p>Before</p><p>After</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p><img src="x"onerror="alert(1)"> tekst</p>'), '<p><img src="x" /> tekst</p>')
|
||||
assert.equal(sanitizeJournalHtml('<a href="javascript:alert(1)">link</a>'), '<a>link</a>')
|
||||
})
|
||||
|
||||
test('text that names a scheme is ordinary text (WR-01)', () => {
|
||||
const texts = [
|
||||
'<p>Data: 12 maja 2024</p>',
|
||||
'<p>Data publikacji: 3 czerwca 2025</p>',
|
||||
'<p>The metadata: title, author and date.</p>',
|
||||
'<p>Big data: what it means. JavaScript: a language. vbscript: legacy.</p>',
|
||||
]
|
||||
for (const html of texts) {
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(isSafeJournalHtml(html), true, html)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
|
||||
}
|
||||
})
|
||||
|
||||
// The author footer of an imported post (post 64), without its <style> block.
|
||||
const AUTHOR_FOOTER = `<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="author-info">
|
||||
<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image">
|
||||
<div class="author-details">
|
||||
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jane Doe</div>
|
||||
<span style="color: #138c74;" itemprop="jobTitle">Ekspert Mykologii</span>
|
||||
<p itemprop="description">Jestem entuzjastką grzybów, szczególnie tych funkcjonalnych.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`
|
||||
|
||||
test('an imported author footer keeps classes, microdata and inline styles', () => {
|
||||
const out = sanitizeJournalHtml(AUTHOR_FOOTER)
|
||||
assert.equal(isSafeJournalHtml(AUTHOR_FOOTER), true)
|
||||
for (const part of [
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">',
|
||||
'<div class="author-info">',
|
||||
'<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image" />',
|
||||
'<div style="font-size:1.5em;font-weight:bold" itemprop="name">Jane Doe</div>',
|
||||
'<span style="color:#138c74" itemprop="jobTitle">Ekspert Mykologii</span>',
|
||||
'<p itemprop="description">',
|
||||
]) {
|
||||
assert.ok(out.includes(part), `missing ${part} in ${out}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the footer with its <style> block loses only the style block', () => {
|
||||
const html = `${AUTHOR_FOOTER}\n<style>\n .author-footer { padding: 20px; }\n</style>\n<hr>`
|
||||
const out = sanitizeJournalHtml(html)
|
||||
assert.equal(isSafeJournalHtml(html), false)
|
||||
assert.doesNotMatch(out, /<style|padding/)
|
||||
assert.equal(out, `${sanitizeJournalHtml(AUTHOR_FOOTER)}\n\n<hr />`)
|
||||
})
|
||||
|
||||
test('inline styles keep only the four allowed properties with valid values', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<p style="color: red; font-size: 12px; font-weight: 700; text-align: center; position: fixed">x</p>'),
|
||||
'<p style="color:red;font-size:12px;font-weight:700;text-align:center">x</p>',
|
||||
)
|
||||
assert.equal(sanitizeJournalHtml('<p style="color: rgb(1, 2, 3)">x</p>'), '<p style="color:rgb(1, 2, 3)">x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p style="font-size: calc(1px + 1em)">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p style="position: fixed">x</p>'), '<p>x</p>')
|
||||
})
|
||||
|
||||
test('goldmark markup passes whole: headings, tables, footnotes, links and images', () => {
|
||||
const safe = [
|
||||
'<h1>Hello</h1>',
|
||||
'<table><thead><tr><th>a</th><th style="text-align:right">b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
|
||||
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>',
|
||||
'<div class="footnotes" role="doc-endnotes"><hr /><ol><li id="fn:1"><p>Note. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p></li></ol></div>',
|
||||
'<p><a href="https://blog.acme.test/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las" /></p>',
|
||||
'<p><a href="mailto:hello@acme.test">mail</a> <a href="//cdn.acme.test/x">cdn</a> <a href="?page=2">page</a></p>',
|
||||
'<blockquote cite="https://acme.test/source"><p>Quote</p></blockquote>',
|
||||
'<p class="content">one of the onions</p>',
|
||||
]
|
||||
for (const html of safe) {
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
|
||||
}
|
||||
})
|
||||
|
||||
test('pasted chat transcripts keep dir="auto" and the two data attributes only', () => {
|
||||
const html = '<div dir="auto" data-message-author-role="user" data-message-id="abc-123">Hi</div>'
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<div data-message-author-role="a b" data-other="x" dir="sideways">Hi</div>'),
|
||||
'<div>Hi</div>',
|
||||
)
|
||||
})
|
||||
|
||||
test('attribute values must match their patterns', () => {
|
||||
assert.equal(sanitizeJournalHtml('<p itemtype="https://evil.test/Person">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p class="a"b">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<a role="button" href="/x">x</a>'), '<a href="/x">x</a>')
|
||||
assert.equal(sanitizeJournalHtml('<td colspan="2;">x</td>'), '<td>x</td>')
|
||||
assert.equal(sanitizeJournalHtml('<a href="http://[bad">x</a>'), '<a>x</a>')
|
||||
assert.equal(sanitizeJournalHtml('<a target="_blank" rel="opener" href="/x">x</a>'), '<a href="/x">x</a>')
|
||||
})
|
||||
|
||||
test('attribute and tag names from Object.prototype find no rule', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<a constructor="x" tostring="y" __proto__="z" href="/a">x</a><constructor>t</constructor>'),
|
||||
'<a href="/a">x</a>t',
|
||||
)
|
||||
})
|
||||
|
||||
test('sanitizing is idempotent, so server and client agree on a sanitized body', () => {
|
||||
const inputs = [AUTHOR_FOOTER, ...REVIEW_BYPASSES.map(([, html]) => html), ...GO_CASES.map(([, html]) => html)]
|
||||
for (const html of inputs) {
|
||||
const once = sanitizeJournalHtml(html)
|
||||
assert.equal(sanitizeJournalHtml(once), once, html)
|
||||
}
|
||||
})
|
||||
|
||||
test('empty input is safe and empty', () => {
|
||||
for (const html of [null, undefined, '']) {
|
||||
assert.equal(sanitizeJournalHtml(html), '')
|
||||
assert.equal(isSafeJournalHtml(html), true)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' })
|
||||
}
|
||||
})
|
||||
|
||||
// The Blog Editor's own output (Phase 16.2, D-05 amended). tests/fixtures/
|
||||
// editor-corpus.json is a byte copy of sm-journal-plugin
|
||||
// tests/blogeditor/fixtures/editor-corpus.json, the editor's corpus that also
|
||||
// pins the Go policy (TestEditorOutputPassesPolicy). Never edit the copy by
|
||||
// hand; copy it again when the editor's corpus changes.
|
||||
const CORPUS = JSON.parse(
|
||||
readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'),
|
||||
) as Array<{ name: string; html: string }>
|
||||
const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
|
||||
test('the editor corpus has the entries the mirror rules are for', () => {
|
||||
assert.equal(CORPUS.length, 15)
|
||||
const all = CORPUS.map((e) => e.html).join('\n')
|
||||
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to "grzyb odporności"?"']) {
|
||||
assert.ok(all.includes(part), `corpus lacks ${part}`)
|
||||
}
|
||||
})
|
||||
|
||||
for (const entry of CORPUS) {
|
||||
test(`editor corpus entry ${entry.name} is safe`, () => {
|
||||
const gate = gateJournalHtml(entry.html)
|
||||
assert.equal(gate.safe, true, `${entry.name}: ${gate.html}`)
|
||||
assert.equal(sanitizeJournalHtml(gate.html), gate.html, `${entry.name} not idempotent`)
|
||||
})
|
||||
}
|
||||
|
||||
test('the embed placeholder survives two sanitizer passes (payload, then sink)', () => {
|
||||
const once = sanitizeJournalHtml(PLACEHOLDER)
|
||||
assert.equal(once, PLACEHOLDER)
|
||||
assert.equal(sanitizeJournalHtml(once), PLACEHOLDER)
|
||||
assert.deepEqual(gateJournalHtml(PLACEHOLDER), { safe: true, html: PLACEHOLDER })
|
||||
})
|
||||
|
||||
test('embed attributes outside the four editor rules are still stripped', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['other provider', '<figure data-embed="vimeo" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
|
||||
['10-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXc"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['12-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXcQQ"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['id with a quote', '<figure data-embed="youtube" data-id="dQw4w9WgX"Q"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['data-id on div', '<div data-embed="youtube" data-id="dQw4w9WgXcQ">x</div>', '<div>x</div>'],
|
||||
['data-id on img', '<img src="/a.jpg" data-id="dQw4w9WgXcQ" />', '<img src="/a.jpg" />'],
|
||||
['other data attribute on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" data-src="https://evil.test/"></figure>', PLACEHOLDER],
|
||||
['src on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" src="https://evil.test/"></figure>', PLACEHOLDER],
|
||||
['onload on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" onload="alert(1)"></figure>', PLACEHOLDER],
|
||||
]
|
||||
for (const [name, html, want] of cases) {
|
||||
assert.equal(sanitizeJournalHtml(html), want, name)
|
||||
assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an iframe is still stripped with its content', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<p>a</p><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">fallback</iframe><p>b</p>'),
|
||||
'<p>a</p><p>b</p>',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><iframe src="https://evil.test/"></iframe></figure>'),
|
||||
PLACEHOLDER,
|
||||
)
|
||||
})
|
||||
|
||||
test('ol start keeps an integer only, and only on ol', () => {
|
||||
assert.equal(sanitizeJournalHtml('<ol start="3"><li>x</li></ol>'), '<ol start="3"><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ol start="x"><li>x</li></ol>'), '<ol><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ol start="-1"><li>x</li></ol>'), '<ol><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ul start="3"><li>x</li></ul>'), '<ul><li>x</li></ul>')
|
||||
})
|
||||
|
||||
test('img alt keeps free text with a colon, a question mark and quotes', () => {
|
||||
const html = '<p><img src="/a.jpg" alt="Turkey Tail: czy to "grzyb odporności"?" /></p>'
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(isSafeJournalHtml(html), true)
|
||||
// alt is text, never markup: an angle bracket stays inside the attribute.
|
||||
const out = sanitizeJournalHtml('<img src="/a.jpg" alt=""><script>alert(1)</script>" />')
|
||||
assert.doesNotMatch(out, /<script/)
|
||||
assert.match(out, /^<img src="\/a\.jpg" alt="[^"]*" \/>$/)
|
||||
// area alt keeps the UGC pattern; only img alt is widened.
|
||||
assert.equal(sanitizeJournalHtml('<map name="m"><area alt="a: b?" href="/x" /></map>'), '<map name="m"><area href="/x" /></map>')
|
||||
})
|
||||
56
tests/i18n.test.ts
Normal file
56
tests/i18n.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// pl and en strings have the same keys and the same {name} placeholders,
|
||||
// so a string added to one locale fails until the other has it too.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'locales')
|
||||
|
||||
type Messages = { [key: string]: string | Messages }
|
||||
|
||||
function load(code: string): Messages {
|
||||
return JSON.parse(readFileSync(join(LOCALES, `${code}.json`), 'utf8')) as Messages
|
||||
}
|
||||
|
||||
// Every leaf string of the message tree, keyed by its dotted path.
|
||||
function leaves(tree: Messages, prefix = ''): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = []
|
||||
for (const [key, value] of Object.entries(tree)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof value === 'string') out.push([path, value])
|
||||
else out.push(...leaves(value, path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function placeholders(value: string): string[] {
|
||||
return [...value.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]!).sort()
|
||||
}
|
||||
|
||||
const pl = new Map(leaves(load('pl')))
|
||||
const en = new Map(leaves(load('en')))
|
||||
|
||||
test('every string lives under the journal namespace', () => {
|
||||
for (const key of [...pl.keys(), ...en.keys()]) assert.ok(key.startsWith('journal.'), key)
|
||||
})
|
||||
|
||||
test('pl and en have identical leaf key sets', () => {
|
||||
assert.deepEqual([...pl.keys()].sort(), [...en.keys()].sort())
|
||||
assert.ok(pl.size > 0)
|
||||
})
|
||||
|
||||
test('no leaf is empty', () => {
|
||||
for (const [key, value] of [...pl, ...en]) assert.ok(value.trim() !== '', key)
|
||||
})
|
||||
|
||||
test('pl and en use the same placeholders per key', () => {
|
||||
for (const [key, value] of pl) {
|
||||
assert.deepEqual(placeholders(value), placeholders(en.get(key) ?? ''), key)
|
||||
}
|
||||
})
|
||||
|
||||
test('the pagination title carries {page}', () => {
|
||||
assert.deepEqual(placeholders(pl.get('journal.pagination.pageTitle') ?? ''), ['page'])
|
||||
})
|
||||
47
tests/pagination.test.ts
Normal file
47
tests/pagination.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// ?page=N parsing, out-of-range detection and the page window.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pageOutOfRange, paginationWindow, parsePageParam } from '../app/utils/pagination.ts'
|
||||
|
||||
test('a missing page is page 1', () => {
|
||||
assert.equal(parsePageParam(undefined), 1)
|
||||
assert.equal(parsePageParam(null), 1)
|
||||
})
|
||||
|
||||
test('a string of digits above 0 is that page', () => {
|
||||
assert.equal(parsePageParam('1'), 1)
|
||||
assert.equal(parsePageParam('2'), 2)
|
||||
assert.equal(parsePageParam('017'), 17)
|
||||
})
|
||||
|
||||
test('anything else is a 404 (null)', () => {
|
||||
for (const raw of ['0', '-1', '1.5', 'abc', '', ' 2', '2 ', '1e3', '0x2', '99999999999999999999']) {
|
||||
assert.equal(parsePageParam(raw), null, JSON.stringify(raw))
|
||||
}
|
||||
assert.equal(parsePageParam(['2']), null)
|
||||
assert.equal(parsePageParam(2), null)
|
||||
})
|
||||
|
||||
test('pageOutOfRange: past the last page only, page 1 never', () => {
|
||||
assert.equal(pageOutOfRange(3, 2), true)
|
||||
assert.equal(pageOutOfRange(2, 2), false)
|
||||
assert.equal(pageOutOfRange(1, 0), false)
|
||||
assert.equal(pageOutOfRange(1, 1), false)
|
||||
assert.equal(pageOutOfRange(2, 0), true)
|
||||
})
|
||||
|
||||
test('paginationWindow centres on the current page', () => {
|
||||
assert.deepEqual(paginationWindow(5, 10), [3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
test('paginationWindow stays full width near either end', () => {
|
||||
assert.deepEqual(paginationWindow(1, 10), [1, 2, 3, 4, 5])
|
||||
assert.deepEqual(paginationWindow(10, 10), [6, 7, 8, 9, 10])
|
||||
assert.deepEqual(paginationWindow(9, 10, 3), [8, 9, 10])
|
||||
})
|
||||
|
||||
test('paginationWindow clips to the page count and handles empty input', () => {
|
||||
assert.deepEqual(paginationWindow(1, 2), [1, 2])
|
||||
assert.deepEqual(paginationWindow(1, 0), [])
|
||||
assert.deepEqual(paginationWindow(1, 5, 0), [])
|
||||
})
|
||||
55
tests/postView.test.ts
Normal file
55
tests/postView.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// The post page's payload: the body is sanitized, never blanked, and the raw
|
||||
// source, translations and related post bodies stay out of it.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { toPostView } from '../app/utils/postView.ts'
|
||||
import type { JournalPost, JournalPostDetail } from '../app/utils/types.ts'
|
||||
|
||||
function post(overrides: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Hello',
|
||||
slug: 'hello',
|
||||
excerpt: null,
|
||||
content: 'source',
|
||||
content_html: '<p>Hello</p>',
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2026-01-01T00:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: { en: { title: 'Hello' } },
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(overrides: Partial<JournalPostDetail> = {}): JournalPostDetail {
|
||||
return { ...post(), previous_post: null, next_post: null, related_posts: [], ...overrides }
|
||||
}
|
||||
|
||||
test('the body is sanitized and the safe parts are kept', () => {
|
||||
const view = toPostView(detail({ content_html: '<p>Data: 12 maja 2024</p><script>alert(1)</script><p>After</p>' }))
|
||||
assert.equal(view.content_html, '<p>Data: 12 maja 2024</p><p>After</p>')
|
||||
})
|
||||
|
||||
test('source, translations and related post bodies stay out of the payload', () => {
|
||||
const view = toPostView(detail({ related_posts: [post({ id: 2, slug: 'other' })] }))
|
||||
assert.equal(view.content, null)
|
||||
assert.equal(view.translations, null)
|
||||
assert.equal(view.related_posts.length, 1)
|
||||
assert.equal(view.related_posts[0]!.content_html, null)
|
||||
assert.equal(view.related_posts[0]!.content, null)
|
||||
assert.equal(view.related_posts[0]!.translations, null)
|
||||
})
|
||||
|
||||
test('a missing body becomes an empty string', () => {
|
||||
assert.equal(toPostView(detail({ content_html: null })).content_html, '')
|
||||
})
|
||||
111
tests/routes.test.ts
Normal file
111
tests/routes.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// journal.routes: defaults, overrides, disabling, validation and path filling.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
DEFAULT_JOURNAL_ROUTES,
|
||||
JOURNAL_ROUTE_NAMES,
|
||||
fillJournalPath,
|
||||
normalizeJournalRoutes,
|
||||
} from '../app/utils/routes.ts'
|
||||
|
||||
test('no input gives the defaults', () => {
|
||||
assert.deepEqual(normalizeJournalRoutes(), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes(null), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes({}), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
})
|
||||
|
||||
test('the defaults round-trip through normalization', () => {
|
||||
const once = normalizeJournalRoutes({ ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes(once), once)
|
||||
})
|
||||
|
||||
test('the result is a copy, never the frozen defaults', () => {
|
||||
const routes = normalizeJournalRoutes()
|
||||
assert.notEqual(routes, DEFAULT_JOURNAL_ROUTES)
|
||||
assert.ok(Object.isFrozen(DEFAULT_JOURNAL_ROUTES))
|
||||
})
|
||||
|
||||
test('an override replaces one key and keeps the others', () => {
|
||||
const routes = normalizeJournalRoutes({ category: '/:category' })
|
||||
assert.equal(routes.category, '/:category')
|
||||
assert.equal(routes.index, DEFAULT_JOURNAL_ROUTES.index)
|
||||
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
|
||||
assert.equal(routes.tag, DEFAULT_JOURNAL_ROUTES.tag)
|
||||
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
|
||||
})
|
||||
|
||||
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
|
||||
assert.deepEqual(normalizeJournalRoutes(input), input)
|
||||
})
|
||||
|
||||
test('false disables a route; null and undefined keep the default', () => {
|
||||
const routes = normalizeJournalRoutes({ tag: false, author: null, post: undefined })
|
||||
assert.equal(routes.tag, false)
|
||||
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
|
||||
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
|
||||
})
|
||||
|
||||
test('unknown keys are ignored', () => {
|
||||
assert.deepEqual(normalizeJournalRoutes({ archive: '/archive' }), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
})
|
||||
|
||||
test('a post path without :slug throws naming journal.routes.post', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:id' }), /journal\.routes\.post/)
|
||||
})
|
||||
|
||||
test('a param prefix is not the param (:slugs is not :slug)', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:slugs' }), /journal\.routes\.post/)
|
||||
assert.equal(normalizeJournalRoutes({ post: '/blog/:slug(.*)' }).post, '/blog/:slug(.*)')
|
||||
})
|
||||
|
||||
test('a category path without :category throws naming journal.routes.category', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ category: '/kategoria' }), /journal\.routes\.category/)
|
||||
})
|
||||
|
||||
test('tag and author paths need their params', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ tag: '/tags/:slug' }), /journal\.routes\.tag/)
|
||||
assert.throws(() => normalizeJournalRoutes({ author: '/autor' }), /journal\.routes\.author/)
|
||||
})
|
||||
|
||||
test('a path without a leading / throws naming the key', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ index: 'blog' }), /journal\.routes\.index/)
|
||||
assert.throws(() => normalizeJournalRoutes({ post: 'blog/:slug' }), /journal\.routes\.post/)
|
||||
})
|
||||
|
||||
test('true and non-string values throw', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ index: true }), /journal\.routes\.index/)
|
||||
assert.throws(() => normalizeJournalRoutes({ index: 42 }), /journal\.routes\.index/)
|
||||
})
|
||||
|
||||
test('route names are stable', () => {
|
||||
assert.deepEqual({ ...JOURNAL_ROUTE_NAMES }, {
|
||||
index: 'journal-index',
|
||||
post: 'journal-post',
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
})
|
||||
})
|
||||
|
||||
test('fillJournalPath encodes a value', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')
|
||||
})
|
||||
|
||||
test('fillJournalPath encodes a slug with / per segment', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: '2024/a b?' }), '/blog/2024/a%20b%3F')
|
||||
})
|
||||
|
||||
test('fillJournalPath fills several params and numbers', () => {
|
||||
assert.equal(fillJournalPath('/:category/:slug', { category: 'cordyceps', slug: 7 }), '/cordyceps/7')
|
||||
})
|
||||
|
||||
test('fillJournalPath drops a custom matcher and a modifier with the param', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug(.*)', { slug: 'a/b' }), '/blog/a/b')
|
||||
assert.equal(fillJournalPath('/blog/:slug?', { slug: 'x' }), '/blog/x')
|
||||
})
|
||||
|
||||
test('fillJournalPath leaves unknown params empty', () => {
|
||||
assert.equal(fillJournalPath('/autor/:author', {}), '/autor/')
|
||||
})
|
||||
94
tests/seo.test.ts
Normal file
94
tests/seo.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// SEO values for Journal pages: description fallback, absolute og:image and
|
||||
// list titles.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { absoluteUrl, htmlToText, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
|
||||
import type { JournalPost } from '../app/utils/types.ts'
|
||||
|
||||
function post(extra: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Najnowsze Badania nad Cordycepsem',
|
||||
slug: 'blogbadania-nad-cordycepsem',
|
||||
excerpt: null,
|
||||
content: null,
|
||||
content_html: null,
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2024-06-12T06:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: null,
|
||||
created_at: '2024-06-12T06:00:00+00:00',
|
||||
updated_at: '2024-06-13T06:00:00+00:00',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
test('htmlToText drops tags, scripts and styles and decodes entities', () => {
|
||||
assert.equal(
|
||||
htmlToText('<p>A &<b> "c" 'd'</p><script>x()</script><style>p{}</style>\n<p>e</p>'),
|
||||
'A &<b> "c" \'d\' e',
|
||||
)
|
||||
assert.equal(htmlToText(null), '')
|
||||
})
|
||||
|
||||
test('truncateText keeps short text and cuts long text at a word with an ellipsis', () => {
|
||||
assert.equal(truncateText('krótki', 160), 'krótki')
|
||||
const long = 'słowo '.repeat(40).trim()
|
||||
const cut = truncateText(long, 160)
|
||||
assert.ok(cut.length <= 160)
|
||||
assert.ok(cut.endsWith('…'))
|
||||
assert.ok(!cut.endsWith(' …'))
|
||||
assert.equal(truncateText('x'.repeat(200), 10), `${'x'.repeat(9)}…`)
|
||||
})
|
||||
|
||||
test('the excerpt is the description when set', () => {
|
||||
const meta = postSeoMeta(post({ excerpt: 'Co mówi nauka.', content_html: '<p>Treść</p>' }))
|
||||
assert.equal(meta.description, 'Co mówi nauka.')
|
||||
assert.equal(meta.ogDescription, 'Co mówi nauka.')
|
||||
assert.equal(meta.title, 'Najnowsze Badania nad Cordycepsem')
|
||||
assert.equal(meta.ogTitle, meta.title)
|
||||
assert.equal(meta.ogType, 'article')
|
||||
})
|
||||
|
||||
test('without an excerpt the description is the first 160 characters of the body text', () => {
|
||||
const body = `<p>${'Grzyby funkcjonalne wspierają zdrowie. '.repeat(10)}</p>`
|
||||
const meta = postSeoMeta(post({ excerpt: '', content_html: body }))
|
||||
assert.ok(meta.description.length <= 160)
|
||||
assert.ok(meta.description.startsWith('Grzyby funkcjonalne wspierają zdrowie.'))
|
||||
assert.ok(!meta.description.includes('<'))
|
||||
})
|
||||
|
||||
test('og:image becomes absolute against the site URL', () => {
|
||||
const url = '/storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png'
|
||||
assert.equal(postSeoMeta(post({ featured_image_url: url }), 'https://grzybyfunkcjonalne.pl/').ogImage, `https://grzybyfunkcjonalne.pl${url}`)
|
||||
assert.equal(postSeoMeta(post({ featured_image_url: 'https://cdn.test/a.png' }), 'https://x.test').ogImage, 'https://cdn.test/a.png')
|
||||
assert.equal(postSeoMeta(post()).ogImage, undefined)
|
||||
})
|
||||
|
||||
test('article times come from published_at and updated_at', () => {
|
||||
const meta = postSeoMeta(post())
|
||||
assert.equal(meta.articlePublishedTime, '2024-06-12T06:00:00+00:00')
|
||||
assert.equal(meta.articleModifiedTime, '2024-06-13T06:00:00+00:00')
|
||||
const bare = postSeoMeta(post({ published_at: null, updated_at: null }))
|
||||
assert.equal(bare.articlePublishedTime, undefined)
|
||||
assert.equal(bare.articleModifiedTime, undefined)
|
||||
})
|
||||
|
||||
test('absoluteUrl passes through absolute URLs and a missing site URL', () => {
|
||||
assert.equal(absoluteUrl('a.png', 'https://x.test'), 'https://x.test/a.png')
|
||||
assert.equal(absoluteUrl('HTTPS://y.test/a.png', 'https://x.test'), 'HTTPS://y.test/a.png')
|
||||
assert.equal(absoluteUrl('/a.png', null), '/a.png')
|
||||
})
|
||||
|
||||
test('listTitle adds the page title only after page 1', () => {
|
||||
assert.equal(listTitle('Artykuły', 1, 'Strona 1'), 'Artykuły')
|
||||
assert.equal(listTitle('Artykuły', 2, 'Strona 2'), 'Artykuły · Strona 2')
|
||||
})
|
||||
98
tests/sitemap.test.ts
Normal file
98
tests/sitemap.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Sitemap entries are built from journal.routes, never from fixed paths.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildJournalSitemapEntries } from '../app/utils/sitemap.ts'
|
||||
import { DEFAULT_JOURNAL_ROUTES, normalizeJournalRoutes } from '../app/utils/routes.ts'
|
||||
import type { JournalCategory, JournalPost } from '../app/utils/types.ts'
|
||||
|
||||
function post(id: number, slug: string, extra: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id,
|
||||
title: slug,
|
||||
slug,
|
||||
excerpt: null,
|
||||
content: null,
|
||||
content_html: null,
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2024-06-12T06:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: null,
|
||||
created_at: '2024-06-12T06:00:00+00:00',
|
||||
updated_at: '2024-06-13T06:00:00+00:00',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
|
||||
const row: JournalCategory = {
|
||||
id,
|
||||
name: slug,
|
||||
slug,
|
||||
description: null,
|
||||
content: null,
|
||||
parent_id: null,
|
||||
nest_depth: 0,
|
||||
post_count: 0,
|
||||
}
|
||||
if (children) row.children = children
|
||||
return row
|
||||
}
|
||||
|
||||
const POSTS = [post(1, 'blogbadania-nad-cordycepsem'), post(2, 'bez-daty', { updated_at: null })]
|
||||
const CATEGORIES = [category(1, 'cordyceps', [category(2, 'militaris')]), category(3, 'amanita-muscaria')]
|
||||
|
||||
test('default routes give /blog/:slug and /blog/category/:category', () => {
|
||||
assert.deepEqual(buildJournalSitemapEntries(POSTS, CATEGORIES, { ...DEFAULT_JOURNAL_ROUTES }), [
|
||||
{ loc: '/blog/blogbadania-nad-cordycepsem', lastmod: '2024-06-13T06:00:00+00:00' },
|
||||
{ loc: '/blog/bez-daty' },
|
||||
{ loc: '/blog/category/cordyceps' },
|
||||
{ loc: '/blog/category/militaris' },
|
||||
{ loc: '/blog/category/amanita-muscaria' },
|
||||
])
|
||||
})
|
||||
|
||||
test('a custom root category route gives /cordyceps', () => {
|
||||
const routes = normalizeJournalRoutes({ category: '/:category', tag: false })
|
||||
const locs = buildJournalSitemapEntries(POSTS, CATEGORIES, routes).map((e) => e.loc)
|
||||
assert.ok(locs.includes('/cordyceps'))
|
||||
assert.ok(locs.includes('/militaris'), 'nested categories are flattened')
|
||||
assert.ok(locs.includes('/amanita-muscaria'), 'empty categories are listed')
|
||||
})
|
||||
|
||||
test('a disabled route contributes nothing', () => {
|
||||
const noCategories = normalizeJournalRoutes({ category: false })
|
||||
assert.deepEqual(
|
||||
buildJournalSitemapEntries(POSTS, CATEGORIES, noCategories).map((e) => e.loc),
|
||||
['/blog/blogbadania-nad-cordycepsem', '/blog/bez-daty'],
|
||||
)
|
||||
const noPosts = normalizeJournalRoutes({ post: false })
|
||||
assert.equal(buildJournalSitemapEntries(POSTS, CATEGORIES, noPosts).filter((e) => e.loc.startsWith('/blog/b')).length, 0)
|
||||
})
|
||||
|
||||
test('unpublished posts and empty slugs are skipped', () => {
|
||||
const rows = [post(1, 'draft', { published: false }), post(2, ''), post(3, 'live')]
|
||||
assert.deepEqual(
|
||||
buildJournalSitemapEntries(rows, [category(9, '')], { ...DEFAULT_JOURNAL_ROUTES }).map((e) => e.loc),
|
||||
['/blog/live'],
|
||||
)
|
||||
})
|
||||
|
||||
test('lastmod comes from updated_at', () => {
|
||||
const [entry] = buildJournalSitemapEntries([post(1, 'x', { updated_at: '2025-01-02T03:04:05+00:00' })], [], {
|
||||
...DEFAULT_JOURNAL_ROUTES,
|
||||
})
|
||||
assert.equal(entry?.lastmod, '2025-01-02T03:04:05+00:00')
|
||||
})
|
||||
|
||||
test('slugs are encoded per segment', () => {
|
||||
const [entry] = buildJournalSitemapEntries([post(1, 'a b/c')], [], { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.equal(entry?.loc, '/blog/a%20b/c')
|
||||
})
|
||||
Reference in New Issue
Block a user