> ## Documentation Index
> Fetch the complete documentation index at: https://constanza101-borrissol-28.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Multilingual i18n routing and translations in Borrissol

> How Borrissol handles Catalan, Spanish, English, and French with Astro's i18n routing, a single translation catalog, hreflang tags, and 301 redirects.

Borrissol serves its content in four languages — Catalan (`ca`), Spanish (`es`), English (`en`), and French (`fr`) — without duplicating any page files. Catalan is the site's default locale and lives at the root URL with no prefix (e.g. `/`, `/gallery`, `/blog`). Every other language gets its own two-letter prefix (`/es`, `/en`, `/fr`). The entire approach is powered by Astro 6's built-in i18n routing combined with a single translation catalog in `src/i18n/ui.ts` and a set of utility functions in `src/i18n/utils.ts`.

## Routing architecture

Astro's i18n config in `astro.config.mjs` declares the four locales and sets `prefixDefaultLocale: false` so Catalan URLs stay clean:

```js astro.config.mjs theme={null}
i18n: {
  defaultLocale: 'ca',
  locales: ['ca', 'es', 'en', 'fr'],
  routing: {
    prefixDefaultLocale: false,
  },
},
```

Each translated page lives under `src/pages/[lang]/` (e.g. `src/pages/[lang]/index.astro`). That file calls `getStaticPaths()` to emit one static route per non-default locale. The Catalan version of every page has a twin file directly in `src/pages/` (e.g. `src/pages/index.astro`) that handles the root route. This means there is exactly one `.astro` file per page, not one per language.

```typescript theme={null}
// src/pages/[lang]/index.astro  (simplified)
import { nonDefaultLangPaths } from '../../i18n/utils';

export function getStaticPaths() {
  return nonDefaultLangPaths(); // → [{ params: { lang: 'es' } }, { params: { lang: 'en' } }, { params: { lang: 'fr' } }]
}
```

## The translation catalog (`src/i18n/ui.ts`)

All copy for every language lives in one file. There are no per-language JSON files to keep in sync.

```typescript src/i18n/ui.ts theme={null}
export const languages = {
  ca: 'CAT',
  es: 'ES',
  en: 'EN',
  fr: 'FR',
} as const;

export const defaultLang = 'ca' as const;
export type Lang = keyof typeof languages;
```

The `ui` object holds every translation key for Spanish, English, and French. Catalan translations are inlined directly into components through the fallback mechanism (the default locale's keys are used when a language's entry is missing).

The `notFound` object is intentionally separated from `ui`. The 404 page is built once as a static HTML file in the default locale, so it serializes all four languages and picks the correct one at runtime from the URL:

```typescript src/i18n/ui.ts theme={null}
export const notFound: Record<Lang, Record<string, string>> = {
  ca: { title: "S'ha descosit un fil", home: "Torna a l'inici", /* … */ },
  es: { title: 'Se ha soltado un hilo',  home: 'Volver al inicio', /* … */ },
  en: { title: 'A thread came loose',    home: 'Back to home',     /* … */ },
  fr: { title: "Un fil s'est défait",    home: "Retour à l'accueil", /* … */ },
};
```

## Utility functions (`src/i18n/utils.ts`)

Four helpers cover every i18n use case across the codebase.

| Function                            | Purpose                                                        |
| ----------------------------------- | -------------------------------------------------------------- |
| `getLangFromUrl(url)`               | Extracts locale from the URL path; falls back to `defaultLang` |
| `useTranslations(lang)`             | Returns a `t(key)` function bound to the given locale          |
| `localizedPath(lang, path)`         | Prefixes a path for non-default locales                        |
| `getAlternatePath(url, targetLang)` | Computes the equivalent URL in another locale                  |
| `nonDefaultLangPaths()`             | Returns `getStaticPaths` entries for all non-default locales   |

A typical component setup looks like this:

```typescript theme={null}
import { getLangFromUrl, useTranslations, localizedPath } from '../../i18n/utils';

const lang = getLangFromUrl(Astro.url);
const t    = useTranslations(lang);

// t('nav.services') → 'Services' (EN) | 'Serveis' (CA) | 'Servicios' (ES) | 'Services' (FR)
// localizedPath('es', '/gallery') → '/es/gallery'
// localizedPath('ca', '/gallery') → '/gallery'
```

`getAlternatePath` does a URL-prefix swap instead of a lookup table, so it works for every route automatically — including future ones:

