feat: add MSIX file support

This commit is contained in:
2026-09-20 11:41:35 +08:00
parent d17bde9b00
commit 18a7309ac7
14 changed files with 694 additions and 49 deletions
+15
View File
@@ -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 %}
```
+78
View File
@@ -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/<lang>/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 `<Resources>` section, where the first listed language acts as the default one:
```xml
<Resources>
{% for lang in langs %}
<Resource Language="{{ lang }}" />
{% endfor %}
</Resources>
```
`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.
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="Example" Publisher="CN=Example" Version="1.0.0.0" />
<Properties>
<DisplayName>ms-resource:AppDisplayName</DisplayName>
<PublisherDisplayName>ms-resource:PublisherDisplayName</PublisherDisplayName>
<Logo>Assets\Square44x44Logo.png</Logo>
</Properties>
<Resources>
{% for lang in langs %}
<Resource Language="{{ lang }}" />
{% endfor %}
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.22000.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application Id="Example" Executable="example.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="ms-resource:AppDisplayName"
Description="ms-resource:AppDescription"
BackgroundColor="transparent"
Square44x44Logo="Assets\Square44x44Logo.png"
Square150x150Logo="Assets\Square150x150Logo.png" />
<Extensions>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="jpegimages">
<uap:DisplayName>ms-resource:FileTypeJpegDisplayName</uap:DisplayName>
<uap:Logo>Assets\FileAssoc\jpegimages.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.jpeg</uap:FileType>
<uap:FileType>.jpg</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="pngimages">
<uap:DisplayName>ms-resource:FileTypePngDisplayName</uap:DisplayName>
<uap:Logo>Assets\FileAssoc\pngimages.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.png</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
</Applications>
</Package>
+22
View File
@@ -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."
+7
View File
@@ -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 }}"
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms</value>
</resheader>
<data name="AppDisplayName" xml:space="preserve">
<value>{{ strings.app_display_name | xml_escape }}</value>
</data>
<data name="PublisherDisplayName" xml:space="preserve">
<value>{{ strings.publisher_display_name | xml_escape }}</value>
</data>
<data name="AppDescription" xml:space="preserve">
<value>{{ strings.app_description | xml_escape }}</value>
</data>
<data name="FileTypeJpegDisplayName" xml:space="preserve">
<value>{{ strings.file_type_jpeg | xml_escape }}</value>
</data>
<data name="FileTypePngDisplayName" xml:space="preserve">
<value>{{ strings.file_type_png | xml_escape }}</value>
</data>
</root>
+12 -2
View File
@@ -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}")
+301
View File
@@ -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,
)
+38
View File
@@ -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)
+33 -14
View File
@@ -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
+4 -33
View File
@@ -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)
+48
View File
@@ -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))
+16
View File
@@ -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)
+29
View File
@@ -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)