Compare commits
10
Commits
0496e1390b
...
14c52f5e71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c52f5e71 | ||
|
|
fdd19ce874 | ||
|
|
f85488fe2b | ||
|
|
eb9db2afff | ||
|
|
96ec05f649 | ||
|
|
86049dd8b5 | ||
|
|
1d65bb7aa8 | ||
|
|
2606508713 | ||
|
|
e9d34f9d13 | ||
|
|
a29cb5e984 |
+24
@@ -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/
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/)
|
||||
@@ -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/)
|
||||
@@ -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).
|
||||
@@ -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
Generated
+4769
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.ombrora.com/sitemap-index.xml
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user