# 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 ``, 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 ``. 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('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('applies the global stylesheet (inlined or linked)', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const hasInlineStyle = /