Skip to content
Merged
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
4 changes: 3 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion server/api/__sitemap__/blog-authors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
const byLocale: Record<string, { articles: BlogArticle[], resolvable: string[] }> = {}
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
Expand Down
31 changes: 31 additions & 0 deletions server/api/__sitemap__/index-pages.ts
Original file line number Diff line number Diff line change
@@ -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<string, {
articles: BlogArticle[]
projects: Array<{ slug?: string, updatedAt?: Date | string }>
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())
})
17 changes: 13 additions & 4 deletions shared/utils/blogAuthors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, BlogAuthorSitemapSource>,
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
Expand Down
27 changes: 23 additions & 4 deletions shared/utils/eventRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,43 @@ export interface EventSitemapSource {
slug: string
event: EventScheduleFields
unlisted?: boolean
translationKey?: string
}

const regionOf: Record<string, string> = { 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<string, EventSitemapSource[]>,
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),
})),
}
})
}
36 changes: 36 additions & 0 deletions shared/utils/indexPageSitemap.ts
Original file line number Diff line number Diff line change
@@ -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<string, IndexPageItems>,
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
}
35 changes: 35 additions & 0 deletions shared/utils/sitemapDates.ts
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions tests/architecture/module-boundaries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ const ALLOWED_DEEP_IMPORTS: Record<string, string> = {
'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. */
Expand Down
23 changes: 22 additions & 1 deletion tests/shared/blogAuthors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
32 changes: 31 additions & 1 deletion tests/shared/eventRoutes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
90 changes: 90 additions & 0 deletions tests/shared/indexPageSitemap.spec.ts
Original file line number Diff line number Diff line change
@@ -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') })
})
})
Loading