diff --git a/doc/filters.md b/doc/filters.md index 88f9c7f..f734f8c 100644 --- a/doc/filters.md +++ b/doc/filters.md @@ -59,3 +59,18 @@ Examples: ```liquid {{ "Example" | desktop_escape }} {% comment %}renders "Example"{% endcomment %} ``` + +## MSIX Filters + +The following filters are available when rendering MSIX files. `xml_escape` is available to the manifest file rendering; both filters are available to the resources file rendering, where the template decides between a `.resw` (XML) and a `.resjson` (JSON) output. + +| Filter | Available to | Description | +|---|---|---| +| `xml_escape` | manifest, resources | The same filter described in the AppStream section above; `AppxManifest.xml` and `.resw` files are XML too. | +| `json_escape` | resources | Escapes a string for use inside a JSON string literal, such as a `.resjson` value: `\` becomes `\\`, `"` becomes `\"`, newline, carriage return and tab become `\n`, `\r` and `\t`, and any other control character below 0x20 becomes its `\uXXXX` form. Like the other escape filters it adds no surrounding quotes; write them in the template: `"{{ strings.key \| json_escape }}"`. | + +Examples: + +```liquid +{{ "Example" | json_escape }} {% comment %}renders "Example", without quotes{% endcomment %} +``` diff --git a/doc/msix-render-context.md b/doc/msix-render-context.md new file mode 100644 index 0000000..6bf963b --- /dev/null +++ b/doc/msix-render-context.md @@ -0,0 +1,78 @@ +# MSIX Render Context + +This document describes the data that `metaglot render msix` provides to user-provided [Liquid](https://shopify.github.io/liquid/) templates when rendering Windows MSIX files. + +Unlike the other targets, an MSIX rendering involves multiple output files: one `AppxManifest.xml` (or `Package.appxmanifest`) referencing localized strings through `ms-resource:` URIs, plus one `Resources.resw` or `Resources.resjson` file per language under a language-tagged folder (typically `Strings//Resources.resw`). The output location of every file is resolved by a path template command line argument instead of a fixed output file argument. + +MetaGlot reads templates as UTF-8 and writes every rendered file as UTF-8 (without BOM) using the system's native line endings. This encoding is a built-in constraint of the tool and is not configurable. The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render. + +Every component below renders with its **own environment**, so the filters available to one component never leak into another. Path templates resolve to paths relative to the output folder (an absolute rendered path is used as-is); their result is stripped of surrounding whitespace and must not be empty. + +## Path Template Contexts + +The `--manifest-path` and `--resources-path` arguments are in-memory Liquid templates rendered into output paths. Only the general filters described in [filters.md](filters.md) are available to them. + +### `--manifest-path` + +| Variable | Type | Description | +|---|---|---| +| (none) | | The context is empty: the path is typically the plain literal `AppxManifest.xml`. | + +The template is rendered once. Referencing any variable fails the render. + +### `--resources-path` + +| Variable | Type | Description | +|---|---|---| +| `lang` | string | The BCP 47 language tag of the resources file being placed, e.g. `en-US`, `zh-CN`. | + +The template is rendered once per language, for example: + +```liquid +Strings/{{ lang }}/Resources.resw +``` + +## File Render Contexts + +The `--manifest-template` and `--resources-template` arguments point to template files. Besides the general filters, the manifest template rendering provides `xml_escape`, and the resources template rendering provides `xml_escape` and `json_escape`. + +### `--manifest-template` + +| Variable | Type | Description | +|---|---|---| +| `default` | language | The manifest's source language entry, see below. | +| `langs` | array of string | The BCP 47 language tag of every language, source language first. | + +The manifest itself carries no translated text: it references resources through `ms-resource:` URIs and lists every language for the `` section, where the first listed language acts as the default one: + +```xml + +{% for lang in langs %} + +{% endfor %} + +``` + +`default` is provided for manifests that opt out of localization and write text directly: `{{ default.strings.app_display_name | xml_escape }}`. A fully localized manifest instead writes `ms-resource:AppDisplayName` and friends. + +The source language always leads `langs` and always gets a resources file: it comes from a PO file when one provides it, and is otherwise synthesized from the manifest's source strings. Every language listed in `langs` is guaranteed a complete resources folder, since any language may become a user's fallback. + +### `--resources-template` + +The members of the current language are exposed at the top level. The structure is identical for every language; only the data differs. There is no `default`. + +| Variable | Type | Description | +|---|---|---| +| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. | +| `lang` | string | The BCP 47 language tag of this resources file, e.g. `en-US`, `zh-CN`, `sr-Latn-RS`. | +| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `strings.app_display_name`. | + +The resolved text of an entry falls back to the manifest source string when the entry is untranslated, missing from the PO file, or marked fuzzy. The template is rendered once per language: + +```liquid +{ + "AppDisplayName": "{{ strings.app_display_name | json_escape }}" +} +``` + +The `lang` conversion follows BCP 47 with the shared composition rules: the language and country codes carry over hyphenated, known script words (`latin`, `cyrillic`, `arabic`) become script subtags placed before the country (`sr_RS@latin` becomes `sr-Latn-RS`), and other variant words become variant subtags after it. Legacy Gettext variants that the IANA registry does not register (e.g. `euro`) are rejected. diff --git a/example/AppxManifest.xml.liquid b/example/AppxManifest.xml.liquid new file mode 100644 index 0000000..9afbf4b --- /dev/null +++ b/example/AppxManifest.xml.liquid @@ -0,0 +1,61 @@ + + + + + + + ms-resource:AppDisplayName + ms-resource:PublisherDisplayName + Assets\Square44x44Logo.png + + + +{% for lang in langs %} + +{% endfor %} + + + + + + + + + + + + + + + + + ms-resource:FileTypeJpegDisplayName + Assets\FileAssoc\jpegimages.png + + .jpeg + .jpg + + + + + + ms-resource:FileTypePngDisplayName + Assets\FileAssoc\pngimages.png + + .png + + + + + + + diff --git a/example/AppxManifest.xml.toml b/example/AppxManifest.xml.toml new file mode 100644 index 0000000..e434e24 --- /dev/null +++ b/example/AppxManifest.xml.toml @@ -0,0 +1,22 @@ +version = 1 +source_language = "en_US" + +[strings.app_display_name] +msgid = "Example" +comment = "Display name of the application, referenced as ms-resource:AppDisplayName." + +[strings.publisher_display_name] +msgid = "Example contributors" +comment = "Publisher display name, referenced as ms-resource:PublisherDisplayName." + +[strings.app_description] +msgid = "A lightweight image viewer." +comment = "Application description, referenced as ms-resource:AppDescription." + +[strings.file_type_jpeg] +msgid = "JPEG Image" +comment = "Display name of the JPEG file type association, referenced as ms-resource:FileTypeJpegDisplayName." + +[strings.file_type_png] +msgid = "PNG Image" +comment = "Display name of the PNG file type association, referenced as ms-resource:FileTypePngDisplayName." diff --git a/example/Resources.resjson.liquid b/example/Resources.resjson.liquid new file mode 100644 index 0000000..7af86cb --- /dev/null +++ b/example/Resources.resjson.liquid @@ -0,0 +1,7 @@ +{ + "AppDisplayName": "{{ strings.app_display_name | json_escape }}", + "PublisherDisplayName": "{{ strings.publisher_display_name | json_escape }}", + "AppDescription": "{{ strings.app_description | json_escape }}", + "FileTypeJpegDisplayName": "{{ strings.file_type_jpeg | json_escape }}", + "FileTypePngDisplayName": "{{ strings.file_type_png | json_escape }}" +} diff --git a/example/Resources.resw.liquid b/example/Resources.resw.liquid new file mode 100644 index 0000000..9ef52f8 --- /dev/null +++ b/example/Resources.resw.liquid @@ -0,0 +1,30 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + {{ strings.app_display_name | xml_escape }} + + + {{ strings.publisher_display_name | xml_escape }} + + + {{ strings.app_description | xml_escape }} + + + {{ strings.file_type_jpeg | xml_escape }} + + + {{ strings.file_type_png | xml_escape }} + + diff --git a/src/metaglot/cmds/render/__init__.py b/src/metaglot/cmds/render/__init__.py index eb51660..2ac1c82 100644 --- a/src/metaglot/cmds/render/__init__.py +++ b/src/metaglot/cmds/render/__init__.py @@ -2,17 +2,18 @@ import enum from argparse import ArgumentParser from dataclasses import dataclass from typing import Any -from . import rc, desktop, appstream +from . import rc, desktop, appstream, msix from .rc import RcRenderOpts from .desktop import DesktopRenderOpts from .appstream import AppStreamRenderOpts +from .msix import MsixRenderOpts _SUBCMD_DEST = "metadata" @dataclass(frozen=True) class RenderOpts: - opts: RcRenderOpts | DesktopRenderOpts | AppStreamRenderOpts + opts: RcRenderOpts | DesktopRenderOpts | AppStreamRenderOpts | MsixRenderOpts """The option of one of subcommand.""" @@ -20,6 +21,7 @@ class _Metadata(enum.StrEnum): Rc = "rc" Desktop = "desktop" Appstream = "appstream" + Msix = "msix" def register(parser: ArgumentParser) -> None: @@ -36,6 +38,10 @@ def register(parser: ArgumentParser) -> None: _Metadata.Appstream.value, help="Render AppStream metainfo XML files." ) appstream.register(subcmd) + subcmd = cmds.add_parser( + _Metadata.Msix.value, help="Render Windows MSIX files." + ) + msix.register(subcmd) def parse(args: dict[str, Any]) -> RenderOpts: @@ -46,6 +52,8 @@ def parse(args: dict[str, Any]) -> RenderOpts: opts = RenderOpts(desktop.parse(args)) case _Metadata.Appstream: opts = RenderOpts(appstream.parse(args)) + case _Metadata.Msix: + opts = RenderOpts(msix.parse(args)) case _: raise ValueError(f"unhandled render command: {args[_SUBCMD_DEST]}") return opts @@ -59,5 +67,7 @@ def run(opts: RenderOpts) -> None: desktop.run(desktop_opts) case AppStreamRenderOpts() as appstream_opts: appstream.run(appstream_opts) + case MsixRenderOpts() as msix_opts: + msix.run(msix_opts) case _: raise RuntimeError(f"unhandled render options: {opts.opts!r}") diff --git a/src/metaglot/cmds/render/msix.py b/src/metaglot/cmds/render/msix.py new file mode 100644 index 0000000..c3d8246 --- /dev/null +++ b/src/metaglot/cmds/render/msix.py @@ -0,0 +1,301 @@ +import logging +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from ...filters import ( + register_general_filters, + register_msix_manifest_filters, + register_msix_resources_filters, +) +from ...langmap import msix_lang_map +from ...manifest import load_manifest +from ...pofile import ( + LanguagePack, + build_default_pack, + resolve_glob_files, + resolve_translations, +) +from ...render import create_environment, render, render_path + +_MANIFEST_DEST = "manifest" +_PO_DEST = "po" +_MANIFEST_TEMPLATE_DEST = "manifest_template" +_RESOURCES_TEMPLATE_DEST = "resources_template" +_MANIFEST_PATH_DEST = "manifest_path" +_RESOURCES_PATH_DEST = "resources_path" +_OUTPUT_DEST = "output" + + +@dataclass(frozen=True) +class MsixRenderOpts: + in_manifest: Path + """The path to input manifest file including translation strings.""" + in_po: list[Path] + """The path to input PO files to read, each may be a glob pattern.""" + in_manifest_template: Path + """The path to the input user-provided Liquid for the manifest file.""" + in_resources_template: Path + """The path to the input user-provided Liquid for the resources files.""" + in_manifest_path: str + """The Liquid template resolving the output path of the manifest file.""" + in_resources_path: str + """The Liquid template resolving the output path of every resources file.""" + out_dir: Path + """The path of the output folder holding every rendered file.""" + + +def register(parser: ArgumentParser) -> None: + parser.add_argument( + "-m", + "--manifest", + dest=_MANIFEST_DEST, + action="store", + type=Path, + required=True, + help="The path to input manifest file including translation strings.", + metavar="FILE", + ) + parser.add_argument( + "-p", + "--po", + dest=_PO_DEST, + action="extend", + nargs="+", + type=Path, + required=True, + help="The PO files to read, each may be a glob pattern.", + metavar="GLOB", + ) + parser.add_argument( + "--manifest-template", + dest=_MANIFEST_TEMPLATE_DEST, + action="store", + type=Path, + required=True, + help="The path to the input user-provided Liquid for the manifest file.", + metavar="FILE", + ) + parser.add_argument( + "--resources-template", + dest=_RESOURCES_TEMPLATE_DEST, + action="store", + type=Path, + required=True, + help="The path to the input user-provided Liquid for the resources files.", + metavar="FILE", + ) + parser.add_argument( + "--manifest-path", + dest=_MANIFEST_PATH_DEST, + action="store", + type=str, + required=True, + help=( + "The Liquid template resolving the output path of the manifest file, " + "relative to the output folder." + ), + metavar="TEMPLATE", + ) + parser.add_argument( + "--resources-path", + dest=_RESOURCES_PATH_DEST, + action="store", + type=str, + required=True, + help=( + "The Liquid template resolving the output path of every resources " + "file, relative to the output folder. It is rendered once per " + "language with the language tag exposed as 'lang'." + ), + metavar="TEMPLATE", + ) + parser.add_argument( + "-o", + "--output", + dest=_OUTPUT_DEST, + action="store", + type=Path, + required=True, + help="The path of the output folder holding every rendered file.", + metavar="DIR", + ) + + +def parse(args: dict[str, Any]) -> MsixRenderOpts: + return MsixRenderOpts( + args[_MANIFEST_DEST], + args[_PO_DEST], + args[_MANIFEST_TEMPLATE_DEST], + args[_RESOURCES_TEMPLATE_DEST], + args[_MANIFEST_PATH_DEST], + args[_RESOURCES_PATH_DEST], + args[_OUTPUT_DEST], + ) + + +class _MsixStringsView: + """Route template string key lookups to their resolved text.""" + + __pack: LanguagePack + """The language pack the lookups route to.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + + def __getitem__(self, key: str) -> str: + return self.__pack.translations[key].text + + +class _MsixManifestPathContext: + """The Liquid-facing context of the manifest path template. + + The context is empty: the manifest path is typically the plain + literal 'AppxManifest.xml', and referencing any variable fails + the render. + """ + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return () + + def __getitem__(self, key: str) -> object: + raise KeyError(key) + + +class _MsixResourcesPathContext: + """The Liquid-facing context of one resources path template rendering. + + It is rendered once per language, with the language tag of the + resources file being placed exposed as ``lang``. + """ + + __lang: str + """The BCP 47 language tag of the resources file being placed.""" + + def __init__(self, lang: str): + self.__lang = lang + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("lang",) + + def __getitem__(self, key: str) -> object: + if key == "lang": + return self.__lang + raise KeyError(key) + + +class _MsixResourcesTemplateContext: + """The Liquid-facing top-level context of every resources file rendering. + + The members of the current language are exposed at the top level. + The structure is identical for every language; only the data differs. + The class also serves as the value of ``default`` inside the manifest + template context, where it exposes the source language the same way. + """ + + __pack: LanguagePack + """The wrapped language pack.""" + __strings: _MsixStringsView + """The view of the pack's translations.""" + __lang: str + """The BCP 47 language tag of the pack's language.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + self.__strings = _MsixStringsView(pack) + self.__lang = msix_lang_map.convert(pack.lang).value + + def keys(self) -> tuple[str, ...]: + """Return the names of the exposed template variables.""" + return ("name", "lang", "strings") + + def __getitem__(self, key: str) -> object: + match key: + case "name": + return self.__pack.lang.value + case "lang": + return self.__lang + case "strings": + return self.__strings + case _: + raise KeyError(key) + + +class _MsixManifestTemplateContext: + """The Liquid-facing top-level context of the manifest file rendering.""" + + __default: _MsixResourcesTemplateContext + """The view of the manifest's source language.""" + __langs: tuple[str, ...] + """The BCP 47 language tags of every language, source language first.""" + + def __init__(self, source: LanguagePack, langs: tuple[str, ...]): + self.__default = _MsixResourcesTemplateContext(source) + self.__langs = langs + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("default", "langs") + + def __getitem__(self, key: str) -> object: + match key: + case "default": + return self.__default + case "langs": + return self.__langs + case _: + raise KeyError(key) + + +def run(opts: MsixRenderOpts) -> None: + manifest = load_manifest(opts.in_manifest) + packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) + # The source language always leads the language list: MSIX takes the + # first listed language as the default one, and its resources folder + # is the fallback for every other language. + source = packs.pop(manifest.source_language, None) + if source is None: + source = build_default_pack(manifest, manifest.source_language) + ordered = [source, *packs.values()] + langs = tuple(msix_lang_map.convert(pack.lang).value for pack in ordered) + logging.info("rendering languages: %s", ", ".join(langs)) + + # Every component renders with its own environment, so their filters + # never interfere with each other. + manifest_path_env = create_environment() + register_general_filters(manifest_path_env) + resources_path_env = create_environment() + register_general_filters(resources_path_env) + manifest_template_env = create_environment() + register_general_filters(manifest_template_env) + register_msix_manifest_filters(manifest_template_env) + resources_template_env = create_environment() + register_general_filters(resources_template_env) + register_msix_resources_filters(resources_template_env) + + manifest_out = opts.out_dir / render_path( + manifest_path_env, opts.in_manifest_path, _MsixManifestPathContext() + ) + manifest_out.parent.mkdir(parents=True, exist_ok=True) + render( + manifest_template_env, + opts.in_manifest_template, + _MsixManifestTemplateContext(source, langs), + manifest_out, + ) + + for pack, lang in zip(ordered, langs): + resources_out = opts.out_dir / render_path( + resources_path_env, + opts.in_resources_path, + _MsixResourcesPathContext(lang), + ) + resources_out.parent.mkdir(parents=True, exist_ok=True) + render( + resources_template_env, + opts.in_resources_template, + _MsixResourcesTemplateContext(pack), + resources_out, + ) diff --git a/src/metaglot/filters.py b/src/metaglot/filters.py index 5842a55..32e9bf0 100644 --- a/src/metaglot/filters.py +++ b/src/metaglot/filters.py @@ -92,3 +92,41 @@ def register_appstream_filters(env: Environment) -> None: :param env: The environment to register the filters on. """ env.add_filter("xml_escape", _xml_escape) + + +def _json_escape(value: str) -> str: + """Escape a string for use inside a JSON string literal. + + Like the other ``_escape`` filters, this escapes only and adds no + surrounding quotes: ``"`` becomes ``\\"``, ``\\`` becomes ``\\\\``, + newline, carriage return and tab become ``\\n``, ``\\r`` and ``\\t``, + any other control character below 0x20 becomes its ``\\uXXXX`` form, + and non-ASCII characters carry over unchanged. + """ + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return "".join( + char if ord(char) >= 0x20 else f"\\u{ord(char):04X}" for char in escaped + ) + + +def register_msix_manifest_filters(env: Environment) -> None: + """Register the Liquid filters for the MSIX manifest file rendering. + + :param env: The environment to register the filters on. + """ + env.add_filter("xml_escape", _xml_escape) + + +def register_msix_resources_filters(env: Environment) -> None: + """Register the Liquid filters for the MSIX resources file rendering. + + :param env: The environment to register the filters on. + """ + env.add_filter("xml_escape", _xml_escape) + env.add_filter("json_escape", _json_escape) diff --git a/src/metaglot/langid.py b/src/metaglot/langid.py index 557c155..22391dc 100644 --- a/src/metaglot/langid.py +++ b/src/metaglot/langid.py @@ -255,10 +255,9 @@ class DesktopLang: # endregion -class AppStreamLang: +class Bcp47Lang: """ - Represents a BCP 47 language tag, as required by the ``xml:lang`` - attribute values of AppStream XML files. + Represents a BCP 47 language tag. Validation and serialization are delegated to the core of the ``langcodes`` library (without its optional ``data`` extra). The whole @@ -271,18 +270,18 @@ class AppStreamLang: to ``zh-CN``. Deliberate usage policy: MetaGlot only parses, validates and - serializes tags through this class. It never calls + serializes tags through this class hierarchy. It never calls ``langcodes.standardize_tag`` nor enables macro language collapsing, - so an emitted ``xml:lang`` is always a faithful hyphenation of its - Gettext source tag - no script injection, no redundant-script - removal, no macrolanguage folding. + so an emitted tag is always a faithful hyphenation of its Gettext + source tag - no script injection, no redundant-script removal, no + macrolanguage folding. Examples: - AppStreamLang("zh-CN") # valid - AppStreamLang("sr-Latn-RS") # valid - AppStreamLang("zh_CN") # valid, normalizes to "zh-CN" - AppStreamLang("de-DE-euro") # invalid (unregistered variant) - AppStreamLang("jp") # invalid (not a language code) + Bcp47Lang("zh-CN") # valid + Bcp47Lang("sr-Latn-RS") # valid + Bcp47Lang("zh_CN") # valid, normalizes to "zh-CN" + Bcp47Lang("de-DE-euro") # invalid (unregistered variant) + Bcp47Lang("jp") # invalid (not a language code) """ __lang: langcodes.Language @@ -335,12 +334,12 @@ class AppStreamLang: return self.value def __repr__(self) -> str: - return f"AppStreamLang({self.value!r})" + return f"{type(self).__name__}({self.value!r})" def __eq__(self, other) -> bool: if self is other: return True - if not isinstance(other, AppStreamLang): + if type(self) is not type(other): return NotImplemented return self.value == other.value @@ -350,6 +349,26 @@ class AppStreamLang: # endregion +class AppStreamLang(Bcp47Lang): + """ + Represents a BCP 47 language tag, as required by the ``xml:lang`` + attribute values of AppStream XML files. + + See :class:`Bcp47Lang` for the accepted grammar, the validation + rules and the serialization policy. + """ + + +class MsixLang(Bcp47Lang): + """ + Represents a BCP 47 language tag, as required by the language folder + names and ``Resource Language`` values of Windows MSIX packages. + + See :class:`Bcp47Lang` for the accepted grammar, the validation + rules and the serialization policy. + """ + + class WinLcid: """ Represents a Windows language identifier (LANGID), the 2-byte language diff --git a/src/metaglot/langmap/appstream_lang_map.py b/src/metaglot/langmap/appstream_lang_map.py index 26f2d94..52b717e 100644 --- a/src/metaglot/langmap/appstream_lang_map.py +++ b/src/metaglot/langmap/appstream_lang_map.py @@ -1,45 +1,16 @@ from ..langid import AppStreamLang, PoLang - -# YYC MARK: -# Gettext's variant slot may hold either a script designator or a -# genuine variant, and BCP 47 places the two at different positions with -# different vocabularies ('latin' != 'Latn'), so the conversion cannot be -# done mechanically. Candidate matching is done here as a workaround, with -# the script interpretation tried first: 'sr_RS@latin' becomes the -# semantically correct 'sr-Latn-RS', not 'sr-RS-latin'. This is not the -# full ISO 15924 table, only the words Gettext uses for scripts; extend it -# as needed. -_SCRIPT_WORDS: dict[str, str] = { - "latin": "Latn", - "cyrillic": "Cyrl", - "arabic": "Arab", -} +from . import bcp47 def convert(lang: PoLang) -> AppStreamLang: """ Convert a PO file language to a BCP 47 language tag. - The language and country codes carry over unchanged (hyphenated), and - the PO variant slot fills either the script subtag or the variant - subtag, whichever interpretation the variant word supports. Variants - matching no script word are emitted as BCP 47 variant subtags and are - rejected by :class:`AppStreamLang` when the IANA registry does not - register them (e.g. the legacy Gettext variant 'euro'). + The tag is composed and validated by :func:`metaglot.langmap.bcp47.convert` + on behalf of :class:`AppStreamLang`. :param lang: The PO file language. :return: The BCP 47 language tag for the language. :raises ValueError: If the composed tag is not a valid BCP 47 tag. """ - segments = [lang.language] - script = _SCRIPT_WORDS.get(lang.variant) if lang.variant is not None else None - if script is not None: - segments.append(script) - if lang.country is not None: - segments.append(lang.country) - else: - if lang.country is not None: - segments.append(lang.country) - if lang.variant is not None: - segments.append(lang.variant) - return AppStreamLang("-".join(segments)) + return bcp47.convert(lang, AppStreamLang) diff --git a/src/metaglot/langmap/bcp47.py b/src/metaglot/langmap/bcp47.py new file mode 100644 index 0000000..12afb24 --- /dev/null +++ b/src/metaglot/langmap/bcp47.py @@ -0,0 +1,48 @@ +from ..langid import Bcp47Lang, PoLang + +# YYC MARK: +# Gettext's variant slot may hold either a script designator or a +# genuine variant, and BCP 47 places the two at different positions with +# different vocabularies ('latin' != 'Latn'), so the conversion cannot be +# done mechanically. Candidate matching is done here as a workaround, with +# the script interpretation tried first: 'sr_RS@latin' becomes the +# semantically correct 'sr-Latn-RS', not 'sr-RS-latin'. This is not the +# full ISO 15924 table, only the words Gettext uses for scripts; extend it +# as needed. +_SCRIPT_WORDS: dict[str, str] = { + "latin": "Latn", + "cyrillic": "Cyrl", + "arabic": "Arab", +} + + +def convert[T: Bcp47Lang](lang: PoLang, cls: type[T]) -> T: + """ + Convert a PO file language to a BCP 47 language tag. + + The tag is composed from the rules shared by every BCP 47 based + target: the language and country codes carry over unchanged + (hyphenated), and the PO variant slot fills either the script subtag + or the variant subtag, whichever interpretation the variant word + supports. The composed tag is validated once by ``cls`` itself during + the construction: legacy Gettext variants that the IANA registry does + not register (e.g. 'euro') are rejected there. + + :param lang: The PO file language. + :param cls: The BCP 47 class to construct, e.g. ``AppStreamLang`` or + ``MsixLang``. + :return: The BCP 47 language tag of class ``cls``. + :raises ValueError: If the composed tag is not a valid BCP 47 tag. + """ + segments = [lang.language] + script = _SCRIPT_WORDS.get(lang.variant) if lang.variant is not None else None + if script is not None: + segments.append(script) + if lang.country is not None: + segments.append(lang.country) + else: + if lang.country is not None: + segments.append(lang.country) + if lang.variant is not None: + segments.append(lang.variant) + return cls("-".join(segments)) diff --git a/src/metaglot/langmap/msix_lang_map.py b/src/metaglot/langmap/msix_lang_map.py new file mode 100644 index 0000000..9f4e0fe --- /dev/null +++ b/src/metaglot/langmap/msix_lang_map.py @@ -0,0 +1,16 @@ +from ..langid import MsixLang, PoLang +from . import bcp47 + + +def convert(lang: PoLang) -> MsixLang: + """ + Convert a PO file language to a BCP 47 language tag. + + The tag is composed and validated by :func:`metaglot.langmap.bcp47.convert` + on behalf of :class:`MsixLang`. + + :param lang: The PO file language. + :return: The BCP 47 language tag for the language. + :raises ValueError: If the composed tag is not a valid BCP 47 tag. + """ + return bcp47.convert(lang, MsixLang) diff --git a/src/metaglot/render.py b/src/metaglot/render.py index a6bf2cb..5ecd21f 100644 --- a/src/metaglot/render.py +++ b/src/metaglot/render.py @@ -74,3 +74,32 @@ def render( output_path.write_text(content, encoding="utf-8") logging.info("Wrote rendered output to %s", output_path) + + +def render_path( + env: Environment, template_source: str, context: RenderContext +) -> Path: + """Render an in-memory Liquid template and return the result as a path. + + Like :func:`render`, the environment is provided by the caller. The + template is an in-memory string (typically a command line argument) + instead of a file. The rendered output is stripped of surrounding + whitespace and must not be empty. + + :param env: The environment to render with. + :param template_source: The source of the template to render. + :param context: The top-level render context passed to the template. + :return: The rendered path. + :raises ValueError: If the rendered result is empty. + :raises RuntimeError: If the template cannot be rendered. + """ + try: + template = env.from_string(template_source) + content = template.render(context) + except LiquidError as exc: + raise RuntimeError(f"failed to render path template: {exc}") from exc + + content = content.strip() + if not content: + raise ValueError("rendered path template result is empty") + return Path(content)