diff --git a/messages/de.json b/messages/de.json index 9e1c94730..14c3eda42 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3824,7 +3824,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow - Guides, Blog, Workshops | evig", + "title": "Knowhow — Guides, Blog und Workshops", "description": "Lerne Linux, offene Software und den praktischen Umgang mit KI. Guides, Workshops, Blog und kuratierte Ressourcen." }, "hero": { diff --git a/messages/en.json b/messages/en.json index aace242c6..aa4adcc9d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3981,7 +3981,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow - Guides, Blog, Workshops | evig", + "title": "Knowhow — guides, blog and workshops", "description": "Learn Linux, open software and the practical use of AI. Guides, workshops, blog and curated resources." }, "hero": { diff --git a/messages/es.json b/messages/es.json index cb87f38f7..0384392cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2564,7 +2564,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow - Guías, Blog, Talleres | evig", + "title": "Conocimiento: guías, blog y talleres", "description": "Aprende Linux, software abierto y el uso práctico de la IA. Guías, talleres, blog y recursos seleccionados." }, "hero": { diff --git a/messages/fr.json b/messages/fr.json index 537246429..bcb5d87a0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3981,7 +3981,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow - Guides, Blog, Ateliers | evig", + "title": "Savoir-faire — guides, blog et ateliers", "description": "Apprenez Linux, les logiciels libres et l'usage concret de l'IA. Guides, ateliers, blog et ressources sélectionnées." }, "hero": { diff --git a/messages/it.json b/messages/it.json index 6d06e498e..468e4f75e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2564,7 +2564,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow - Guide, Blog, Workshop | evig", + "title": "Know-how — guide, blog e laboratori", "description": "Impara Linux, il software aperto e l'uso pratico dell'IA. Guide, laboratori, blog e risorse selezionate." }, "hero": { diff --git a/messages/ja.json b/messages/ja.json index cce8ca135..36dadddfe 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -2564,7 +2564,7 @@ }, "knowhow": { "meta": { - "title": "ノウハウ — ガイド、ブログ、ワークショップ | evig", + "title": "ナレッジ — ガイド、ブログ、ワークショップ", "description": "Linux、オープンなソフトウェア、AI の実践的な使い方を学ぶ。ガイド、ワークショップ、ブログ、厳選した資料。" }, "hero": { diff --git a/messages/ko.json b/messages/ko.json index aac492258..4ae5704e8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2564,7 +2564,7 @@ }, "knowhow": { "meta": { - "title": "노하우 - 가이드, 블로그, 워크숍 | evig", + "title": "노하우 — 가이드, 블로그, 워크숍", "description": "리눅스와 열린 소프트웨어, 그리고 AI의 실질적인 활용을 배우세요. 가이드, 워크숍, 블로그, 선별된 자료." }, "hero": { diff --git a/messages/ru.json b/messages/ru.json index dcd56cbe6..e4591d2f1 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -3827,7 +3827,7 @@ }, "knowhow": { "meta": { - "title": "Knowhow — гайды, блог, воркшопы | evig", + "title": "Знания — руководства, блог и мастерские", "description": "Осваивайте Linux, открытые программы и практическое применение ИИ. Руководства, мастерские, блог и отобранные материалы." }, "hero": { diff --git a/src/app/[locale]/abos/page.tsx b/src/app/[locale]/abos/page.tsx index d4ec75874..0e58097f9 100644 --- a/src/app/[locale]/abos/page.tsx +++ b/src/app/[locale]/abos/page.tsx @@ -3,8 +3,31 @@ // null during parallel static generation workers, causing hooks to throw. export const dynamic = 'force-dynamic' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' import AbosPageClient from './AbosPageClient' +/** + * This page had no metadata at all, so the browser tab fell through to the + * layout's default — "evig — Intelligenz, für alle bezahlbar. | evig", the + * homepage's own title, with the org name twice. A live product page was + * indistinguishable from the homepage in a tab, in search, and in a shared + * link. + */ +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'abos' }) + return { + title: t('title'), + description: t('subtitle'), + openGraph: { title: t('title'), description: t('subtitle'), type: 'website' }, + } +} + export default function AbosPage() { return } diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx index 2e8ce90cc..f6ddf5270 100644 --- a/src/app/[locale]/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -6,12 +6,16 @@ import { getTranslations } from 'next-intl/server' export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params const t = await getTranslations({ locale, namespace: 'about' }) - const title = `${t('meta.title')} - ${ORG.name}` + // The layout's title template already appends "| evig", so adding the org + // name here produced "Über uns - evig | evig" in the browser tab. The + // template owns the name; a page title is the descriptive half only. + // openGraph gets the full form, because a shared card has no template. + const title = t('meta.title') const description = t('meta.description') return { title, description, - openGraph: { title, description, type: 'website' }, + openGraph: { title: `${title} | ${ORG.name}`, description, type: 'website' }, } } diff --git a/src/app/[locale]/blog/layout.tsx b/src/app/[locale]/blog/layout.tsx index c5f12c623..5e5f73594 100644 --- a/src/app/[locale]/blog/layout.tsx +++ b/src/app/[locale]/blog/layout.tsx @@ -7,14 +7,15 @@ export async function generateMetadata({ params: Promise<{ locale: string }> }): Promise { const { locale } = await params + // No `title` here on purpose. Both this layout and the index page below it + // set one, and the tab ended up reading a bare "Blog" with no "| evig" — + // the only page on the site missing the suffix. The page owns its title; + // the layout contributes the description and the OG defaults. const t = await getTranslations({ locale, namespace: 'blog.meta' }) - const title = t('layoutTitle') const description = t('description') return { - title, description, openGraph: { - title, description, type: 'website', }, diff --git a/src/app/[locale]/services/layout.tsx b/src/app/[locale]/services/layout.tsx index 9b3e3a945..c2e2f0331 100644 --- a/src/app/[locale]/services/layout.tsx +++ b/src/app/[locale]/services/layout.tsx @@ -8,14 +8,23 @@ export async function generateMetadata({ params: Promise<{ locale: string }> }): Promise { const { locale } = await params + // No `title` here on purpose, and specifically no `title.absolute`. + // + // This layout used to set `{ absolute: `${layoutTitle} | evig` }`, which + // hand-rolled the suffix for the hub AND replaced the inherited + // `title.template` for the ENTIRE /services subtree. Every child page + // therefore shipped with no brand suffix at all — including + // /services/ai-robotics, which rendered a bare "KI und Robotik im Betrieb". + // Measured on the live site; no gate could see it, because `absolute` is a + // perfectly valid metadata field. + // + // Dropping it lets the root template apply to every page below, and the hub + // now sets its own title in page.tsx like any other page. const t = await getTranslations({ locale, namespace: 'services.meta' }) - const title = `${t('layoutTitle')} | ${ORG.name}` const description = t('description') return { - title: { absolute: title }, description, openGraph: { - title, description, type: 'website', url: `${ORG.website}/services`, diff --git a/src/app/[locale]/services/page.tsx b/src/app/[locale]/services/page.tsx index a10d092a2..0652d9dee 100644 --- a/src/app/[locale]/services/page.tsx +++ b/src/app/[locale]/services/page.tsx @@ -2,5 +2,27 @@ // due to React-null circular dep in SSR bundle during parallel static generation workers. export const dynamic = 'force-dynamic' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' import ServicesPageClient from './ServicesPageClient' + +/** + * The hub's title used to come from the layout's `title.absolute`, which also + * killed the brand suffix for every page under /services. The layout no longer + * sets a title, so the hub declares its own here — like every other page — and + * the root template appends "| evig" exactly once. + */ +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'services.meta' }) + return { + title: t('layoutTitle'), + description: t('description'), + } +} + export default ServicesPageClient diff --git a/src/app/[locale]/services/web-design-development/layout.tsx b/src/app/[locale]/services/web-design-development/layout.tsx deleted file mode 100644 index f5ebbf063..000000000 --- a/src/app/[locale]/services/web-design-development/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Metadata } from 'next' -import { ORG } from '@/config/org' - -export const metadata: Metadata = { - title: { absolute: `Web Design & Development | ${ORG.name}` }, - description: 'Professional web design and development services using open source technologies. Modern, responsive websites built with sustainability and performance in mind.', - openGraph: { - title: `Web Design & Development | ${ORG.name}`, - description: 'Professional web design and development services using open source technologies. Modern, responsive websites built with sustainability and performance in mind.', - type: 'website', - url: `${ORG.website}/services/web-design-development`, - }, -} - -export default function WebDesignDevelopmentLayout({ - children, -}: { - children: React.ReactNode -}) { - return <>{children} -} \ No newline at end of file diff --git a/src/config/__tests__/inventory-routes-match-services.test.ts b/src/config/__tests__/inventory-routes-match-services.test.ts new file mode 100644 index 000000000..52f7e33c2 --- /dev/null +++ b/src/config/__tests__/inventory-routes-match-services.test.ts @@ -0,0 +1,49 @@ +/** + * The E2E inventory smoke must not assert service pages that do not exist. + * + * `tests/e2e/helpers/inventory-routes.ts` hand-lists the `/services/*` paths + * it smoke-tests for a non-404. When #389 retired two services and deleted the + * `[service]/repair` route, that list kept asserting four dead paths — + * `/services/hardware-recycling` and three `/services//repair` pages. + * + * What made it expensive is WHERE that smoke runs: against the LIVE site, not + * the branch. So it stayed green until the deletion actually deployed, and + * then turned red on every subsequent PR, for a reason having nothing to do + * with the PR being reviewed. A dead assertion in a live-site smoke is a trap + * that springs on whoever comes next. + * + * This runs in jest, on the branch, before any of that: every `/services/...` + * path the smoke expects to be live must correspond to an available entry in + * SERVICE_CONFIGS. Redirect assertions (which carry a `urlPattern`) are + * exempt — a retired service keeps its URL alive on purpose. + */ + +import { PUBLIC_ROUTES } from '../../../tests/e2e/helpers/inventory-routes' +import { SERVICE_CONFIGS } from '@/app/[locale]/services/data' + +describe('E2E inventory service routes match SERVICE_CONFIGS', () => { + const liveServiceRoutes = PUBLIC_ROUTES.filter( + (r) => r.path.startsWith('/services/') && !r.urlPattern, + ) + + it('sweeps a non-empty set', () => { + // A filter that matches nothing would pass the assertion below trivially. + expect(liveServiceRoutes.length).toBeGreaterThan(1) + expect(SERVICE_CONFIGS.length).toBeGreaterThan(1) + }) + + it('every smoke-tested service page is an available service', () => { + const live = new Set(SERVICE_CONFIGS.filter((s) => s.available).map((s) => s.href)) + const orphans = liveServiceRoutes + .filter((r) => !live.has(r.path)) + .map((r) => `${r.path} is smoke-tested for a non-404 but is not an available service`) + expect(orphans).toEqual([]) + }) + + it('every available service page is smoke-tested', () => { + const smoked = new Set(liveServiceRoutes.map((r) => r.path)) + const missing = SERVICE_CONFIGS.filter((s) => s.available && !smoked.has(s.href)) + .map((s) => `${s.href} is an available service but nothing smoke-tests it`) + expect(missing).toEqual([]) + }) +}) diff --git a/src/config/__tests__/page-titles-not-doubled.test.ts b/src/config/__tests__/page-titles-not-doubled.test.ts new file mode 100644 index 000000000..dde2ec6af --- /dev/null +++ b/src/config/__tests__/page-titles-not-doubled.test.ts @@ -0,0 +1,55 @@ +/** + * A page title must not carry the org name — the layout template adds it. + * + * `src/app/[locale]/layout.tsx` sets `title.template = '%s | evig'`, so every + * page title gets " | evig" appended automatically. Three separate places + * added it a second time anyway, and each produced a real duplicate in the + * browser tab, in search results and in shared links: + * + * / "evig – evig — Intelligenz, für alle bezahlbar … | evig" + * (a page prefix AND a message value that opened with "evig —") + * /about "Über uns - evig | evig" (page appended `${ORG.name}`) + * /knowhow "Knowhow - … | evig | evig" (the MESSAGE VALUE ended "| evig") + * + * The homepage one was fixed, then /about and /knowhow turned up later by + * reading rendered titles. Three instances of one mistake is where the class + * gets closed rather than the instance. + * + * This catches the message-file half — a translator or a bulk edit + * reintroducing "| evig" into a title string. The component half (appending + * ORG.name in generateMetadata) is not statically checkable here; the note in + * each page explains why the template owns the name. + */ + +import de from '../../../messages/de.json' + +/** Any title-ish key: `meta.title`, `hero.title`, `layoutTitle`, … */ +const TITLE_KEY = /(^|\.)(meta\.)?(title|layoutTitle|metaTitle)$/i +/** "… | evig", "evig | …", "evig — …" at the start — the brand as decoration. */ +const CARRIES_BRAND = /\|\s*evig\b|\bevig\s*\||^\s*evig\s*[—–-]/i + +function walk(node: unknown, trail: string[] = []): Array<{ path: string; value: string }> { + if (typeof node === 'string') return [{ path: trail.join('.'), value: node }] + if (Array.isArray(node)) return node.flatMap((v, i) => walk(v, [...trail, String(i)])) + if (node && typeof node === 'object') { + return Object.entries(node).flatMap(([k, v]) => walk(v, [...trail, k])) + } + return [] +} + +describe('page titles do not repeat the org name', () => { + const all = walk(de) + const titles = all.filter((e) => TITLE_KEY.test(e.path)) + + it('sweeps a non-trivial number of titles', () => { + // A sweep that matched nothing would pass the assertion below trivially. + expect(titles.length).toBeGreaterThan(20) + }) + + it('no title string carries the brand — the layout template appends it', () => { + const offenders = titles + .filter((e) => CARRIES_BRAND.test(e.value)) + .map((e) => `${e.path} :: "${e.value}" — the layout already appends "| evig"`) + expect(offenders).toEqual([]) + }) +}) diff --git a/tests/e2e/helpers/inventory-routes.ts b/tests/e2e/helpers/inventory-routes.ts index cdd05650e..90772a746 100644 --- a/tests/e2e/helpers/inventory-routes.ts +++ b/tests/e2e/helpers/inventory-routes.ts @@ -5,11 +5,21 @@ import { ROUTES } from '@/config/routes' -/** Service slugs that expose `/services/[slug]/repair` booking pages. */ -const SERVICE_REPAIR_SLUGS = [ - 'computer-repair-upgrades', - 'data-recovery-transfer', - 'linux-open-source', +/** + * Public service pages, smoke-tested for a non-404. + * + * Kept in step with SERVICE_CONFIGS by + * `src/config/__tests__/inventory-routes-match-services.test.ts` — this list + * asserted `/services/hardware-recycling` and three `/services//repair` + * pages for weeks after they were deleted, and because this smoke runs + * against the LIVE site it only went red once the deletion deployed, turning + * every later PR red for a reason unrelated to that PR. + */ +const SERVICE_PAGE_PATHS = [ + '/services/ai-robotics', + '/services/web-design-development', + '/services/linux-open-source', + '/services/open-source-solutions', ] as const export interface InventoryRoute { @@ -63,14 +73,14 @@ export const PUBLIC_ROUTES: InventoryRoute[] = [ { id: 51, label: 'Workshop catalog', path: ROUTES.public.workshops }, { id: 56, label: 'Propose workshop', path: ROUTES.public.workshopsPropose }, { id: 68, label: 'Services landing', path: ROUTES.public.services }, - { id: 69, label: 'Service category', path: '/services/hardware-recycling' }, - ...SERVICE_REPAIR_SLUGS.map(slug => ({ - id: 70, - label: `Book repair (${slug})`, - path: `/services/${slug}/repair`, - urlPattern: /\/services\/[^/]+\/repair/, + ...SERVICE_PAGE_PATHS.map(path => ({ + id: 69, + label: `Service page (${path.split('/').pop()})`, + path, })), - { id: 71, label: 'Open-source solutions', path: '/services/open-source-solutions' }, + // Retired services keep their URLs alive as redirects into IT-Hilfe, so the + // old inbound links still land somewhere useful. + { id: 70, label: 'Retired service redirect', path: '/services/computer-repair-upgrades', urlPattern: /\/it-hilfe/ }, // Route #116 (the /projects/upcycling/* mini-site) was intentionally removed // with the /projects purge (commit 10c1b8adf); its config lingers but the pages // 404, so it is no longer smoke-tested here.