Compare commits

...
10 Commits
Author SHA1 Message Date
anthony 14c52f5e71 add posthog 2026-08-11 09:08:00 +02:00
anthony fdd19ce874 feat: add root language redirect for o2switch hosting 2026-08-02 16:02:17 +02:00
anthony f85488fe2b feat: add sitemap generation and robots.txt 2026-08-02 16:01:21 +02:00
anthony eb9db2afff feat: add footer to the hub page 2026-08-02 16:00:04 +02:00
anthony 96ec05f649 feat: add hero section and service cards to the hub page
CVE Watch card resolves its href per-locale (cve-en/cve-fr), Convert
resolves to the same URL on both locales.
2026-08-02 15:58:44 +02:00
anthony 86049dd8b5 feat: add Header with language switcher and theme toggle
Also disables Vitest file parallelism since concurrent astro builds
across test files raced on the shared dist/ output.
2026-08-02 15:57:35 +02:00
anthony 1d65bb7aa8 feat: add BaseLayout with SEO head tags and theme init script
Canonical/hreflang/OG tags derived from the locale dictionary, plus a
pre-paint script applying data-theme from localStorage or system
preference.
2026-08-02 15:54:42 +02:00
anthony 2606508713 feat: add i18n dictionaries and service data
Locale-aware EN/FR string dictionaries and the two-service data model
(CVE Watch with per-locale URLs, Convert with a single URL).
2026-08-02 15:51:45 +02:00
anthony e9d34f9d13 feat: scaffold Astro project with en/fr i18n routing
Sets output to static (Astro default) with path-based i18n routing
(/en/, /fr/) and no unprefixed default-locale route, per the hub
landing page design spec.
2026-08-02 15:50:28 +02:00
anthony a29cb5e984 Add implementation plan for Ombrora hub landing page
Eight TDD tasks covering scaffold/i18n routing, content dictionaries,
BaseLayout/SEO/theming, header/nav, hero/service cards, footer,
sitemap/robots.txt, and the o2switch root-redirect .htaccess.
2026-08-02 14:37:37 +02:00
38 changed files with 7009 additions and 97 deletions
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
+11 -97
View File
@@ -6,102 +6,16 @@
- No emojis or em-dashes.
- Do not guess APIs, versions, flags, commit SHAs, or package names. Verify by reading code or docs before asserting.
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
## Development
- When starting the dev server, use background mode: `astro dev --background`
- Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
## Documentation
Full documentation: https://docs.astro.build
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
## Local environment / running tests
- `.env` holds **production** credentials (o2switch host, real DB name/password). Never load it for local runs or tests.
- `.env.local` holds the local dev DB credentials (`DB_HOST=127.0.0.1`, `DB_USER=convert_user`, `DB_NAME=file_converter`, `DB_PASSWORD=change_me`). This is what local testing should use.
- `src/config.js` uses `dotenv/config`, which only loads `.env` and does not override variables already present in `process.env`. There's no vitest/dotenv wiring that picks up `.env.local` automatically.
- To run tests locally without touching prod config, pass the `.env.local` values as inline env vars so they take precedence before `dotenv/config` runs, e.g.:
```
DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run
```
- A local MariaDB is expected to already be running on `127.0.0.1:3306` with the `file_converter` DB and `convert_user` credentials seeded.
- Known pre-existing failures unrelated to any fix: `test/cleanup.test.js` ("deletes an expired pending job...") and `test/jobs/jobRepository.test.js` ("finds expired jobs and allows deleting them") — both fail on `main` independent of other changes (looks like a clock/timezone mismatch around `expiresAt` comparisons, not yet root-caused). Don't assume a change caused these; verify against `main` first if they show up again.
### Manual end-to-end testing (starting server.js/worker.js yourself)
- This dev machine typically already has its own long-running `node src/server.js` / `node src/worker.js` (and sometimes `npm run dev` for the frontend) processes started outside the session, from before any given conversation begins. Before manually verifying a change end-to-end, check for them first: `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine"`.
- If a pre-existing worker is running, it loaded `src/converters/*.js` at its own start time and will keep running that in-memory code until restarted — it does not pick up edits made later in the session. If you start a *second*, freshly-spawned worker to test new converter code, both workers poll the same DB and race for pending jobs; if the stale one wins, the job fails with a misleading error (e.g. "No converter registered for X -> Y" for a pair that was, in fact, just added) even though the new code is correct. Don't conclude the new code is broken from a failure like this without first checking whether an older worker process grabbed the job.
- Safe options once you notice this: only kill/clean up processes you started yourself in the session (identifiable by matching the exact command line you launched); never kill or restart the user's pre-existing `server.js`/`worker.js`/`npm run dev` instances without asking, since that's their running environment. If you want a real end-to-end confirmation, tell the user their existing server/worker needs restarting to pick up the change — don't restart it for them silently.
- `curl -F "files=@/tmp/somefile"` fails with exit code 26 ("Failed to read local file") in this Git Bash environment when the path is under `/tmp`. Write the fixture under the project directory instead (e.g. `./scratch-test.md`) and reference it with a relative path in the `-F` flag; delete it afterward.
## Deployment (o2switch)
- o2switch is shared hosting: no compiler toolchain, no root access. Any dependency with a native/binary component must ship as a precompiled binary — it cannot be built from source on the server.
- `sharp` (libvips) and `puppeteer` (bundled Chromium) are the current binary dependencies in `package.json`. Install them so npm fetches the prebuilt binary for the target platform/arch rather than triggering a source build.
- Before adding any new dependency with native bindings, confirm it publishes prebuilt binaries for o2switch's platform/arch — otherwise it will fail to install or run there.
- **The o2switch nodevenv/Passenger setup ("Setup Node.js App" in cPanel) only supports a single `package.json`/`node_modules` for the whole registered app — not one per subfolder.** Verified by directly debugging a failed `frontend/` build: `npm install --prefix frontend --include=dev` (root's own `build` script) and even a plain `npm install` run with `cd frontend` first (confirmed via `pwd` to genuinely be inside `frontend/`) both completed "successfully" (correct, unmodified `frontend/package-lock.json`, real `resolved` entries for every package) yet never created a `frontend/node_modules` directory on the server at all. Meanwhile `vite`/`@vitejs/plugin-react` (already present as root devDependencies) resolved fine during the build — only packages that exist *exclusively* in `frontend/package.json` (`react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react`) failed to resolve, with Vite/Rolldown erroring `Rolldown failed to resolve import "react-router-dom"`.
- **Fix (applied):** every runtime package `frontend/src/**` imports must also be listed in the **root** `package.json`'s `dependencies` (not just `frontend/package.json`'s) — `react`/`react-dom` already were; `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` were added there too. Root's single `node_modules` is an ancestor directory of `frontend/src/`, so Node/Vite's normal upward `node_modules` resolution walk finds them there even with no `frontend/node_modules` on the server.
- `frontend/package.json` still declares the same packages in its own `dependencies` — that's intentional, not stale duplication. It's what makes local dev (`npm run dev` inside `frontend/`, which gets a real, normal `frontend/node_modules` on a dev machine) work independently of this server-only constraint. When adding a new frontend runtime dependency, add it to **both** `package.json` files (frontend's own, for local dev; root's, for the o2switch build) and run `npm install` in both places to keep both lockfiles in sync.
- `frontend/package.json`'s `devDependencies` (`vite`, `@vitejs/plugin-react`, `oxlint`, `@types/react*`) do **not** need mirroring to root — only the ones already there (`vite`, `@vitejs/plugin-react`) are actually required for the production build to run at all; `oxlint`/`@types/*` are dev-only tooling never invoked during `npm run build`.
## Database schema & migrations
- `prisma/schema.prisma` is the source of truth for the `conversion_jobs` schema; `prisma/migrations/` is its version history. `db/schema.sql` no longer exists.
- Prisma CLI commands (`prisma migrate dev`, `prisma migrate deploy`, `prisma generate`) need `DATABASE_URL` in their own process environment, separate from the app. Compute it from the same `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` values used for local tests, via `node scripts/printDatabaseUrl.js` — never write `DATABASE_URL` into `.env` or `.env.local`.
- **`prisma migrate dev` does NOT work against the local dev DB** — verified by actually running it (`DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate dev --name test_shadow_db_probe`). It needs to create/drop a temporary shadow database to detect schema drift, and the local `convert_user` does not have `CREATE DATABASE`/`DROP DATABASE` privileges, so it fails immediately with:
```
Error: P3014
Prisma Migrate could not create the shadow database. Please make sure the database user has permission to create databases.
Original error: Error code: P1010
User was denied access on the database `prisma_migrate_shadow_db_...`
```
No migration folder is created when it fails this way (it errors before writing anything), so nothing needs cleaning up afterward. Do not use `migrate dev` in this project until `convert_user`'s privileges change.
- To create a new migration locally after editing `prisma/schema.prisma`, use the shadow-database-free fallback instead. Note this is **not** the same pattern Task 3 used for the `0_init` baseline — Task 3 used `--from-empty` (diffing against a blank schema), which is a different mode that also happens not to need a shadow database, but is not applicable here since the local DB is not empty. The verified fallback for an already-baselined DB is `--from-schema-datasource` (introspects the live DB's actual current structure — a real DB connection, but not a shadow database) diffed against `--to-schema-datamodel` (reads the target state straight from a schema file, no DB connection at all):
```
DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage \
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate diff \
--from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script > /tmp/migration.sql
```
This was verified twice against the local dev DB: with `prisma/schema.prisma` unchanged it printed `-- This is an empty migration.` (no shadow database requested, no error); with a throwaway copy of the schema (an extra `notes String? @db.Text` field on `ConversionJob`, passed as `--to-schema-datamodel <path-to-throwaway-copy>`) it printed a correct `ALTER TABLE conversion_jobs ADD COLUMN notes TEXT NULL;` — again with no shadow database involved. The earlier documented fallback here (`--from-migrations prisma/migrations`) was wrong: `--from-migrations` internally requires `--shadow-database-url` too, so it fails for the same P3014 reason `migrate dev` does; it was never actually tested before being written down.
Write the generated SQL to a path under the OS temp directory (e.g. `/tmp/migration.sql`), never to a file in the repo (an untracked `migration.sql` in the repo root is easy to accidentally commit). Review it, then manually create the next `prisma/migrations/<timestamp>_<description>/migration.sql` folder with that content, and mark it applied without executing it (since you'll apply it for real via `migrate deploy` or by hand):
```
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate resolve --applied <timestamp>_<description>
```
Delete the temp SQL file once it's been copied into the migration folder.
- To apply pending migrations in production: with the real `DB_*` values loaded from `.env`, run `DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate deploy` before starting the app.
- **ONE-TIME step, required before the very first `migrate deploy` against any environment whose `conversion_jobs` table predates Prisma** (this includes o2switch production, which still has the table created by hand from the now-deleted `db/schema.sql`, but no `_prisma_migrations` tracking table): do **not** run `migrate deploy` first. Instead, baseline that environment exactly like Task 3 did locally:
```
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate resolve --applied 0_init
```
This tells Prisma that `0_init` is already applied (the table already exists) without trying to `CREATE TABLE` it again. Run this once, ever, per environment — after that, `migrate deploy` is the correct command for all subsequent deployments to that environment. Running `migrate deploy` first (without this baseline step) against such an environment will fail with a MySQL "table already exists" error and leave Prisma's migration history in a failed state requiring manual recovery.
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
+43
View File
@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
+24
View File
@@ -0,0 +1,24 @@
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',
},
},
}),
],
});
File diff suppressed because it is too large Load Diff
+4769
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ombrora-landing",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"test": "vitest run"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.1.6"
},
"devDependencies": {
"vitest": "^4.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/$
RewriteCond %{HTTP:Accept-Language} fr [NC]
RewriteRule ^$ /fr/ [R=302,L]
RewriteCond %{REQUEST_URI} ^/$
RewriteRule ^$ /en/ [R=302,L]
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.ombrora.com/sitemap-index.xml
+15
View File
@@ -0,0 +1,15 @@
---
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>
+20
View File
@@ -0,0 +1,20 @@
---
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>
+14
View File
@@ -0,0 +1,14 @@
---
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>
+18
View File
@@ -0,0 +1,18 @@
---
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>
+37
View File
@@ -0,0 +1,37 @@
---
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>
+27
View File
@@ -0,0 +1,27 @@
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;
+27
View File
@@ -0,0 +1,27 @@
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',
},
};
+10
View File
@@ -0,0 +1,10 @@
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];
}
+11
View File
@@ -0,0 +1,11 @@
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}/`);
}
+39
View File
@@ -0,0 +1,39 @@
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];
}
+55
View File
@@ -0,0 +1,55 @@
---
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>
<script>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="Ji Yi init fn mn Hr pn bn cn capture calculateEventProperties Sn register register_once register_for_session unregister unregister_for_session Tn getFeatureFlag getFeatureFlagPayload getFeatureFlagResult getAllFeatureFlags isFeatureEnabled reloadFeatureFlags updateFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey cancelPendingSurvey canRenderSurvey canRenderSurveyAsync Mn identify setPersonProperties unsetPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset shutdown setIdentity clearIdentity get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException addExceptionStep captureLog startExceptionAutocapture stopExceptionAutocapture loadToolbar get_property getSessionProperty Cn xn createPersonProfile setInternalOrTestUser In hn Pn opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing debug Ur Rt getPageViewId captureTraceFeedback captureTraceMetric an".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('phc_vSCpFEHuGgHiqRNEHZ5agwNxQdSgY4gNTtht8kiAjuMG', {
cookieless_mode: "always",
api_host: 'https://eu.i.posthog.com',
defaults: '2026-05-30',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
})
</script>
</head>
<body>
<slot />
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
---
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>
+27
View File
@@ -0,0 +1,27 @@
---
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>
+137
View File
@@ -0,0 +1,137 @@
: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);
}
+17
View File
@@ -0,0 +1,17 @@
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);
});
});
+35
View File
@@ -0,0 +1,35 @@
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)");
});
});
+17
View File
@@ -0,0 +1,17 @@
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]');
});
});
+50
View File
@@ -0,0 +1,50 @@
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']);
});
});
+15
View File
@@ -0,0 +1,15 @@
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);
});
});
+40
View File
@@ -0,0 +1,40 @@
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);
});
});
+33
View File
@@ -0,0 +1,33 @@
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);
});
});
+23
View File
@@ -0,0 +1,23 @@
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');
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
fileParallelism: false,
},
});