Files
vue-journal-layer/lib/journal-module.ts
Jakub Zych d64fe47adf 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)
2026-11-04 22:53:13 +01:00

119 lines
5.6 KiB
TypeScript

// 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, 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> = {
index: 'PostIndexPage',
post: 'PostPage',
category: 'CategoryPage',
tag: 'TagPage',
author: 'AuthorPage',
search: 'SearchPage',
}
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')
}
/** journal.postTitleTag: 'h1' or 'p' as given, anything else 'auto'. */
function postTitleTag(value: unknown): JournalPostTitleTag {
return value === 'h1' || value === 'p' ? value : 'auto'
}
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, search: 9 },
sort: 'published_at desc',
relatedCount: 3,
rss: true,
postTitleTag: 'auto',
breadcrumbs: true,
},
setup(options, nuxt) {
const routes = normalizeJournalRoutes(options.routes as unknown as Record<string, unknown>)
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
pages.push({ name: JOURNAL_ROUTE_NAMES[key], path, file: `${pagesDir}${PAGE_FILES[key]}.vue` })
}
for (const route of paged) pages.push({ name: route.name, path: route.path, file: `${pagesDir}${PAGE_FILES[route.key]}.vue` })
})
// The search page renders per ?q, so it is never route-cached: Nitro's
// cache keys by path, and an app rule such as '/**': { swr } would serve
// one query's results for every query. It is noindex, follow (the
// page's own meta sets that through vue-seo-layer; the robots rule and
// the sitemap exclude serve an app that adds nuxt-robots or
// @nuxtjs/sitemap itself). The locale-prefixed copies get the same
// rules. An app's own rule for the path wins key by key.
if (routes.search !== false) {
const i18n = (nuxt.options as { i18n?: { locales?: unknown } }).i18n
const paths = [routes.search, ...enabledLocaleCodes(i18n?.locales).map((code) => `/${code}${routes.search}`)]
const options = nuxt.options as { routeRules?: Record<string, Record<string, unknown>> }
const rules = (options.routeRules ??= {})
for (const path of paths) rules[path] = { cache: false, robots: 'noindex, follow', ...(rules[path] ?? {}) }
const opts = nuxt.options as { sitemap?: { exclude?: Array<string | RegExp> } }
opts.sitemap = { ...(opts.sitemap ?? {}), exclude: [...(opts.sitemap?.exclude ?? []), ...paths] }
}
// 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>
pub.journal = {
...(pub.journal ?? {}),
routes,
perPage: { ...options.perPage },
sort: options.sort,
relatedCount: options.relatedCount,
rss: options.rss,
postTitleTag: postTitleTag(options.postTitleTag),
breadcrumbs: options.breadcrumbs !== false,
}
},
})