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
2 changes: 1 addition & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2564,7 +2564,7 @@
},
"knowhow": {
"meta": {
"title": "ノウハウ — ガイド、ブログ、ワークショップ | evig",
"title": "ナレッジ — ガイド、ブログ、ワークショップ",
"description": "Linux、オープンなソフトウェア、AI の実践的な使い方を学ぶ。ガイド、ワークショップ、ブログ、厳選した資料。"
},
"hero": {
Expand Down
2 changes: 1 addition & 1 deletion messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2564,7 +2564,7 @@
},
"knowhow": {
"meta": {
"title": "노하우 - 가이드, 블로그, 워크숍 | evig",
"title": "노하우 가이드, 블로그, 워크숍",
"description": "리눅스와 열린 소프트웨어, 그리고 AI의 실질적인 활용을 배우세요. 가이드, 워크숍, 블로그, 선별된 자료."
},
"hero": {
Expand Down
2 changes: 1 addition & 1 deletion messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -3827,7 +3827,7 @@
},
"knowhow": {
"meta": {
"title": "Knowhowгайды, блог, воркшопы | evig",
"title": "Знанияруководства, блог и мастерские",
"description": "Осваивайте Linux, открытые программы и практическое применение ИИ. Руководства, мастерские, блог и отобранные материалы."
},
"hero": {
Expand Down
23 changes: 23 additions & 0 deletions src/app/[locale]/abos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
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 <AbosPageClient />
}
8 changes: 6 additions & 2 deletions src/app/[locale]/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import { getTranslations } from 'next-intl/server'
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
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' },
}
}

Expand Down
7 changes: 4 additions & 3 deletions src/app/[locale]/blog/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ export async function generateMetadata({
params: Promise<{ locale: string }>
}): Promise<Metadata> {
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',
},
Expand Down
15 changes: 12 additions & 3 deletions src/app/[locale]/services/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,23 @@ export async function generateMetadata({
params: Promise<{ locale: string }>
}): Promise<Metadata> {
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`,
Expand Down
22 changes: 22 additions & 0 deletions src/app/[locale]/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'services.meta' })
return {
title: t('layoutTitle'),
description: t('description'),
}
}

export default ServicesPageClient
21 changes: 0 additions & 21 deletions src/app/[locale]/services/web-design-development/layout.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions src/config/__tests__/inventory-routes-match-services.test.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>/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([])
})
})
55 changes: 55 additions & 0 deletions src/config/__tests__/page-titles-not-doubled.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
34 changes: 22 additions & 12 deletions tests/e2e/helpers/inventory-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading