From a29cb5e98440c3041123eb6f917ccd1328072349 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sun, 2 Aug 2026 14:37:37 +0200 Subject: [PATCH] Add implementation plan for Ombrora hub landing page Eight TDD tasks covering scaffold/i18n routing, content dictionaries, BaseLayout/SEO/theming, header/nav, hero/service cards, footer, sitemap/robots.txt, and the o2switch root-redirect .htaccess. --- .../plans/2026-08-02-ombrora-hub-landing.md | 1350 +++++++++++++++++ 1 file changed, 1350 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md diff --git a/docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md b/docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md new file mode 100644 index 0000000..617cf66 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md @@ -0,0 +1,1350 @@ +# 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 ``, 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`): + +```bash +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** + +```bash +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`: + +```astro +--- +const locale = 'en'; +--- + + + + Ombrora + + +

Ombrora — EN

+ + +``` + +Create `src/pages/fr/index.astro`: + +```astro +--- +const locale = 'fr'; +--- + + + + Ombrora + + +

Ombrora — FR

+ + +``` + +- [ ] **Step 5: Configure `site` and `i18n` in `astro.config.mjs`** + +Replace its contents with: + +```js +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** + +```bash +npm install -D vitest +``` + +In `package.json`, add to `"scripts"`: + +```json +"test": "vitest run" +``` + +- [ ] **Step 7: Write the failing test** + +Create `tests/routing.test.ts`: + +```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** + +```bash +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; name: Record; tag: Record; description: Record; }`, `const services: Service[]`, `function resolveServiceUrl(service: Service, locale: Locale): string`. + - These are consumed by Tasks 3–6 (BaseLayout, Header, LanguageSwitcher, ThemeToggle, ServiceCard, Footer, pages). + +- [ ] **Step 1: Write the failing test** + +Create `tests/i18n.test.ts`: + +```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`** + +```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`** + +```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`** + +```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`** + +```ts +import { en } from './en'; +import { fr } from './fr'; +import type { Dictionary } from './en'; +import type { Locale } from './locale'; + +const dictionaries: Record = { en, fr }; + +export function getDictionary(locale: Locale): Dictionary { + return dictionaries[locale]; +} +``` + +- [ ] **Step 7: Create `src/i18n/services.ts`** + +```ts +import type { Locale } from './locale'; + +export interface Service { + id: string; + url: string | Record; + name: Record; + tag: Record; + description: Record; +} + +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** + +```bash +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 `` usage) +- Test: `tests/seo-theme.test.ts` + +**Interfaces:** +- Consumes: `getDictionary`, `Locale`, `otherLocale`, `swapLocalePath` from Task 2. +- Produces: `BaseLayout.astro` with `Props { locale: Locale }` — derives ``/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`: + +```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'); + expect(fr).toContain('Ombrora — Veille sécurité et outils de fichiers'); + }); + + 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('links the global stylesheet', () => { + const en = readFileSync('dist/en/index.html', 'utf-8'); + expect(en).toMatch(/]*href="[^"]+\.css"/); + }); +}); +``` + +Note: from this task on, tests call `npm run build` themselves via `beforeAll`, so `npm run test` alone is sufficient going forward. + +- [ ] **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`** + +```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`** + +```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; +--- + + + + + + {t.meta.title} + + + + + + + + + + + + + + + +``` + +- [ ] **Step 5: Update `src/pages/en/index.astro`** + +```astro +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; + +const locale = 'en'; +--- + +

Ombrora

+
+``` + +- [ ] **Step 6: Update `src/pages/fr/index.astro`** + +```astro +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; + +const locale = 'fr'; +--- + +

Ombrora

+
+``` + +- [ ] **Step 7: Run the test and confirm it passes** + +Run: `npm run test` +Expected: PASS (all test files). + +- [ ] **Step 8: Commit** + +```bash +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 `
` inside ``) +- 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`: + +```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`** + +```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'; +--- +{label} +``` + +- [ ] **Step 4: Create `src/components/ThemeToggle.astro`** + +```astro +--- +import { getDictionary } from '../i18n'; +import type { Locale } from '../i18n/locale'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const t = getDictionary(locale); +--- + + +``` + +- [ ] **Step 5: Create `src/components/Header.astro`** + +```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); +--- + +``` + +- [ ] **Step 6: Update `src/pages/en/index.astro`** + +```astro +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Header from '../../components/Header.astro'; + +const locale = 'en'; +--- + +
+

Ombrora

+ +``` + +- [ ] **Step 7: Update `src/pages/fr/index.astro`** + +```astro +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Header from '../../components/Header.astro'; + +const locale = 'fr'; +--- + +
+

Ombrora

+ +``` + +- [ ] **Step 8: Run the test and confirm it passes** + +Run: `npm run test` +Expected: PASS (all test files). + +- [ ] **Step 9: Commit** + +```bash +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`: + +```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`** + +```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); +--- + + {service.tag[locale]} +

{service.name[locale]}

+

{service.description[locale]}

+
+``` + +- [ ] **Step 4: Update `src/pages/en/index.astro`** + +```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); +--- + +
+
+
+

{t.hero.title}

+

{t.hero.subtitle}

+
+
+

{t.services.heading}

+
+ {services.map((service) => )} +
+
+
+ +``` + +- [ ] **Step 5: Update `src/pages/fr/index.astro`** + +```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); +--- + +
+
+
+

{t.hero.title}

+

{t.hero.subtitle}

+
+
+

{t.services.heading}

+
+ {services.map((service) => )} +
+
+
+ +``` + +- [ ] **Step 6: Run the test and confirm it passes** + +Run: `npm run test` +Expected: PASS (all test files). + +- [ ] **Step 7: Commit** + +```bash +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" +``` + +--- + +### Task 6: Footer + +**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 `` on both pages. + +- [ ] **Step 1: Write the failing test** + +Create `tests/footer.test.ts`: + +```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`** + +```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(); +--- +
+

© {year} {t.footer.text}

+
+``` + +- [ ] **Step 4: Update `src/pages/en/index.astro`** (add `Footer` import and render it after ``) + +```astro +--- +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); +--- + +
+
+
+

{t.hero.title}

+

{t.hero.subtitle}

+
+
+

{t.services.heading}

+
+ {services.map((service) => )} +
+
+
+