diff --git a/doc/manifest.md b/doc/manifest.md index 9ba948b..c3e500a 100644 --- a/doc/manifest.md +++ b/doc/manifest.md @@ -9,6 +9,7 @@ The manifest is platform-neutral. It only describes *what* can be translated, no | Field | Type | Description | |---|---|---| | `version` | int | The manifest format version. Currently `1`; any other value is rejected. | +| `source_language` | string | Required. The language of the source strings (`msgid`). Must be a valid Gettext `Language` field value, e.g. `en`, `en_US`. | | `strings` | table | All translatable strings. Each item maps a string entry key to a string entry, see below. | ## String Entry @@ -26,6 +27,7 @@ The string entry key identifies the entry inside the manifest and in generated o A manifest is rejected when: - `version` is not matched with current value introduced above. +- `source_language` is missing or not a valid language tag. - Any string entry key is empty. - Two entries share both `msgid` and `context`. - An entry or the document root contains unknown fields. @@ -34,6 +36,7 @@ A manifest is rejected when: ```toml version = 1 +source_language = "en" [strings.panel_title] msgid = "Example - Image Viewer" diff --git a/doc/rc-render-context.md b/doc/rc-render-context.md index d6e2ef8..f0beb55 100644 --- a/doc/rc-render-context.md +++ b/doc/rc-render-context.md @@ -12,7 +12,12 @@ The renderer runs in strict mode: referencing a variable or a property that does |---|---|---| | `languages` | array of language | One entry per language, see below. | -The order of the entries is not guaranteed: they appear in the order their PO files are matched. The presence of an English entry is not guaranteed either. An English entry exists only if an English PO file is provided; otherwise it is simply absent, and templates must handle that themselves (the text of every entry already falls back to the manifest source string, which is typically English). +The order of the entries is not guaranteed: they appear in the order their PO files +are matched, with a synthesized entry (see below) appended last. The manifest's +source language is always present: it comes from a PO file when one provides it, +and is otherwise synthesized from the manifest's source strings — a manifest +declaring `source_language = "en"` yields a neutral `en` entry (`0x0009`), while +`"en_US"` yields `0x0409`. The language of a PO file is resolved from its `Language:` header, falling back to the file name without extension; only values valid as Gettext `Language` fields are accepted. Languages without a Windows language mapping, two PO files describing the same language, and PO entries unknown to the manifest are rejected. Languages without a country resolve to a neutral language identifier (sublanguage `0x00`), e.g. `en` resolves to `0x0009`. diff --git a/example/example.rc.toml b/example/example.rc.toml index 47c482a..16308a1 100644 --- a/example/example.rc.toml +++ b/example/example.rc.toml @@ -1,4 +1,5 @@ version = 1 +source_language = "en" [strings.1000] msgid = "Example - Image Viewer" diff --git a/src/metaglot/cmds/rc/render.py b/src/metaglot/cmds/rc/render.py index b4db38a..37db4db 100644 --- a/src/metaglot/cmds/rc/render.py +++ b/src/metaglot/cmds/rc/render.py @@ -5,7 +5,12 @@ from ...filters import register_general_filters, register_rc_filters from ...langid import PoLang, WinLcid from ...langmap import win_lcid_map from ...manifest import Manifest, load_manifest -from ...pofile import LanguagePack, resolve_glob_files, resolve_translations +from ...pofile import ( + LanguagePack, + build_default_pack, + resolve_glob_files, + resolve_translations, +) from ...render import create_environment, render _CODE_PAGE = 1200 @@ -39,6 +44,12 @@ def _language_context(pack: LanguagePack, manifest: Manifest) -> dict: def run(opts: RcRenderOpts) -> None: manifest = load_manifest(opts.in_manifest) packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) + # The manifest's source language is always rendered: taken from a PO file + # when available, synthesized from the source strings otherwise. + if manifest.source_language not in packs: + packs[manifest.source_language] = build_default_pack( + manifest, manifest.source_language + ) languages = [_language_context(pack, manifest) for pack in packs.values()] logging.info( "rendering languages: %s", diff --git a/src/metaglot/manifest.py b/src/metaglot/manifest.py index 372cc46..ff0310e 100644 --- a/src/metaglot/manifest.py +++ b/src/metaglot/manifest.py @@ -1,7 +1,16 @@ import tomllib -from typing import Optional +from typing import Annotated, Optional from pathlib import Path -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + GetPydanticSchema, + field_validator, + model_validator, +) +from pydantic_core import core_schema + +from .langid import PoLang MANIFEST_VERSION = 1 """The manifest format version this build of MetaGlot understands.""" @@ -18,11 +27,32 @@ class StringEntry(BaseModel, frozen=True): """Translator-oriented comment of this entry. ``None`` if nothing.""" +def _validate_po_lang(value) -> PoLang: + if isinstance(value, PoLang): + return value + return PoLang(value) + + +def _po_lang_schema(source_type, handler) -> core_schema.CoreSchema: + """Describe how pydantic validates a ``PoLang`` field. + + The whole validation is delegated to :func:`_validate_po_lang`, which + accepts any value parseable by ``PoLang``. This keeps the field typed + as ``PoLang`` without enabling ``arbitrary_types_allowed`` on the model. + """ + return core_schema.no_info_plain_validator_function( + _validate_po_lang, + serialization=core_schema.to_string_ser_schema(), + ) + + class Manifest(BaseModel, frozen=True): model_config = ConfigDict(extra="forbid", strict=True) version: int """The version of this manifest file.""" + source_language: Annotated[PoLang, GetPydanticSchema(_po_lang_schema)] + """The language of the source strings (``msgid``).""" strings: dict[str, StringEntry] """The list holding all strings to be translated.""" diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index 011ba3b..da01378 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -165,6 +165,22 @@ def resolve_translations( return packs +def build_default_pack(manifest: Manifest, lang: PoLang) -> LanguagePack: + """Build the default language pack of a manifest. + + The pack is built directly from the manifest without reading any PO + file: every entry resolves to its own source string. The language of + the pack is not derived from a PO file either; callers pass whatever + value fits their use. + + :param manifest: The manifest describing all translatable entries. + :param lang: The language stamped on the returned pack. + :return: The default language pack. + """ + translations = {key: entry.msgid for key, entry in manifest.strings.items()} + return LanguagePack(lang, translations) + + def generate_pot(manifest: Manifest, output_path: Path) -> None: """Generate a POT template file from a manifest.