Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions scripts/seo/public-crawl-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<urlset'), 'pages sitemap returns a URL set')
const pageURLs = sitemapLocs(pagesXML)
assert(
pageURLs.every((url) => 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)
Expand All @@ -83,6 +91,15 @@ async function main() {
assert(/<h1\b/i.test(visibleArticle), `${locale} article heading is in the HTML response`)
assert(!/class="site-skeleton(?:\s|")/i.test(visibleArticle), `${locale} crawler does not receive global placeholder markup`)
assert(/rel="canonical"/.test(articleHTML) && articleHTML.includes(article), `${locale} article carries its canonical URL`)

const author = pageURLs.find((url) => 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(/<h1\b/i.test(visibleAuthor), `${locale} author profile heading is in the HTML response`)
assert(/rel="canonical"/.test(authorHTML) && authorHTML.includes(author), `${locale} author profile carries its canonical URL`)
}
}

Expand Down
36 changes: 34 additions & 2 deletions src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { unstable_cache } from 'next/cache'

import { SITE_IDENTITY } from '@/seo/structuredData'
import { editorialRubrics, localizedRubricPath } from '@/editorial/rubrics'
import { buildLocalizedAuthorSitemapEntries } from '@/seo/authorsSitemap'

const localizedEditorialPaths = [
'',
Expand Down Expand Up @@ -43,6 +44,37 @@ const getPagesSitemap = unstable_cache(
const dateFallback = new Date().toISOString()
const locales = ['fr', 'en'] as const

// Author profiles exist only through the posts they sign, per locale.
const authorPages = []
for (const locale of locales) {
const posts = await payload.find({
collection: 'posts',
overrideAccess: false,
draft: false,
depth: 0,
fallbackLocale: false,
locale,
limit: 1000,
pagination: false,
where: {
_status: {
equals: 'published',
},
},
select: {
title: true,
slug: true,
excerpt: true,
content: true,
updatedAt: true,
authors: true,
populatedAuthors: true,
},
})

authorPages.push(...buildLocalizedAuthorSitemapEntries(locale, posts.docs, dateFallback))
}

const localizedEditorialPages = locales.flatMap((locale) => [
...localizedEditorialPaths.map((path) => ({
loc: `${SITE_URL}/${locale}${path}`,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/collections/Posts/hooks/revalidatePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
41 changes: 41 additions & 0 deletions src/seo/authorsSitemap.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()

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,
}))
}
61 changes: 61 additions & 0 deletions tests/unit/authorsSitemap.spec.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
2 changes: 2 additions & 0 deletions tests/unit/seoPublicCrawl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
})
})

Expand Down
Loading