diff --git a/scripts/seo/public-crawl-smoke.mjs b/scripts/seo/public-crawl-smoke.mjs index 88b07d6..aa5a43c 100644 --- a/scripts/seo/public-crawl-smoke.mjs +++ b/scripts/seo/public-crawl-smoke.mjs @@ -64,6 +64,14 @@ async function main() { 'News sitemap URLs point to the canonical host', ) + const { text: pagesXML } = await request('/pages-sitemap.xml') + assert(pagesXML.includes(' new URL(url).host === canonicalHost), + 'pages sitemap URLs point to the canonical host', + ) + for (const locale of ['fr', 'en']) { const { text: homeHTML } = await request(`/${locale}`) const visibleHome = documentWithoutScripts(homeHTML) @@ -83,6 +91,15 @@ async function main() { assert(/ new URL(url).pathname.startsWith(`/${locale}/authors/`)) + assert(Boolean(author), `${locale} pages sitemap lists at least one author profile`) + if (!author) continue + + const { text: authorHTML } = await request(new URL(author).pathname) + const visibleAuthor = documentWithoutScripts(authorHTML) + assert(/ [ ...localizedEditorialPaths.map((path) => ({ loc: `${SITE_URL}/${locale}${path}`, @@ -64,9 +96,9 @@ const getPagesSitemap = unstable_cache( })) : [] - return [...localizedEditorialPages, ...cmsPages] + return [...localizedEditorialPages, ...authorPages, ...cmsPages] }, - ['pages-sitemap-v4'], + ['pages-sitemap-v5'], { tags: ['pages-sitemap'], revalidate: 86400, diff --git a/src/collections/Posts/hooks/revalidatePost.ts b/src/collections/Posts/hooks/revalidatePost.ts index bce2c43..d62ed34 100644 --- a/src/collections/Posts/hooks/revalidatePost.ts +++ b/src/collections/Posts/hooks/revalidatePost.ts @@ -14,6 +14,8 @@ import { LLMS_CACHE_TAG } from '@/seo/llms' function revalidateEditorialSitemaps() { revalidateTag('posts-sitemap', 'max') revalidateTag('news-sitemap', 'max') + // Author profile URLs in the pages sitemap are derived from published posts. + revalidateTag('pages-sitemap', 'max') revalidateTag(LLMS_CACHE_TAG, 'max') } diff --git a/src/seo/authorsSitemap.ts b/src/seo/authorsSitemap.ts new file mode 100644 index 0000000..c18f565 --- /dev/null +++ b/src/seo/authorsSitemap.ts @@ -0,0 +1,41 @@ +import { absoluteCanonicalURL } from '@/seo/structuredData' +import { isPublicLocalizedPost, type PublicSitemapPost } from '@/seo/postsSitemap' +import type { SiteLocale } from '@/i18n/config' +import { authorSlug } from '@/utilities/authorSlug' + +export type PublicSitemapAuthorPost = PublicSitemapPost & { + populatedAuthors?: Array<{ name?: string | null } | null> | null +} + +/** + * One canonical entry per public author profile. An author is listed only when + * they sign at least one article the localized route can render, which is also + * the condition for the profile page itself to return 200. + */ +export function buildLocalizedAuthorSitemapEntries( + locale: SiteLocale, + posts: PublicSitemapAuthorPost[], + fallbackDate = new Date().toISOString(), +) { + const lastmodBySlug = new Map() + + for (const post of posts) { + if (!isPublicLocalizedPost(post)) continue + const updatedAt = post.updatedAt || fallbackDate + + for (const author of post.populatedAuthors || []) { + const slug = author?.name ? authorSlug(author.name) : '' + if (!slug) continue + + const current = lastmodBySlug.get(slug) + if (!current || updatedAt > current) lastmodBySlug.set(slug, updatedAt) + } + } + + return [...lastmodBySlug.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([slug, lastmod]) => ({ + loc: absoluteCanonicalURL(`/${locale}/authors/${slug}`), + lastmod, + })) +} diff --git a/tests/unit/authorsSitemap.spec.ts b/tests/unit/authorsSitemap.spec.ts new file mode 100644 index 0000000..2caef5d --- /dev/null +++ b/tests/unit/authorsSitemap.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { buildLocalizedAuthorSitemapEntries } from '@/seo/authorsSitemap' + +const content = { root: { children: [] } } + +describe('localized author profile sitemap', () => { + it('lists each author once with the canonical URL and latest article date', () => { + expect( + buildLocalizedAuthorSitemapEntries('fr', [ + { + slug: 'older', + title: 'Older', + excerpt: 'Summary', + content, + updatedAt: '2026-10-01T10:00:00.000Z', + populatedAuthors: [{ name: 'Éloïse Ngono' }, { name: 'Team Trigenys' }], + }, + { + slug: 'newer', + title: 'Newer', + excerpt: 'Summary', + content, + updatedAt: '2026-10-11T10:00:00.000Z', + populatedAuthors: [{ name: 'Eloise Ngono' }], + }, + ]), + ).toEqual([ + { + loc: 'https://insight.trigenys.com/fr/authors/eloise-ngono', + lastmod: '2026-10-11T10:00:00.000Z', + }, + { + loc: 'https://insight.trigenys.com/fr/authors/team-trigenys', + lastmod: '2026-10-01T10:00:00.000Z', + }, + ]) + }) + + it('skips authors whose only articles cannot render in that locale', () => { + expect( + buildLocalizedAuthorSitemapEntries('en', [ + { + slug: 'untranslated', + title: '', + excerpt: null, + content: null, + updatedAt: '2026-10-11T10:00:00.000Z', + populatedAuthors: [{ name: 'French Only' }], + }, + { + slug: 'anonymous', + title: 'Anonymous', + excerpt: 'Summary', + content, + populatedAuthors: [null, { name: ' ' }], + }, + ]), + ).toEqual([]) + }) +}) diff --git a/tests/unit/seoPublicCrawl.spec.ts b/tests/unit/seoPublicCrawl.spec.ts index da010f0..cc05978 100644 --- a/tests/unit/seoPublicCrawl.spec.ts +++ b/tests/unit/seoPublicCrawl.spec.ts @@ -31,6 +31,8 @@ describe('P0: public crawl entry points', () => { expect(script).toContain('posts-sitemap.xml') expect(script).toContain('article-reading-main') expect(script).toContain('rel="canonical"') + expect(script).toContain('pages-sitemap.xml') + expect(script).toContain('/authors/') }) })