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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ jobs:
- name: Enforce legal and licensing boundaries
run: python tools/check_legal_boundaries.py

- name: Validate Identity Bridge contracts
run: |
python tools/sync_identity_contracts.py --check
python -m unittest tools.tests.test_identity_contracts
python -m unittest tools.tests.test_localization_contracts

wordpress-plugin-release-check:
name: WordPress plugin release check
runs-on: ubuntu-latest
Expand Down Expand Up @@ -99,6 +105,9 @@ jobs:
}
PHP

- name: Test WordPress locale registry
run: php tools/tests/wordpress_locale_registry.test.php

- name: Set up Python
uses: actions/setup-python@v7
with:
Expand Down Expand Up @@ -176,6 +185,7 @@ jobs:
run: >-
node --test
tools/tests/calorieapp_embed_readiness.test.mjs
tools/tests/identity_locales.test.mjs
tools/tests/xaman_login_start_retry.test.mjs

- name: Build frontend
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ gate can additionally run the local developer health check.

## Documentation

- Versioned Identity Bridge contracts: contracts/identity-bridge/v1/
- Historical image localization contract: contracts/localization/v1/
- Public architecture: docs/public/architecture.md
- Public roadmap: docs/public/roadmap.md
- Public deployment guide: docs/public/deployment.md
Expand Down
98 changes: 98 additions & 0 deletions backend/app/data/locales.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{
"$schema": "./locales.schema.json",
"contract_id": "calorieapp.locale-registry",
"contract_version": "1.0.0",
"source_locale": "en",
"fallback_locale": "en",
"selection_policy": "Top ten individual languages by total L1 plus L2 speakers, with Dutch as an additional fully supported locale.",
"locales": [
{
"tag": "en",
"english_name": "English",
"native_name": "English",
"direction": "ltr",
"source": true,
"aliases": ["en-US", "en-GB"]
},
{
"tag": "zh-Hans",
"english_name": "Mandarin Chinese (Simplified)",
"native_name": "简体中文",
"direction": "ltr",
"source": false,
"aliases": ["zh", "zh-CN", "zh-SG"]
},
{
"tag": "hi",
"english_name": "Hindi",
"native_name": "हिन्दी",
"direction": "ltr",
"source": false,
"aliases": ["hi-IN"]
},
{
"tag": "es",
"english_name": "Spanish",
"native_name": "Español",
"direction": "ltr",
"source": false,
"aliases": ["es-ES", "es-MX"]
},
{
"tag": "ar",
"english_name": "Modern Standard Arabic",
"native_name": "العربية",
"direction": "rtl",
"source": false,
"aliases": ["ar-EG", "ar-SA"]
},
{
"tag": "fr",
"english_name": "French",
"native_name": "Français",
"direction": "ltr",
"source": false,
"aliases": ["fr-FR", "fr-CA"]
},
{
"tag": "bn",
"english_name": "Bengali",
"native_name": "বাংলা",
"direction": "ltr",
"source": false,
"aliases": ["bn-BD", "bn-IN"]
},
{
"tag": "pt",
"english_name": "Portuguese",
"native_name": "Português",
"direction": "ltr",
"source": false,
"aliases": ["pt-BR", "pt-PT"]
},
{
"tag": "id",
"english_name": "Indonesian",
"native_name": "Bahasa Indonesia",
"direction": "ltr",
"source": false,
"aliases": ["id-ID"]
},
{
"tag": "ur",
"english_name": "Urdu",
"native_name": "اردو",
"direction": "rtl",
"source": false,
"aliases": ["ur-PK", "ur-IN"]
},
{
"tag": "nl",
"english_name": "Dutch",
"native_name": "Nederlands",
"direction": "ltr",
"source": false,
"aliases": ["nl-NL", "nl-BE"]
}
]
}
78 changes: 78 additions & 0 deletions backend/app/locales.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Shared locale resolution for CalorieApp identity and UI surfaces."""

from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterator, Optional


REGISTRY_PATH = Path(__file__).with_name("data") / "locales.json"


@lru_cache(maxsize=1)
def locale_registry() -> dict[str, Any]:
"""Load the deployable copy of the frozen v1 locale registry."""
try:
value = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError("CalorieApp locale registry is unavailable") from exc
if not isinstance(value, dict) or not isinstance(value.get("locales"), list):
raise RuntimeError("CalorieApp locale registry is malformed")
return value


def supported_locale_tags() -> tuple[str, ...]:
return tuple(locale["tag"] for locale in locale_registry()["locales"])


@lru_cache(maxsize=1)
def _locale_identifier_map() -> dict[str, str]:
identifiers: dict[str, str] = {}
for locale in locale_registry()["locales"]:
tag = locale["tag"]
for identifier in [tag, *locale.get("aliases", [])]:
identifiers[_normalize_identifier(identifier)] = tag
return identifiers


def _normalize_identifier(value: str) -> str:
return value.strip().replace("_", "-").lower()


def _requested_candidates(value: Optional[str]) -> Iterator[str]:
if not value:
return
for part in value.split(","):
candidate = part.split(";", 1)[0].strip()
if candidate and candidate != "*":
yield _normalize_identifier(candidate)


def resolve_locale(value: Optional[str]) -> str:
"""Resolve a locale tag or Accept-Language-like value with English fallback."""
registry = locale_registry()
identifiers = _locale_identifier_map()
canonical_primary_tags = {
locale["tag"].lower(): locale["tag"]
for locale in registry["locales"]
if "-" not in locale["tag"]
}

for candidate in _requested_candidates(value):
exact = identifiers.get(candidate)
if exact:
return exact
primary = candidate.split("-", 1)[0]
if primary in canonical_primary_tags:
return canonical_primary_tags[primary]
return registry["fallback_locale"]


def locale_direction(value: Optional[str]) -> str:
resolved = resolve_locale(value)
for locale in locale_registry()["locales"]:
if locale["tag"] == resolved:
return locale["direction"]
return "ltr"
45 changes: 45 additions & 0 deletions backend/tests/test_locales.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from app.locales import locale_direction, locale_registry, resolve_locale, supported_locale_tags


EXPECTED_LOCALES = (
"en",
"zh-Hans",
"hi",
"es",
"ar",
"fr",
"bn",
"pt",
"id",
"ur",
"nl",
)


def test_frozen_v1_locale_set_and_order() -> None:
assert supported_locale_tags() == EXPECTED_LOCALES
assert locale_registry()["source_locale"] == "en"
assert locale_registry()["fallback_locale"] == "en"


def test_locale_resolution_accepts_aliases_and_language_variants() -> None:
assert resolve_locale("zh-CN") == "zh-Hans"
assert resolve_locale("pt_BR") == "pt"
assert resolve_locale("es-AR") == "es"
assert resolve_locale("nl-BE") == "nl"
assert resolve_locale("fr-CH,fr;q=0.8,en;q=0.5") == "fr"


def test_unknown_or_unsupported_locale_falls_back_to_english() -> None:
assert resolve_locale(None) == "en"
assert resolve_locale("") == "en"
assert resolve_locale("de-DE") == "en"
assert resolve_locale("zh-Hant") == "en"
assert resolve_locale("*") == "en"


def test_only_arabic_and_urdu_are_right_to_left() -> None:
assert locale_direction("ar") == "rtl"
assert locale_direction("ur-PK") == "rtl"
assert locale_direction("en") == "ltr"
assert locale_direction("unknown") == "ltr"
35 changes: 35 additions & 0 deletions contracts/identity-bridge/v1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# CalorieApp Identity Bridge contract v1

This directory is the repository source of truth for the first Identity Bridge
contract and the shared CalorieApp locale registry. Runtime copies are
generated for the backend, frontend and WordPress plugin because those three
artifacts are deployed and packaged independently.

## Frozen v1 boundaries

- WordPress owns the authenticated WordPress browser session.
- Xaman provides a server-verified proof; browser-supplied identity claims are
never authoritative.
- The CalorieApp backend owns its opaque application session and private food
log authorization.
- Login state, browser handoff and authorization codes are short-lived,
single-use and replay protected.
- Origins and callback URLs are explicit allowlists. HTTPS is mandatory outside
loopback-only local development.
- The v1 identity payload remains minimal. Optional names, email addresses,
donation details or public profile fields require a separate consent and
purpose contract before they may be added.

## Locale contract

English is the source and fallback locale. The registry contains the fixed set
of ten selected world languages plus Dutch. Arabic and Urdu are right-to-left.
All products must resolve unsupported or malformed locale input to English.

Run `python tools/sync_identity_contracts.py` after changing the canonical
registry. CI uses `--check` and rejects drift between the source and the three
runtime copies.

Localized external publishing remains a separate editorial workflow:
preview, review, explicit GO, scheduling and publishing. The locale registry
must not trigger automatic bulk posting.
98 changes: 98 additions & 0 deletions contracts/identity-bridge/v1/locales.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{
"$schema": "./locales.schema.json",
"contract_id": "calorieapp.locale-registry",
"contract_version": "1.0.0",
"source_locale": "en",
"fallback_locale": "en",
"selection_policy": "Top ten individual languages by total L1 plus L2 speakers, with Dutch as an additional fully supported locale.",
"locales": [
{
"tag": "en",
"english_name": "English",
"native_name": "English",
"direction": "ltr",
"source": true,
"aliases": ["en-US", "en-GB"]
},
{
"tag": "zh-Hans",
"english_name": "Mandarin Chinese (Simplified)",
"native_name": "简体中文",
"direction": "ltr",
"source": false,
"aliases": ["zh", "zh-CN", "zh-SG"]
},
{
"tag": "hi",
"english_name": "Hindi",
"native_name": "हिन्दी",
"direction": "ltr",
"source": false,
"aliases": ["hi-IN"]
},
{
"tag": "es",
"english_name": "Spanish",
"native_name": "Español",
"direction": "ltr",
"source": false,
"aliases": ["es-ES", "es-MX"]
},
{
"tag": "ar",
"english_name": "Modern Standard Arabic",
"native_name": "العربية",
"direction": "rtl",
"source": false,
"aliases": ["ar-EG", "ar-SA"]
},
{
"tag": "fr",
"english_name": "French",
"native_name": "Français",
"direction": "ltr",
"source": false,
"aliases": ["fr-FR", "fr-CA"]
},
{
"tag": "bn",
"english_name": "Bengali",
"native_name": "বাংলা",
"direction": "ltr",
"source": false,
"aliases": ["bn-BD", "bn-IN"]
},
{
"tag": "pt",
"english_name": "Portuguese",
"native_name": "Português",
"direction": "ltr",
"source": false,
"aliases": ["pt-BR", "pt-PT"]
},
{
"tag": "id",
"english_name": "Indonesian",
"native_name": "Bahasa Indonesia",
"direction": "ltr",
"source": false,
"aliases": ["id-ID"]
},
{
"tag": "ur",
"english_name": "Urdu",
"native_name": "اردو",
"direction": "rtl",
"source": false,
"aliases": ["ur-PK", "ur-IN"]
},
{
"tag": "nl",
"english_name": "Dutch",
"native_name": "Nederlands",
"direction": "ltr",
"source": false,
"aliases": ["nl-NL", "nl-BE"]
}
]
}
Loading