From 9c8a8448df27fd048550665b776e5ed348c6d749 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 11 Oct 2026 00:24:40 +0200 Subject: [PATCH] fix(sitemap): add lastmod and hreflang alternates where missing The blog, projects and community POI index pages and the blog author pages now carry lastmod: the newest date among the items they list, dated as each item's own sitemap entry is. Event translations are paired by translationKey, as the detail page pairs them, so events whose slugs differ get alternates. Team pages and events have no content date and keep no lastmod; nothing is derived from the build or request time. Team and author alternates already came from the module's sibling pairing and are unchanged. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- nuxt.config.ts | 4 +- server/api/__sitemap__/blog-authors.ts | 2 +- server/api/__sitemap__/index-pages.ts | 31 +++++++ shared/utils/blogAuthors.ts | 17 +++- shared/utils/eventRoutes.ts | 27 +++++- shared/utils/indexPageSitemap.ts | 36 ++++++++ shared/utils/sitemapDates.ts | 35 ++++++++ tests/architecture/module-boundaries.spec.ts | 3 + tests/shared/blogAuthors.spec.ts | 23 ++++- tests/shared/eventRoutes.spec.ts | 32 ++++++- tests/shared/indexPageSitemap.spec.ts | 90 ++++++++++++++++++++ 11 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 server/api/__sitemap__/index-pages.ts create mode 100644 shared/utils/indexPageSitemap.ts create mode 100644 shared/utils/sitemapDates.ts create mode 100644 tests/shared/indexPageSitemap.spec.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index ef8ca33b..8728bae8 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -165,10 +165,12 @@ export default defineNuxtConfig({ // Event detail pages are listed per request, because whether an // event is visible depends on the time — see the route's comment. // So are author pages: one exists only for a person with a released article. + // The index pages join them only to carry a lastmod the app's own entry lacks. sources: [ '/api/__sitemap__/team', '/api/__sitemap__/events', - '/api/__sitemap__/blog-authors' + '/api/__sitemap__/blog-authors', + '/api/__sitemap__/index-pages' ], // No changefreq/priority defaults: Google ignores both. lastmod comes // from real content dates only (content.config.ts), never the build. diff --git a/server/api/__sitemap__/blog-authors.ts b/server/api/__sitemap__/blog-authors.ts index 1fffa327..763a5d9c 100644 --- a/server/api/__sitemap__/blog-authors.ts +++ b/server/api/__sitemap__/blog-authors.ts @@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => { const byLocale: Record = {} for (const locale of locales) { const articles = (await queryCollection(event, `blog_${locale}` as 'blog_de' | 'blog_en') - .select('slug', 'author', 'pubDate', 'releaseDate') + .select('slug', 'author', 'pubDate', 'releaseDate', 'updatedDate') .all()) as BlogArticle[] const team = (await queryCollection(event, `team_${locale}` as 'team_de' | 'team_en') .all())[0] as TeamDocument | undefined diff --git a/server/api/__sitemap__/index-pages.ts b/server/api/__sitemap__/index-pages.ts new file mode 100644 index 00000000..3c4913ae --- /dev/null +++ b/server/api/__sitemap__/index-pages.ts @@ -0,0 +1,31 @@ +import { queryCollection } from '@nuxt/content/server' +// Type-only entry points, never the layer barrels — see team.ts next to it. +import type { BlogArticle } from '#layers/blog/types' +// Same path as blog-authors.ts: `#layers/content-core` cannot be value-imported here. +import { locales } from '~/layers/content-core/utils/content/locales' + +/** + * Sitemap source for the list pages that have a derivable lastmod: blog, + * projects and community POI. The app already lists them; this only adds + * their date. `indexPageSitemapEntries` from shared/utils holds the rule. + */ +export default defineEventHandler(async (event) => { + const byLocale: Record + pois: Array<{ slug?: string, updatedAt?: Date | string, startedAt?: Date | string }> + }> = {} + for (const locale of locales) { + const articles = (await queryCollection(event, `blog_${locale}` as 'blog_de' | 'blog_en') + .select('pubDate', 'releaseDate', 'updatedDate') + .all()) as BlogArticle[] + const projects = await queryCollection(event, `projects_${locale}` as 'projects_de' | 'projects_en') + .select('updatedAt') + .all() + const pois = await queryCollection(event, `community_poi_${locale}` as 'community_poi_de' | 'community_poi_en') + .select('updatedAt', 'startedAt') + .all() + byLocale[locale] = { articles, projects, pois } + } + return indexPageSitemapEntries(byLocale, new Date()) +}) diff --git a/shared/utils/blogAuthors.ts b/shared/utils/blogAuthors.ts index ab5c9788..2bd5c464 100644 --- a/shared/utils/blogAuthors.ts +++ b/shared/utils/blogAuthors.ts @@ -3,12 +3,14 @@ * page, the team profile and the Nitro sitemap source so they never disagree * about which articles exist for a visitor. */ +import { articleLastmod, newestDate } from './sitemapDates' export interface BlogAuthorFields { slug?: string author?: string | string[] releaseDate?: Date | string pubDate?: Date | string + updatedDate?: Date | string } /** Release time in ms, or null when the article has no usable date. */ @@ -61,15 +63,22 @@ export interface BlogAuthorSitemapSource { resolvable: readonly string[] } -/** Sitemap entries for exactly the author pages that answer 200 at `now`. */ +/** + * Sitemap entries for exactly the author pages that answer 200 at `now`. The + * page lists the author's released articles, so its lastmod is the newest of + * theirs; without a dated article it has none. + */ export function blogAuthorSitemapEntries( byLocale: Record, now: Date -): { loc: string }[] { - const entries: { loc: string }[] = [] +): { loc: string, lastmod?: Date }[] { + const entries: { loc: string, lastmod?: Date }[] = [] for (const [locale, { articles, resolvable }] of Object.entries(byLocale)) { for (const slug of releasedAuthorSlugs(articles, now)) { - if (resolvable.includes(slug)) entries.push({ loc: blogAuthorPath(locale, slug) }) + if (!resolvable.includes(slug)) continue + const loc = blogAuthorPath(locale, slug) + const lastmod = newestDate(articlesByAuthor(articles, slug, now).map(articleLastmod)) + entries.push(lastmod ? { loc, lastmod } : { loc }) } } return entries diff --git a/shared/utils/eventRoutes.ts b/shared/utils/eventRoutes.ts index 6d249f7a..63f0c5f8 100644 --- a/shared/utils/eventRoutes.ts +++ b/shared/utils/eventRoutes.ts @@ -15,24 +15,43 @@ export interface EventSitemapSource { slug: string event: EventScheduleFields unlisted?: boolean + translationKey?: string } +const regionOf: Record = { de: 'de-DE', en: 'en-US' } + /** * Sitemap entries for every listed event at `now`, per locale. Hidden events * are left out entirely, so an unannounced event is not discoverable through * the sitemap before its page would answer; unlisted events are left out * regardless of phase, same as the overview and the carousel. + * + * Translations are paired by `translationKey`, as the detail page pairs them, + * because the slugs of one event may differ between languages. Only listed + * translations become alternates. */ export function visibleEventSitemapEntries( eventsByLocale: Record, now: Date | number -): { loc: string }[] { - const entries: { loc: string }[] = [] +): { loc: string, alternatives?: { hreflang: string, href: string }[] }[] { + const listed: Array<{ locale: string, entry: EventSitemapSource }> = [] for (const [locale, events] of Object.entries(eventsByLocale)) { for (const entry of events) { if (!entry.slug || !isEventListedAt(entry.event, entry.unlisted, now)) continue - entries.push({ loc: eventDetailPath(locale, entry.slug) }) + listed.push({ locale, entry }) } } - return entries + return listed.map(({ locale, entry }) => { + const translations = entry.translationKey + ? listed.filter((other) => other.entry.translationKey === entry.translationKey) + : [] + if (translations.length < 2) return { loc: eventDetailPath(locale, entry.slug) } + return { + loc: eventDetailPath(locale, entry.slug), + alternatives: translations.map((other) => ({ + hreflang: regionOf[other.locale] ?? other.locale, + href: eventDetailPath(other.locale, other.entry.slug), + })), + } + }) } diff --git a/shared/utils/indexPageSitemap.ts b/shared/utils/indexPageSitemap.ts new file mode 100644 index 00000000..c8b4dc41 --- /dev/null +++ b/shared/utils/indexPageSitemap.ts @@ -0,0 +1,36 @@ +import { isReleasedAt, type BlogAuthorFields } from './blogAuthors' +import { articleLastmod, newestDate } from './sitemapDates' + +/** + * lastmod for the list pages (blog, projects, community POI): the newest date + * among the items they list, each dated as that item's own sitemap entry is. + * The item rules mirror `onUrl` in content.config.ts. + */ + +export interface IndexPageItems { + articles: BlogAuthorFields[] + projects: Array<{ slug?: string, updatedAt?: Date | string }> + pois: Array<{ slug?: string, updatedAt?: Date | string, startedAt?: Date | string }> +} + +/** + * One entry per index page that has a derivable date. Pages without one are + * left out, so the app's own entry for them stands. + */ +export function indexPageSitemapEntries( + byLocale: Record, + now: Date +): { loc: string, lastmod: Date }[] { + const entries: { loc: string, lastmod: Date }[] = [] + for (const [locale, items] of Object.entries(byLocale)) { + const dated = [ + { path: 'blog', lastmod: newestDate(items.articles.filter((article) => isReleasedAt(article, now)).map(articleLastmod)) }, + { path: 'projects', lastmod: newestDate(items.projects.map((project) => project.updatedAt)) }, + { path: 'community-poi', lastmod: newestDate(items.pois.map((poi) => poi.updatedAt ?? poi.startedAt)) }, + ] + for (const { path, lastmod } of dated) { + if (lastmod) entries.push({ loc: `/${locale}/${path}`, lastmod }) + } + } + return entries +} diff --git a/shared/utils/sitemapDates.ts b/shared/utils/sitemapDates.ts new file mode 100644 index 00000000..88c847c1 --- /dev/null +++ b/shared/utils/sitemapDates.ts @@ -0,0 +1,35 @@ +/** + * Dates for sitemap lastmod. Only real content dates reach these helpers: a + * page without one gets no lastmod, never the build or request time. + */ + +export type DateInput = Date | string | null | undefined + +/** A valid date from a Date or a date string, or undefined. */ +export function toValidDate(raw: DateInput): Date | undefined { + if (!raw) return undefined + const time = new Date(raw).getTime() + return Number.isNaN(time) ? undefined : new Date(time) +} + +/** The latest of the given dates, or undefined when none is usable. */ +export function newestDate(dates: readonly DateInput[]): Date | undefined { + let newest: Date | undefined + for (const raw of dates) { + const date = toValidDate(raw) + if (date && (!newest || date.getTime() > newest.getTime())) newest = date + } + return newest +} + +/** + * A blog article's lastmod, the date its own sitemap entry carries (`onUrl` + * in content.config.ts; that body cannot import this file). + */ +export function articleLastmod(article: { + updatedDate?: DateInput + releaseDate?: DateInput + pubDate?: DateInput +}): Date | undefined { + return toValidDate(article.updatedDate ?? article.releaseDate ?? article.pubDate) +} diff --git a/tests/architecture/module-boundaries.spec.ts b/tests/architecture/module-boundaries.spec.ts index 78809ba6..7faccd1b 100644 --- a/tests/architecture/module-boundaries.spec.ts +++ b/tests/architecture/module-boundaries.spec.ts @@ -52,6 +52,9 @@ const ALLOWED_DEEP_IMPORTS: Record = { 'server/api/__sitemap__/blog-authors.ts': 'Same Nitro limitation as the team route above: ' + 'it needs `locales` at runtime and cannot take it through #layers/content-core. ' + 'The release and author rules come from shared/utils, not from a layer.', + 'server/api/__sitemap__/index-pages.ts': 'Same Nitro limitation as the team route above: ' + + 'it needs `locales` at runtime and cannot take it through #layers/content-core. ' + + 'The index dating rule comes from shared/utils, not from a layer.', } /** Layers that are domains: everything that is not foundation. */ diff --git a/tests/shared/blogAuthors.spec.ts b/tests/shared/blogAuthors.spec.ts index 5ef999a1..d4bca951 100644 --- a/tests/shared/blogAuthors.spec.ts +++ b/tests/shared/blogAuthors.spec.ts @@ -127,7 +127,28 @@ describe('blogAuthorSitemapEntries', () => { it('lists one entry per author with a released article', () => { const entries = blogAuthorSitemapEntries({ de }, NOW) - expect(entries).toEqual([{ loc: '/de/blog/author/tp' }, { loc: '/de/blog/author/gast' }]) + expect(entries.map((entry) => entry.loc)).toEqual(['/de/blog/author/tp', '/de/blog/author/gast']) + }) + + it('dates an author page by the newest modified date of its released articles', () => { + const [entry] = blogAuthorSitemapEntries({ + de: { + articles: [ + post('a', { author: 'tp', pubDate: '2025-01-01T00:00:00Z', updatedDate: '2026-02-01T00:00:00Z' }), + post('b', { author: 'tp', pubDate: '2026-03-01T00:00:00Z' }), + post('c', { author: 'tp', pubDate: '2026-09-01T00:00:00Z', releaseDate: '2026-09-01T00:00:00Z' }), + ], + resolvable: ['tp'], + }, + }, NOW) + expect(entry?.lastmod).toEqual(new Date('2026-03-01T00:00:00Z')) + }) + + it('leaves lastmod out when no released article of the author has a date', () => { + const [entry] = blogAuthorSitemapEntries({ + de: { articles: [{ slug: 'a', author: 'tp' }], resolvable: ['tp'] }, + }, NOW) + expect(entry).not.toHaveProperty('lastmod') }) it('leaves out an author that resolves to nobody', () => { diff --git a/tests/shared/eventRoutes.spec.ts b/tests/shared/eventRoutes.spec.ts index 72e18a19..7bdc6bbd 100644 --- a/tests/shared/eventRoutes.spec.ts +++ b/tests/shared/eventRoutes.spec.ts @@ -4,7 +4,9 @@ import { eventDetailPath, visibleEventSitemapEntries } from '../../shared/utils/ const now = new Date('2026-10-05T12:00:00+02:00') type Schedule = { announceAt?: string, startsAt: string, endsAt?: string } -const fixture = (slug: string, event: Schedule, unlisted?: boolean) => ({ slug, event, unlisted }) +const fixture = (slug: string, event: Schedule, unlisted?: boolean, translationKey?: string) => ({ + slug, event, unlisted, translationKey, +}) describe('visibleEventSitemapEntries', () => { it('lists announced, running and past events and nothing hidden', () => { @@ -38,6 +40,34 @@ describe('visibleEventSitemapEntries', () => { }, now) expect(entries).toEqual([{ loc: '/de/events/laeuft' }]) }) + + it('pairs translations by translationKey even when the slugs differ', () => { + const running = { startsAt: '2026-10-01T18:00:00+02:00' } + const [de] = visibleEventSitemapEntries({ + de: [fixture('herbst-bauevent', running, false, 'bau-2026')], + en: [fixture('autumn-build', running, false, 'bau-2026')], + }, now) + const own = { hreflang: 'de-DE', href: '/de/events/herbst-bauevent' } + expect(de?.alternatives).toEqual([own, { hreflang: 'en-US', href: '/en/events/autumn-build' }]) + }) + + it('leaves a translation out of the alternates when it is not listed', () => { + const running = { startsAt: '2026-10-01T18:00:00+02:00' } + const [de] = visibleEventSitemapEntries({ + de: [fixture('herbst-bauevent', running, false, 'bau-2026')], + en: [fixture('autumn-build', running, true, 'bau-2026')], + }, now) + expect(de).toEqual({ loc: '/de/events/herbst-bauevent' }) + }) + + it('adds no alternates to an event without a translationKey', () => { + const running = { startsAt: '2026-10-01T18:00:00+02:00' } + const [de] = visibleEventSitemapEntries({ + de: [fixture('bau', running)], + en: [fixture('bau', running)], + }, now) + expect(de).not.toHaveProperty('alternatives') + }) }) describe('eventDetailPath', () => { diff --git a/tests/shared/indexPageSitemap.spec.ts b/tests/shared/indexPageSitemap.spec.ts new file mode 100644 index 00000000..14fad4bd --- /dev/null +++ b/tests/shared/indexPageSitemap.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { indexPageSitemapEntries } from '../../shared/utils/indexPageSitemap' +import { newestDate } from '../../shared/utils/sitemapDates' + +const NOW = new Date('2026-06-01T12:00:00Z') +const at = (iso: string) => new Date(iso) + +describe('newestDate', () => { + it('returns the latest of several dates', () => { + const dates = [ + '2025-01-01T00:00:00Z', + at('2026-03-01T00:00:00Z'), + undefined, + ] + expect(newestDate(dates)).toEqual(at('2026-03-01T00:00:00Z')) + }) + + it('is undefined when no date is usable', () => { + const dates = [ + undefined, + null, + 'soon', + ] + expect(newestDate(dates)).toBeUndefined() + }) +}) + +describe('indexPageSitemapEntries', () => { + it('gives the blog index the newest modified date of its released articles', () => { + const older = { slug: 'alt', pubDate: '2025-01-01T00:00:00Z', updatedDate: '2026-02-01T00:00:00Z' } + const newer = { slug: 'neu', pubDate: '2026-03-01T00:00:00Z' } + const entries = indexPageSitemapEntries({ + de: { articles: [older, newer], projects: [], pois: [] }, + }, NOW) + expect(entries).toEqual([{ loc: '/de/blog', lastmod: at('2026-03-01T00:00:00Z') }]) + }) + + it('ignores an article that is not released yet when dating the blog index', () => { + const entries = indexPageSitemapEntries({ + de: { + articles: [ + { slug: 'bald', pubDate: '2026-01-01T00:00:00Z', releaseDate: '2026-12-01T00:00:00Z' }, + ], + projects: [], + pois: [], + }, + }, NOW) + expect(entries.map((entry) => entry.loc)).not.toContain('/de/blog') + }) + + it('gives the projects index the newest updatedAt of its projects', () => { + const entries = indexPageSitemapEntries({ + en: { + articles: [], + projects: [ + { slug: 'a', updatedAt: '2026-01-10T00:00:00Z' }, + { slug: 'b', updatedAt: '2026-04-02T00:00:00Z' }, + { slug: 'c' }, + ], + pois: [], + }, + }, NOW) + expect(entries).toEqual([{ loc: '/en/projects', lastmod: at('2026-04-02T00:00:00Z') }]) + }) + + it('dates a community POI by updatedAt, falling back to startedAt', () => { + const startedOnly = { slug: 'a', startedAt: '2026-05-01T00:00:00Z' } + const edited = { slug: 'b', updatedAt: '2026-02-01T00:00:00Z', startedAt: '2025-01-01T00:00:00Z' } + const entries = indexPageSitemapEntries({ + de: { articles: [], projects: [], pois: [startedOnly, edited] }, + }, NOW) + expect(entries).toEqual([{ loc: '/de/community-poi', lastmod: at('2026-05-01T00:00:00Z') }]) + }) + + it('omits an index whose items carry no date', () => { + expect(indexPageSitemapEntries({ + de: { articles: [], projects: [{ slug: 'a' }], pois: [{ slug: 'b' }] }, + }, NOW)).toEqual([]) + }) + + it('dates each locale from its own items', () => { + const entries = indexPageSitemapEntries({ + de: { articles: [{ slug: 'a', pubDate: '2026-01-01T00:00:00Z' }], projects: [], pois: [] }, + en: { articles: [{ slug: 'b', pubDate: '2026-02-01T00:00:00Z' }], projects: [], pois: [] }, + }, NOW) + const [de, en] = entries + expect(de).toEqual({ loc: '/de/blog', lastmod: at('2026-01-01T00:00:00Z') }) + expect(en).toEqual({ loc: '/en/blog', lastmod: at('2026-02-01T00:00:00Z') }) + }) +})