feat: scaffold Journal layer with configurable post route and git-install check

- journal module registers the Journal pages at journal.routes paths
- typed anonymous-GET client, html gate, SSR x-forwarded-for forwarding
- fixture API, built-server SSR smoke and git-tag install check
This commit is contained in:
2026-10-09 17:31:45 +02:00
commit 6f6792f51c
34 changed files with 12893 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.nuxt/
.output/
.data/
.playground/.nuxt/
.playground/.output/
.env

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.22.0

5
.playground/app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,14 @@
// Local playground: an app that only extends the layer, with en as the
// default locale and pl prefixed, the same shape the git-install check uses.
export default defineNuxtConfig({
extends: ['..'],
compatibilityDate: '2025-07-15',
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'pl', language: 'pl-PL' },
],
},
})

5
app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,13 @@
<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'
const props = defineProps<{ html: string | null | undefined; blocked?: boolean }>()
const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(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>
</template>

View File

@@ -0,0 +1,26 @@
// The Journal API client for pages and components. On the server it calls
// the internal base (runtimeConfig.journal.apiBase) and forwards the visitor's
// x-forwarded-for, plus the address of the peer that reached Nuxt, so the Go
// rate limiter keeps one bucket per visitor instead of one for the Nuxt
// server. In the browser it calls the same-origin public base. The base is
// always runtime config, never request input.
import { createJournalClient, type JournalClient, type JournalFetcher } from '../utils/client.ts'
export function useJournalApi(): JournalClient {
const config = useRuntimeConfig()
if (import.meta.server) {
const base = (config.journal as { apiBase: string }).apiBase
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
const peer = useRequestEvent()?.node?.req?.socket?.remoteAddress
const forwarded = [incoming, peer].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 })
return createJournalClient(fetcher)
}
const base = (config.public.journal as { apiBase: string }).apiBase
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query })
return createJournalClient(fetcher)
}

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
// journal-author: an author and their posts; an unknown author is a 404.
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)
if (!author) return null
return { author, page: await api.listAuthorPosts(slug) }
})
if (!data.value) throw createError({ statusCode: 404, fatal: true })
</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>
</section>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
// journal-category: posts in one category; an unknown category is a 404.
const route = useRoute()
const api = useJournalApi()
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 }) }
})
if (!data.value) throw createError({ statusCode: 404, fatal: true })
</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>
</section>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
// journal-index: the latest posts.
const api = useJournalApi()
const { data: page } = await useAsyncData('journal-index', () => api.listPosts({ perPage: 9 }))
</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>
</template>

View File

@@ -0,0 +1,29 @@
<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'
const route = useRoute()
const api = useJournalApi()
const slug = computed(() => String(route.params.slug ?? ''))
const { data: post, error } = await useAsyncData(
() => `journal-post-${slug.value}`,
() => api.getPost(slug.value),
{ transform: (p) => (p ? toPostView(p) : null) },
)
if (error.value) {
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
}
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
</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" />
</article>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
// journal-tag: posts with one tag; an unknown tag is a 404.
const route = useRoute()
const api = useJournalApi()
const slug = String(route.params.tag ?? '')
const { data } = await useAsyncData(`journal-tag-${slug}`, async () => {
const tag = (await api.listTags()).find((t) => t.slug === slug)
if (!tag) return null
return { tag, page: await api.listPosts({ tag: tag.slug }) }
})
if (!data.value) throw createError({ statusCode: 404, fatal: true })
</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>
</section>
</template>

5
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<main class="journal-layout">
<slot />
</main>
</template>

150
app/utils/client.ts Normal file
View File