```typescript theme={null}
// /es/gallery + 'en'  → '/en/gallery'
// /es/gallery + 'ca'  → '/gallery'
// /gallery    + 'fr'  → '/fr/gallery'
getAlternatePath(Astro.url, 'en');
```

The `localeMap` in `utils.ts` maps short codes to BCP 47 tags for Open Graph and the HTML `lang` attribute:

```typescript theme={null}
export const localeMap: Record<Lang, string> = {
  es: 'es_ES',
  en: 'en_US',
  ca: 'ca_ES',
  fr: 'fr_FR',
};
```

## Adding a new translation key

<Steps>
  <Step title="Open `src/i18n/ui.ts`">
    Add the key to all four language objects inside the `ui` export. Catalan is the fallback, so if you add it only to `ca` other locales will silently display the Catalan text — always add to all four.

    ```typescript theme={null}
    // ui.ts — add to each language block:
    'offer.new.label': 'CAT text here',   // ca (inline in component — see below)
    // es:
    'offer.new.label': 'ES text here',
    // en:
    'offer.new.label': 'EN text here',
    // fr:
    'offer.new.label': 'FR text here',
    ```
  </Step>

  <Step title="Use `t('your.key')` in the component">
    ```typescript theme={null}
    const lang = getLangFromUrl(Astro.url);
    const t    = useTranslations(lang);

    // In the template:
    // {t('offer.new.label')}
    ```
  </Step>
</Steps>

<Note>
  `useTranslations` falls back first to the default locale's value, then to the raw key string. A missing translation will never crash the build, but it will surface Catalan text in other locales — treat missing keys as bugs.
</Note>

## Hreflang tags

Every page gets `<link rel="alternate" hreflang="…">` tags in its `<head>` via `Seo.astro`. The sitemap generated by `@astrojs/sitemap` also emits hreflang cross-references inside the sitemap XML, reinforcing the signal for search engines:

```js astro.config.mjs theme={null}
sitemap({
  i18n: {
    defaultLocale: 'ca',
    locales: {
      ca: 'ca-ES',
      es: 'es-ES',
      en: 'en-US',
      fr: 'fr-FR',
    },
  },
  filter: (page) => !page.includes('/ca'),
}),
```

## 301 redirects

The `redirects` block in `astro.config.mjs` handles two categories of legacy or incorrect URLs:

**Legacy `/ca/*` URLs** — Catalan was historically served under a `/ca` prefix. Those paths no longer exist; all are redirected to the root equivalent:

```js astro.config.mjs theme={null}
'/ca':             { status: 301, destination: '/' },
'/ca/gallery':     { status: 301, destination: '/gallery' },
'/ca/press':       { status: 301, destination: '/press' },
'/ca/blog':        { status: 301, destination: '/blog' },
'/ca/blog/[slug]': { status: 301, destination: '/blog/[slug]' },
```

**Non-Catalan blog URLs** — The blog is intentionally Catalan-only (authored exclusively by the workshop founder in her native language). Visitors who follow a translated blog link are redirected to the Catalan blog rather than seeing a 404:

```js astro.config.mjs theme={null}
'/es/blog':         { status: 301, destination: '/blog' },
'/es/blog/[slug]':  { status: 301, destination: '/blog/[slug]' },
'/en/blog':         { status: 301, destination: '/blog' },
'/en/blog/[slug]':  { status: 301, destination: '/blog/[slug]' },
'/fr/blog':         { status: 301, destination: '/blog' },
'/fr/blog/[slug]':  { status: 301, destination: '/blog/[slug]' },
```

<Note>
  Astro requires redirect destinations to match real routes, which is why each path is listed explicitly rather than using a wildcard pattern.
</Note>

## Language switcher

The language switcher is part of `Navbar.astro`. It uses `getAlternatePath` to compute the target URL for each locale and preserves the URL hash via client-side JavaScript, so a visitor on `/en#workshops` lands on `/es#workshops` after switching to Spanish — not just `/es`.

<Tip>
  URL slugs are always in English, lowercase, and single-word (`/gallery`, `/press`, `/blog`) regardless of the display language. Translated labels (`nav.gallery` → "Galeria" / "Galería" / "Gallery" / "Galerie") are in `ui.ts`; the routes are not. This keeps hreflang clean and avoids duplicated slug logic.
</Tip>
