locon 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and write one flat locon locale JSON file.
3
+
4
+ Use as a CLI from any project directory:
5
+
6
+ python3 write_locale.py --locale fr --input /tmp/fr.json \
7
+ --assets src/i18n/assets --source de
8
+
9
+ Or import `write()` and pass the same values as a dictionary. The source file
10
+ defines key order and grouping; target-only CLDR plural categories stay beside
11
+ their family. Invalid parity, placeholders, duplicate JSON keys, and unsafe
12
+ locale names fail before the destination is touched.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import pathlib
21
+ import re
22
+ import tempfile
23
+ from typing import Iterable
24
+
25
+ DEFAULT_ASSETS = pathlib.Path(os.environ.get('LOCON_ASSETS', 'src/i18n/assets'))
26
+ DEFAULT_SOURCE_LOCALE = os.environ.get('LOCON_SOURCE', 'en')
27
+
28
+ PLURAL = re.compile(r'_(zero|one|two|few|many|other)$')
29
+ PLURAL_CATEGORIES = ('zero', 'one', 'two', 'few', 'many', 'other')
30
+ LOCALE_NAME = re.compile(r'^[A-Za-z]{2,8}(?:[-_][A-Za-z0-9]{1,8})*$')
31
+
32
+
33
+ class DuplicateKeyError(ValueError):
34
+ """Raised when JSON would silently overwrite a translation key."""
35
+
36
+
37
+ def _unique_object(pairs):
38
+ result = {}
39
+ for key, value in pairs:
40
+ if key in result:
41
+ raise DuplicateKeyError(f'duplicate key: {key}')
42
+ result[key] = value
43
+ return result
44
+
45
+
46
+ def _read_flat_asset(path: pathlib.Path) -> dict[str, str]:
47
+ try:
48
+ value = json.loads(
49
+ path.read_text(encoding='utf-8'),
50
+ object_pairs_hook=_unique_object,
51
+ )
52
+ except (OSError, json.JSONDecodeError, DuplicateKeyError) as error:
53
+ raise SystemExit(f'{path}: {error}') from error
54
+
55
+ if not isinstance(value, dict) or any(
56
+ not isinstance(key, str) or not isinstance(item, str)
57
+ for key, item in value.items()
58
+ ):
59
+ raise SystemExit(f'{path}: expected a flat JSON object with string values')
60
+ return value
61
+
62
+
63
+ def _source_boundaries(path: pathlib.Path) -> set[str]:
64
+ boundaries = set()
65
+ previous = None
66
+ for line in path.read_text(encoding='utf-8').splitlines():
67
+ match = re.match(r'\s*"([^"]+)":', line)
68
+ if match:
69
+ previous = match.group(1)
70
+ elif not line.strip() and previous:
71
+ boundaries.add(previous)
72
+ return boundaries
73
+
74
+
75
+ def _validate_locale(locale: str) -> None:
76
+ if not LOCALE_NAME.fullmatch(locale):
77
+ raise SystemExit(f'invalid locale name: {locale!r}')
78
+
79
+
80
+ def write(
81
+ locale: str,
82
+ values: dict[str, str],
83
+ *,
84
+ assets: pathlib.Path | str = DEFAULT_ASSETS,
85
+ source_locale: str = DEFAULT_SOURCE_LOCALE,
86
+ allow_implicit_count: Iterable[str] = (),
87
+ ) -> None:
88
+ """Validate `values` and atomically write `<assets>/<locale>.json`."""
89
+ _validate_locale(locale)
90
+ _validate_locale(source_locale)
91
+
92
+ asset_dir = pathlib.Path(assets)
93
+ source_path = asset_dir / f'{source_locale}.json'
94
+ if not source_path.exists():
95
+ raise SystemExit(f'source locale not found: {source_path}')
96
+
97
+ source = _read_flat_asset(source_path)
98
+ if not isinstance(values, dict) or any(
99
+ not isinstance(key, str) or not isinstance(value, str)
100
+ for key, value in values.items()
101
+ ):
102
+ raise SystemExit(f'{locale}: expected a dictionary with string values')
103
+
104
+ families = {PLURAL.sub('', key) for key in source if PLURAL.search(key)}
105
+ missing_other = [family for family in sorted(families) if f'{family}_other' not in source]
106
+ if missing_other:
107
+ raise SystemExit(
108
+ f'{source_locale}: plural families must define an _other catch-all: '
109
+ + ', '.join(missing_other)
110
+ )
111
+
112
+ missing = [key for key in source if key not in values]
113
+ unknown = [
114
+ key for key in values
115
+ if key not in source and not (PLURAL.search(key) and PLURAL.sub('', key) in families)
116
+ ]
117
+ if missing:
118
+ raise SystemExit(f'{locale}: missing {len(missing)} keys: {missing}')
119
+ if unknown:
120
+ raise SystemExit(f'{locale}: unknown keys: {unknown}')
121
+
122
+ allowances = set(allow_implicit_count)
123
+ used_allowances = set()
124
+
125
+ for key, value in values.items():
126
+ source_key = PLURAL.sub('_other', key) if PLURAL.search(key) else key
127
+ if source_key not in source:
128
+ continue
129
+ want = set(re.findall(r'\{(\w+)\}', source[source_key]))
130
+ got = set(re.findall(r'\{(\w+)\}', value))
131
+ introduced = got - want
132
+ dropped = want - got
133
+
134
+ if introduced:
135
+ raise SystemExit(f'{locale}.{key}: unknown placeholders {sorted(introduced)}')
136
+ if dropped == {'count'} and key in allowances:
137
+ used_allowances.add(key)
138
+ elif dropped:
139
+ suffix = (
140
+ f'; pass allow_implicit_count={{\'{key}\'}} if the wording states the exact number'
141
+ if 'count' in dropped else ''
142
+ )
143
+ raise SystemExit(f'{locale}.{key}: missing placeholders {sorted(dropped)}{suffix}')
144
+
145
+ unused_allowances = allowances - used_allowances
146
+ if unused_allowances:
147
+ raise SystemExit(f'{locale}: unused allow_implicit_count keys: {sorted(unused_allowances)}')
148
+
149
+ ordered = []
150
+ for key in source:
151
+ if key not in ordered:
152
+ ordered.append(key)
153
+ if PLURAL.search(key):
154
+ base = PLURAL.sub('', key)
155
+ for category in PLURAL_CATEGORIES:
156
+ sibling = f'{base}_{category}'
157
+ if sibling in values and sibling not in ordered:
158
+ ordered.append(sibling)
159
+
160
+ boundaries = _source_boundaries(source_path)
161
+ expanded_boundaries = set()
162
+ for boundary in boundaries:
163
+ if PLURAL.search(boundary):
164
+ family = PLURAL.sub('', boundary)
165
+ siblings = [key for key in ordered if PLURAL.search(key) and PLURAL.sub('', key) == family]
166
+ if siblings:
167
+ expanded_boundaries.add(siblings[-1])
168
+ else:
169
+ expanded_boundaries.add(boundary)
170
+
171
+ lines = ['{']
172
+ for index, key in enumerate(ordered):
173
+ comma = '' if index == len(ordered) - 1 else ','
174
+ lines.append(f' {json.dumps(key)}: {json.dumps(values[key], ensure_ascii=False)}{comma}')
175
+ if key in expanded_boundaries and index != len(ordered) - 1:
176
+ lines.append('')
177
+ lines.append('}')
178
+
179
+ asset_dir.mkdir(parents=True, exist_ok=True)
180
+ destination = asset_dir / f'{locale}.json'
181
+ destination_mode = destination.stat().st_mode & 0o777 if destination.exists() else 0o644
182
+ temporary_path = None
183
+ try:
184
+ with tempfile.NamedTemporaryFile(
185
+ mode='w',
186
+ encoding='utf-8',
187
+ dir=asset_dir,
188
+ prefix=f'.{locale}.',
189
+ suffix='.tmp',
190
+ delete=False,
191
+ ) as temporary:
192
+ temporary.write('\n'.join(lines) + '\n')
193
+ temporary_path = pathlib.Path(temporary.name)
194
+ temporary_path.chmod(destination_mode)
195
+ temporary_path.replace(destination)
196
+ finally:
197
+ if temporary_path and temporary_path.exists():
198
+ temporary_path.unlink()
199
+
200
+ print(f'{locale}.json: {len(values)} keys')
201
+
202
+
203
+ def main() -> None:
204
+ parser = argparse.ArgumentParser(description=__doc__)
205
+ parser.add_argument('--locale', required=True, help='target BCP-47 locale and output filename')
206
+ parser.add_argument('--input', required=True, type=pathlib.Path, help='flat JSON file with translated values')
207
+ parser.add_argument('--assets', type=pathlib.Path, default=DEFAULT_ASSETS, help='asset directory')
208
+ parser.add_argument('--source', default=DEFAULT_SOURCE_LOCALE, help='source locale (default: en)')
209
+ parser.add_argument(
210
+ '--allow-implicit-count',
211
+ default='',
212
+ help='comma-separated target keys intentionally omitting {count}',
213
+ )
214
+ args = parser.parse_args()
215
+ _validate_locale(args.locale)
216
+ _validate_locale(args.source)
217
+ values = _read_flat_asset(args.input)
218
+ allowances = [item.strip() for item in args.allow_implicit_count.split(',') if item.strip()]
219
+ write(
220
+ args.locale,
221
+ values,
222
+ assets=args.assets,
223
+ source_locale=args.source,
224
+ allow_implicit_count=allowances,
225
+ )
226
+
227
+
228
+ if __name__ == '__main__':
229
+ main()
@@ -1,10 +0,0 @@
1
- import { ReactElement, ComponentProps } from 'react';
2
- import { Text } from 'react-native';
3
-
4
- interface Props extends ComponentProps<typeof Text> {
5
- children: string;
6
- assetKey?: string;
7
- }
8
- declare function LText({ children, assetKey, ...props }: Props): ReactElement;
9
-
10
- export { LText as default };
@@ -1,10 +0,0 @@
1
- import { ReactElement, ComponentProps } from 'react';
2
- import { Text } from 'react-native';
3
-
4
- interface Props extends ComponentProps<typeof Text> {
5
- children: string;
6
- assetKey?: string;
7
- }
8
- declare function LText({ children, assetKey, ...props }: Props): ReactElement;
9
-
10
- export { LText as default };
@@ -1,41 +0,0 @@
1
- "use strict";
2
- var __create = Object.create;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __export = (target, all) => {
9
- for (var name in all)
10
- __defProp(target, name, { get: all[name], enumerable: true });
11
- };
12
- var __copyProps = (to, from, except, desc) => {
13
- if (from && typeof from === "object" || typeof from === "function") {
14
- for (let key of __getOwnPropNames(from))
15
- if (!__hasOwnProp.call(to, key) && key !== except)
16
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
- }
18
- return to;
19
- };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
- var LText_exports = {};
30
- __export(LText_exports, {
31
- default: () => LText_default
32
- });
33
- module.exports = __toCommonJS(LText_exports);
34
- var import_jsx_runtime = require("react/jsx-runtime");
35
- var import_react_native = require("react-native");
36
- var import_useLocon = __toESM(require("../hooks/useLocon"));
37
- function LText({ children, assetKey, ...props }) {
38
- const { l } = (0, import_useLocon.default)();
39
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { ...props, children: l(assetKey || children) });
40
- }
41
- var LText_default = LText;
@@ -1,11 +0,0 @@
1
- import { jsx } from "react/jsx-runtime";
2
- import { Text } from "react-native";
3
- import useLocon from "../hooks/useLocon";
4
- function LText({ children, assetKey, ...props }) {
5
- const { l } = useLocon();
6
- return /* @__PURE__ */ jsx(Text, { ...props, children: l(assetKey || children) });
7
- }
8
- var LText_default = LText;
9
- export {
10
- LText_default as default
11
- };
@@ -1,6 +0,0 @@
1
- import LoconContext from '../types/LoconContext.mjs';
2
- import '../types/Assets.mjs';
3
-
4
- declare function useLocon(): LoconContext;
5
-
6
- export { useLocon as default };
@@ -1,6 +0,0 @@
1
- import LoconContext from '../types/LoconContext.js';
2
- import '../types/Assets.js';
3
-
4
- declare function useLocon(): LoconContext;
5
-
6
- export { useLocon as default };
@@ -1,30 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
- var useLocon_exports = {};
20
- __export(useLocon_exports, {
21
- default: () => useLocon_default
22
- });
23
- module.exports = __toCommonJS(useLocon_exports);
24
- var import_react = require("react");
25
- var import__ = require("../index");
26
- function useLocon() {
27
- const locon = (0, import_react.useContext)(import__.LoconContext);
28
- return locon;
29
- }
30
- var useLocon_default = useLocon;
@@ -1,10 +0,0 @@
1
- import { useContext } from "react";
2
- import { LoconContext } from "../index";
3
- function useLocon() {
4
- const locon = useContext(LoconContext);
5
- return locon;
6
- }
7
- var useLocon_default = useLocon;
8
- export {
9
- useLocon_default as default
10
- };
package/dist/index.d.mts DELETED
@@ -1,19 +0,0 @@
1
- import * as react from 'react';
2
- import { ReactElement, PropsWithChildren } from 'react';
3
- import LoconContext$1 from './types/LoconContext.mjs';
4
- export { default as LText } from './components/LText.mjs';
5
- export { default as useLocon } from './hooks/useLocon.mjs';
6
- import './types/Assets.mjs';
7
- import 'react-native';
8
-
9
- declare const LoconContext: react.Context<LoconContext$1>;
10
- interface Props extends PropsWithChildren {
11
- assets?: Record<string, Record<string, string>>;
12
- currentLocale?: string;
13
- defaultLocale?: string;
14
- projectLocale?: string;
15
- autodetect?: boolean;
16
- }
17
- declare function Locon({ children, assets, currentLocale: currentLocaleProps, defaultLocale, projectLocale, autodetect, }: Props): ReactElement;
18
-
19
- export { LoconContext, Locon as default };
package/dist/index.mjs DELETED
@@ -1,104 +0,0 @@
1
- import { jsx } from "react/jsx-runtime";
2
- import { createContext, useCallback, useEffect, useMemo, useState } from "react";
3
- import getSystemLanguage from "./utils/getSystemLanguage";
4
- const LoconContext = createContext({
5
- assets: {},
6
- currentLocale: "en",
7
- defaultLocale: "en",
8
- autodetect: true,
9
- setLocale: () => {
10
- },
11
- // Default implementation
12
- l: (assetKey) => assetKey
13
- // Default implementation
14
- });
15
- function Locon({
16
- children,
17
- assets = {},
18
- currentLocale: currentLocaleProps,
19
- defaultLocale = "en",
20
- projectLocale = "en",
21
- autodetect = true
22
- }) {
23
- const resolvedProjectLocale = projectLocale || defaultLocale;
24
- const [currentLocale, setCurrentLocale] = useState(() => {
25
- if (currentLocaleProps) {
26
- return currentLocaleProps;
27
- }
28
- if (autodetect) {
29
- const systemLocale = getSystemLanguage(Object.keys(assets));
30
- if (systemLocale && assets[systemLocale]) {
31
- return systemLocale;
32
- }
33
- }
34
- return defaultLocale;
35
- });
36
- const getAssets = useCallback(() => {
37
- const currentAssets = assets[currentLocale] ?? assets[defaultLocale];
38
- return currentAssets ?? {};
39
- }, [assets, currentLocale, defaultLocale]);
40
- const getAsset = useCallback(
41
- (assetKey) => {
42
- const assetsObj = getAssets();
43
- return assetsObj[assetKey] ?? "";
44
- },
45
- [getAssets]
46
- );
47
- const setCurrentLocaleHandler = useCallback(
48
- (locale) => {
49
- if (assets[locale]) {
50
- setCurrentLocale(locale);
51
- } else {
52
- console.warn(`[locon] Locale "${locale}" not found in assets.`);
53
- }
54
- },
55
- [assets]
56
- );
57
- const getAssetByValue = useCallback(
58
- (assetValue, locale = resolvedProjectLocale) => {
59
- const currentAssets = assets[locale];
60
- if (!currentAssets) {
61
- return void 0;
62
- }
63
- const assetKey = Object.keys(currentAssets).find((key) => currentAssets[key] === assetValue);
64
- return assetKey ? getAsset(assetKey) : void 0;
65
- },
66
- [assets, getAsset, resolvedProjectLocale]
67
- );
68
- const l = useCallback(
69
- (assetKey) => {
70
- if (!assetKey) {
71
- return "";
72
- }
73
- const asset = getAsset(assetKey) || getAssetByValue(assetKey) || getAssetByValue(assetKey, defaultLocale);
74
- return asset ?? assetKey;
75
- },
76
- [getAsset, getAssetByValue, defaultLocale]
77
- );
78
- useEffect(() => {
79
- if (currentLocaleProps) {
80
- setCurrentLocale((locale) => locale === currentLocaleProps ? locale : currentLocaleProps);
81
- }
82
- }, [currentLocaleProps]);
83
- const value = useMemo(
84
- () => ({
85
- assets,
86
- currentLocale,
87
- defaultLocale,
88
- autodetect,
89
- l,
90
- setLocale: setCurrentLocaleHandler
91
- }),
92
- [assets, currentLocale, defaultLocale, autodetect, l, setCurrentLocaleHandler]
93
- );
94
- return /* @__PURE__ */ jsx(LoconContext, { value, children });
95
- }
96
- var locon_default = Locon;
97
- import { default as default2 } from "./components/LText";
98
- import { default as default3 } from "./hooks/useLocon";
99
- export {
100
- default2 as LText,
101
- LoconContext,
102
- locon_default as default,
103
- default3 as useLocon
104
- };
@@ -1,3 +0,0 @@
1
- type Assets = Record<string, Record<string, string>>;
2
-
3
- export type { Assets as default };
@@ -1,3 +0,0 @@
1
- type Assets = Record<string, Record<string, string>>;
2
-
3
- export type { Assets as default };
@@ -1,16 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __copyProps = (to, from, except, desc) => {
7
- if (from && typeof from === "object" || typeof from === "function") {
8
- for (let key of __getOwnPropNames(from))
9
- if (!__hasOwnProp.call(to, key) && key !== except)
10
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
- }
12
- return to;
13
- };
14
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
- var Assets_exports = {};
16
- module.exports = __toCommonJS(Assets_exports);
File without changes
@@ -1,12 +0,0 @@
1
- import Assets from './Assets.mjs';
2
-
3
- interface LoconContext {
4
- l: (assetKey: string) => string;
5
- assets: Assets;
6
- currentLocale: string;
7
- defaultLocale: string;
8
- autodetect: boolean;
9
- setLocale: (locale: string) => void;
10
- }
11
-
12
- export type { LoconContext as default };
@@ -1,12 +0,0 @@
1
- import Assets from './Assets.js';
2
-
3
- interface LoconContext {
4
- l: (assetKey: string) => string;
5
- assets: Assets;
6
- currentLocale: string;
7
- defaultLocale: string;
8
- autodetect: boolean;
9
- setLocale: (locale: string) => void;
10
- }
11
-
12
- export type { LoconContext as default };
@@ -1,16 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __copyProps = (to, from, except, desc) => {
7
- if (from && typeof from === "object" || typeof from === "function") {
8
- for (let key of __getOwnPropNames(from))
9
- if (!__hasOwnProp.call(to, key) && key !== except)
10
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
- }
12
- return to;
13
- };
14
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
- var LoconContext_exports = {};
16
- module.exports = __toCommonJS(LoconContext_exports);
File without changes
@@ -1,7 +0,0 @@
1
- /**
2
- * Detects the system language and returns the best matching available locale.
3
- * Uses react-native-localize if available, otherwise falls back to native APIs.
4
- */
5
- declare function getSystemLanguage(availableLocales?: string[]): string | null;
6
-
7
- export { getSystemLanguage as default };
@@ -1,7 +0,0 @@
1
- /**
2
- * Detects the system language and returns the best matching available locale.
3
- * Uses react-native-localize if available, otherwise falls back to native APIs.
4
- */
5
- declare function getSystemLanguage(availableLocales?: string[]): string | null;
6
-
7
- export { getSystemLanguage as default };