Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/yaml-first-translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@edoxen/browser': minor
---

YAML-first translations.

Translators work in YAML, not JS literals. The runtime i18n table now ships as six per-locale YAML files (`src/i18n/strings/<locale>.yaml`) parsed at build time via Vite's `?raw` import + the `yaml` package. Consumer overrides gain a symmetric loader.

### Added
- `loadYamlTranslations(yamlString)` helper, exported from `@edoxen/browser`. Parses a YAML mapping of `'key': 'value'` pairs into a frozen `UiStrings` record. Throws clearly on non-mapping input, ignores non-string values, returns `{}` for empty input.
- `src/i18n/load-translations.spec.ts` — six tests covering happy path, unicode/quote escaping, empty input, malformed input, freeze guarantee.

### Changed
- `src/i18n/strings/{eng,fra,zho,spa,ara,rus}.yaml` — built-in locale tables extracted from the TS literal that used to live inline in `ui.ts`. Same keys, same values, just YAML. Translators can read these as worked examples.
- `src/i18n/ui.ts` — `STRINGS` const now references `BUILTIN_STRINGS` (loaded from YAML); the 557-line inline table is gone. `loadYamlTranslations` is re-exported for consumers.
- `src/virtual.d.ts` — declares `*.yaml?raw` and `*.yml?raw` modules so TypeScript accepts Vite's raw YAML imports.
- README §Custom translations rewritten to make YAML the primary path: Option A (file-based, recommended) loads via `readFileSync` + `loadYamlTranslations`; Option B (inline literals) still works for small overrides. Includes YAML escaping rules (`'` doubling inside single-quoted scalars, or backslash escapes in double-quoted).

### Migration notes for consumers
No breaking changes — inline `uiStrings: { fra: {...} }` literals still work. Consumers who want file-based translations can adopt the new helper without touching existing config. Built-in English + French values are unchanged.
44 changes: 39 additions & 5 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,31 @@ tabs when a decision carries multiple spellings.

### Custom translations

Provide translations via `uiStrings`; missing keys fall back to
English. See [`docs/i18n-keys.yaml`](./docs/i18n-keys.yaml) for the full
list of translatable strings — including section titles such as
`section.adoptedDecisions` ("Resolutions"), which is how you rename the
adopted-decisions section on meeting pages.
Translations live in **YAML files** — one per locale. Translators work
in YAML, not JS literals. Two ways to load them:

**Option A — read the file in your `edoxen.config.ts` (recommended).**
Reference paths you control; the build picks up changes on reload:

```ts
// edoxen.config.ts
import { readFileSync } from 'node:fs'
import { loadYamlTranslations } from '@edoxen/browser'

const deu = loadYamlTranslations(readFileSync('./translations/deu.yaml', 'utf8'))
const zho = loadYamlTranslations(readFileSync('./translations/zho.yaml', 'utf8'))

export default {
// …
uiStrings: {
deu,
zho,
},
}
```

**Option B — inline (still works for small overrides).** Use JS object
literals directly when you only need to tweak a handful of strings:

```ts
uiStrings: {
Expand All @@ -250,6 +270,20 @@ uiStrings: {
}
```

Missing keys fall back to English automatically. See
[`docs/i18n-keys.yaml`](./docs/i18n-keys.yaml) for the **translation
template** — copy the block for your locale, fill in values, save as
`<locale>.yaml`, point `uiStrings` at it. Built-in English + French
ship in [`src/i18n/strings/`](./packages/browser/src/i18n/strings/) as
YAML — translators can read those as worked examples.

YAML escaping rules worth knowing:

- Inside single-quoted scalars, escape a literal `'` by doubling it:
`'L''archive'`
- Or use double-quoted scalars with backslash escapes: `"L'archive"`
- Unicode is fine in either form: `'Plénière'`, `'全体会议'`

### Terminology — renaming "decisions" and "meetings"

