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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
.data/
|
||||
.playground/.nuxt/
|
||||
.playground/.output/
|
||||
.env
|
||||
5
.playground/app/app.vue
Normal file
5
.playground/app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
14
.playground/nuxt.config.ts
Normal file
14
.playground/nuxt.config.ts
Normal 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
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
13
app/components/Journal/PostBody.vue
Normal file
13
app/components/Journal/PostBody.vue
Normal 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>
|
||||
26
app/composables/useJournalApi.ts
Normal file
26
app/composables/useJournalApi.ts
Normal 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)
|
||||
}
|
||||
21
app/journal-pages/AuthorPage.vue
Normal file
21
app/journal-pages/AuthorPage.vue
Normal 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>
|
||||
21
app/journal-pages/CategoryPage.vue
Normal file
21
app/journal-pages/CategoryPage.vue
Normal 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>
|
||||
13
app/journal-pages/PostIndexPage.vue
Normal file
13
app/journal-pages/PostIndexPage.vue
Normal 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>
|
||||
29
app/journal-pages/PostPage.vue
Normal file
29
app/journal-pages/PostPage.vue
Normal 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>
|
||||
21
app/journal-pages/TagPage.vue
Normal file
21
app/journal-pages/TagPage.vue
Normal 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
5
app/layouts/default.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<main class="journal-layout">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
150
app/utils/client.ts
Normal file
150
app/utils/client.ts
Normal 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
23
app/utils/htmlGate.ts
Normal 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
78
app/utils/routes.ts
Normal 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
143
app/utils/types.ts
Normal 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
29
app/utils/view.ts
Normal 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
58
lib/journal-module.ts
Normal 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
42
nuxt.config.ts
Normal 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
29
package.json
Normal 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
11075
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
86
scripts/check-git-install.sh
Executable file
86
scripts/check-git-install.sh
Executable 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
9
testing/expect-acme.txt
Normal 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
247
testing/fixture-api.mjs
Normal 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())),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
9
testing/fixtures/acme/authors.json
Normal file
9
testing/fixtures/acme/authors.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "jane-doe",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"login": "jdoe"
|
||||
}
|
||||
]
|
||||
22
testing/fixtures/acme/categories.json
Normal file
22
testing/fixtures/acme/categories.json
Normal 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
|
||||
}
|
||||
]
|
||||
466
testing/fixtures/acme/posts.json
Normal file
466
testing/fixtures/acme/posts.json
Normal 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"
|
||||
}
|
||||
]
|
||||
15
testing/fixtures/acme/rss.xml
Normal file
15
testing/fixtures/acme/rss.xml
Normal 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>
|
||||
8
testing/fixtures/acme/tags.json
Normal file
8
testing/fixtures/acme/tags.json
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Release",
|
||||
"slug": "release",
|
||||
"post_count": 1
|
||||
}
|
||||
]
|
||||
71
testing/nuxt-server.mjs
Normal file
71
testing/nuxt-server.mjs
Normal 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
132
testing/smoke-ssr.mjs
Executable 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
16
tsconfig.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user