@xoong/i18n 0.0.0-stage → 0.0.35

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @xoong/i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Internationalisation for xoong: JSON catalogs with an ICU subset, `xo-t` in markup, a locale signal and Intl formats. Built-in strings come with an English catalog and Vietnamese.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/i18n
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```js
14
+ import { configureI18n, setLocale } from '@xoong/i18n';
15
+
16
+ configureI18n({ locale: 'en', load: (code) => fetch(`/i18n/${code}.json`).then((r) => r.json()) });
17
+ await setLocale('vi');
18
+ ```
19
+ ```html
20
+ <h1 xo-t="home.title">Welcome</h1>
21
+ ```
22
+
23
+ ## More
24
+
25
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
26
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/i18n
27
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
28
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
29
+
30
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@xoong/i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.35",
4
+ "description": "xoong internationalization: ICU-subset JSON catalogs, xo-t in markup, a locale signal, Intl formats. Zero dependencies besides @xoong/core and @xoong/data.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "default": "./src/index.js"
11
+ },
12
+ "./message": "./src/message.js",
13
+ "./vi": "./src/catalogs/vi.json",
14
+ "./builtin": "./src/builtin.js",
15
+ "./spec": "./src/xo-t.js"
16
+ },
17
+ "sideEffects": true,
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "keywords": [
22
+ "xoong",
23
+ "i18n",
24
+ "l10n",
25
+ "icu",
26
+ "messageformat"
27
+ ],
28
+ "license": "MIT",
29
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
33
+ "directory": "packages/i18n"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
37
+ },
38
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
39
+ "dependencies": {
40
+ "@xoong/core": "0.0.35",
41
+ "@xoong/data": "0.0.35"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.12"
48
+ }
49
+ }
package/src/builtin.js ADDED
@@ -0,0 +1,101 @@
1
+ // The English catalog of xoong's own strings: the starting point for translators. A test keeps it identical to the defaults written in the source
2
+ // (regenerate with: xoong i18n extract packages/core/src packages/forms/src packages/ui/src packages/controls/src packages/richtext/src).
3
+ export const builtin = {
4
+ "xoong.carousel.choose": "Choose a slide",
5
+ "xoong.carousel.goto": "Go to slide {n}",
6
+ "xoong.carousel.next": "Next slide",
7
+ "xoong.carousel.pause": "Pause automatic slide show",
8
+ "xoong.carousel.play": "Start automatic slide show",
9
+ "xoong.carousel.previous": "Previous slide",
10
+ "xoong.carousel.slide": "{n} of {count}",
11
+ "xoong.carousel.slides": "Slides",
12
+ "xoong.confirm.cancel": "Cancel",
13
+ "xoong.confirm.ok": "Confirm",
14
+ "xoong.controls.apply": "Apply",
15
+ "xoong.controls.chooseDate": "Choose date",
16
+ "xoong.controls.clear": "Clear",
17
+ "xoong.controls.copied": "Copied",
18
+ "xoong.controls.copy": "Copy",
19
+ "xoong.controls.create": "Add \"{value}\"",
20
+ "xoong.controls.date": "Date",
21
+ "xoong.controls.dateRange": "Choose a date within the allowed range.",
22
+ "xoong.controls.dateRangeHint": "Start - end",
23
+ "xoong.controls.days": "{n} days",
24
+ "xoong.controls.decrease": "Decrease",
25
+ "xoong.controls.hidePassword": "Hide password",
26
+ "xoong.controls.hour": "Hour",
27
+ "xoong.controls.increase": "Increase",
28
+ "xoong.controls.minute": "Minute",
29
+ "xoong.controls.nextMonth": "Next month",
30
+ "xoong.controls.noResults": "No results",
31
+ "xoong.controls.password": "Password",
32
+ "xoong.controls.pickEnd": "pick the last day",
33
+ "xoong.controls.pickStart": "Pick the first day",
34
+ "xoong.controls.prevMonth": "Previous month",
35
+ "xoong.controls.remove": "Remove {label}",
36
+ "xoong.controls.showPassword": "Show password",
37
+ "xoong.controls.timeRange": "Choose a time within the allowed range.",
38
+ "xoong.controls.value": "Value",
39
+ "xoong.form.add": "Add",
40
+ "xoong.form.moveDown": "Move down",
41
+ "xoong.form.moveUp": "Move up",
42
+ "xoong.form.remove": "Remove",
43
+ "xoong.form.save": "Save",
44
+ "xoong.guard.leave": "You have unsaved changes. Leave this page?",
45
+ "xoong.richtext.apply": "Apply",
46
+ "xoong.richtext.bold": "Bold",
47
+ "xoong.richtext.done": "Done",
48
+ "xoong.richtext.editor": "Rich text",
49
+ "xoong.richtext.h2": "Heading",
50
+ "xoong.richtext.heading2": "Heading 2",
51
+ "xoong.richtext.heading3": "Heading 3",
52
+ "xoong.richtext.image": "Image",
53
+ "xoong.richtext.italic": "Italic",
54
+ "xoong.richtext.link": "Link",
55
+ "xoong.richtext.linkInvalid": "Use an address that starts with http://, https://, mailto: or tel:.",
56
+ "xoong.richtext.linkUrl": "Link address",
57
+ "xoong.richtext.mixed": "Mixed",
58
+ "xoong.richtext.more": "More",
59
+ "xoong.richtext.ol": "Numbered list",
60
+ "xoong.richtext.paragraph": "Paragraph",
61
+ "xoong.richtext.quote": "Quote",
62
+ "xoong.richtext.redo": "Redo",
63
+ "xoong.richtext.removeLink": "Remove link",
64
+ "xoong.richtext.style": "Text style",
65
+ "xoong.richtext.tooLong": "Use at most {max} characters.",
66
+ "xoong.richtext.toolbar": "Formatting",
67
+ "xoong.richtext.ul": "Bulleted list",
68
+ "xoong.richtext.underline": "Underline",
69
+ "xoong.richtext.undo": "Undo",
70
+ "xoong.table.actions": "Actions",
71
+ "xoong.table.next": "Next",
72
+ "xoong.table.pagination": "Pagination",
73
+ "xoong.table.previous": "Previous",
74
+ "xoong.table.range": "{$from}–{$to} of {total}",
75
+ "xoong.table.rowsPerPage": "Rows per page",
76
+ "xoong.table.selectAll": "Select all rows",
77
+ "xoong.table.selectRow": "Select row",
78
+ "xoong.table.selected": "{$selectedCount} selected",
79
+ "xoong.validation.enum": "Choose one of the listed values.",
80
+ "xoong.validation.exclusiveMaximum": "Must be less than {max}.",
81
+ "xoong.validation.exclusiveMinimum": "Must be greater than {min}.",
82
+ "xoong.validation.format.date": "Enter a valid date.",
83
+ "xoong.validation.format.dateTime": "Enter a valid date and time.",
84
+ "xoong.validation.format.email": "Enter a valid email address.",
85
+ "xoong.validation.format.uri": "Enter a valid URL.",
86
+ "xoong.validation.format.uuid": "Enter a valid ID.",
87
+ "xoong.validation.maxItems": "Add at most {max}.",
88
+ "xoong.validation.maxLength": "Use at most {max, plural, one {# character} other {# characters}}.",
89
+ "xoong.validation.maximum": "Must be at most {max}.",
90
+ "xoong.validation.minItems": "Add at least {min}.",
91
+ "xoong.validation.minLength": "Use at least {min, plural, one {# character} other {# characters}}.",
92
+ "xoong.validation.minimum": "Must be at least {min}.",
93
+ "xoong.validation.multipleOf": "Must be a multiple of {step}.",
94
+ "xoong.validation.pattern": "This does not match the expected format.",
95
+ "xoong.validation.required": "Please fill in this field.",
96
+ "xoong.validation.type.integer": "Enter a whole number.",
97
+ "xoong.validation.type.number": "Enter a number.",
98
+ "xoong.validation.type.other": "This value is not valid.",
99
+ "xoong.validation.uniqueItems": "Items must be different from each other.",
100
+ "xoong.validation.variant": "Choose a type."
101
+ };
@@ -0,0 +1,99 @@
1
+ {
2
+ "xoong.carousel.choose": "Chọn một slide",
3
+ "xoong.carousel.goto": "Đến slide {n}",
4
+ "xoong.carousel.next": "Slide tiếp theo",
5
+ "xoong.carousel.pause": "Tạm dừng tự động chuyển slide",
6
+ "xoong.carousel.play": "Bật tự động chuyển slide",
7
+ "xoong.carousel.previous": "Slide trước",
8
+ "xoong.carousel.slide": "{n} trên {count}",
9
+ "xoong.carousel.slides": "Các slide",
10
+ "xoong.confirm.cancel": "Hủy",
11
+ "xoong.confirm.ok": "Xác nhận",
12
+ "xoong.controls.apply": "Áp dụng",
13
+ "xoong.controls.chooseDate": "Chọn ngày",
14
+ "xoong.controls.clear": "Xóa",
15
+ "xoong.controls.copied": "Đã sao chép",
16
+ "xoong.controls.copy": "Sao chép",
17
+ "xoong.controls.create": "Thêm \"{value}\"",
18
+ "xoong.controls.date": "Ngày",
19
+ "xoong.controls.dateRange": "Hãy chọn ngày trong khoảng cho phép.",
20
+ "xoong.controls.dateRangeHint": "Bắt đầu - kết thúc",
21
+ "xoong.controls.days": "{n} ngày",
22
+ "xoong.controls.decrease": "Giảm",
23
+ "xoong.controls.hidePassword": "Ẩn mật khẩu",
24
+ "xoong.controls.hour": "Giờ",
25
+ "xoong.controls.increase": "Tăng",
26
+ "xoong.controls.minute": "Phút",
27
+ "xoong.controls.nextMonth": "Tháng sau",
28
+ "xoong.controls.noResults": "Không có kết quả",
29
+ "xoong.controls.password": "Mật khẩu",
30
+ "xoong.controls.pickEnd": "chọn ngày cuối",
31
+ "xoong.controls.pickStart": "Chọn ngày đầu",
32
+ "xoong.controls.prevMonth": "Tháng trước",
33
+ "xoong.controls.remove": "Bỏ {label}",
34
+ "xoong.controls.showPassword": "Hiện mật khẩu",
35
+ "xoong.controls.timeRange": "Hãy chọn giờ trong khoảng cho phép.",
36
+ "xoong.controls.value": "Giá trị",
37
+ "xoong.form.add": "Thêm",
38
+ "xoong.form.moveDown": "Chuyển xuống",
39
+ "xoong.form.moveUp": "Chuyển lên",
40
+ "xoong.form.remove": "Xóa",
41
+ "xoong.form.save": "Lưu",
42
+ "xoong.guard.leave": "Bạn có thay đổi chưa lưu. Rời khỏi trang này?",
43
+ "xoong.richtext.apply": "Áp dụng",
44
+ "xoong.richtext.bold": "In đậm",
45
+ "xoong.richtext.done": "Xong",
46
+ "xoong.richtext.editor": "Soạn thảo văn bản",
47
+ "xoong.richtext.h2": "Tiêu đề",
48
+ "xoong.richtext.heading2": "Tiêu đề 2",
49
+ "xoong.richtext.heading3": "Tiêu đề 3",
50
+ "xoong.richtext.image": "Hình ảnh",
51
+ "xoong.richtext.italic": "In nghiêng",
52
+ "xoong.richtext.link": "Liên kết",
53
+ "xoong.richtext.linkInvalid": "Dùng địa chỉ bắt đầu bằng http://, https://, mailto: hoặc tel:.",
54
+ "xoong.richtext.linkUrl": "Địa chỉ liên kết",
55
+ "xoong.richtext.mixed": "Hỗn hợp",
56
+ "xoong.richtext.more": "Thêm",
57
+ "xoong.richtext.ol": "Danh sách đánh số",
58
+ "xoong.richtext.paragraph": "Đoạn văn",
59
+ "xoong.richtext.quote": "Trích dẫn",
60
+ "xoong.richtext.redo": "Làm lại",
61
+ "xoong.richtext.removeLink": "Bỏ liên kết",
62
+ "xoong.richtext.style": "Kiểu chữ",
63
+ "xoong.richtext.tooLong": "Tối đa {max} ký tự.",
64
+ "xoong.richtext.toolbar": "Định dạng",
65
+ "xoong.richtext.ul": "Danh sách gạch đầu dòng",
66
+ "xoong.richtext.underline": "Gạch chân",
67
+ "xoong.richtext.undo": "Hoàn tác",
68
+ "xoong.table.actions": "Thao tác",
69
+ "xoong.table.next": "Sau",
70
+ "xoong.table.pagination": "Phân trang",
71
+ "xoong.table.previous": "Trước",
72
+ "xoong.table.range": "{$from}–{$to} trong tổng số {total}",
73
+ "xoong.table.rowsPerPage": "Số dòng mỗi trang",
74
+ "xoong.table.selectAll": "Chọn tất cả các dòng",
75
+ "xoong.table.selectRow": "Chọn dòng",
76
+ "xoong.table.selected": "Đã chọn {$selectedCount}",
77
+ "xoong.validation.enum": "Hãy chọn một trong các giá trị có sẵn.",
78
+ "xoong.validation.exclusiveMaximum": "Phải nhỏ hơn {max}.",
79
+ "xoong.validation.exclusiveMinimum": "Phải lớn hơn {min}.",
80
+ "xoong.validation.format.date": "Nhập ngày hợp lệ.",
81
+ "xoong.validation.format.dateTime": "Nhập ngày và giờ hợp lệ.",
82
+ "xoong.validation.format.email": "Nhập địa chỉ email hợp lệ.",
83
+ "xoong.validation.format.uri": "Nhập URL hợp lệ.",
84
+ "xoong.validation.format.uuid": "Nhập ID hợp lệ.",
85
+ "xoong.validation.maxItems": "Thêm tối đa {max} mục.",
86
+ "xoong.validation.maxLength": "Dùng tối đa {max} ký tự.",
87
+ "xoong.validation.maximum": "Tối đa là {max}.",
88
+ "xoong.validation.minItems": "Thêm ít nhất {min} mục.",
89
+ "xoong.validation.minLength": "Dùng ít nhất {min} ký tự.",
90
+ "xoong.validation.minimum": "Tối thiểu là {min}.",
91
+ "xoong.validation.multipleOf": "Phải là bội số của {step}.",
92
+ "xoong.validation.pattern": "Giá trị không đúng định dạng yêu cầu.",
93
+ "xoong.validation.required": "Vui lòng điền thông tin này.",
94
+ "xoong.validation.type.integer": "Nhập một số nguyên.",
95
+ "xoong.validation.type.number": "Nhập một số.",
96
+ "xoong.validation.type.other": "Giá trị này không hợp lệ.",
97
+ "xoong.validation.uniqueItems": "Các mục không được trùng nhau.",
98
+ "xoong.validation.variant": "Hãy chọn một loại."
99
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,139 @@
1
+ // @ts-check
2
+ /**
3
+ * Language state and lookup (ADR-0010).
4
+ *
5
+ * configureI18n({ locale: 'en', fallback: 'en', messages: { vi: {...} }, load: (code) => import(`/i18n/${code}.json`) });
6
+ * await setLocale('vi'); // loads the catalog if needed, updates <html lang dir>, re-translates the page
7
+ * t('articles.count', { count: 3 }) // "3 articles" (message from the catalog, in the active language)
8
+ * locale.value // a signal: read it inside an effect or a template
9
+ */
10
+ import { signal, batch, effect, untracked } from '@xoong/core/signals';
11
+ import { setTextProvider } from '@xoong/core';
12
+ import { setLocaleSource } from '@xoong/data/format';
13
+ import { formatMessage } from './message.js';
14
+
15
+ /** @type {Record<string, Record<string, string>>} */
16
+ const catalogs = {};
17
+ /** @type {Record<string, Promise<void>>} */
18
+ const loading = {};
19
+ const state = { fallback: 'en', load: /** @type {((code: string) => Promise<any> | any) | null} */ (null) };
20
+
21
+ /** The active language (BCP 47 tag, for example "vi" or "pt-BR"). A signal. */
22
+ export const locale = signal('en');
23
+ /** Bumped whenever catalogs change, so text that depends on them is re-read. */
24
+ const version = signal(0);
25
+
26
+ const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi', 'dv', 'ug', 'ckb']);
27
+ /** 'ltr' or 'rtl' for a language. */
28
+ export function directionOf(code) {
29
+ try {
30
+ const l = /** @type {any} */ (new Intl.Locale(code));
31
+ const info = l.getTextInfo?.() ?? l.textInfo;
32
+ if (info?.direction) return info.direction;
33
+ } catch { /* an invalid tag: fall through */ }
34
+ return RTL.has(code.split('-')[0].toLowerCase()) ? 'rtl' : 'ltr';
35
+ }
36
+
37
+ /** The tags to try for a language, most specific first: "pt-BR", "pt", then the fallback and its base language. */
38
+ function chain(code) {
39
+ const list = [code, code.split('-')[0], state.fallback, state.fallback.split('-')[0]];
40
+ return list.filter((c, i) => c && list.indexOf(c) === i);
41
+ }
42
+
43
+ /** Forget every catalog and go back to English (for tests and for applications that reconfigure at runtime). */
44
+ export function resetI18n() {
45
+ for (const k of Object.keys(catalogs)) delete catalogs[k];
46
+ for (const k of Object.keys(loading)) delete loading[k];
47
+ state.fallback = 'en';
48
+ state.load = null;
49
+ batch(() => { locale.value = 'en'; version.value += 1; });
50
+ }
51
+
52
+ /** Add messages for a language (merged over what is there). Plain JSON: { "key": "message" }. */
53
+ export function addMessages(code, messages) {
54
+ catalogs[code] = { ...(catalogs[code] ?? {}), ...messages };
55
+ version.value += 1;
56
+ }
57
+
58
+ async function ensure(code) {
59
+ if (catalogs[code] || !state.load) return;
60
+ loading[code] ??= Promise.resolve(state.load(code)).then(
61
+ (m) => { addMessages(code, m?.default ?? m ?? {}); },
62
+ (e) => { catalogs[code] = {}; console.error(`xoong i18n: could not load the "${code}" catalog: ${e?.message ?? e}`); },
63
+ );
64
+ await loading[code];
65
+ }
66
+
67
+ /**
68
+ * @param {{ locale?: string, fallback?: string, messages?: Record<string, Record<string, string>>, load?: (code: string) => Promise<any> | any }} [options]
69
+ */
70
+ export function configureI18n(options = {}) {
71
+ batch(() => {
72
+ if (options.fallback) state.fallback = options.fallback;
73
+ if (options.load !== undefined) state.load = options.load;
74
+ for (const [code, messages] of Object.entries(options.messages ?? {})) addMessages(code, messages);
75
+ if (options.locale) { locale.value = options.locale; applyDocument(options.locale); }
76
+ });
77
+ return Promise.all(chain(locale.peek()).map(ensure)).then(() => undefined);
78
+ }
79
+
80
+ function applyDocument(code) {
81
+ const root = globalThis.document?.documentElement;
82
+ if (!root) return;
83
+ root.setAttribute('lang', code);
84
+ root.setAttribute('dir', directionOf(code));
85
+ }
86
+
87
+ /** Change the language: loads its catalog (and its base language's) first, so the page switches once, then updates `<html lang dir>`. */
88
+ export async function setLocale(code) {
89
+ await Promise.all(chain(code).map(ensure));
90
+ batch(() => { locale.value = code; });
91
+ applyDocument(code);
92
+ }
93
+
94
+ /** The message for a key in the active language, or undefined. Reading it inside an effect tracks the language and the catalogs. */
95
+ export function messageFor(key) {
96
+ version.value; // track catalogs
97
+ for (const code of chain(locale.value)) { const m = catalogs[code]?.[key]; if (m !== undefined) return m; }
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * Translate: `t('articles.count', { count: 3 })`. `fallbackText` is shown when the key is unknown in every language of the chain; without it the key is.
103
+ * @param {string} key @param {Record<string, any>} [args] @param {string} [fallbackText]
104
+ */
105
+ export function t(key, args = {}, fallbackText) {
106
+ const message = messageFor(key) ?? fallbackText ?? key;
107
+ return formatMessage(message, (name) => args[name], locale.value);
108
+ }
109
+
110
+ /** Run `fn(code)` after each language change (not for the current one); returns a function that stops it. */
111
+ export function onLocaleChange(fn) {
112
+ let first = true;
113
+ return effect(() => {
114
+ const code = locale.value;
115
+ if (first) { first = false; return; }
116
+ untracked(() => fn(code));
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Pick a supported language: the one in `?lang=`, a saved choice, then the browser's preferences, then `default`.
122
+ * @param {string[]} supported @param {{ default?: string, query?: string, storageKey?: string }} [options]
123
+ */
124
+ export function detectLocale(supported, { default: fallback = supported[0], query = 'lang', storageKey = 'xoong.locale' } = {}) {
125
+ const base = (c) => c.split('-')[0].toLowerCase();
126
+ const pick = (code) => (code ? supported.find((s) => s.toLowerCase() === code.toLowerCase()) ?? supported.find((s) => base(s) === base(code)) : undefined); // exact, then any variant of the same language
127
+ const fromQuery = pick(new URLSearchParams(globalThis.location?.search).get(query) ?? '');
128
+ if (fromQuery) return fromQuery;
129
+ try { const saved = pick(globalThis.localStorage?.getItem(storageKey) ?? ''); if (saved) return saved; } catch { /* storage may be blocked */ }
130
+ for (const code of globalThis.navigator?.languages ?? []) { const hit = pick(code); if (hit) return hit; }
131
+ return fallback;
132
+ }
133
+
134
+ // xoong's own packages ask for words through the text hook; dates and numbers follow the active language
135
+ setTextProvider((key, args) => {
136
+ const m = messageFor(key);
137
+ return m === undefined ? undefined : formatMessage(m, (name) => args?.[name], locale.value);
138
+ });
139
+ setLocaleSource(() => locale.value);
package/src/index.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Types for @xoong/i18n. The runtime is plain JavaScript; this file describes its exports.
2
+ import type { Signal } from '@xoong/core/signals';
3
+
4
+ /** The active language code ("en", "vi"); setting it with `setLocale` loads the catalog first. */
5
+ export const locale: Signal<string>;
6
+ export function directionOf(code: string): 'ltr' | 'rtl';
7
+ export function resetI18n(): void;
8
+ /** Add messages for a language, merged over what is there: `{ "key": "message" }`. */
9
+ export function addMessages(code: string, messages: Record<string, string>): void;
10
+ export function configureI18n(options?: { locale?: string; fallback?: string; messages?: Record<string, Record<string, string>>; load?: (code: string) => Promise<any> | any }): void;
11
+ export function setLocale(code: string): Promise<void>;
12
+ export function messageFor(key: string): string | undefined;
13
+ /** Translate: `t('articles.count', { count: 3 })`; `fallbackText` is shown when the key is unknown, else the key. */
14
+ export function t(key: string, args?: Record<string, any>, fallbackText?: string): string;
15
+ export function onLocaleChange(fn: (code: string) => void): () => void;
16
+ export function detectLocale(supported: string[], options?: { default?: string; query?: string; storageKey?: string }): string;
17
+
18
+ export function formatMessage(source: string, get: (name: string) => any, locale: string): string;
19
+ export function parse(source: string): unknown[];
20
+ export function argsOf(nodes: unknown[], out?: Set<string>): Set<string>;
21
+
22
+ export function apply(el: Element, get: (name: string) => any): void;
23
+ export function hasText(spec: string): boolean;
24
+ export function parseSpec(spec: string): { attr?: string; key: string }[];
25
+ export function startEnhancer(root?: Document | Element): void;
26
+
27
+ /** The English catalog of xoong's own strings (the starting point for translators). */
28
+ export const builtin: Record<string, string>;
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ // @ts-check
2
+ // @xoong/i18n: importing it turns translation on for the page (xo-t, the text hook of xoong's own packages, locale-aware formats).
3
+ export { locale, configureI18n, setLocale, addMessages, resetI18n, t, messageFor, onLocaleChange, detectLocale, directionOf } from './i18n.js';
4
+ export { formatMessage, parse, argsOf } from './message.js';
5
+ export { apply, hasText, parseSpec, startEnhancer } from './xo-t.js';
6
+ export { builtin } from './builtin.js';
7
+
8
+ import { setTranslator } from '@xoong/data/view';
9
+ import { apply, hasText, startEnhancer } from './xo-t.js';
10
+ import { addMessages } from './i18n.js';
11
+ setTranslator({ apply, hasText }); // views translate their own `xo-t` elements with their scope
12
+ if (globalThis.document) startEnhancer();
13
+ void addMessages;
package/src/message.js ADDED
@@ -0,0 +1,157 @@
1
+ // @ts-check
2
+ /**
3
+ * The message language of xoong catalogs (ADR-0010): a subset of ICU MessageFormat 1, which is also valid input for the
4
+ * Unicode MessageFormat tooling. Pure functions, no DOM.
5
+ *
6
+ * Hello {name} argument
7
+ * {total, number} {when, date} {when, time} {when, relative} formatted with Intl
8
+ * {count, plural, =0 {No items} one {# item} other {# items}} plural categories from Intl.PluralRules; # is the number
9
+ * {count, plural, offset:1 =0 {Nobody} one {{name} and # other} other {{name} and # others}}
10
+ * {gender, select, female {her} male {his} other {their}}
11
+ * {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
12
+ * Use '{' and '}' for literal braces, and '' for an apostrophe
13
+ *
14
+ * A message that cannot be parsed is reported once and shown as it was written; formatting never throws.
15
+ */
16
+ import { format as formatValue } from '@xoong/data/format';
17
+
18
+ /** @typedef {string | { arg: string, type?: string } | { arg: string, kind: 'plural' | 'select' | 'selectordinal', offset: number, options: Record<string, Node[]> } | { hash: true }} Node */
19
+
20
+ class ParseError extends Error {}
21
+
22
+ /** @param {string} source @returns {Node[]} */
23
+ export function parse(source) {
24
+ let i = 0;
25
+ const n = source.length;
26
+
27
+ /** Text and placeholders until `}` (when nested) or the end. */
28
+ function nodes(nested, inPlural) {
29
+ /** @type {Node[]} */ const out = [];
30
+ let text = '';
31
+ const flush = () => { if (text) { out.push(text); text = ''; } };
32
+ while (i < n) {
33
+ const c = source[i];
34
+ if (c === "'") { // quoting: '' is an apostrophe; '{ ... }' up to the closing quote is literal
35
+ if (source[i + 1] === "'") { text += "'"; i += 2; continue; }
36
+ if (source[i + 1] === '{' || source[i + 1] === '}' || (inPlural && source[i + 1] === '#')) {
37
+ const end = source.indexOf("'", i + 1);
38
+ if (end > 0) { text += source.slice(i + 1, end).replace(/''/g, "'"); i = end + 1; continue; }
39
+ }
40
+ text += c; i++; continue;
41
+ }
42
+ if (c === '}') { if (nested) break; throw new ParseError('unmatched }'); }
43
+ if (c === '{') { flush(); out.push(placeholder()); continue; }
44
+ if (c === '#' && inPlural) { flush(); out.push({ hash: true }); i++; continue; }
45
+ text += c; i++;
46
+ }
47
+ flush();
48
+ return out;
49
+ }
50
+
51
+ const ws = () => { while (i < n && /\s/.test(source[i])) i++; };
52
+ const word = () => { const s = i; while (i < n && /[^\s,{}]/.test(source[i])) i++; return source.slice(s, i); };
53
+
54
+ function placeholder() {
55
+ i++; // {
56
+ ws();
57
+ const arg = word();
58
+ if (!arg) throw new ParseError('empty argument');
59
+ ws();
60
+ if (source[i] === '}') { i++; return { arg }; }
61
+ if (source[i] !== ',') throw new ParseError(`expected "," or "}" after ${arg}`);
62
+ i++; ws();
63
+ const type = word();
64
+ ws();
65
+ if (['number', 'date', 'time', 'relative'].includes(type)) {
66
+ if (source[i] === ',') { i++; ws(); word(); ws(); } // a style ("short", "percent") is accepted and ignored
67
+ if (source[i] !== '}') throw new ParseError(`expected "}" after ${arg}, ${type}`);
68
+ i++;
69
+ return { arg, type };
70
+ }
71
+ if (type === 'plural' || type === 'select' || type === 'selectordinal') {
72
+ if (source[i] !== ',') throw new ParseError(`expected "," after ${type}`);
73
+ i++; ws();
74
+ let offset = 0;
75
+ if (source.startsWith('offset:', i)) { i += 7; offset = Number(word()); ws(); }
76
+ /** @type {Record<string, Node[]>} */ const options = {};
77
+ while (i < n && source[i] !== '}') {
78
+ const key = word();
79
+ if (!key) throw new ParseError(`expected a case in ${type}`);
80
+ ws();
81
+ if (source[i] !== '{') throw new ParseError(`expected "{" after case ${key}`);
82
+ i++;
83
+ options[key] = nodes(true, type !== 'select');
84
+ if (source[i] !== '}') throw new ParseError(`unclosed case ${key}`);
85
+ i++; ws();
86
+ }
87
+ if (source[i] !== '}') throw new ParseError(`unclosed ${type}`);
88
+ if (!options.other) throw new ParseError(`${type} needs an "other" case`);
89
+ i++;
90
+ return { arg, kind: type, offset, options };
91
+ }
92
+ throw new ParseError(`unknown type "${type}"`);
93
+ }
94
+
95
+ const result = nodes(false, false);
96
+ if (i < n) throw new ParseError('unexpected input');
97
+ return result;
98
+ }
99
+
100
+ /** The argument names a message uses, nested ones included (for checking that a translation keeps the same placeholders). */
101
+ export function argsOf(nodes, out = new Set()) {
102
+ for (const node of nodes) {
103
+ if (typeof node === 'string' || 'hash' in node) continue;
104
+ out.add(node.arg);
105
+ if ('options' in node) for (const list of Object.values(node.options)) argsOf(list, out);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const warned = new Set();
111
+ const report = (source, error) => {
112
+ if (warned.has(source)) return;
113
+ warned.add(source);
114
+ console.error(`xoong i18n: cannot read the message "${source}": ${error.message}. It is shown as written.`);
115
+ };
116
+
117
+ /** @type {Map<string, Node[] | null>} */
118
+ const cache = new Map();
119
+ function compiled(source) {
120
+ if (!cache.has(source)) {
121
+ try { cache.set(source, parse(source)); } catch (e) { report(source, /** @type {Error} */ (e)); cache.set(source, null); }
122
+ }
123
+ return cache.get(source);
124
+ }
125
+
126
+ /**
127
+ * Format a message.
128
+ * @param {string} source the message
129
+ * @param {(name: string) => any} get where argument values come from (an object lookup, or the scope of a view)
130
+ * @param {string} locale
131
+ */
132
+ export function formatMessage(source, get, locale) {
133
+ const tree = compiled(source);
134
+ if (!tree) return source;
135
+ try { return render(tree, get, locale, undefined); } catch { return source; }
136
+ }
137
+
138
+ function render(tree, get, locale, hash) {
139
+ let out = '';
140
+ for (const node of tree) {
141
+ if (typeof node === 'string') { out += node; continue; }
142
+ if ('hash' in node) { out += hash === undefined ? '#' : new Intl.NumberFormat(locale).format(hash); continue; }
143
+ const value = get(node.arg);
144
+ if ('kind' in node) {
145
+ if (node.kind === 'select') { out += render(node.options[String(value)] ?? node.options.other, get, locale, hash); continue; }
146
+ const n = Number(value) - node.offset;
147
+ const exact = node.options[`=${Number(value)}`];
148
+ const category = new Intl.PluralRules(locale, { type: node.kind === 'selectordinal' ? 'ordinal' : 'cardinal' }).select(n);
149
+ out += render(exact ?? node.options[category] ?? node.options.other, get, locale, n);
150
+ continue;
151
+ }
152
+ if (node.type === 'number') out += value == null || value === '' ? '' : formatValue('number', value, locale);
153
+ else if (node.type) out += formatValue(node.type, value, locale);
154
+ else out += value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
155
+ }
156
+ return out;
157
+ }
package/src/xo-t.js ADDED
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+ /**
3
+ * `xo-t` (ADR-0010): translate an element. The text written in the HTML is the default and the source `xoong i18n extract` collects.
4
+ *
5
+ * <h1 xo-t="articles.title">Articles</h1>
6
+ * <input xo-t="placeholder:search.placeholder; aria-label:search.label" placeholder="Search" aria-label="Search">
7
+ * <p xo-t="articles.summary">Showing {$from}–{$to} of {total}</p> inside an <xo-view>: placeholders come from the view's scope
8
+ *
9
+ * Elements inside an `<xo-view>` are translated by the view (it has the scope); everything else (route templates, layouts, static pages)
10
+ * by the enhancer in this file. Both call `apply`, and both re-run it when the language or the catalogs change.
11
+ */
12
+ import { effect } from '@xoong/core/signals';
13
+ import { messageFor, locale } from './i18n.js';
14
+ import { formatMessage } from './message.js';
15
+
16
+ /** "key", or "attr:key; attr2:key2" -> [{ attr, key }]. A bare key is the element's text. */
17
+ export function parseSpec(spec) {
18
+ return spec.split(';').map((p) => p.trim()).filter(Boolean).map((p) => {
19
+ const i = p.indexOf(':');
20
+ return i > 0 && /^[\w-]+$/.test(p.slice(0, i)) ? { attr: p.slice(0, i), key: p.slice(i + 1).trim() } : { attr: null, key: p };
21
+ });
22
+ }
23
+
24
+ /** Does the spec translate the element's text (a bare key), not only attributes? */
25
+ export const hasText = (spec) => parseSpec(spec).some((e) => !e.attr);
26
+
27
+ const defaults = new WeakMap();
28
+
29
+ /**
30
+ * Put the translation into the element. Reads the catalogs and the language, so call it inside an effect.
31
+ * @param {Element} el @param {(name: string) => any} get where `{placeholder}` values come from
32
+ */
33
+ export function apply(el, get) {
34
+ const entries = parseSpec(el.getAttribute('xo-t') ?? '');
35
+ let kept = defaults.get(el);
36
+ if (!kept) { kept = {}; for (const { attr } of entries) kept[attr ?? '#text'] = attr ? el.getAttribute(attr) ?? '' : el.textContent ?? ''; defaults.set(el, kept); }
37
+ for (const { attr, key } of entries) {
38
+ const source = messageFor(key) ?? kept[attr ?? '#text'] ?? key;
39
+ const out = formatMessage(source, get, locale.value);
40
+ if (attr) { if (el.getAttribute(attr) !== out) el.setAttribute(attr, out); } else if (el.textContent !== out) el.textContent = out;
41
+ }
42
+ }
43
+
44
+ // ---- the enhancer for everything outside <xo-view> -------------------------------------------------------------------------------
45
+
46
+ /** @type {WeakMap<Element, () => void>} */
47
+ const stops = new WeakMap();
48
+ const inView = (el) => !!el.closest?.('xo-view');
49
+
50
+ function track(el) {
51
+ if (stops.has(el) || inView(el)) return;
52
+ stops.set(el, effect(() => apply(el, () => undefined)));
53
+ }
54
+ function untrack(el) { stops.get(el)?.(); stops.delete(el); }
55
+
56
+ function scan(root) {
57
+ if (root.matches?.('[xo-t]')) track(root);
58
+ for (const el of root.querySelectorAll?.('[xo-t]') ?? []) track(el);
59
+ }
60
+
61
+ /** Start translating the document (done automatically when the package is imported in a browser). */
62
+ export function startEnhancer(root = globalThis.document) {
63
+ if (!root) return () => {};
64
+ const run = () => scan(root.body ?? root);
65
+ if (root.readyState === 'loading') root.addEventListener('DOMContentLoaded', run, { once: true }); else run();
66
+ const observer = typeof MutationObserver !== 'undefined' ? new MutationObserver((records) => {
67
+ for (const r of records) {
68
+ for (const n of r.removedNodes) if (n.nodeType === 1 && !n.isConnected) { /** @type {Element} */ (n).querySelectorAll?.('[xo-t]').forEach(untrack); untrack(/** @type {Element} */ (n)); }
69
+ for (const n of r.addedNodes) if (n.nodeType === 1) scan(/** @type {Element} */ (n));
70
+ }
71
+ }) : null;
72
+ observer?.observe(root.body ?? root.documentElement, { childList: true, subtree: true });
73
+ return () => observer?.disconnect();
74
+ }