fix: path-form listing routes use the SEO module's resolved pagination

- journalPaginationSource answers runtimeConfig.public.seo.pagination (the
  SEO module's option with its defaults), else the raw seo.pagination
- the module computes the paged routes inside its extendPages callback, so
  an app with only form: 'path' gets /blog/strona/:page and
  /blog/page/:page (L4 WR-03)
This commit is contained in:
Jakub Zych
2026-11-04 22:53:13 +01:00
parent 839db5156c
commit d64fe47adf
4 changed files with 43 additions and 7 deletions

View File

@@ -240,7 +240,7 @@ The page head comes from the CMS: vue-seo-layer resolves every page through the
- **ItemList.** The index, category, tag and author pages add an `ItemList` node (`@id` `<page URL>#itemlist`, one `ListItem` with the absolute URL of each post shown, positions continuing across pages) through vue-seo-layer's `useSchemaNode` (`useJournalItemList`), so the page keeps one JSON-LD script.
- **Body links.** `JournalPostBody` renders the body through `renderJournalBody` in the UI-SPEC L7 order: the sanitizer (with H1 demotion), vue-seo-layer's `autolink` with the resolve answer's `autolinks` and `autolink_max` (first occurrence, whole words, never in headings, links, code, figure captions or buttons, never to the page itself), `applyOutbound` with the answer's `outbound` (the editor's `rel` kept, `nofollow` or `sponsored` from the domain lists; `target="_blank"`, `rel="noopener"` and a `<span class="seo-visually-hidden">` hint only while `new_tab` is on, the hint while vue-seo-layer's `seo.links.newTabHint` is on, its default) and `imageAttrs` (`loading="lazy"`, `decoding="async"`, stored width, height and `alt=""` kept). Internal links are untouched.
- **Breadcrumbs.** Post, index, category, tag and author pages render `<SeoBreadcrumbs>` at the top of their content while `journal.breadcrumbs` is on. A site that places the trail in its layout sets it to `false` and renders one `<SeoBreadcrumbs>` there; vue-seo-layer renders one trail per page either way, and the `BreadcrumbList` node is the CMS's.
- **Pagination.** `JournalPagination` renders `<SeoPagination class-prefix="journal-pagination">` with the journal's labels: the classes `journal-pagination`, `__prev`, `__pages`, `__current` and `__next` stay, plus page 1, the last page and `__gap` items, `aria-label`s and head `rel="prev"`/`rel="next"` links. Links follow the CMS's pagination form; with `seo.pagination.form: 'path'` in the app's config the module registers `<listing>/<segment>/:page` routes for the index, category, tag and author pages.
- **Pagination.** `JournalPagination` renders `<SeoPagination class-prefix="journal-pagination">` with the journal's labels: the classes `journal-pagination`, `__prev`, `__pages`, `__current` and `__next` stay, plus page 1, the last page and `__gap` items, `aria-label`s and head `rel="prev"`/`rel="next"` links. Links follow the CMS's pagination form; with `seo.pagination.form: 'path'` in the app's config the module registers `<listing>/<segment>/:page` routes for the index, category, tag and author pages. The segments follow vue-seo-layer's resolved `seo.pagination` option, defaults included (`pl` `strona`, `en` `page`), the same ones its pagination links use, so an app that sets only `form: 'path'` gets `/blog/strona/:page` and `/blog/page/:page`.
### Headings

View File

@@ -88,6 +88,24 @@ export function journalPagedRouteName(key: keyof JournalRoutes, segment: string)
return `${JOURNAL_ROUTE_NAMES[key]}-page-${segment}`
}
/**
* The pagination option the journal's path-form routes follow (P33-71, L4
* WR-03): vue-seo-layer's resolved option, which its module's setup writes
* into runtimeConfig.public.seo.pagination with its defaults (segments pl
* strona, en page), so the routes match the links usePagination builds;
* else the raw seo.pagination of the app's config. Read inside the
* extendPages callback, which runs after every module's setup. No import
* from vue-seo-layer: a git-installed layer's .ts is not type-stripped by
* Node under node_modules.
*/
export function journalPaginationSource(nuxtOptions: unknown): unknown {
if (nuxtOptions === null || typeof nuxtOptions !== 'object') return undefined
const o = nuxtOptions as { runtimeConfig?: { public?: { seo?: { pagination?: unknown } } }; seo?: { pagination?: unknown } }
const resolved = o.runtimeConfig?.public?.seo?.pagination
if (resolved !== null && typeof resolved === 'object') return resolved
return o.seo?.pagination
}
/**
* The extra listing routes of the path pagination form (D-40): with the
* vue-seo-layer build option seo.pagination.form 'path', each listing gets

View File

@@ -7,7 +7,7 @@
import { fileURLToPath } from 'node:url'
import { defineNuxtModule, extendPages } from 'nuxt/kit'
import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, journalPagedRoutes, normalizeJournalRoutes } from '../app/utils/routes.ts'
import { DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, journalPagedRoutes, journalPaginationSource, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalModuleOptions, JournalPostTitleTag, JournalRoutes } from '../app/utils/types.ts'
const PAGE_FILES: Record<keyof JournalRoutes, string> = {
@@ -55,12 +55,13 @@ export default defineNuxtModule<JournalModuleOptions>({
setup(options, nuxt) {
const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>)
// The path pagination form (vue-seo-layer's seo.pagination.form 'path',
// D-40) adds <listing>/<segment>/:page routes rendering the same pages,
// which read the page from the :page param.
const paged = journalPagedRoutes(routes, (nuxt.options as { seo?: { pagination?: unknown } }).seo?.pagination)
extendPages((pages) => {
// The path pagination form (vue-seo-layer's seo.pagination.form 'path',
// D-40) adds <listing>/<segment>/:page routes rendering the same pages,
// which read the page from the :page param. The segments come from the
// SEO module's resolved option (its defaults included), written in its
// setup, which has run by now (L4 WR-03).
const paged = journalPagedRoutes(routes, journalPaginationSource(nuxt.options))
for (const key of Object.keys(PAGE_FILES) as Array<keyof JournalRoutes>) {
const path = routes[key]
if (path === false) continue

View File

@@ -6,6 +6,7 @@ import {
JOURNAL_ROUTE_NAMES,
fillJournalPath,
journalPagedRoutes,
journalPaginationSource,
normalizeJournalRoutes,
} from '../app/utils/routes.ts'
@@ -144,3 +145,19 @@ test('journalPagedRoutes: the query form, no option and disabled views add nothi
path: '/news/p/:page(\\d+)',
})
})
test('journalPaginationSource: the SEO module\'s resolved option, else the raw seo.pagination (L4 WR-03)', () => {
const resolved = { form: 'path', param: 'page', segments: { pl: 'strona', en: 'page' } }
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: { seo: { pagination: resolved } } }, seo: { pagination: { form: 'path' } } }), resolved)
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: {} }, seo: { pagination: { form: 'path' } } }), { form: 'path' })
assert.deepEqual(journalPaginationSource({ runtimeConfig: { public: { seo: { cms: true } } }, seo: { pagination: { form: 'query' } } }), { form: 'query' })
assert.equal(journalPaginationSource({}), undefined)
assert.equal(journalPaginationSource(null), undefined)
})
test('journalPagedRoutes: an app that set only form path gets the default pl and en segments through the resolved option', () => {
// vue-seo-layer's setup writes runtimeConfig.public.seo.pagination with its defaults
const nuxtOptions = { seo: { pagination: { form: 'path' } }, runtimeConfig: { public: { seo: { pagination: { form: 'path', param: 'page', segments: { pl: 'strona', en: 'page' } } } } } }
const paths = journalPagedRoutes(normalizeJournalRoutes({ category: false, tag: false, author: false }), journalPaginationSource(nuxtOptions)).map((r) => r.path)
assert.deepEqual(paths, ['/blog/strona/:page(\\d+)', '/blog/page/:page(\\d+)'])
})