langsys-js-typescript 0.6.3 → 0.6.5
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/CHANGELOG.md +30 -0
- package/README.md +26 -8
- package/dist/index.js +100 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +100 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## 0.6.5 - 2026-08-16
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Debug warning when a locale tag is not valid BCP 47.** `canonicalizeLocale` falls back to best-effort casing rather than throwing, which is the right behavior — but the return value cannot signal that it happened: a best-effort string is indistinguishable from a successfully canonicalized one. So a typo'd config locale degrades to a silent failed catalog lookup, and the app renders base language, which is exactly what a legitimately-untranslated locale looks like. Nothing downstream can tell the difference, so the warning is emitted where the information still exists. Behavior and return values are unchanged; silent in production.
|
|
6
|
+
|
|
7
|
+
Found by applying a predictive form of a pattern this release series kept hitting, contributed by the Svelte binding: **look for any fallback whose output is drawn from the same value space as its success output.** That makes the class searchable by inspection rather than discoverable by tripping over it — null-as-zero, empty-string-as-absent, best-effort-locale-as-canonical.
|
|
8
|
+
|
|
9
|
+
## 0.6.4 - 2026-08-16
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A missing ICU argument no longer dumps the message source to the page.** `intl-messageformat` throws when an argument is absent, and the fallback ran the simple interpolator — whose pattern deliberately cannot match an ICU slot, so the entire construct passed through untouched. A phrase promoted to `{name_gender, select, …}` rendered as literal ICU source in front of users.
|
|
14
|
+
|
|
15
|
+
This is reachable with no caller mistake: langsys-ai's ICU promoter *introduces* a select argument the source phrase never had — a plain `{name}` becomes `{name_gender, select, …}` in gendered target locales. The application cannot supply `name_gender`; it doesn't exist in the phrase the developer wrote, and nothing tells them the target grew one. Every app translating into a gendered locale hits it.
|
|
16
|
+
|
|
17
|
+
Recovery, agreed with `langsys-php` so a shared catalog cannot render two different sentences for one input:
|
|
18
|
+
|
|
19
|
+
| slot | missing-argument behavior | outcome |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `select` | take the `other` branch | a correct sentence — `other` is what an unknown gender should render |
|
|
22
|
+
| `plural` | take `other`, render `#` as `{argName}` | sentence survives with a visible gap; nothing can be inferred for a count |
|
|
23
|
+
| `{name}`, `{n, number}`, dates | render as `{argName}` | matches the simple path's rule that unknown keys stay visible |
|
|
24
|
+
|
|
25
|
+
The asymmetry is deliberate: `select` is genuinely recoverable, `plural` is only made less bad. Arguments that ARE supplied are untouched and recursed into, so a missing argument nested inside a satisfied branch still recovers. `plural`/`select` without an `other` branch is malformed ICU and is left alone, falling through to the simple path as before.
|
|
26
|
+
|
|
27
|
+
**A `null` argument counts as missing**, matching `langsys-php`. It doesn't throw — `intl-messageformat` coerces null to `0` — so `{count, plural, …}` rendered `0 items`. That is worse than it looks: a genuine `0` also renders `0 items`, so a bug and valid data became indistinguishable, in the output, in a screenshot, and in a bug report. A real `0` still renders `0 items`; only null and absent produce `{count} items`.
|
|
28
|
+
|
|
29
|
+
Found by `langsys-php` from a report against their SDK; both implementations had it, by different mechanisms. Verified against their shipped v1.3.1 behavior, including a nested case where an *unsatisfied* branch's missing argument correctly costs nothing (`{g, select, f {Ella} other {{n, plural …}}}` with `g='f'` renders `Ella`).
|
|
30
|
+
|
|
1
31
|
## 0.6.3 - 2026-08-16
|
|
2
32
|
|
|
3
33
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# Langsys SDK — TypeScript
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/langsys-js-typescript)
|
|
4
|
+
[](https://github.com/langsys/langsys-js-typescript/actions)
|
|
5
|
+
[](https://github.com/langsys/langsys-js-typescript/commits)
|
|
6
|
+
[](https://github.com/langsys/langsys-js-typescript/pulse)
|
|
7
|
+
[](https://bundlejs.com/?q=langsys-js-typescript)
|
|
8
|
+
[](https://www.npmjs.com/package/langsys-js-typescript)
|
|
9
|
+
[](https://www.npmjs.com/package/langsys-js-typescript)
|
|
10
|
+
[](./LICENSE)
|
|
4
11
|
|
|
5
|
-
|
|
12
|
+
Framework-agnostic TypeScript SDK for the [Langsys](https://Langsys.dev/) Translation Manager. Realtime, continuous translations with automatic token discovery — no framework required.
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, `langsys-js-react`, `langsys-js-vue`) are thin wrappers that add idiomatic framework reactivity around the same core.
|
|
8
15
|
|
|
9
16
|
## What's inside
|
|
10
17
|
|
|
@@ -14,7 +21,7 @@ This is the base SDK. Framework-specific bindings (`langsys-js-svelte`, and simi
|
|
|
14
21
|
- `Translate` class — wraps a DOM element, walks text + translatable attributes, registers a content block with the Translation Manager, re-translates on locale change.
|
|
15
22
|
- `Signal<T>` — tiny subscribe/set/update/get primitive. Compatible with Svelte's store contract by design, so the Svelte binding is nearly trivial.
|
|
16
23
|
- `LangsysAppAPI` — direct HTTP access if you want to bypass everything else.
|
|
17
|
-
-
|
|
24
|
+
- One runtime dependency (`intl-messageformat`, for ICU plurals and gender selects). ~20 kB gzipped. Works in browsers, Node, any bundler.
|
|
18
25
|
|
|
19
26
|
## Install
|
|
20
27
|
|
|
@@ -161,11 +168,22 @@ handle.destroy();
|
|
|
161
168
|
|
|
162
169
|
Attributes honored on contained elements:
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
**Visible text**
|
|
172
|
+
|
|
173
|
+
- `placeholder`, `alt`, `title`, `label`
|
|
174
|
+
- `value` on `<button>`, `<input type="submit">`, `<input type="button">` — and *only* those; a text input's value is never rewritten
|
|
166
175
|
- `<option>` text inside `<select>`
|
|
167
|
-
|
|
168
|
-
-
|
|
176
|
+
|
|
177
|
+
**Screen-reader text** — `aria-label`, `aria-placeholder`, `aria-description`, `aria-valuetext`, `aria-roledescription`
|
|
178
|
+
|
|
179
|
+
> Leaving these untranslated has **no visible symptom**. Nothing on the page changes, so reviewing the rendered output cannot catch it — the failure is audible only to the users least able to work around it.
|
|
180
|
+
|
|
181
|
+
**Validation messages** — `data-error`, `data-error-message`, `data-validation-message`, `data-invalid-message`, `data-required-message`, `data-pattern-message`
|
|
182
|
+
|
|
183
|
+
**Opting out**
|
|
184
|
+
|
|
185
|
+
- `translate="no"` (case-insensitive), or `data-notrans` — `langsys-php`'s author-facing alias, honored here so an SSR-rendered page opts out consistently in both SDKs. Presence means intent; an explicit `="false"` or `="0"` opts back in.
|
|
186
|
+
- Elements marked this way, and their children, are skipped entirely.
|
|
169
187
|
|
|
170
188
|
> **`Translate` tokenizes per text node — so inline markup splits a sentence.**
|
|
171
189
|
>
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
var intlMessageformat = require('intl-messageformat');
|
|
4
4
|
|
|
5
|
+
// src/logger.ts
|
|
6
|
+
var STYLES = {
|
|
7
|
+
log: "display:inline-block;background:rgb(239,239,244);color:black;font-weight:bold;padding:3px 7px;border-radius:3px",
|
|
8
|
+
warn: "display:inline-block;background:gold;color:black;font-weight:bold;padding:3px 7px;border-radius:3px",
|
|
9
|
+
error: "display:inline-block;background:#e0005a;color:#fff;font-weight:bold;padding:3px 7px;border-radius:3px"
|
|
10
|
+
};
|
|
11
|
+
var supportsStyle = typeof window !== "undefined";
|
|
12
|
+
function emit(level, label, args) {
|
|
13
|
+
if (supportsStyle) {
|
|
14
|
+
console[level](`%c${label}`, STYLES[level], ...args);
|
|
15
|
+
} else {
|
|
16
|
+
console[level](`[${label}]`, ...args);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
var Logger = class {
|
|
20
|
+
constructor(debugEnabled = false) {
|
|
21
|
+
this.debugEnabled = debugEnabled;
|
|
22
|
+
}
|
|
23
|
+
log(...args) {
|
|
24
|
+
if (!this.debugEnabled) return;
|
|
25
|
+
emit("log", "Langsys Debug", args);
|
|
26
|
+
}
|
|
27
|
+
warn(...args) {
|
|
28
|
+
emit("warn", "Langsys Warning", args);
|
|
29
|
+
}
|
|
30
|
+
error(...args) {
|
|
31
|
+
emit("error", "Langsys Error", args);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var logger = new Logger(false);
|
|
35
|
+
|
|
5
36
|
// src/locale.ts
|
|
6
37
|
function canonicalizeLocale(locale) {
|
|
7
38
|
if (!locale || typeof locale !== "string") return locale;
|
|
@@ -10,6 +41,9 @@ function canonicalizeLocale(locale) {
|
|
|
10
41
|
const [canonical] = Intl.getCanonicalLocales(cleaned);
|
|
11
42
|
return canonical ?? cleaned;
|
|
12
43
|
} catch {
|
|
44
|
+
if (logger.debugEnabled) logger.warn(
|
|
45
|
+
`"${locale}" is not a valid BCP 47 locale tag. Falling back to best-effort casing, which will very likely miss the catalog and render base language with no other symptom.`
|
|
46
|
+
);
|
|
13
47
|
return cleaned.split("-").map((part, i) => {
|
|
14
48
|
if (i === 0) return part.toLowerCase();
|
|
15
49
|
if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
|
|
@@ -53,37 +87,6 @@ function getValue(signal) {
|
|
|
53
87
|
return signal.get();
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
// src/logger.ts
|
|
57
|
-
var STYLES = {
|
|
58
|
-
log: "display:inline-block;background:rgb(239,239,244);color:black;font-weight:bold;padding:3px 7px;border-radius:3px",
|
|
59
|
-
warn: "display:inline-block;background:gold;color:black;font-weight:bold;padding:3px 7px;border-radius:3px",
|
|
60
|
-
error: "display:inline-block;background:#e0005a;color:#fff;font-weight:bold;padding:3px 7px;border-radius:3px"
|
|
61
|
-
};
|
|
62
|
-
var supportsStyle = typeof window !== "undefined";
|
|
63
|
-
function emit(level, label, args) {
|
|
64
|
-
if (supportsStyle) {
|
|
65
|
-
console[level](`%c${label}`, STYLES[level], ...args);
|
|
66
|
-
} else {
|
|
67
|
-
console[level](`[${label}]`, ...args);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
var Logger = class {
|
|
71
|
-
constructor(debugEnabled = false) {
|
|
72
|
-
this.debugEnabled = debugEnabled;
|
|
73
|
-
}
|
|
74
|
-
log(...args) {
|
|
75
|
-
if (!this.debugEnabled) return;
|
|
76
|
-
emit("log", "Langsys Debug", args);
|
|
77
|
-
}
|
|
78
|
-
warn(...args) {
|
|
79
|
-
emit("warn", "Langsys Warning", args);
|
|
80
|
-
}
|
|
81
|
-
error(...args) {
|
|
82
|
-
emit("error", "Langsys Error", args);
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
var logger = new Logger(false);
|
|
86
|
-
|
|
87
90
|
// src/api.ts
|
|
88
91
|
var LangsysAppAPIClass = class {
|
|
89
92
|
constructor() {
|
|
@@ -289,14 +292,76 @@ function warnUnmatchedParams(source, texts, params, context) {
|
|
|
289
292
|
}
|
|
290
293
|
function interpolate(template, params, locale) {
|
|
291
294
|
if (isICU(template)) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
const resolved = locale || "en";
|
|
296
|
+
const hasNullParam = !!params && Object.values(params).some((value) => value === null);
|
|
297
|
+
if (!hasNullParam) {
|
|
298
|
+
try {
|
|
299
|
+
return new intlMessageformat.IntlMessageFormat(template, resolved).format(params);
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
{
|
|
304
|
+
try {
|
|
305
|
+
const ast = new intlMessageformat.IntlMessageFormat(template, resolved).ast;
|
|
306
|
+
if (!Array.isArray(ast)) return simpleInterpolate(template, params, locale);
|
|
307
|
+
const recovered = _recoverMissingArgs(JSON.parse(JSON.stringify(ast)), params);
|
|
308
|
+
return new intlMessageformat.IntlMessageFormat(recovered, resolved).format(params);
|
|
309
|
+
} catch {
|
|
310
|
+
return simpleInterpolate(template, params, locale);
|
|
311
|
+
}
|
|
296
312
|
}
|
|
297
313
|
}
|
|
298
314
|
return simpleInterpolate(template, params, locale);
|
|
299
315
|
}
|
|
316
|
+
var ICU_LITERAL = 0;
|
|
317
|
+
var ICU_ARGUMENT = 1;
|
|
318
|
+
var ICU_NUMBER = 2;
|
|
319
|
+
var ICU_DATE = 3;
|
|
320
|
+
var ICU_TIME = 4;
|
|
321
|
+
var ICU_SELECT = 5;
|
|
322
|
+
var ICU_PLURAL = 6;
|
|
323
|
+
var ICU_POUND = 7;
|
|
324
|
+
var ICU_TAG = 8;
|
|
325
|
+
function _recoverMissingArgs(nodes, params) {
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const node of nodes) {
|
|
328
|
+
const name = node.value;
|
|
329
|
+
const value = name !== void 0 && name in params ? params[name] : void 0;
|
|
330
|
+
const supplied = value !== void 0 && value !== null;
|
|
331
|
+
if (node.type === ICU_TAG) {
|
|
332
|
+
out.push({ ...node, children: _recoverMissingArgs(node.children ?? [], params) });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (node.type === ICU_SELECT || node.type === ICU_PLURAL) {
|
|
336
|
+
if (supplied) {
|
|
337
|
+
const options = {};
|
|
338
|
+
for (const [key, branch2] of Object.entries(node.options ?? {})) {
|
|
339
|
+
options[key] = { value: _recoverMissingArgs(branch2.value, params) };
|
|
340
|
+
}
|
|
341
|
+
out.push({ ...node, options });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const other = node.options?.other;
|
|
345
|
+
if (!other) {
|
|
346
|
+
out.push(node);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const branch = _recoverMissingArgs(other.value, params);
|
|
350
|
+
out.push(
|
|
351
|
+
...branch.map(
|
|
352
|
+
(child) => child.type === ICU_POUND ? { type: ICU_LITERAL, value: `{${name}}` } : child
|
|
353
|
+
)
|
|
354
|
+
);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!supplied && (node.type === ICU_ARGUMENT || node.type === ICU_NUMBER || node.type === ICU_DATE || node.type === ICU_TIME)) {
|
|
358
|
+
out.push({ type: ICU_LITERAL, value: `{${name}}` });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
out.push(node);
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
300
365
|
function simpleInterpolate(template, params, locale) {
|
|
301
366
|
return template.replace(/\{([^{},]+)\}/g, (match, rawKey) => {
|
|
302
367
|
const key = rawKey.trim();
|