From 2606508713a0f8c73ed3cac3daed5b495c5d44b9 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sun, 2 Aug 2026 15:51:45 +0200 Subject: [PATCH] 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). --- src/i18n/en.ts | 27 ++++++++++++++++++++++++ src/i18n/fr.ts | 27 ++++++++++++++++++++++++ src/i18n/index.ts | 10 +++++++++ src/i18n/locale.ts | 11 ++++++++++ src/i18n/services.ts | 39 ++++++++++++++++++++++++++++++++++ tests/i18n.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/fr.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locale.ts create mode 100644 src/i18n/services.ts create mode 100644 tests/i18n.test.ts diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..b6f053f --- /dev/null +++ b/src/i18n/en.ts @@ -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; diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts new file mode 100644 index 0000000..0b7cd6a --- /dev/null +++ b/src/i18n/fr.ts @@ -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', + }, +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..e03f826 --- /dev/null +++ b/src/i18n/index.ts @@ -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 = { en, fr }; + +export function getDictionary(locale: Locale): Dictionary { + return dictionaries[locale]; +} diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 0000000..ca13827 --- /dev/null +++ b/src/i18n/locale.ts @@ -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}/`); +} diff --git a/src/i18n/services.ts b/src/i18n/services.ts new file mode 100644 index 0000000..50ea399 --- /dev/null +++ b/src/i18n/services.ts @@ -0,0 +1,39 @@ +import type { Locale } from './locale'; + +export interface Service { + id: string; + url: string | Record; + name: Record; + tag: Record; + description: Record; +} + +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]; +} diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts new file mode 100644 index 0000000..5a2a7a2 --- /dev/null +++ b/tests/i18n.test.ts @@ -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']); + }); +});