langsys-js-typescript 0.4.0 → 0.4.1

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 CHANGED
@@ -1,3 +1,23 @@
1
+ ## 0.4.1 - 2026-07-08
2
+
3
+ ### Added
4
+
5
+ - **`%key%` — markup-safe placeholder spelling for DOM content.** Bare `{key}` written inside `<Translate>`/`<Phrase>` markup is consumed by framework compilers before the SDK ever sees it (Svelte compiles it to an expression; JSX evaluates it). Markup now also accepts `%key%`, which survives compilation as literal text. It's additive — plain `{key}` in vanilla HTML keeps working — and `%key%` is normalized to canonical `{key}` at every capture boundary (content-block tokenizer, `Translate` snapshots, `Phrase` encoding), so the Translation Manager, the wire payloads, and translators only ever see the `{key}` form. Keys are restricted to identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` in prose ("20% off", "50% to 60%") can never false-match. `t()` phrases are JS strings with no compiler collision and stay `{key}`-only.
6
+ - **`normalizeMarkupPlaceholders(text)`** — the normalizer, exported for framework wrappers that render content-block tokens through their own templating.
7
+
8
+ ## 0.4.0 - 2026-07-08
9
+
10
+ Params interpolation for the `Translate` DOM class — content blocks can now carry runtime values the same way `t()` phrases do.
11
+
12
+ ### Added
13
+
14
+ - **`TranslateOptions.params`** (`Record<string, ParamPrimitive>`) — single-brace `{key}` placeholders, same syntax and semantics as `t()`: unknown keys fall through untouched so missing data stays visible, and Number/Date values are formatted per the active locale's CLDR rules. Interpolation applies everywhere resolved text lands in the DOM — content-block text nodes, translatable attributes, `<select>` option text, and the single-text-run path. Untranslated fallbacks interpolate too, and the base locale gets a render pass when params are present, so placeholders never leak into the UI.
15
+ - **`Translate.setParams(params)`** — swap params after mount (e.g. a changed count) and re-render, mirroring `Phrase.setParams`. The framework bindings expose this as a reactive `params` prop on their `<Translate>` components: `langsys-js-svelte` ≥ 3.3.0, `langsys-js-react` ≥ 0.4.0.
16
+
17
+ ### Fixed
18
+
19
+ - Content-block text replacement no longer treats `$`-patterns (`$&`, `` $` ``, `$1`…) in translations or interpolated values as `String.replace` directives — they now render literally.
20
+
1
21
  ## 0.3.0 - 2026-07-03
2
22
 
3
23
  Full CLDR compliance, matching the backend's CLDR migration. Everything rides on the platform's native `Intl` APIs — no new dependencies, no bundled locale data.
package/README.md CHANGED
@@ -158,6 +158,43 @@ Attributes honored on contained elements:
158
158
  - Several validation-message `data-*` attributes
159
159
  - `translate="no"` — elements marked this way (and their children) are skipped
160
160
 
161
+ ### Interpolation params
162
+
163
+ `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:
164
+
165
+ ```html
166
+ <div id="stats">
167
+ <h2>Hello {name}</h2>
168
+ <p>You have {count} new messages.</p>
169
+ </div>
170
+ ```
171
+
172
+ ```ts
173
+ const stats = document.querySelector<HTMLElement>('#stats')!;
174
+ const handle = new Translate(stats, {
175
+ category: 'Dashboard',
176
+ params: { name: 'Sarah', count: 5 },
177
+ });
178
+
179
+ // Update reactively — e.g. when the count changes:
180
+ handle.setParams({ name: 'Sarah', count: 6 });
181
+ ```
182
+
183
+ The placeholders are part of the registered phrase, so translators see `{name}` and `{count}` and keep them in the translation.
184
+
185
+ #### `%key%` — the markup-safe spelling
186
+
187
+ In compiled frameworks, bare `{key}` written in markup never reaches the SDK — Svelte compiles it to an expression and JSX evaluates it. Content passed through `<Translate>`/`<Phrase>` therefore also accepts `%key%`, which survives compilation as literal text:
188
+
189
+ ```svelte
190
+ <Translate category="Dashboard" params={{ name, count }}>
191
+ <h2>Hello %name%</h2>
192
+ <p>You have %count% new messages.</p>
193
+ </Translate>
194
+ ```
195
+
196
+ Both spellings are equivalent: `%key%` is normalized to canonical `{key}` at tokenization, so the Translation Manager, the wire, and translators only ever see `{key}`, and existing `{key}` content in vanilla HTML keeps working unchanged. Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` signs in prose ("20% off") are never touched. `t()` phrases are plain JS strings with no compiler in the way — they stay `{key}`-only.
197
+
161
198
  ## Server-Side Rendering
162
199
 
163
200
  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
@@ -670,6 +670,22 @@ declare function md5(inputString: string): string;
670
670
  * to decide whether to render a plural-slot editor vs. a single text field.
671
671
  */
672
672
  declare function isICU(template: string): boolean;
673
+ /**
674
+ * Normalize `%name%` markup placeholders to canonical `{name}`.
675
+ *
676
+ * Framework compilers consume bare `{name}` written in markup before the DOM
677
+ * walker ever sees it (Svelte compiles it to an expression; JSX evaluates it),
678
+ * so DOM content accepts `%name%` as a collision-free authoring escape.
679
+ * Normalization runs at every markup capture boundary (content-block
680
+ * tokenizer, `Translate` original-value snapshots, `Phrase` encoding), so the
681
+ * catalog, the wire, and translators only ever see the canonical `{name}`
682
+ * form — and plain `{name}` keeps working for vanilla-HTML authors.
683
+ *
684
+ * Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` in
685
+ * prose ("20% off", "50% to 60%") can't match. `t()` phrases are JS strings
686
+ * with no compiler collision and stay `{name}`-only.
687
+ */
688
+ declare function normalizeMarkupPlaceholders(text: string): string;
673
689
  /**
674
690
  * Substitute placeholders in a translated string with values from `params`.
675
691
  *
@@ -738,4 +754,4 @@ interface iProject {
738
754
  declare const t: TFunction;
739
755
  declare const tSignal: Signal<TFunction>;
740
756
 
741
- 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, 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, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
757
+ 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, 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 };
package/dist/index.d.ts CHANGED
@@ -670,6 +670,22 @@ declare function md5(inputString: string): string;
670
670
  * to decide whether to render a plural-slot editor vs. a single text field.
671
671
  */
672
672
  declare function isICU(template: string): boolean;
673
+ /**
674
+ * Normalize `%name%` markup placeholders to canonical `{name}`.
675
+ *
676
+ * Framework compilers consume bare `{name}` written in markup before the DOM
677
+ * walker ever sees it (Svelte compiles it to an expression; JSX evaluates it),
678
+ * so DOM content accepts `%name%` as a collision-free authoring escape.
679
+ * Normalization runs at every markup capture boundary (content-block
680
+ * tokenizer, `Translate` original-value snapshots, `Phrase` encoding), so the
681
+ * catalog, the wire, and translators only ever see the canonical `{name}`
682
+ * form — and plain `{name}` keeps working for vanilla-HTML authors.
683
+ *
684
+ * Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` in
685
+ * prose ("20% off", "50% to 60%") can't match. `t()` phrases are JS strings
686
+ * with no compiler collision and stay `{name}`-only.
687
+ */
688
+ declare function normalizeMarkupPlaceholders(text: string): string;
673
689
  /**
674
690
  * Substitute placeholders in a translated string with values from `params`.
675
691
  *
@@ -738,4 +754,4 @@ interface iProject {
738
754
  declare const t: TFunction;
739
755
  declare const tSignal: Signal<TFunction>;
740
756
 
741
- 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, 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, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
757
+ 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, 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 };
package/dist/index.js CHANGED
@@ -265,6 +265,9 @@ function isICU(template) {
265
265
  return ICU_PATTERN.test(template);
266
266
  }
267
267
  var ICU_PATTERN = /\{[^{}]+,\s*(plural|select|selectordinal|number|date|time)\s*[,}]/;
268
+ function normalizeMarkupPlaceholders(text) {
269
+ return text.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, "{$1}");
270
+ }
268
271
  function interpolate(template, params, locale) {
269
272
  if (isICU(template)) {
270
273
  try {
@@ -1141,7 +1144,7 @@ function tokenizeElement(element) {
1141
1144
  const tokens = [];
1142
1145
  const clone = element.cloneNode(true);
1143
1146
  _walkForTokens(element, Array.from(clone.childNodes), tokens, []);
1144
- return { tokens, content: clone.outerHTML };
1147
+ return { tokens, content: normalizeMarkupPlaceholders(clone.outerHTML) };
1145
1148
  }
1146
1149
  function _writeKnownContentBlockToCache(category, customId) {
1147
1150
  const lookupCat = category || "__uncategorized__";
@@ -1171,7 +1174,7 @@ function _walkForTokens(liveRoot, cloneNodes, tokens, indices) {
1171
1174
  }
1172
1175
  const contentToken = node.nodeValue?.replace(/\s+/g, " ").trim();
1173
1176
  if (node.nodeType === Node.TEXT_NODE && contentToken) {
1174
- tokens.push(contentToken);
1177
+ tokens.push(normalizeMarkupPlaceholders(contentToken));
1175
1178
  return;
1176
1179
  }
1177
1180
  if (!node.hasChildNodes()) return;
@@ -1186,24 +1189,24 @@ function _tokenizeAttributes(element, tokens) {
1186
1189
  }
1187
1190
  for (const attr of TRANSLATABLE_ATTRIBUTES) {
1188
1191
  const value = element.getAttribute(attr)?.trim();
1189
- if (value) tokens.push(value);
1192
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1190
1193
  }
1191
1194
  if (VALUE_TRANSLATABLE_ELEMENTS.includes(tagName)) {
1192
1195
  const value = element.getAttribute("value")?.trim();
1193
- if (value) tokens.push(value);
1196
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1194
1197
  }
1195
1198
  if (tagName === "input") {
1196
1199
  const inputType = element.getAttribute("type")?.toLowerCase();
1197
1200
  if (inputType && VALUE_TRANSLATABLE_INPUT_TYPES.includes(inputType)) {
1198
1201
  const value = element.getAttribute("value")?.trim();
1199
- if (value) tokens.push(value);
1202
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1200
1203
  }
1201
1204
  }
1202
1205
  if (tagName === "select") {
1203
1206
  const options = element.querySelectorAll("option");
1204
1207
  options.forEach((option) => {
1205
1208
  const optionText = option.textContent?.trim();
1206
- if (optionText) tokens.push(optionText);
1209
+ if (optionText) tokens.push(normalizeMarkupPlaceholders(optionText));
1207
1210
  });
1208
1211
  }
1209
1212
  }
@@ -1344,7 +1347,7 @@ var Translate = class {
1344
1347
  }
1345
1348
  if (isEmpty(node.nodeValue?.trim())) return;
1346
1349
  if (isEmpty(node.originalNodeValue)) {
1347
- node.originalNodeValue = node.nodeValue;
1350
+ node.originalNodeValue = normalizeMarkupPlaceholders(node.nodeValue);
1348
1351
  }
1349
1352
  const contentToken = node.originalNodeValue?.replace(/\s+/g, " ").trim();
1350
1353
  if (!contentToken) return;
@@ -1393,7 +1396,7 @@ var Translate = class {
1393
1396
  const optionEl = option;
1394
1397
  if (!optionEl.originalAttributes) optionEl.originalAttributes = {};
1395
1398
  if (optionEl.originalAttributes["textContent"] === void 0) {
1396
- optionEl.originalAttributes["textContent"] = option.textContent || "";
1399
+ optionEl.originalAttributes["textContent"] = normalizeMarkupPlaceholders(option.textContent || "");
1397
1400
  }
1398
1401
  const originalText = optionEl.originalAttributes["textContent"].trim();
1399
1402
  if (originalText) {
@@ -1407,7 +1410,7 @@ var Translate = class {
1407
1410
  const currentValue = element.getAttribute(attr);
1408
1411
  if (!currentValue) return;
1409
1412
  if (element.originalAttributes[attr] === void 0) {
1410
- element.originalAttributes[attr] = currentValue;
1413
+ element.originalAttributes[attr] = normalizeMarkupPlaceholders(currentValue);
1411
1414
  }
1412
1415
  const originalValue = element.originalAttributes[attr].trim();
1413
1416
  if (!originalValue) return;
@@ -1426,7 +1429,7 @@ var SENT_SCAN = /\uE000(\d+)\uE001|\uE002(\d+)\uE003/g;
1426
1429
  function encodeRichText(root) {
1427
1430
  const slots = [];
1428
1431
  const phrase = _encodeNodes(Array.from(root.childNodes), slots);
1429
- return { phrase: phrase.replace(/\s+/g, " ").trim(), slots };
1432
+ return { phrase: normalizeMarkupPlaceholders(phrase.replace(/\s+/g, " ").trim()), slots };
1430
1433
  }
1431
1434
  function _encodeNodes(nodes, slots) {
1432
1435
  let out = "";
@@ -1565,6 +1568,7 @@ exports.isICU = isICU;
1565
1568
  exports.logger = logger;
1566
1569
  exports.markupTokenValues = markupTokenValues;
1567
1570
  exports.md5 = md5;
1571
+ exports.normalizeMarkupPlaceholders = normalizeMarkupPlaceholders;
1568
1572
  exports.persist = persist;
1569
1573
  exports.reconstitute = reconstitute;
1570
1574
  exports.registerContentBlock = registerContentBlock;