langsys-js-typescript 0.4.3 → 0.6.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/CHANGELOG.md +74 -3
- package/README.md +44 -0
- package/dist/index.d.mts +72 -5
- package/dist/index.d.ts +72 -5
- package/dist/index.js +41 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,46 @@
|
|
|
1
|
-
## 0.
|
|
1
|
+
## 0.6.0 - unreleased
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **`md5` now agrees with a standard UTF-8 MD5 for all input.** It packed UTF-16 code units straight into byte lanes, so it matched a real MD5 only for ASCII. Two consequences, both confirmed against the published 0.5.0 build:
|
|
6
|
+
- *Cross-SDK divergence (certain, universal).* Every non-ASCII content block received a different `custom_id` in the JS SDK than in `langsys-php`, which hashes UTF-8 bytes. Any project rendering the same block through both SDKs got two catalog entries for one block.
|
|
7
|
+
- *Self-collision (real, rare).* A character's high byte was OR-ed into the neighbouring byte lane, vanishing whenever that lane already carried those bits — so `"éa"` and `"ǩa"` hashed identically, as did `"Café"`/`"Cafǩ"` and `"Don’t"`/`"Don<0x19>t"`. Distinct content blocks could share one `custom_id`, letting one block's translations serve another's content. A 20,000-phrase scan of realistic copy found no collisions, so reaching one needs a specific pairing — but the failure mode is silent and wrong when it happens.
|
|
8
|
+
|
|
9
|
+
The message bit-length was also a code-unit count rather than a byte count, so astral characters (`😀`) mis-lengthed the message independently of the packing bug.
|
|
10
|
+
|
|
11
|
+
**Migration is automatic and lossless.** `Translate` now resolves a content block against the corrected id first and falls back to the legacy id when that misses, so blocks registered by an older SDK keep serving their existing translations instead of orphaning. The fallback is **lookup-only** — registration always uses the corrected id, so the legacy-keyed population can only shrink. **Pure-ASCII ids are byte-identical to before**, so only non-ASCII blocks are affected at all.
|
|
12
|
+
|
|
13
|
+
Found by the `langsys-skill` and `langsys-php` agents; the collision mechanism and the JSON-offset dependency below were established jointly with them.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`md5Legacy(input)`** and **`generateLegacyCustomId(category, tokens)`** — the pre-0.6.0 hash, exported for migration tooling and cross-SDK reconciliation. Lookup-only; never register new content under these. Deprecated on arrival, to be removed once catalogs have been rebased.
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
|
|
21
|
+
- Collisions are a property of the **final hashed string**, not the phrase: `JSON.stringify` shifts every character's offset, so a pair that collides standalone may not collide once wrapped, and changing `category` moves every character into different lanes. Reason at the `generateCustomId` level, never at bare `md5()`.
|
|
22
|
+
|
|
23
|
+
## 0.5.0 - 2026-08-14
|
|
24
|
+
|
|
25
|
+
### Changed (breaking, types only)
|
|
26
|
+
|
|
27
|
+
- **`PhraseOptions.params` is now `Record<string, ParamPrimitive>`**, matching `TranslateOptions.params` and `t()`. It was `Record<string, unknown>`, which allowed values — objects, arrays, functions — that interpolation can only render as `[object Object]`. No runtime behavior changes; this is a compile-time narrowing that surfaces those calls as errors instead of silently rendering garbage. `Phrase.setParams()` now also accepts `undefined`, matching `Translate.setParams()`.
|
|
28
|
+
|
|
29
|
+
**Impact — two distinct cases.** First, and most visible to application code: **passing an object or array as a param value no longer compiles** (`params={{ user: someObject }}`). That code was already broken at runtime — interpolation could only render it as `[object Object]` — so this converts a silent bug into a build error, but it does bite at build time. Second: code typing its params as `Record<string, unknown>` and forwarding it no longer compiles, which covered all three framework bindings; the fix there is one line per call site, declaring `Record<string, ParamPrimitive>` (exported from the package root). Usefully, that narrowed declaration compiles against 0.4.x *and* 0.5.0, so wrappers can land it independently of the version bump. Runtime is unaffected throughout — code that skips the type change still *works*, it just fails `tsc`.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`Phrase` is documented in the README for the first time.** It's been exported since 0.1.0 but had no section, so the only documented tool for markup-bearing content was `Translate` — which splits at tag boundaries and is the wrong choice for a sentence. The new section covers the three reasons it exists (grammatical agreement, reordering, phrase-key stability), and the `Translate` section now shows the literal per-text-node split so the trade-off is visible at the point of decision.
|
|
34
|
+
- Warning against building phrases with template literals (`` t(`Hello, ${name}!`) ``), which registers every runtime value as its own catalog phrase and pollutes the catalog shared across every Langsys SDK.
|
|
35
|
+
- Params doc comments now name the `%name%` markup spelling alongside the canonical `{name}`, including Vue's `{{ name }}` case.
|
|
36
|
+
|
|
37
|
+
## 0.4.3 - 2026-08-08
|
|
2
38
|
|
|
3
39
|
### Fixed
|
|
4
40
|
|
|
5
41
|
- **The params diagnostic no longer names the wrong framework.** 0.4.2's warning hardcoded "the framework compiler (Svelte/JSX)" and the `{key}` spelling, which misdiagnoses Vue users — Vue consumes `{{ key }}`, not single braces, so a Vue developer hitting the warning was pointed at a syntax their framework never touches. The message is now framework-neutral and names both brace spellings.
|
|
6
42
|
|
|
7
|
-
## 0.4.2 - 2026-
|
|
43
|
+
## 0.4.2 - 2026-08-08
|
|
8
44
|
|
|
9
45
|
### Added
|
|
10
46
|
|
|
@@ -50,7 +86,23 @@ Full CLDR compliance, matching the backend's CLDR migration. Everything rides on
|
|
|
50
86
|
|
|
51
87
|
- `canonicalizeLocale(locale)` — exported BCP 47 canonicalizer, useful when seeding your own `UserLocaleStore`.
|
|
52
88
|
|
|
53
|
-
## 0.2.
|
|
89
|
+
## 0.2.3 - 2026-07-01
|
|
90
|
+
|
|
91
|
+
> Reconstructed retroactively (2026-08-15) from git history — this release shipped without a changelog entry.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **`ready()` never settled on an SSR-seeded locale, hanging every content-block consumer.** The SSR handoff calls `markLoaded()` to prime the fetch cache so `change()` short-circuits, but `readyResolve()` lived only in `getTranslations()`. With no fetch ever firing, `Translate` and `Phrase` awaited `ready()` forever on the seeded locale and never rendered. Now resolved in `markLoaded()` — a seeded catalog counts as ready — and in the legacy `skipFetch` branch of `change()`, which had the same hole.
|
|
96
|
+
|
|
97
|
+
## 0.2.2 - 2026-06-24
|
|
98
|
+
|
|
99
|
+
> Reconstructed retroactively (2026-08-15) from git history — this release shipped without a changelog entry.
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- **Switching back to the initial/SSR locale left the previous language on screen.** The init-time locale subscription recomputed `skipFetch` on every settle as `initialTranslationsLocale === locale && !!initialTranslations`. Since `initialTranslations` stays truthy for the whole session, every *return* to the initial locale skipped `change()`'s store updates, so the previous locale's catalog stayed active and the `t` signal never rebuilt. Replaced the persistent skip flag with `Translations.markLoaded()`, which primes the same `lastLoaded` cache `change()` consults: the first settle after the SSR handoff is still a cache hit with no redundant fetch, but switching back later flows through `change()` normally.
|
|
104
|
+
|
|
105
|
+
## 0.2.1 - 2026-06-12
|
|
54
106
|
|
|
55
107
|
### Changed
|
|
56
108
|
|
|
@@ -60,6 +112,25 @@ Full CLDR compliance, matching the backend's CLDR migration. Everything rides on
|
|
|
60
112
|
- Missing-phrase registration now chunks into batches of 200 to respect the `/translatable-items` per-request cap.
|
|
61
113
|
- Added `LangsysAppAPI.createTranslatableItems()` as the single write entry point for both phrases and content blocks.
|
|
62
114
|
|
|
115
|
+
## 0.2.0 - 2026-06-10
|
|
116
|
+
|
|
117
|
+
> Reconstructed retroactively (2026-08-15). This release shipped without a changelog entry, which is how the reversed `t()` signature survived in `CLAUDE.md` for four months — there was no release note to prompt the update. Details recovered from git history (`v0.1.0..v0.2.0`).
|
|
118
|
+
|
|
119
|
+
### Changed (breaking)
|
|
120
|
+
|
|
121
|
+
- **`t()` argument order flipped to `t(phrase, category?, params?)`.** 0.1.0 shipped `t(category, phrase, params?)`; the phrase now comes first and the category is optional. Implemented via overloads that discriminate on the type at position 2 (string ⇒ category, object ⇒ params). This is the single most consequential API change in the package's history and the one most likely to be miswritten from memory: with a placeholder-free phrase, the old order still type-checks, silently registering the category as the phrase. Nothing catches it until someone reads the catalog.
|
|
122
|
+
- Narrowed `LocaleSource`; extracted the framework-agnostic content-block helpers (`generateCustomId`, `isContentBlockKnown`, `registerContentBlock`, `tokenizeElement`) so non-DOM wrappers can reuse registration without the DOM mutator.
|
|
123
|
+
- Missing phrases are sent with a null category on the wire rather than the `__uncategorized__` sentinel, which is server-internal and must not be sent by clients.
|
|
124
|
+
|
|
125
|
+
### Added
|
|
126
|
+
|
|
127
|
+
- **`<Phrase>` and `<DontTranslate>`** — markup-sentinel rich-text handling, so a markup-bearing sentence stays one translatable phrase.
|
|
128
|
+
- **ICU MessageFormat** wired through `interpolate()`, with the SDK declaring ICU capability so the server sends raw ICU rather than pre-rendered text.
|
|
129
|
+
|
|
130
|
+
### Fixed
|
|
131
|
+
|
|
132
|
+
- Content-block dedup unified, and a `custom_id` hash collision fixed. (Note: the deeper `md5` encoding defect behind that class of collision was not found until 0.6.0.)
|
|
133
|
+
|
|
63
134
|
## 0.1.0 - 2026-05-19
|
|
64
135
|
|
|
65
136
|
Initial public release. Framework-agnostic TypeScript SDK for the Langsys Translation Manager — the foundation that `langsys-js-svelte` (and future framework-specific bindings) build on.
|
package/README.md
CHANGED
|
@@ -72,6 +72,15 @@ Allowed value types: `string | number | Date | boolean`. Numbers and Dates forma
|
|
|
72
72
|
|
|
73
73
|
> Translations containing ICU MessageFormat syntax — `{count, plural, one {# item} other {# items}}`, `{gender, select, …}`, `{n, number}`, `{d, date}` — are rendered with full CLDR plural rules for the active locale (Arabic's six categories, Russian's four, etc.). Plain `{name}` slots keep working unchanged.
|
|
74
74
|
|
|
75
|
+
> **⚠️ Always pass values through `params` — never build the phrase yourself.**
|
|
76
|
+
>
|
|
77
|
+
> ```ts
|
|
78
|
+
> t(`Hello, ${name}!`, 'Greetings'); // ❌ never do this
|
|
79
|
+
> t('Hello, {name}!', 'Greetings', { name }); // ✓
|
|
80
|
+
> ```
|
|
81
|
+
>
|
|
82
|
+
> The template literal resolves *before* `t()` runs, so with a write key the SDK registers `"Hello, Sarah!"` as a brand-new phrase — and every distinct runtime value mints another one ("Hello, Priya!", "Hello, Ahmed!"). Your Translation Manager fills with untranslatable one-offs, and since projects share one catalog across SDKs, it pollutes what every other binding reads too. The SDK cannot detect this: an interpolated string is indistinguishable from an authored one. The `params` form registers the phrase once, with the placeholder intact for translators.
|
|
83
|
+
|
|
75
84
|
#### Categorization disambiguates context
|
|
76
85
|
|
|
77
86
|
```ts
|
|
@@ -158,6 +167,14 @@ Attributes honored on contained elements:
|
|
|
158
167
|
- Several validation-message `data-*` attributes
|
|
159
168
|
- `translate="no"` — elements marked this way (and their children) are skipped
|
|
160
169
|
|
|
170
|
+
> **`Translate` tokenizes per text node — so inline markup splits a sentence.**
|
|
171
|
+
>
|
|
172
|
+
> ```html
|
|
173
|
+
> <p>My content <strong>is the best</strong> when internationalized.</p>
|
|
174
|
+
> ```
|
|
175
|
+
>
|
|
176
|
+
> registers **three** phrases: `"My content"`, `"is the best"`, `"when internationalized."` That's correct for a block of independent runs, and it's what `Translate` is for. But a translator sees three fragments with no context, and neither reordering nor grammatical agreement can cross a fragment boundary — fatal for a sentence whose word order or noun forms change with the language. When inline markup sits *inside a sentence*, reach for [`Phrase`](#the-phrase-class) instead.
|
|
177
|
+
|
|
161
178
|
### Interpolation params
|
|
162
179
|
|
|
163
180
|
`Translate` accepts the same single-brace `{key}` placeholders as `t()`. Pass `params` and every translated text node and attribute is interpolated after lookup — unknown keys stay visible as-is, and `Number`/`Date` values are formatted per the active locale's CLDR rules:
|
|
@@ -206,6 +223,33 @@ instead.
|
|
|
206
223
|
|
|
207
224
|
The check runs whenever the params key-set changes, so a ticking `count` won't spam the console, and it's silent in production.
|
|
208
225
|
|
|
226
|
+
## The `Phrase` class
|
|
227
|
+
|
|
228
|
+
`Phrase` wraps an element whose contents are **one** translatable sentence *even though it contains inline markup*. Where `Translate` splits at tag boundaries, `Phrase` keeps the run whole.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { Phrase } from 'langsys-js-typescript';
|
|
232
|
+
|
|
233
|
+
const el = document.querySelector<HTMLElement>('#reviews')!;
|
|
234
|
+
new Phrase(el, { category: 'ProductCard', params: { n: reviewCount } });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
```html
|
|
238
|
+
<span id="reviews">Based on %n% <strong>reviews</strong></span>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
That registers as a single phrase — `Based on {n} {m0o}reviews{m0c}` — rather than `"Based on {n}"` and `"reviews"`. Three things depend on it:
|
|
242
|
+
|
|
243
|
+
1. **Grammatical agreement.** The count and the noun it inflects must live in one phrase, or no ICU plural rule can select the right noun form. Russian has four plural categories, Arabic six, Polish four — split them apart and the output is confidently wrong, not merely awkward.
|
|
244
|
+
2. **Reordering.** What you write as `<span>White</span> House` renders in Spanish as `Casa <span>Blanca</span>` — the emphasis follows the word it belongs to, across a word-order change. That works because the markup becomes ordinary ICU placeholders (`{m0o}White{m0c} House` → `Casa {m0o}Blanca{m0c}`), so a translator or model *places* them around the translated word rather than being stuck with the original order. Impossible once the sentence is split at the tag boundary.
|
|
245
|
+
3. **Phrase-key stability.** Your real elements never reach the translator — they're replaced by neutral tokens and reconstituted at render. Framework scoped-CSS classes (`svelte-a1b2c3`) change every build; if they rode along in the phrase, the key would drift and silently re-translate everything.
|
|
246
|
+
|
|
247
|
+
Use it standalone, or nested inside a `Translate` block to protect specific runs — the block tokenizer skips any subtree carrying `data-ls-phrase` (exported as `PHRASE_MARKER_ATTR`), letting the `Phrase` own it. `setParams()` re-renders on a changed count, same as `Translate`.
|
|
248
|
+
|
|
249
|
+
> **Who sets `data-ls-phrase`.** In the framework bindings it's internal — `<Phrase>` puts it on its own host element and you never write it yourself. In **vanilla usage it is yours to set**: the `Phrase` class does not add it, so a `Phrase` nested inside a `Translate` block needs the attribute present in your HTML *before* the wrapping `Translate` tokenizes, or the block will tokenize straight through the phrase and both will fight over the same nodes. Standalone `Phrase` doesn't need it at all. (Not to be confused with `langsys-php`'s `data-langsys-*` attributes, which are author-facing by design.)
|
|
250
|
+
|
|
251
|
+
If a translation drops or unbalances a markup token, rendering degrades to plain text with the markers stripped — the meaning survives even when the markup doesn't.
|
|
252
|
+
|
|
209
253
|
## Server-Side Rendering
|
|
210
254
|
|
|
211
255
|
Pre-fetch translations on the server and seed them through `initialTranslations` to skip the duplicate client fetch on hydration:
|
package/dist/index.d.mts
CHANGED
|
@@ -375,7 +375,13 @@ interface TranslateOptions {
|
|
|
375
375
|
custom_id?: string;
|
|
376
376
|
/** Optional human-readable label shown in the Translation Manager. */
|
|
377
377
|
label?: string;
|
|
378
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* Interpolation params — `{name}`, `{count}`, etc. Same single-brace syntax
|
|
380
|
+
* as `t()`, and what you write in plain DOM. In a framework component's
|
|
381
|
+
* markup write `%name%` / `%count%` instead: Svelte and JSX consume
|
|
382
|
+
* `{name}` at compile time (Vue consumes `{{ name }}`), and `%name%` is
|
|
383
|
+
* normalized back to `{name}` at capture.
|
|
384
|
+
*/
|
|
379
385
|
params?: Record<string, ParamPrimitive>;
|
|
380
386
|
}
|
|
381
387
|
/**
|
|
@@ -433,8 +439,15 @@ declare const PHRASE_MARKER_ATTR = "data-ls-phrase";
|
|
|
433
439
|
interface PhraseOptions {
|
|
434
440
|
/** Category the phrase registers under (disambiguation for translators). */
|
|
435
441
|
category?: string;
|
|
436
|
-
/**
|
|
437
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Interpolation params — `{n}` for pluralization, `{name}`, etc.
|
|
444
|
+
*
|
|
445
|
+
* Those are the canonical spellings, and what you write in plain DOM. In a
|
|
446
|
+
* framework component's markup write `%n%` / `%name%` instead: Svelte and
|
|
447
|
+
* JSX consume `{n}` at compile time (Vue consumes `{{ n }}`), and `%n%` is
|
|
448
|
+
* normalized back to `{n}` at capture.
|
|
449
|
+
*/
|
|
450
|
+
params?: Record<string, ParamPrimitive>;
|
|
438
451
|
}
|
|
439
452
|
/**
|
|
440
453
|
* DOM handler for a markup-bearing "rich" phrase (`<Phrase>`).
|
|
@@ -460,7 +473,7 @@ declare class Phrase {
|
|
|
460
473
|
private checkedParamKeys;
|
|
461
474
|
constructor(host: HTMLElement, options?: PhraseOptions);
|
|
462
475
|
/** Update interpolation params (e.g. a changed count) and re-render. */
|
|
463
|
-
setParams(params: Record<string,
|
|
476
|
+
setParams(params: Record<string, ParamPrimitive> | undefined): void;
|
|
464
477
|
/** Stop reacting to locale/translation changes. Safe to call multiple times. */
|
|
465
478
|
destroy(): void;
|
|
466
479
|
private _init;
|
|
@@ -599,6 +612,24 @@ interface iContentBlock {
|
|
|
599
612
|
* and render via your framework's templating.
|
|
600
613
|
*/
|
|
601
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Attributes that mark a subtree as a self-managed "keep-together" phrase,
|
|
617
|
+
* which the block tokenizer must skip rather than split at tag boundaries.
|
|
618
|
+
*
|
|
619
|
+
* Two spellings, because two SDKs mark the same thing:
|
|
620
|
+
* - `data-ls-phrase` — set by this SDK's framework `<Phrase>` components.
|
|
621
|
+
* - `data-langsys-phrase` — `langsys-php`'s author-facing marker, which
|
|
622
|
+
* survives into `translatePage()` output.
|
|
623
|
+
*
|
|
624
|
+
* The distinction is invisible in normal use, but on SSR handoff there is only
|
|
625
|
+
* ONE DOM: a page rendered by PHP and then hydrated by a JS SDK is walked by
|
|
626
|
+
* both implementations, so this tokenizer has to recognise PHP's marker or it
|
|
627
|
+
* re-tokenizes a subtree PHP deliberately kept whole. Recognising both is
|
|
628
|
+
* additive — `data-langsys-phrase` never appears in DOM our components emit.
|
|
629
|
+
*/
|
|
630
|
+
declare const PHRASE_MARKER_ATTRS: readonly ["data-ls-phrase", "data-langsys-phrase"];
|
|
631
|
+
/** True when an element is marked as a self-managed phrase by either SDK. */
|
|
632
|
+
declare function isPhraseMarked(element: Element): boolean;
|
|
602
633
|
/**
|
|
603
634
|
* Compute the deterministic content-block id from a `(category, tokens)` pair.
|
|
604
635
|
*
|
|
@@ -611,6 +642,23 @@ interface iContentBlock {
|
|
|
611
642
|
* whether to call `registerContentBlock`.
|
|
612
643
|
*/
|
|
613
644
|
declare function generateCustomId(category: string, tokens: string[]): string;
|
|
645
|
+
/**
|
|
646
|
+
* The id this block would have had before the 0.6.0 MD5 fix.
|
|
647
|
+
*
|
|
648
|
+
* **Lookup only — never register under this.** Blocks registered by an older
|
|
649
|
+
* SDK are keyed by it, so `Translate` falls back to it when the corrected id
|
|
650
|
+
* misses, which keeps existing translations resolving instead of orphaning
|
|
651
|
+
* them. Registering under it would keep minting ids from a hash that both
|
|
652
|
+
* diverges across SDKs and can collide.
|
|
653
|
+
*
|
|
654
|
+
* Note the collision is a property of the FINAL hashed string, not the phrase:
|
|
655
|
+
* `JSON.stringify` shifts every character's offset, so the same phrase pair can
|
|
656
|
+
* collide standalone and not collide here — and changing `category` moves every
|
|
657
|
+
* character into different lanes. Always reason at this level, not at `md5()`.
|
|
658
|
+
*
|
|
659
|
+
* @deprecated Migration aid; will be removed once catalogs have been rebased.
|
|
660
|
+
*/
|
|
661
|
+
declare function generateLegacyCustomId(category: string, tokens: string[]): string;
|
|
614
662
|
/**
|
|
615
663
|
* True iff the local translations cache already has the content block
|
|
616
664
|
* for the given `(category, customId)` — meaning the backend knows about
|
|
@@ -671,7 +719,26 @@ declare const sTranslations: Signal<iCategories>;
|
|
|
671
719
|
declare const currentlyLoadedLocale: Signal<string>;
|
|
672
720
|
|
|
673
721
|
declare const isEmpty: (data: unknown) => boolean;
|
|
722
|
+
/**
|
|
723
|
+
* MD5 of a string, matching a standard UTF-8 MD5 for all input.
|
|
724
|
+
*
|
|
725
|
+
* Content-block `custom_id`s are derived from this, so it's a wire identity —
|
|
726
|
+
* see `generateCustomId`. Before 0.6.0 this packed UTF-16 code units directly
|
|
727
|
+
* into byte lanes, which agreed with a real MD5 only for ASCII: non-ASCII
|
|
728
|
+
* input both diverged from every other Langsys SDK and could collide (a
|
|
729
|
+
* character's high byte was OR-ed into the neighbouring lane, vanishing
|
|
730
|
+
* whenever that lane already carried those bits — so `éa` and `ǩa` hashed
|
|
731
|
+
* alike). `md5Legacy` preserves the old behavior for migration lookups only.
|
|
732
|
+
*/
|
|
674
733
|
declare function md5(inputString: string): string;
|
|
734
|
+
/**
|
|
735
|
+
* The pre-0.6.0 MD5, retained ONLY so existing content blocks stay resolvable:
|
|
736
|
+
* ids minted by an older SDK are keyed by this. Lookup-only — never register
|
|
737
|
+
* new content under it, or the colliding-id population keeps growing.
|
|
738
|
+
*
|
|
739
|
+
* @deprecated Migration aid; will be removed once catalogs have been rebased.
|
|
740
|
+
*/
|
|
741
|
+
declare function md5Legacy(inputString: string): string;
|
|
675
742
|
|
|
676
743
|
/**
|
|
677
744
|
* Detects ICU MessageFormat syntax in a translation string.
|
|
@@ -778,4 +845,4 @@ interface iProject {
|
|
|
778
845
|
declare const t: TFunction;
|
|
779
846
|
declare const tSignal: Signal<TFunction>;
|
|
780
847
|
|
|
781
|
-
export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, findUnusedParamKeys, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, normalizeMarkupPlaceholders, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
|
|
848
|
+
export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, PHRASE_MARKER_ATTRS, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, findUnusedParamKeys, generateCustomId, generateLegacyCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, isPhraseMarked, logger, markupTokenValues, md5, md5Legacy, normalizeMarkupPlaceholders, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
|
package/dist/index.d.ts
CHANGED
|
@@ -375,7 +375,13 @@ interface TranslateOptions {
|
|
|
375
375
|
custom_id?: string;
|
|
376
376
|
/** Optional human-readable label shown in the Translation Manager. */
|
|
377
377
|
label?: string;
|
|
378
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* Interpolation params — `{name}`, `{count}`, etc. Same single-brace syntax
|
|
380
|
+
* as `t()`, and what you write in plain DOM. In a framework component's
|
|
381
|
+
* markup write `%name%` / `%count%` instead: Svelte and JSX consume
|
|
382
|
+
* `{name}` at compile time (Vue consumes `{{ name }}`), and `%name%` is
|
|
383
|
+
* normalized back to `{name}` at capture.
|
|
384
|
+
*/
|
|
379
385
|
params?: Record<string, ParamPrimitive>;
|
|
380
386
|
}
|
|
381
387
|
/**
|
|
@@ -433,8 +439,15 @@ declare const PHRASE_MARKER_ATTR = "data-ls-phrase";
|
|
|
433
439
|
interface PhraseOptions {
|
|
434
440
|
/** Category the phrase registers under (disambiguation for translators). */
|
|
435
441
|
category?: string;
|
|
436
|
-
/**
|
|
437
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Interpolation params — `{n}` for pluralization, `{name}`, etc.
|
|
444
|
+
*
|
|
445
|
+
* Those are the canonical spellings, and what you write in plain DOM. In a
|
|
446
|
+
* framework component's markup write `%n%` / `%name%` instead: Svelte and
|
|
447
|
+
* JSX consume `{n}` at compile time (Vue consumes `{{ n }}`), and `%n%` is
|
|
448
|
+
* normalized back to `{n}` at capture.
|
|
449
|
+
*/
|
|
450
|
+
params?: Record<string, ParamPrimitive>;
|
|
438
451
|
}
|
|
439
452
|
/**
|
|
440
453
|
* DOM handler for a markup-bearing "rich" phrase (`<Phrase>`).
|
|
@@ -460,7 +473,7 @@ declare class Phrase {
|
|
|
460
473
|
private checkedParamKeys;
|
|
461
474
|
constructor(host: HTMLElement, options?: PhraseOptions);
|
|
462
475
|
/** Update interpolation params (e.g. a changed count) and re-render. */
|
|
463
|
-
setParams(params: Record<string,
|
|
476
|
+
setParams(params: Record<string, ParamPrimitive> | undefined): void;
|
|
464
477
|
/** Stop reacting to locale/translation changes. Safe to call multiple times. */
|
|
465
478
|
destroy(): void;
|
|
466
479
|
private _init;
|
|
@@ -599,6 +612,24 @@ interface iContentBlock {
|
|
|
599
612
|
* and render via your framework's templating.
|
|
600
613
|
*/
|
|
601
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Attributes that mark a subtree as a self-managed "keep-together" phrase,
|
|
617
|
+
* which the block tokenizer must skip rather than split at tag boundaries.
|
|
618
|
+
*
|
|
619
|
+
* Two spellings, because two SDKs mark the same thing:
|
|
620
|
+
* - `data-ls-phrase` — set by this SDK's framework `<Phrase>` components.
|
|
621
|
+
* - `data-langsys-phrase` — `langsys-php`'s author-facing marker, which
|
|
622
|
+
* survives into `translatePage()` output.
|
|
623
|
+
*
|
|
624
|
+
* The distinction is invisible in normal use, but on SSR handoff there is only
|
|
625
|
+
* ONE DOM: a page rendered by PHP and then hydrated by a JS SDK is walked by
|
|
626
|
+
* both implementations, so this tokenizer has to recognise PHP's marker or it
|
|
627
|
+
* re-tokenizes a subtree PHP deliberately kept whole. Recognising both is
|
|
628
|
+
* additive — `data-langsys-phrase` never appears in DOM our components emit.
|
|
629
|
+
*/
|
|
630
|
+
declare const PHRASE_MARKER_ATTRS: readonly ["data-ls-phrase", "data-langsys-phrase"];
|
|
631
|
+
/** True when an element is marked as a self-managed phrase by either SDK. */
|
|
632
|
+
declare function isPhraseMarked(element: Element): boolean;
|
|
602
633
|
/**
|
|
603
634
|
* Compute the deterministic content-block id from a `(category, tokens)` pair.
|
|
604
635
|
*
|
|
@@ -611,6 +642,23 @@ interface iContentBlock {
|
|
|
611
642
|
* whether to call `registerContentBlock`.
|
|
612
643
|
*/
|
|
613
644
|
declare function generateCustomId(category: string, tokens: string[]): string;
|
|
645
|
+
/**
|
|
646
|
+
* The id this block would have had before the 0.6.0 MD5 fix.
|
|
647
|
+
*
|
|
648
|
+
* **Lookup only — never register under this.** Blocks registered by an older
|
|
649
|
+
* SDK are keyed by it, so `Translate` falls back to it when the corrected id
|
|
650
|
+
* misses, which keeps existing translations resolving instead of orphaning
|
|
651
|
+
* them. Registering under it would keep minting ids from a hash that both
|
|
652
|
+
* diverges across SDKs and can collide.
|
|
653
|
+
*
|
|
654
|
+
* Note the collision is a property of the FINAL hashed string, not the phrase:
|
|
655
|
+
* `JSON.stringify` shifts every character's offset, so the same phrase pair can
|
|
656
|
+
* collide standalone and not collide here — and changing `category` moves every
|
|
657
|
+
* character into different lanes. Always reason at this level, not at `md5()`.
|
|
658
|
+
*
|
|
659
|
+
* @deprecated Migration aid; will be removed once catalogs have been rebased.
|
|
660
|
+
*/
|
|
661
|
+
declare function generateLegacyCustomId(category: string, tokens: string[]): string;
|
|
614
662
|
/**
|
|
615
663
|
* True iff the local translations cache already has the content block
|
|
616
664
|
* for the given `(category, customId)` — meaning the backend knows about
|
|
@@ -671,7 +719,26 @@ declare const sTranslations: Signal<iCategories>;
|
|
|
671
719
|
declare const currentlyLoadedLocale: Signal<string>;
|
|
672
720
|
|
|
673
721
|
declare const isEmpty: (data: unknown) => boolean;
|
|
722
|
+
/**
|
|
723
|
+
* MD5 of a string, matching a standard UTF-8 MD5 for all input.
|
|
724
|
+
*
|
|
725
|
+
* Content-block `custom_id`s are derived from this, so it's a wire identity —
|
|
726
|
+
* see `generateCustomId`. Before 0.6.0 this packed UTF-16 code units directly
|
|
727
|
+
* into byte lanes, which agreed with a real MD5 only for ASCII: non-ASCII
|
|
728
|
+
* input both diverged from every other Langsys SDK and could collide (a
|
|
729
|
+
* character's high byte was OR-ed into the neighbouring lane, vanishing
|
|
730
|
+
* whenever that lane already carried those bits — so `éa` and `ǩa` hashed
|
|
731
|
+
* alike). `md5Legacy` preserves the old behavior for migration lookups only.
|
|
732
|
+
*/
|
|
674
733
|
declare function md5(inputString: string): string;
|
|
734
|
+
/**
|
|
735
|
+
* The pre-0.6.0 MD5, retained ONLY so existing content blocks stay resolvable:
|
|
736
|
+
* ids minted by an older SDK are keyed by this. Lookup-only — never register
|
|
737
|
+
* new content under it, or the colliding-id population keeps growing.
|
|
738
|
+
*
|
|
739
|
+
* @deprecated Migration aid; will be removed once catalogs have been rebased.
|
|
740
|
+
*/
|
|
741
|
+
declare function md5Legacy(inputString: string): string;
|
|
675
742
|
|
|
676
743
|
/**
|
|
677
744
|
* Detects ICU MessageFormat syntax in a translation string.
|
|
@@ -778,4 +845,4 @@ interface iProject {
|
|
|
778
845
|
declare const t: TFunction;
|
|
779
846
|
declare const tSignal: Signal<TFunction>;
|
|
780
847
|
|
|
781
|
-
export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, findUnusedParamKeys, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, normalizeMarkupPlaceholders, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
|
|
848
|
+
export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, PHRASE_MARKER_ATTRS, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, findUnusedParamKeys, generateCustomId, generateLegacyCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, isPhraseMarked, logger, markupTokenValues, md5, md5Legacy, normalizeMarkupPlaceholders, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
|
package/dist/index.js
CHANGED
|
@@ -937,8 +937,23 @@ var isEmpty = (data) => {
|
|
|
937
937
|
if (typeof data === "object") return Object.keys(data).length === 0;
|
|
938
938
|
return false;
|
|
939
939
|
};
|
|
940
|
+
function toUtf8ByteString(input) {
|
|
941
|
+
const bytes = new TextEncoder().encode(input);
|
|
942
|
+
let out = "";
|
|
943
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
944
|
+
out += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
945
|
+
}
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
940
948
|
function md5(inputString) {
|
|
941
949
|
if (typeof inputString !== "string") inputString = JSON.stringify(inputString);
|
|
950
|
+
return md5Core(toUtf8ByteString(inputString));
|
|
951
|
+
}
|
|
952
|
+
function md5Legacy(inputString) {
|
|
953
|
+
if (typeof inputString !== "string") inputString = JSON.stringify(inputString);
|
|
954
|
+
return md5Core(inputString);
|
|
955
|
+
}
|
|
956
|
+
function md5Core(inputString) {
|
|
942
957
|
const hc = "0123456789abcdef";
|
|
943
958
|
function rh(n) {
|
|
944
959
|
let j, s = "";
|
|
@@ -1070,6 +1085,10 @@ var TRANSLATABLE_ATTRIBUTES = [
|
|
|
1070
1085
|
"data-required-message",
|
|
1071
1086
|
"data-pattern-message"
|
|
1072
1087
|
];
|
|
1088
|
+
var PHRASE_MARKER_ATTRS = ["data-ls-phrase", "data-langsys-phrase"];
|
|
1089
|
+
function isPhraseMarked(element) {
|
|
1090
|
+
return PHRASE_MARKER_ATTRS.some((attr) => element.hasAttribute(attr));
|
|
1091
|
+
}
|
|
1073
1092
|
var VALUE_TRANSLATABLE_ELEMENTS = ["button"];
|
|
1074
1093
|
var VALUE_TRANSLATABLE_INPUT_TYPES = ["submit", "button"];
|
|
1075
1094
|
var SEMANTIC_STYLE_PROPERTIES = [
|
|
@@ -1124,6 +1143,9 @@ var SEMANTIC_STYLE_PROPERTIES = [
|
|
|
1124
1143
|
function generateCustomId(category, tokens) {
|
|
1125
1144
|
return md5(JSON.stringify([category, tokens]));
|
|
1126
1145
|
}
|
|
1146
|
+
function generateLegacyCustomId(category, tokens) {
|
|
1147
|
+
return md5Legacy(JSON.stringify([category, tokens]));
|
|
1148
|
+
}
|
|
1127
1149
|
function isContentBlockKnown(category, customId) {
|
|
1128
1150
|
const cats = sTranslations.get();
|
|
1129
1151
|
const lookupCat = category || "__uncategorized__";
|
|
@@ -1183,7 +1205,7 @@ function _walkForTokens(liveRoot, cloneNodes, tokens, indices) {
|
|
|
1183
1205
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1184
1206
|
const el = node;
|
|
1185
1207
|
if (el.getAttribute("translate") === "no") return;
|
|
1186
|
-
if (el
|
|
1208
|
+
if (isPhraseMarked(el)) return;
|
|
1187
1209
|
}
|
|
1188
1210
|
if (node.hasChildNodes()) {
|
|
1189
1211
|
_applyStylesToClone(liveRoot, node, [...indices, index]);
|
|
@@ -1333,7 +1355,8 @@ var Translate = class {
|
|
|
1333
1355
|
return true;
|
|
1334
1356
|
}
|
|
1335
1357
|
async handleContentBlock(contentBlock) {
|
|
1336
|
-
|
|
1358
|
+
const derivedId = isEmpty(this.custom_id);
|
|
1359
|
+
if (derivedId) {
|
|
1337
1360
|
this.custom_id = generateCustomId(contentBlock.category, contentBlock.tokens);
|
|
1338
1361
|
}
|
|
1339
1362
|
contentBlock.custom_id = this.custom_id;
|
|
@@ -1345,6 +1368,17 @@ var Translate = class {
|
|
|
1345
1368
|
}
|
|
1346
1369
|
return;
|
|
1347
1370
|
}
|
|
1371
|
+
if (derivedId) {
|
|
1372
|
+
const legacyId = generateLegacyCustomId(contentBlock.category, contentBlock.tokens);
|
|
1373
|
+
if (legacyId !== this.custom_id && isContentBlockKnown(contentBlock.category, legacyId)) {
|
|
1374
|
+
this.custom_id = legacyId;
|
|
1375
|
+
if (this.tokens.length > 1 && this.element) {
|
|
1376
|
+
this.translate(Array.from(this.element.childNodes));
|
|
1377
|
+
this.lastTranslatedLocale = currentlyLoadedLocale.get();
|
|
1378
|
+
}
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1348
1382
|
void registerContentBlock(contentBlock);
|
|
1349
1383
|
if (this.tokens.length > 1 && this.element) {
|
|
1350
1384
|
this.translate(Array.from(this.element.childNodes));
|
|
@@ -1360,7 +1394,7 @@ var Translate = class {
|
|
|
1360
1394
|
if (node?.nodeType === Node.ELEMENT_NODE) {
|
|
1361
1395
|
const element = node;
|
|
1362
1396
|
if (element.getAttribute("translate") === "no") return;
|
|
1363
|
-
if (element
|
|
1397
|
+
if (isPhraseMarked(element)) return;
|
|
1364
1398
|
this.translateAttributes(node);
|
|
1365
1399
|
}
|
|
1366
1400
|
if (node?.nodeType !== Node.TEXT_NODE) {
|
|
@@ -1605,6 +1639,7 @@ exports.LangsysApp = LangsysApp;
|
|
|
1605
1639
|
exports.LangsysAppAPI = LangsysAppAPI;
|
|
1606
1640
|
exports.Logger = Logger;
|
|
1607
1641
|
exports.PHRASE_MARKER_ATTR = PHRASE_MARKER_ATTR;
|
|
1642
|
+
exports.PHRASE_MARKER_ATTRS = PHRASE_MARKER_ATTRS;
|
|
1608
1643
|
exports.Phrase = Phrase;
|
|
1609
1644
|
exports.Translate = Translate;
|
|
1610
1645
|
exports.Translations = Translations;
|
|
@@ -1614,14 +1649,17 @@ exports.currentlyLoadedLocale = currentlyLoadedLocale;
|
|
|
1614
1649
|
exports.encodeRichText = encodeRichText;
|
|
1615
1650
|
exports.findUnusedParamKeys = findUnusedParamKeys;
|
|
1616
1651
|
exports.generateCustomId = generateCustomId;
|
|
1652
|
+
exports.generateLegacyCustomId = generateLegacyCustomId;
|
|
1617
1653
|
exports.getValue = getValue;
|
|
1618
1654
|
exports.interpolate = interpolate;
|
|
1619
1655
|
exports.isContentBlockKnown = isContentBlockKnown;
|
|
1620
1656
|
exports.isEmpty = isEmpty;
|
|
1621
1657
|
exports.isICU = isICU;
|
|
1658
|
+
exports.isPhraseMarked = isPhraseMarked;
|
|
1622
1659
|
exports.logger = logger;
|
|
1623
1660
|
exports.markupTokenValues = markupTokenValues;
|
|
1624
1661
|
exports.md5 = md5;
|
|
1662
|
+
exports.md5Legacy = md5Legacy;
|
|
1625
1663
|
exports.normalizeMarkupPlaceholders = normalizeMarkupPlaceholders;
|
|
1626
1664
|
exports.persist = persist;
|
|
1627
1665
|
exports.reconstitute = reconstitute;
|