feat: German as the main language, English under /en, with a language switch
The site now exists in German (at the root, with /impressum and /datenschutz) and English (/en). A request without a language follows the visitor's choice from the language switch, then the browser language, then German; the old English legal addresses move to /en for good. All visible texts are data in src/content/de.ts and en.ts, so components hold none of their own. The header carries a DE/EN switch whose bit curtain covers the page while it is swapped and reopens at the same reading position. Title, description, canonical, hreflang, Open Graph, the share image, the sitemap and the structured data are per language. The privacy notice now describes the cookie that remembers the chosen language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import * as React from "react";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
/**
|
||||
* PipelineRun: what happens between a change and its release, shown as the test run it is.
|
||||
@@ -24,56 +26,37 @@ const SUITES: readonly Suite[] = [
|
||||
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
|
||||
];
|
||||
|
||||
/** A change and the test it breaks elsewhere. The texts are in en.ts and de.ts, by id. */
|
||||
type Scenario = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly branch: string;
|
||||
readonly failingFile: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: readonly Scenario[] = [
|
||||
{
|
||||
id: "orders",
|
||||
label: "Orders",
|
||||
branch: "change/order-discounts",
|
||||
failingFile: "invoices/total.spec.ts",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Login",
|
||||
branch: "change/login-with-username",
|
||||
failingFile: "emails/confirmation.spec.ts",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
label: "Invoices",
|
||||
branch: "change/invoice-layout",
|
||||
failingFile: "emails/invoice-mail.spec.ts",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
{
|
||||
id: "emails",
|
||||
label: "Emails",
|
||||
branch: "change/email-queue",
|
||||
failingFile: "orders/status.spec.ts",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
];
|
||||
|
||||
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
|
||||
const STAGE_COUNT = 4;
|
||||
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
|
||||
|
||||
/**
|
||||
@@ -129,6 +112,7 @@ const STAGE_MARK: Readonly<Record<StageState, string>> = {
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const PipelineRun: React.FC = () => {
|
||||
const copy = useContent().technology.pipeline;
|
||||
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
|
||||
const [frameIndex, setFrameIndex] = React.useState(0);
|
||||
const [run, setRun] = React.useState(0);
|
||||
@@ -170,7 +154,8 @@ export const PipelineRun: React.FC = () => {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [inView, scenarioId, run]);
|
||||
|
||||
if (!scenario) return null;
|
||||
const scenarioCopy = scenario ? copy.scenarios[scenario.id] : undefined;
|
||||
if (!scenario || !scenarioCopy) return null;
|
||||
|
||||
const frame = FRAMES[frameIndex] ?? FRAMES[0];
|
||||
if (!frame) return null;
|
||||
@@ -178,7 +163,7 @@ export const PipelineRun: React.FC = () => {
|
||||
const firstAttempt = frame.attempt === 1;
|
||||
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
|
||||
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
|
||||
const finished = frame.stagesDone === STAGES.length;
|
||||
const finished = frame.stagesDone === STAGE_COUNT;
|
||||
|
||||
const stageState = (index: number): StageState => {
|
||||
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
|
||||
@@ -201,20 +186,10 @@ export const PipelineRun: React.FC = () => {
|
||||
// Which of the three explanations applies right now.
|
||||
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
|
||||
|
||||
const EXPLANATIONS = [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
] as const;
|
||||
const explanations = copy.explanations.map((item) => ({
|
||||
title: item.title,
|
||||
text: fill(item.text, { area: scenarioCopy.label }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -222,7 +197,7 @@ export const PipelineRun: React.FC = () => {
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{copy.simulate}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SCENARIOS.map((item) => {
|
||||
const selected = item.id === scenario.id;
|
||||
@@ -241,14 +216,14 @@ export const PipelineRun: React.FC = () => {
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{copy.scenarios[item.id]?.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
|
||||
{EXPLANATIONS.map((item, index) => {
|
||||
{explanations.map((item, index) => {
|
||||
const active = index === explain;
|
||||
return (
|
||||
<li
|
||||
@@ -281,7 +256,7 @@ export const PipelineRun: React.FC = () => {
|
||||
onClick={() => setRun((n) => n + 1)}
|
||||
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
|
||||
>
|
||||
Run again
|
||||
{copy.runAgain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -302,16 +277,16 @@ export const PipelineRun: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
run #{frame.attempt === 1 ? 481 : 482}
|
||||
{copy.run} #{frame.attempt === 1 ? 481 : 482}
|
||||
{frame.attempt === 2 && (
|
||||
<span className="text-slate-400"> · fix pushed</span>
|
||||
<span className="text-slate-400"> · {copy.fixPushed}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
|
||||
{STAGES.map((stage, index) => {
|
||||
{copy.stages.map((stage, index) => {
|
||||
const state = stageState(index);
|
||||
return (
|
||||
<div
|
||||
@@ -329,9 +304,7 @@ export const PipelineRun: React.FC = () => {
|
||||
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
|
||||
{frame.stagesDone < 2 && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
{frame.stagesDone === 0
|
||||
? "$ lint: checking the code style…"
|
||||
: "$ types: checking that the parts fit together…"}
|
||||
{frame.stagesDone === 0 ? copy.lint : copy.types}
|
||||
</p>
|
||||
)}
|
||||
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
|
||||
@@ -348,7 +321,7 @@ export const PipelineRun: React.FC = () => {
|
||||
{suite.file}
|
||||
</span>
|
||||
<span className="hidden text-slate-500 sm:inline">
|
||||
{suite.tests} tests
|
||||
{suite.tests} {copy.tests}
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-slate-600">
|
||||
{suite.ms} ms
|
||||
@@ -357,13 +330,13 @@ export const PipelineRun: React.FC = () => {
|
||||
{failed && (
|
||||
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
|
||||
● {scenario.testName}
|
||||
● {scenarioCopy.testName}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
|
||||
expected {scenario.expected}
|
||||
{copy.expected} {scenarioCopy.expected}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
|
||||
received {scenario.received}
|
||||
{copy.received} {scenarioCopy.received}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +345,9 @@ export const PipelineRun: React.FC = () => {
|
||||
})}
|
||||
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
running {SUITES[frame.suitesDone]?.file}…
|
||||
{fill(copy.runningFile, {
|
||||
file: SUITES[frame.suitesDone]?.file ?? "",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -380,14 +355,19 @@ export const PipelineRun: React.FC = () => {
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
|
||||
<span>
|
||||
<span className="text-slate-500">Tests </span>
|
||||
<span className="text-slate-500">{copy.summaryLabel} </span>
|
||||
{failedCount > 0 && (
|
||||
<span className="text-rose-400">{failedCount} failed, </span>
|
||||
<span className="text-rose-400">
|
||||
{fill(copy.failed, { n: failedCount })},{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-emerald-400">
|
||||
{reportedTests - failedCount} passed
|
||||
{fill(copy.passed, { n: reportedTests - failedCount })}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{" "}
|
||||
{fill(copy.of, { total: TOTAL_TESTS })}
|
||||
</span>
|
||||
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
|
||||
@@ -399,18 +379,16 @@ export const PipelineRun: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
{!finished
|
||||
? "release pending"
|
||||
? copy.pending
|
||||
: firstAttempt
|
||||
? "release blocked"
|
||||
: "released"}
|
||||
? copy.blocked
|
||||
: copy.released}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BitAura>
|
||||
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
|
||||
An illustration with made-up file names and numbers, modelled on a
|
||||
real test run. Tests do not catch every mistake, but they do catch
|
||||
this kind.
|
||||
{copy.footnote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user