@@ -0,0 +1,150 @@
// Typed client for the anonymous GET routes of the Journal API. It wraps an
// injected fetcher, so the Nuxt composable decides the base URL and headers
// and `node --test` can drive it with a stub. It never calls a write route.
import type {
JournalAuthor,
JournalCategory,
JournalItem,
JournalPage,
JournalPost,
JournalPostDetail,
JournalTag,
} from './types.ts'
/** The Journal API's per_page ceiling. */
export const JOURNAL_MAX_PER_PAGE = 30
/** The Journal API's per_page default. */
export const JOURNAL_DEFAULT_PER_PAGE = 9
/** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
export interface PostListOptions {
page?: number
perPage?: number
/** "<column> <asc|desc>", passed through verbatim. */
sort?: string
/** Category slug or id; the API includes nested children. */
category?: string
tag?: string
search?: string
}
export interface AuthorPostListOptions {
page?: number
perPage?: number
sort?: string
}
/** Clamps a per_page value to 1..30 like the API does; non-numeric gives the default 9. */
export function clampPerPage(value: unknown): number {
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
if (!Number.isFinite(n)) return JOURNAL_DEFAULT_PER_PAGE
const i = Math.trunc(n)
if (i < 1) return 1
if (i > JOURNAL_MAX_PER_PAGE) return JOURNAL_MAX_PER_PAGE
return i
}
/** Query for GET /posts: per_page always clamped, other keys only when set. */
export function buildPostListQuery(opts: PostListOptions = {}): Record<string, string | number> {
const q: Record<string, string | number> = { per_page: clampPerPage(opts.perPage) }
if (opts.page !== undefined && Number.isInteger(opts.page) && opts.page > 0) q.page = opts.page
if (opts.sort) q.sort = opts.sort
if (opts.category) q.category = opts.category
if (opts.tag) q.tag = opts.tag
if (opts.search) q.search = opts.search
return q
}
/** Encodes each "/"-separated segment of a slug (post slugs may contain "/"). */
export function encodeSlugPath(slug: string): string {
return String(slug)
.split('/')
.map((part) => encodeURIComponent(part))
.join('/')
}
/** Depth-first flattening of the categories tree, parents before children. */
export function flattenCategories(tree: readonly JournalCategory[] | null | undefined): JournalCategory[] {
const out: JournalCategory[] = []
const walk = (nodes: readonly JournalCategory[] | null | undefined): void => {
for (const node of nodes ?? []) {
out.push(node)
walk(node.children)
}
}
walk(tree)
return out
}
/** True for a fetch error that carries HTTP status 404 (ofetch, h3 or plain). */
export function isNotFound(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } }
return e.status === 404 || e.statusCode === 404 || e.response?.status === 404
}
export interface JournalClient {
getPost(slug: string): Promise<JournalPostDetail | null>
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>>
listCategories(): Promise<JournalCategory[]>
findCategory(slug: string): Promise<JournalCategory | null>
listTags(): Promise<JournalTag[]>
getAuthor(slug: string): Promise<JournalAuthor | null>
listAuthorPosts(slug: string, opts?: AuthorPostListOptions): Promise<JournalPage<JournalPost> | null>
}
export function createJournalClient(fetcher: JournalFetcher): JournalClient {
const orNull = async <T>(p: Promise<T>): Promise<T | null> => {
try {
return await p
} catch (err) {
if (isNotFound(err)) return null
throw err
}
}
const listCategories = async (): Promise<JournalCategory[]> => {
// include_empty=1 always (D-24): an empty category still has a page.
const res = (await fetcher('/categories', { include_empty: 1 })) as JournalItem<JournalCategory[]>
return Array.isArray(res?.data) ? res.data : []
}
return {
async getPost(slug) {
const res = (await orNull(fetcher(`/posts/${encodeSlugPath(slug)}`))) as JournalItem<JournalPostDetail> | null
return res?.data ?? null
},
async listPosts(opts = {}) {
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost>
},
listCategories,
async findCategory(slug) {
const rows = flattenCategories(await listCategories())
return rows.find((c) => c.slug === slug) ?? null
},
async listTags() {
const res = (await fetcher('/tags')) as JournalItem<JournalTag[]>
return Array.isArray(res?.data) ? res.data : []
},
async getAuthor(slug) {
const res = (await orNull(fetcher(`/authors/${encodeURIComponent(slug)}`))) as JournalItem<JournalAuthor> | null
return res?.data ?? null
},
async listAuthorPosts(slug, opts = {}) {
const q: Record<string, string | number> = { per_page: clampPerPage(opts.perPage) }
if (opts.page !== undefined && Number.isInteger(opts.page) && opts.page > 0) q.page = opts.page
if (opts.sort) q.sort = opts.sort
return (await orNull(fetcher(`/authors/${encodeURIComponent(slug)}/posts`, q))) as JournalPage<JournalPost> | null
},
}
}

23
app/utils/htmlGate.ts Normal file
View File

@@ -0,0 +1,23 @@
// 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.
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
/** 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))
}
/**
* 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 "".
*/
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: '' }
}

78
app/utils/routes.ts Normal file
View File

