From c70833a7250452335f37cb11f940056136013f29 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 4 Oct 2026 16:34:38 +0200 Subject: [PATCH] feat: freelance page, a story on the homepage, and design fixes The homepage was too long and read as a list of sections. The technical ones (project fit, tools, how a change is secured) move to a page of their own, /freelance and /en/freelance, linked from the header, the footer and a pointer on the homepage. The rest is reordered into one story (who, what you need, examples, how working together goes, results, questions, contact) and each section opens with a sentence that leads over from the one before. routes.ts: a page may now have the same name in both languages. Before, /en/ was always moved to the English name, which for such a page would have redirected the address onto itself. Design: list titles such as "Passt gut" are real headings instead of small grey labels. The automation examples, their description and the run are one box, so it is clear they belong together. Digits on light backgrounds are the blue of the panels instead of black. The square corner marks on the rounded service cards are gone. The years in the career line are readable. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 20 ++- apps/web/app/[locale]/freelance/page.tsx | 141 ++++++++++++++++++ apps/web/app/[locale]/page.tsx | 122 ++++++--------- apps/web/app/globals.css | 45 ------ apps/web/app/sitemap.ts | 6 +- apps/web/src/components/Footer.tsx | 6 + apps/web/src/components/Header.tsx | 12 +- apps/web/src/components/bits/BitAura.tsx | 2 +- apps/web/src/components/home/Block.tsx | 20 +++ apps/web/src/components/home/PathPicker.tsx | 2 +- .../illustrations/AutomationFlow.tsx | 99 ++++++------ .../components/illustrations/CareerLine.tsx | 2 +- apps/web/src/content/de.ts | 27 ++++ apps/web/src/content/en.ts | 27 ++++ apps/web/src/content/types.ts | 24 +++ apps/web/src/domain/routes.spec.ts | 62 ++++++++ apps/web/src/domain/routes.ts | 15 +- 17 files changed, 442 insertions(+), 190 deletions(-) create mode 100644 apps/web/app/[locale]/freelance/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 5b2a7f7..3d65ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,9 +25,14 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m - Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage, pull requests). The automation service may name AI as part of what the client gets. Plain language. - Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1. -- One page: everything lives on the homepage. The header has the language switch and "Get in touch", which - jumps to the contact section (`#contact`). Contact is one email address: no calendar integration, no form. - The only other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/` +- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how + working together goes, results, questions, contact); each section opens with a `bridge` sentence from + `story` in the content files that leads over from the one before. The technical sections (project fit, + tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves + or lead a team. Do not move technical detail back onto the homepage. +- The header has the link to the freelance page, the language switch and "Get in touch", which jumps to the + contact section (`#contact`). Contact is one email address: no calendar integration, no form. The only + other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/` (see `next.config.mjs`). - Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`. The share image is a screenshot of the real hero, one per language: after changing the hero headline or @@ -39,8 +44,8 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m ## Two languages (German is the main one) -- German lives at the root (`/`, `/impressum`, `/datenschutz`), English under `/en` (`/en/imprint`, - `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses +- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en` + (`/en/freelance`, `/en/imprint`, `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`. - Detection: an address without a language follows the `lang` cookie (set only by the language switch), then the browser's `Accept-Language`, then German. `/en/...` is never redirected. @@ -54,8 +59,9 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m - Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits), `BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits), - `BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition). Reuse them - instead of inventing new effects. + `BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit + edge that sheds digits; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam`, + `BitGlow`. Reuse them instead of inventing new effects. - `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump. - Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element never counts as visible. Observe an unclipped wrapper (see `BitWipe`). diff --git a/apps/web/app/[locale]/freelance/page.tsx b/apps/web/app/[locale]/freelance/page.tsx new file mode 100644 index 0000000..1cb38ba --- /dev/null +++ b/apps/web/app/[locale]/freelance/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from "next"; +import { DecodeText } from "@/src/components/bits/DecodeText"; +import { Block } from "@/src/components/home/Block"; +import { ClosingCta } from "@/src/components/home/ClosingCta"; +import { PipelineRun } from "@/src/components/illustrations/PipelineRun"; +import { Reveal } from "@/src/components/Reveal"; +import { SwipeRow } from "@/src/components/SwipeRow"; +import { contentFor } from "@/src/content"; +import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale"; +import { languageAlternates, pathFor } from "@/src/domain/routes"; + +type Props = { readonly params: Promise<{ readonly locale: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE; + const { freelance } = contentFor(locale); + return { + title: { absolute: freelance.metaTitle }, + description: freelance.metaDescription, + alternates: { + canonical: pathFor("freelance", locale), + languages: languageAlternates("freelance"), + }, + }; +} + +const MUTED = "text-slate-400"; +const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl"; +const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl"; + +/** + * The page for people who develop themselves or lead a team: which projects + * fit, the tools, and how a change is secured before it goes live. + */ +export default async function FreelancePage({ params }: Props) { + const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE; + const content = contentFor(locale); + const { freelance, technology } = content; + + return ( +
+
+
+

