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') }) + }) +})