@@ -0,0 +1,78 @@
// Journal route configuration: defaults, stable route names, validation and
// path filling. Pure TypeScript, so `node --test` and the layer module both
// import it directly.
import type { JournalRoutes } from './types.ts'
/** Neutral default paths. An app moves or disables any of them in journal.routes. */
export const DEFAULT_JOURNAL_ROUTES: Readonly<JournalRoutes> = Object.freeze({
index: '/blog',
post: '/blog/:slug',
category: '/blog/category/:category',
tag: '/blog/tag/:tag',
author: '/blog/author/:author',
})
/** Route names stay stable whatever the paths are, so links never hard-code a path. */
export const JOURNAL_ROUTE_NAMES: Readonly<Record<keyof JournalRoutes, string>> = Object.freeze({
index: 'journal-index',
post: 'journal-post',
category: 'journal-category',
tag: 'journal-tag',
author: 'journal-author',
})
/** The route param each path must carry. */
const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
index: null,
post: 'slug',
category: 'category',
tag: 'tag',
author: 'author',
}
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes>
/**
* Merges `input` over the defaults and validates every value: each is `false`
* or a string starting with "/", and post, category, tag and author carry
* their `:slug`, `:category`, `:tag` or `:author` param. Throws an Error that
* names `journal.routes.<key>` otherwise.
*/
export function normalizeJournalRoutes(input?: Partial<Record<string, unknown>> | null): JournalRoutes {
const out = { ...DEFAULT_JOURNAL_ROUTES } as JournalRoutes
for (const key of ROUTE_KEYS) {
const raw = input && Object.prototype.hasOwnProperty.call(input, key) ? input[key] : undefined
if (raw === undefined || raw === null) continue
if (raw === false) {
out[key] = false
continue
}
if (typeof raw !== 'string' || !raw.startsWith('/')) {
throw new Error(`journal.routes.${key} must be false or a path starting with "/", got ${JSON.stringify(raw)}`)
}
const param = REQUIRED_PARAM[key]
if (param && !new RegExp(`:${param}(?![A-Za-z0-9_])`).test(raw)) {
throw new Error(`journal.routes.${key} must contain the :${param} param, got "${raw}"`)
}
out[key] = raw
}
return out
}
/**
* Fills the `:name` params of a route pattern, encoding each value per path
* segment (a "/" inside a value stays a separator). A custom matcher such as
* `:slug(.*)` and a trailing modifier are dropped with the param. Unknown
* params become empty.
*/
export function fillJournalPath(pattern: string, params: Record<string, string | number>): string {
return pattern.replace(/:([A-Za-z_][A-Za-z0-9_]*)(\([^)]*\))?[?*+]?/g, (_m, name: string) => {
const value = params[name]
if (value === undefined || value === null) return ''
return String(value)
.split('/')
.map((part) => encodeURIComponent(part))
.join('/')
})
}

143
app/utils/types.ts Normal file
View File

@@ -0,0 +1,143 @@
// Wire types of the anonymous Journal API (/_journal/api/v1). Field names
// mirror the Go serializers exactly (snake_case); a field is nullable where
// Go can emit null. Types only, so `node --test` can import any util.
/** The author object embedded in a post row. `null` when the post has none. */
export interface JournalAuthorRef {
id: number
first_name: string
last_name: string
login: string
}
/** A category or tag reference embedded in a post row. */
export interface JournalTermRef {
id: number
name: string
slug: string
}
export interface JournalFeaturedImage {
id: number
url: string
file_name: string
file_size: number
}
/** One post row, as GET /posts and GET /authors/{author}/posts list it. */
export interface JournalPost {
id: number
title: string
slug: string
excerpt: string | null
content: string | null
content_html: string | null
reading_time: number
published: boolean
/** UTC, "2006-01-02T15:04:05+00:00". */
published_at: string | null
is_pinned: boolean
metadata: unknown
sources: unknown
author: JournalAuthorRef | null
categories: JournalTermRef[]
tags: JournalTermRef[]
featured_images: JournalFeaturedImage[]
featured_image_url: string | null
translations: unknown
created_at: string | null
updated_at: string | null
}
/** previous_post / next_post on GET /posts/{slug}. */
export interface JournalPostSibling {
id: number
title: string
slug: string
}
/** GET /posts/{slug} data: the post plus its neighbours and related posts. */
export interface JournalPostDetail extends JournalPost {
previous_post: JournalPostSibling | null
next_post: JournalPostSibling | null
/** Latest 3 in the same categories; empty when the post has no category. */
related_posts: JournalPost[]
}
/** One node of the GET /categories tree. */
export interface JournalCategory {
id: number
name: string
slug: string
description: string | null
/** Rich HTML body of the category page, stored as is; null when unset. */
content: string | null
parent_id: number | null
nest_depth: number | null
post_count: number
children?: JournalCategory[]
}
export interface JournalTag {
id: number
name: string
slug: string
post_count: number
}
/**
* GET /authors/{author} data. `slug` is the canonical identifier: the author
* slug when set, otherwise the login. Missing names are "".
*/
export interface JournalAuthor {
id: number
slug: string
first_name: string
last_name: string
}
export interface JournalPageMeta {
current_page: number
last_page: number
per_page: number
total: number
}
/** The paginated envelope of GET /posts and GET /authors/{author}/posts. */
export interface JournalPage<T> {
data: T[]
meta: JournalPageMeta
}
/** The single-object envelope ({ data }) of show and list endpoints. */
export interface JournalItem<T> {
data: T
}
/** Route paths per Journal view; `false` disables the view. */
export interface JournalRoutes {
index: string | false
post: string | false
category: string | false
tag: string | false
author: string | false
}
export interface JournalPerPage {
index: number
category: number
tag: number
author: number
}
/** Options of the `journal` config key in nuxt.config. */
export interface JournalModuleOptions {
routes: JournalRoutes
perPage: JournalPerPage
/** "<column> <asc|desc>", column one of published_at, created_at, updated_at, title, id. */
sort: string
/** How many related posts a post page shows. */
relatedCount: number
/** Whether /rss.xml is served and linked. */
rss: boolean
}

29
app/utils/view.ts Normal file
View File

@@ -0,0 +1,29 @@
// 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.
import { gateJournalHtml } from './htmlGate.ts'
import type { JournalPost, JournalPostDetail } 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),
}
}

