diff --git a/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts index 61fd4f5..0b1cb85 100644 --- a/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts +++ b/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -3,19 +3,16 @@ import { getPayload } from 'payload' import config from '@payload-config' import { unstable_cache } from 'next/cache' +import { buildLocalizedPostSitemapEntries } from '@/seo/postsSitemap' + +// Hooks normally invalidate this tag after an editorial change. A bounded TTL also +// recovers if a bulk import bypasses Payload hooks or an invalidation is lost. const getPostsSitemap = unstable_cache( async () => { const payload = await getPayload({ config }) - const SITE_URL = - process.env.NEXT_PUBLIC_SERVER_URL || - process.env.VERCEL_PROJECT_PRODUCTION_URL || - 'https://example.com' - - const dateFallback = new Date().toISOString() - const locales = ['fr', 'en'] as const const sitemap = [] - for (const locale of locales) { + for (const locale of ['fr', 'en'] as const) { const results = await payload.find({ collection: 'posts', overrideAccess: false, @@ -33,30 +30,27 @@ const getPostsSitemap = unstable_cache( select: { title: true, slug: true, + excerpt: true, + content: true, updatedAt: true, }, }) - for (const post of results.docs) { - if (!post?.slug || !post?.title) continue - - sitemap.push({ - loc: `${SITE_URL}/${locale}/posts/${post.slug}`, - lastmod: post.updatedAt || dateFallback, - }) - } + // The public article route returns 404 if any of these localized fields + // are missing. Do not advertise such URLs to search engines. + sitemap.push(...buildLocalizedPostSitemapEntries(locale, results.docs)) } return sitemap }, - ['posts-sitemap-v2'], + ['posts-sitemap-v3'], { tags: ['posts-sitemap'], + revalidate: 3600, }, ) export async function GET() { const sitemap = await getPostsSitemap() - return getServerSideSitemap(sitemap) } diff --git a/src/collections/Posts/hooks/revalidatePost.ts b/src/collections/Posts/hooks/revalidatePost.ts index c826500..fb2d3bd 100644 --- a/src/collections/Posts/hooks/revalidatePost.ts +++ b/src/collections/Posts/hooks/revalidatePost.ts @@ -15,6 +15,17 @@ function revalidateEditorialSitemaps() { revalidateTag('news-sitemap', 'max') } +// These are the public URLs, not just the legacy /posts route. Keep landing +// pages and article archives fresh when published content changes. +function revalidatePublicPostPaths(slug: string) { + revalidatePath(`/posts/${slug}`) + for (const locale of ['fr', 'en'] as const) { + revalidatePath(`/${locale}/posts/${slug}`) + revalidatePath(`/${locale}/posts`) + revalidatePath(`/${locale}`) + } +} + async function getLocalizedPostUrls({ payload, id, @@ -70,22 +81,20 @@ export const revalidatePost: CollectionAfterChangeHook = async ({ req: { payload, context }, }) => { if (!context.disableRevalidate) { - if (doc._status === 'published') { - const path = `/posts/${doc.slug}` - - payload.logger.info(`Revalidating post at path: ${path}`) - - revalidatePath(path) - revalidateEditorialSitemaps() - } + const wasPublished = previousDoc._status === 'published' + const isPublished = doc._status === 'published' - // If the post was previously published, we need to revalidate the old path - if (previousDoc._status === 'published' && doc._status !== 'published') { - const oldPath = `/posts/${previousDoc.slug}` + if (isPublished || wasPublished) { + if (isPublished && doc.slug) { + payload.logger.info(`Revalidating published post: ${doc.slug}`) + revalidatePublicPostPaths(doc.slug) + } - payload.logger.info(`Revalidating old post at path: ${oldPath}`) + // Also invalidate the old canonical URLs after a rename or unpublish. + if (wasPublished && previousDoc.slug && (!isPublished || previousDoc.slug !== doc.slug)) { + revalidatePublicPostPaths(previousDoc.slug) + } - revalidatePath(oldPath) revalidateEditorialSitemaps() } } @@ -134,9 +143,7 @@ export const revalidateDelete: CollectionAfterDeleteHook = async ({ req: { payload, context }, }) => { if (!context.disableRevalidate) { - const path = `/posts/${doc?.slug}` - - revalidatePath(path) + if (doc?.slug) revalidatePublicPostPaths(doc.slug) revalidateEditorialSitemaps() } diff --git a/src/seo/postsSitemap.ts b/src/seo/postsSitemap.ts new file mode 100644 index 0000000..869475d --- /dev/null +++ b/src/seo/postsSitemap.ts @@ -0,0 +1,34 @@ +import { absoluteCanonicalURL } from '@/seo/structuredData' +import type { SiteLocale } from '@/i18n/config' + +export type PublicSitemapPost = { + title?: string | null + slug?: string | null + excerpt?: string | null + content?: unknown + updatedAt?: string | null +} + +/** + * Build canonical entries only for articles that the localized public route can + * actually render (title, excerpt and rich-text content in that locale). + */ +export function buildLocalizedPostSitemapEntries( + locale: SiteLocale, + posts: PublicSitemapPost[], + fallbackDate = new Date().toISOString(), +) { + return posts + .filter( + (post) => + Boolean(post.title?.trim()) && + Boolean(post.slug?.trim()) && + Boolean(post.excerpt?.trim()) && + post.content !== null && + post.content !== undefined, + ) + .map((post) => ({ + loc: absoluteCanonicalURL(`/${locale}/posts/${post.slug}`), + lastmod: post.updatedAt || fallbackDate, + })) +} diff --git a/tests/unit/postsSitemap.spec.ts b/tests/unit/postsSitemap.spec.ts new file mode 100644 index 0000000..5227af4 --- /dev/null +++ b/tests/unit/postsSitemap.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { buildLocalizedPostSitemapEntries } from '@/seo/postsSitemap' + +describe('localized public article sitemap', () => { + const now = '2026-10-11T18:00:00.000Z' + + it('uses the canonical domain, even when deployment environment points to a preview', () => { + expect( + buildLocalizedPostSitemapEntries( + 'en', + [ + { + slug: 'example', + title: 'Example', + excerpt: 'Summary', + content: { root: { children: [] } }, + updatedAt: now, + }, + ], + now, + ), + ).toEqual([ + { + loc: 'https://insight.trigenys.com/en/posts/example', + lastmod: now, + }, + ]) + }) + + it('never advertises untranslated or incomplete article routes that return 404', () => { + const result = buildLocalizedPostSitemapEntries( + 'en', + [ + { slug: 'fr-only', title: null, excerpt: null, content: null }, + { slug: 'missing-excerpt', title: 'Title', excerpt: '', content: {} }, + { slug: 'missing-content', title: 'Title', excerpt: 'Summary', content: null }, + { slug: null, title: 'Title', excerpt: 'Summary', content: {} }, + { slug: 'complete', title: 'Title', excerpt: 'Summary', content: {}, updatedAt: now }, + ], + now, + ) + + expect(result).toEqual([ + { + loc: 'https://insight.trigenys.com/en/posts/complete', + lastmod: now, + }, + ]) + }) + + it('provides a stable ISO fallback when updatedAt is missing', () => { + expect( + buildLocalizedPostSitemapEntries( + 'fr', + [{ slug: 'test', title: 'Titre', excerpt: 'Résumé', content: {} }], + now, + )[0].lastmod, + ).toBe(now) + }) +})