feat: Journal list, category pages, pagination, tokens and pl/en strings

- index and category pages paginate with ?page=N and 404 out of range
- category content renders above the list through the html gate
- related posts fall back to the latest posts
- --journal-* tokens and neutral base styles
- pl/en strings registered only for locales the app enables
This commit is contained in:
2026-10-09 18:02:48 +02:00
parent 6f6792f51c
commit c408eeb2ca
23 changed files with 807 additions and 21 deletions

View File

@@ -0,0 +1,281 @@
/* Neutral Journal styles. Only --journal-* tokens; no site colours. */
.journal-layout {
box-sizing: border-box;
max-width: var(--journal-wide-width);
margin: 0 auto;
padding: var(--journal-space-5) var(--journal-space-4) var(--journal-space-6);
color: var(--journal-color-text);
background: var(--journal-color-bg);
font-family: var(--journal-font-body);
font-size: var(--journal-font-size-base);
line-height: var(--journal-line-height);
}
.journal-layout a {
color: var(--journal-color-link);
}
.journal-layout a:hover {
color: var(--journal-color-link-hover);
}
.journal-layout a:focus-visible {
outline: 2px solid var(--journal-color-accent);
outline-offset: 2px;
}
.journal-layout h1,
.journal-layout h2,
.journal-layout h3 {
font-family: var(--journal-font-heading);
line-height: 1.25;
}
/* Headers */
.journal-index-header,
.journal-category-header {
margin-bottom: var(--journal-space-5);
}
.journal-index-header__title,
.journal-category-header__title {
margin: 0 0 var(--journal-space-2);
}
.journal-category-header__description {
margin: 0 0 var(--journal-space-4);
color: var(--journal-color-muted);
}
.journal-category-header__content {
max-width: var(--journal-content-width);
}
/* Post page */
.journal-post {
max-width: var(--journal-content-width);
margin: 0 auto;
}
.journal-post__title {
margin: 0 0 var(--journal-space-3);
}
.journal-post-meta {
display: flex;
flex-wrap: wrap;
gap: var(--journal-space-2) var(--journal-space-3);
margin-bottom: var(--journal-space-4);
color: var(--journal-color-muted);
font-size: 0.9em;
}
.journal-post-meta__categories {
display: flex;
flex-wrap: wrap;
gap: var(--journal-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.journal-featured-image {
margin: 0 0 var(--journal-space-4);
}
.journal-featured-image img {
display: block;
width: 100%;
height: auto;
aspect-ratio: var(--journal-image-ratio);
object-fit: cover;
border-radius: var(--journal-radius-lg);
background: var(--journal-color-surface);
}
.journal-post__siblings {
display: flex;
justify-content: space-between;
gap: var(--journal-space-3);
margin: var(--journal-space-5) 0;
padding-top: var(--journal-space-4);
border-top: 1px solid var(--journal-color-border);
}
.journal-post__siblings span {
display: block;
color: var(--journal-color-muted);
font-size: 0.85em;
}
.journal-post__next {
margin-left: auto;
text-align: right;
}
/* Rendered HTML bodies (posts and category content) */
.journal-post-body > :first-child {
margin-top: 0;
}
.journal-post-body img,
.journal-post-body video {
max-width: 100%;
height: auto;
border-radius: var(--journal-radius-md);
}
.journal-post-body ul,
.journal-post-body ol {
padding-left: var(--journal-space-4);
}
.journal-post-body blockquote {
margin: var(--journal-space-4) 0;
padding: var(--journal-space-3) var(--journal-space-4);
border-left: 4px solid var(--journal-color-border);
background: var(--journal-color-surface);
}
.journal-post-body code {
padding: 0 var(--journal-space-1);
border-radius: var(--journal-radius-sm);
background: var(--journal-color-surface);
font-family: var(--journal-font-mono);
font-size: 0.9em;
}
.journal-post-body pre {
overflow-x: auto;
padding: var(--journal-space-3);
border-radius: var(--journal-radius-md);
background: var(--journal-color-surface);
}
.journal-post-body pre code {
padding: 0;
}
.journal-post-body table {
width: 100%;
border-collapse: collapse;
margin: var(--journal-space-4) 0;
}
.journal-post-body th,
.journal-post-body td {
padding: var(--journal-space-2) var(--journal-space-3);
border: 1px solid var(--journal-color-border);
text-align: left;
}
.journal-post-body--blocked {
padding: var(--journal-space-3);
border: 1px dashed var(--journal-color-border);
border-radius: var(--journal-radius-md);
color: var(--journal-color-muted);
}
/* Listings */
.journal-post-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--journal-card-min), 1fr));
gap: var(--journal-space-4);
}
.journal-post-list__empty {
grid-column: 1 / -1;
color: var(--journal-color-muted);
}
.journal-post-card {
display: flex;
flex-direction: column;
gap: var(--journal-space-2);
padding: var(--journal-space-4);
border: 1px solid var(--journal-color-border);
border-radius: var(--journal-radius-md);
background: var(--journal-color-bg);
}
.journal-post-card .journal-featured-image {
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
}
.journal-post-card .journal-featured-image img {
border-radius: var(--journal-radius-md) var(--journal-radius-md) 0 0;
}
.journal-post-card__title {
margin: 0;
font-size: 1.2em;
}
.journal-post-card__title a {
text-decoration: none;
}
.journal-post-card__date {
color: var(--journal-color-muted);
font-size: 0.85em;
}
.journal-post-card__excerpt {
margin: 0;
}
.journal-post-card__more {
margin-top: auto;
}
.journal-related {
margin-top: var(--journal-space-5);
}
/* Pagination */
.journal-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--journal-space-3);
margin-top: var(--journal-space-5);
}
.journal-pagination__pages {
display: flex;
gap: var(--journal-space-1);
margin: 0;
padding: 0;
list-style: none;
}
.journal-pagination__pages a,
.journal-pagination__current {
display: inline-block;
min-width: 2.25em;
padding: var(--journal-space-1) var(--journal-space-2);
border-radius: var(--journal-radius-sm);
text-align: center;
}
.journal-pagination__current {
background: var(--journal-color-accent);
color: var(--journal-color-on-accent);
}
/* Author */
.journal-author-card {
margin-bottom: var(--journal-space-5);
}
.journal-author-card__name {
margin: 0 0 var(--journal-space-2);
}
.journal-rss-link {
display: inline-block;
margin-top: var(--journal-space-4);
font-size: 0.9em;
}