+ {freelance.label} +

+

+ {freelance.title.lead}{" "} + {freelance.title.rest} +

+

+ {freelance.intro} +

+
+
+ + + {technology.title.lead}{" "} + {technology.title.rest} + + } + > + + {technology.fit.map((group, i) => ( + +
+

+ {group.title} +

+
    + {group.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ ))} +
+ + +
+

+ {technology.techLabel} +

+
+ {technology.tech.map((group) => ( +
+

+ {group.area} +

+
    + {group.items.map((item) => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+
+
+ + + +

+ {technology.pipelineIntro} +

+
+ + + +
+ + +
+ ); +} diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index c52aafe..6f4b218 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { BitAura } from "@/src/components/bits/BitAura"; -import { DecodeText } from "@/src/components/bits/DecodeText"; import { Block } from "@/src/components/home/Block"; import { ClosingCta } from "@/src/components/home/ClosingCta"; import { Faq } from "@/src/components/home/Faq"; @@ -12,7 +12,6 @@ import { StickyBook } from "@/src/components/home/StickyBook"; import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow"; import { CareerLine } from "@/src/components/illustrations/CareerLine"; import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram"; -import { PipelineRun } from "@/src/components/illustrations/PipelineRun"; import { PresentationCompare, ReasonDrawing, @@ -42,7 +41,8 @@ export async function generateMetadata({ params }: Props): Promise { const MUTED = "text-slate-400"; const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl"; -const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500"; +const LIST_TITLE = + "m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl"; const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({ title, @@ -61,7 +61,8 @@ export default async function LandingPage({ params }: Props) { services, automation, presentations, - technology, + story, + freelance, working, work, faq, @@ -105,6 +106,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -115,6 +117,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -127,7 +130,7 @@ export default async function LandingPage({ params }: Props) { {automation.notes.map((group, i) => (
-

{group.title}

+

{group.title}

    {group.items.map((item) => (
  • } > @@ -182,9 +186,9 @@ export default async function LandingPage({ params }: Props) {
    -

    +

    {presentations.goodFor.title} -

    +

      {presentations.goodFor.items.map((item) => (
    • } - > - - {technology.fit.map((group, i) => ( - -
      -

      {group.title}

      -
        - {group.items.map((item) => ( -
      • - {item} -
      • - ))} -
      -
      -
      - ))} -
      - - -
      -

      {technology.techLabel}

      -
      - {technology.tech.map((group) => ( -
      -

      - {group.area} -

      -
        - {group.items.map((item) => ( -
      • - -
      • - ))} -
      -
      - ))} -
      -
      -
      - -
      - -

      - {technology.pipelineTitle} -

      -

      - {technology.pipelineIntro} -

      -
      - - - -
      - - - } > @@ -309,11 +245,36 @@ export default async function LandingPage({ params }: Props) { ))} + + + + + {freelance.teaser.title} + + + {freelance.teaser.text} + + + + {freelance.teaser.cta} + + + + } > @@ -325,7 +286,12 @@ export default async function LandingPage({ params }: Props) { - }> + } + > diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7caba2b..2c9e0b7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1018,47 +1018,6 @@ pre:has(code[class*="language-"]) { } } -/* Corner marks that snap onto a card on hover or focus. */ -.bit-card { - position: relative; -} -.bit-card::before, -.bit-card::after { - content: ""; - position: absolute; - width: 14px; - height: 14px; - border: 1.5px solid currentColor; - opacity: 0; - transition: - opacity 250ms ease, - transform 350ms cubic-bezier(0.16, 1, 0.3, 1); - pointer-events: none; -} -.bit-card::before { - top: 12px; - right: 12px; - border-left: 0; - border-bottom: 0; - transform: translate(-6px, 6px); -} -.bit-card::after { - bottom: 12px; - left: 12px; - border-right: 0; - border-top: 0; - transform: translate(6px, -6px); -} -.bit-card:hover::before, -.bit-card:hover::after, -.bit-card:focus-visible::before, -.bit-card:focus-visible::after, -.bit-card[aria-selected="true"]::before, -.bit-card[aria-selected="true"]::after { - opacity: 0.55; - transform: translate(0, 0); -} - /* Light that follows the pointer on a `data-glow` surface (see BitGlow). */ .bit-glow { position: absolute; @@ -1127,10 +1086,6 @@ pre:has(code[class*="language-"]) { .bit-glow { display: none; } - .bit-card::before, - .bit-card::after { - transition: none; - } } /* diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index 49ea37b..fd1f6eb 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -3,13 +3,13 @@ import { SITE_URL } from "@/src/content/site"; import { sitemapEntries } from "@/src/domain/routes"; /** - * The site is a single page in two languages; the legal pages are not meant for - * search results. No modification date: it would have to be the date the content + * The homepage and the freelance page, each in two languages; the legal pages + * are not meant for search results. No modification date: it would have to be the date the content * last changed, and a date that moves with every build teaches search engines to * ignore it. */ export default function sitemap(): MetadataRoute.Sitemap { - return sitemapEntries(SITE_URL, ["home"]).map((entry) => ({ + return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({ url: entry.url, alternates: { languages: { ...entry.alternates.languages } }, })); diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index fd306f0..5f5654c 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -50,6 +50,12 @@ export const Footer: React.FC = () => { > {nav.contactLong} + + {nav.freelance} + { const { nav } = useContent(); - const home = pathFor("home", useLocale()); + const locale = useLocale(); + const home = pathFor("home", locale); + const freelance = pathFor("freelance", locale); const [isScrolled, setIsScrolled] = React.useState(false); React.useEffect(() => { @@ -62,6 +64,12 @@ export const Header: React.FC = () => {
      + + {nav.freelance} + = ({ index, label, title, + bridge, id, tone = "light", children, @@ -50,6 +53,23 @@ export const Block: React.FC = ({ )} {dark && }
      + {bridge && ( + +
      +
      +
      + )}
      { aria-controls={`path-panel-${path.id}`} onClick={() => setActiveId(path.id)} data-glow - className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-4 text-left transition-all duration-300 md:p-7 ${ + className={`group flex h-full flex-col justify-between rounded-3xl border p-4 text-left transition-all duration-300 md:p-7 ${ selected ? "border-slate-900 bg-slate-900 text-white" : "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400" diff --git a/apps/web/src/components/illustrations/AutomationFlow.tsx b/apps/web/src/components/illustrations/AutomationFlow.tsx index 5080919..3fee43e 100644 --- a/apps/web/src/components/illustrations/AutomationFlow.tsx +++ b/apps/web/src/components/illustrations/AutomationFlow.tsx @@ -223,40 +223,35 @@ export const AutomationFlow: React.FC = () => { return (
      -
      - {workflows.map((item) => { - const selected = item.id === flow.id; - return ( - - ); - })} -
      - -

      - {flow.summary} -

      - - + {/* The examples, their description and the run are one box: they belong together. */} +
      -
      - {flow.name} +
      +
      + {workflows.map((item) => { + const selected = item.id === flow.id; + return ( + + ); + })} +
      { {finished ? automation.runComplete : automation.running}
      +

      + {flow.summary} +

      { ))}
    +
    + +
    +

    + {automation.byHand} +

    +

    + {flow.byHand} +

    +
    +
    +

    + {automation.automated} +

    +

    + {flow.automated} +

    +
    +
    +
    - - -
    -

    {automation.byHand}

    -

    - {flow.byHand} -

    -
    -
    -

    {automation.automated}

    -

    - {flow.automated} -

    -
    -
); }; diff --git a/apps/web/src/components/illustrations/CareerLine.tsx b/apps/web/src/components/illustrations/CareerLine.tsx index 2a77791..e4cae09 100644 --- a/apps/web/src/components/illustrations/CareerLine.tsx +++ b/apps/web/src/components/illustrations/CareerLine.tsx @@ -46,7 +46,7 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({ }`} aria-hidden="true" /> - + {station.years} diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts index 19fc925..0339e0a 100644 --- a/apps/web/src/content/de.ts +++ b/apps/web/src/content/de.ts @@ -26,6 +26,7 @@ export const de: Content = { }, nav: { contact: "Kontakt", + freelance: "Freelance", contactLong: GET_IN_TOUCH, logoLabel: "Marc Mintel, nach oben", imprint: "Impressum", @@ -497,6 +498,32 @@ export const de: Content = { }, cta: "Nach einer Präsentation fragen", }, + story: { + services: "So viel zu mir. Jetzt zu Ihnen.", + automation: "Einen dieser Fälle zeige ich genauer.", + presentations: "Und manchmal ist das Richtige gar keine App.", + working: "Egal, was Sie brauchen: So läuft die Zusammenarbeit.", + work: "Und so sieht das Ergebnis aus.", + faq: "Bleiben die Fragen, die fast immer kommen.", + }, + freelance: { + metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel", + metaDescription: + "Für welche Projekte ich passe, womit ich arbeite (React, Next.js, TypeScript) und wie ich Änderungen absichere, bevor sie live gehen.", + label: "Freelance", + title: { + lead: "Ein Entwickler für Ihr Team.", + rest: "Die technischen Details.", + }, + intro: + "Diese Seite ist für alle, die selbst entwickeln oder ein Team leiten: für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, bevor sie live gehen.", + pipelineLabel: "Absicherung", + teaser: { + title: "Sie entwickeln selbst oder leiten ein Team?", + text: "Für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, steht auf einer eigenen Seite.", + cta: "Zu den technischen Details", + }, + }, technology: { label: "Technik", title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." }, diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts index eef42b7..4162867 100644 --- a/apps/web/src/content/en.ts +++ b/apps/web/src/content/en.ts @@ -25,6 +25,7 @@ export const en: Content = { }, nav: { contact: GET_IN_TOUCH, + freelance: "Freelance", contactLong: GET_IN_TOUCH, logoLabel: "Marc Mintel, back to top", imprint: "Imprint", @@ -477,6 +478,32 @@ export const en: Content = { }, cta: "Ask about a presentation", }, + story: { + services: "That is me. Now to you.", + automation: "One of these cases, shown in more detail.", + presentations: "And sometimes the right thing is not an app at all.", + working: "Whatever you need: this is how working together goes.", + work: "And this is what the result looks like.", + faq: "That leaves the questions that almost always come up.", + }, + freelance: { + metaTitle: "Freelance web developer for your team | Marc Mintel", + metaDescription: + "Which projects I am right for, what I work with (React, Next.js, TypeScript) and how I secure changes before they go live.", + label: "Freelance", + title: { + lead: "A developer for your team.", + rest: "The technical details.", + }, + intro: + "This page is for people who develop themselves or lead a team: which projects I am right for, what I work with, and how I secure changes before they go live.", + pipelineLabel: "Safeguards", + teaser: { + title: "You develop yourself or lead a team?", + text: "Which projects I am right for, what I work with and how I secure changes is on a page of its own.", + cta: "To the technical details", + }, + }, technology: { label: "Technology", title: { lead: "Which projects", rest: "I am right for." }, diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts index ecb32d6..52435b1 100644 --- a/apps/web/src/content/types.ts +++ b/apps/web/src/content/types.ts @@ -146,6 +146,7 @@ export type Content = { }; readonly nav: { readonly contact: string; + readonly freelance: string; readonly contactLong: string; readonly logoLabel: string; readonly imprint: string; @@ -220,6 +221,29 @@ export type Content = { readonly honest: TitledText; readonly cta: string; }; + /** + * The thread through the homepage: one sentence at the top of each section + * that leads over from the one before, so the page reads as one story. + */ + readonly story: { + readonly services: string; + readonly automation: string; + readonly presentations: string; + readonly working: string; + readonly work: string; + readonly faq: string; + }; + /** The page for people who develop themselves or lead a team. */ + readonly freelance: { + readonly metaTitle: string; + readonly metaDescription: string; + readonly label: string; + readonly title: SplitTitle; + readonly intro: string; + readonly pipelineLabel: string; + /** The pointer to this page on the homepage. */ + readonly teaser: TitledText & { readonly cta: string }; + }; readonly technology: { readonly label: string; readonly title: SplitTitle; diff --git a/apps/web/src/domain/routes.spec.ts b/apps/web/src/domain/routes.spec.ts index 683d2ac..7a4c836 100644 --- a/apps/web/src/domain/routes.spec.ts +++ b/apps/web/src/domain/routes.spec.ts @@ -296,3 +296,65 @@ describe("sitemapEntries", () => { expect(sitemapEntries("https://example.com", [])).toEqual([]); }); }); + +describe("the freelance page, which has the same name in both languages", () => { + it("lives at /freelance in German and /en/freelance in English", () => { + expect(pathFor("freelance", "de")).toBe("/freelance"); + expect(pathFor("freelance", "en")).toBe("/en/freelance"); + expect(languageAlternates("freelance")).toEqual({ + de: "/freelance", + en: "/en/freelance", + "x-default": "/freelance", + }); + }); + + it("switches between the languages", () => { + expect(switchPath("/freelance", "en")).toBe("/en/freelance"); + expect(switchPath("/en/freelance", "de")).toBe("/freelance"); + }); + + it("shows /freelance in German to a German browser", () => { + expect(routeRequest({ pathname: "/freelance", ...german })).toEqual({ + kind: "rewrite", + to: "/de/freelance", + negotiated: true, + }); + }); + + it("sends an English browser from /freelance to /en/freelance, not permanently", () => { + expect(routeRequest({ pathname: "/freelance", ...english })).toEqual({ + kind: "redirect", + to: "/en/freelance", + permanent: false, + negotiated: true, + }); + }); + + it("shows /en/freelance to everyone instead of redirecting it onto itself", () => { + expect(routeRequest({ pathname: "/en/freelance", ...german })).toEqual({ + kind: "rewrite", + to: "/en/freelance", + negotiated: false, + }); + }); + + it("moves the internal address /de/freelance to /freelance", () => { + expect(routeRequest({ pathname: "/de/freelance", ...german })).toEqual({ + kind: "redirect", + to: "/freelance", + permanent: true, + negotiated: false, + }); + }); + + it("is listed in the sitemap in both languages", () => { + expect( + sitemapEntries("https://example.com", ["freelance"]).map( + (entry) => entry.url, + ), + ).toEqual([ + "https://example.com/freelance", + "https://example.com/en/freelance", + ]); + }); +}); diff --git a/apps/web/src/domain/routes.ts b/apps/web/src/domain/routes.ts index 3d8dc24..71df099 100644 --- a/apps/web/src/domain/routes.ts +++ b/apps/web/src/domain/routes.ts @@ -6,7 +6,7 @@ import { LOCALES, preferredLocale, type Locale } from "./locale"; * Internally every page is rendered under its language (/de/..., /en/...); * `routeRequest` maps one onto the other. */ -export type PageId = "home" | "imprint" | "privacy"; +export type PageId = "home" | "freelance" | "imprint" | "privacy"; type LanguageAlternates = Readonly>; @@ -31,18 +31,24 @@ export type SitemapEntry = { type PagePaths = Readonly>; -const PAGES: readonly PageId[] = ["home", "imprint", "privacy"]; +const PAGES: readonly PageId[] = ["home", "freelance", "imprint", "privacy"]; /** The path of a page below its language, as the app directory names it. */ const INTERNAL: PagePaths = { home: "", + freelance: "/freelance", imprint: "/imprint", privacy: "/privacy", }; /** The path of a page below its language, as visitors see it. */ const PUBLIC: Readonly> = { - de: { home: "", imprint: "/impressum", privacy: "/datenschutz" }, + de: { + home: "", + freelance: "/freelance", + imprint: "/impressum", + privacy: "/datenschutz", + }, en: INTERNAL, }; @@ -111,8 +117,9 @@ export function routeRequest(request: { const english = restAfter(request.pathname, PREFIX.en); if (english !== null) { + // A German page name under /en is moved, unless the page has the same name in both languages. const misnamed = pageAt(english, PUBLIC.de); - return misnamed === null || misnamed === "home" + return misnamed === null || PUBLIC.de[misnamed] === INTERNAL[misnamed] ? { kind: "rewrite", to: `/en${english}`, negotiated: false } : redirect(pathFor(misnamed, "en"), true); }