From 4064ecc53e131c66f9ed07da6b5bc3ca123d77b4 Mon Sep 17 00:00:00 2001 From: EagleFox31 <86088743+EagleFox31@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:03:30 +0000 Subject: [PATCH] feat(seo): list public author profiles in the pages sitemap Author profile pages added in #110 were linked from articles but absent from every sitemap. Each locale now advertises the authors who sign at least one renderable article, with the latest article date as lastmod. Post changes invalidate the pages sitemap, and the daily crawl smoke now checks the pages sitemap and one author profile per locale. --- scripts/seo/public-crawl-smoke.mjs | 17 ++++++ .../(sitemaps)/pages-sitemap.xml/route.ts | 36 ++++++++++- src/collections/Posts/hooks/revalidatePost.ts | 2 + src/seo/authorsSitemap.ts | 41 +++++++++++++ tests/unit/authorsSitemap.spec.ts | 61 +++++++++++++++++++ tests/unit/seoPublicCrawl.spec.ts | 2 + 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/seo/authorsSitemap.ts create mode 100644 tests/unit/authorsSitemap.spec.ts 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/') }) })