View File

@@ -0,0 +1,40 @@
/* Journal design tokens. Every visual value in the layer comes from these.
An app overrides any of them in its own CSS, e.g. on :root:root. */
:root {
/* Colour */
--journal-color-text: #1f2328; /* body text */
--journal-color-muted: #59636e; /* dates, meta, captions */
--journal-color-bg: #ffffff; /* page background */
--journal-color-surface: #f6f8fa; /* cards, quotes, code blocks */
--journal-color-border: #d1d9e0; /* card and table borders, rules */
--journal-color-link: #0969da; /* links */
--journal-color-link-hover: #0550ae; /* hovered links */
--journal-color-accent: #0969da; /* current page, focus ring */
--journal-color-on-accent: #ffffff; /* text on the accent colour */
/* Type */
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
--journal-font-heading: var(--journal-font-body); /* headings */
--journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
--journal-font-size-base: 1.0625rem; /* body size */
--journal-line-height: 1.7; /* body leading */
/* Spacing scale */
--journal-space-1: 0.25rem; /* hairline gaps */
--journal-space-2: 0.5rem; /* inline gaps */
--journal-space-3: 1rem; /* paragraph gap */
--journal-space-4: 1.5rem; /* card padding */
--journal-space-5: 2.5rem; /* section gap */
--journal-space-6: 4rem; /* page padding */
/* Radii */
--journal-radius-sm: 4px; /* code, tags */
--journal-radius-md: 8px; /* cards, images */
--journal-radius-lg: 16px; /* featured image */
/* Layout */
--journal-content-width: 46rem; /* reading column */
--journal-wide-width: 72rem; /* listings */
--journal-card-min: 16rem; /* narrowest card in a grid */
--journal-image-ratio: 1200 / 630; /* featured image crop */
}

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
// Heading of a category page: name, description and the category's rich
// content, which passes the Journal HTML gate before v-html.
import type { JournalCategory } from '../../utils/types.ts'
import { gateJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number; blocked?: boolean }>()
const content = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.category.content)))
</script>
<template>
<header class="journal-category-header">
<h1 class="journal-category-header__title">
{{ category.name }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
</h1>
<p v-if="category.description" class="journal-category-header__description">{{ category.description }}</p>
<div v-if="content.safe && content.html" class="journal-category-header__content journal-post-body" v-html="content.html" />
<p v-else-if="!content.safe" class="journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
</header>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// A post's featured image. The intrinsic width/height reserve space before
// the image loads; the rendered size and crop come from --journal-image-ratio.
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
</script>
<template>
<figure v-if="src" class="journal-featured-image">
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
</figure>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
// Heading of the post index; pages after the first say which page they are.
defineProps<{ page: number }>()
</script>
<template>
<header class="journal-index-header">
<h1 class="journal-index-header__title">
{{ $t('journal.index.title') }}<template v-if="page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
</h1>
</header>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
// Previous/next and a window of page links for the current listing. Links
// keep the current path and use ?page=N; page 1 drops the query.
import { paginationWindow } from '../../utils/pagination.ts'
const props = defineProps<{ current: number; last: number }>()
const route = useRoute()
const to = (page: number) => {
const query = { ...route.query }
if (page > 1) query.page = String(page)
else delete query.page
return { path: route.path, query }
}
const pages = computed(() => paginationWindow(props.current, props.last))
</script>
<template>
<nav v-if="last > 1" class="journal-pagination" :aria-label="$t('journal.pagination.label')">
<NuxtLink v-if="current > 1" :to="to(current - 1)" rel="prev" class="journal-pagination__prev">
{{ $t('journal.pagination.previous') }}
</NuxtLink>
<ol class="journal-pagination__pages">
<li v-for="page in pages" :key="page">
<span v-if="page === current" class="journal-pagination__current" aria-current="page">{{ page }}</span>
<NuxtLink v-else :to="to(page)">{{ page }}</NuxtLink>
</li>
</ol>
<NuxtLink v-if="current < last" :to="to(current + 1)" rel="next" class="journal-pagination__next">
{{ $t('journal.pagination.next') }}
</NuxtLink>
</nav>
</template>

View File

@@ -9,5 +9,5 @@ const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJo
<template>
<div v-if="gated.safe" class="journal-post-body" v-html="gated.html" />
<p v-else class="journal-post-body journal-post-body--blocked">This content cannot be displayed.</p>
<p v-else class="journal-post-body journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
// One post in a listing: image, title link, date and excerpt.
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const { localeProperties } = useI18n()
const links = useJournalLinks()
const href = computed(() => links.postLink(props.post.slug))
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
</script>
<template>
<article class="journal-post-card">
<NuxtLink v-if="href && post.featured_image_url" :to="href" class="journal-post-card__image" tabindex="-1" aria-hidden="true">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
</NuxtLink>
<h2 class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
<template v-else>{{ post.title }}</template>
</h2>
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time>
<p v-if="post.excerpt" class="journal-post-card__excerpt">{{ post.excerpt }}</p>
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
</article>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
// A grid of post cards, or the empty-state text when there are none.
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[]; empty?: string }>()
</script>
<template>
<div class="journal-post-list">
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
// Date, reading time, categories and author of a post. Links go through
// route names, so a disabled view renders its label as plain text.
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const { localeProperties } = useI18n()
const links = useJournalLinks()
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const authorName = computed(() => {
const a = props.post.author
return a ? `${a.first_name} ${a.last_name}`.trim() || a.login : ''
})
const authorHref = computed(() => (props.post.author ? links.authorLink(props.post.author.login) : null))
</script>
<template>
<div class="journal-post-meta">
<time v-if="date" class="journal-post-meta__date" :datetime="post.published_at ?? undefined">
{{ $t('journal.post.publishedOn', { date }) }}
</time>
<span v-if="post.reading_time > 0" class="journal-post-meta__reading">
{{ $t('journal.post.readingTime', { minutes: post.reading_time }) }}
</span>
<span v-if="authorName" class="journal-post-meta__author">
{{ $t('journal.post.by') }}
<NuxtLink v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLink>
<template v-else>{{ authorName }}</template>
</span>
<ul v-if="post.categories.length" class="journal-post-meta__categories">
<li v-for="cat in post.categories" :key="cat.id">
<NuxtLink v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLink>
<span v-else>{{ cat.name }}</span>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
// Related posts under a post. The page passes the API's related_posts, or
// the latest posts when the post has no category.
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[] }>()
</script>
<template>
<section v-if="posts.length" class="journal-related">
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
<JournalPostList :posts="posts" />
</section>
</template>