58
lib/journal-module.ts Normal file
View File

@@ -0,0 +1,58 @@
// The layer's local Nuxt module (configKey `journal`). It registers the
// Journal page components at the paths in journal.routes, so an app moves or
// disables a view in its own nuxt.config without copying page files. It lives
// in lib/ rather than modules/ so Nuxt's local-module scan cannot register it
// a second time. Layer source never uses the ~ or @ aliases: they resolve to
// the consuming project.
import { fileURLToPath } from 'node:url'
import { defineNuxtModule, extendPages } from 'nuxt/kit'
import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalModuleOptions, JournalRoutes } from '../app/utils/types.ts'
const PAGE_FILES: Record<keyof JournalRoutes, string> = {
index: 'PostIndexPage',
post: 'PostPage',
category: 'CategoryPage',
tag: 'TagPage',
author: 'AuthorPage',
}
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
export default defineNuxtModule<JournalModuleOptions>({
meta: {
name: 'vue-journal-layer',
configKey: 'journal',
},
defaults: {
routes: { ...DEFAULT_JOURNAL_ROUTES },
perPage: { index: 9, category: 9, tag: 9, author: 9 },
sort: 'published_at desc',
relatedCount: 3,
rss: true,
},
setup(options, nuxt) {
const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>)
extendPages((pages) => {
for (const key of Object.keys(PAGE_FILES) as Array<keyof JournalRoutes>) {
const path = routes[key]
if (path === false) continue
pages.push({ name: JOURNAL_ROUTE_NAMES[key], path, file: `${pagesDir}${PAGE_FILES[key]}.vue` })
}
})
// 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>
pub.journal = {
...(pub.journal ?? {}),
routes,
perPage: { ...options.perPage },
sort: options.sort,
relatedCount: options.relatedCount,
rss: options.rss,
}
},
})

42
nuxt.config.ts Normal file
View File

@@ -0,0 +1,42 @@
// vue-journal-layer: reusable Journal views as a Nuxt layer. An app consumes
// it with `extends: ['vue-journal-layer']`.
//
// Every path below is absolute and built from this file's directory: a
// relative or ~ path in a layer config resolves against the consuming
// project, not the layer.
import { fileURLToPath } from 'node:url'
const currentDir = fileURLToPath(new URL('.', import.meta.url))
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
// The consuming app may have no app/pages directory; the Journal pages are
// registered by the journal module, so the router must be on regardless.
pages: true,
// Declared once here, so the app never re-lists them (two copies of
// @nuxtjs/i18n split locale state).
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
runtimeConfig: {
journal: {
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
// comes from request input.
apiBase: 'http://127.0.0.1:8096/_journal/api/v1',
},
public: {
journal: {
// Same-origin base for browser fetches (NUXT_PUBLIC_JOURNAL_API_BASE).
apiBase: '/_journal/api/v1',
},
},
},
// No OG image rendering at runtime.
ogImage: { enabled: false },
// i18n: the layer declares no locales. The journal module registers the
// layer's pl/en strings only for locales the app enables.
})

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "vue-journal-layer",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",
"scripts": {
"dev": "nuxi dev .playground",
"build:playground": "nuxt build .playground",
"dev:prepare": "nuxt prepare .playground",
"check:git-install": "bash scripts/check-git-install.sh"
},
"engines": {
"node": ">=22.18"
},
"dependencies": {
"@nuxt/fonts": "0.14.0",
"@nuxtjs/i18n": "10.4.0",
"@nuxtjs/seo": "5.2.1"
},
"peerDependencies": {
"nuxt": "4.4.8"
},
"devDependencies": {
"nuxt": "4.4.8",
"typescript": "5.9.3",
"vue": "3.5.35"
}
}

11075
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
vue-demi: true

