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).
This commit is contained in:
2026-08-02 15:51:45 +02:00
parent e9d34f9d13
commit 2606508713
6 changed files with 164 additions and 0 deletions
+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];
}
+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']);
});
});