Committees call their records different things (TC 184/SC 4 adopts
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export {
availableUiLocales,
applyTerminology,
meetingTypeLabel,
loadYamlTranslations,
DEFAULT_TERMINOLOGY,
SUPPORTED_UI_LOCALES,
LOCALE_LABELS,
Expand Down
57 changes: 57 additions & 0 deletions packages/browser/src/i18n/load-translations.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'

import { loadYamlTranslations } from './load-translations.js'

describe('loadYamlTranslations', () => {
it('parses a flat key:value YAML mapping', () => {
const yaml = `
'nav.home': Home
'nav.about': 'About'
`
const out = loadYamlTranslations(yaml)
expect(out).toEqual({
'nav.home': 'Home',
'nav.about': 'About',
})
})

it('preserves unicode, single quotes, and special characters', () => {
// YAML single-quote escaping: literal ' inside a single-quoted scalar
// must be doubled (''). Double-quoted scalars can use either.
const yaml = `
'meeting.type.plenary': 'Plénière'
'phrase': 'L''archive'
'greeting': '今日は — welcome'
`
const out = loadYamlTranslations(yaml)
expect(out['meeting.type.plenary']).toBe('Plénière')
expect(out['phrase']).toBe("L'archive")
expect(out['greeting']).toBe('今日は — welcome')
})

it('returns an empty record for empty input', () => {
expect(loadYamlTranslations('')).toEqual({})
expect(loadYamlTranslations('---')).toEqual({})
})

it('ignores non-string values (arrays, objects, numbers)', () => {
const yaml = `
'valid': 'ok'
'nested':
key: value
'list': [a, b]
'number': 42
`
const out = loadYamlTranslations(yaml)
expect(out).toEqual({ valid: 'ok' })
})

it('throws on top-level non-mapping YAML', () => {
expect(() => loadYamlTranslations('- item1\n- item2')).toThrow(/mapping/)
})

it('result is frozen (translators get a read-only view)', () => {
const out = loadYamlTranslations("'k': 'v'")
expect(Object.isFrozen(out)).toBe(true)
})
})
46 changes: 46 additions & 0 deletions packages/browser/src/i18n/load-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// YAML-first i18n loader.
//
// Translators prefer YAML over JS literals — flat key:value files, one
// per locale. This module:
// * Parses a YAML string into a UiStrings record.
// * Loads built-in locale files from ./strings/<locale>.yaml at build
// time (Vite ?raw imports + parse).
//
// Consumers can load their own YAML files the same way via
// `loadYamlTranslations(content)` in their edoxen.config.ts.

import { parse } from 'yaml'

import engStrings from './strings/eng.yaml?raw'
import fraStrings from './strings/fra.yaml?raw'
import zhoStrings from './strings/zho.yaml?raw'
import spaStrings from './strings/spa.yaml?raw'
import araStrings from './strings/ara.yaml?raw'
import rusStrings from './strings/rus.yaml?raw'

export type UiStrings = Readonly<Record<string, string>>

/** Parse a YAML string of `'key': 'value'` pairs into a UiStrings record. */
export function loadYamlTranslations(yaml: string): UiStrings {
const parsed: unknown = parse(yaml)
if (parsed == null) return {}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Expected a YAML mapping of key: value pairs, got ${typeof parsed}`)
}
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value === 'string') out[key] = value
}
return Object.freeze(out)
}

// Built-in strings — kept private; consumers use the `t()` and
// `meetingTypeLabel()` helpers rather than reading the table directly.
export const BUILTIN_STRINGS: Readonly<Record<string, UiStrings>> = Object.freeze({
eng: loadYamlTranslations(engStrings),
fra: loadYamlTranslations(fraStrings),
zho: loadYamlTranslations(zhoStrings),
spa: loadYamlTranslations(spaStrings),
ara: loadYamlTranslations(araStrings),
rus: loadYamlTranslations(rusStrings),
})
71 changes: 71 additions & 0 deletions packages/browser/src/i18n/strings/ara.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'nav.home': 'الرئيسية'
'nav.decisions': 'القرارات'
'nav.meetings': 'الاجتماعات'
'nav.about': 'حول'
'page.home.heroLabel': 'أرشيف القرارات'
'page.home.stats.decisions': 'القرارات المسجلة'
'page.home.stats.meetings': 'الاجتماعات الموثقة'
'page.home.stats.recent': 'الأحدث'
'page.home.latestDecisions': 'أحدث القرارات'
'page.home.viewAll': 'عرض الكل'
'page.home.recentMeetings': 'الاجتماعات الأخيرة'
'label.body': 'الهيئة'
'label.kind': 'النوع'
'label.date': 'التاريخ'
'label.urn': 'URN'
'label.venue': 'المكان'
'label.adopted': 'اعتُمدت'
'label.effective': 'سارٍ'
'label.meeting': 'الاجتماع'
'label.acclamation': 'بالتصفيق'
'section.when': 'متى'
'section.venue': 'المكان'
'section.officers': 'المسؤولون'
'section.schedule': 'الجدول'
'section.agenda': 'جدول الأعمال'
'section.deadlines': 'المواعيد النهائية'
'section.minutes': 'محاضر الاجتماع'
'section.subject': 'الموضوع'
'section.considering': 'إذ يراعي'
'section.considerations': 'الاعتبارات'
'section.actions': 'الإجراءات'
'section.approvals': 'الموافقات'
'section.dates': 'التواريخ'
'section.referenceDocs': 'الوثائق المرجعية'
'section.adoptedAt': 'اعتُمد في'
'section.adoptedDecisions': 'القرارات'
'section.sourceDocs': 'الوثائق المصدر'
'section.declarations': 'الإعلانات'
'section.committee': 'اللجنة'
'section.hosts': 'المضيفون'
'section.note': 'ملاحظة'
'section.categories': 'الفئات'
'section.related': 'قرارات ذات صلة'
'section.overview': 'نظرة عامة'
'section.identifiers': 'المعرفات'
'page.home.stats.span': 'نطاق السنوات'
'label.scheduled': 'مجدولة'
'label.occurred': 'عُقدت'
'decisions.empty': 'لا توجد قرارات.'
'meetings.empty': 'لا توجد اجتماعات.'
'search.empty': 'لم يتم العثور على نتائج.'
'search.placeholder': 'بحث…'
'search.allBodies': 'جميع الهيئات'
'search.allKinds': 'جميع الأنواع'
'search.ariaLabel': 'البحث في القرارات'
'search.ariaLabelMeetings': 'البحث في الاجتماعات'
'search.dateFrom': 'من'
'search.dateTo': 'إلى'
'search.showMore': 'عرض المزيد'
'nav.prev': '→ السابق'
'nav.next': 'التالي ←'
'decade.browseLabel': 'تصفح حسب العقد'
'about.title': 'حول'
'about.format': 'صيغة Edoxen'
'about.formatBody': 'يعرض هذا الموقع بيانات الاجتماعات والقرارات باستخدام نموذج معلومات Edoxen — مخطط YAML للسجلات الرسمية لهيئات المعايير.'
'about.using': 'استخدام الموقع'
'about.usingDecisions': 'القرارات — تصفح أرشيف القرارات.'
'about.usingMeetings': 'الاجتماعات — شاهد الاجتماعات وجداول أعمالها ومحاضرها وقراراتها المعتمدة.'
'about.usingUrns': 'URN — لكل كيان URN مستقر للاستشهاد.'
'about.stats.decisions': 'القرارات'
'about.stats.meetings': 'الاجتماعات'
113 changes: 113 additions & 0 deletions packages/browser/src/i18n/strings/eng.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'nav.home': 'Home'
'nav.decisions': 'Resolutions'
'nav.meetings': 'Meetings'
'nav.about': 'About'
'page.home.heroLabel': 'Resolutions Archive'
'page.home.stats.decisions': 'Decisions on record'
'page.home.stats.meetings': 'Meetings documented'
'page.home.stats.recent': 'Most recent'
'page.home.latestDecisions': 'Latest Decisions'
'page.home.viewAll': 'View all'
'page.home.recentMeetings': 'Recent Meetings'
'label.body': 'Body'
'label.kind': 'Kind'
'label.date': 'Date'
'label.urn': 'URN'
'label.venue': 'Venue'
'label.adopted': 'Adopted'
'label.effective': 'Effective'
'label.meeting': 'Meeting'
'label.acclamation': 'Acclamation'
'section.when': 'When'
'section.venue': 'Venue'
'section.officers': 'Officers'
'section.schedule': 'Schedule'
'section.agenda': 'Agenda'
'section.deadlines': 'Deadlines'
'section.minutes': 'Minutes'
'section.subject': 'Subject'
'section.considering': 'Considering'
'section.considerations': 'Considerations'
'section.actions': 'Actions'
'section.approvals': 'Approvals'
'section.dates': 'Dates'
'section.referenceDocs': 'Reference documents'
'section.adoptedAt': 'Adopted at'
'section.adoptedDecisions': 'Resolutions'
'section.sourceDocs': 'Source documents'
'section.declarations': 'Declarations'
'section.committee': 'Committee'
'section.hosts': 'Hosts'
'section.note': 'Note'
'section.categories': 'Categories'
'section.related': 'Related decisions'
'section.overview': 'Overview'
'section.identifiers': 'Identifiers'
'page.home.stats.span': 'Year span'
'label.scheduled': 'Scheduled'
'label.occurred': 'Occurred'
'decisions.empty': 'No decisions.'
'meetings.empty': 'No meetings.'
'search.empty': 'No matches found.'
'search.placeholder': 'Search…'
'search.allBodies': 'All bodies'
'search.allKinds': 'All kinds'
'search.ariaLabel': 'Search decisions'
'search.ariaLabelMeetings': 'Search meetings'
'search.dateFrom': 'From'
'search.dateTo': 'To'
'search.showMore': 'Show more'
'search.groupYear': 'Year'
'search.groupLocation': 'Location'
'search.groupType': 'Type'
'search.groupActions': 'Actions'
'search.groupBody': 'Body'
'meeting.virtual': 'Virtual'
'meeting.type.plenary': 'Plenary'
'meeting.type.working_group': 'Working Group'
'meeting.type.task_group': 'Task Group'
'meeting.type.ad_hoc': 'Ad Hoc'
'meeting.type.joint': 'Joint'
'meeting.type.general_assembly': 'General Assembly'
'meeting.type.committee': 'Committee'
'meeting.type.subcommittee': 'Subcommittee'
'meeting.type.conference': 'Conference'
'meeting.type.workshop': 'Workshop'
'meeting.type.seminar': 'Seminar'
'meeting.type.webinar': 'Webinar'
'meeting.type.hearing': 'Hearing'
'meeting.type.markup': 'Markup'
'meeting.type.board_meeting': 'Board Meeting'
'meeting.type.annual_general_meeting': 'Annual General Meeting'
'meeting.type.other': 'Other'
'nav.prev': '← Previous'
'nav.next': 'Next →'
'decade.browseLabel': 'Browse by decade'
'about.title': 'About'
'about.subtitle': 'A digital record of {count} {records} from {committee}, spanning {year} to today.'
'about.format': 'Edoxen format'
'about.formatBody': 'This site renders meeting and decision data using the Edoxen information model — a YAML-based schema for formal proceedings of standards bodies.'
'about.formatBody2': 'Each file carries a metadata block (title, date, source, location) followed by a decisions array. Every decision has a structured identifier (prefix + number), a kind (resolution, order, ruling), per-language localizations, and the considerations, actions and approvals that form its record.'
'about.actionTypesTitle': 'Action Types'
'about.actionTypesBody': 'Every resolution records typed actions — what the committee resolved to do. The type vocabulary makes the archive filterable and analyzable across decades.'
'about.using': 'Using this site'
'about.usingDecisions': 'Decisions — browse the resolutions archive.'
'about.usingMeetings': 'Meetings — see meetings and their agendas, minutes, and adopted decisions.'
'about.usingUrns': 'URNs — every entity has a stable URN for citation.'
'about.stats.decisions': 'Decisions'
'about.stats.meetings': 'Meetings'
'about.urnTitle': 'URN Identifiers'
'about.urnBody': 'Resources in this archive are assigned Uniform Resource Names (URNs) to provide persistent, location-independent identifiers.'
'about.urnDecisionUrns': 'Decision URNs'
'about.urnMeetingUrns': 'Meeting URNs'
'about.urnExample': 'Example'
'about.urnNote': 'Note that per RFC 5141, "documents at or below the Technical Committee level" are not covered by the standard urn:iso:std: namespace. Section 2.6 delegates URN management for TC resources to the Technical Committees themselves.'
'about.lifecycleTitle': 'Resolution Lifecycle'
'about.lifecycleIntro': 'Each decision in the Edoxen model follows a structured lifecycle, captured through three interconnected sections:'
'about.lifecycleConsiderations': 'Considerations'
'about.lifecycleConsiderationsDesc': 'The context and background that led to the decision. Each consideration has a type (e.g., noting, recalling, recognising) and a message explaining what the committee observed or referenced.'
'about.lifecycleActions': 'Actions'
'about.lifecycleActionsDesc': 'The decisions themselves — what the committee resolved to do. Each action carries a semantic type (e.g., requests, approves, appoints) that categorizes the nature of the decision, along with the detailed message.'
'about.lifecycleApprovals': 'Approvals'
'about.lifecycleApprovalsDesc': 'How the decision was formally adopted, including the degree of consensus (e.g., unanimous, consensus) and any relevant notes about the approval process.'
'about.lifecycleOutro': 'A single decision may contain multiple considerations, actions, and approvals — together forming a complete record of the committee\u2019s decision-making process.'
Loading
Loading