86
scripts/check-git-install.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Usage: scripts/check-git-install.sh <tag>
#
# Proves the layer's distribution end to end: a scratch app installs
# vue-journal-layer from git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>
# anonymously, the lockfile pins the tag's commit, `nuxt build` succeeds, and
# the built server passes testing/expect-acme.txt against the fixture API.
# Nothing outside a temporary directory is touched.
set -euo pipefail
REPO_URL="https://git.golem15.com/golem15/vue-journal-layer.git"
die() { echo "check-git-install: $*" >&2; exit 1; }
TAG="${1:-}"
[[ -n "$TAG" ]] || die "usage: $0 <tag>"
for tool in git pnpm node; do
command -v "$tool" >/dev/null 2>&1 || die "$tool is not installed"
done
WORK="$(mktemp -d "${TMPDIR:-/tmp}/journal-git-install.XXXXXX")"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
# The commit the tag points at (peeled when annotated).
REMOTE_REFS="$(GIT_TERMINAL_PROMPT=0 git ls-remote "$REPO_URL" "refs/tags/$TAG" "refs/tags/$TAG^{}")" \
|| die "git ls-remote $REPO_URL failed"
TAG_SHA="$(awk -v ref="refs/tags/$TAG^{}" '$2 == ref { print $1 }' <<<"$REMOTE_REFS")"
[[ -n "$TAG_SHA" ]] || TAG_SHA="$(awk -v ref="refs/tags/$TAG" '$2 == ref { print $1 }' <<<"$REMOTE_REFS")"
[[ -n "$TAG_SHA" ]] || die "tag $TAG not found on $REPO_URL"
cd "$WORK"
cat >package.json <<JSON
{
"name": "acme-blog",
"private": true,
"type": "module",
"dependencies": {
"nuxt": "4.4.8",
"vue": "3.5.35",
"vue-journal-layer": "git+$REPO_URL#$TAG"
}
}
JSON
cat >pnpm-workspace.yaml <<'YAML'
allowBuilds:
'@parcel/watcher': true
esbuild: true
vue-demi: true
YAML
cat >nuxt.config.ts <<'TS'
export default defineNuxtConfig({
extends: ['vue-journal-layer'],
compatibilityDate: '2025-07-15',
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'pl', language: 'pl-PL' },
],
},
})
TS
GIT_TERMINAL_PROMPT=0 pnpm install || die "pnpm install failed"
# The lockfile must pin the tag's commit, not a branch.
LOCK_SHA="$(grep -oE "vue-journal-layer[^[:space:]]*[0-9a-f]{40}|commit: [0-9a-f]{40}" pnpm-lock.yaml | grep -oE '[0-9a-f]{40}' | sort -u)"
[[ -n "$LOCK_SHA" ]] || die "pnpm-lock.yaml records no commit for vue-journal-layer"
[[ "$(wc -l <<<"$LOCK_SHA")" -eq 1 ]] || die "pnpm-lock.yaml records more than one commit: $LOCK_SHA"
[[ "$LOCK_SHA" == "$TAG_SHA" ]] || die "pnpm-lock.yaml pins $LOCK_SHA, tag $TAG is $TAG_SHA"
pnpm exec nuxt build || die "nuxt build failed"
node node_modules/vue-journal-layer/testing/smoke-ssr.mjs \
--output .output \
--fixtures node_modules/vue-journal-layer/testing/fixtures/acme \
--expect node_modules/vue-journal-layer/testing/expect-acme.txt \
|| die "SSR smoke failed"
echo "check-git-install: ok $TAG $TAG_SHA"

9
testing/expect-acme.txt Normal file
View File

@@ -0,0 +1,9 @@
# 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).
# Single post
200 /blog/hello-acme contains=Hello Acme
404 /blog/missing-post
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
404 /blog/draft-note

247
testing/fixture-api.mjs Normal file
View File

