feat: add plist support
This commit is contained in:
@@ -74,3 +74,12 @@ 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. |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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>
|
||||
```
|
||||
|
||||
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,97 @@
|
||||
<?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>
|
||||
<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 }}";
|
||||
@@ -2,18 +2,25 @@ import enum
|
||||
from argparse import ArgumentParser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from . import rc, desktop, appstream, msix
|
||||
from . import rc, desktop, appstream, msix, plist
|
||||
from .rc import RcRenderOpts
|
||||
from .desktop import DesktopRenderOpts
|
||||
from .appstream import AppStreamRenderOpts
|
||||
from .msix import MsixRenderOpts
|
||||
from .plist import PlistRenderOpts
|
||||
|
||||
_SUBCMD_DEST = "metadata"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderOpts:
|
||||
opts: RcRenderOpts | DesktopRenderOpts | AppStreamRenderOpts | MsixRenderOpts
|
||||
opts: (
|
||||
RcRenderOpts
|
||||
| DesktopRenderOpts
|
||||
| AppStreamRenderOpts
|
||||
| MsixRenderOpts
|
||||
| PlistRenderOpts
|
||||
)
|
||||
"""The option of one of subcommand."""
|
||||
|
||||
|
||||
@@ -22,6 +29,7 @@ class _Metadata(enum.StrEnum):
|
||||
Desktop = "desktop"
|
||||
Appstream = "appstream"
|
||||
Msix = "msix"
|
||||
Plist = "plist"
|
||||
|
||||
|
||||
def register(parser: ArgumentParser) -> None:
|
||||
@@ -42,6 +50,10 @@ def register(parser: ArgumentParser) -> None:
|
||||
_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)
|
||||
|
||||
|
||||
def parse(args: dict[str, Any]) -> RenderOpts:
|
||||
@@ -54,6 +66,8 @@ def parse(args: dict[str, Any]) -> RenderOpts:
|
||||
opts = RenderOpts(appstream.parse(args))
|
||||
case _Metadata.Msix:
|
||||
opts = RenderOpts(msix.parse(args))
|
||||
case _Metadata.Plist:
|
||||
opts = RenderOpts(plist.parse(args))
|
||||
case _:
|
||||
raise ValueError(f"unhandled render command: {args[_SUBCMD_DEST]}")
|
||||
return opts
|
||||
@@ -69,5 +83,7 @@ def run(opts: RenderOpts) -> None:
|
||||
appstream.run(appstream_opts)
|
||||
case MsixRenderOpts() as msix_opts:
|
||||
msix.run(msix_opts)
|
||||
case PlistRenderOpts() as plist_opts:
|
||||
plist.run(plist_opts)
|
||||
case _:
|
||||
raise RuntimeError(f"unhandled render options: {opts.opts!r}")
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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()
|
||||
)
|
||||
plist_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
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),
|
||||
)
|
||||
strings_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
render(
|
||||
strings_template_env,
|
||||
opts.in_strings_template,
|
||||
_PlistStringsTemplateContext(pack),
|
||||
strings_out,
|
||||
)
|
||||
@@ -130,3 +130,36 @@ def register_msix_resources_filters(env: Environment) -> None:
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -369,6 +369,17 @@ class MsixLang(Bcp47Lang):
|
||||
"""
|
||||
|
||||
|
||||
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,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)
|
||||
Reference in New Issue
Block a user