View File

@@ -0,0 +1,23 @@
// Links to Journal views, built from stable route names through
// useLocalePath() (i18n gives every route a per-locale name), never from
// concatenated paths. A link to a disabled view (journal.routes.<key>: false)
// is null; components then render plain text.
import { JOURNAL_ROUTE_NAMES } from '../utils/routes.ts'
import type { JournalRoutes } from '../utils/types.ts'
export function useJournalLinks() {
const localePath = useLocalePath()
const routes = (useRuntimeConfig().public.journal as { routes: JournalRoutes }).routes
const link = (key: keyof JournalRoutes, params?: Record<string, string>, query?: Record<string, string>) =>
routes[key] === false ? null : localePath({ name: JOURNAL_ROUTE_NAMES[key], params, query })
return {
postLink: (slug: string) => link('post', { slug }),
categoryLink: (slug: string) => link('category', { category: slug }),
tagLink: (slug: string) => link('tag', { tag: slug }),
authorLink: (slug: string) => link('author', { author: slug }),
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
}
}

View File

@@ -1,21 +1,49 @@
<script setup lang="ts">
// journal-category: posts in one category; an unknown category is a 404.
// journal-category: one category's header and rich content above its posts.
// The slug resolves against GET /categories?include_empty=1, so an empty
// category answers 200 with its content; an unknown slug is a real 404 (this
// route may be a root-level catch-all in the app).
import { gateJournalHtml } from '../utils/htmlGate.ts'
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
const route = useRoute()
const api = useJournalApi()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const slug = String(route.params.category ?? '')
const { data } = await useAsyncData(`journal-category-${slug}`, async () => {
const category = await api.findCategory(slug)
if (!category) return null
return { category, page: await api.listPosts({ category: category.slug }) }
const page = parsePageParam(route.query.page)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => {
const found = await api.findCategory(slug)
if (!found) return null
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
// Only this category reaches the payload, with its content already gated.
const gated = gateJournalHtml(found.content)
const { children: _children, ...category } = found
return {
category: { ...category, content: gated.html || null },
contentBlocked: !gated.safe,
posts: { ...posts, data: posts.data.map(toListPost) },
}
})
if (!data.value) throw createError({ statusCode: 404, fatal: true })
if (error.value) {
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
}
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
</script>
<template>
<section v-if="data" class="journal-category">
<h1>{{ data.category.name }}</h1>
<ul>
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
</ul>
<JournalCategoryHeader :category="data.category" :page="page" :blocked="data.contentBlocked" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
</section>
</template>

View File

@@ -1,13 +1,37 @@
<script setup lang="ts">
// journal-index: the latest posts.
// journal-index: the post list, paginated with ?page=N. A non-numeric page
// or a page past the last one is a real 404.
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
// Re-run setup (and its 404 checks) on every query change.
definePageMeta({ key: (route) => route.fullPath })
const route = useRoute()
const api = useJournalApi()
const { data: page } = await useAsyncData('journal-index', () => api.listPosts({ perPage: 9 }))
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const page = parsePageParam(route.query.page)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(
`journal-index-${page}`,
() => api.listPosts({ page, perPage: cfg.perPage.index, sort: cfg.sort }),
{ transform: (res) => ({ ...res, data: res.data.map(toListPost) }) },
)
if (error.value || !data.value) {
throw createError({ statusCode: (error.value as { statusCode?: number } | undefined)?.statusCode ?? 500, fatal: true })
}
if (pageOutOfRange(page, data.value.meta.last_page)) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
</script>
<template>
<section class="journal-index">
<ul v-if="page">
<li v-for="post in page.data" :key="post.id">{{ post.title }}</li>
</ul>
<section v-if="data" class="journal-index">
<JournalIndexHeader :page="page" />
<JournalPostList :posts="data.data" />
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
</section>
</template>

View File

@@ -1,15 +1,30 @@
<script setup lang="ts">
// journal-post: one published post by slug. An unknown, draft or future post
// is a real 404. The transform gates the body before it reaches the payload.
import { toPostView } from '../utils/view.ts'
// Related posts fall back to the latest posts when the API has none (it
// returns none for a post without a category).
import { toListPost, toPostView } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
const route = useRoute()
const api = useJournalApi()
const links = useJournalLinks()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const slug = computed(() => String(route.params.slug ?? ''))
const { data: post, error } = await useAsyncData(
() => `journal-post-${slug.value}`,
() => api.getPost(slug.value),
async () => {
const found = await api.getPost(slug.value)
if (!found) return null
const count = Math.max(0, cfg.relatedCount)
let related = found.related_posts ?? []
if (related.length === 0 && count > 0) {
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
related = latest.data.filter((p) => p.slug !== found.slug)
}
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
},
{ transform: (p) => (p ? toPostView(p) : null) },
)
@@ -23,7 +38,30 @@ if (!post.value) {
<template>
<article v-if="post" class="journal-post">
<h1 class="journal-post__title">{{ post.title }}</h1>
<header class="journal-post__header">
<h1 class="journal-post__title">{{ post.title }}</h1>
<JournalPostMeta :post="post" />
</header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
<NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
:to="links.postLink(post.previous_post.slug)!"
rel="prev"
class="journal-post__previous"
>
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
</NuxtLink>
<NuxtLink
v-if="post.next_post && links.postLink(post.next_post.slug)"
:to="links.postLink(post.next_post.slug)!"
rel="next"
class="journal-post__next"
>
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
</NuxtLink>
</nav>
<JournalRelatedPosts :posts="post.related_posts" />
</article>
</template>

36
app/utils/pagination.ts Normal file
View File

@@ -0,0 +1,36 @@
// Page-number rules for Journal listings. Pages use ?page=N (never a path
// segment). Anything but a positive integer, and any page past the last one,
// is a real 404.
/**
* Parses the ?page query value: 1 when absent, the integer for a string of
* digits greater than 0, and null (a 404) for anything else: non-numeric,
* empty, 0, negative, decimal, repeated (array) or beyond the safe range.
*/
export function parsePageParam(raw: unknown): number | null {
if (raw === undefined || raw === null) return 1
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
const n = Number(raw)
if (!Number.isSafeInteger(n) || n < 1) return null
return n
}
/**
* True when `page` lies past the last page. Page 1 is always in range, so an
* empty listing renders its empty state instead of a 404.
*/
export function pageOutOfRange(page: number, lastPage: number): boolean {
return page > 1 && page > lastPage
}
/**
* The page numbers to render around `current`: at most `size` consecutive
* pages, clipped to 1..last and kept full-width near either end.
*/
export function paginationWindow(current: number, last: number, size: number = 5): number[] {
if (last < 1 || size < 1) return []
const width = Math.min(size, last)
let start = Math.max(1, current - Math.floor(width / 2))
if (start + width - 1 > last) start = last - width + 1
return Array.from({ length: width }, (_, i) => start + i)
}

View File

@@ -16,6 +16,22 @@ export interface JournalPostView extends JournalPostDetail {
content_blocked: boolean
}
/**
* Formats an API timestamp as a long date for `language` (a BCP 47 tag such
* as "pl-PL"). UTC on both server and browser, so hydration matches. Returns
* "" for a missing or invalid value.
*/
export function formatJournalDate(iso: string | null | undefined, language: string): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
try {
return new Intl.DateTimeFormat(language || 'en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
} catch {
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
}
}
export function toPostView(post: JournalPostDetail): JournalPostView {
const gated = gateJournalHtml(post.content_html)
return {

39
i18n/locales/en.json Normal file
View File

@@ -0,0 +1,39 @@
{
"journal": {
"index": {
"title": "Latest posts",
"empty": "There are no posts yet."
},
"pagination": {
"label": "Pagination",
"previous": "Previous page",
"next": "Next page",
"pageTitle": "Page {page}"
},
"post": {
"publishedOn": "Published on {date}",
"readingTime": "{minutes} min read",
"by": "By",
"related": "Related posts",
"previous": "Previous post",
"next": "Next post",
"contentUnavailable": "This content cannot be displayed."
},
"category": {
"empty": "There are no posts in this category yet."
},
"tag": {
"title": "Posts tagged {name}",
"empty": "There are no posts with this tag yet."
},
"author": {
"title": "Posts by {name}",
"posts": "Posts",
"empty": "This author has not published any posts yet."
},
"readMore": "Read more",
"rss": {
"title": "RSS feed"
}
}
}

39
i18n/locales/pl.json Normal file
View File

@@ -0,0 +1,39 @@
{
"journal": {
"index": {
"title": "Najnowsze wpisy",
"empty": "Nie ma jeszcze żadnych wpisów."
},
"pagination": {
"label": "Stronicowanie",
"previous": "Poprzednia strona",
"next": "Następna strona",
"pageTitle": "Strona {page}"
},
"post": {
"publishedOn": "Opublikowano {date}",
"readingTime": "{minutes} min czytania",
"by": "Autor:",
"related": "Powiązane wpisy",
"previous": "Poprzedni wpis",
"next": "Następny wpis",
"contentUnavailable": "Nie można wyświetlić tej treści."
},
"category": {
"empty": "W tej kategorii nie ma jeszcze wpisów."
},
"tag": {
"title": "Wpisy z tagiem {name}",
"empty": "Nie ma jeszcze wpisów z tym tagiem."
},
"author": {
"title": "Wpisy autora {name}",
"posts": "Wpisy",
"empty": "Ten autor nie opublikował jeszcze żadnych wpisów."
},
"readMore": "Czytaj dalej",
"rss": {
"title": "Kanał RSS"
}
}
}

View File

@@ -19,6 +19,18 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
}
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
const langDir = fileURLToPath(new URL('../i18n/locales/', import.meta.url))
/** UI string locales the layer ships, each in i18n/locales/<code>.json. */
const LAYER_LOCALES = ['pl', 'en'] as const
/** Locale codes the project enables in its i18n config (strings or objects). */
function enabledLocaleCodes(locales: unknown): string[] {
if (!Array.isArray(locales)) return []
return locales
.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
.filter((c): c is string => typeof c === 'string')
}
export default defineNuxtModule<JournalModuleOptions>({
meta: {
@@ -43,6 +55,20 @@ export default defineNuxtModule<JournalModuleOptions>({
}
})
// Register the layer's strings only for locales the app enables. The
// layer config declares no locales: @nuxtjs/i18n merges every locale a
// layer declares into the app, which would add /en routes to an app that
// enables only pl.
const hook = nuxt.hook as unknown as (
name: 'i18n:registerModule',
fn: (register: (config: { langDir: string; locales: Array<{ code: string; file: string }> }) => void) => void,
) => void
hook('i18n:registerModule', (register) => {
const enabled = enabledLocaleCodes((nuxt.options as { i18n?: { locales?: unknown } }).i18n?.locales)
const locales = LAYER_LOCALES.filter((code) => enabled.includes(code)).map((code) => ({ code, file: `${code}.json` }))
if (locales.length > 0) register({ langDir, locales })
})
// The public runtime config carries the resolved options next to apiBase,
// so pages, links and the sitemap source read one source of truth.
const pub = nuxt.options.runtimeConfig.public as Record<string, any>

View File

@@ -20,6 +20,10 @@ export default defineNuxtConfig({
// @nuxtjs/i18n split locale state).
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
// Tokens first, then the neutral styles that use them. The app's own css
// array loads after these, so its token overrides win.
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
runtimeConfig: {
journal: {
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never

View File

@@ -7,3 +7,19 @@
404 /blog/missing-post
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
404 /blog/draft-note
# Post index and pagination (9 per page, 13 published posts: 2 pages)
200 /blog contains=Hello Acme
200 /blog?page=2
404 /blog?page=3
404 /blog?page=abc
404 /blog?page=0
200 /pl/blog contains=Najnowsze wpisy
# Categories (include_empty=1; content renders above the list)
200 /blog/category/news contains=Acme news marker
200 /blog/category/quiet contains=There are no posts in this category yet.
404 /blog/category/nope
# Related posts fall back to the latest posts for a post without a category
200 /blog/uncategorised-note contains=Hello Acme

View File

@@ -10,7 +10,8 @@
// end of the line, so it may contain spaces. Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
// lines, at least one fixture API request must have carried that address
// (the SSR fetches forward the visitor's x-forwarded-for).
// (the SSR fetches forward the visitor's x-forwarded-for), and every
// categories request must have carried include_empty=1.
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -114,6 +115,13 @@ async function main() {
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
}
// Categories are always requested with include_empty=1, so empty ones resolve.
for (const r of api.requests) {
const u = new URL(r.url, 'http://fixture.local')
if (u.pathname.endsWith('/categories') && u.searchParams.get('include_empty') !== '1') {
failures.push(`include_empty: ${r.url} requested without include_empty=1`)
}
}
} finally {
if (nuxt) await nuxt.close()
await api.close()