@@ -0,0 +1,247 @@
// A small node:http stand-in for the anonymous Journal API
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
// handlers' query rules (page, per_page clamp, sort allow-list, category with
// nested children, tag, search, include_empty) and the author routes, and it
// records every request so a smoke run can assert header forwarding.
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps import this file from there.
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const PREFIX = '/_journal/api/v1'
const DEFAULT_PER_PAGE = 9
const MAX_PER_PAGE = 30
const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id'])
/**
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
*/
/** @param {string} dir @param {string} name @param {any} fallback */
function readJson(dir, name, fallback) {
try {
return JSON.parse(readFileSync(join(dir, name), 'utf8'))
} catch (err) {
if (/** @type {any} */ (err).code === 'ENOENT') return fallback
throw err
}
}
/** @param {any} post */
function isPublished(post) {
return post.published === true && !!post.published_at && Date.parse(post.published_at) <= Date.now()
}
/** @param {string | null} raw */
function parsePage(raw) {
const n = Number.parseInt(raw ?? '', 10)
return Number.isFinite(n) && n > 0 ? n : 1
}
/** @param {string | null} raw */
function parsePerPage(raw) {
if (raw === null || raw.trim() === '') return DEFAULT_PER_PAGE
const n = Number.parseInt(raw, 10)
if (!Number.isFinite(n)) return DEFAULT_PER_PAGE
return Math.min(MAX_PER_PAGE, Math.max(1, n))
}
/** @param {string | null} raw @returns {[string, 'asc' | 'desc']} */
function parseSort(raw) {
const [col, dir] = (raw ?? '').trim().split(/\s+/)
if (col && SORT_COLUMNS.has(col)) return [col, dir === 'asc' ? 'asc' : 'desc']
return ['published_at', 'desc']
}
/** @param {any[]} rows @param {[string, 'asc' | 'desc']} sort */
function sortRows(rows, [col, dir]) {
const sign = dir === 'asc' ? 1 : -1
return [...rows].sort((a, b) => {
const av = a[col]
const bv = b[col]
if (av === bv) return (a.id - b.id) * sign
return (av < bv ? -1 : 1) * sign
})
}
/** @param {any[]} tree @returns {any[]} */
function flatten(tree) {
/** @type {any[]} */
const out = []
const walk = (/** @type {any[]} */ nodes) => {
for (const n of nodes ?? []) {
out.push(n)
walk(n.children)
}
}
walk(tree)
return out
}
/** @param {any[]} tree @param {boolean} includeEmpty @returns {any[]} */
function filterTree(tree, includeEmpty) {
/** @type {any[]} */
const out = []
for (const node of tree ?? []) {
const children = filterTree(node.children ?? [], includeEmpty)
if (!includeEmpty && node.post_count === 0 && children.length === 0) continue
const { children: _drop, ...row } = node
if (children.length > 0) row.children = children
out.push(row)
}
return out
}
/** @param {any[]} rows @param {number} page @param {number} perPage */
function paginate(rows, page, perPage) {
const total = rows.length
const lastPage = Math.max(1, Math.ceil(total / perPage))
return {
data: rows.slice((page - 1) * perPage, page * perPage),
meta: { current_page: page, last_page: lastPage, per_page: perPage, total },
}
}
/** @param {any} post */
function sibling(post) {
return post ? { id: post.id, title: post.title, slug: post.slug } : null
}
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0 }) {
/** @type {any[]} */
const posts = readJson(dir, 'posts.json', [])
/** @type {any[]} */
const categories = readJson(dir, 'categories.json', [])
/** @type {any[]} */
const tags = readJson(dir, 'tags.json', [])
/** @type {any[]} */
const authors = readJson(dir, 'authors.json', [])
let rss = ''
try {
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
} catch {
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
}
/** @type {FixtureRequest[]} */
const requests = []
const published = () => posts.filter(isPublished)
/** @param {string} value */
const resolveAuthor = (value) => {
const bySlug = authors.find((a) => a.slug === value)
if (bySlug) return bySlug
const byLogin = authors.find((a) => a.login === value)
if (byLogin && published().some((p) => p.author && p.author.id === byLogin.id)) return byLogin
return null
}
/** @param {URLSearchParams} q */
const listPosts = (q, base = published()) => {
let rows = base
const category = q.get('category')
if (category) {
const flat = flatten(categories)
const root = flat.find((c) => c.slug === category || String(c.id) === category)
const ids = new Set(root ? flatten([root]).map((c) => c.id) : [])
rows = rows.filter((p) => p.categories.some((/** @type {any} */ c) => ids.has(c.id)))
}
const tag = q.get('tag')
if (tag) rows = rows.filter((p) => p.tags.some((/** @type {any} */ t) => t.slug === tag))
const search = (q.get('search') ?? '').trim()
if (search.length >= 3) {
const s = search.toLowerCase()
rows = rows.filter((p) => p.title.toLowerCase().includes(s))
}
rows = sortRows(rows, parseSort(q.get('sort')))
return paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
}
/**
* @param {import('node:http').IncomingMessage} req
* @returns {{ status: number, body: string, type: string }}
*/
const route = (req) => {
const url = new URL(req.url ?? '/', 'http://fixture.local')
const json = (/** @type {number} */ status, /** @type {any} */ value) => ({
status,
body: JSON.stringify(value),
type: 'application/json',
})
if (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' })
if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' })
const path = decodeURIComponent(url.pathname.slice(PREFIX.length))
const q = url.searchParams
if (path === '/posts') return json(200, listPosts(q))
if (path.startsWith('/posts/')) {
const slug = path.slice('/posts/'.length)
const live = sortRows(published(), ['published_at', 'desc'])
const post = live.find((p) => p.slug === slug || String(p.id) === slug)
if (!post) return json(404, { error: 'Post not found' })
const idx = live.indexOf(post)
const catIds = new Set(post.categories.map((/** @type {any} */ c) => c.id))
const related =
catIds.size === 0
? []
: live
.filter((p) => p.id !== post.id && p.categories.some((/** @type {any} */ c) => catIds.has(c.id)))
.slice(0, 3)
return json(200, {
data: { ...post, previous_post: sibling(live[idx + 1]), next_post: sibling(live[idx - 1]), related_posts: related },
})
}
if (path === '/categories') {
const includeEmpty = ['1', 'true'].includes(q.get('include_empty') ?? '')
return json(200, { data: filterTree(categories, includeEmpty) })
}
if (path === '/tags') return json(200, { data: tags })
if (path === '/rss') return { status: 200, body: rss, type: 'application/rss+xml; charset=utf-8' }
const author = /^\/authors\/([^/]+)(\/posts)?$/.exec(path)
if (author) {
const row = resolveAuthor(author[1].trim())
if (!row) return json(404, { error: 'Author not found' })
if (!author[2]) {
return json(200, { data: { id: row.id, slug: row.slug, first_name: row.first_name, last_name: row.last_name } })
}
const mine = published().filter((p) => p.author && p.author.id === row.id)
return json(200, listPosts(new URLSearchParams({ page: q.get('page') ?? '', per_page: q.get('per_page') ?? '', sort: q.get('sort') ?? '' }), mine))
}
return json(404, { error: 'Not found' })
}
const server = createServer((req, res) => {
const xff = req.headers['x-forwarded-for']
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) })
let out
try {
out = route(req)
} catch (err) {
out = { status: 500, body: JSON.stringify({ error: String(err) }), type: 'application/json' }
}
res.writeHead(out.status, { 'content-type': out.type })
res.end(req.method === 'HEAD' ? undefined : out.body)
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
close: () => new Promise((done) => server.close(() => done())),
})
})
})
}

