@celema/verba 0.3.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.
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright (c) 2026-present Celema developers and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Celema Verba — JavaScript runtime
2
+
3
+ The browser half of [Celema Verba](../README.md): a dependency-free ESM
4
+ runtime for catalogs exported by the PHP `Translator`. Verba's
5
+ `JavascriptScanner` extracts the same eight calls from `.js`, `.ts`, `.jsx`,
6
+ `.tsx`, `.svelte`, and `.vue` sources, so marked strings flow through
7
+ `i18n:sync` exactly like PHP ones.
8
+
9
+ ## Usage
10
+
11
+ Inline the payload from `Translator::exportMany()` server-side and boot once
12
+ per page:
13
+
14
+ ```html
15
+ <script id="verba-catalog" type="application/json">
16
+ {
17
+ "locale": "de",
18
+ "domains": [
19
+ {
20
+ "domain": "app",
21
+ "plural": "de",
22
+ "messages": { "Save": "Speichern" },
23
+ "contexts": { "menu": { "Open": "Öffnen" } }
24
+ }
25
+ ]
26
+ }
27
+ </script>
28
+ ```
29
+
30
+ ```js
31
+ import { __, __n, __p, loadAndActivate } from '@celema/verba';
32
+
33
+ loadAndActivate(); // reads #verba-catalog, returns null during SSR
34
+
35
+ __('Save'); // 'Speichern'
36
+ __p('menu', 'Open'); // 'Öffnen'
37
+ __('Hello :name', { name: 'Ada' }); // named args only — no sprintf in JS
38
+ __n(':count file', ':count files', 3); // ':count' is bound automatically
39
+ ```
40
+
41
+ The semantics mirror PHP: bare `__`/`__n`/`__p`/`__np` walk the domain cascade
42
+ in payload order, while `__d`/`__dn`/`__dp`/`__dnp` pin one domain. Context is
43
+ an exact lookup axis, so a contextual miss never uses an uncontextual entry or a
44
+ different context. Any miss falls back to the message id. With no translator
45
+ active the functions return the interpolated id, so calls are safe during SSR
46
+ and in tests. Use `load()` and `activate()` separately when the translator needs
47
+ inspection or other setup before activation.
48
+
49
+ During SSR, `loadAndActivate()` returns `null` because no DOM is available, so
50
+ the global helpers use the source-id fallback. Do not call the module-global
51
+ `activate()` from a request handler because concurrent requests could share
52
+ translators. The helper API does not currently support request-local translated
53
+ SSR.
54
+
55
+ Only positional `sprintf` interpolation is PHP-only; strings shared with the
56
+ frontend should use named `:placeholder` arguments.
57
+
58
+ ## Scripts
59
+
60
+ - `pnpm test` runs the Vitest suite.
61
+ - `pnpm check` type-checks and verifies formatting.
62
+ - `pnpm build` emits ESM and type declarations to `dist/`.
63
+
64
+ ## License
65
+
66
+ This project is licensed under the [MIT license](LICENSE.md).
@@ -0,0 +1,4 @@
1
+ export { type Args, interpolate } from './interpolate.js';
2
+ export { type PluralRule, pluralRule } from './plurals.js';
3
+ export { type Contexts, type DomainPayload, type Messages, type Payload, Translator, } from './translator.js';
4
+ export { __, __d, __dn, __dnp, __dp, __n, __np, __p, activate, deactivate, load, loadAndActivate, translator, } from './verba.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { interpolate } from './interpolate.js';
2
+ export { pluralRule } from './plurals.js';
3
+ export { Translator, } from './translator.js';
4
+ export { __, __d, __dn, __dnp, __dp, __n, __np, __p, activate, deactivate, load, loadAndActivate, translator, } from './verba.js';
@@ -0,0 +1,8 @@
1
+ export type Args = Record<string, string | number>;
2
+ /**
3
+ * Fills named `:key` placeholders in a message template. Longer keys are
4
+ * replaced first and replacements are never re-scanned, mirroring PHP's
5
+ * strtr. An empty args object leaves the template untouched. Positional
6
+ * sprintf arguments are PHP-only; a stray `%s` passes through unchanged.
7
+ */
8
+ export declare function interpolate(template: string, args?: Args): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Fills named `:key` placeholders in a message template. Longer keys are
3
+ * replaced first and replacements are never re-scanned, mirroring PHP's
4
+ * strtr. An empty args object leaves the template untouched. Positional
5
+ * sprintf arguments are PHP-only; a stray `%s` passes through unchanged.
6
+ */
7
+ export function interpolate(template, args = {}) {
8
+ const keys = Object.keys(args);
9
+ if (keys.length === 0) {
10
+ return template;
11
+ }
12
+ const pattern = keys
13
+ .sort((a, b) => b.length - a.length)
14
+ .map((key) => escape(':' + key))
15
+ .join('|');
16
+ return template.replace(new RegExp(pattern, 'g'), (token) => String(args[token.slice(1)]));
17
+ }
18
+ function escape(literal) {
19
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
@@ -0,0 +1,8 @@
1
+ export type PluralRule = (n: number) => number;
2
+ /**
3
+ * Returns the rule mapping a count to its zero-based plural form index —
4
+ * the same classic gettext formulas as the PHP runtime. Only the exceptions
5
+ * are listed; every other language falls through to the two-form `n !== 1`
6
+ * default. Region subtags are honored where they change the rule.
7
+ */
8
+ export declare function pluralRule(key: string): PluralRule;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Returns the rule mapping a count to its zero-based plural form index —
3
+ * the same classic gettext formulas as the PHP runtime. Only the exceptions
4
+ * are listed; every other language falls through to the two-form `n !== 1`
5
+ * default. Region subtags are honored where they change the rule.
6
+ */
7
+ export function pluralRule(key) {
8
+ const norm = key.toLowerCase().replaceAll('-', '_');
9
+ const lang = norm.split('_')[0];
10
+ if (norm === 'pt_br' || lang === 'fr') {
11
+ return (n) => (n > 1 ? 1 : 0);
12
+ }
13
+ if (['ja', 'ko', 'zh', 'vi', 'th', 'id', 'fa'].includes(lang)) {
14
+ return () => 0;
15
+ }
16
+ if (['ru', 'uk', 'be'].includes(lang)) {
17
+ return (n) => {
18
+ if (n % 10 === 1 && n % 100 !== 11) {
19
+ return 0;
20
+ }
21
+ if (n % 10 >= 2 && n % 10 <= 4 && !(n % 100 >= 12 && n % 100 <= 14)) {
22
+ return 1;
23
+ }
24
+ return 2;
25
+ };
26
+ }
27
+ if (lang === 'pl') {
28
+ return (n) => {
29
+ if (n === 1) {
30
+ return 0;
31
+ }
32
+ if (n % 10 >= 2 && n % 10 <= 4 && !(n % 100 >= 12 && n % 100 <= 14)) {
33
+ return 1;
34
+ }
35
+ return 2;
36
+ };
37
+ }
38
+ if (lang === 'cs' || lang === 'sk') {
39
+ return (n) => {
40
+ if (n === 1) {
41
+ return 0;
42
+ }
43
+ return n >= 2 && n <= 4 ? 1 : 2;
44
+ };
45
+ }
46
+ if (lang === 'ar') {
47
+ return (n) => {
48
+ if (n === 0 || n === 1 || n === 2) {
49
+ return n;
50
+ }
51
+ if (n % 100 >= 3 && n % 100 <= 10) {
52
+ return 3;
53
+ }
54
+ return n % 100 >= 11 ? 4 : 5;
55
+ };
56
+ }
57
+ return (n) => (n === 1 ? 0 : 1);
58
+ }
@@ -0,0 +1,39 @@
1
+ import { type Args } from './interpolate.js';
2
+ export type Messages = Record<string, string | string[]>;
3
+ export type Contexts = Record<string, Messages>;
4
+ export type DomainPayload = {
5
+ domain: string;
6
+ plural?: string;
7
+ messages?: Messages;
8
+ contexts?: Contexts;
9
+ };
10
+ export type Payload = {
11
+ locale?: string;
12
+ domains?: DomainPayload[];
13
+ };
14
+ /**
15
+ * Resolves messages for one locale across an ordered cascade of domains —
16
+ * the JavaScript mirror of the PHP Translator, fed by the payload that
17
+ * `Translator::exportMany()` produces. The first entry whose catalog holds
18
+ * a translation wins; a miss falls back to the message id itself. A domain
19
+ * may appear once per locale of the PHP-side fallback chain, each entry
20
+ * carrying its own plural rule, so walking entries in payload order
21
+ * resolves the same chain the PHP runtime does.
22
+ */
23
+ export declare class Translator {
24
+ readonly locale: string;
25
+ private readonly domains;
26
+ constructor(payload?: Payload);
27
+ translate(id: string, args?: Args): string;
28
+ translateContext(context: string, id: string, args?: Args): string;
29
+ translateDomain(name: string, id: string, args?: Args): string;
30
+ translateDomainContext(name: string, context: string, id: string, args?: Args): string;
31
+ translatePlural(one: string, many: string, n: number, args?: Args): string;
32
+ translateContextPlural(context: string, one: string, many: string, n: number, args?: Args): string;
33
+ translateDomainPlural(name: string, one: string, many: string, n: number, args?: Args): string;
34
+ translateDomainContextPlural(name: string, context: string, one: string, many: string, n: number, args?: Args): string;
35
+ private translateFrom;
36
+ private translateDomainFrom;
37
+ private translatePluralFrom;
38
+ private translateDomainPluralFrom;
39
+ }
@@ -0,0 +1,119 @@
1
+ import { interpolate } from './interpolate.js';
2
+ import { pluralRule } from './plurals.js';
3
+ /**
4
+ * Resolves messages for one locale across an ordered cascade of domains —
5
+ * the JavaScript mirror of the PHP Translator, fed by the payload that
6
+ * `Translator::exportMany()` produces. The first entry whose catalog holds
7
+ * a translation wins; a miss falls back to the message id itself. A domain
8
+ * may appear once per locale of the PHP-side fallback chain, each entry
9
+ * carrying its own plural rule, so walking entries in payload order
10
+ * resolves the same chain the PHP runtime does.
11
+ */
12
+ export class Translator {
13
+ locale;
14
+ domains;
15
+ constructor(payload = {}) {
16
+ this.locale = payload.locale ?? 'en';
17
+ this.domains = (payload.domains ?? []).map((entry) => ({
18
+ name: entry.domain,
19
+ messages: readMessages(entry.messages),
20
+ contexts: readContexts(entry.contexts),
21
+ rule: pluralRule(entry.plural ?? this.locale),
22
+ }));
23
+ }
24
+ translate(id, args = {}) {
25
+ return this.translateFrom(null, id, args);
26
+ }
27
+ translateContext(context, id, args = {}) {
28
+ return this.translateFrom(context, id, args);
29
+ }
30
+ translateDomain(name, id, args = {}) {
31
+ return this.translateDomainFrom(name, null, id, args);
32
+ }
33
+ translateDomainContext(name, context, id, args = {}) {
34
+ return this.translateDomainFrom(name, context, id, args);
35
+ }
36
+ translatePlural(one, many, n, args = {}) {
37
+ return this.translatePluralFrom(null, one, many, n, args);
38
+ }
39
+ translateContextPlural(context, one, many, n, args = {}) {
40
+ return this.translatePluralFrom(context, one, many, n, args);
41
+ }
42
+ translateDomainPlural(name, one, many, n, args = {}) {
43
+ return this.translateDomainPluralFrom(name, null, one, many, n, args);
44
+ }
45
+ translateDomainContextPlural(name, context, one, many, n, args = {}) {
46
+ return this.translateDomainPluralFrom(name, context, one, many, n, args);
47
+ }
48
+ translateFrom(context, id, args) {
49
+ for (const domain of this.domains) {
50
+ const entry = messageFrom(domain, context, id);
51
+ if (typeof entry === 'string') {
52
+ return interpolate(entry, args);
53
+ }
54
+ }
55
+ return interpolate(id, args);
56
+ }
57
+ translateDomainFrom(name, context, id, args) {
58
+ for (const domain of this.domains) {
59
+ if (domain.name !== name) {
60
+ continue;
61
+ }
62
+ const entry = messageFrom(domain, context, id);
63
+ if (typeof entry === 'string') {
64
+ return interpolate(entry, args);
65
+ }
66
+ }
67
+ return interpolate(id, args);
68
+ }
69
+ translatePluralFrom(context, one, many, n, args) {
70
+ for (const domain of this.domains) {
71
+ const form = pluralFrom(domain, context, one, n, args);
72
+ if (form !== null) {
73
+ return form;
74
+ }
75
+ }
76
+ return interpolate(n === 1 ? one : many, pluralArgs(args, n));
77
+ }
78
+ translateDomainPluralFrom(name, context, one, many, n, args) {
79
+ for (const domain of this.domains) {
80
+ if (domain.name !== name) {
81
+ continue;
82
+ }
83
+ const form = pluralFrom(domain, context, one, n, args);
84
+ if (form !== null) {
85
+ return form;
86
+ }
87
+ }
88
+ return interpolate(n === 1 ? one : many, pluralArgs(args, n));
89
+ }
90
+ }
91
+ /** PHP encodes an empty message map as a JSON array, so lists read as empty. */
92
+ function readMessages(messages) {
93
+ return messages === undefined || Array.isArray(messages) ? {} : messages;
94
+ }
95
+ function readContexts(contexts) {
96
+ if (contexts === undefined || Array.isArray(contexts)) {
97
+ return {};
98
+ }
99
+ return Object.fromEntries(Object.entries(contexts).map(([context, messages]) => [context, readMessages(messages)]));
100
+ }
101
+ function messageFrom(domain, context, id) {
102
+ return context === null ? domain.messages[id] : domain.contexts[context]?.[id];
103
+ }
104
+ /** An empty form list counts as untranslated, like a missing id — mirrors PHP. */
105
+ function pluralFrom(domain, context, one, n, args) {
106
+ const entry = messageFrom(domain, context, one);
107
+ if (Array.isArray(entry) && entry.length > 0) {
108
+ const form = entry[domain.rule(n)] ?? entry[entry.length - 1];
109
+ return interpolate(form, pluralArgs(args, n));
110
+ }
111
+ if (typeof entry === 'string') {
112
+ return interpolate(entry, pluralArgs(args, n));
113
+ }
114
+ return null;
115
+ }
116
+ /** Binds `:count` to the count unless the caller already set it. */
117
+ function pluralArgs(args, n) {
118
+ return 'count' in args ? args : { ...args, count: n };
119
+ }
@@ -0,0 +1,33 @@
1
+ import type { Args } from './interpolate.js';
2
+ import { Translator } from './translator.js';
3
+ /**
4
+ * Wires the global functions to a translator. With no translator active,
5
+ * lookups return the message id (with interpolation), which keeps
6
+ * translation calls safe during SSR, in tests, and before boot.
7
+ */
8
+ export declare function activate(translator: Translator): void;
9
+ export declare function deactivate(): void;
10
+ export declare function translator(): Translator | null;
11
+ /**
12
+ * Reads the JSON payload inlined in the given script element and wraps it in
13
+ * a Translator. Returns null without a DOM (SSR), when the element is
14
+ * missing or empty, or when its content is not valid JSON.
15
+ */
16
+ export declare function load(elementId?: string): Translator | null;
17
+ export declare function loadAndActivate(elementId?: string): Translator | null;
18
+ /** Translate a message through the active domain cascade. */
19
+ export declare function __(id: string, args?: Args): string;
20
+ /** Translate a contextual message through the active domain cascade. */
21
+ export declare function __p(context: string, id: string, args?: Args): string;
22
+ /** Translate a pluralized message, choosing the form for n. */
23
+ export declare function __n(one: string, many: string, n: number, args?: Args): string;
24
+ /** Translate a contextual pluralized message, choosing the form for n. */
25
+ export declare function __np(context: string, one: string, many: string, n: number, args?: Args): string;
26
+ /** Translate a message from a specific domain. */
27
+ export declare function __d(domain: string, id: string, args?: Args): string;
28
+ /** Translate a contextual message from a specific domain. */
29
+ export declare function __dp(domain: string, context: string, id: string, args?: Args): string;
30
+ /** Translate a pluralized message from a specific domain. */
31
+ export declare function __dn(domain: string, one: string, many: string, n: number, args?: Args): string;
32
+ /** Translate a contextual pluralized message from a specific domain. */
33
+ export declare function __dnp(domain: string, context: string, one: string, many: string, n: number, args?: Args): string;
package/dist/verba.js ADDED
@@ -0,0 +1,76 @@
1
+ import { Translator } from './translator.js';
2
+ let active = null;
3
+ let fallback = null;
4
+ /**
5
+ * Wires the global functions to a translator. With no translator active,
6
+ * lookups return the message id (with interpolation), which keeps
7
+ * translation calls safe during SSR, in tests, and before boot.
8
+ */
9
+ export function activate(translator) {
10
+ active = translator;
11
+ }
12
+ export function deactivate() {
13
+ active = null;
14
+ }
15
+ export function translator() {
16
+ return active;
17
+ }
18
+ /**
19
+ * Reads the JSON payload inlined in the given script element and wraps it in
20
+ * a Translator. Returns null without a DOM (SSR), when the element is
21
+ * missing or empty, or when its content is not valid JSON.
22
+ */
23
+ export function load(elementId = 'verba-catalog') {
24
+ const el = typeof document === 'undefined' ? null : document.getElementById(elementId);
25
+ if (!el?.textContent) {
26
+ return null;
27
+ }
28
+ try {
29
+ return new Translator(JSON.parse(el.textContent));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function loadAndActivate(elementId) {
36
+ const loaded = load(elementId);
37
+ if (loaded) {
38
+ activate(loaded);
39
+ }
40
+ return loaded;
41
+ }
42
+ /** Translate a message through the active domain cascade. */
43
+ export function __(id, args = {}) {
44
+ return current().translate(id, args);
45
+ }
46
+ /** Translate a contextual message through the active domain cascade. */
47
+ export function __p(context, id, args = {}) {
48
+ return current().translateContext(context, id, args);
49
+ }
50
+ /** Translate a pluralized message, choosing the form for n. */
51
+ export function __n(one, many, n, args = {}) {
52
+ return current().translatePlural(one, many, n, args);
53
+ }
54
+ /** Translate a contextual pluralized message, choosing the form for n. */
55
+ export function __np(context, one, many, n, args = {}) {
56
+ return current().translateContextPlural(context, one, many, n, args);
57
+ }
58
+ /** Translate a message from a specific domain. */
59
+ export function __d(domain, id, args = {}) {
60
+ return current().translateDomain(domain, id, args);
61
+ }
62
+ /** Translate a contextual message from a specific domain. */
63
+ export function __dp(domain, context, id, args = {}) {
64
+ return current().translateDomainContext(domain, context, id, args);
65
+ }
66
+ /** Translate a pluralized message from a specific domain. */
67
+ export function __dn(domain, one, many, n, args = {}) {
68
+ return current().translateDomainPlural(domain, one, many, n, args);
69
+ }
70
+ /** Translate a contextual pluralized message from a specific domain. */
71
+ export function __dnp(domain, context, one, many, n, args = {}) {
72
+ return current().translateDomainContextPlural(domain, context, one, many, n, args);
73
+ }
74
+ function current() {
75
+ return active ?? (fallback ??= new Translator());
76
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@celema/verba",
3
+ "version": "0.3.0",
4
+ "description": "JavaScript runtime for Celema Verba translation catalogs",
5
+ "license": "MIT",
6
+ "homepage": "https://celema.dev/verba",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://codeberg.org/celema/verba.git",
10
+ "directory": "js"
11
+ },
12
+ "keywords": [
13
+ "celema",
14
+ "internationalization",
15
+ "i18n",
16
+ "gettext"
17
+ ],
18
+ "type": "module",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "sideEffects": false,
32
+ "devDependencies": {
33
+ "prettier": "^3.9.4",
34
+ "typescript": "^7.0.2",
35
+ "vitest": "^4.1.10"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "check": "tsc && prettier --check .",
40
+ "format": "prettier --write .",
41
+ "test": "vitest run"
42
+ }
43
+ }