Files
landing/docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md
T
anthony 86049dd8b5 feat: add Header with language switcher and theme toggle
Also disables Vitest file parallelism since concurrent astro builds
across test files raced on the shared dist/ output.
2026-08-02 15:57:35 +02:00

39 KiB
Raw Blame History

Ombrora Hub Landing Page Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a static, SEO-optimized Astro landing page at www.ombrora.com that acts as a bilingual (FR/EN) hub linking out to Ombrora's CVE Watch and Convert services.

Architecture: A single Astro page rendered twice via Astro's built-in i18n routing (/en/, /fr/), composed from small shared components (BaseLayout, Header, Hero content inline, ServiceCard, Footer) driven by plain TypeScript dictionaries and a service data file. No client framework, no server runtime — everything is prerendered HTML/CSS with a small amount of vanilla JS for theme switching.

Tech Stack: Astro 7.x (static output, the default), TypeScript, Vitest for tests, @astrojs/sitemap for sitemap generation, plain CSS (custom properties for theming). Deployed as static files to o2switch shared hosting.

Global Constraints

  • Static output only — no SSR, no adapter (output: 'static', Astro's default; never add an adapter).
  • i18n routing is path-based and symmetric: /en/ and /fr/, via routing: { prefixDefaultLocale: true } — there is no unprefixed default-locale route.
  • The bare root / is handled entirely by public/.htaccess (Apache mod_rewrite, Accept-Language-based), not by Astro routing or client-side JS.
  • Deploy target is o2switch shared hosting: the contents of dist/ are uploaded as-is (including .htaccess).
  • Light and dark themes are both required. Default follows prefers-color-scheme; a manual toggle persists to localStorage under the exact key ombrora-theme with values "dark" or "light". Theme is applied via a data-theme attribute on <html>, driven by CSS custom properties.
  • Scope is minimal: one hub page in two locales, two services (CVE Watch, Convert), a footer. No About/Contact/legal pages, no analytics.
  • Package manager is npm.
  • Every page needs: canonical link, hreflang alternates (en, fr, x-default), meta description, Open Graph tags, and must be covered by the sitemap.

Task 1: Scaffold Astro project with i18n routing

Files:

  • Create (via CLI, then moved into repo root): package.json, astro.config.mjs, tsconfig.json, .gitignore, src/pages/en/index.astro, src/pages/fr/index.astro
  • Delete: src/pages/index.astro (default template page — this project has no unprefixed route)
  • Test: tests/routing.test.ts

Interfaces:

  • Consumes: nothing (first task).

  • Produces: a working npm run build that outputs dist/en/index.html and dist/fr/index.html, and no dist/index.html. npm run test runs Vitest. astro.config.mjs exports site: 'https://www.ombrora.com' and i18n: { locales: ['en', 'fr'], defaultLocale: 'en', routing: { prefixDefaultLocale: true } } — later tasks add to this file's integrations array but must not change these two settings.

  • Step 1: Scaffold a fresh Astro project into a temporary directory

Run from the repo root (C:\Users\tony1\PhpstormProjects\Ombrora-Landing):

npm create astro@latest ombrora-scaffold-tmp -- --template minimal --no-git --yes

This avoids any ambiguity around scaffolding into the current, non-empty directory (it already contains CLAUDE.md, docs/, .git, .idea).

  • Step 2: Move the scaffolded files into the repo root
mv ombrora-scaffold-tmp/.[!.]* ombrora-scaffold-tmp/* .
rmdir ombrora-scaffold-tmp

Verify package.json, astro.config.mjs, tsconfig.json, .gitignore, and src/ now exist at the repo root.

  • Step 3: Confirm .gitignore excludes build artifacts

Open .gitignore and confirm it contains at least:

node_modules/
dist/
.astro/

Add any missing line.

  • Step 4: Remove the default page and add locale placeholder pages

Delete src/pages/index.astro.

Create src/pages/en/index.astro:

---
const locale = 'en';
---
<html lang={locale}>
  <head>
    <meta charset="utf-8" />
    <title>Ombrora</title>
  </head>
  <body>
    <h1>Ombrora — EN</h1>
  </body>
</html>

Create src/pages/fr/index.astro:

---
const locale = 'fr';
---
<html lang={locale}>
  <head>
    <meta charset="utf-8" />
    <title>Ombrora</title>
  </head>
  <body>
    <h1>Ombrora — FR</h1>
  </body>
</html>
  • Step 5: Configure site and i18n in astro.config.mjs

Replace its contents with:

import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://www.ombrora.com',
  i18n: {
    locales: ['en', 'fr'],
    defaultLocale: 'en',
    routing: {
      prefixDefaultLocale: true,
    },
  },
});
  • Step 6: Add Vitest
npm install -D vitest

In package.json, add to "scripts":

"test": "vitest run"
  • Step 7: Write the failing test

Create tests/routing.test.ts:

import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';

describe('locale routing', () => {
  it('builds /en/ and /fr/ pages with correct lang attributes', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');
    expect(en).toContain('lang="en"');
    expect(fr).toContain('lang="fr"');
  });

  it('does not build an unprefixed root page', () => {
    expect(existsSync('dist/index.html')).toBe(false);
  });
});
  • Step 8: Run the test and confirm it fails

Run: npm run test Expected: FAIL — dist/en/index.html does not exist yet (no build has run).

  • Step 9: Build the site

Run: npm run build

  • Step 10: Run the test and confirm it passes

Run: npm run test Expected: PASS (2 tests).

  • Step 11: Commit
git add package.json package-lock.json astro.config.mjs tsconfig.json .gitignore src tests
git commit -m "feat: scaffold Astro project with en/fr i18n routing"

Task 2: i18n content dictionaries and service data

Files:

  • Create: src/i18n/locale.ts, src/i18n/en.ts, src/i18n/fr.ts, src/i18n/index.ts, src/i18n/services.ts
  • Test: tests/i18n.test.ts

Interfaces:

  • Consumes: nothing (standalone data module).

  • Produces:

    • src/i18n/locale.ts: type Locale = 'en' | 'fr', const locales: Locale[], function otherLocale(locale: Locale): Locale, function swapLocalePath(pathname: string, from: Locale, to: Locale): string.
    • src/i18n/en.ts: const en = {...} as const, type Dictionary = typeof en.
    • src/i18n/fr.ts: const fr: Dictionary.
    • src/i18n/index.ts: function getDictionary(locale: Locale): Dictionary.
    • src/i18n/services.ts: interface Service { id: string; url: string | Record<Locale, string>; name: Record<Locale, string>; tag: Record<Locale, string>; description: Record<Locale, string>; }, const services: Service[], function resolveServiceUrl(service: Service, locale: Locale): string.
    • These are consumed by Tasks 36 (BaseLayout, Header, LanguageSwitcher, ThemeToggle, ServiceCard, Footer, pages).
  • Step 1: Write the failing test

Create tests/i18n.test.ts:

import { describe, it, expect } from 'vitest';
import { en } from '../src/i18n/en';
import { fr } from '../src/i18n/fr';
import { getDictionary } from '../src/i18n';
import { otherLocale, swapLocalePath, locales } from '../src/i18n/locale';
import { services, resolveServiceUrl } from '../src/i18n/services';

describe('i18n dictionaries', () => {
  it('exposes both locales', () => {
    expect(locales).toEqual(['en', 'fr']);
  });

  it('fr and en dictionaries have the same keys', () => {
    expect(Object.keys(fr)).toEqual(Object.keys(en));
    expect(Object.keys(fr.hero)).toEqual(Object.keys(en.hero));
    expect(Object.keys(fr.theme)).toEqual(Object.keys(en.theme));
  });

  it('getDictionary resolves the right dictionary', () => {
    expect(getDictionary('en')).toBe(en);
    expect(getDictionary('fr')).toBe(fr);
  });

  it('otherLocale flips locale', () => {
    expect(otherLocale('en')).toBe('fr');
    expect(otherLocale('fr')).toBe('en');
  });

  it('swapLocalePath swaps the locale segment', () => {
    expect(swapLocalePath('/en/', 'en', 'fr')).toBe('/fr/');
  });
});

describe('services data', () => {
  it('resolves a locale-specific url', () => {
    const cve = services.find((s) => s.id === 'cve')!;
    expect(resolveServiceUrl(cve, 'en')).toBe('https://cve-en.ombrora.com');
    expect(resolveServiceUrl(cve, 'fr')).toBe('https://cve-fr.ombrora.com');
  });

  it('resolves a single-url service the same for both locales', () => {
    const convert = services.find((s) => s.id === 'convert')!;
    expect(resolveServiceUrl(convert, 'en')).toBe('https://convert.ombrora.com');
    expect(resolveServiceUrl(convert, 'fr')).toBe('https://convert.ombrora.com');
  });

  it('has exactly two services: cve and convert', () => {
    expect(services.map((s) => s.id).sort()).toEqual(['convert', 'cve']);
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test Expected: FAIL — cannot find module ../src/i18n/en (files don't exist yet).

  • Step 3: Create src/i18n/locale.ts
export type Locale = 'en' | 'fr';

export const locales: Locale[] = ['en', 'fr'];

export function otherLocale(locale: Locale): Locale {
  return locale === 'en' ? 'fr' : 'en';
}

export function swapLocalePath(pathname: string, from: Locale, to: Locale): string {
  return pathname.replace(`/${from}/`, `/${to}/`);
}
  • Step 4: Create src/i18n/en.ts
export const en = {
  meta: {
    title: 'Ombrora — Security tracking and file tools',
    description:
      "Ombrora is a hub for a growing set of independent tools: CVE tracking and file conversion.",
  },
  nav: {
    brand: 'Ombrora',
  },
  hero: {
    title: 'The Ombrora hub',
    subtitle:
      'Small, focused tools for security tracking and everyday file conversion — this page is the map.',
  },
  services: {
    heading: 'Services',
  },
  theme: {
    toggleToDark: 'Switch to dark mode',
    toggleToLight: 'Switch to light mode',
  },
  footer: {
    text: 'Ombrora',
  },
} as const;

export type Dictionary = typeof en;
  • Step 5: Create src/i18n/fr.ts
import type { Dictionary } from './en';

export const fr: Dictionary = {
  meta: {
    title: 'Ombrora — Veille sécurité et outils de fichiers',
    description:
      'Ombrora est un hub regroupant des outils indépendants : veille CVE et conversion de fichiers.',
  },
  nav: {
    brand: 'Ombrora',
  },
  hero: {
    title: 'Le hub des outils Ombrora',
    subtitle:
      'Des outils simples et ciblés pour la veille sécurité et la conversion de fichiers au quotidien — cette page en est la carte.',
  },
  services: {
    heading: 'Services',
  },
  theme: {
    toggleToDark: 'Passer en mode sombre',
    toggleToLight: 'Passer en mode clair',
  },
  footer: {
    text: 'Ombrora',
  },
};
  • Step 6: Create src/i18n/index.ts
import { en } from './en';
import { fr } from './fr';
import type { Dictionary } from './en';
import type { Locale } from './locale';

const dictionaries: Record<Locale, Dictionary> = { en, fr };

export function getDictionary(locale: Locale): Dictionary {
  return dictionaries[locale];
}
  • Step 7: Create src/i18n/services.ts
import type { Locale } from './locale';

export interface Service {
  id: string;
  url: string | Record<Locale, string>;
  name: Record<Locale, string>;
  tag: Record<Locale, string>;
  description: Record<Locale, string>;
}

export const services: Service[] = [
  {
    id: 'cve',
    url: {
      en: 'https://cve-en.ombrora.com',
      fr: 'https://cve-fr.ombrora.com',
    },
    name: { en: 'CVE Watch', fr: 'Veille CVE' },
    tag: { en: 'Security blog', fr: 'Blog sécurité' },
    description: {
      en: 'Daily-tracked CVE disclosures, explained.',
      fr: 'Les CVE publiées, suivies et expliquées au quotidien.',
    },
  },
  {
    id: 'convert',
    url: 'https://convert.ombrora.com',
    name: { en: 'Convert', fr: 'Convert' },
    tag: { en: 'File converter', fr: 'Convertisseur de fichiers' },
    description: {
      en: 'Convert documents, audio, images, video, and archives.',
      fr: 'Convertissez documents, audio, images, vidéos et archives.',
    },
  },
];

export function resolveServiceUrl(service: Service, locale: Locale): string {
  return typeof service.url === 'string' ? service.url : service.url[locale];
}
  • Step 8: Run the test and confirm it passes

Run: npm run test Expected: PASS (all tests in tests/i18n.test.ts and tests/routing.test.ts).

  • Step 9: Commit
git add src/i18n tests/i18n.test.ts
git commit -m "feat: add i18n dictionaries and service data"

Task 3: BaseLayout — SEO head tags, global theming CSS, pre-paint theme script

Files:

  • Create: src/layouts/BaseLayout.astro, src/styles/global.css
  • Modify: src/pages/en/index.astro, src/pages/fr/index.astro (replace placeholder markup with <BaseLayout> usage)
  • Test: tests/seo-theme.test.ts

Interfaces:

  • Consumes: getDictionary, Locale, otherLocale, swapLocalePath from Task 2.

  • Produces: BaseLayout.astro with Props { locale: Locale } — derives <title>/description from getDictionary(locale).meta internally (callers only pass locale). Sets <html lang>, canonical, hreflang alternates, OG tags, links global.css, and includes an inline pre-paint script that reads localStorage.getItem('ombrora-theme') (falling back to prefers-color-scheme) and sets data-theme on <html>. This data-theme attribute and the ombrora-theme localStorage key are the contract Task 4's ThemeToggle must use.

  • Step 1: Write the failing test

Create tests/seo-theme.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

describe('BaseLayout SEO tags and theming', () => {
  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
  });

  it('sets canonical and hreflang alternates pointing at each other', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');

    expect(en).toContain('rel="canonical" href="https://www.ombrora.com/en/"');
    expect(en).toContain('hreflang="fr" href="https://www.ombrora.com/fr/"');
    expect(fr).toContain('rel="canonical" href="https://www.ombrora.com/fr/"');
    expect(fr).toContain('hreflang="en" href="https://www.ombrora.com/en/"');
  });

  it('uses the locale-specific meta title and description', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');

    expect(en).toContain('<title>Ombrora — Security tracking and file tools</title>');
    expect(fr).toContain('<title>Ombrora — Veille sécurité et outils de fichiers</title>');
  });

  it('includes a pre-paint theme script keyed on ombrora-theme', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    expect(en).toContain("localStorage.getItem('ombrora-theme')");
    expect(en).toContain('prefers-color-scheme: light');
  });

  it('applies the global stylesheet (inlined or linked)', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const hasInlineStyle = /<style>[\s\S]*--accent[\s\S]*<\/style>/.test(en);
    const hasStylesheetLink = /<link rel="stylesheet"[^>]*href="[^"]+\.css"/.test(en);
    expect(hasInlineStyle || hasStylesheetLink).toBe(true);
  });
});

Note: from this task on, tests call npm run build themselves via beforeAll, so npm run test alone is sufficient going forward. Astro inlines small stylesheets into a <style> tag during build rather than emitting an external <link> — the test accepts either form.

Once a second build-triggering test file exists (Task 4 onward), Vitest's default parallel file execution races multiple concurrent astro build runs against the same dist/ directory. Add vitest.config.ts at the repo root with test: { fileParallelism: false } to force test files to run sequentially.

  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/seo-theme.test.ts Expected: FAIL — the built HTML doesn't contain canonical/hreflang tags or the theme script yet.

  • Step 3: Create src/styles/global.css
:root {
  --bg: #ffffff;
  --fg: #14171a;
  --muted: #5b6470;
  --accent: #6d5bff;
  --card-bg: #f4f5f7;
  --border: #e1e4e8;
}

:root[data-theme='dark'] {
  --bg: #0d0f12;
  --fg: #e6e8eb;
  --muted: #9aa3ad;
  --accent: #8f7cff;
  --card-bg: #16191d;
  --border: #262b31;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --bg: #0d0f12;
    --fg: #e6e8eb;
    --muted: #9aa3ad;
    --accent: #8f7cff;
    --card-bg: #16191d;
    --border: #262b31;
  }
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  line-height: 1.5;
}

.site-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid var(--border);
}

.header-actions {
  display: flex;
  gap: 0.75rem;
  align-items: center;
}

.brand {
  font-weight: 700;
  color: var(--fg);
  text-decoration: none;
}

.language-switcher {
  color: var(--muted);
  text-decoration: none;
  font-size: 0.9rem;
}

.theme-toggle {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.25rem 0.6rem;
  cursor: pointer;
  color: var(--fg);
}

main {
  max-width: 960px;
  margin: 0 auto;
  padding: 2rem 1.5rem 4rem;
}

.hero {
  padding: 3rem 0;
}

.hero h1 {
  font-size: clamp(2rem, 4vw, 2.75rem);
  margin: 0 0 0.75rem;
}

.hero p {
  color: var(--muted);
  max-width: 40ch;
}

.services-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 1.25rem;
}

.service-card {
  display: block;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card-bg);
  text-decoration: none;
  color: var(--fg);
}

.service-card__tag {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.75rem;
  color: var(--accent);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.service-card__name {
  margin: 0.4rem 0 0.35rem;
}

.service-card__description {
  color: var(--muted);
  margin: 0;
  font-size: 0.95rem;
}

.site-footer {
  padding: 1.5rem;
  text-align: center;
  color: var(--muted);
  font-size: 0.85rem;
  border-top: 1px solid var(--border);
}
  • Step 4: Create src/layouts/BaseLayout.astro
---
import '../styles/global.css';
import { getDictionary } from '../i18n';
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';

interface Props {
  locale: Locale;
}

const { locale } = Astro.props;
const t = getDictionary(locale);
const target = otherLocale(locale);
const alternatePath = swapLocalePath(Astro.url.pathname, locale, target);
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const alternateURL = new URL(alternatePath, Astro.site);
const enURL = locale === 'en' ? canonicalURL : alternateURL;
const frURL = locale === 'fr' ? canonicalURL : alternateURL;
---
<!doctype html>
<html lang={locale} data-theme="dark">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{t.meta.title}</title>
    <meta name="description" content={t.meta.description} />
    <link rel="canonical" href={canonicalURL} />
    <link rel="alternate" hreflang="en" href={enURL} />
    <link rel="alternate" hreflang="fr" href={frURL} />
    <link rel="alternate" hreflang="x-default" href={enURL} />
    <meta property="og:title" content={t.meta.title} />
    <meta property="og:description" content={t.meta.description} />
    <meta property="og:type" content="website" />
    <meta property="og:url" content={canonicalURL} />
    <script is:inline>
      (function () {
        var stored = localStorage.getItem('ombrora-theme');
        var theme = stored || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
        document.documentElement.setAttribute('data-theme', theme);
      })();
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>
  • Step 5: Update src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';

const locale = 'en';
---
<BaseLayout locale={locale}>
  <h1>Ombrora</h1>
</BaseLayout>
  • Step 6: Update src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';

const locale = 'fr';
---
<BaseLayout locale={locale}>
  <h1>Ombrora</h1>
</BaseLayout>
  • Step 7: Run the test and confirm it passes

Run: npm run test Expected: PASS (all test files).

  • Step 8: Commit
git add src/layouts src/styles src/pages tests/seo-theme.test.ts
git commit -m "feat: add BaseLayout with SEO head tags and theme init script"

Task 4: Header — brand, language switcher, theme toggle

Files:

  • Create: src/components/Header.astro, src/components/LanguageSwitcher.astro, src/components/ThemeToggle.astro
  • Modify: src/pages/en/index.astro, src/pages/fr/index.astro (render <Header locale={locale} /> inside <BaseLayout>)
  • Test: tests/header.test.ts

Interfaces:

  • Consumes: getDictionary, otherLocale, swapLocalePath, Locale from Task 2; the data-theme attribute / ombrora-theme localStorage key contract from Task 3.

  • Produces: Header.astro Props { locale: Locale }, LanguageSwitcher.astro Props { locale: Locale }, ThemeToggle.astro Props { locale: Locale }. ThemeToggle renders a button.theme-toggle that flips data-theme and writes to localStorage['ombrora-theme'] on click.

  • Step 1: Write the failing test

Create tests/header.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

describe('Header, language switcher, theme toggle', () => {
  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
  });

  it('renders the brand link on both locales', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');
    expect(en).toContain('class="brand" href="/en/"');
    expect(fr).toContain('class="brand" href="/fr/"');
  });

  it('language switcher on /en/ links to /fr/ and vice versa', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');
    expect(en).toContain('class="language-switcher" href="/fr/"');
    expect(fr).toContain('class="language-switcher" href="/en/"');
  });

  it('theme toggle button carries localized aria-labels', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');
    expect(en).toContain('aria-label="Switch to dark mode"');
    expect(fr).toContain('aria-label="Passer en mode sombre"');
  });

  it('theme toggle click handler flips data-theme and persists to localStorage', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    expect(en).toContain("localStorage.setItem('ombrora-theme', next)");
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/header.test.ts Expected: FAIL — no header markup exists yet.

  • Step 3: Create src/components/LanguageSwitcher.astro
---
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';

interface Props {
  locale: Locale;
}

const { locale } = Astro.props;
const target = otherLocale(locale);
const href = swapLocalePath(Astro.url.pathname, locale, target);
const label = target === 'en' ? 'English' : 'Français';
---
<a class="language-switcher" href={href} hreflang={target}>{label}</a>
  • Step 4: Create src/components/ThemeToggle.astro
---
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';

interface Props {
  locale: Locale;
}

const { locale } = Astro.props;
const t = getDictionary(locale);
---
<button
  type="button"
  class="theme-toggle"
  data-label-to-dark={t.theme.toggleToDark}
  data-label-to-light={t.theme.toggleToLight}
  aria-label={t.theme.toggleToDark}
>
  🌙
</button>
<script is:inline>
  (function () {
    document.querySelectorAll('.theme-toggle').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var html = document.documentElement;
        var current = html.getAttribute('data-theme');
        var next = current === 'dark' ? 'light' : 'dark';
        html.setAttribute('data-theme', next);
        localStorage.setItem('ombrora-theme', next);
        btn.setAttribute(
          'aria-label',
          next === 'dark' ? btn.dataset.labelToDark : btn.dataset.labelToLight
        );
      });
    });
  })();
</script>
  • Step 5: Create src/components/Header.astro
---
import LanguageSwitcher from './LanguageSwitcher.astro';
import ThemeToggle from './ThemeToggle.astro';
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';

interface Props {
  locale: Locale;
}

const { locale } = Astro.props;
const t = getDictionary(locale);
---
<header class="site-header">
  <a class="brand" href={`/${locale}/`}>{t.nav.brand}</a>
  <div class="header-actions">
    <LanguageSwitcher locale={locale} />
    <ThemeToggle locale={locale} />
  </div>
</header>
  • Step 6: Update src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';

const locale = 'en';
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <h1>Ombrora</h1>
</BaseLayout>
  • Step 7: Update src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';

const locale = 'fr';
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <h1>Ombrora</h1>
</BaseLayout>
  • Step 8: Run the test and confirm it passes

Run: npm run test Expected: PASS (all test files).

  • Step 9: Commit
git add src/components src/pages tests/header.test.ts
git commit -m "feat: add Header with language switcher and theme toggle"

Task 5: Hero and service cards — full page body

Files:

  • Create: src/components/ServiceCard.astro
  • Modify: src/pages/en/index.astro, src/pages/fr/index.astro
  • Test: tests/services.test.ts

Interfaces:

  • Consumes: getDictionary, services, resolveServiceUrl from Task 2; Header from Task 4.

  • Produces: ServiceCard.astro Props { service: Service; locale: Locale }. Pages render the hero copy and a services-grid of ServiceCard per entry in services.

  • Step 1: Write the failing test

Create tests/services.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

describe('hero and service cards', () => {
  let en: string;
  let fr: string;

  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
    en = readFileSync('dist/en/index.html', 'utf-8');
    fr = readFileSync('dist/fr/index.html', 'utf-8');
  });

  it('renders the localized hero copy', () => {
    expect(en).toContain('The Ombrora hub');
    expect(fr).toContain('Le hub des outils Ombrora');
  });

  it('renders the CVE Watch card with a locale-specific link', () => {
    expect(en).toContain('href="https://cve-en.ombrora.com"');
    expect(fr).toContain('href="https://cve-fr.ombrora.com"');
    expect(en).toContain('CVE Watch');
    expect(fr).toContain('Veille CVE');
  });

  it('renders the Convert card with the same link on both locales', () => {
    const enMatches = en.match(/href="https:\/\/convert\.ombrora\.com"/g) ?? [];
    const frMatches = fr.match(/href="https:\/\/convert\.ombrora\.com"/g) ?? [];
    expect(enMatches.length).toBeGreaterThan(0);
    expect(frMatches.length).toBeGreaterThan(0);
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/services.test.ts Expected: FAIL — hero/service markup doesn't exist yet.

  • Step 3: Create src/components/ServiceCard.astro
---
import { resolveServiceUrl } from '../i18n/services';
import type { Service } from '../i18n/services';
import type { Locale } from '../i18n/locale';

interface Props {
  service: Service;
  locale: Locale;
}

const { service, locale } = Astro.props;
const url = resolveServiceUrl(service, locale);
---
<a class="service-card" href={url} target="_blank" rel="noopener noreferrer">
  <span class="service-card__tag">{service.tag[locale]}</span>
  <h3 class="service-card__name">{service.name[locale]}</h3>
  <p class="service-card__description">{service.description[locale]}</p>
</a>
  • Step 4: Update src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';

const locale = 'en';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <main>
    <section class="hero">
      <h1>{t.hero.title}</h1>
      <p>{t.hero.subtitle}</p>
    </section>
    <section class="services">
      <h2>{t.services.heading}</h2>
      <div class="services-grid">
        {services.map((service) => <ServiceCard service={service} locale={locale} />)}
      </div>
    </section>
  </main>
</BaseLayout>
  • Step 5: Update src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';

const locale = 'fr';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <main>
    <section class="hero">
      <h1>{t.hero.title}</h1>
      <p>{t.hero.subtitle}</p>
    </section>
    <section class="services">
      <h2>{t.services.heading}</h2>
      <div class="services-grid">
        {services.map((service) => <ServiceCard service={service} locale={locale} />)}
      </div>
    </section>
  </main>
</BaseLayout>
  • Step 6: Run the test and confirm it passes

Run: npm run test Expected: PASS (all test files).

  • Step 7: Commit
git add src/components/ServiceCard.astro src/pages tests/services.test.ts
git commit -m "feat: add hero section and service cards to the hub page"

Files:

  • Create: src/components/Footer.astro
  • Modify: src/pages/en/index.astro, src/pages/fr/index.astro
  • Test: tests/footer.test.ts

Interfaces:

  • Consumes: getDictionary, Locale from Task 2.

  • Produces: Footer.astro Props { locale: Locale }, rendered as the last child inside <BaseLayout> on both pages.

  • Step 1: Write the failing test

Create tests/footer.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

describe('footer', () => {
  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
  });

  it('renders the current year and brand name on both locales', () => {
    const en = readFileSync('dist/en/index.html', 'utf-8');
    const fr = readFileSync('dist/fr/index.html', 'utf-8');
    const yearPattern = /©\s*\d{4}\s*Ombrora/;
    expect(en).toMatch(yearPattern);
    expect(fr).toMatch(yearPattern);
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/footer.test.ts Expected: FAIL — no footer markup exists yet.

  • Step 3: Create src/components/Footer.astro
---
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';

interface Props {
  locale: Locale;
}

const { locale } = Astro.props;
const t = getDictionary(locale);
const year = new Date().getFullYear();
---
<footer class="site-footer">
  <p>© {year} {t.footer.text}</p>
</footer>
  • Step 4: Update src/pages/en/index.astro (add Footer import and render it after </main>)
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';

const locale = 'en';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <main>
    <section class="hero">
      <h1>{t.hero.title}</h1>
      <p>{t.hero.subtitle}</p>
    </section>
    <section class="services">
      <h2>{t.services.heading}</h2>
      <div class="services-grid">
        {services.map((service) => <ServiceCard service={service} locale={locale} />)}
      </div>
    </section>
  </main>
  <Footer locale={locale} />
</BaseLayout>
  • Step 5: Update src/pages/fr/index.astro (same change, locale = 'fr')
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';

const locale = 'fr';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
  <Header locale={locale} />
  <main>
    <section class="hero">
      <h1>{t.hero.title}</h1>
      <p>{t.hero.subtitle}</p>
    </section>
    <section class="services">
      <h2>{t.services.heading}</h2>
      <div class="services-grid">
        {services.map((service) => <ServiceCard service={service} locale={locale} />)}
      </div>
    </section>
  </main>
  <Footer locale={locale} />
</BaseLayout>
  • Step 6: Run the test and confirm it passes

Run: npm run test Expected: PASS (all test files).

  • Step 7: Commit
git add src/components/Footer.astro src/pages tests/footer.test.ts
git commit -m "feat: add footer to the hub page"

Task 7: Sitemap and robots.txt

Files:

  • Modify: astro.config.mjs (add @astrojs/sitemap integration)
  • Create: public/robots.txt
  • Test: tests/sitemap.test.ts

Interfaces:

  • Consumes: site and i18n.locales already set in astro.config.mjs (Task 1).

  • Produces: dist/sitemap-index.xml and dist/sitemap-0.xml covering both locale routes; dist/robots.txt referencing the sitemap.

  • Step 1: Write the failing test

Create tests/sitemap.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';

describe('sitemap and robots.txt', () => {
  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
  });

  it('generates a sitemap index referencing both locales', () => {
    expect(existsSync('dist/sitemap-index.xml')).toBe(true);
    const sitemap = readFileSync('dist/sitemap-0.xml', 'utf-8');
    expect(sitemap).toContain('https://www.ombrora.com/en/');
    expect(sitemap).toContain('https://www.ombrora.com/fr/');
  });

  it('robots.txt allows all crawlers and points to the sitemap', () => {
    const robots = readFileSync('dist/robots.txt', 'utf-8');
    expect(robots).toContain('User-agent: *');
    expect(robots).toContain('Allow: /');
    expect(robots).toContain('Sitemap: https://www.ombrora.com/sitemap-index.xml');
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/sitemap.test.ts Expected: FAIL — no sitemap or robots.txt exists yet.

  • Step 3: Install @astrojs/sitemap
npm install @astrojs/sitemap
  • Step 4: Update astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://www.ombrora.com',
  i18n: {
    locales: ['en', 'fr'],
    defaultLocale: 'en',
    routing: {
      prefixDefaultLocale: true,
    },
  },
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'en',
        locales: {
          en: 'en',
          fr: 'fr',
        },
      },
    }),
  ],
});
  • Step 5: Create public/robots.txt
User-agent: *
Allow: /

Sitemap: https://www.ombrora.com/sitemap-index.xml
  • Step 6: Run the test and confirm it passes

Run: npm run test Expected: PASS (all test files).

  • Step 7: Commit
git add astro.config.mjs package.json package-lock.json public/robots.txt tests/sitemap.test.ts
git commit -m "feat: add sitemap generation and robots.txt"

Task 8: Root language redirect for o2switch hosting

Files:

  • Create: public/.htaccess
  • Test: tests/htaccess.test.ts

Interfaces:

  • Consumes: nothing.

  • Produces: dist/.htaccess, copied through unchanged by Astro's static build, containing the Apache rewrite rules that redirect the bare / root based on Accept-Language.

  • Step 1: Write the failing test

Create tests/htaccess.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

describe('root language redirect', () => {
  beforeAll(() => {
    execSync('npm run build', { stdio: 'inherit' });
  });

  it('copies .htaccess to dist with the french/english redirect rules', () => {
    const htaccess = readFileSync('dist/.htaccess', 'utf-8');
    expect(htaccess).toContain('RewriteEngine On');
    expect(htaccess).toContain('HTTP:Accept-Language} fr');
    expect(htaccess).toContain('RewriteRule ^$ /fr/ [R=302,L]');
    expect(htaccess).toContain('RewriteRule ^$ /en/ [R=302,L]');
  });
});
  • Step 2: Run the test and confirm it fails

Run: npm run test -- tests/htaccess.test.ts Expected: FAIL — dist/.htaccess does not exist yet.

  • Step 3: Create public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/$
RewriteCond %{HTTP:Accept-Language} fr [NC]
RewriteRule ^$ /fr/ [R=302,L]
RewriteCond %{REQUEST_URI} ^/$
RewriteRule ^$ /en/ [R=302,L]
  • Step 4: Rebuild and run the test to confirm it passes

Run: npm run test Expected: PASS (all test files, full suite green).

  • Step 5: Commit
git add public/.htaccess tests/htaccess.test.ts
git commit -m "feat: add root language redirect for o2switch hosting"