View File

@@ -0,0 +1,9 @@
[
{
"id": 1,
"slug": "jane-doe",
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
}
]

View File

@@ -0,0 +1,22 @@
[
{
"id": 1,
"name": "News",
"slug": "news",
"description": "Company news from Acme.",
"content": "<h2>About Acme news</h2>\n<p>Acme news marker: every launch, recall and anvil delivery.</p>\n",
"parent_id": null,
"nest_depth": 0,
"post_count": 12
},
{
"id": 2,
"name": "Quiet",
"slug": "quiet",
"description": "A category with no posts yet.",
"content": null,
"parent_id": null,
"nest_depth": 0,
"post_count": 0
}
]

View File

@@ -0,0 +1,466 @@
[
{
"id": 1,
"title": "Hello Acme",
"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",
"reading_time": 1,
"published": true,
"published_at": "2026-01-14T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [
{
"id": 1,
"name": "Release",
"slug": "release"
}
],
"featured_images": [
{
"id": 1,
"url": "/storage/app/uploads/public/acme/hello.png",
"file_name": "hello.png",
"file_size": 1024
}
],
"featured_image_url": "/storage/app/uploads/public/acme/hello.png",
"translations": {},
"created_at": "2026-01-14T09:00:00+00:00",
"updated_at": "2026-02-01T10:00:00+00:00"
},
{
"id": 2,
"title": "Unsafe Sample",
"slug": "unsafe-sample",
"excerpt": "Unsafe Sample: a short summary.",
"content": "Before",
"content_html": "<p>Before</p><script>alert(1)</script><p>After</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-13T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-13T09:00:00+00:00",
"updated_at": "2026-01-13T09:00:00+00:00"
},
{
"id": 3,
"title": "Acme Note 03",
"slug": "acme-note-03",
"excerpt": "Acme Note 03: a short summary.",
"content": "Acme Note 03 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 03 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-12T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-12T09:00:00+00:00",
"updated_at": "2026-01-12T09:00:00+00:00"
},
{
"id": 4,
"title": "Acme Note 04",
"slug": "acme-note-04",
"excerpt": "Acme Note 04: a short summary.",
"content": "Acme Note 04 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 04 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-11T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-11T09:00:00+00:00",
"updated_at": "2026-01-11T09:00:00+00:00"
},
{
"id": 5,
"title": "Acme Note 05",
"slug": "acme-note-05",
"excerpt": "Acme Note 05: a short summary.",
"content": "Acme Note 05 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 05 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-10T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-01-10T09:00:00+00:00"
},
{
"id": 6,
"title": "Acme Note 06",
"slug": "acme-note-06",
"excerpt": "Acme Note 06: a short summary.",
"content": "Acme Note 06 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 06 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-09T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-09T09:00:00+00:00",
"updated_at": "2026-01-09T09:00:00+00:00"
},
{
"id": 7,
"title": "Acme Note 07",
"slug": "acme-note-07",
"excerpt": "Acme Note 07: a short summary.",
"content": "Acme Note 07 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 07 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-08T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-08T09:00:00+00:00",
"updated_at": "2026-01-08T09:00:00+00:00"
},
{
"id": 8,
"title": "Acme Note 08",
"slug": "acme-note-08",
"excerpt": "Acme Note 08: a short summary.",
"content": "Acme Note 08 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 08 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-07T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-07T09:00:00+00:00",
"updated_at": "2026-01-07T09:00:00+00:00"
},
{
"id": 9,
"title": "Acme Note 09",
"slug": "acme-note-09",
"excerpt": "Acme Note 09: a short summary.",
"content": "Acme Note 09 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 09 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-06T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-06T09:00:00+00:00",
"updated_at": "2026-01-06T09:00:00+00:00"
},
{
"id": 10,
"title": "Acme Note 10",
"slug": "acme-note-10",
"excerpt": "Acme Note 10: a short summary.",
"content": "Acme Note 10 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 10 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-05T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-05T09:00:00+00:00",
"updated_at": "2026-01-05T09:00:00+00:00"
},
{
"id": 11,
"title": "Acme Note 11",
"slug": "acme-note-11",
"excerpt": "Acme Note 11: a short summary.",
"content": "Acme Note 11 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 11 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-04T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-04T09:00:00+00:00",
"updated_at": "2026-01-04T09:00:00+00:00"
},
{
"id": 12,
"title": "Acme Note 12",
"slug": "acme-note-12",
"excerpt": "Acme Note 12: a short summary.",
"content": "Acme Note 12 is a sample post for the Acme blog.",
"content_html": "<p>Acme Note 12 is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-03T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-03T09:00:00+00:00",
"updated_at": "2026-01-03T09:00:00+00:00"
},
{
"id": 13,
"title": "Uncategorised Note",
"slug": "uncategorised-note",
"excerpt": "Uncategorised Note: a short summary.",
"content": "Uncategorised Note is a sample post for the Acme blog.",
"content_html": "<p>Uncategorised Note is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": true,
"published_at": "2026-01-02T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-02T09:00:00+00:00",
"updated_at": "2026-01-02T09:00:00+00:00"
},
{
"id": 14,
"title": "Draft Note",
"slug": "draft-note",
"excerpt": "Draft Note: a short summary.",
"content": "Draft Note is a sample post for the Acme blog.",
"content_html": "<p>Draft Note is a sample post for the Acme blog.</p>",
"reading_time": 1,
"published": false,
"published_at": "2026-01-20T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"login": "jdoe"
},
"categories": [
{
"id": 1,
"name": "News",
"slug": "news"
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2026-01-20T09:00:00+00:00",
"updated_at": "2026-01-20T09:00:00+00:00"
}
]

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Acme Blog</title>
<link>http://acme.test</link>
<description>News from Acme</description>
<item>
<title>Hello Acme</title>
<link>http://acme.test/blog/hello-acme</link>
<guid>http://acme.test/blog/hello-acme</guid>
<pubDate>Wed, 14 Jan 2026 09:00:00 +0000</pubDate>
<description>Acme says hello and introduces its blog.</description>
</item>
</channel>
</rss>

