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.
1351 lines
38 KiB
Markdown
1351 lines
38 KiB
Markdown
# 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 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<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"
|
||
```
|