Files
landing/docs/superpowers/plans/2026-08-02-ombrora-hub-landing.md
T
anthony a29cb5e984 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.
2026-08-02 14:37:37 +02:00

1351 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```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';
---
<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`:
```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:
```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<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`:
```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<Locale, Dictionary> = { 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<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**
```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 `<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`:
```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('links the global stylesheet', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toMatch(/<link rel="stylesheet"[^>]*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;
---
<!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`**
```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`**
```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**
```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 `<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`:
```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';
---
<a class="language-switcher" href={href} hreflang={target}>{label}</a>
```
- [ ] **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);
---
<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`**
```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`**
```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`**
```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**
```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);
---
<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`**
```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`**
```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**
```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 `<BaseLayout>` 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();
---
<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>`)
```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);
---
<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'`)
```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 = '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**
```bash
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`:
```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`**
```bash
npm install @astrojs/sitemap
```
- [ ] **Step 4: Update `astro.config.mjs`**
```js
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**
```bash
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`:
```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`**
```apache
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**
```bash
git add public/.htaccess tests/htaccess.test.ts
git commit -m "feat: add root language redirect for o2switch hosting"
```