feat: category neighbours on the post page

The post page links previous_in_category and next_in_category (the
Journal's opt-in scored related mode) as server-rendered links with the
title in the anchor text when the Journal answers one, else the
site-wide previous_post and next_post (postNeighbours). Types, a fixture
post and expectation lines for both cases.
This commit is contained in:
Jakub Zych
2026-11-04 14:15:22 +01:00
parent ed50826bf7
commit adcea4d538
7 changed files with 70 additions and 12 deletions

View File

@@ -19,7 +19,7 @@ Route paths are configuration, not files. The layer's page components live in `a
## Features
- **Post list** (`journal-index`): paginated with `?page=N`, or `<listing>/<segment>/N` when vue-seo-layer's `seo.pagination.form` is `path`. A non-numeric page or a page past the last one answers 404.
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead.
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead. With the Journal's opt-in `golem15.journal.related_mode: scored`, the related posts are the Journal's ranked ones and the previous and next links are the post's neighbours within its first category (`previous_in_category`, `next_in_category`, server-rendered links with the title in the anchor text); without them, the site-wide `previous_post` and `next_post`.
- **Category** (`journal-category`): name, description and the category's rich HTML content (the pillar text, on page 1 only; the description shows on every page) above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL. Post meta links to the author by `author.slug` (sm-journal-plugin v0.3.1+), falling back to the login on older APIs.
- **Search** (`journal-search`, opt-in): a results page for `?q=` and the `JournalSearchBox` header component with suggestions as the visitor types. Off until the app sets `journal.routes.search`. See [Search](#search).

View File

@@ -13,7 +13,7 @@
// The head comes from the CMS through vue-seo-layer; the post's own title,
// excerpt and featured image apply only while there is no CMS answer.
import { cmsHeadPresent, journalPostTitleTag, journalSiteOrigin, postSeoMeta } from '../utils/seo.ts'
import { demotesH1, toPostView } from '../utils/postView.ts'
import { demotesH1, postNeighbours, toPostView } from '../utils/postView.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
@@ -65,6 +65,9 @@ useSeoMeta({
const titleTag = computed(() => journalPostTitleTag(cfg.postTitleTag, post.value?.content_h1_to_h2))
const demoteH1 = computed(() => demotesH1(post.value))
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
// previous_in_category / next_in_category (the Journal's opt-in scored
// mode) win over the site-wide neighbours when the Journal answers one.
const siblings = computed(() => (post.value ? postNeighbours(post.value) : { previous: null, next: null, inCategory: false }))
</script>
<template>
@@ -76,22 +79,22 @@ const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
</header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
<JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
<nav v-if="siblings.previous || siblings.next" class="journal-post__siblings">
<NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
:to="links.postLink(post.previous_post.slug)!"
v-if="siblings.previous && links.postLink(siblings.previous.slug)"
:to="links.postLink(siblings.previous.slug)!"
rel="prev"
class="journal-post__previous"
>
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
<span>{{ $t('journal.post.previous') }}</span> {{ siblings.previous.title }}
</NuxtLink>
<NuxtLink
v-if="post.next_post && links.postLink(post.next_post.slug)"
:to="links.postLink(post.next_post.slug)!"
v-if="siblings.next && links.postLink(siblings.next.slug)"
:to="links.postLink(siblings.next.slug)!"
rel="next"
class="journal-post__next"
>
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
<span>{{ $t('journal.post.next') }}</span> {{ siblings.next.title }}
</NuxtLink>
</nav>
<JournalRelatedPosts :posts="post.related_posts" />

View File

@@ -15,6 +15,24 @@ export function demotesH1(post: { content_h1_to_h2?: boolean | null } | null | u
return post?.content_h1_to_h2 !== false
}
/**
* The previous and next links of a post page: the neighbours within the
* post's first category when the Journal answers them (its opt-in scored
* mode) and at least one exists, else the site-wide previous_post and
* next_post.
*/
export function postNeighbours(post: {
previous_post?: { slug: string; title: string } | null
next_post?: { slug: string; title: string } | null
previous_in_category?: { slug: string; title: string } | null
next_in_category?: { slug: string; title: string } | null
}): { previous: { slug: string; title: string } | null; next: { slug: string; title: string } | null; inCategory: boolean } {
if (post.previous_in_category || post.next_in_category) {
return { previous: post.previous_in_category ?? null, next: post.next_in_category ?? null, inCategory: true }
}
return { previous: post.previous_post ?? null, next: post.next_post ?? null, inCategory: false }
}
/** A post page's data: the post with its body sanitized and no source or translations. */
export type JournalPostView = JournalPostDetail

View File

@@ -75,8 +75,19 @@ export interface JournalPostSibling {
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. */
/**
* Latest 3 in the same categories (empty when the post has no category);
* with the Journal's related_mode scored, 3 to 6 ranked by shared tags
* and categories.
*/
related_posts: JournalPost[]
/**
* The neighbours by publish time within the post's first category, in
* the direction of previous_post and next_post. Only with the Journal's
* opt-in related_mode scored; absent otherwise.
*/
previous_in_category?: JournalPostSibling | null
next_in_category?: JournalPostSibling | null
/**
* The Journal H1 setting (newer Journal APIs): true while stored
* content has its H1 headings converted to H2, so the post title is the

View File

@@ -134,3 +134,10 @@
200 /blog/tag/release count=application/ld+json:1 contains=ItemList
200 /blog/author/jane-doe count=application/ld+json:1 contains=ItemList
200 /blog/hello-acme notcontains=ItemList
# Category neighbours (the Journal's opt-in related_mode scored): the post
# answer's previous_in_category and next_in_category render as server-side
# links instead of the site-wide neighbours; without them the site-wide
# previous_post and next_post stay.
200 /blog/heading-on contains=href="/blog/acme-note-04" class="journal-post__previous" rel="prev"><span>Previous post</span> Acme Note 04</a> contains=href="/blog/acme-note-06" class="journal-post__next" rel="next"><span>Next post</span> Acme Note 06</a>
200 /blog/heading-off contains=href="/blog/heading-on" class="journal-post__next" rel="next"><span>Next post</span> Heading On Sample</a> notcontains=journal-post__previous

View File

@@ -497,7 +497,17 @@
"translations": {},
"created_at": "2025-12-20T09:00:00+00:00",
"updated_at": "2025-12-20T09:00:00+00:00",
"content_h1_to_h2": true
"content_h1_to_h2": true,
"previous_in_category": {
"id": 4,
"title": "Acme Note 04",
"slug": "acme-note-04"
},
"next_in_category": {
"id": 6,
"title": "Acme Note 06",
"slug": "acme-note-06"
}
},
{
"id": 16,

View File

@@ -2,7 +2,7 @@
// source, translations and related post bodies stay out of it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { toPostView } from '../app/utils/postView.ts'
import { postNeighbours, toPostView } from '../app/utils/postView.ts'
import type { JournalPost, JournalPostDetail } from '../app/utils/types.ts'
function post(overrides: Partial<JournalPost> = {}): JournalPost {
@@ -53,3 +53,12 @@ test('source, translations and related post bodies stay out of the payload', ()
test('a missing body becomes an empty string', () => {
assert.equal(toPostView(detail({ content_html: null })).content_html, '')
})
test('postNeighbours prefers the in-category neighbours when the Journal answers one', () => {
const site = { previous_post: { slug: 'p', title: 'P' }, next_post: { slug: 'n', title: 'N' } }
assert.deepEqual(postNeighbours(site), { previous: site.previous_post, next: site.next_post, inCategory: false })
assert.deepEqual(postNeighbours({ ...site, previous_in_category: null, next_in_category: null }), { previous: site.previous_post, next: site.next_post, inCategory: false })
const cat = { slug: 'c', title: 'C' }
assert.deepEqual(postNeighbours({ ...site, previous_in_category: null, next_in_category: cat }), { previous: null, next: cat, inCategory: true })
assert.deepEqual(postNeighbours({}), { previous: null, next: null, inCategory: false })
})