Canonical/hreflang/OG tags derived from the locale dictionary, plus a pre-paint script applying data-theme from localStorage or system preference.
38 KiB
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/, viarouting: { prefixDefaultLocale: true }— there is no unprefixed default-locale route. - The bare root
/is handled entirely bypublic/.htaccess(Apachemod_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 tolocalStorageunder the exact keyombrora-themewith values"dark"or"light". Theme is applied via adata-themeattribute 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,
hreflangalternates (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 buildthat outputsdist/en/index.htmlanddist/fr/index.html, and nodist/index.html.npm run testruns Vitest.astro.config.mjsexportssite: 'https://www.ombrora.com'andi18n: { locales: ['en', 'fr'], defaultLocale: 'en', routing: { prefixDefaultLocale: true } }— later tasks add to this file'sintegrationsarray but must not change these two settings. -
Step 1: Scaffold a fresh Astro project into a temporary directory
Run from the repo root (C:\Users\tony1\PhpstormProjects\Ombrora-Landing):
npm create astro@latest ombrora-scaffold-tmp -- --template minimal --no-git --yes
This avoids any ambiguity around scaffolding into the current, non-empty directory (it already contains CLAUDE.md, docs/, .git, .idea).
- Step 2: Move the scaffolded files into the repo root
mv ombrora-scaffold-tmp/.[!.]* ombrora-scaffold-tmp/* .
rmdir ombrora-scaffold-tmp
Verify package.json, astro.config.mjs, tsconfig.json, .gitignore, and src/ now exist at the repo root.
- Step 3: Confirm
.gitignoreexcludes build artifacts
Open .gitignore and confirm it contains at least:
node_modules/
dist/
.astro/
Add any missing line.
- Step 4: Remove the default page and add locale placeholder pages
Delete src/pages/index.astro.
Create src/pages/en/index.astro:
---
const locale = 'en';
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>Ombrora</title>
</head>
<body>
<h1>Ombrora — EN</h1>
</body>
</html>
Create src/pages/fr/index.astro:
---
const locale = 'fr';
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>Ombrora</title>
</head>
<body>
<h1>Ombrora — FR</h1>
</body>
</html>
- Step 5: Configure
siteandi18ninastro.config.mjs
Replace its contents with:
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://www.ombrora.com',
i18n: {
locales: ['en', 'fr'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: true,
},
},
});
- Step 6: Add Vitest
npm install -D vitest
In package.json, add to "scripts":
"test": "vitest run"
- Step 7: Write the failing test
Create tests/routing.test.ts:
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
describe('locale routing', () => {
it('builds /en/ and /fr/ pages with correct lang attributes', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('lang="en"');
expect(fr).toContain('lang="fr"');
});
it('does not build an unprefixed root page', () => {
expect(existsSync('dist/index.html')).toBe(false);
});
});
- Step 8: Run the test and confirm it fails
Run: npm run test
Expected: FAIL — dist/en/index.html does not exist yet (no build has run).
- Step 9: Build the site
Run: npm run build
- Step 10: Run the test and confirm it passes
Run: npm run test
Expected: PASS (2 tests).
- Step 11: Commit
git add package.json package-lock.json astro.config.mjs tsconfig.json .gitignore src tests
git commit -m "feat: scaffold Astro project with en/fr i18n routing"
Task 2: i18n content dictionaries and service data
Files:
- Create:
src/i18n/locale.ts,src/i18n/en.ts,src/i18n/fr.ts,src/i18n/index.ts,src/i18n/services.ts - Test:
tests/i18n.test.ts
Interfaces:
-
Consumes: nothing (standalone data module).
-
Produces:
src/i18n/locale.ts:type Locale = 'en' | 'fr',const locales: Locale[],function otherLocale(locale: Locale): Locale,function swapLocalePath(pathname: string, from: Locale, to: Locale): string.src/i18n/en.ts:const en = {...} as const,type Dictionary = typeof en.src/i18n/fr.ts:const fr: Dictionary.src/i18n/index.ts:function getDictionary(locale: Locale): Dictionary.src/i18n/services.ts:interface Service { id: string; url: string | Record<Locale, string>; name: Record<Locale, string>; tag: Record<Locale, string>; description: Record<Locale, string>; },const services: Service[],function resolveServiceUrl(service: Service, locale: Locale): string.- These are consumed by Tasks 3–6 (BaseLayout, Header, LanguageSwitcher, ThemeToggle, ServiceCard, Footer, pages).
-
Step 1: Write the failing test
Create tests/i18n.test.ts:
import { describe, it, expect } from 'vitest';
import { en } from '../src/i18n/en';
import { fr } from '../src/i18n/fr';
import { getDictionary } from '../src/i18n';
import { otherLocale, swapLocalePath, locales } from '../src/i18n/locale';
import { services, resolveServiceUrl } from '../src/i18n/services';
describe('i18n dictionaries', () => {
it('exposes both locales', () => {
expect(locales).toEqual(['en', 'fr']);
});
it('fr and en dictionaries have the same keys', () => {
expect(Object.keys(fr)).toEqual(Object.keys(en));
expect(Object.keys(fr.hero)).toEqual(Object.keys(en.hero));
expect(Object.keys(fr.theme)).toEqual(Object.keys(en.theme));
});
it('getDictionary resolves the right dictionary', () => {
expect(getDictionary('en')).toBe(en);
expect(getDictionary('fr')).toBe(fr);
});
it('otherLocale flips locale', () => {
expect(otherLocale('en')).toBe('fr');
expect(otherLocale('fr')).toBe('en');
});
it('swapLocalePath swaps the locale segment', () => {
expect(swapLocalePath('/en/', 'en', 'fr')).toBe('/fr/');
});
});
describe('services data', () => {
it('resolves a locale-specific url', () => {
const cve = services.find((s) => s.id === 'cve')!;
expect(resolveServiceUrl(cve, 'en')).toBe('https://cve-en.ombrora.com');
expect(resolveServiceUrl(cve, 'fr')).toBe('https://cve-fr.ombrora.com');
});
it('resolves a single-url service the same for both locales', () => {
const convert = services.find((s) => s.id === 'convert')!;
expect(resolveServiceUrl(convert, 'en')).toBe('https://convert.ombrora.com');
expect(resolveServiceUrl(convert, 'fr')).toBe('https://convert.ombrora.com');
});
it('has exactly two services: cve and convert', () => {
expect(services.map((s) => s.id).sort()).toEqual(['convert', 'cve']);
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test
Expected: FAIL — cannot find module ../src/i18n/en (files don't exist yet).
- Step 3: Create
src/i18n/locale.ts
export type Locale = 'en' | 'fr';
export const locales: Locale[] = ['en', 'fr'];
export function otherLocale(locale: Locale): Locale {
return locale === 'en' ? 'fr' : 'en';
}
export function swapLocalePath(pathname: string, from: Locale, to: Locale): string {
return pathname.replace(`/${from}/`, `/${to}/`);
}
- Step 4: Create
src/i18n/en.ts
export const en = {
meta: {
title: 'Ombrora — Security tracking and file tools',
description:
"Ombrora is a hub for a growing set of independent tools: CVE tracking and file conversion.",
},
nav: {
brand: 'Ombrora',
},
hero: {
title: 'The Ombrora hub',
subtitle:
'Small, focused tools for security tracking and everyday file conversion — this page is the map.',
},
services: {
heading: 'Services',
},
theme: {
toggleToDark: 'Switch to dark mode',
toggleToLight: 'Switch to light mode',
},
footer: {
text: 'Ombrora',
},
} as const;
export type Dictionary = typeof en;
- Step 5: Create
src/i18n/fr.ts
import type { Dictionary } from './en';
export const fr: Dictionary = {
meta: {
title: 'Ombrora — Veille sécurité et outils de fichiers',
description:
'Ombrora est un hub regroupant des outils indépendants : veille CVE et conversion de fichiers.',
},
nav: {
brand: 'Ombrora',
},
hero: {
title: 'Le hub des outils Ombrora',
subtitle:
'Des outils simples et ciblés pour la veille sécurité et la conversion de fichiers au quotidien — cette page en est la carte.',
},
services: {
heading: 'Services',
},
theme: {
toggleToDark: 'Passer en mode sombre',
toggleToLight: 'Passer en mode clair',
},
footer: {
text: 'Ombrora',
},
};
- Step 6: Create
src/i18n/index.ts
import { en } from './en';
import { fr } from './fr';
import type { Dictionary } from './en';
import type { Locale } from './locale';
const dictionaries: Record<Locale, Dictionary> = { en, fr };
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale];
}
- Step 7: Create
src/i18n/services.ts
import type { Locale } from './locale';
export interface Service {
id: string;
url: string | Record<Locale, string>;
name: Record<Locale, string>;
tag: Record<Locale, string>;
description: Record<Locale, string>;
}
export const services: Service[] = [
{
id: 'cve',
url: {
en: 'https://cve-en.ombrora.com',
fr: 'https://cve-fr.ombrora.com',
},
name: { en: 'CVE Watch', fr: 'Veille CVE' },
tag: { en: 'Security blog', fr: 'Blog sécurité' },
description: {
en: 'Daily-tracked CVE disclosures, explained.',
fr: 'Les CVE publiées, suivies et expliquées au quotidien.',
},
},
{
id: 'convert',
url: 'https://convert.ombrora.com',
name: { en: 'Convert', fr: 'Convert' },
tag: { en: 'File converter', fr: 'Convertisseur de fichiers' },
description: {
en: 'Convert documents, audio, images, video, and archives.',
fr: 'Convertissez documents, audio, images, vidéos et archives.',
},
},
];
export function resolveServiceUrl(service: Service, locale: Locale): string {
return typeof service.url === 'string' ? service.url : service.url[locale];
}
- Step 8: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all tests in tests/i18n.test.ts and tests/routing.test.ts).
- Step 9: Commit
git add src/i18n tests/i18n.test.ts
git commit -m "feat: add i18n dictionaries and service data"
Task 3: BaseLayout — SEO head tags, global theming CSS, pre-paint theme script
Files:
- Create:
src/layouts/BaseLayout.astro,src/styles/global.css - Modify:
src/pages/en/index.astro,src/pages/fr/index.astro(replace placeholder markup with<BaseLayout>usage) - Test:
tests/seo-theme.test.ts
Interfaces:
-
Consumes:
getDictionary,Locale,otherLocale,swapLocalePathfrom Task 2. -
Produces:
BaseLayout.astrowithProps { locale: Locale }— derives<title>/description fromgetDictionary(locale).metainternally (callers only passlocale). Sets<html lang>, canonical, hreflang alternates, OG tags, linksglobal.css, and includes an inline pre-paint script that readslocalStorage.getItem('ombrora-theme')(falling back toprefers-color-scheme) and setsdata-themeon<html>. Thisdata-themeattribute and theombrora-themelocalStorage key are the contract Task 4'sThemeTogglemust use. -
Step 1: Write the failing test
Create tests/seo-theme.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('BaseLayout SEO tags and theming', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('sets canonical and hreflang alternates pointing at each other', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('rel="canonical" href="https://www.ombrora.com/en/"');
expect(en).toContain('hreflang="fr" href="https://www.ombrora.com/fr/"');
expect(fr).toContain('rel="canonical" href="https://www.ombrora.com/fr/"');
expect(fr).toContain('hreflang="en" href="https://www.ombrora.com/en/"');
});
it('uses the locale-specific meta title and description', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('<title>Ombrora — Security tracking and file tools</title>');
expect(fr).toContain('<title>Ombrora — Veille sécurité et outils de fichiers</title>');
});
it('includes a pre-paint theme script keyed on ombrora-theme', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toContain("localStorage.getItem('ombrora-theme')");
expect(en).toContain('prefers-color-scheme: light');
});
it('applies the global stylesheet (inlined or linked)', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const hasInlineStyle = /<style>[\s\S]*--accent[\s\S]*<\/style>/.test(en);
const hasStylesheetLink = /<link rel="stylesheet"[^>]*href="[^"]+\.css"/.test(en);
expect(hasInlineStyle || hasStylesheetLink).toBe(true);
});
});
Note: from this task on, tests call npm run build themselves via beforeAll, so npm run test alone is sufficient going forward. Astro inlines small stylesheets into a <style> tag during build rather than emitting an external <link> — the test accepts either form.
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/seo-theme.test.ts
Expected: FAIL — the built HTML doesn't contain canonical/hreflang tags or the theme script yet.
- Step 3: Create
src/styles/global.css
:root {
--bg: #ffffff;
--fg: #14171a;
--muted: #5b6470;
--accent: #6d5bff;
--card-bg: #f4f5f7;
--border: #e1e4e8;
}
:root[data-theme='dark'] {
--bg: #0d0f12;
--fg: #e6e8eb;
--muted: #9aa3ad;
--accent: #8f7cff;
--card-bg: #16191d;
--border: #262b31;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #0d0f12;
--fg: #e6e8eb;
--muted: #9aa3ad;
--accent: #8f7cff;
--card-bg: #16191d;
--border: #262b31;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.5;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.brand {
font-weight: 700;
color: var(--fg);
text-decoration: none;
}
.language-switcher {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem 0.6rem;
cursor: pointer;
color: var(--fg);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.hero {
padding: 3rem 0;
}
.hero h1 {
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 0.75rem;
}
.hero p {
color: var(--muted);
max-width: 40ch;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.service-card {
display: block;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
text-decoration: none;
color: var(--fg);
}
.service-card__tag {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.service-card__name {
margin: 0.4rem 0 0.35rem;
}
.service-card__description {
color: var(--muted);
margin: 0;
font-size: 0.95rem;
}
.site-footer {
padding: 1.5rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
}
- Step 4: Create
src/layouts/BaseLayout.astro
---
import '../styles/global.css';
import { getDictionary } from '../i18n';
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
const target = otherLocale(locale);
const alternatePath = swapLocalePath(Astro.url.pathname, locale, target);
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const alternateURL = new URL(alternatePath, Astro.site);
const enURL = locale === 'en' ? canonicalURL : alternateURL;
const frURL = locale === 'fr' ? canonicalURL : alternateURL;
---
<!doctype html>
<html lang={locale} data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{t.meta.title}</title>
<meta name="description" content={t.meta.description} />
<link rel="canonical" href={canonicalURL} />
<link rel="alternate" hreflang="en" href={enURL} />
<link rel="alternate" hreflang="fr" href={frURL} />
<link rel="alternate" hreflang="x-default" href={enURL} />
<meta property="og:title" content={t.meta.title} />
<meta property="og:description" content={t.meta.description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<script is:inline>
(function () {
var stored = localStorage.getItem('ombrora-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<slot />
</body>
</html>
- Step 5: Update
src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const locale = 'en';
---
<BaseLayout locale={locale}>
<h1>Ombrora</h1>
</BaseLayout>
- Step 6: Update
src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const locale = 'fr';
---
<BaseLayout locale={locale}>
<h1>Ombrora</h1>
</BaseLayout>
- Step 7: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all test files).
- Step 8: Commit
git add src/layouts src/styles src/pages tests/seo-theme.test.ts
git commit -m "feat: add BaseLayout with SEO head tags and theme init script"
Task 4: Header — brand, language switcher, theme toggle
Files:
- Create:
src/components/Header.astro,src/components/LanguageSwitcher.astro,src/components/ThemeToggle.astro - Modify:
src/pages/en/index.astro,src/pages/fr/index.astro(render<Header locale={locale} />inside<BaseLayout>) - Test:
tests/header.test.ts
Interfaces:
-
Consumes:
getDictionary,otherLocale,swapLocalePath,Localefrom Task 2; thedata-themeattribute /ombrora-themelocalStorage key contract from Task 3. -
Produces:
Header.astroProps { locale: Locale },LanguageSwitcher.astroProps { locale: Locale },ThemeToggle.astroProps { locale: Locale }.ThemeTogglerenders abutton.theme-togglethat flipsdata-themeand writes tolocalStorage['ombrora-theme']on click. -
Step 1: Write the failing test
Create tests/header.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('Header, language switcher, theme toggle', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('renders the brand link on both locales', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('class="brand" href="/en/"');
expect(fr).toContain('class="brand" href="/fr/"');
});
it('language switcher on /en/ links to /fr/ and vice versa', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('class="language-switcher" href="/fr/"');
expect(fr).toContain('class="language-switcher" href="/en/"');
});
it('theme toggle button carries localized aria-labels', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('aria-label="Switch to dark mode"');
expect(fr).toContain('aria-label="Passer en mode sombre"');
});
it('theme toggle click handler flips data-theme and persists to localStorage', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toContain("localStorage.setItem('ombrora-theme', next)");
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/header.test.ts
Expected: FAIL — no header markup exists yet.
- Step 3: Create
src/components/LanguageSwitcher.astro
---
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const target = otherLocale(locale);
const href = swapLocalePath(Astro.url.pathname, locale, target);
const label = target === 'en' ? 'English' : 'Français';
---
<a class="language-switcher" href={href} hreflang={target}>{label}</a>
- Step 4: Create
src/components/ThemeToggle.astro
---
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
---
<button
type="button"
class="theme-toggle"
data-label-to-dark={t.theme.toggleToDark}
data-label-to-light={t.theme.toggleToLight}
aria-label={t.theme.toggleToDark}
>
🌙
</button>
<script is:inline>
(function () {
document.querySelectorAll('.theme-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var html = document.documentElement;
var current = html.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('ombrora-theme', next);
btn.setAttribute(
'aria-label',
next === 'dark' ? btn.dataset.labelToDark : btn.dataset.labelToLight
);
});
});
})();
</script>
- Step 5: Create
src/components/Header.astro
---
import LanguageSwitcher from './LanguageSwitcher.astro';
import ThemeToggle from './ThemeToggle.astro';
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
---
<header class="site-header">
<a class="brand" href={`/${locale}/`}>{t.nav.brand}</a>
<div class="header-actions">
<LanguageSwitcher locale={locale} />
<ThemeToggle locale={locale} />
</div>
</header>
- Step 6: Update
src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
const locale = 'en';
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<h1>Ombrora</h1>
</BaseLayout>
- Step 7: Update
src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
const locale = 'fr';
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<h1>Ombrora</h1>
</BaseLayout>
- Step 8: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all test files).
- Step 9: Commit
git add src/components src/pages tests/header.test.ts
git commit -m "feat: add Header with language switcher and theme toggle"
Task 5: Hero and service cards — full page body
Files:
- Create:
src/components/ServiceCard.astro - Modify:
src/pages/en/index.astro,src/pages/fr/index.astro - Test:
tests/services.test.ts
Interfaces:
-
Consumes:
getDictionary,services,resolveServiceUrlfrom Task 2;Headerfrom Task 4. -
Produces:
ServiceCard.astroProps { service: Service; locale: Locale }. Pages render the hero copy and aservices-gridofServiceCardper entry inservices. -
Step 1: Write the failing test
Create tests/services.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('hero and service cards', () => {
let en: string;
let fr: string;
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
en = readFileSync('dist/en/index.html', 'utf-8');
fr = readFileSync('dist/fr/index.html', 'utf-8');
});
it('renders the localized hero copy', () => {
expect(en).toContain('The Ombrora hub');
expect(fr).toContain('Le hub des outils Ombrora');
});
it('renders the CVE Watch card with a locale-specific link', () => {
expect(en).toContain('href="https://cve-en.ombrora.com"');
expect(fr).toContain('href="https://cve-fr.ombrora.com"');
expect(en).toContain('CVE Watch');
expect(fr).toContain('Veille CVE');
});
it('renders the Convert card with the same link on both locales', () => {
const enMatches = en.match(/href="https:\/\/convert\.ombrora\.com"/g) ?? [];
const frMatches = fr.match(/href="https:\/\/convert\.ombrora\.com"/g) ?? [];
expect(enMatches.length).toBeGreaterThan(0);
expect(frMatches.length).toBeGreaterThan(0);
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/services.test.ts
Expected: FAIL — hero/service markup doesn't exist yet.
- Step 3: Create
src/components/ServiceCard.astro
---
import { resolveServiceUrl } from '../i18n/services';
import type { Service } from '../i18n/services';
import type { Locale } from '../i18n/locale';
interface Props {
service: Service;
locale: Locale;
}
const { service, locale } = Astro.props;
const url = resolveServiceUrl(service, locale);
---
<a class="service-card" href={url} target="_blank" rel="noopener noreferrer">
<span class="service-card__tag">{service.tag[locale]}</span>
<h3 class="service-card__name">{service.name[locale]}</h3>
<p class="service-card__description">{service.description[locale]}</p>
</a>
- Step 4: Update
src/pages/en/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';
const locale = 'en';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<main>
<section class="hero">
<h1>{t.hero.title}</h1>
<p>{t.hero.subtitle}</p>
</section>
<section class="services">
<h2>{t.services.heading}</h2>
<div class="services-grid">
{services.map((service) => <ServiceCard service={service} locale={locale} />)}
</div>
</section>
</main>
</BaseLayout>
- Step 5: Update
src/pages/fr/index.astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';
const locale = 'fr';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<main>
<section class="hero">
<h1>{t.hero.title}</h1>
<p>{t.hero.subtitle}</p>
</section>
<section class="services">
<h2>{t.services.heading}</h2>
<div class="services-grid">
{services.map((service) => <ServiceCard service={service} locale={locale} />)}
</div>
</section>
</main>
</BaseLayout>
- Step 6: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all test files).
- Step 7: Commit
git add src/components/ServiceCard.astro src/pages tests/services.test.ts
git commit -m "feat: add hero section and service cards to the hub page"
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,Localefrom Task 2. -
Produces:
Footer.astroProps { locale: Locale }, rendered as the last child inside<BaseLayout>on both pages. -
Step 1: Write the failing test
Create tests/footer.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('footer', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('renders the current year and brand name on both locales', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
const yearPattern = /©\s*\d{4}\s*Ombrora/;
expect(en).toMatch(yearPattern);
expect(fr).toMatch(yearPattern);
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/footer.test.ts
Expected: FAIL — no footer markup exists yet.
- Step 3: Create
src/components/Footer.astro
---
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
const year = new Date().getFullYear();
---
<footer class="site-footer">
<p>© {year} {t.footer.text}</p>
</footer>
- Step 4: Update
src/pages/en/index.astro(addFooterimport and render it after</main>)
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';
const locale = 'en';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<main>
<section class="hero">
<h1>{t.hero.title}</h1>
<p>{t.hero.subtitle}</p>
</section>
<section class="services">
<h2>{t.services.heading}</h2>
<div class="services-grid">
{services.map((service) => <ServiceCard service={service} locale={locale} />)}
</div>
</section>
</main>
<Footer locale={locale} />
</BaseLayout>
- Step 5: Update
src/pages/fr/index.astro(same change,locale = 'fr')
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import { getDictionary } from '../../i18n';
import { services } from '../../i18n/services';
const locale = 'fr';
const t = getDictionary(locale);
---
<BaseLayout locale={locale}>
<Header locale={locale} />
<main>
<section class="hero">
<h1>{t.hero.title}</h1>
<p>{t.hero.subtitle}</p>
</section>
<section class="services">
<h2>{t.services.heading}</h2>
<div class="services-grid">
{services.map((service) => <ServiceCard service={service} locale={locale} />)}
</div>
</section>
</main>
<Footer locale={locale} />
</BaseLayout>
- Step 6: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all test files).
- Step 7: Commit
git add src/components/Footer.astro src/pages tests/footer.test.ts
git commit -m "feat: add footer to the hub page"
Task 7: Sitemap and robots.txt
Files:
- Modify:
astro.config.mjs(add@astrojs/sitemapintegration) - Create:
public/robots.txt - Test:
tests/sitemap.test.ts
Interfaces:
-
Consumes:
siteandi18n.localesalready set inastro.config.mjs(Task 1). -
Produces:
dist/sitemap-index.xmlanddist/sitemap-0.xmlcovering both locale routes;dist/robots.txtreferencing the sitemap. -
Step 1: Write the failing test
Create tests/sitemap.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
describe('sitemap and robots.txt', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('generates a sitemap index referencing both locales', () => {
expect(existsSync('dist/sitemap-index.xml')).toBe(true);
const sitemap = readFileSync('dist/sitemap-0.xml', 'utf-8');
expect(sitemap).toContain('https://www.ombrora.com/en/');
expect(sitemap).toContain('https://www.ombrora.com/fr/');
});
it('robots.txt allows all crawlers and points to the sitemap', () => {
const robots = readFileSync('dist/robots.txt', 'utf-8');
expect(robots).toContain('User-agent: *');
expect(robots).toContain('Allow: /');
expect(robots).toContain('Sitemap: https://www.ombrora.com/sitemap-index.xml');
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/sitemap.test.ts
Expected: FAIL — no sitemap or robots.txt exists yet.
- Step 3: Install
@astrojs/sitemap
npm install @astrojs/sitemap
- Step 4: Update
astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://www.ombrora.com',
i18n: {
locales: ['en', 'fr'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: true,
},
},
integrations: [
sitemap({
i18n: {
defaultLocale: 'en',
locales: {
en: 'en',
fr: 'fr',
},
},
}),
],
});
- Step 5: Create
public/robots.txt
User-agent: *
Allow: /
Sitemap: https://www.ombrora.com/sitemap-index.xml
- Step 6: Run the test and confirm it passes
Run: npm run test
Expected: PASS (all test files).
- Step 7: Commit
git add astro.config.mjs package.json package-lock.json public/robots.txt tests/sitemap.test.ts
git commit -m "feat: add sitemap generation and robots.txt"
Task 8: Root language redirect for o2switch hosting
Files:
- Create:
public/.htaccess - Test:
tests/htaccess.test.ts
Interfaces:
-
Consumes: nothing.
-
Produces:
dist/.htaccess, copied through unchanged by Astro's static build, containing the Apache rewrite rules that redirect the bare/root based onAccept-Language. -
Step 1: Write the failing test
Create tests/htaccess.test.ts:
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('root language redirect', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('copies .htaccess to dist with the french/english redirect rules', () => {
const htaccess = readFileSync('dist/.htaccess', 'utf-8');
expect(htaccess).toContain('RewriteEngine On');
expect(htaccess).toContain('HTTP:Accept-Language} fr');
expect(htaccess).toContain('RewriteRule ^$ /fr/ [R=302,L]');
expect(htaccess).toContain('RewriteRule ^$ /en/ [R=302,L]');
});
});
- Step 2: Run the test and confirm it fails
Run: npm run test -- tests/htaccess.test.ts
Expected: FAIL — dist/.htaccess does not exist yet.
- Step 3: Create
public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/$
RewriteCond %{HTTP:Accept-Language} fr [NC]
RewriteRule ^$ /fr/ [R=302,L]
RewriteCond %{REQUEST_URI} ^/$
RewriteRule ^$ /en/ [R=302,L]
- Step 4: Rebuild and run the test to confirm it passes
Run: npm run test
Expected: PASS (all test files, full suite green).
- Step 5: Commit
git add public/.htaccess tests/htaccess.test.ts
git commit -m "feat: add root language redirect for o2switch hosting"