Compare commits
34
Commits
312e323d1d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d751c64c | ||
|
|
45bf57d936 | ||
|
|
ea4b629d2c | ||
|
|
387808f482 | ||
|
|
3069ad4e26 | ||
|
|
5491092037 | ||
|
|
cc9d1b534e | ||
|
|
ef8103f1fd | ||
|
|
046c06679c | ||
|
|
429422715c | ||
|
|
835e3437c7 | ||
|
|
6f9a712813 | ||
|
|
d2254d5491 | ||
|
|
5e9fde5052 | ||
|
|
29c2b6d1f6 | ||
|
|
18a7309ac7 | ||
|
|
d17bde9b00 | ||
|
|
aaa7eea338 | ||
|
|
1c40731ffa | ||
|
|
a527b5807a | ||
|
|
7e9f4323c1 | ||
|
|
650f7cd6d7 | ||
|
|
deb94dd51a | ||
|
|
b33d634020 | ||
|
|
f1bb0e2a0c | ||
|
|
7986c72ff0 | ||
|
|
93c69f764d | ||
|
|
0350e139c1 | ||
|
|
b4dbd34c3b | ||
|
|
08d47c3ea8 | ||
|
|
4a82292aff | ||
|
|
d0bca69006 | ||
|
|
cea485f85c | ||
|
|
a0a48b1865 |
+75
@@ -0,0 +1,75 @@
|
||||
# Developer Notes
|
||||
|
||||
This document introduces some notes for the developer of MetaGlot.
|
||||
|
||||
## Reference
|
||||
|
||||
This chapter introduces all references used when developing MetaGlot.
|
||||
|
||||
### PO File
|
||||
|
||||
We fetch the standard of `Language` field of metadata of PO file from [GNU Gettext document](https://www.gnu.org/software/gettext/manual/html_node/Header-Entry.html). And we use it to create the representation of PO file language.
|
||||
|
||||
### Windows RC File
|
||||
|
||||
Some constant values located in Windows RC file rely on Microsoft LCID. We use [Microsoft provided LCID document](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/70feba9f-294e-491e-b6eb-56532684c37f) to create LCID representation and convertion code which converts PO file language representation to LCID representation. The adopted version of this specification is **revision 2024-04-23**.
|
||||
|
||||
The string used in Windows RC file is basically C string literal. We design corresponding Liquid filter based on this.
|
||||
|
||||
### `.desktop` File
|
||||
|
||||
We browse [FreeDesktop provided `.desktop` file format document](https://specifications.freedesktop.org/desktop-entry/1.5/localized-keys.html) to get the standard of its localized key. Then we use it to create the representation of `.desktop` file language, and convertion code which converts PO file language representation to this representation. The adopted version of specification is **1.5**.
|
||||
|
||||
We also browse [FreeDesktop provided document about valid string format in `.desktop` file](https://specifications.freedesktop.org/desktop-entry/1.5/value-types.html), and create corresponding Liquid filter based on it.
|
||||
|
||||
### AppStream Metadata File
|
||||
|
||||
AppStream XML file use standard `xml:lang` attribute to mark its languages and locales. According to the [standard of XML](https://www.w3.org/TR/REC-xml/), the value of it must follow BCP 47. We use it to design the representation of AppStream metadata file language, and convertion code which converts PO file language representation to this representation.
|
||||
|
||||
Based on the example AppStream metadata file, all strings which need to be inserted are located between two XML labels, or at the value of attribute. So we simply create corresponding Liquid filter according to the [standard of XML](https://www.w3.org/TR/REC-xml/).
|
||||
|
||||
### Windows MSIX Files
|
||||
|
||||
Windows MSIX involves multiple file formats. All formats of them can be browsed from their official documents:
|
||||
|
||||
- `Package.appxmanifest` (for Visual Studio project use) and `AppxManifest.xml` (for final delivery): https://learn.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/schema-root
|
||||
- `.resw` and `.resjson` file: https://learn.microsoft.com/en-us/previous-versions/windows/apps/jj552944
|
||||
|
||||
We use these formats to create Liquid filters.
|
||||
|
||||
Additionally, we check [the document about localization file path](https://learn.microsoft.com/en-us/windows/uwp/app-resources/localize-strings-ui-manifest) to know the language tag used by Windows MSIX. Then we use it to create the representation of it, and convertion code which converts PO file language representation to this representation.
|
||||
|
||||
### macOS Bundle Files
|
||||
|
||||
macOS `.plist` and its localization mechanism involve multiple file formats. All formats of them can be browsed from their official documents:
|
||||
|
||||
- `Info.plist` file: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PropertyLists/UnderstandXMLPlist/UnderstandXMLPlist.html
|
||||
- Available keys and values in `Info.plist` file: https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Introduction/Introduction.html
|
||||
- `InfoPlist.strings` and other string resource files: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html
|
||||
- UTI string: https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/understanding_utis/understand_utis_conc/understand_utis_conc.html
|
||||
|
||||
Based on these document, we know`.plist` file is ordinary XML file, so we create corresponding Liquid filter for it. Oppositely, for `.strings` file, according to [Apple document](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html), we also create corresponding filter.
|
||||
|
||||
[Apple document](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledevelopmentregion) doesn't explicitly declare the standard of `<lang>` part in expected `.strings` file path `Contents/Resources/<lang>.lproj/InfoPlist.strings`. We only can tract its history based on [Apple old document](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html). First it is "human-readable names". such as `English`, `German`, and etc. Then it becomes `language_region`, like `en_GB`. And finally, it possible be a BCP 47 string now based on multiple modern Apple documents. So we are forced to follow this vague standard to design the representation of it and may change it in future if we meet with some issues or more clear standard specification.
|
||||
|
||||
### InnoSetup Script Files
|
||||
|
||||
InnoSetup installation involves multiple file formats. All formats of them can be browsed from their official documents:
|
||||
|
||||
- `.iss` script file: https://jrsoftware.org/ishelp/index.php?topic=languages (the `[Languages]` section) and https://jrsoftware.org/ishelp/index.php?topic=custommessages (the `[CustomMessages]` section)
|
||||
- `.isl` messages file: https://jrsoftware.org/ishelp/index.php?topic=langoptions (the `[LangOptions]` section) and https://jrsoftware.org/ishelp/index.php?topic=messages (the `[Messages]` section)
|
||||
|
||||
Based on these document, we create corresponding Liquid filters for both files, since they share the same message value format.
|
||||
|
||||
Unlike the other targets, InnoSetup has no standard representation of language identifiers: both the internal language names declared in the `[Languages]` section and the message file names are chosen freely by the packager, as its official document says. So there is no language representation or convertion code for this target. The PO language tag carries over verbatim, and the pairing between a language and its base `.isl` file is expressed explicitly by the template author.
|
||||
|
||||
## Bump Version Up
|
||||
|
||||
### Bump Application Version Up
|
||||
|
||||
TODO...
|
||||
|
||||
### Bump Manifest Version Up
|
||||
|
||||
- Update version value in document `doc/manifest.md`.
|
||||
- Update version value in example `example/*.toml`.
|
||||
@@ -11,29 +11,54 @@
|
||||
|
||||
MetaGlot is a command-line tool that unifies localization metadata for applications shipped on multiple platforms. It extracts or defines translatable strings from platform-specific metadata files, normalizes them into Gettext-compatible POT/PO catalogs, and compiles translated PO files back into platform-native files.
|
||||
|
||||
It currently is designed for projects that ship on:
|
||||
|
||||
- **Windows** using RC resource files
|
||||
- **Linux** using `.desktop` and AppStream MetaInfo XML files
|
||||
- **macOS** using `Info.plist` and localized `InfoPlist.strings` files
|
||||
It currently is designed for projects that ship on Windows, Linux and macOS.
|
||||
|
||||
Instead of asking translators to edit platform-specific formats, MetaGlot lets them work with standard Gettext PO files.
|
||||
|
||||
## Why MetaGlot?
|
||||
|
||||
Every platform has its own localization mechanism for application metadata:
|
||||
|
||||
| Platform | Metadata format | Localization mechanism |
|
||||
|---|---|---|
|
||||
| Windows | `.rc` | String tables, version resources |
|
||||
| Linux | `.desktop` | `Name`, `Comment`, etc. |
|
||||
| Linux | AppStream XML | `<name>`, `<summary>`, `<description>` |
|
||||
| macOS | `Info.plist` | `InfoPlist.strings`, plist keys |
|
||||
Every platform has its own localization mechanism for application metadata. Windows applications use RC resource files to hold string tables and version resources, Linux applications use `.desktop` files to describe names and comments, and macOS applications use `Info.plist` together with localized `InfoPlist.strings` files.
|
||||
|
||||
These formats are necessary for packaging and distribution, but they are inconvenient as translation sources. MetaGlot introduces a common localization layer.
|
||||
|
||||
MetaGlot does not replace Gettext. It makes Gettext the canonical translation workflow for platform metadata.
|
||||
|
||||
## Supported Metadata Types
|
||||
|
||||
MetaGlot supports compiling metadata from the following formats:
|
||||
|
||||
| Platform | Metadata format |
|
||||
|---|---|
|
||||
| Windows | Windows resource file `.rc` |
|
||||
| Windows | MSIX manifest (`Package.appxmanifest` or `AppxManifest.xml`) and localization files (`.resw` or `.resjson`) |
|
||||
| Windows | InnoSetup script (`.iss`) and its localization files (`.isl`) |
|
||||
| Linux | `.desktop` file |
|
||||
| Linux | AppStream XML file (`.metainfo.xml`) |
|
||||
| macOS | `Info.plist` and its localization files (`InfoPlist.strings`) |
|
||||
|
||||
## Getting Started
|
||||
|
||||
A typical MetaGlot workflow consists of four steps:
|
||||
|
||||
1. Write the TOML string manifest of your metadata — extracted by a tool or written by hand.
|
||||
2. Generate a POT template from the manifest with `metaglot pot`.
|
||||
3. Bring the translation PO files up to date with the POT using `metaglot update`.
|
||||
4. Produce the platform-native files with `metaglot render` for every metadata type you ship.
|
||||
|
||||
When you need more information along the way, refer to the following resources:
|
||||
|
||||
- Run a command with the `--help` option to inspect its command line arguments.
|
||||
- Read the documents in the `doc` directory for the TOML manifest format, the render contexts and the available Liquid filters.
|
||||
- Browse the examples in the `example` directory for ready-to-study samples.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
MetaGlot uses following environment variables at runtime:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `METAGLOT_MSGMERGE` | Path to the `msgmerge` executable used by `metaglot update`, overriding the default lookup on `PATH`. |
|
||||
|
||||
## License
|
||||
|
||||
MetaGlot is released under the MIT License. See `LICENSE` for details.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Liquid Filters
|
||||
|
||||
MetaGlot renders output files through [Liquid](https://shopify.github.io/liquid/) templates. The filters MetaGlot provides are grouped by rendering target and described in this document.
|
||||
|
||||
The rendering environment runs in strict mode: referencing a variable or a property that does not exist fails the render.
|
||||
|
||||
## General Filters
|
||||
|
||||
The following filters are available in every rendering.
|
||||
|
||||
| Filter | Description |
|
||||
|---|---|
|
||||
| `to_string` | Convert the value to its string representation. |
|
||||
| `format` | Treat the value as a Python format template and apply `str.format` with the arguments passed to the filter. |
|
||||
| `to_integer` | Convert the value to an integer. |
|
||||
| `to_float` | Convert the value to a float. |
|
||||
| `is_integer` | Whether the value can be parsed as an integer. |
|
||||
| `is_float` | Whether the value can be parsed as a float. |
|
||||
|
||||
Examples:
|
||||
|
||||
```liquid
|
||||
{{ 1000 | to_string }} {% comment %}renders "1000"{% endcomment %}
|
||||
{{ "ID: {0}" | format: 1001 }} {% comment %}renders "ID: 1001"{% endcomment %}
|
||||
```
|
||||
|
||||
## RC Filters
|
||||
|
||||
The following filters are available only when rendering Windows RC files.
|
||||
|
||||
| Filter | Description |
|
||||
|---|---|
|
||||
| `rc_escape` | Escapes a string for use inside a double-quoted C-style string literal, such as an RC string table entry: `\` becomes `\\` and `"` becomes `\"`. |
|
||||
|
||||
## Desktop Filters
|
||||
|
||||
The following filters are available only when rendering desktop entry files.
|
||||
|
||||
| Filter | Description |
|
||||
|---|---|
|
||||
| `desktop_escape` | Escapes a string for use as a desktop entry `string`, `localestring` or `iconstring` value: `\` becomes `\\`, newline becomes `\n`, carriage return becomes `\r` and tab becomes `\t` anywhere; leading and trailing spaces become `\s`. Semicolons are not escaped, because they separate the values of plural keys (e.g. `Keywords`); escape them with the standard `replace` filter when composing such values item by item. |
|
||||
|
||||
## AppStream Filters
|
||||
|
||||
The following filters are available only when rendering AppStream XML files.
|
||||
|
||||
| Filter | Description |
|
||||
|---|---|
|
||||
| `xml_escape` | Escapes a string for use in XML text content or attribute values: `&` becomes `&`, `<` becomes `<`, `>` becomes `>`, `"` becomes `"` and `'` becomes `'`. Values that intentionally carry AppStream markup (e.g. `<em>` inside descriptions) must not be passed through this filter; whether to escape is the template author's decision. |
|
||||
|
||||
Examples:
|
||||
|
||||
```liquid
|
||||
{{ "Example" | xml_escape }} {% comment %}renders "Example"{% endcomment %}
|
||||
```
|
||||
|
||||
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 %}
|
||||
```
|
||||
|
||||
## Plist Filters
|
||||
|
||||
The following filters are available when rendering macOS bundle files. `xml_escape` is available to the Info.plist file rendering; `plist_strings_escape` is available to the InfoPlist.strings file rendering.
|
||||
|
||||
| Filter | Available to | Description |
|
||||
|---|---|---|
|
||||
| `xml_escape` | Info.plist | The same filter described in the AppStream section above; Info.plist is XML too. |
|
||||
| `plist_strings_escape` | InfoPlist.strings | Escapes a string for use inside an InfoPlist.strings value: `\` becomes `\\`, `"` becomes `\"`, newline, carriage return and tab become `\n`, `\r` and `\t`. Non-ASCII characters carry over unchanged; use the `\Uxxxx` form manually for the rare cases a literal escape is required. |
|
||||
|
||||
## Inno Filters
|
||||
|
||||
The following filter is available to both the `.iss` and the `.isl` file renderings of the Inno Setup pipeline, which share the same message value escaping.
|
||||
|
||||
| Filter | Description |
|
||||
|---|---|
|
||||
| `inno_escape` | Escapes a string for use inside an Inno Setup message value: linefeed and carriage return become `%n`. The `%1`..`%9` argument placeholders and a literal `%` are left untouched - the `%%` escape is only defined for messages that take arguments, so managing `%` is the translator's responsibility. |
|
||||
+18
-23
@@ -1,37 +1,33 @@
|
||||
# String Manifest
|
||||
|
||||
This document describes how to write a MetaGlot string manifest: the TOML file that
|
||||
declares the translatable strings of an application's metadata.
|
||||
This document describes how to write a MetaGlot string manifest: the TOML file that declares the translatable strings of an application's metadata.
|
||||
|
||||
The manifest is platform-neutral. It only describes *what* can be translated, not how
|
||||
translations end up in any particular platform format; MetaGlot reads it to produce
|
||||
Gettext translation catalogs and to fill platform-native output files. The file must
|
||||
be encoded in UTF-8.
|
||||
The manifest is platform-neutral. It only describes *what* can be translated, not how translations end up in any particular platform format; MetaGlot reads it to produce Gettext translation catalogs and to fill platform-native output files. The file must be encoded in UTF-8.
|
||||
|
||||
## Top-level fields
|
||||
## Top-Level Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `version` | int | The manifest format version. Currently `1`; any other value is rejected. |
|
||||
| `strings` | table | All translatable strings. Each item maps a string entry key to a string entry, see below. |
|
||||
| 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
|
||||
## String Entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `msgid` | string | Required. The untranslated source string. |
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `msgid` | string | Required. The untranslated source string. |
|
||||
| `context` | string | Optional. Disambiguates entries that share the same `msgid` but are used differently, mirroring the context (`msgctxt`) mechanism of Gettext. Defaults to empty. |
|
||||
| `comment` | string | Optional. A note for translators explaining where and how the string is used. Defaults to empty. |
|
||||
|
||||
The string entry key identifies the entry inside the manifest and in generated
|
||||
outputs. It must be non-empty; both numeric IDs (e.g. `1000`) and names
|
||||
(e.g. `file_description`) are acceptable, and numeric IDs are represented as strings.
|
||||
The string entry key identifies the entry inside the manifest and in generated outputs. It must be non-empty; both numeric IDs (e.g. `1000`) and names (e.g. `file_description`) are acceptable, and numeric IDs are represented as strings.
|
||||
|
||||
## Validation rules
|
||||
## Validation Rules
|
||||
|
||||
A manifest is rejected when:
|
||||
|
||||
- `version` is not `1`.
|
||||
- `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.
|
||||
@@ -40,6 +36,7 @@ A manifest is rejected when:
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.panel_title]
|
||||
msgid = "Example - Image Viewer"
|
||||
@@ -64,6 +61,4 @@ msgid = "Example"
|
||||
comment = "Display name of the product."
|
||||
```
|
||||
|
||||
Note that entries `panel_title` and `file_description` share the same `msgid`. This
|
||||
is allowed because their `context` values differ; without distinct contexts the
|
||||
combination would be rejected as ambiguous.
|
||||
Note that entries `panel_title` and `file_description` share the same `msgid`. This is allowed because their `context` values differ; without distinct contexts the combination would be rejected as ambiguous.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# RC Render Context
|
||||
|
||||
This document describes the data that `metaglot rc render` provides to a user-provided
|
||||
[Liquid](https://shopify.github.io/liquid/) template when rendering Windows RC files.
|
||||
|
||||
The renderer runs in strict mode: referencing a variable or a property that does not
|
||||
exist fails the render. Output is written as UTF-8 (without BOM) using LF line endings.
|
||||
|
||||
## Top-level variables
|
||||
|
||||
| Variable | Type | Description |
|
||||
|-------------|-------------------|-------------|
|
||||
| `languages` | array of language | One entry per language, see below. The English (`en`) entry is always present and comes first; remaining entries follow, ordered by Windows language identifier. |
|
||||
|
||||
## Language object
|
||||
|
||||
| Property | Type | Description |
|
||||
|---------------|--------|-------------|
|
||||
| `name` | string | Human-readable language name, e.g. `English (United States)`. |
|
||||
| `locale` | string | Gettext-style locale code, e.g. `en`, `zh_CN`. |
|
||||
| `langid` | string | Windows language identifier in hexadecimal, e.g. `0x409`. |
|
||||
| `primary` | string | Windows primary language identifier in hexadecimal, e.g. `0x09`. |
|
||||
| `sublanguage` | string | Windows sublanguage identifier in hexadecimal, e.g. `0x01`. |
|
||||
| `block_key` | string | Eight hex digits combining `langid` and code page 1200 (`0x4B0`), suitable as a `StringFileInfo` block key, e.g. `040904B0`. |
|
||||
| `strings` | array of string entry | All manifest entries, in manifest order, see below. |
|
||||
| `named` | mapping of string to string | Maps every entry key to its translated text, convenient for direct access such as `lang.named.file_description`. |
|
||||
|
||||
The locale of a PO file is resolved from its `Language:` header, falling back to the
|
||||
file name without extension. Locales without a Windows language mapping are rejected.
|
||||
|
||||
## String entry object
|
||||
|
||||
| Property | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `key` | string | The entry key from the manifest, e.g. `100` or `file_description`. Numeric resource IDs are represented as strings. |
|
||||
| `msgid` | string | The untranslated source string. |
|
||||
| `text` | string | The translated text for this language. Falls back to `msgid` when the entry is untranslated or marked fuzzy. Provided raw, without any escaping. |
|
||||
| `context` | string | The entry context from the manifest, e.g. `File type name for: jpg, jpeg, jfif`. |
|
||||
|
||||
## Filters
|
||||
|
||||
| Filter | Description |
|
||||
|-------------|-------------|
|
||||
| `rc_escape` | Escapes a string for use inside a double-quoted RC string literal: `\` becomes `\\` and `"` becomes `\"`. |
|
||||
@@ -0,0 +1,22 @@
|
||||
# Render Context
|
||||
|
||||
A render context is the set of top-level variables that MetaGlot passes into a user-provided Liquid template when `metaglot render` executes. The context carries the resolved translation data — one entry per language read from the PO files, each holding the text of every manifest entry — together with the extra variables a metadata type needs, such as the manifest's source language or per-language identifiers.
|
||||
|
||||
Every rendering shares the following semantics:
|
||||
|
||||
- Templates are read as UTF-8, and the rendered output is written as UTF-8 (without BOM) using the system's native line endings. The parent folder of an output file is created when it does not exist.
|
||||
- The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render.
|
||||
- The 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 language of a PO file is resolved from its `Language:` header, falling back to the file name without extension; two PO files describing the same language and PO entries unknown to the manifest are rejected.
|
||||
- Some metadata types consist of multiple files; for them the `--*-path` command line arguments are themselves in-memory Liquid templates, rendered with the same mechanism into per-file output paths.
|
||||
|
||||
Filters are documented separately in [filters.md](filters.md).
|
||||
|
||||
The render context of every supported metadata type is described in its own document:
|
||||
|
||||
- [Windows RC resource files](render-context/rc.md) — language identifiers, string table statements and version information values.
|
||||
- [Windows MSIX packages](render-context/msix.md) — the package manifest and the per-language resources files.
|
||||
- [Windows InnoSetup scripts](render-context/inno.md) — the installation script and the per-language messages files.
|
||||
- [Linux desktop entries](render-context/desktop.md) — localized keys with locale postfixes.
|
||||
- [Linux AppStream metadata](render-context/appstream.md) — translations marked with `xml:lang`.
|
||||
- [macOS bundles](render-context/plist.md) — the bundle `Info.plist` and the per-language `InfoPlist.strings` files.
|
||||
@@ -0,0 +1,48 @@
|
||||
# AppStream Render Context
|
||||
|
||||
This document describes the data that `metaglot render appstream` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering AppStream metainfo XML files.
|
||||
|
||||
MetaGlot reads templates as UTF-8 and writes the 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. AppStream XML files are UTF-8 by definition, so the XML declaration should say so (see the `example` folder for a complete sample).
|
||||
|
||||
The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render.
|
||||
|
||||
## Top-Level Variables
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `languages` | array of language | One entry per translation language, see below. |
|
||||
| `default` | language | The manifest's source language entry, see below. |
|
||||
|
||||
The order of the `languages` entries is not guaranteed: they appear in the order their PO files are matched. The manifest's source language is **not** merged into the array (unlike the RC rendering, which always synthesizes it): `languages` holds exactly the languages of the provided PO files, and the source language is exposed separately as the top-level `default` variable, built directly from the manifest's source strings without reading any PO file. If a PO file for the source language is provided, its language appears in the array as a regular entry alongside `default`.
|
||||
|
||||
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. Two PO files describing the same language, and PO entries unknown to the manifest, are rejected.
|
||||
|
||||
## Language Object
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. |
|
||||
| `lang` | string | The BCP 47 language tag for `xml:lang`, e.g. `en-US`, `zh-CN`, `sr-Latn-RS`, `ca-ES-valencia`. |
|
||||
| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.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.
|
||||
|
||||
### Language Tag Conversion
|
||||
|
||||
The `lang` property holds a BCP 47 language tag, as required by the `xml:lang` attribute: the language and country codes of the PO language carry over unchanged, hyphenated. The Gettext variant slot fills either the script subtag or the variant subtag, whichever interpretation the variant word supports: known script words (`latin`, `cyrillic`, `arabic`) become script subtags placed before the country (`sr_RS@latin` becomes `sr-Latn-RS`), while other words become variant subtags placed after it (`ca_ES@valencia` becomes `ca-ES-valencia`).
|
||||
|
||||
Tags are validated against BCP 47 with the IANA subtag registry: legacy Gettext variants that are not registered there (e.g. `euro`) are rejected with an error instead of being emitted as invalid `xml:lang` values. The conversion never rewrites a tag beyond hyphenation: no script is injected or removed, and no macrolanguage folding happens, so an emitted `xml:lang` is always a faithful hyphenation of its Gettext source tag.
|
||||
|
||||
## Default Object
|
||||
|
||||
The top-level `default` is a language object of the same shape as the entries of `languages`. It describes the manifest's source language (`source_language`) and is always present. It is built directly from the manifest's source strings — never from PO files — and is not part of the `languages` array: iterating `languages` yields the translation languages only.
|
||||
|
||||
AppStream uses the element without `xml:lang` as the untranslated default, mirroring how desktop entry files use unlocalized keys. `default.strings` is the intended source for those elements:
|
||||
|
||||
```liquid
|
||||
<name>{{ default.strings.name | xml_escape }}</name>
|
||||
```
|
||||
|
||||
## Filters
|
||||
|
||||
All filters described in [filters.md](../filters.md) are available when rendering AppStream XML files: the general filters available in every rendering, and the AppStream filters such as `xml_escape`. Note that translated descriptions may legitimately carry AppStream markup (e.g. `<em>`); such values must not be passed through `xml_escape`, and deciding what to escape is the template author's responsibility.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Desktop Render Context
|
||||
|
||||
This document describes the data that `metaglot render desktop` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering freedesktop desktop entry files.
|
||||
|
||||
MetaGlot reads templates as UTF-8 and writes the 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. Desktop entry files are UTF-8 by definition, so no encoding declaration is needed (see the `example` folder for a complete sample).
|
||||
|
||||
The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render.
|
||||
|
||||
## Top-Level Variables
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `languages` | array of language | One entry per translation language, see below. |
|
||||
| `default` | language | The manifest's source language entry, see below. |
|
||||
|
||||
The order of the `languages` entries is not guaranteed: they appear in the order their PO files are matched. The manifest's source language is **not** merged into the array (unlike the RC rendering, which always synthesizes it): `languages` holds exactly the languages of the provided PO files, and the source language is exposed separately as the top-level `default` variable, built directly from the manifest's source strings without reading any PO file. If a PO file for the source language is provided, its language appears in the array as a regular entry alongside `default`.
|
||||
|
||||
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. Two PO files describing the same language, and PO entries unknown to the manifest, are rejected.
|
||||
|
||||
## Language Object
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. |
|
||||
| `locale` | string | The desktop entry locale of the language, used as the `LOCALE` postfix of localized keys, e.g. `en_US`, `zh_CN`, `sr_RS@latin`. |
|
||||
| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.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 `locale` property holds the value defined by the desktop entry specification for localized keys: the language and country codes carry over from the PO language unchanged, and the Gettext variant slot fills the locale's modifier slot (e.g. `sr_RS@latin` stays `sr_RS@latin`). Every valid PO language converts successfully; there is no "unmapped language" rejection (unlike RC rendering).
|
||||
|
||||
## Default Object
|
||||
|
||||
The top-level `default` is a language object of the same shape as the entries of `languages`. It describes the manifest's source language (`source_language`) and is always present. It is built directly from the manifest's source strings — never from PO files — and is not part of the `languages` array: iterating `languages` yields the translation languages only.
|
||||
|
||||
The desktop entry specification requires every localized key (e.g. `Name[zh_CN]`) to be accompanied by the same key without a locale postfix (e.g. `Name`). `default.strings` is the intended source for those unlocalized keys:
|
||||
|
||||
```liquid
|
||||
Name={{ default.strings.name | desktop_escape }}
|
||||
```
|
||||
|
||||
## Filters
|
||||
|
||||
All filters described in [filters.md](../filters.md) are available when rendering desktop entry files: the general filters available in every rendering, and the desktop filters such as `desktop_escape`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Inno Render Context
|
||||
|
||||
This document describes the data that `metaglot render inno` provides to user-provided [Liquid](https://shopify.github.io/liquid/) templates when rendering Inno Setup installation files.
|
||||
|
||||
Like the MSIX and plist renderings, an Inno rendering involves multiple output files: one `.iss` script referencing localized custom messages through `{cm:...}` constants, plus one incremental `.isl` messages file per language carrying the `[CustomMessages]` entries. 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. Inno Setup 6.3 and later recommend exactly this encoding for `.iss` and `.isl` files, so the output is used as-is. 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.
|
||||
|
||||
## Language Identification
|
||||
|
||||
Inno Setup has **no standard representation of language identifiers**: the `[Languages]` internal `Name:` and the message file names are both chosen freely by the packager. MetaGlot therefore performs **no conversion** for this target - the PO language tag (`zh_CN`, `en`) carries over verbatim, and the same tag serves as the single join key across everything the pipeline generates:
|
||||
|
||||
- the `Name:` value of a `[Languages]` entry,
|
||||
- the incremental `.isl` file name (e.g. `Strings\zh_CN.isl`),
|
||||
- and the matching branch of the base file pairing in the `.iss` template (see below).
|
||||
|
||||
The pairing between a language and its base `.isl` - the official full translation whose `[LangOptions]` provides the language identity (the Select Language dialog name and the auto-detection identifier) - is knowledge of the template author and is expressed explicitly, for example:
|
||||
|
||||
```liquid
|
||||
{% for lang in langs %}
|
||||
{% case lang %}
|
||||
{% when 'zh_CN' %}
|
||||
{% assign base = 'compiler:Languages\ChineseSimplified.isl' %}
|
||||
{% when 'ja' %}
|
||||
{% assign base = 'compiler:Languages\Japanese.isl' %}
|
||||
{% else %}
|
||||
{% assign base = 'compiler:Default.isl' %}
|
||||
{% endcase %}
|
||||
Name: "{{ lang }}"; MessagesFile: "{{ base }},Strings\{{ lang }}.isl"
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
The multi-line decision block renders some blank lines into the INI output, which is harmless; Liquid's whitespace control markers (`{%-`/`-%}`) can remove them once used consistently across templates.
|
||||
|
||||
Keep the `case` branches aligned with the languages you ship PO files for: an unmatched language falls to the `{% else %}` base (usually `compiler:Default.isl`) and inherits the English language identity. Review the rendered `[Languages]` section after adding a new language.
|
||||
|
||||
## Path Template Contexts
|
||||
|
||||
The `--iss-path` and `--isl-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.
|
||||
|
||||
### `--iss-path`
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| (none) | | The context is empty: the path is typically the plain literal `example.iss`. |
|
||||
|
||||
The template is rendered once. Referencing any variable fails the render.
|
||||
|
||||
### `--isl-path`
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `lang` | string | The PO language tag of the `.isl` messages file being placed, e.g. `en`, `zh_CN`. |
|
||||
|
||||
The template is rendered once per language, for example:
|
||||
|
||||
```liquid
|
||||
Strings/{{ lang }}.isl
|
||||
```
|
||||
|
||||
## File Render Contexts
|
||||
|
||||
The `--iss-template` and `--isl-template` arguments point to template files. Besides the general filters, the Inno filter (`inno_escape`) is available to both.
|
||||
|
||||
### `--iss-template`
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `default` | language | The manifest's source language entry, see below. |
|
||||
| `langs` | array of string | The PO language tag of every language, source language first. |
|
||||
|
||||
The `.iss` script references the localized text through `{cm:...}` constants (e.g. `AppName={cm:app_name}`) and enumerates `[Languages]` from `langs` with the base file pairing shown above. `default` is available for values written directly instead of via constants:
|
||||
|
||||
```liquid
|
||||
AppName={{ default.strings.app_name | inno_escape }}
|
||||
```
|
||||
|
||||
The source language always leads `langs` and always gets an `.isl` messages file: it comes from a PO file when one provides it, and is otherwise synthesized from the manifest's source strings.
|
||||
|
||||
### `--isl-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 PO language tag of this `.isl` messages file, e.g. `en_US`, `zh_CN`. |
|
||||
| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `strings.app_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.
|
||||
|
||||
The recommended shape of a generated `.isl` is a plain `[CustomMessages]` section: language identity (the `[LangOptions]` fields) is inherited from the base `.isl` listed before it in `MessagesFile`, following the merge order of Inno Setup.
|
||||
|
||||
```liquid
|
||||
[CustomMessages]
|
||||
app_name={{ strings.app_name | inno_escape }}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Plist Render Context
|
||||
|
||||
This document describes the data that `metaglot render plist` provides to user-provided [Liquid](https://shopify.github.io/liquid/) templates when rendering macOS bundle metadata files.
|
||||
|
||||
Like the MSIX rendering, a plist rendering involves multiple output files: one `Info.plist` carrying the source-language metadata, plus one `InfoPlist.strings` file per language under a language-tagged folder (typically `Contents/Resources/<lang>.lproj/InfoPlist.strings`). Unlike the MSIX manifest, `Info.plist` carries no resource references: it writes the source strings directly, while every localized value lives in the `InfoPlist.strings` files. 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 `--plist-path` and `--strings-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.
|
||||
|
||||
### `--plist-path`
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| (none) | | The context is empty: the path is typically the plain literal `Contents/Info.plist`. |
|
||||
|
||||
The template is rendered once. Referencing any variable fails the render.
|
||||
|
||||
### `--strings-path`
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `lang` | string | The BCP 47 language tag of the InfoPlist.strings file being placed, e.g. `en`, `zh-Hans`, `sr-Latn-RS`. |
|
||||
|
||||
The template is rendered once per language, for example:
|
||||
|
||||
```liquid
|
||||
Contents/Resources/{{ lang }}.lproj/InfoPlist.strings
|
||||
```
|
||||
|
||||
## File Render Contexts
|
||||
|
||||
The `--plist-template` and `--strings-template` arguments point to template files. Besides the general filters, the Info.plist rendering provides `xml_escape`, and the InfoPlist.strings rendering provides `plist_strings_escape`.
|
||||
|
||||
### `--plist-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. |
|
||||
|
||||
`Info.plist` writes the source strings directly through `default`:
|
||||
|
||||
```liquid
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>{{ default.strings.app_display_name | xml_escape }}</string>
|
||||
```
|
||||
|
||||
`langs` lists every language that gets an InfoPlist.strings file, useful for enumerating `CFBundleLocalizations`:
|
||||
|
||||
```xml
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
{% for lang in langs %}
|
||||
<string>{{ lang }}</string>
|
||||
{% endfor %}
|
||||
</array>
|
||||
```
|
||||
|
||||
Note that `CFBundleLocalizations` declares the localizations handled by the application itself rather than those discovered through the bundle localization mechanism — think of a Qt application localizing through its embedded translations. MetaGlot only knows the metadata languages (the languages receiving an `InfoPlist.strings` file), so rendering `langs` into this key assumes the application's self-handled UI languages and its metadata languages are the same set — the common case when the project translates both in lockstep. When the two differ, maintain the list in the template yourself instead of iterating `langs`. An application localizing entirely through the bundle mechanism usually does not need this key at all.
|
||||
|
||||
The source language always leads `langs` and always gets an InfoPlist.strings 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 `.lproj` folder.
|
||||
|
||||
### `--strings-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 InfoPlist.strings file, e.g. `en`, `zh-Hans-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`. |
|
||||
| `sources` | mapping of string to string | Maps every manifest entry key to its manifest source text, e.g. `sources.file_type_jpeg`. |
|
||||
|
||||
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.
|
||||
|
||||
`InfoPlist.strings` entries use two key forms, and `sources` exists because of the second one:
|
||||
|
||||
- Top-level `Info.plist` values (`CFBundleDisplayName`, `NSHumanReadableCopyright`, the `NS*UsageDescription` keys, ...) are localized with the **Info.plist key name** as the key.
|
||||
- Nested display values inside arrays (`CFBundleTypeName` of a document type, `UTTypeDescription` of an exported UTI, `CFBundleURLName` of a URL scheme) are localized with their **source text** as the key. One entry keyed by the source text localizes every field sharing that text:
|
||||
|
||||
```liquid
|
||||
"CFBundleDisplayName" = "{{ strings.app_display_name | plist_strings_escape }}";
|
||||
"{{ sources.file_type_jpeg | plist_strings_escape }}" = "{{ strings.file_type_jpeg | plist_strings_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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# RC Render Context
|
||||
|
||||
This document describes the data that `metaglot render rc` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering Windows RC files.
|
||||
|
||||
MetaGlot reads templates as UTF-8 and writes the 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. Because the generated file is UTF-8, templates should declare the encoding with `#pragma code_page(65001)` so that the RC compiler parses the file correctly (see the `example` folder for a complete sample).
|
||||
|
||||
The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render.
|
||||
|
||||
## Top-Level Variables
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `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, 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`.
|
||||
|
||||
## Language Object
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. |
|
||||
| `langid` | string | Windows language identifier in hexadecimal, e.g. `0x0409`. |
|
||||
| `primary` | string | Windows primary language identifier in hexadecimal, e.g. `0x09`. |
|
||||
| `sublanguage` | string | Windows sublanguage identifier in hexadecimal, e.g. `0x01`. |
|
||||
| `block_key` | string | Eight hex digits combining `langid` and the Unicode code page, suitable as a `StringFileInfo` block key, e.g. `040904B0`. |
|
||||
| `code_page` | string | The version resource code page in decimal, always `1200` (Unicode, `0x04B0`). It is fixed by MetaGlot's UTF-8 read/write pipeline and shares its source with `block_key`; templates must not attempt to use another code page. |
|
||||
| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.file_description`. |
|
||||
|
||||
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.
|
||||
|
||||
## Filters
|
||||
|
||||
All filters described in [filters.md](../filters.md) are available when rendering RC files: the general filters available in every rendering, and the RC filters such as `rc_escape`.
|
||||
@@ -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>
|
||||
@@ -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."
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>{{ default.strings.app_display_name | xml_escape }}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>example</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.example.Example</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Example</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{ default.strings.copyright | xml_escape }}</string>
|
||||
{% comment %}
|
||||
CFBundleLocalizations declares the localizations the application handles by itself,
|
||||
not those discovered through the bundle localization mechanism.
|
||||
This example assumes the application's UI languages (e.g. Qt-embedded translations) match its metadata languages,
|
||||
so it renders the metadata language list directly;
|
||||
maintain your own list here if the two ever differ.
|
||||
{% endcomment %}
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
{% for lang in langs %}
|
||||
<string>{{ lang }}</string>
|
||||
{% endfor %}
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{ default.strings.file_type_jpeg | xml_escape }}</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.example.Example.jpeg</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{ default.strings.file_type_png | xml_escape }}</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.example.Example.png</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.example.Example.jpeg</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>{{ default.strings.file_type_jpeg | xml_escape }}</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.image</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>jpeg</string>
|
||||
<string>jpg</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.example.Example.png</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>{{ default.strings.file_type_png | xml_escape }}</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.image</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>png</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.app_display_name]
|
||||
msgid = "Example"
|
||||
comment = "Display name of the application, localized via the CFBundleDisplayName entry of InfoPlist.strings."
|
||||
|
||||
[strings.copyright]
|
||||
msgid = "Copyright © 2026 Example contributors."
|
||||
comment = "Copyright notice, localized via the NSHumanReadableCopyright entry of InfoPlist.strings."
|
||||
|
||||
[strings.file_type_jpeg]
|
||||
msgid = "JPEG Image"
|
||||
comment = "Name of the JPEG file type. Localized via an InfoPlist.strings entry keyed by the source text, covering both CFBundleTypeName and UTTypeDescription."
|
||||
|
||||
[strings.file_type_png]
|
||||
msgid = "PNG Image"
|
||||
comment = "Name of the PNG file type. Localized the same way as the JPEG one."
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Application display name. The key is the Info.plist key name. */
|
||||
"CFBundleDisplayName" = "{{ strings.app_display_name | plist_strings_escape }}";
|
||||
/* Copyright notice. The key is the Info.plist key name. */
|
||||
"NSHumanReadableCopyright" = "{{ strings.copyright | plist_strings_escape }}";
|
||||
/* File type names. The key is the source text, covering both CFBundleTypeName and UTTypeDescription. */
|
||||
"{{ sources.file_type_jpeg | plist_strings_escape }}" = "{{ strings.file_type_jpeg | plist_strings_escape }}";
|
||||
"{{ sources.file_type_png | plist_strings_escape }}" = "{{ strings.file_type_png | plist_strings_escape }}";
|
||||
@@ -0,0 +1,39 @@
|
||||
# Examples
|
||||
|
||||
This folder holds one example per supported metadata type. Two kinds of files appear here:
|
||||
|
||||
- `.toml` files are string manifests: they declare the translatable strings of one metadata type.
|
||||
- `.liquid` files are render templates: they decide how the translated strings are written back into the platform-native file.
|
||||
|
||||
## Windows RC Resource File
|
||||
|
||||
- `example.rc.toml` — The string manifest of the RC example.
|
||||
- `example.rc.liquid` — Renders the `.rc` file, including string tables and version information blocks.
|
||||
|
||||
## Windows MSIX Package
|
||||
|
||||
- `AppxManifest.xml.toml` — The string manifest of the MSIX example.
|
||||
- `AppxManifest.xml.liquid` — Renders the `AppxManifest.xml` package manifest, including the resource language list.
|
||||
- `Resources.resw.liquid`, `Resources.resjson.liquid` — Render the per-language resources file. They hold the same content in the two accepted resource formats; pick whichever matches the packaging pipeline.
|
||||
|
||||
## Windows InnoSetup Script
|
||||
|
||||
- `example.iss.toml` — The string manifest of the InnoSetup example.
|
||||
- `example.iss.liquid` — Renders the `.iss` installation script, including its `[Languages]` section.
|
||||
- `example.isl.liquid` — Renders the per-language `.isl` messages files.
|
||||
|
||||
## Linux Desktop Entry
|
||||
|
||||
- `example.desktop.toml` — The string manifest of the desktop entry example.
|
||||
- `example.desktop.liquid` — Renders the `.desktop` file, including localized keys such as `Name[zh_CN]`.
|
||||
|
||||
## Linux AppStream Metadata
|
||||
|
||||
- `example.metainfo.xml.toml` — The string manifest of the AppStream example.
|
||||
- `example.metainfo.xml.liquid` — Renders the `.metainfo.xml` file, including translations marked with `xml:lang`.
|
||||
|
||||
## macOS Bundle
|
||||
|
||||
- `Info.plist.toml` — The string manifest of the macOS bundle example.
|
||||
- `Info.plist.liquid` — Renders the bundle `Info.plist`.
|
||||
- `InfoPlist.strings.liquid` — Renders the per-language `InfoPlist.strings` localization files.
|
||||
@@ -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 }}"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name={{ default.strings.name | desktop_escape }}
|
||||
GenericName={{ default.strings.generic_name | desktop_escape }}
|
||||
Comment={{ default.strings.comment | desktop_escape }}
|
||||
Exec=example %F
|
||||
Icon=org.example.Example
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Categories=Graphics;
|
||||
MimeType=image/bmp;image/jpeg;image/png;image/svg+xml;
|
||||
Keywords={{ default.strings.keywords | desktop_escape }}
|
||||
{% for lang in languages %}
|
||||
Name[{{ lang.locale }}]={{ lang.strings.name | desktop_escape }}
|
||||
GenericName[{{ lang.locale }}]={{ lang.strings.generic_name | desktop_escape }}
|
||||
Comment[{{ lang.locale }}]={{ lang.strings.comment | desktop_escape }}
|
||||
Keywords[{{ lang.locale }}]={{ lang.strings.keywords | desktop_escape }}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,18 @@
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.name]
|
||||
msgid = "Example"
|
||||
comment = "Display name of the application."
|
||||
|
||||
[strings.generic_name]
|
||||
msgid = "Image Viewer"
|
||||
comment = "Generic name describing the application category."
|
||||
|
||||
[strings.comment]
|
||||
msgid = "A lightweight image viewer"
|
||||
comment = "Tooltip-like description of the application."
|
||||
|
||||
[strings.keywords]
|
||||
msgid = "Picture;Image;Viewer;"
|
||||
comment = "Search keywords. Semicolon separated list; the trailing semicolon is required."
|
||||
@@ -0,0 +1,4 @@
|
||||
[CustomMessages]
|
||||
app_name={{ strings.app_name | inno_escape }}
|
||||
app_versioned_name={{ strings.app_versioned_name | inno_escape }}
|
||||
task_desktop_icon={{ strings.task_desktop_icon | inno_escape }}
|
||||
@@ -0,0 +1,30 @@
|
||||
[Setup]
|
||||
AppId=ExampleApp
|
||||
AppName={cm:app_name}
|
||||
AppVersion=1.0.0
|
||||
AppVerName={cm:app_versioned_name,1.0.0}
|
||||
DefaultDirName={autopf}\{cm:app_name}
|
||||
OutputBaseFilename=example-setup
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
|
||||
[Languages]
|
||||
{% for lang in langs -%}
|
||||
{% case lang -%}
|
||||
{% when 'zh_CN' -%}
|
||||
{% assign base = 'compiler:Languages\ChineseSimplified.isl' -%}
|
||||
{% when 'ja' -%}
|
||||
{% assign base = 'compiler:Languages\Japanese.isl' -%}
|
||||
{% else -%}
|
||||
{% assign base = 'compiler:Default.isl' -%}
|
||||
{% endcase -%}
|
||||
Name: "{{ lang }}"; MessagesFile: "{{ base }},Strings\{{ lang }}.isl"
|
||||
{% endfor -%}
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:task_desktop_icon}"
|
||||
|
||||
[Files]
|
||||
Source: "example.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autodesktop}\{cm:app_name}"; Filename: "{app}\example.exe"; Tasks: desktopicon
|
||||
@@ -0,0 +1,14 @@
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.app_name]
|
||||
msgid = "Example"
|
||||
comment = "Name of the application, referenced as {cm:app_name} in the .iss script."
|
||||
|
||||
[strings.app_versioned_name]
|
||||
msgid = "Example %1"
|
||||
comment = "Versioned name of the application, referenced as {cm:app_versioned_name,<version>}. The %1 placeholder receives the version."
|
||||
|
||||
[strings.task_desktop_icon]
|
||||
msgid = "Create a desktop shortcut"
|
||||
comment = "Description of the desktop icon task, referenced as {cm:task_desktop_icon}."
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>org.example.Example</id>
|
||||
<name>{{ default.strings.name | xml_escape }}</name>
|
||||
{% for lang in languages %}
|
||||
<name xml:lang="{{ lang.lang }}">{{ lang.strings.name | xml_escape }}</name>
|
||||
{% endfor %}
|
||||
<summary>{{ default.strings.summary | xml_escape }}</summary>
|
||||
{% for lang in languages %}
|
||||
<summary xml:lang="{{ lang.lang }}">{{ lang.strings.summary | xml_escape }}</summary>
|
||||
{% endfor %}
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<description>
|
||||
<p>{{ default.strings.description | xml_escape }}</p>
|
||||
{% for lang in languages %}
|
||||
<p xml:lang="{{ lang.lang }}">{{ lang.strings.description | xml_escape }}</p>
|
||||
{% endfor %}
|
||||
</description>
|
||||
<developer id="org.example">
|
||||
<name>{{ default.strings.developer_name | xml_escape }}</name>
|
||||
{% for lang in languages %}
|
||||
<name xml:lang="{{ lang.lang }}">{{ lang.strings.developer_name | xml_escape }}</name>
|
||||
{% endfor %}
|
||||
</developer>
|
||||
<launchable type="desktop-id">org.example.Example.desktop</launchable>
|
||||
<url type="homepage">https://example.org/example</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>{{ default.strings.screenshot_caption | xml_escape }}</caption>
|
||||
{% for lang in languages %}
|
||||
<caption xml:lang="{{ lang.lang }}">{{ lang.strings.screenshot_caption | xml_escape }}</caption>
|
||||
{% endfor %}
|
||||
<image type="source">https://example.org/example/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
</component>
|
||||
@@ -0,0 +1,22 @@
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.name]
|
||||
msgid = "Example"
|
||||
comment = "Display name of the application."
|
||||
|
||||
[strings.summary]
|
||||
msgid = "A lightweight image viewer"
|
||||
comment = "One-line summary shown in software centers."
|
||||
|
||||
[strings.description]
|
||||
msgid = "Example is a lightweight and easy-to-use image viewer. It focuses on viewing images and does not include any image management features."
|
||||
comment = "Description paragraph shown in the details page."
|
||||
|
||||
[strings.developer_name]
|
||||
msgid = "Example contributors"
|
||||
comment = "Name of the application developers."
|
||||
|
||||
[strings.screenshot_caption]
|
||||
msgid = "Main window when an image file is loaded"
|
||||
comment = "Caption of the primary screenshot."
|
||||
+20
-13
@@ -4,15 +4,15 @@
|
||||
// String tables
|
||||
// ---------------------------
|
||||
|
||||
{% for lang in languages %}// {{ lang.name }}
|
||||
{% for lang in languages -%}
|
||||
// {{ lang.name }}
|
||||
LANGUAGE {{ lang.primary }}, {{ lang.sublanguage }}
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
{% for i in (1000..1002) %}
|
||||
{%- for i in (1000..1002) -%}
|
||||
{% assign key = i | to_string %}
|
||||
{% assign s = lang.named[key] %}
|
||||
{{ s.key }} "{{ s.text | rc_escape }}" // {{ s.comment }}
|
||||
{% endfor %}
|
||||
{{ i }} "{{ lang.strings[key] | rc_escape }}"
|
||||
{%- endfor %}
|
||||
END
|
||||
{% endfor %}
|
||||
|
||||
@@ -20,33 +20,40 @@ END
|
||||
// Version information
|
||||
// ---------------------------
|
||||
|
||||
{% for lang in languages -%}
|
||||
LANGUAGE {{ lang.primary }}, {{ lang.sublanguage }}
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 1, 0, 0, 0
|
||||
PRODUCTVERSION 1, 0, 0, 0
|
||||
FILEFLAGMASK 0x3fL
|
||||
FILEFLAGSMASK 0x3fL
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS 0x1L
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
#endif
|
||||
FILEOS 0x40004L // VOS_NT_WINDOWS32
|
||||
FILETYPE 0x1L // VFT_APP
|
||||
FILESUBTYPE 0x0L // VFT2_UNKNOWN
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
{% for lang in languages %}
|
||||
BLOCK "{{ lang.block_key }}"
|
||||
BEGIN
|
||||
VALUE "Comments", "https://github.com/SarasasChipWorkshop/metaglot"
|
||||
VALUE "CompanyName", "Sarasa's Chip Workshop"
|
||||
VALUE "FileDescription", "{{ lang.named.file_description | rc_escape }}"
|
||||
VALUE "FileDescription", "{{ lang.strings.file_description | rc_escape }}"
|
||||
VALUE "FileVersion", "1.0.0.0"
|
||||
VALUE "InternalName", "example"
|
||||
VALUE "LegalCopyright", "MIT/Expat License - Copyright (C) 2026 Sarasa's Chip Workshop"
|
||||
VALUE "OriginalFilename", "example.exe"
|
||||
VALUE "ProductName", "{{ lang.named.product_name | rc_escape }}"
|
||||
VALUE "ProductName", "{{ lang.strings.product_name | rc_escape }}"
|
||||
VALUE "ProductVersion", "1.0.0.0"
|
||||
END
|
||||
{% endfor %}
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", {% for lang in languages %}{{ lang.langid }}, 1200{% unless forloop.last %}, {% endunless %}{% endfor %}
|
||||
VALUE "Translation", {{ lang.langid }}, {{ lang.code_page }}
|
||||
END
|
||||
END
|
||||
|
||||
{% endfor -%}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
version = 1
|
||||
source_language = "en"
|
||||
|
||||
[strings.1000]
|
||||
msgid = "Example - Image Viewer"
|
||||
|
||||
+31
-1
@@ -8,11 +8,41 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.13"
|
||||
license = { text = "MIT" }
|
||||
keywords = [
|
||||
"localization",
|
||||
"l10n",
|
||||
"i18n",
|
||||
"translation",
|
||||
"gettext",
|
||||
"pot",
|
||||
"po",
|
||||
"metadata",
|
||||
"rc",
|
||||
"msix",
|
||||
"appstream",
|
||||
"desktop-entry",
|
||||
"plist",
|
||||
"innosetup",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 2 - Pre-Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Internationalization",
|
||||
"Topic :: Software Development :: Localization",
|
||||
"Topic :: Software Development :: Build Tools",
|
||||
]
|
||||
dependencies = [
|
||||
"langcodes>=3.5.1",
|
||||
"polib>=1.2.0",
|
||||
"pycountry>=26.2.16",
|
||||
"pydantic>=2.11.7",
|
||||
"python-liquid>=2.2.0",
|
||||
"python-liquid==2.3.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
+14
-20
@@ -1,31 +1,25 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from . import pofile, render
|
||||
from .cli import parse, RcOpts, RcPotOpts, RcRenderOpts
|
||||
from argparse import ArgumentParser
|
||||
from . import hack
|
||||
from .cmds import run, register, parse
|
||||
|
||||
|
||||
def main() -> None:
|
||||
hack.relax_liquid_range_literal_boundary()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
|
||||
opts = parse()
|
||||
|
||||
parser = ArgumentParser(
|
||||
prog="metaglot",
|
||||
description="A command-line tool that unifies localization metadata for applications shipped on multiple platforms.",
|
||||
)
|
||||
register(parser)
|
||||
args = vars(parser.parse_args())
|
||||
opts = parse(args)
|
||||
|
||||
try:
|
||||
match opts.opts:
|
||||
case RcOpts() as rc_opts:
|
||||
match rc_opts.opts:
|
||||
case RcPotOpts() as rc_pot_opts:
|
||||
pofile.generate(rc_pot_opts.in_manifest, rc_pot_opts.out_pot)
|
||||
case RcRenderOpts() as rc_rdr_opts:
|
||||
render.render(
|
||||
rc_rdr_opts.in_manifest,
|
||||
rc_rdr_opts.in_po,
|
||||
rc_rdr_opts.in_template,
|
||||
rc_rdr_opts.out_rc,
|
||||
)
|
||||
case _:
|
||||
raise RuntimeError(f"unhandled rc options: {rc_opts.opts!r}")
|
||||
case _:
|
||||
raise RuntimeError(f"unhandled options: {opts.opts!r}")
|
||||
run(opts)
|
||||
except Exception as e:
|
||||
logging.fatal("Runtime error: %s", e, exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import enum
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# region: Options and Commands
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RcPotOpts:
|
||||
in_manifest: Path
|
||||
"""The path to input manifest file including translation strings."""
|
||||
out_pot: Path
|
||||
"""The path to output POT file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RcRenderOpts:
|
||||
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_template: Path
|
||||
"""The path to the input user-provided Liquid for rendering."""
|
||||
out_rc: Path
|
||||
"""The path to the output rendered Windows RC file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RcOpts:
|
||||
opts: RcPotOpts | RcRenderOpts
|
||||
"""The option of one of subcommand."""
|
||||
|
||||
|
||||
class RcCommand(enum.StrEnum):
|
||||
Pot = "pot"
|
||||
Render = "render"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Opts:
|
||||
"""The root options."""
|
||||
|
||||
opts: RcOpts
|
||||
"""The option of one of subcommand."""
|
||||
|
||||
|
||||
class Command(enum.StrEnum):
|
||||
Rc = "rc"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Parameter Register
|
||||
|
||||
_SUBCMD_DEST = "command"
|
||||
_RC_SUBCMD_DEST = "rc_command"
|
||||
|
||||
|
||||
_RC_POT_MANIFEST_DEST = "manifest"
|
||||
_RC_POT_OUTPUT_DEST = "output"
|
||||
|
||||
_RC_RDR_MANIFEST_DEST = "manifest"
|
||||
_RC_RDR_PO_DEST = "po"
|
||||
_RC_RDR_TEMPLATE_DEST = "template"
|
||||
_RC_RDR_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
def _register_rc_pot_param(parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--manifest",
|
||||
dest=_RC_POT_MANIFEST_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to input manifest file including translation strings.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_RC_POT_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to output POT file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def _register_rc_render_param(parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--manifest",
|
||||
dest=_RC_RDR_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=_RC_RDR_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(
|
||||
"-t",
|
||||
"--template",
|
||||
dest=_RC_RDR_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for rendering.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_RC_RDR_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the output rendered Windows RC file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def _register_rc_param(parser: ArgumentParser) -> None:
|
||||
cmds = parser.add_subparsers(dest=_RC_SUBCMD_DEST, required=True)
|
||||
pot_subcmd = cmds.add_parser(
|
||||
RcCommand.Pot.value, help="Generate a POT template from given manifest."
|
||||
)
|
||||
_register_rc_pot_param(pot_subcmd)
|
||||
render_subcmd = cmds.add_parser(
|
||||
RcCommand.Render.value,
|
||||
help="Render a user-provided template with manifest data and PO translations.",
|
||||
)
|
||||
_register_rc_render_param(render_subcmd)
|
||||
|
||||
|
||||
def _register_param(parser: ArgumentParser) -> None:
|
||||
cmds = parser.add_subparsers(dest=_SUBCMD_DEST, required=True)
|
||||
rc_subcmd = cmds.add_parser(
|
||||
Command.Rc.value, help="Commands for Windows RC resource files."
|
||||
)
|
||||
_register_rc_param(rc_subcmd)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def parse() -> Opts:
|
||||
"""
|
||||
Parse MetaGlot command line options.
|
||||
|
||||
:return: The parsed command line options.
|
||||
"""
|
||||
parser = ArgumentParser(
|
||||
prog="metaglot",
|
||||
description="A command-line tool that unifies localization metadata for applications shipped on multiple platforms.",
|
||||
)
|
||||
_register_param(parser)
|
||||
|
||||
args = vars(parser.parse_args())
|
||||
|
||||
match Command(args[_SUBCMD_DEST]):
|
||||
case Command.Rc:
|
||||
match RcCommand(args[_RC_SUBCMD_DEST]):
|
||||
case RcCommand.Pot:
|
||||
rc_pot_opts = RcPotOpts(
|
||||
args[_RC_POT_MANIFEST_DEST], args[_RC_POT_OUTPUT_DEST]
|
||||
)
|
||||
rc_opts = RcOpts(rc_pot_opts)
|
||||
case RcCommand.Render:
|
||||
rc_rdr_opts = RcRenderOpts(
|
||||
args[_RC_RDR_MANIFEST_DEST],
|
||||
args[_RC_RDR_PO_DEST],
|
||||
args[_RC_RDR_TEMPLATE_DEST],
|
||||
args[_RC_RDR_OUTPUT_DEST],
|
||||
)
|
||||
rc_opts = RcOpts(rc_rdr_opts)
|
||||
case _:
|
||||
raise ValueError(f"unhandled rc command: {args[_RC_SUBCMD_DEST]}")
|
||||
opts = Opts(rc_opts)
|
||||
case _:
|
||||
raise ValueError(f"unhandled command: {args[_SUBCMD_DEST]}")
|
||||
return opts
|
||||
@@ -0,0 +1,67 @@
|
||||
import enum
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from . import pot, update, render
|
||||
from .pot import PotOpts
|
||||
from .update import UpdateOpts
|
||||
from .render import RenderOpts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Opts:
|
||||
"""The root options."""
|
||||
|
||||
opts: PotOpts | UpdateOpts | RenderOpts
|
||||
"""The option of one of subcommand."""
|
||||
|
||||
|
||||
_SUBCMD_DEST = "command"
|
||||
|
||||
|
||||
class _Command(enum.StrEnum):
|
||||
Pot = "pot"
|
||||
Update = "update"
|
||||
Render = "render"
|
||||
|
||||
|
||||
def register(parser: ArgumentParser) -> None:
|
||||
cmds = parser.add_subparsers(dest=_SUBCMD_DEST, required=True)
|
||||
subcmd = cmds.add_parser(
|
||||
_Command.Pot.value, help="Generate a POT template from given manifest."
|
||||
)
|
||||
pot.register(subcmd)
|
||||
subcmd = cmds.add_parser(
|
||||
_Command.Update.value, help="Update PO files against a POT template."
|
||||
)
|
||||
update.register(subcmd)
|
||||
subcmd = cmds.add_parser(
|
||||
_Command.Render.value,
|
||||
help="Render a user-provided template with manifest data and PO translations.",
|
||||
)
|
||||
render.register(subcmd)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> Opts:
|
||||
match _Command(args[_SUBCMD_DEST]):
|
||||
case _Command.Pot:
|
||||
opts = Opts(pot.parse(args))
|
||||
case _Command.Update:
|
||||
opts = Opts(update.parse(args))
|
||||
case _Command.Render:
|
||||
opts = Opts(render.parse(args))
|
||||
case _:
|
||||
raise ValueError(f"unhandled command: {args[_SUBCMD_DEST]}")
|
||||
return opts
|
||||
|
||||
|
||||
def run(opts: Opts) -> None:
|
||||
match opts.opts:
|
||||
case PotOpts() as pot_opts:
|
||||
pot.run(pot_opts)
|
||||
case UpdateOpts() as update_opts:
|
||||
update.run(update_opts)
|
||||
case RenderOpts() as render_opts:
|
||||
render.run(render_opts)
|
||||
case _:
|
||||
raise RuntimeError(f"unhandled options: {opts.opts!r}")
|
||||
@@ -0,0 +1,49 @@
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from ..manifest import load_manifest
|
||||
from ..pofile import generate_pot
|
||||
|
||||
_MANIFEST_DEST = "manifest"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PotOpts:
|
||||
in_manifest: Path
|
||||
"""The path to input manifest file including translation strings."""
|
||||
out_pot: Path
|
||||
"""The path to output POT 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(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to output POT file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> PotOpts:
|
||||
return PotOpts(args[_MANIFEST_DEST], args[_OUTPUT_DEST])
|
||||
|
||||
|
||||
def run(opts: PotOpts) -> None:
|
||||
manifest = load_manifest(opts.in_manifest)
|
||||
generate_pot(manifest, opts.out_pot)
|
||||
@@ -0,0 +1,100 @@
|
||||
import enum
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from . import rc, desktop, appstream, msix, plist, inno
|
||||
from .rc import RcRenderOpts
|
||||
from .desktop import DesktopRenderOpts
|
||||
from .appstream import AppStreamRenderOpts
|
||||
from .msix import MsixRenderOpts
|
||||
from .plist import PlistRenderOpts
|
||||
from .inno import InnoRenderOpts
|
||||
|
||||
_SUBCMD_DEST = "metadata"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderOpts:
|
||||
opts: (
|
||||
RcRenderOpts
|
||||
| DesktopRenderOpts
|
||||
| AppStreamRenderOpts
|
||||
| MsixRenderOpts
|
||||
| PlistRenderOpts
|
||||
| InnoRenderOpts
|
||||
)
|
||||
"""The option of one of subcommand."""
|
||||
|
||||
|
||||
class _Metadata(enum.StrEnum):
|
||||
Rc = "rc"
|
||||
Desktop = "desktop"
|
||||
Appstream = "appstream"
|
||||
Msix = "msix"
|
||||
Plist = "plist"
|
||||
Inno = "inno"
|
||||
|
||||
|
||||
def register(parser: ArgumentParser) -> None:
|
||||
cmds = parser.add_subparsers(dest=_SUBCMD_DEST, required=True)
|
||||
subcmd = cmds.add_parser(
|
||||
_Metadata.Rc.value, help="Render Windows RC resource files."
|
||||
)
|
||||
rc.register(subcmd)
|
||||
subcmd = cmds.add_parser(
|
||||
_Metadata.Desktop.value, help="Render freedesktop desktop entry files."
|
||||
)
|
||||
desktop.register(subcmd)
|
||||
subcmd = cmds.add_parser(
|
||||
_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)
|
||||
subcmd = cmds.add_parser(
|
||||
_Metadata.Plist.value, help="Render macOS bundle Info.plist files."
|
||||
)
|
||||
plist.register(subcmd)
|
||||
subcmd = cmds.add_parser(
|
||||
_Metadata.Inno.value, help="Render Inno Setup installation files."
|
||||
)
|
||||
inno.register(subcmd)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> RenderOpts:
|
||||
match _Metadata(args[_SUBCMD_DEST]):
|
||||
case _Metadata.Rc:
|
||||
opts = RenderOpts(rc.parse(args))
|
||||
case _Metadata.Desktop:
|
||||
opts = RenderOpts(desktop.parse(args))
|
||||
case _Metadata.Appstream:
|
||||
opts = RenderOpts(appstream.parse(args))
|
||||
case _Metadata.Msix:
|
||||
opts = RenderOpts(msix.parse(args))
|
||||
case _Metadata.Plist:
|
||||
opts = RenderOpts(plist.parse(args))
|
||||
case _Metadata.Inno:
|
||||
opts = RenderOpts(inno.parse(args))
|
||||
case _:
|
||||
raise ValueError(f"unhandled render command: {args[_SUBCMD_DEST]}")
|
||||
return opts
|
||||
|
||||
|
||||
def run(opts: RenderOpts) -> None:
|
||||
match opts.opts:
|
||||
case RcRenderOpts() as rc_opts:
|
||||
rc.run(rc_opts)
|
||||
case DesktopRenderOpts() as desktop_opts:
|
||||
desktop.run(desktop_opts)
|
||||
case AppStreamRenderOpts() as appstream_opts:
|
||||
appstream.run(appstream_opts)
|
||||
case MsixRenderOpts() as msix_opts:
|
||||
msix.run(msix_opts)
|
||||
case PlistRenderOpts() as plist_opts:
|
||||
plist.run(plist_opts)
|
||||
case InnoRenderOpts() as inno_opts:
|
||||
inno.run(inno_opts)
|
||||
case _:
|
||||
raise RuntimeError(f"unhandled render options: {opts.opts!r}")
|
||||
@@ -0,0 +1,170 @@
|
||||
import logging
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from ...filters import register_appstream_filters, register_general_filters
|
||||
from ...langid import AppStreamLang, PoLang
|
||||
from ...langmap import appstream_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
|
||||
|
||||
_MANIFEST_DEST = "manifest"
|
||||
_PO_DEST = "po"
|
||||
_TEMPLATE_DEST = "template"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppStreamRenderOpts:
|
||||
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_template: Path
|
||||
"""The path to the input user-provided Liquid for rendering."""
|
||||
out_appstream: Path
|
||||
"""The path of the output rendered AppStream XML 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(
|
||||
"-t",
|
||||
"--template",
|
||||
dest=_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for rendering.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path of the output rendered AppStream XML file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> AppStreamRenderOpts:
|
||||
return AppStreamRenderOpts(
|
||||
args[_MANIFEST_DEST],
|
||||
args[_PO_DEST],
|
||||
args[_TEMPLATE_DEST],
|
||||
args[_OUTPUT_DEST],
|
||||
)
|
||||
|
||||
|
||||
class _AppStreamStringsView:
|
||||
"""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 _AppStreamLanguageView:
|
||||
"""The Liquid-facing view of one language for AppStream rendering."""
|
||||
|
||||
__pack: LanguagePack
|
||||
"""The wrapped language pack."""
|
||||
__strings: _AppStreamStringsView
|
||||
"""The view of the pack's translations."""
|
||||
__lang: AppStreamLang
|
||||
"""The BCP 47 language tag of the pack's language."""
|
||||
|
||||
def __init__(self, pack: LanguagePack):
|
||||
self.__pack = pack
|
||||
self.__strings = _AppStreamStringsView(pack)
|
||||
self.__lang = appstream_lang_map.convert(pack.lang)
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "name":
|
||||
return self.__pack.lang.value
|
||||
case "strings":
|
||||
return self.__strings
|
||||
case "lang":
|
||||
return self.__lang.value
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class _AppStreamRenderContext:
|
||||
"""The Liquid-facing top-level context for AppStream rendering."""
|
||||
|
||||
__languages: tuple[_AppStreamLanguageView, ...]
|
||||
"""The views of every translation language."""
|
||||
__default: _AppStreamLanguageView
|
||||
"""The view of the manifest's source language."""
|
||||
|
||||
def __init__(self, packs: dict[PoLang, LanguagePack], default: LanguagePack):
|
||||
self.__languages = tuple(
|
||||
_AppStreamLanguageView(pack) for pack in packs.values()
|
||||
)
|
||||
self.__default = _AppStreamLanguageView(default)
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Return the names of the top-level template variables."""
|
||||
return ("languages", "default")
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "languages":
|
||||
return self.__languages
|
||||
case "default":
|
||||
return self.__default
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def run(opts: AppStreamRenderOpts) -> None:
|
||||
manifest = load_manifest(opts.in_manifest)
|
||||
packs = resolve_translations(manifest, resolve_glob_files(opts.in_po))
|
||||
default = build_default_pack(manifest, manifest.source_language)
|
||||
context = _AppStreamRenderContext(packs, default)
|
||||
logging.info(
|
||||
"rendering languages: %s (default %s)",
|
||||
", ".join(str(pack.lang) for pack in packs.values()),
|
||||
default.lang,
|
||||
)
|
||||
env = create_environment()
|
||||
register_general_filters(env)
|
||||
register_appstream_filters(env)
|
||||
render(env, opts.in_template, context, opts.out_appstream)
|
||||
@@ -0,0 +1,170 @@
|
||||
import logging
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from ...filters import register_desktop_filters, register_general_filters
|
||||
from ...langid import DesktopLang, PoLang
|
||||
from ...langmap import desktop_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
|
||||
|
||||
_MANIFEST_DEST = "manifest"
|
||||
_PO_DEST = "po"
|
||||
_TEMPLATE_DEST = "template"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DesktopRenderOpts:
|
||||
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_template: Path
|
||||
"""The path to the input user-provided Liquid for rendering."""
|
||||
out_desktop: Path
|
||||
"""The path of the output rendered desktop entry 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(
|
||||
"-t",
|
||||
"--template",
|
||||
dest=_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for rendering.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path of the output rendered desktop entry file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> DesktopRenderOpts:
|
||||
return DesktopRenderOpts(
|
||||
args[_MANIFEST_DEST],
|
||||
args[_PO_DEST],
|
||||
args[_TEMPLATE_DEST],
|
||||
args[_OUTPUT_DEST],
|
||||
)
|
||||
|
||||
|
||||
class _DesktopStringsView:
|
||||
"""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 _DesktopLanguageView:
|
||||
"""The Liquid-facing view of one language for desktop entry rendering."""
|
||||
|
||||
__pack: LanguagePack
|
||||
"""The wrapped language pack."""
|
||||
__strings: _DesktopStringsView
|
||||
"""The view of the pack's translations."""
|
||||
__locale: DesktopLang
|
||||
"""The desktop entry locale of the pack's language."""
|
||||
|
||||
def __init__(self, pack: LanguagePack):
|
||||
self.__pack = pack
|
||||
self.__strings = _DesktopStringsView(pack)
|
||||
self.__locale = desktop_lang_map.convert(pack.lang)
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "name":
|
||||
return self.__pack.lang.value
|
||||
case "strings":
|
||||
return self.__strings
|
||||
case "locale":
|
||||
return self.__locale.value
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class _DesktopRenderContext:
|
||||
"""The Liquid-facing top-level context for desktop entry rendering."""
|
||||
|
||||
__languages: tuple[_DesktopLanguageView, ...]
|
||||
"""The views of every translation language."""
|
||||
__default: _DesktopLanguageView
|
||||
"""The view of the manifest's source language."""
|
||||
|
||||
def __init__(self, packs: dict[PoLang, LanguagePack], default: LanguagePack):
|
||||
self.__languages = tuple(
|
||||
_DesktopLanguageView(pack) for pack in packs.values()
|
||||
)
|
||||
self.__default = _DesktopLanguageView(default)
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Return the names of the top-level template variables."""
|
||||
return ("languages", "default")
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "languages":
|
||||
return self.__languages
|
||||
case "default":
|
||||
return self.__default
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def run(opts: DesktopRenderOpts) -> None:
|
||||
manifest = load_manifest(opts.in_manifest)
|
||||
packs = resolve_translations(manifest, resolve_glob_files(opts.in_po))
|
||||
default = build_default_pack(manifest, manifest.source_language)
|
||||
context = _DesktopRenderContext(packs, default)
|
||||
logging.info(
|
||||
"rendering languages: %s (default %s)",
|
||||
", ".join(str(pack.lang) for pack in packs.values()),
|
||||
default.lang,
|
||||
)
|
||||
env = create_environment()
|
||||
register_general_filters(env)
|
||||
register_desktop_filters(env)
|
||||
render(env, opts.in_template, context, opts.out_desktop)
|
||||
@@ -0,0 +1,295 @@
|
||||
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_inno_filters
|
||||
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"
|
||||
_ISS_TEMPLATE_DEST = "iss_template"
|
||||
_ISL_TEMPLATE_DEST = "isl_template"
|
||||
_ISS_PATH_DEST = "iss_path"
|
||||
_ISL_PATH_DEST = "isl_path"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InnoRenderOpts:
|
||||
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_iss_template: Path
|
||||
"""The path to the input user-provided Liquid for the .iss script file."""
|
||||
in_isl_template: Path
|
||||
"""The path to the input user-provided Liquid for the .isl messages files."""
|
||||
in_iss_path: str
|
||||
"""The Liquid template resolving the output path of the .iss script file."""
|
||||
in_isl_path: str
|
||||
"""The Liquid template resolving the output path of every .isl messages 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(
|
||||
"--iss-template",
|
||||
dest=_ISS_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for the .iss script file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--isl-template",
|
||||
dest=_ISL_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help=(
|
||||
"The path to the input user-provided Liquid for the .isl "
|
||||
"messages files."
|
||||
),
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iss-path",
|
||||
dest=_ISS_PATH_DEST,
|
||||
action="store",
|
||||
type=str,
|
||||
required=True,
|
||||
help=(
|
||||
"The Liquid template resolving the output path of the .iss "
|
||||
"script file, relative to the output folder."
|
||||
),
|
||||
metavar="TEMPLATE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--isl-path",
|
||||
dest=_ISL_PATH_DEST,
|
||||
action="store",
|
||||
type=str,
|
||||
required=True,
|
||||
help=(
|
||||
"The Liquid template resolving the output path of every .isl "
|
||||
"messages 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]) -> InnoRenderOpts:
|
||||
return InnoRenderOpts(
|
||||
args[_MANIFEST_DEST],
|
||||
args[_PO_DEST],
|
||||
args[_ISS_TEMPLATE_DEST],
|
||||
args[_ISL_TEMPLATE_DEST],
|
||||
args[_ISS_PATH_DEST],
|
||||
args[_ISL_PATH_DEST],
|
||||
args[_OUTPUT_DEST],
|
||||
)
|
||||
|
||||
|
||||
class _InnoStringsView:
|
||||
"""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 _InnoIssPathContext:
|
||||
"""The Liquid-facing context of the .iss path template.
|
||||
|
||||
The context is empty: the .iss path is typically a plain literal
|
||||
like 'example.iss', 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 _InnoIslPathContext:
|
||||
"""The Liquid-facing context of one .isl path template rendering.
|
||||
|
||||
It is rendered once per language, with the PO language tag of the
|
||||
messages file being placed exposed as ``lang``. Inno Setup has no
|
||||
standard representation of language identifiers, so no conversion
|
||||
happens: the tag is the Gettext PO tag verbatim.
|
||||
"""
|
||||
|
||||
__lang: str
|
||||
"""The PO language tag of the .isl messages 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 _InnoIslTemplateContext:
|
||||
"""The Liquid-facing top-level context of every .isl messages 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 .iss
|
||||
template context, where it exposes the source language the same way.
|
||||
"""
|
||||
|
||||
__pack: LanguagePack
|
||||
"""The wrapped language pack."""
|
||||
__strings: _InnoStringsView
|
||||
"""The view of the pack's translations."""
|
||||
|
||||
def __init__(self, pack: LanguagePack):
|
||||
self.__pack = pack
|
||||
self.__strings = _InnoStringsView(pack)
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Return the names of the exposed template variables."""
|
||||
return ("name", "strings")
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "name":
|
||||
return self.__pack.lang.value
|
||||
case "strings":
|
||||
return self.__strings
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class _InnoIssTemplateContext:
|
||||
"""The Liquid-facing top-level context of the .iss script file
|
||||
rendering."""
|
||||
|
||||
__default: _InnoIslTemplateContext
|
||||
"""The view of the manifest's source language."""
|
||||
__langs: tuple[str, ...]
|
||||
"""The PO language tags of every language, source language first."""
|
||||
|
||||
def __init__(self, source: LanguagePack, langs: tuple[str, ...]):
|
||||
self.__default = _InnoIslTemplateContext(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: InnoRenderOpts) -> 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 and always gets
|
||||
# an .isl messages file: its entry is the fallback localization of
|
||||
# the installer.
|
||||
source = packs.pop(manifest.source_language, None)
|
||||
if source is None:
|
||||
source = build_default_pack(manifest, manifest.source_language)
|
||||
ordered = [source, *packs.values()]
|
||||
# Inno Setup has no standard language identifier representation, so
|
||||
# the PO language tags carry over verbatim.
|
||||
langs = tuple(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.
|
||||
iss_path_env = create_environment()
|
||||
register_general_filters(iss_path_env)
|
||||
isl_path_env = create_environment()
|
||||
register_general_filters(isl_path_env)
|
||||
iss_template_env = create_environment()
|
||||
register_general_filters(iss_template_env)
|
||||
register_inno_filters(iss_template_env)
|
||||
isl_template_env = create_environment()
|
||||
register_general_filters(isl_template_env)
|
||||
register_inno_filters(isl_template_env)
|
||||
|
||||
iss_out = opts.out_dir / render_path(
|
||||
iss_path_env, opts.in_iss_path, _InnoIssPathContext()
|
||||
)
|
||||
render(
|
||||
iss_template_env,
|
||||
opts.in_iss_template,
|
||||
_InnoIssTemplateContext(source, langs),
|
||||
iss_out,
|
||||
)
|
||||
|
||||
for pack, lang in zip(ordered, langs):
|
||||
isl_out = opts.out_dir / render_path(
|
||||
isl_path_env, opts.in_isl_path, _InnoIslPathContext(lang)
|
||||
)
|
||||
render(
|
||||
isl_template_env,
|
||||
opts.in_isl_template,
|
||||
_InnoIslTemplateContext(pack),
|
||||
isl_out,
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
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()
|
||||
)
|
||||
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),
|
||||
)
|
||||
render(
|
||||
resources_template_env,
|
||||
opts.in_resources_template,
|
||||
_MsixResourcesTemplateContext(pack),
|
||||
resources_out,
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
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_plist_info_filters,
|
||||
register_plist_strings_filters,
|
||||
)
|
||||
from ...langmap import plist_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
|
||||
|
||||
_PLIST_DEST = "plist"
|
||||
_PO_DEST = "po"
|
||||
_PLIST_TEMPLATE_DEST = "plist_template"
|
||||
_STRINGS_TEMPLATE_DEST = "strings_template"
|
||||
_PLIST_PATH_DEST = "plist_path"
|
||||
_STRINGS_PATH_DEST = "strings_path"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlistRenderOpts:
|
||||
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_plist_template: Path
|
||||
"""The path to the input user-provided Liquid for the Info.plist file."""
|
||||
in_strings_template: Path
|
||||
"""The path to the input user-provided Liquid for the InfoPlist.strings files."""
|
||||
in_plist_path: str
|
||||
"""The Liquid template resolving the output path of the Info.plist file."""
|
||||
in_strings_path: str
|
||||
"""The Liquid template resolving the output path of every InfoPlist.strings 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=_PLIST_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(
|
||||
"--plist-template",
|
||||
dest=_PLIST_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for the Info.plist file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strings-template",
|
||||
dest=_STRINGS_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help=(
|
||||
"The path to the input user-provided Liquid for the "
|
||||
"InfoPlist.strings files."
|
||||
),
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--plist-path",
|
||||
dest=_PLIST_PATH_DEST,
|
||||
action="store",
|
||||
type=str,
|
||||
required=True,
|
||||
help=(
|
||||
"The Liquid template resolving the output path of the Info.plist "
|
||||
"file, relative to the output folder."
|
||||
),
|
||||
metavar="TEMPLATE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strings-path",
|
||||
dest=_STRINGS_PATH_DEST,
|
||||
action="store",
|
||||
type=str,
|
||||
required=True,
|
||||
help=(
|
||||
"The Liquid template resolving the output path of every "
|
||||
"InfoPlist.strings 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]) -> PlistRenderOpts:
|
||||
return PlistRenderOpts(
|
||||
args[_PLIST_DEST],
|
||||
args[_PO_DEST],
|
||||
args[_PLIST_TEMPLATE_DEST],
|
||||
args[_STRINGS_TEMPLATE_DEST],
|
||||
args[_PLIST_PATH_DEST],
|
||||
args[_STRINGS_PATH_DEST],
|
||||
args[_OUTPUT_DEST],
|
||||
)
|
||||
|
||||
|
||||
class _PlistStringsView:
|
||||
"""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 _PlistSourcesView:
|
||||
"""Route template string key lookups to their manifest source 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].entry.msgid
|
||||
|
||||
|
||||
class _PlistInfoPathContext:
|
||||
"""The Liquid-facing context of the Info.plist path template.
|
||||
|
||||
The context is empty: the Info.plist path is typically the plain
|
||||
literal 'Contents/Info.plist', 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 _PlistStringsPathContext:
|
||||
"""The Liquid-facing context of one InfoPlist.strings path template
|
||||
rendering.
|
||||
|
||||
It is rendered once per language, with the language tag of the
|
||||
InfoPlist.strings file being placed exposed as ``lang``.
|
||||
"""
|
||||
|
||||
__lang: str
|
||||
"""The BCP 47 language tag of the InfoPlist.strings 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 _PlistStringsTemplateContext:
|
||||
"""The Liquid-facing top-level context of every InfoPlist.strings
|
||||
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 Info.plist
|
||||
template context, where it exposes the source language the same way.
|
||||
"""
|
||||
|
||||
__pack: LanguagePack
|
||||
"""The wrapped language pack."""
|
||||
__strings: _PlistStringsView
|
||||
"""The view of the pack's resolved translations."""
|
||||
__sources: _PlistSourcesView
|
||||
"""The view of the pack's manifest source texts."""
|
||||
__lang: str
|
||||
"""The BCP 47 language tag of the pack's language."""
|
||||
|
||||
def __init__(self, pack: LanguagePack):
|
||||
self.__pack = pack
|
||||
self.__strings = _PlistStringsView(pack)
|
||||
self.__sources = _PlistSourcesView(pack)
|
||||
self.__lang = plist_lang_map.convert(pack.lang).value
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Return the names of the exposed template variables."""
|
||||
return ("name", "lang", "strings", "sources")
|
||||
|
||||
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 "sources":
|
||||
return self.__sources
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class _PlistInfoTemplateContext:
|
||||
"""The Liquid-facing top-level context of the Info.plist file rendering."""
|
||||
|
||||
__default: _PlistStringsTemplateContext
|
||||
"""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 = _PlistStringsTemplateContext(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: PlistRenderOpts) -> 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 and always gets
|
||||
# an InfoPlist.strings file: its .lproj folder is the fallback
|
||||
# localization of the bundle.
|
||||
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(plist_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.
|
||||
plist_path_env = create_environment()
|
||||
register_general_filters(plist_path_env)
|
||||
strings_path_env = create_environment()
|
||||
register_general_filters(strings_path_env)
|
||||
plist_template_env = create_environment()
|
||||
register_general_filters(plist_template_env)
|
||||
register_plist_info_filters(plist_template_env)
|
||||
strings_template_env = create_environment()
|
||||
register_general_filters(strings_template_env)
|
||||
register_plist_strings_filters(strings_template_env)
|
||||
|
||||
plist_out = opts.out_dir / render_path(
|
||||
plist_path_env, opts.in_plist_path, _PlistInfoPathContext()
|
||||
)
|
||||
render(
|
||||
plist_template_env,
|
||||
opts.in_plist_template,
|
||||
_PlistInfoTemplateContext(source, langs),
|
||||
plist_out,
|
||||
)
|
||||
|
||||
for pack, lang in zip(ordered, langs):
|
||||
strings_out = opts.out_dir / render_path(
|
||||
strings_path_env,
|
||||
opts.in_strings_path,
|
||||
_PlistStringsPathContext(lang),
|
||||
)
|
||||
render(
|
||||
strings_template_env,
|
||||
opts.in_strings_template,
|
||||
_PlistStringsTemplateContext(pack),
|
||||
strings_out,
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
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_rc_filters
|
||||
from ...langid import PoLang, WinLcid
|
||||
from ...langmap import win_lcid_map
|
||||
from ...manifest import load_manifest
|
||||
from ...pofile import (
|
||||
LanguagePack,
|
||||
build_default_pack,
|
||||
resolve_glob_files,
|
||||
resolve_translations,
|
||||
)
|
||||
from ...render import create_environment, render
|
||||
|
||||
_CODE_PAGE = 1200
|
||||
"""The Unicode (UTF-16) code page used by VERSIONINFO resources.
|
||||
|
||||
MetaGlot reads and writes everything as UTF-8, so this code page is a fixed
|
||||
constraint of the pipeline, not a configuration point.
|
||||
"""
|
||||
|
||||
_MANIFEST_DEST = "manifest"
|
||||
_PO_DEST = "po"
|
||||
_TEMPLATE_DEST = "template"
|
||||
_OUTPUT_DEST = "output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RcRenderOpts:
|
||||
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_template: Path
|
||||
"""The path to the input user-provided Liquid for rendering."""
|
||||
out_rc: Path
|
||||
"""The path of the output rendered Windows RC 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(
|
||||
"-t",
|
||||
"--template",
|
||||
dest=_TEMPLATE_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input user-provided Liquid for rendering.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest=_OUTPUT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path of the output rendered Windows RC file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> RcRenderOpts:
|
||||
return RcRenderOpts(
|
||||
args[_MANIFEST_DEST],
|
||||
args[_PO_DEST],
|
||||
args[_TEMPLATE_DEST],
|
||||
args[_OUTPUT_DEST],
|
||||
)
|
||||
|
||||
|
||||
def _lookup_langid(lang: PoLang) -> WinLcid:
|
||||
try:
|
||||
return win_lcid_map.lookup(lang)
|
||||
except KeyError:
|
||||
raise ValueError(f"language '{lang}' has no Windows language mapping") from None
|
||||
|
||||
|
||||
class _RcStringsView:
|
||||
"""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 _RcLanguageView:
|
||||
"""The Liquid-facing view of one language for RC rendering."""
|
||||
|
||||
__pack: LanguagePack
|
||||
"""The wrapped language pack."""
|
||||
__strings: _RcStringsView
|
||||
"""The view of the pack's translations."""
|
||||
__lcid: WinLcid
|
||||
"""The Windows language identifier of the pack's language."""
|
||||
|
||||
def __init__(self, pack: LanguagePack):
|
||||
self.__pack = pack
|
||||
self.__strings = _RcStringsView(pack)
|
||||
self.__lcid = _lookup_langid(pack.lang)
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "name":
|
||||
return self.__pack.lang.value
|
||||
case "strings":
|
||||
return self.__strings
|
||||
case "langid":
|
||||
return f"0x{self.__lcid.value:04X}"
|
||||
case "primary":
|
||||
return f"0x{self.__lcid.primary:02X}"
|
||||
case "sublanguage":
|
||||
return f"0x{self.__lcid.sublanguage:02X}"
|
||||
case "block_key":
|
||||
return f"{self.__lcid.value:04X}{_CODE_PAGE:04X}"
|
||||
case "code_page":
|
||||
return f"{_CODE_PAGE}"
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class _RcRenderContext:
|
||||
"""The Liquid-facing top-level context for RC rendering."""
|
||||
|
||||
__languages: tuple[_RcLanguageView, ...]
|
||||
"""The views of every rendered language."""
|
||||
|
||||
def __init__(self, packs: dict[PoLang, LanguagePack]):
|
||||
self.__languages = tuple(_RcLanguageView(pack) for pack in packs.values())
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Return the names of the top-level template variables."""
|
||||
return ("languages",)
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
match key:
|
||||
case "languages":
|
||||
return self.__languages
|
||||
case _:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
context = _RcRenderContext(packs)
|
||||
logging.info(
|
||||
"rendering languages: %s",
|
||||
", ".join(str(pack.lang) for pack in packs.values()),
|
||||
)
|
||||
env = create_environment()
|
||||
register_general_filters(env)
|
||||
register_rc_filters(env)
|
||||
render(env, opts.in_template, context, opts.out_rc)
|
||||
@@ -0,0 +1,48 @@
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from ..pofile import resolve_glob_files, update_po_files
|
||||
|
||||
_POT_DEST = "pot"
|
||||
_PO_DEST = "po"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateOpts:
|
||||
in_pot: Path
|
||||
"""The path to the input POT template file."""
|
||||
in_po: list[Path]
|
||||
"""The path to input PO files to update, each may be a glob pattern."""
|
||||
|
||||
|
||||
def register(parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--pot",
|
||||
dest=_POT_DEST,
|
||||
action="store",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The path to the input POT template file.",
|
||||
metavar="FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--po",
|
||||
dest=_PO_DEST,
|
||||
action="extend",
|
||||
nargs="+",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="The PO files to update, each may be a glob pattern.",
|
||||
metavar="GLOB",
|
||||
)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> UpdateOpts:
|
||||
return UpdateOpts(args[_POT_DEST], args[_PO_DEST])
|
||||
|
||||
|
||||
def run(opts: UpdateOpts) -> None:
|
||||
update_po_files(opts.in_pot, resolve_glob_files(opts.in_po))
|
||||
+154
-1
@@ -18,6 +18,10 @@ def _is_float(s: str) -> bool:
|
||||
|
||||
|
||||
def register_general_filters(env: Environment) -> None:
|
||||
"""Register the general-purpose Liquid filters available in every rendering.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("to_string", str)
|
||||
env.add_filter("format", str.format)
|
||||
env.add_filter("to_integer", int)
|
||||
@@ -30,5 +34,154 @@ def _rc_escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def register_rc_specific_filters(env: Environment) -> None:
|
||||
def register_rc_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters specific to Windows RC file rendering.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("rc_escape", _rc_escape)
|
||||
|
||||
|
||||
def _desktop_escape(value: str) -> str:
|
||||
"""Escape a desktop entry string value (see the Desktop Entry
|
||||
Specification, section 4 "Possible value types").
|
||||
|
||||
Backslash, newline, carriage return and tab are escaped everywhere.
|
||||
Spaces are additionally escaped as ``\\s`` when leading or trailing,
|
||||
where parsers may trim them away.
|
||||
"""
|
||||
core = (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
lstripped = core.lstrip(" ")
|
||||
leading = len(core) - len(lstripped)
|
||||
trailing = len(lstripped) - len(lstripped.rstrip(" "))
|
||||
return "\\s" * leading + lstripped.rstrip(" ") + "\\s" * trailing
|
||||
|
||||
|
||||
def register_desktop_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters specific to desktop entry file rendering.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("desktop_escape", _desktop_escape)
|
||||
|
||||
|
||||
def _xml_escape(value: str) -> str:
|
||||
"""Escape a string for use in XML text content or attribute values.
|
||||
|
||||
The five predefined XML entities are produced: ``&`` becomes ``&``,
|
||||
``<`` becomes ``<``, ``>`` becomes ``>``, ``"`` becomes ``"``
|
||||
and ``'`` becomes ``'``.
|
||||
"""
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def register_appstream_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters specific to AppStream XML file rendering.
|
||||
|
||||
: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)
|
||||
|
||||
|
||||
def _plist_strings_escape(value: str) -> str:
|
||||
"""Escape a string for use inside an InfoPlist.strings value.
|
||||
|
||||
The escape sequences of the strings file format are produced: ``\\``
|
||||
becomes ``\\\\``, ``"`` becomes ``\\"``, linefeed, carriage return and
|
||||
tab become ``\\n``, ``\\r`` and ``\\t``. Non-ASCII characters carry
|
||||
over unchanged, as strings files are UTF-8 by definition.
|
||||
"""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
|
||||
def register_plist_info_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters for the Info.plist file rendering.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("xml_escape", _xml_escape)
|
||||
|
||||
|
||||
def register_plist_strings_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters for the InfoPlist.strings file rendering.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("plist_strings_escape", _plist_strings_escape)
|
||||
|
||||
|
||||
def _inno_escape(value: str) -> str:
|
||||
"""Escape a string for use inside an Inno Setup message value.
|
||||
|
||||
Linefeed and carriage return become ``%n``. The ``%1``..``%9``
|
||||
argument placeholders and a literal ``%`` are left untouched: the
|
||||
``%%`` escape is only defined for messages that take arguments, so
|
||||
managing ``%`` is the translator's responsibility.
|
||||
"""
|
||||
return value.replace("\n", "%n").replace("\r", "%n")
|
||||
|
||||
|
||||
def register_inno_filters(env: Environment) -> None:
|
||||
"""Register the Liquid filters for Inno Setup file rendering.
|
||||
|
||||
Both the .iss and the .isl file renderings share the same message
|
||||
value escaping, so one registration function serves both.
|
||||
|
||||
:param env: The environment to register the filters on.
|
||||
"""
|
||||
env.add_filter("inno_escape", _inno_escape)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Runtime hacks over third-party dependencies.
|
||||
|
||||
Each hack should be applied at the very start of ``main`` and
|
||||
documents why it exists and when it can be dropped.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import liquid.builtin.expressions.primitive as _primitive
|
||||
|
||||
# TODO:
|
||||
# When python-liquid relax the boundary of range literal,
|
||||
# remove this hack.
|
||||
|
||||
|
||||
def relax_liquid_range_literal_boundary() -> None:
|
||||
"""Relax python-liquid's range literal bounds.
|
||||
|
||||
python-liquid (pinned to 2.3.1) clamps the endpoints of range literals
|
||||
such as ``(1000..1028)`` to plus/minus 1024, silently truncating the
|
||||
loop to 1000..1023. Windows RC string identifiers legitimately go up
|
||||
to 65535, so templates iterating numeric ID ranges lose entries
|
||||
without any error. Relax the bounds the library itself defines
|
||||
to a more large boundary (as its commented)
|
||||
"""
|
||||
_primitive.MAX_RANGE = sys.maxsize
|
||||
_primitive.MIN_RANGE = -sys.maxsize
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
from typing import ClassVar, Optional
|
||||
import langcodes
|
||||
import pycountry
|
||||
|
||||
|
||||
@@ -127,6 +128,258 @@ class PoLang:
|
||||
# endregion
|
||||
|
||||
|
||||
class DesktopLang:
|
||||
"""
|
||||
Represents a freedesktop.org desktop entry locale, i.e. the ``LOCALE``
|
||||
postfix of a localized key (see the Desktop Entry Specification,
|
||||
section 5 "Localized values for keys").
|
||||
|
||||
Supported formats:
|
||||
ll - ISO 639 language code (2 or 3 letters, lowercase)
|
||||
ll_CC - language code + ISO 3166 country code (uppercase)
|
||||
ll@modifier - language code + modifier
|
||||
ll_CC@modifier - language code + country code + modifier
|
||||
|
||||
The ``.ENCODING`` part of the specification's grammar is deliberately
|
||||
not modeled: it is ignored when matching locales and never written by
|
||||
real world desktop entry files.
|
||||
|
||||
Examples:
|
||||
DesktopLang("en") # valid
|
||||
DesktopLang("zh_CN") # valid
|
||||
DesktopLang("sr@Latn") # valid (modifier without country)
|
||||
DesktopLang("sr_RS@latin") # valid
|
||||
DesktopLang("EN") # invalid (language code must be lowercase)
|
||||
"""
|
||||
|
||||
__PATTERN: ClassVar[re.Pattern] = re.compile(
|
||||
r"^(?P<language>[a-z]{2,3})"
|
||||
r"(?:_(?P<country>[A-Z]{2}))?"
|
||||
r"(?:@(?P<modifier>[a-zA-Z][a-zA-Z0-9-]*))?$"
|
||||
)
|
||||
|
||||
__language_code: str
|
||||
"""ISO 639 two-letter or three-letter language code (lowercase)."""
|
||||
__country_code: Optional[str]
|
||||
"""ISO 3166 two-letter country code (uppercase) or no presented."""
|
||||
__modifier: Optional[str]
|
||||
"""The modifier designator, such as 'latin' or 'Latn'."""
|
||||
|
||||
def __init__(self, value: str):
|
||||
match = self.__PATTERN.match(value)
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"Invalid desktop locale format: {value!r}. "
|
||||
f"Expected 'll', 'll_CC', 'll@modifier' or 'll_CC@modifier'"
|
||||
)
|
||||
|
||||
self.__language_code = match.group("language")
|
||||
self.__country_code = match.group("country") # may be None
|
||||
self.__modifier = match.group("modifier") # may be None
|
||||
|
||||
self.__validate_language(self.__language_code)
|
||||
if self.__country_code:
|
||||
self.__validate_country(self.__country_code)
|
||||
|
||||
# region: Internal validation helpers
|
||||
|
||||
@staticmethod
|
||||
def __validate_language(language_code: str) -> None:
|
||||
"""Validate the language code as an ISO 639 code using pycountry."""
|
||||
lang = pycountry.languages.get(alpha_2=language_code)
|
||||
if lang is None:
|
||||
lang = pycountry.languages.get(alpha_3=language_code)
|
||||
if lang is None:
|
||||
raise ValueError(f"Invalid language code: {language_code!r}")
|
||||
|
||||
@staticmethod
|
||||
def __validate_country(country_code: str) -> None:
|
||||
"""Validate the country code as an ISO 3166 alpha-2 code using pycountry."""
|
||||
country = pycountry.countries.get(alpha_2=country_code)
|
||||
if country is None:
|
||||
raise ValueError(f"Invalid country code: {country_code!r}")
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Properties
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""The lowercase language code."""
|
||||
return self.__language_code
|
||||
|
||||
@property
|
||||
def country(self) -> str | None:
|
||||
"""The uppercase country code, or None if not specified."""
|
||||
return self.__country_code
|
||||
|
||||
@property
|
||||
def modifier(self) -> str | None:
|
||||
"""The modifier identifier, or None if not specified."""
|
||||
return self.__modifier
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
"""The canonical string representation, rebuilt from the components."""
|
||||
result = self.__language_code
|
||||
if self.__country_code is not None:
|
||||
result += f"_{self.__country_code}"
|
||||
if self.__modifier is not None:
|
||||
result += f"@{self.__modifier}"
|
||||
return result
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Object methods
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DesktopLang({self.value!r})"
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if self is other:
|
||||
return True
|
||||
if not isinstance(other, DesktopLang):
|
||||
return NotImplemented
|
||||
return (
|
||||
self.__language_code == other.__language_code
|
||||
and self.__country_code == other.__country_code
|
||||
and self.__modifier == other.__modifier
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.__language_code, self.__country_code, self.__modifier))
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
class Bcp47Lang:
|
||||
"""
|
||||
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
|
||||
of BCP 47 is therefore accepted, and subtags are checked against the
|
||||
IANA registry: ``zh-CN``, ``sr-Latn-RS`` and ``ca-ES-valencia`` are
|
||||
valid, while ``jp`` and the unregistered variant ``euro`` are not.
|
||||
|
||||
Tags are case-insensitive per BCP 47, and close-but-nonstandard
|
||||
spellings are normalized: ``zh-cn`` and ``zh_CN`` both canonicalize
|
||||
to ``zh-CN``.
|
||||
|
||||
Deliberate usage policy: MetaGlot only parses, validates and
|
||||
serializes tags through this class hierarchy. It never calls
|
||||
``langcodes.standardize_tag`` nor enables macro language collapsing,
|
||||
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:
|
||||
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
|
||||
"""The underlying langcodes language object."""
|
||||
|
||||
def __init__(self, value: str):
|
||||
try:
|
||||
lang = langcodes.Language.get(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid BCP 47 language tag: {value!r}. ({exc})"
|
||||
) from exc
|
||||
if not lang.is_valid():
|
||||
raise ValueError(f"Invalid BCP 47 language tag: {value!r}")
|
||||
self.__lang = lang
|
||||
|
||||
# region: Properties
|
||||
|
||||
@property
|
||||
def language(self) -> Optional[str]:
|
||||
"""The lowercase language subtag, or None if unspecified."""
|
||||
return self.__lang.language
|
||||
|
||||
@property
|
||||
def script(self) -> Optional[str]:
|
||||
"""The title-case script subtag, or None if not specified."""
|
||||
return self.__lang.script
|
||||
|
||||
@property
|
||||
def territory(self) -> Optional[str]:
|
||||
"""The uppercase territory subtag, or None if not specified."""
|
||||
return self.__lang.territory
|
||||
|
||||
@property
|
||||
def variant(self) -> Optional[str]:
|
||||
"""The first variant subtag, or None if not specified."""
|
||||
variants = self.__lang.variants or []
|
||||
return variants[0] if variants else None
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
"""The canonical string form, in BCP 47 conventional casing."""
|
||||
return str(self.__lang)
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Object methods
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.value!r})"
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if self is other:
|
||||
return True
|
||||
if type(self) is not type(other):
|
||||
return NotImplemented
|
||||
return self.value == other.value
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
|
||||
# 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 PlistLang(Bcp47Lang):
|
||||
"""
|
||||
Represents a BCP 47 language tag, as required by the language folder
|
||||
names of macOS bundle localizations (e.g. ``en.lproj``,
|
||||
``zh-Hans.lproj``).
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from ..langid import AppStreamLang, PoLang
|
||||
from . import bcp47
|
||||
|
||||
|
||||
def convert(lang: PoLang) -> AppStreamLang:
|
||||
"""
|
||||
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:`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.
|
||||
"""
|
||||
return bcp47.convert(lang, AppStreamLang)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..langid import DesktopLang, PoLang
|
||||
|
||||
|
||||
def convert(lang: PoLang) -> DesktopLang:
|
||||
"""
|
||||
Convert a PO file language to a desktop entry locale.
|
||||
|
||||
Both sides speak ISO codes and share the same component layout, so the
|
||||
conversion maps components one to one: the language and country codes
|
||||
carry over unchanged, and the PO variant slot fills the desktop
|
||||
modifier slot. No lookup table is needed here - unlike the Windows
|
||||
map, whose vocabulary differs on both sides ('cyrillic' != 'Cyrl') -
|
||||
which keeps this module the extension point should special cases
|
||||
emerge.
|
||||
|
||||
:param lang: The PO file language.
|
||||
:return: The desktop entry locale for the language.
|
||||
"""
|
||||
segments = [lang.language]
|
||||
if lang.country is not None:
|
||||
segments.append(lang.country)
|
||||
value = "_".join(segments)
|
||||
if lang.variant is not None:
|
||||
value += f"@{lang.variant}"
|
||||
return DesktopLang(value)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,16 @@
|
||||
from ..langid import PlistLang, PoLang
|
||||
from . import bcp47
|
||||
|
||||
|
||||
def convert(lang: PoLang) -> PlistLang:
|
||||
"""
|
||||
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:`PlistLang`.
|
||||
|
||||
: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, PlistLang)
|
||||
@@ -1,24 +1,31 @@
|
||||
from ..langid import PoLang, WinLcid
|
||||
|
||||
# YYC MARK: The table below is transcribed from the 'Language ID' table in
|
||||
|
||||
# YYC MARK:
|
||||
# The table below is transcribed from the 'Language ID' table in
|
||||
# [MS-LCID] section 2.2 (revision 2024-04-23). Underscore separators in the
|
||||
# original language tags have been normalized to hyphens
|
||||
# (e.g. 'es-ES_tradnl' -> 'es-ES-tradnl').
|
||||
# YYC MARK: Rows whose tags are marked 'reserved' in the document are kept
|
||||
#
|
||||
# Rows whose tags are marked 'reserved' in the document are kept
|
||||
# for transcription fidelity but commented out with a leading '#', so they
|
||||
# carry no runtime effect and lookups on them fail.
|
||||
# YYC MARK: Rows without a usable language tag are omitted entirely: values
|
||||
#
|
||||
# Rows without a usable language tag are omitted entirely: values
|
||||
# that are neither defined nor reserved, the invariant-locale value 0x007F,
|
||||
# the user-default and unspecified placeholders 0x0C00/0x1000 (harmful
|
||||
# process-local values that must never be persisted into RC resources), and
|
||||
# the transient LCIDs 0x2000-0x4C00.
|
||||
# YYC MARK: Rows carrying multiple tags for one value (e.g.
|
||||
#
|
||||
# Rows carrying multiple tags for one value (e.g.
|
||||
# 'ff-NG, ff-Latn-NG') are expanded into consecutive entries sharing the
|
||||
# same WinLcid.
|
||||
# YYC MARK: The alternate-sort table of section 2.2 (full 4-byte LCIDs with
|
||||
#
|
||||
# The alternate-sort table of section 2.2 (full 4-byte LCIDs with
|
||||
# sort identifiers, e.g. 'de-DE_phoneb' 0x00010407) is not transcribed:
|
||||
# WinLcid only models the 2-byte language identifier used by RC resources.
|
||||
# YYC MARK: Known contradiction inside the document: the appendix assigns
|
||||
#
|
||||
# Known contradiction inside the document: the appendix assigns
|
||||
# 'quc' -> 0x0086 and 'quc-Latn-GT' -> 0x0486, while the section 2.2 registry
|
||||
# has 'qut' -> 0x0086 and marks 'quc' (0x0093), 'qut-GT' (0x0486) and
|
||||
# 'quc-CO' (0x0493) as reserved. The section 2.2 registry wins here.
|
||||
|
||||
+65
-27
@@ -1,29 +1,63 @@
|
||||
import tomllib
|
||||
from typing import Annotated, Optional
|
||||
from pathlib import Path
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
GetPydanticSchema,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import core_schema
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from .langid import PoLang
|
||||
|
||||
MANIFEST_VERSION = 1
|
||||
"""The manifest format version this build of MetaGlot understands."""
|
||||
|
||||
|
||||
class StringEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
class StringEntry(BaseModel, frozen=True):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
msgid: str
|
||||
context: str = ""
|
||||
comment: str = ""
|
||||
"""The string to be translated."""
|
||||
context: Optional[str] = None
|
||||
"""The context of this entry. ``None`` if no context."""
|
||||
comment: Optional[str] = None
|
||||
"""Translator-oriented comment of this entry. ``None`` if nothing."""
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
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."""
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _check_version(cls, value: int) -> int:
|
||||
def validate_version(cls, value: int) -> int:
|
||||
if value != MANIFEST_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported manifest version: {value} (expected {MANIFEST_VERSION})"
|
||||
@@ -31,34 +65,38 @@ class Manifest(BaseModel):
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_entries(self) -> "Manifest":
|
||||
empty_keys = sorted(key for key in self.strings if not key)
|
||||
if empty_keys:
|
||||
def validate_string_entries(self) -> "Manifest":
|
||||
has_empty_key = any(key == "" for key in self.strings.keys())
|
||||
if has_empty_key:
|
||||
raise ValueError("string key(s) must not be empty")
|
||||
pairs = [(entry.msgid, entry.context) for entry in self.strings.values()]
|
||||
duplicated = sorted({pair for pair in pairs if pairs.count(pair) > 1})
|
||||
if duplicated:
|
||||
|
||||
has_empty_value = any(value.msgid == "" for value in self.strings.values())
|
||||
if has_empty_value:
|
||||
raise ValueError("string value(s) must not be empty")
|
||||
|
||||
seen: set[tuple[str, Optional[str]]] = set()
|
||||
duplicates: set[tuple[str, Optional[str]]] = set()
|
||||
for entry in self.strings.values():
|
||||
pair = (entry.msgid, entry.context)
|
||||
if pair in seen:
|
||||
duplicates.add(pair)
|
||||
seen.add(pair)
|
||||
if duplicates:
|
||||
rendered = ", ".join(
|
||||
f"{msgid!r}" if not context else f"{msgid!r} (context {context!r})"
|
||||
for msgid, context in duplicated
|
||||
f"{msgid!r}" if context is None else f"{msgid!r} (context {context!r})"
|
||||
for msgid, context in duplicates
|
||||
)
|
||||
raise ValueError(f"duplicated msgid and context combination(s): {rendered}")
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def entries(self) -> list[tuple[str, StringEntry]]:
|
||||
return list(self.strings.items())
|
||||
|
||||
@property
|
||||
def msgids(self) -> set[str]:
|
||||
return {entry.msgid for entry in self.strings.values()}
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
try:
|
||||
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
with open(path, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
return Manifest.model_validate(raw)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise ValueError(f"failed to parse manifest '{path}': {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read manifest '{path}': {exc}") from exc
|
||||
return Manifest.model_validate(raw)
|
||||
|
||||
+209
-27
@@ -1,57 +1,213 @@
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Iterable, Iterator, Optional
|
||||
import polib
|
||||
|
||||
from .manifest import Manifest, load_manifest
|
||||
from .langid import PoLang
|
||||
from .manifest import Manifest, StringEntry
|
||||
|
||||
_POT_HEADER = {
|
||||
"Project-Id-Version": "PACKAGE VERSION",
|
||||
"POT-Creation-Date": "YEAR-MO-DA HO:MI+ZONE",
|
||||
"PO-Revision-Date": "YEAR-MO-DA HO:MI+ZONE",
|
||||
"Last-Translator": "FULL NAME <EMAIL@ADDRESS>",
|
||||
"Language-Team": "LANGUAGE <LL@li.org>",
|
||||
"MIME-Version": "1.0",
|
||||
"Content-Type": "text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding": "8bit",
|
||||
"X-Generator": "MetaGlot/polib",
|
||||
}
|
||||
|
||||
|
||||
def collect_po_files(patterns: list[Path]) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
@dataclass(frozen=True)
|
||||
class Translation:
|
||||
"""The resolution of one manifest entry in one language."""
|
||||
|
||||
entry: StringEntry
|
||||
"""The manifest entry this translation resolves."""
|
||||
|
||||
text: str
|
||||
"""The usable text of the entry. It falls back to the manifest source
|
||||
string when the entry is untranslated, missing or marked fuzzy."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LanguagePack:
|
||||
"""Resolved translations of one language against a manifest."""
|
||||
|
||||
lang: PoLang
|
||||
"""The language of this pack."""
|
||||
|
||||
translations: dict[str, Translation]
|
||||
"""The resolved translation of every manifest entry, keyed by manifest
|
||||
entry key."""
|
||||
|
||||
|
||||
def resolve_glob_files(patterns: Iterable[Path]) -> Iterator[Path]:
|
||||
"""Lazily iterate the files matched by the given glob patterns.
|
||||
|
||||
A pattern matching no file only produces a warning. A file matched by
|
||||
several patterns is yielded once per matching pattern.
|
||||
|
||||
:param patterns: The glob patterns to match.
|
||||
:return: An iterator of resolved file paths.
|
||||
"""
|
||||
for pattern in patterns:
|
||||
matched = sorted(glob.glob(str(pattern)))
|
||||
if not matched:
|
||||
raise ValueError(f"the pattern '{pattern}' matched no PO file")
|
||||
matched = tuple(Path(p) for p in glob.glob(str(pattern)))
|
||||
if len(matched) == 0:
|
||||
logging.warning("Pattern %s does not match any file.", pattern)
|
||||
for name in matched:
|
||||
file = Path(name)
|
||||
resolved = file.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
files.append(file)
|
||||
return files
|
||||
yield name.resolve()
|
||||
|
||||
|
||||
def load_po(path: Path) -> tuple[str, dict[str, str]]:
|
||||
def _load_po(path: Path) -> polib.POFile:
|
||||
"""Load and parse a PO file.
|
||||
|
||||
:param path: The path of the PO file to load.
|
||||
:return: The parsed PO file.
|
||||
:raises RuntimeError: If the file cannot be read or parsed.
|
||||
"""
|
||||
try:
|
||||
po = polib.pofile(str(path))
|
||||
except (OSError, IOError) as exc:
|
||||
return polib.pofile(str(path))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read PO file '{path}': {exc}") from exc
|
||||
locale = po.metadata.get("Language") or path.stem
|
||||
|
||||
|
||||
def _extract_lang(path: Path, po: polib.POFile) -> PoLang:
|
||||
"""Extract the language of a loaded PO file.
|
||||
|
||||
The ``Language`` metadata field is tried first, then the file name
|
||||
without extension.
|
||||
|
||||
:param path: The path of the PO file, used for fallback and reporting.
|
||||
:param po: The loaded PO file.
|
||||
:return: The language of the PO file.
|
||||
:raises RuntimeError: If no valid language can be extracted.
|
||||
"""
|
||||
metadata_language = po.metadata.get("Language")
|
||||
if metadata_language is not None:
|
||||
try:
|
||||
return PoLang(metadata_language)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return PoLang(path.stem)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise RuntimeError(f"can't fetch valid language id in PO file '{path}'")
|
||||
|
||||
|
||||
def _translated_text(entry: Optional[polib.POEntry], fallback: str) -> str:
|
||||
"""Return the usable translation of an entry.
|
||||
|
||||
:param entry: The PO entry to read, or ``None`` when absent.
|
||||
:param fallback: The source string used when the entry is missing,
|
||||
untranslated or marked fuzzy.
|
||||
:return: The usable translation.
|
||||
"""
|
||||
if entry is None or entry.msgstr == "" or entry.fuzzy:
|
||||
return fallback
|
||||
return entry.msgstr
|
||||
|
||||
|
||||
def resolve_translations(
|
||||
manifest: Manifest, po_paths: Iterable[Path]
|
||||
) -> dict[PoLang, LanguagePack]:
|
||||
"""Resolve the translations of the given PO files against a manifest.
|
||||
|
||||
Each PO file is loaded lazily and resolved to a :class:`LanguagePack`
|
||||
holding the resolution of every manifest entry. Entries that are missing,
|
||||
untranslated or marked fuzzy fall back to their source string.
|
||||
|
||||
:param manifest: The manifest describing all translatable entries.
|
||||
:param po_paths: The PO file paths to resolve.
|
||||
:return: A mapping from each language to its resolved language pack.
|
||||
:raises ValueError: If two PO files describe the same language, or a PO
|
||||
file contains entries unknown to the manifest.
|
||||
"""
|
||||
# Collect every (msgid, context) pair the manifest defines. PO entries
|
||||
# matching none of them are rejected below.
|
||||
expected = {(entry.msgid, entry.context) for entry in manifest.strings.values()}
|
||||
# The resolved packs being built, keyed by language.
|
||||
packs: dict[PoLang, LanguagePack] = {}
|
||||
# Which file introduced each language, for duplicate reporting.
|
||||
sources: dict[PoLang, Path] = {}
|
||||
for path in po_paths:
|
||||
# Load the PO file and identify its language.
|
||||
po = _load_po(path)
|
||||
lang = _extract_lang(path, po)
|
||||
|
||||
# One language must come from exactly one PO file.
|
||||
if lang in packs:
|
||||
raise ValueError(
|
||||
f"duplicate language '{lang}' found in "
|
||||
f"'{sources[lang].name}' and '{path.name}'"
|
||||
)
|
||||
sources[lang] = path
|
||||
|
||||
# Index the entries by their (msgid, msgctxt) pair, so that lookups
|
||||
# and set operations share one pass over the file. Obsolete entries
|
||||
# are ignored, mirroring how polib's find() treats them.
|
||||
index: dict[tuple[str, Optional[str]], polib.POEntry] = {
|
||||
(entry.msgid, entry.msgctxt): entry for entry in po if not entry.obsolete
|
||||
}
|
||||
|
||||
# Reject entries the manifest does not define.
|
||||
unknown = index.keys() - expected
|
||||
if unknown:
|
||||
rendered = ", ".join(
|
||||
f"{msgid!r}" if context is None else f"{msgid!r} (context {context!r})"
|
||||
for msgid, context in unknown
|
||||
)
|
||||
raise ValueError(
|
||||
f"'{path.name}' contains entry(s) unknown to the manifest: {rendered}"
|
||||
)
|
||||
|
||||
# Resolve every manifest entry against the index. Entries that are
|
||||
# missing, untranslated or fuzzy fall back to their source string.
|
||||
translations = {
|
||||
key: Translation(
|
||||
entry,
|
||||
_translated_text(index.get((entry.msgid, entry.context)), entry.msgid),
|
||||
)
|
||||
for key, entry in manifest.strings.items()
|
||||
}
|
||||
packs[lang] = LanguagePack(lang, 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 = {
|
||||
entry.msgid: entry.msgstr
|
||||
for entry in po
|
||||
if entry.msgstr and "fuzzy" not in entry.flags
|
||||
key: Translation(entry, entry.msgid) for key, entry in manifest.strings.items()
|
||||
}
|
||||
return locale, translations
|
||||
return LanguagePack(lang, translations)
|
||||
|
||||
|
||||
def generate(manifest_path: Path, output_path: Path) -> None:
|
||||
manifest: Manifest = load_manifest(manifest_path)
|
||||
def generate_pot(manifest: Manifest, output_path: Path) -> None:
|
||||
"""Generate a POT template file from a manifest.
|
||||
|
||||
:param manifest: The manifest holding the translatable strings.
|
||||
:param output_path: The path of the POT file to write.
|
||||
"""
|
||||
po = polib.POFile()
|
||||
po.metadata = _POT_HEADER
|
||||
for _, entry in manifest.entries:
|
||||
for _, entry in manifest.strings.items():
|
||||
po.append(
|
||||
polib.POEntry(
|
||||
msgid=entry.msgid,
|
||||
@@ -62,4 +218,30 @@ def generate(manifest_path: Path, output_path: Path) -> None:
|
||||
)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
po.save(str(output_path))
|
||||
logging.info("wrote %d entries to %s", len(po), output_path)
|
||||
logging.info("Wrote %d entries to POT file %s.", len(po), output_path)
|
||||
|
||||
|
||||
def update_po_files(pot_path: Path, po_paths: Iterable[Path]) -> None:
|
||||
"""Update the given PO files in place against a POT template.
|
||||
|
||||
Each PO file is merged with the POT using the ``msgmerge`` tool from GNU
|
||||
gettext: entries are added, removed and marked fuzzy as needed, and the
|
||||
result is written back over the PO file without keeping a backup.
|
||||
|
||||
The msgmerge binary is looked up on PATH; set the ``METAGLOT_MSGMERGE``
|
||||
environment variable to use a specific executable instead.
|
||||
|
||||
:param pot_path: The path of the POT template file.
|
||||
:param po_paths: The PO file paths to update.
|
||||
:raises RuntimeError: If msgmerge fails on a PO file.
|
||||
"""
|
||||
msgmerge_bin = os.getenv("METAGLOT_MSGMERGE", "msgmerge")
|
||||
for po_path in po_paths:
|
||||
logging.info("Updating PO by POT: %s -> %s", pot_path, po_path)
|
||||
cmd = [msgmerge_bin, "-U", str(po_path), str(pot_path), "--backup=none"]
|
||||
proc = subprocess.run(cmd, capture_output=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"failed to update PO file '{po_path}' with msgmerge "
|
||||
f"(return code {proc.returncode})"
|
||||
)
|
||||
|
||||
+78
-91
@@ -1,98 +1,61 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from liquid import Environment, StrictUndefined
|
||||
from liquid.exceptions import LiquidError
|
||||
|
||||
from . import winlang
|
||||
from .manifest import Manifest, load_manifest
|
||||
from .pofile import collect_po_files, load_po
|
||||
|
||||
class RenderContext(Protocol):
|
||||
"""The interface required of a top-level rendering context.
|
||||
|
||||
A render context routes template variable lookups dynamically (Liquid
|
||||
performs item access only), and enumerates its variable names the same
|
||||
way the ``dict`` constructor does, so an instance can be passed to
|
||||
``BoundTemplate.render`` directly.
|
||||
"""
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
"""Return the names of the top-level template variables."""
|
||||
...
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
"""Return the value of the named top-level template variable."""
|
||||
...
|
||||
|
||||
|
||||
def _rc_escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
def create_environment() -> Environment:
|
||||
"""Create a Liquid environment for rendering.
|
||||
|
||||
The environment runs in strict mode: referencing a variable or a property
|
||||
that does not exist fails the render. No filters are registered; callers
|
||||
register what they need with the functions of the ``filters`` module.
|
||||
|
||||
:return: The created environment.
|
||||
"""
|
||||
return Environment(undefined=StrictUndefined)
|
||||
|
||||
|
||||
def _create_environment() -> Environment:
|
||||
env = Environment(undefined=StrictUndefined)
|
||||
env.add_filter("rc_escape", _rc_escape)
|
||||
return env
|
||||
def render(
|
||||
env: Environment,
|
||||
template_path: Path,
|
||||
context: RenderContext,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""Render a Liquid template and write the result to a file.
|
||||
|
||||
The template is read as UTF-8. The rendered output is written as UTF-8
|
||||
using the system's native line endings. The parent folder of the output
|
||||
file is created when it does not exist.
|
||||
|
||||
def _language_context(
|
||||
locale: str,
|
||||
language: winlang.WindowsLanguage,
|
||||
translations: dict[str, str],
|
||||
manifest: Manifest,
|
||||
) -> dict:
|
||||
entries = manifest.entries
|
||||
return {
|
||||
"name": language.name,
|
||||
"locale": locale,
|
||||
"langid": f"0x{language.langid:X}",
|
||||
"primary": f"0x{language.primary:02X}",
|
||||
"sublanguage": f"0x{language.sublanguage:02X}",
|
||||
"block_key": f"{language.langid:04X}04B0",
|
||||
"strings": [
|
||||
{
|
||||
"key": key,
|
||||
"msgid": entry.msgid,
|
||||
"text": translations.get(entry.msgid, entry.msgid),
|
||||
"context": entry.context,
|
||||
}
|
||||
for key, entry in entries
|
||||
],
|
||||
"named": {
|
||||
key: translations.get(entry.msgid, entry.msgid)
|
||||
for key, entry in entries
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def render(manifest_path: Path, po_patterns: list[Path], template_path: Path, output_path: Path) -> None:
|
||||
manifest = load_manifest(manifest_path)
|
||||
known = manifest.msgids
|
||||
|
||||
collected = []
|
||||
locale_sources: dict[str, Path] = {}
|
||||
for po_path in collect_po_files(po_patterns):
|
||||
locale, translations = load_po(po_path)
|
||||
unknown = sorted(set(translations) - known)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{po_path.name} contains unknown msgid(s): {', '.join(unknown)}"
|
||||
)
|
||||
if locale in locale_sources:
|
||||
raise ValueError(
|
||||
f"duplicate locale '{locale}' found in '{locale_sources[locale].name}' and '{po_path.name}'"
|
||||
)
|
||||
locale_sources[locale] = po_path
|
||||
try:
|
||||
language = winlang.lookup(locale)
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"locale '{locale}' (from {po_path.name}) has no Windows language mapping"
|
||||
) from None
|
||||
collected.append((locale, language, translations))
|
||||
|
||||
english = winlang.lookup("en")
|
||||
english_po = next(
|
||||
(item for item in collected if item[1].langid == english.langid), None
|
||||
)
|
||||
others = sorted(
|
||||
(item for item in collected if item[1].langid != english.langid),
|
||||
key=lambda item: item[1].langid,
|
||||
)
|
||||
ordered = [
|
||||
("en", english, english_po[2] if english_po else {}),
|
||||
*others,
|
||||
]
|
||||
|
||||
languages = [
|
||||
_language_context(locale, language, translations, manifest)
|
||||
for locale, language, translations in ordered
|
||||
]
|
||||
|
||||
:param env: The environment to render with.
|
||||
:param template_path: The path of the template file.
|
||||
:param context: The top-level render context passed to the template.
|
||||
:param output_path: The path of the rendered file to write.
|
||||
:raises ValueError: If the template file does not exist.
|
||||
:raises RuntimeError: If the template cannot be read or rendered.
|
||||
"""
|
||||
if not template_path.is_file():
|
||||
raise ValueError(f"template file not found: {template_path}")
|
||||
try:
|
||||
@@ -100,21 +63,45 @@ def render(manifest_path: Path, po_patterns: list[Path], template_path: Path, ou
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read template '{template_path}': {exc}") from exc
|
||||
|
||||
env = _create_environment()
|
||||
try:
|
||||
template = env.from_string(
|
||||
template_source, name=str(template_path), path=template_path
|
||||
)
|
||||
content = template.render(languages=languages)
|
||||
content = template.render(context)
|
||||
except LiquidError as exc:
|
||||
raise RuntimeError(
|
||||
f"failed to render template '{template_path}': {exc}"
|
||||
) from exc
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(content, encoding="utf-8", newline="\n")
|
||||
logging.info(
|
||||
"wrote %s with languages: %s",
|
||||
output_path,
|
||||
", ".join(language["name"] for language in languages),
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowsLanguage:
|
||||
langid: int
|
||||
name: str
|
||||
|
||||
@property
|
||||
def primary(self) -> int:
|
||||
return self.langid & 0x3FF
|
||||
|
||||
@property
|
||||
def sublanguage(self) -> int:
|
||||
return self.langid >> 10
|
||||
|
||||
|
||||
_LANGUAGES: dict[str, WindowsLanguage] = {
|
||||
"en": WindowsLanguage(0x0409, "English (United States)"),
|
||||
"ca": WindowsLanguage(0x0403, "Catalan (Catalan)"),
|
||||
"de": WindowsLanguage(0x0407, "German (Germany)"),
|
||||
"es": WindowsLanguage(0x0C0A, "Spanish (Spain)"),
|
||||
"fr": WindowsLanguage(0x040C, "French (France)"),
|
||||
"id": WindowsLanguage(0x0421, "Indonesian (Indonesia)"),
|
||||
"it": WindowsLanguage(0x0410, "Italian (Italy)"),
|
||||
"ja": WindowsLanguage(0x0411, "Japanese (Japan)"),
|
||||
"ko": WindowsLanguage(0x0412, "Korean (Korea)"),
|
||||
"nb_NO": WindowsLanguage(0x0414, "Norwegian Bokmal (Norway)"),
|
||||
"nl": WindowsLanguage(0x0413, "Dutch (Netherlands)"),
|
||||
"pa_PK": WindowsLanguage(0x0846, "Punjabi (Pakistan)"),
|
||||
"ru": WindowsLanguage(0x0419, "Russian (Russia)"),
|
||||
"si": WindowsLanguage(0x045B, "Sinhala (Sri Lanka)"),
|
||||
"sl": WindowsLanguage(0x0424, "Slovenian (Slovenia)"),
|
||||
"ta": WindowsLanguage(0x0449, "Tamil (India)"),
|
||||
"tr": WindowsLanguage(0x041F, "Turkish (Turkey)"),
|
||||
"uk": WindowsLanguage(0x0422, "Ukrainian (Ukraine)"),
|
||||
"vi": WindowsLanguage(0x042A, "Vietnamese (Vietnam)"),
|
||||
"zh_CN": WindowsLanguage(0x0804, "Chinese (Simplified, China)"),
|
||||
}
|
||||
|
||||
_ALIASES: dict[str, str] = {
|
||||
"en_US": "en",
|
||||
"en_GB": "en",
|
||||
"zh": "zh_CN",
|
||||
"zh_Hans": "zh_CN",
|
||||
"zh_Hans_CN": "zh_CN",
|
||||
"nb": "nb_NO",
|
||||
"no": "nb_NO",
|
||||
"pa": "pa_PK",
|
||||
}
|
||||
|
||||
|
||||
def lookup(locale: str) -> WindowsLanguage:
|
||||
normalized = locale.replace("-", "_")
|
||||
if normalized in _LANGUAGES:
|
||||
return _LANGUAGES[normalized]
|
||||
if normalized in _ALIASES:
|
||||
return _LANGUAGES[_ALIASES[normalized]]
|
||||
raise KeyError(locale)
|
||||
@@ -29,6 +29,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langcodes"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/f9edc5d72945019312f359e69ded9f82392a81d49c5051ed3209b100c0d2/langcodes-3.5.1.tar.gz", hash = "sha256:40bff315e01b01d11c2ae3928dd4f5cbd74dd38f9bd912c12b9a3606c143f731", size = 191084, upload-time = "2025-12-02T16:22:01.627Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
@@ -86,6 +95,7 @@ name = "metaglot"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langcodes" },
|
||||
{ name = "polib" },
|
||||
{ name = "pycountry" },
|
||||
{ name = "pydantic" },
|
||||
@@ -94,10 +104,11 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langcodes", specifier = ">=3.5.1" },
|
||||
{ name = "polib", specifier = ">=1.2.0" },
|
||||
{ name = "pycountry", specifier = ">=26.2.16" },
|
||||
{ name = "pydantic", specifier = ">=2.11.7" },
|
||||
{ name = "python-liquid", specifier = ">=2.2.0" },
|
||||
{ name = "python-liquid", specifier = "==2.3.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user