View File

@@ -0,0 +1,8 @@
[
{
"id": 1,
"name": "Release",
"slug": "release",
"post_count": 1
}
]

71
testing/nuxt-server.mjs Normal file
View File

@@ -0,0 +1,71 @@
// Boots a built Nuxt server (`<outputDir>/server/index.mjs`) on 127.0.0.1
// against a given Journal API base and waits until it answers HTTP.
// Plain .mjs with JSDoc types so it runs from node_modules unchanged.
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
/** @returns {Promise<number>} */
function freePort() {
return new Promise((done, fail) => {
const srv = createServer()
srv.once('error', fail)
srv.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
srv.close(() => done(port))
})
})
}
/**
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
*/
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
const entry = join(resolve(outputDir), 'server', 'index.mjs')
if (!existsSync(entry)) throw new Error(`nuxt-server: ${entry} does not exist; build first`)
const listenPort = port || (await freePort())
const url = `http://127.0.0.1:${listenPort}`
const child = spawn(process.execPath, [entry], {
env: {
...process.env,
NODE_ENV: 'production',
NITRO_HOST: '127.0.0.1',
NITRO_PORT: String(listenPort),
NUXT_JOURNAL_API_BASE: apiBase,
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
let exited = false
child.once('exit', () => (exited = true))
const close = () =>
new Promise((/** @type {(v?: void) => void} */ done) => {
if (exited) return done()
child.once('exit', () => done())
child.kill('SIGTERM')
setTimeout(() => {
if (!exited) child.kill('SIGKILL')
}, 5000).unref()
})
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
return { url, close }
} catch {
await new Promise((r) => setTimeout(r, 200))
}
}
await close()
throw new Error(`nuxt-server: no answer on ${url} within ${timeoutMs} ms\n${log}`)
}

132
testing/smoke-ssr.mjs Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
// SSR smoke check: boots the fixture Journal API and a built Nuxt server,
// then checks each line of an expectations file.
//
// node smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
// 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).
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const VISITOR_IP = '203.0.113.7'
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
const out = { env: {} }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = () => {
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
return v
}
if (arg === '--output') out.output = next()
else if (arg === '--fixtures') out.fixtures = next()
else if (arg === '--expect') out.expect = next()
else if (arg === '--env') {
const kv = next()
const eq = kv.indexOf('=')
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
} else throw new Error(`unknown argument ${arg}`)
}
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
}
/**
* @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
*/
export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type)=/g
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
}
/** @type {Record<string, string>} */
const options = {}
marks.forEach((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim()
})
return { ...check, ...options }
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const lines = readFileSync(resolve(args.expect), 'utf8')
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '' && !l.startsWith('#'))
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
/** @type {{ url: string, close: () => Promise<void> } | null} */
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: args.env })
for (const line of lines) {
let check
try {
check = parseExpectLine(line)
} catch (err) {
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
continue
}
let res
try {
res = await fetch(nuxt.url + check.path, {
redirect: 'manual',
headers: { 'X-Forwarded-For': VISITOR_IP },
signal: AbortSignal.timeout(20000),
})
} catch (err) {
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
continue
}
const body = await res.text()
const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
}
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
}
} finally {
if (nuxt) await nuxt.close()
await api.close()
}
if (failures.length > 0) {
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
process.exit(1)
}
console.log(`smoke-ssr: ok (${lines.length} checks)`)
}
main().catch((err) => {
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
// Self-contained on purpose: Vite reads this file when it compiles the
// layer's .vue and .ts files, also from a consumer's node_modules, where no
// generated .nuxt/tsconfig.json exists. For editor types of Nuxt
// auto-imports, run `pnpm dev:prepare` and open .playground.
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"]
}
}