langsys-js-typescript 0.4.0 → 0.4.2

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,30 @@
1
+ ## 0.4.2 - 2026-07-08
2
+
3
+ ### Added
4
+
5
+ - **Debug-mode diagnostic for the `{key}`-in-markup mistake.** Writing `{name}` instead of `%name%` inside `<Translate>`/`<Phrase>` content fails silently — the framework compiler substitutes it before the SDK sees the text, so the base locale still renders correctly and only translated locales break. The SDK can't observe the original braces, but the fingerprint is unmistakable: `params` supplied with no matching placeholder in the captured content. When `debug` is on, that now emits a warning naming the offending key and the exact fix. The check re-runs only when the params key-SET changes, so a ticking counter doesn't spam the console, and it's silent in production.
6
+ - **`findUnusedParamKeys(texts, params)`** — the underlying detector, exported for wrappers that want the same check around their own rendering. Treats both `{key}` and ICU `{key, plural, …}` as used.
7
+
8
+ ## 0.4.1 - 2026-07-08
9
+
10
+ ### Added
11
+
12
+ - **`%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.
13
+ - **`normalizeMarkupPlaceholders(text)`** — the normalizer, exported for framework wrappers that render content-block tokens through their own templating.
14
+
15
+ ## 0.4.0 - 2026-07-08
16
+
17
+ Params interpolation for the `Translate` DOM class — content blocks can now carry runtime values the same way `t()` phrases do.
18
+
19
+ ### Added
20
+
21
+ - **`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.
22
+ - **`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.
23
+
24
+ ### Fixed
25
+
26
+ - Content-block text replacement no longer treats `$`-patterns (`$&`, `` $` ``, `$1`…) in translations or interpolated values as `String.replace` directives — they now render literally.
27
+
1
28
  ## 0.3.0 - 2026-07-03
2
29
 
3
30
  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,53 @@ 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
+
198
+ Writing `{key}` in markup fails *silently* — the base locale still looks right, so the mistake surfaces only once someone switches language. With `debug: true`, the SDK catches it for you: if you pass `params` whose keys have no matching placeholder in the captured content, it warns and names the fix.
199
+
200
+ ```
201
+ Langsys Warning <Translate> received params with no matching placeholder in its
202
+ content: %count%. If you wrote {count} in markup, the framework compiler
203
+ (Svelte/JSX) substituted it before Langsys saw the text — write %count% instead.
204
+ ```
205
+
206
+ The check runs whenever the params key-set changes, so a ticking `count` won't spam the console, and it's silent in production.
207
+
161
208
  ## Server-Side Rendering
162
209
 
163
210
  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
@@ -400,6 +400,8 @@ declare class Translate {
400
400
  private lastTranslatedLocale;
401
401
  private custom_id;
402
402
  private unsubscribeLocale;
403
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
404
+ private checkedParamKeys;
403
405
  constructor(element: HTMLElement, options?: TranslateOptions);
404
406
  /** Update interpolation params (e.g. a changed count) and re-render. Mirrors `Phrase.setParams`. */
405
407
  setParams(params: Record<string, ParamPrimitive> | undefined): void;
@@ -410,6 +412,12 @@ declare class Translate {
410
412
  private handleContentBlock;
411
413
  private translate;
412
414
  private getTranslation;
415
+ /**
416
+ * Debug-time check that every supplied param has a placeholder to land in.
417
+ * Re-runs only when the key SET changes — a changing `count` value must not
418
+ * re-warn on every render.
419
+ */
420
+ private checkParams;
413
421
  /**
414
422
  * Interpolate `{name}`-style params into a resolved text, matching `t()`:
415
423
  * unknown keys fall through untouched, and the untranslated fallback is
@@ -448,12 +456,20 @@ declare class Phrase {
448
456
  private slots;
449
457
  private unsubscribers;
450
458
  private ready;
459
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
460
+ private checkedParamKeys;
451
461
  constructor(host: HTMLElement, options?: PhraseOptions);
452
462
  /** Update interpolation params (e.g. a changed count) and re-render. */
453
463
  setParams(params: Record<string, unknown>): void;
454
464
  /** Stop reacting to locale/translation changes. Safe to call multiple times. */
455
465
  destroy(): void;
456
466
  private _init;
467
+ /**
468
+ * Debug-time check that every supplied param has a placeholder to land in.
469
+ * Re-runs only when the key SET changes — a changing `n` value must not
470
+ * re-warn on every render.
471
+ */
472
+ private _checkParams;
457
473
  private _render;
458
474
  }
459
475
 
@@ -670,6 +686,30 @@ declare function md5(inputString: string): string;
670
686
  * to decide whether to render a plural-slot editor vs. a single text field.
671
687
  */
672
688
  declare function isICU(template: string): boolean;
689
+ /**
690
+ * Normalize `%name%` markup placeholders to canonical `{name}`.
691
+ *
692
+ * Framework compilers consume bare `{name}` written in markup before the DOM
693
+ * walker ever sees it (Svelte compiles it to an expression; JSX evaluates it),
694
+ * so DOM content accepts `%name%` as a collision-free authoring escape.
695
+ * Normalization runs at every markup capture boundary (content-block
696
+ * tokenizer, `Translate` original-value snapshots, `Phrase` encoding), so the
697
+ * catalog, the wire, and translators only ever see the canonical `{name}`
698
+ * form — and plain `{name}` keeps working for vanilla-HTML authors.
699
+ *
700
+ * Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` in
701
+ * prose ("20% off", "50% to 60%") can't match. `t()` phrases are JS strings
702
+ * with no compiler collision and stay `{name}`-only.
703
+ */
704
+ declare function normalizeMarkupPlaceholders(text: string): string;
705
+ /**
706
+ * Params keys that have NO matching placeholder in `texts`.
707
+ *
708
+ * Texts are expected in canonical form (post-`normalizeMarkupPlaceholders`),
709
+ * so a key counts as used when it appears as `{key}` or as the argument of an
710
+ * ICU slot (`{key, plural, …}`). Surrounding whitespace is tolerated.
711
+ */
712
+ declare function findUnusedParamKeys(texts: string[], params: Record<string, unknown> | undefined): string[];
673
713
  /**
674
714
  * Substitute placeholders in a translated string with values from `params`.
675
715
  *
@@ -738,4 +778,4 @@ interface iProject {
738
778
  declare const t: TFunction;
739
779
  declare const tSignal: Signal<TFunction>;
740
780
 
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 };
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 };
package/dist/index.d.ts CHANGED
@@ -400,6 +400,8 @@ declare class Translate {
400
400
  private lastTranslatedLocale;
401
401
  private custom_id;
402
402
  private unsubscribeLocale;
403
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
404
+ private checkedParamKeys;
403
405
  constructor(element: HTMLElement, options?: TranslateOptions);
404
406
  /** Update interpolation params (e.g. a changed count) and re-render. Mirrors `Phrase.setParams`. */
405
407
  setParams(params: Record<string, ParamPrimitive> | undefined): void;
@@ -410,6 +412,12 @@ declare class Translate {
410
412
  private handleContentBlock;
411
413
  private translate;
412
414
  private getTranslation;
415
+ /**
416
+ * Debug-time check that every supplied param has a placeholder to land in.
417
+ * Re-runs only when the key SET changes — a changing `count` value must not
418
+ * re-warn on every render.
419
+ */
420
+ private checkParams;
413
421
  /**
414
422
  * Interpolate `{name}`-style params into a resolved text, matching `t()`:
415
423
  * unknown keys fall through untouched, and the untranslated fallback is
@@ -448,12 +456,20 @@ declare class Phrase {
448
456
  private slots;
449
457
  private unsubscribers;
450
458
  private ready;
459
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
460
+ private checkedParamKeys;
451
461
  constructor(host: HTMLElement, options?: PhraseOptions);
452
462
  /** Update interpolation params (e.g. a changed count) and re-render. */
453
463
  setParams(params: Record<string, unknown>): void;
454
464
  /** Stop reacting to locale/translation changes. Safe to call multiple times. */
455
465
  destroy(): void;
456
466
  private _init;
467
+ /**
468
+ * Debug-time check that every supplied param has a placeholder to land in.
469
+ * Re-runs only when the key SET changes — a changing `n` value must not
470
+ * re-warn on every render.
471
+ */
472
+ private _checkParams;
457
473
  private _render;
458
474
  }
459
475
 
@@ -670,6 +686,30 @@ declare function md5(inputString: string): string;
670
686
  * to decide whether to render a plural-slot editor vs. a single text field.
671
687
  */
672
688
  declare function isICU(template: string): boolean;
689
+ /**
690
+ * Normalize `%name%` markup placeholders to canonical `{name}`.
691
+ *
692
+ * Framework compilers consume bare `{name}` written in markup before the DOM
693
+ * walker ever sees it (Svelte compiles it to an expression; JSX evaluates it),
694
+ * so DOM content accepts `%name%` as a collision-free authoring escape.
695
+ * Normalization runs at every markup capture boundary (content-block
696
+ * tokenizer, `Translate` original-value snapshots, `Phrase` encoding), so the
697
+ * catalog, the wire, and translators only ever see the canonical `{name}`
698
+ * form — and plain `{name}` keeps working for vanilla-HTML authors.
699
+ *
700
+ * Keys must be identifiers (`[A-Za-z_][A-Za-z0-9_]*`), so literal `%` in
701
+ * prose ("20% off", "50% to 60%") can't match. `t()` phrases are JS strings
702
+ * with no compiler collision and stay `{name}`-only.
703
+ */
704
+ declare function normalizeMarkupPlaceholders(text: string): string;
705
+ /**
706
+ * Params keys that have NO matching placeholder in `texts`.
707
+ *
708
+ * Texts are expected in canonical form (post-`normalizeMarkupPlaceholders`),
709
+ * so a key counts as used when it appears as `{key}` or as the argument of an
710
+ * ICU slot (`{key, plural, …}`). Surrounding whitespace is tolerated.
711
+ */
712
+ declare function findUnusedParamKeys(texts: string[], params: Record<string, unknown> | undefined): string[];
673
713
  /**
674
714
  * Substitute placeholders in a translated string with values from `params`.
675
715
  *
@@ -738,4 +778,4 @@ interface iProject {
738
778
  declare const t: TFunction;
739
779
  declare const tSignal: Signal<TFunction>;
740
780
 
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 };
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 };
package/dist/index.js CHANGED
@@ -265,6 +265,28 @@ 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
+ }
271
+ function escapeForRegExp(key) {
272
+ return key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
273
+ }
274
+ function findUnusedParamKeys(texts, params) {
275
+ if (!params) return [];
276
+ const keys = Object.keys(params);
277
+ if (!keys.length || !texts.length) return keys;
278
+ const haystack = texts.join("\0");
279
+ return keys.filter((key) => !new RegExp(`\\{\\s*${escapeForRegExp(key)}\\s*[,}]`).test(haystack));
280
+ }
281
+ function warnUnmatchedParams(source, texts, params, context) {
282
+ if (!logger.debugEnabled) return;
283
+ const unused = findUnusedParamKeys(texts, params);
284
+ if (!unused.length) return;
285
+ const keyList = unused.map((key) => `%${key}%`).join(", ");
286
+ logger.warn(
287
+ `${source} received params with no matching placeholder in its content: ${keyList}. If you wrote {${unused[0]}} in markup, the framework compiler (Svelte/JSX) substituted it before Langsys saw the text \u2014 write %${unused[0]}% instead.` + (context ? ` [${context}]` : "")
288
+ );
289
+ }
268
290
  function interpolate(template, params, locale) {
269
291
  if (isICU(template)) {
270
292
  try {
@@ -1141,7 +1163,7 @@ function tokenizeElement(element) {
1141
1163
  const tokens = [];
1142
1164
  const clone = element.cloneNode(true);
1143
1165
  _walkForTokens(element, Array.from(clone.childNodes), tokens, []);
1144
- return { tokens, content: clone.outerHTML };
1166
+ return { tokens, content: normalizeMarkupPlaceholders(clone.outerHTML) };
1145
1167
  }
1146
1168
  function _writeKnownContentBlockToCache(category, customId) {
1147
1169
  const lookupCat = category || "__uncategorized__";
@@ -1171,7 +1193,7 @@ function _walkForTokens(liveRoot, cloneNodes, tokens, indices) {
1171
1193
  }
1172
1194
  const contentToken = node.nodeValue?.replace(/\s+/g, " ").trim();
1173
1195
  if (node.nodeType === Node.TEXT_NODE && contentToken) {
1174
- tokens.push(contentToken);
1196
+ tokens.push(normalizeMarkupPlaceholders(contentToken));
1175
1197
  return;
1176
1198
  }
1177
1199
  if (!node.hasChildNodes()) return;
@@ -1186,24 +1208,24 @@ function _tokenizeAttributes(element, tokens) {
1186
1208
  }
1187
1209
  for (const attr of TRANSLATABLE_ATTRIBUTES) {
1188
1210
  const value = element.getAttribute(attr)?.trim();
1189
- if (value) tokens.push(value);
1211
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1190
1212
  }
1191
1213
  if (VALUE_TRANSLATABLE_ELEMENTS.includes(tagName)) {
1192
1214
  const value = element.getAttribute("value")?.trim();
1193
- if (value) tokens.push(value);
1215
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1194
1216
  }
1195
1217
  if (tagName === "input") {
1196
1218
  const inputType = element.getAttribute("type")?.toLowerCase();
1197
1219
  if (inputType && VALUE_TRANSLATABLE_INPUT_TYPES.includes(inputType)) {
1198
1220
  const value = element.getAttribute("value")?.trim();
1199
- if (value) tokens.push(value);
1221
+ if (value) tokens.push(normalizeMarkupPlaceholders(value));
1200
1222
  }
1201
1223
  }
1202
1224
  if (tagName === "select") {
1203
1225
  const options = element.querySelectorAll("option");
1204
1226
  options.forEach((option) => {
1205
1227
  const optionText = option.textContent?.trim();
1206
- if (optionText) tokens.push(optionText);
1228
+ if (optionText) tokens.push(normalizeMarkupPlaceholders(optionText));
1207
1229
  });
1208
1230
  }
1209
1231
  }
@@ -1245,6 +1267,8 @@ var Translate = class {
1245
1267
  this.isTokenizing = false;
1246
1268
  this.lastTranslatedLocale = "";
1247
1269
  this.unsubscribeLocale = null;
1270
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
1271
+ this.checkedParamKeys = null;
1248
1272
  this.element = element;
1249
1273
  this.options = { ...options };
1250
1274
  this.custom_id = options.custom_id || "";
@@ -1256,6 +1280,7 @@ var Translate = class {
1256
1280
  /** Update interpolation params (e.g. a changed count) and re-render. Mirrors `Phrase.setParams`. */
1257
1281
  setParams(params) {
1258
1282
  this.options.params = params;
1283
+ this.checkParams();
1259
1284
  if (!this.parseComplete) return;
1260
1285
  this.lastTranslatedLocale = "";
1261
1286
  const locale = currentlyLoadedLocale.get();
@@ -1289,6 +1314,7 @@ var Translate = class {
1289
1314
  this.isTokenizing = true;
1290
1315
  const { tokens, content } = tokenizeElement(this.element);
1291
1316
  this.tokens = tokens;
1317
+ this.checkParams();
1292
1318
  const { category = "" } = this.options;
1293
1319
  if (this.tokens.length === 1) {
1294
1320
  this.element.innerText = this.applyParams(LangsysApp.Translations.t(this.tokens[0], category));
@@ -1344,7 +1370,7 @@ var Translate = class {
1344
1370
  }
1345
1371
  if (isEmpty(node.nodeValue?.trim())) return;
1346
1372
  if (isEmpty(node.originalNodeValue)) {
1347
- node.originalNodeValue = node.nodeValue;
1373
+ node.originalNodeValue = normalizeMarkupPlaceholders(node.nodeValue);
1348
1374
  }
1349
1375
  const contentToken = node.originalNodeValue?.replace(/\s+/g, " ").trim();
1350
1376
  if (!contentToken) return;
@@ -1362,6 +1388,19 @@ var Translate = class {
1362
1388
  const { category = "" } = this.options;
1363
1389
  return LangsysApp.Translations.lookupContent(category, this.custom_id, token);
1364
1390
  }
1391
+ /**
1392
+ * Debug-time check that every supplied param has a placeholder to land in.
1393
+ * Re-runs only when the key SET changes — a changing `count` value must not
1394
+ * re-warn on every render.
1395
+ */
1396
+ checkParams() {
1397
+ if (!this.tokens.length) return;
1398
+ const { params, category = "" } = this.options;
1399
+ const signature = params ? Object.keys(params).sort().join(",") : "";
1400
+ if (signature === this.checkedParamKeys) return;
1401
+ this.checkedParamKeys = signature;
1402
+ warnUnmatchedParams("<Translate>", this.tokens, params, category || void 0);
1403
+ }
1365
1404
  /**
1366
1405
  * Interpolate `{name}`-style params into a resolved text, matching `t()`:
1367
1406
  * unknown keys fall through untouched, and the untranslated fallback is
@@ -1393,7 +1432,7 @@ var Translate = class {
1393
1432
  const optionEl = option;
1394
1433
  if (!optionEl.originalAttributes) optionEl.originalAttributes = {};
1395
1434
  if (optionEl.originalAttributes["textContent"] === void 0) {
1396
- optionEl.originalAttributes["textContent"] = option.textContent || "";
1435
+ optionEl.originalAttributes["textContent"] = normalizeMarkupPlaceholders(option.textContent || "");
1397
1436
  }
1398
1437
  const originalText = optionEl.originalAttributes["textContent"].trim();
1399
1438
  if (originalText) {
@@ -1407,7 +1446,7 @@ var Translate = class {
1407
1446
  const currentValue = element.getAttribute(attr);
1408
1447
  if (!currentValue) return;
1409
1448
  if (element.originalAttributes[attr] === void 0) {
1410
- element.originalAttributes[attr] = currentValue;
1449
+ element.originalAttributes[attr] = normalizeMarkupPlaceholders(currentValue);
1411
1450
  }
1412
1451
  const originalValue = element.originalAttributes[attr].trim();
1413
1452
  if (!originalValue) return;
@@ -1426,7 +1465,7 @@ var SENT_SCAN = /\uE000(\d+)\uE001|\uE002(\d+)\uE003/g;
1426
1465
  function encodeRichText(root) {
1427
1466
  const slots = [];
1428
1467
  const phrase = _encodeNodes(Array.from(root.childNodes), slots);
1429
- return { phrase: phrase.replace(/\s+/g, " ").trim(), slots };
1468
+ return { phrase: normalizeMarkupPlaceholders(phrase.replace(/\s+/g, " ").trim()), slots };
1430
1469
  }
1431
1470
  function _encodeNodes(nodes, slots) {
1432
1471
  let out = "";
@@ -1496,6 +1535,8 @@ var Phrase = class {
1496
1535
  this.slots = [];
1497
1536
  this.unsubscribers = [];
1498
1537
  this.ready = false;
1538
+ /** Sorted params key-set already checked, so value-only updates don't re-warn. */
1539
+ this.checkedParamKeys = null;
1499
1540
  this.host = host;
1500
1541
  this.category = options.category ?? "";
1501
1542
  this.params = options.params ?? {};
@@ -1509,7 +1550,9 @@ var Phrase = class {
1509
1550
  /** Update interpolation params (e.g. a changed count) and re-render. */
1510
1551
  setParams(params) {
1511
1552
  this.params = params ?? {};
1512
- if (this.ready) this._render();
1553
+ this._checkParams();
1554
+ if (!this.ready) return;
1555
+ this._render();
1513
1556
  }
1514
1557
  /** Stop reacting to locale/translation changes. Safe to call multiple times. */
1515
1558
  destroy() {
@@ -1524,11 +1567,24 @@ var Phrase = class {
1524
1567
  const { phrase, slots } = encodeRichText(this.host);
1525
1568
  this.phrase = phrase;
1526
1569
  this.slots = slots;
1570
+ this._checkParams();
1527
1571
  await LangsysApp.Translations.ready();
1528
1572
  LangsysApp.Translations.t(this.phrase, this.category);
1529
1573
  this.ready = true;
1530
1574
  this._render();
1531
1575
  }
1576
+ /**
1577
+ * Debug-time check that every supplied param has a placeholder to land in.
1578
+ * Re-runs only when the key SET changes — a changing `n` value must not
1579
+ * re-warn on every render.
1580
+ */
1581
+ _checkParams() {
1582
+ if (!this.phrase) return;
1583
+ const signature = Object.keys(this.params).sort().join(",");
1584
+ if (signature === this.checkedParamKeys) return;
1585
+ this.checkedParamKeys = signature;
1586
+ warnUnmatchedParams("<Phrase>", [this.phrase], this.params, this.category || void 0);
1587
+ }
1532
1588
  _render() {
1533
1589
  if (!this.phrase) return;
1534
1590
  const raw = LangsysApp.Translations.lookup(this.phrase, this.category) ?? this.phrase;
@@ -1556,6 +1612,7 @@ exports.canonicalizeLocale = canonicalizeLocale;
1556
1612
  exports.createSignal = createSignal;
1557
1613
  exports.currentlyLoadedLocale = currentlyLoadedLocale;
1558
1614
  exports.encodeRichText = encodeRichText;
1615
+ exports.findUnusedParamKeys = findUnusedParamKeys;
1559
1616
  exports.generateCustomId = generateCustomId;
1560
1617
  exports.getValue = getValue;
1561
1618
  exports.interpolate = interpolate;
@@ -1565,6 +1622,7 @@ exports.isICU = isICU;
1565
1622
  exports.logger = logger;
1566
1623
  exports.markupTokenValues = markupTokenValues;
1567
1624
  exports.md5 = md5;
1625
+ exports.normalizeMarkupPlaceholders = normalizeMarkupPlaceholders;
1568
1626
  exports.persist = persist;
1569
1627
  exports.reconstitute = reconstitute;
1570
1628
  exports.registerContentBlock = registerContentBlock;