langsys-js-typescript 0.1.0 → 0.2.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/dist/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { IntlMessageFormat } from 'intl-messageformat';
2
+
1
3
  // src/signal.ts
2
4
  function createSignal(initial) {
3
5
  let value = initial;
@@ -67,7 +69,11 @@ var LangsysAppAPIClass = class {
67
69
  };
68
70
  this.headers = {
69
71
  "Content-Type": "application/json; charset=utf-8",
70
- "x-Authorization": this.config.key
72
+ "x-Authorization": this.config.key,
73
+ // Declare ICU MessageFormat rendering so the server sends raw ICU
74
+ // (plural/select) instead of flat-template downgrades. Pre-ICU
75
+ // builds omit this header and keep receiving flat strings.
76
+ "X-Langsys-Capabilities": "icu"
71
77
  };
72
78
  }
73
79
  setup(config2) {
@@ -209,7 +215,6 @@ var initialTranslations = {
209
215
  };
210
216
  var sTranslations = persist("translations", initialTranslations);
211
217
  var currentlyLoadedLocale = createSignal("");
212
- var contentBlocks = createSignal([]);
213
218
  var config = {
214
219
  projectid: "",
215
220
  key: "",
@@ -218,10 +223,22 @@ var config = {
218
223
  debug: false,
219
224
  key_type: "read"
220
225
  };
221
-
222
- // src/interpolate.ts
223
- function interpolate(template, params) {
224
- return template.replace(/\{([^{}]+)\}/g, (match, rawKey) => {
226
+ function isICU(template) {
227
+ return ICU_PATTERN.test(template);
228
+ }
229
+ var ICU_PATTERN = /\{[^{}]+,\s*(plural|select|selectordinal|number|date|time)\s*,/;
230
+ function interpolate(template, params, locale) {
231
+ if (isICU(template)) {
232
+ try {
233
+ return new IntlMessageFormat(template, locale ?? "en").format(params);
234
+ } catch {
235
+ return simpleInterpolate(template, params);
236
+ }
237
+ }
238
+ return simpleInterpolate(template, params);
239
+ }
240
+ function simpleInterpolate(template, params) {
241
+ return template.replace(/\{([^{},]+)\}/g, (match, rawKey) => {
225
242
  const key = rawKey.trim();
226
243
  if (!(key in params)) return match;
227
244
  const value = params[key];
@@ -245,6 +262,11 @@ var Translations = class {
245
262
  this.timer = null;
246
263
  this.flushScheduled = false;
247
264
  this.isFirstClientRun = true;
265
+ this.readyResolve = () => {
266
+ };
267
+ this.readyPromise = new Promise((resolve) => {
268
+ this.readyResolve = resolve;
269
+ });
248
270
  this.config = config2;
249
271
  this.debug = new Logger(!!config2.debug);
250
272
  this.locale = config2.baseLocale || "";
@@ -253,6 +275,16 @@ var Translations = class {
253
275
  currentlyLoadedLocale.subscribe(() => this.tSignal.set(this.buildTFn()));
254
276
  if (config2.key && config2.projectid) this.setup(config2);
255
277
  }
278
+ /**
279
+ * Resolves once the first `getTranslations` call has completed (or
280
+ * immediately if no fetch is configured). Callers that need to read
281
+ * `sTranslations` before issuing a write — e.g. the `Translate` DOM
282
+ * class deciding whether to POST a content block — should await this
283
+ * to avoid acting on an empty cache during the cold-start race.
284
+ */
285
+ ready() {
286
+ return this.readyPromise;
287
+ }
256
288
  /** Current translation function. Reads fresh state on every call. */
257
289
  get t() {
258
290
  return this.tSignal.get();
@@ -275,8 +307,22 @@ var Translations = class {
275
307
  const value = contentData[token];
276
308
  return typeof value === "string" ? value : null;
277
309
  }
310
+ /**
311
+ * Raw stored translation for a phrase, without interpolation or
312
+ * missing-token registration. Returns null when no (non-empty) translation
313
+ * is cached. Used by the `Phrase` rich-text handler, which needs the
314
+ * un-interpolated template (it supplies its own markup-token values).
315
+ */
316
+ lookup(phrase, category) {
317
+ const cats = sTranslations.get();
318
+ const lookupCat = category || "__uncategorized__";
319
+ const value = cats[lookupCat]?.[phrase];
320
+ return typeof value === "string" && value.length > 0 ? value : null;
321
+ }
278
322
  buildTFn() {
279
- return (category, phrase, ...args) => {
323
+ const fn = ((phrase, ...rest) => {
324
+ const category = typeof rest[0] === "string" ? rest[0] : "";
325
+ const params = typeof rest[0] === "object" ? rest[0] : rest[1];
280
326
  const cats = sTranslations.get();
281
327
  const lookupCat = category || "__uncategorized__";
282
328
  const value = cats[lookupCat]?.[phrase];
@@ -285,12 +331,12 @@ var Translations = class {
285
331
  this.debug.log("TRANSLATION FOUND", [lookupCat, phrase, value]);
286
332
  translated = value;
287
333
  } else {
288
- this.missingToken(lookupCat, phrase);
334
+ this.missingToken(category, phrase);
289
335
  translated = phrase;
290
336
  }
291
- const params = args[0];
292
- return params ? interpolate(translated, params) : translated;
293
- };
337
+ return params ? interpolate(translated, params, currentlyLoadedLocale.get()) : translated;
338
+ });
339
+ return fn;
294
340
  }
295
341
  missingToken(category, token) {
296
342
  if (token === void 0 || token === null) {
@@ -418,11 +464,12 @@ var Translations = class {
418
464
  const currentData = sTranslations.get();
419
465
  this.missingTokens = this.missingTokens.filter((tokenObj) => {
420
466
  tokenObj.projectid = this.config.projectid;
421
- if (tokenObj.category in currentData && tokenObj.token in currentData[tokenObj.category]) {
467
+ const lookupCat = tokenObj.category || "__uncategorized__";
468
+ if (lookupCat in currentData && tokenObj.token in currentData[lookupCat]) {
422
469
  this.debug.log("Missing token already exists! Skipping:", {
423
470
  category: tokenObj.category,
424
471
  token: tokenObj.token,
425
- existing: currentData[tokenObj.category][tokenObj.token]
472
+ existing: currentData[lookupCat][tokenObj.token]
426
473
  });
427
474
  return false;
428
475
  }
@@ -431,8 +478,13 @@ var Translations = class {
431
478
  if (!this.missingTokens.length) return false;
432
479
  this.debug.log("CREATE MISSING TOKENS", this.missingTokens);
433
480
  try {
481
+ const tokensForApi = this.missingTokens.map((tokenObj) => ({
482
+ projectid: tokenObj.projectid,
483
+ token: tokenObj.token,
484
+ category: tokenObj.category || null
485
+ }));
434
486
  const response = await LangsysAppAPI.post("projects/[projectid]/tokens", {
435
- tokens: this.missingTokens
487
+ tokens: tokensForApi
436
488
  });
437
489
  if (!response.status) {
438
490
  if (response.errors) {
@@ -442,14 +494,15 @@ var Translations = class {
442
494
  return false;
443
495
  }
444
496
  this.missingTokens.forEach((tokenObj) => {
445
- if (!currentData[tokenObj.category]) {
446
- currentData[tokenObj.category] = {
447
- __category__: tokenObj.category,
448
- __symbol__: tokenObj.category
497
+ const lookupCat = tokenObj.category || "__uncategorized__";
498
+ if (!currentData[lookupCat]) {
499
+ currentData[lookupCat] = {
500
+ __category__: lookupCat,
501
+ __symbol__: lookupCat
449
502
  };
450
503
  }
451
- currentData[tokenObj.category][tokenObj.token] = tokenObj.token;
452
- currentData[tokenObj.category]["__category__"] = tokenObj.category;
504
+ currentData[lookupCat][tokenObj.token] = tokenObj.token;
505
+ currentData[lookupCat]["__category__"] = lookupCat;
453
506
  });
454
507
  sTranslations.set({ ...currentData });
455
508
  this.missingTokens = [];
@@ -468,6 +521,7 @@ var Translations = class {
468
521
  this.debug.log("GET TRANSLATIONS API RESPONSE", response);
469
522
  if (response.errors) {
470
523
  this.debug.error("Error", response.errors[0]);
524
+ this.readyResolve();
471
525
  return;
472
526
  }
473
527
  const trans = response.data;
@@ -484,6 +538,7 @@ var Translations = class {
484
538
  sTranslations.set(trans);
485
539
  setTimeout(() => currentlyLoadedLocale.set(this.locale), 100);
486
540
  this.lastLoaded[this.locale] = (/* @__PURE__ */ new Date()).getTime() / 1e3;
541
+ this.readyResolve();
487
542
  }
488
543
  };
489
544
 
@@ -513,7 +568,8 @@ var LangsysAppClass = class {
513
568
  }
514
569
  /**
515
570
  * Initialize Langsys. Configuration must include at least `projectid`,
516
- * `key`, and `UserLocaleStore` (any Signal<string>).
571
+ * `key`, and `UserLocaleStore` (anything satisfying `LocaleSource` — see
572
+ * its docstring for framework-specific adapters).
517
573
  */
518
574
  async init(initConfig) {
519
575
  const projectid = initConfig.projectid;
@@ -542,9 +598,9 @@ var LangsysAppClass = class {
542
598
  this.debug.error("LangsysApp.init missing API key in configuration object!");
543
599
  return { status: false, errors: ["Missing API key"] };
544
600
  }
545
- if (!UserLocaleStore?.subscribe) {
601
+ if (!UserLocaleStore?.subscribe || typeof UserLocaleStore.get !== "function") {
546
602
  this.debug.error(
547
- "LangsysApp.init missing UserLocaleStore \u2014 pass a Signal<string> (createSignal('en-us') works if you have nothing of your own)."
603
+ "LangsysApp.init missing UserLocaleStore \u2014 pass any object satisfying LocaleSource (get + subscribe). createSignal('en-us') works if you have nothing of your own."
548
604
  );
549
605
  return { status: false, errors: ["Missing UserLocaleStore"] };
550
606
  }
@@ -908,7 +964,7 @@ function md5(inputString) {
908
964
  return rh(a) + rh(b) + rh(c) + rh(d);
909
965
  }
910
966
 
911
- // src/translate.ts
967
+ // src/content-block.ts
912
968
  var TRANSLATABLE_ATTRIBUTES = [
913
969
  "placeholder",
914
970
  "alt",
@@ -973,9 +1029,141 @@ var SEMANTIC_STYLE_PROPERTIES = [
973
1029
  "list-style-type",
974
1030
  "cursor"
975
1031
  ];
1032
+ function generateCustomId(category, tokens) {
1033
+ return md5(JSON.stringify([category, tokens]));
1034
+ }
1035
+ function isContentBlockKnown(category, customId) {
1036
+ const cats = sTranslations.get();
1037
+ const lookupCat = category || "__uncategorized__";
1038
+ const cbData = cats[lookupCat]?.[customId];
1039
+ return typeof cbData === "object" && cbData !== null;
1040
+ }
1041
+ async function registerContentBlock(contentBlock) {
1042
+ if (config.key_type !== "write") {
1043
+ if (config.debug) {
1044
+ logger.log(`Skipping content block save (API key is ${config.key_type || "unknown"})`);
1045
+ }
1046
+ return { status: true };
1047
+ }
1048
+ try {
1049
+ const response = await LangsysAppAPI.post(
1050
+ "projects/[projectid]/content-blocks",
1051
+ contentBlock
1052
+ );
1053
+ if (!response.status) {
1054
+ logger.error("Could not save content block", response.errors);
1055
+ return { status: false, errors: response.errors };
1056
+ }
1057
+ _writeKnownContentBlockToCache(contentBlock.category, contentBlock.custom_id);
1058
+ return { status: true };
1059
+ } catch (err) {
1060
+ logger.error("Could not save content block", err);
1061
+ return { status: false, errors: [err] };
1062
+ }
1063
+ }
1064
+ function tokenizeElement(element) {
1065
+ const tokens = [];
1066
+ const clone = element.cloneNode(true);
1067
+ _walkForTokens(element, Array.from(clone.childNodes), tokens, []);
1068
+ return { tokens, content: clone.outerHTML };
1069
+ }
1070
+ function _writeKnownContentBlockToCache(category, customId) {
1071
+ const lookupCat = category || "__uncategorized__";
1072
+ sTranslations.update((current) => {
1073
+ if (!current[lookupCat]) {
1074
+ current[lookupCat] = {
1075
+ __category__: lookupCat,
1076
+ __symbol__: lookupCat
1077
+ };
1078
+ }
1079
+ current[lookupCat][customId] = {};
1080
+ return { ...current };
1081
+ });
1082
+ }
1083
+ function _walkForTokens(liveRoot, cloneNodes, tokens, indices) {
1084
+ cloneNodes.forEach((node, index) => {
1085
+ if (node.nodeType === Node.ELEMENT_NODE) {
1086
+ const el = node;
1087
+ if (el.getAttribute("translate") === "no") return;
1088
+ if (el.hasAttribute("data-ls-phrase")) return;
1089
+ }
1090
+ if (node.hasChildNodes()) {
1091
+ _applyStylesToClone(liveRoot, node, [...indices, index]);
1092
+ }
1093
+ if (node.nodeType === Node.ELEMENT_NODE) {
1094
+ _tokenizeAttributes(node, tokens);
1095
+ }
1096
+ const contentToken = node.nodeValue?.replace(/\s+/g, " ").trim();
1097
+ if (node.nodeType === Node.TEXT_NODE && contentToken) {
1098
+ tokens.push(contentToken);
1099
+ return;
1100
+ }
1101
+ if (!node.hasChildNodes()) return;
1102
+ _walkForTokens(liveRoot, Array.from(node.childNodes), tokens, [...indices, index]);
1103
+ });
1104
+ }
1105
+ function _tokenizeAttributes(element, tokens) {
1106
+ const tagName = element.tagName.toLowerCase();
1107
+ if (tagName === "img") {
1108
+ const img = element;
1109
+ if (img.src) element.setAttribute("src", img.src);
1110
+ }
1111
+ for (const attr of TRANSLATABLE_ATTRIBUTES) {
1112
+ const value = element.getAttribute(attr)?.trim();
1113
+ if (value) tokens.push(value);
1114
+ }
1115
+ if (VALUE_TRANSLATABLE_ELEMENTS.includes(tagName)) {
1116
+ const value = element.getAttribute("value")?.trim();
1117
+ if (value) tokens.push(value);
1118
+ }
1119
+ if (tagName === "input") {
1120
+ const inputType = element.getAttribute("type")?.toLowerCase();
1121
+ if (inputType && VALUE_TRANSLATABLE_INPUT_TYPES.includes(inputType)) {
1122
+ const value = element.getAttribute("value")?.trim();
1123
+ if (value) tokens.push(value);
1124
+ }
1125
+ }
1126
+ if (tagName === "select") {
1127
+ const options = element.querySelectorAll("option");
1128
+ options.forEach((option) => {
1129
+ const optionText = option.textContent?.trim();
1130
+ if (optionText) tokens.push(optionText);
1131
+ });
1132
+ }
1133
+ }
1134
+ function _applyStylesToClone(liveRoot, cloneNode, indices) {
1135
+ if (typeof window === "undefined") return;
1136
+ const domNode = _findNode(Array.from(liveRoot.childNodes), indices);
1137
+ if (!domNode) return;
1138
+ const tagName = domNode.tagName.toLowerCase();
1139
+ const reference = document.createElement(tagName);
1140
+ reference.style.visibility = "hidden";
1141
+ reference.style.position = "absolute";
1142
+ document.body.appendChild(reference);
1143
+ const computed = window.getComputedStyle(domNode);
1144
+ const defaults = window.getComputedStyle(reference);
1145
+ for (const prop of SEMANTIC_STYLE_PROPERTIES) {
1146
+ const value = computed.getPropertyValue(prop);
1147
+ const defaultValue = defaults.getPropertyValue(prop);
1148
+ if (value && value !== defaultValue) {
1149
+ cloneNode.style.setProperty(prop, value);
1150
+ }
1151
+ }
1152
+ document.body.removeChild(reference);
1153
+ cloneNode.removeAttribute("class");
1154
+ }
1155
+ function _findNode(nodes, indices) {
1156
+ let currentNode = nodes[indices[0]];
1157
+ for (let i = 1; i < indices.length; i++) {
1158
+ if (!currentNode || !currentNode.hasChildNodes()) return void 0;
1159
+ currentNode = Array.from(currentNode.childNodes)[indices[i]];
1160
+ }
1161
+ return currentNode;
1162
+ }
1163
+
1164
+ // src/translate.ts
976
1165
  var Translate = class {
977
1166
  constructor(element, options = {}) {
978
- this.contentClone = null;
979
1167
  this.tokens = [];
980
1168
  this.parseComplete = false;
981
1169
  this.isTokenizing = false;
@@ -1001,7 +1189,7 @@ var Translate = class {
1001
1189
  if (this.lastTranslatedLocale === currentLocale) return;
1002
1190
  const { category = "" } = this.options;
1003
1191
  if (this.tokens.length === 1) {
1004
- this.element.innerText = LangsysApp.Translations.t(category, this.tokens[0]);
1192
+ this.element.innerText = LangsysApp.Translations.t(this.tokens[0], category);
1005
1193
  } else {
1006
1194
  this.translate(Array.from(this.element.childNodes));
1007
1195
  this.lastTranslatedLocale = currentLocale;
@@ -1015,18 +1203,17 @@ var Translate = class {
1015
1203
  return false;
1016
1204
  }
1017
1205
  this.isTokenizing = true;
1018
- this.contentClone = this.element.cloneNode(true);
1019
- const nodes = Array.from(this.contentClone.childNodes || []);
1020
- this.tokenize(nodes);
1206
+ const { tokens, content } = tokenizeElement(this.element);
1207
+ this.tokens = tokens;
1021
1208
  const { category = "" } = this.options;
1022
1209
  if (this.tokens.length === 1) {
1023
- this.element.innerText = LangsysApp.Translations.t(category, this.tokens[0]);
1210
+ this.element.innerText = LangsysApp.Translations.t(this.tokens[0], category);
1024
1211
  } else {
1025
1212
  const contentBlock = {
1026
1213
  custom_id: "",
1027
1214
  category,
1028
1215
  label: this.options.label,
1029
- content: this.contentClone.outerHTML,
1216
+ content,
1030
1217
  tokens: this.tokens
1031
1218
  };
1032
1219
  await this.handleContentBlock(contentBlock);
@@ -1036,85 +1223,23 @@ var Translate = class {
1036
1223
  return true;
1037
1224
  }
1038
1225
  async handleContentBlock(contentBlock) {
1039
- if (isEmpty(this.custom_id)) this.custom_id = this.generateCustomId(contentBlock);
1226
+ if (isEmpty(this.custom_id)) {
1227
+ this.custom_id = generateCustomId(contentBlock.category, contentBlock.tokens);
1228
+ }
1040
1229
  contentBlock.custom_id = this.custom_id;
1041
- const current = contentBlocks.get();
1042
- const existing = current.find((b) => b.custom_id === this.custom_id);
1043
- if (!existing) {
1044
- if (config.key_type === "write") {
1045
- LangsysAppAPI.post("projects/[projectid]/content-blocks", contentBlock).then((response) => {
1046
- if (!response.status) {
1047
- logger.error("Could not save content block", response.errors);
1048
- } else {
1049
- contentBlocks.update((blocks) => [...blocks, contentBlock]);
1050
- }
1051
- }).catch((err) => logger.error("Could not save content block", err));
1052
- } else if (config.debug) {
1053
- logger.log(`Skipping content block save (API key is ${config.key_type || "unknown"})`);
1054
- }
1230
+ await LangsysApp.Translations.ready();
1231
+ if (isContentBlockKnown(contentBlock.category, this.custom_id)) {
1055
1232
  if (this.tokens.length > 1 && this.element) {
1056
1233
  this.translate(Array.from(this.element.childNodes));
1057
1234
  this.lastTranslatedLocale = currentlyLoadedLocale.get();
1058
1235
  }
1236
+ return;
1059
1237
  }
1060
- }
1061
- tokenize(nodes, indices = []) {
1062
- nodes.forEach((node, index) => {
1063
- if (node?.nodeType === Node.ELEMENT_NODE) {
1064
- const element = node;
1065
- if (element.getAttribute("translate") === "no") return;
1066
- }
1067
- if (node?.hasChildNodes()) {
1068
- this.applyStylesToNode(node, [...indices, index]);
1069
- }
1070
- if (node?.nodeType === Node.ELEMENT_NODE) {
1071
- this.tokenizeAttributes(node);
1072
- }
1073
- const contentToken = node.nodeValue?.replace(/\s+/g, " ").trim();
1074
- if (node?.nodeType === Node.TEXT_NODE && contentToken) {
1075
- this.tokens.push(contentToken);
1076
- return;
1077
- }
1078
- if (!node?.hasChildNodes()) return;
1079
- this.tokenize(Array.from(node.childNodes), [...indices, index]);
1080
- });
1081
- }
1082
- tokenizeAttributes(element) {
1083
- const tagName = element.tagName.toLowerCase();
1084
- if (tagName === "img") {
1085
- const img = element;
1086
- if (img.src) element.setAttribute("src", img.src);
1087
- }
1088
- for (const attr of TRANSLATABLE_ATTRIBUTES) {
1089
- const value = element.getAttribute(attr)?.trim();
1090
- if (value) this.tokens.push(value);
1091
- }
1092
- if (VALUE_TRANSLATABLE_ELEMENTS.includes(tagName)) {
1093
- const value = element.getAttribute("value")?.trim();
1094
- if (value) this.tokens.push(value);
1095
- }
1096
- if (tagName === "input") {
1097
- const inputType = element.getAttribute("type")?.toLowerCase();
1098
- if (inputType && VALUE_TRANSLATABLE_INPUT_TYPES.includes(inputType)) {
1099
- const value = element.getAttribute("value")?.trim();
1100
- if (value) this.tokens.push(value);
1101
- }
1102
- }
1103
- if (tagName === "select") {
1104
- const options = element.querySelectorAll("option");
1105
- options.forEach((option) => {
1106
- const optionText = option.textContent?.trim();
1107
- if (optionText) this.tokens.push(optionText);
1108
- });
1109
- }
1110
- }
1111
- findNode(nodes, indices) {
1112
- let currentNode = nodes[indices[0]];
1113
- for (let i = 1; i < indices.length; i++) {
1114
- if (!currentNode || !currentNode.hasChildNodes()) return void 0;
1115
- currentNode = Array.from(currentNode.childNodes)[indices[i]];
1238
+ void registerContentBlock(contentBlock);
1239
+ if (this.tokens.length > 1 && this.element) {
1240
+ this.translate(Array.from(this.element.childNodes));
1241
+ this.lastTranslatedLocale = currentlyLoadedLocale.get();
1116
1242
  }
1117
- return currentNode;
1118
1243
  }
1119
1244
  translate(nodes) {
1120
1245
  const currentLocale = currentlyLoadedLocale.get();
@@ -1125,6 +1250,7 @@ var Translate = class {
1125
1250
  if (node?.nodeType === Node.ELEMENT_NODE) {
1126
1251
  const element = node;
1127
1252
  if (element.getAttribute("translate") === "no") return;
1253
+ if (element.hasAttribute("data-ls-phrase")) return;
1128
1254
  this.translateAttributes(node);
1129
1255
  }
1130
1256
  if (node?.nodeType !== Node.TEXT_NODE) {
@@ -1194,38 +1320,136 @@ var Translate = class {
1194
1320
  if (translation) element.setAttribute(attr, translation);
1195
1321
  else element.setAttribute(attr, element.originalAttributes[attr]);
1196
1322
  }
1197
- generateCustomId(contentBlock) {
1198
- return md5(`${contentBlock.category}-${contentBlock.tokens.join("-")}`);
1199
- }
1200
- applyStylesToNode(node, indices) {
1201
- if (!this.element || typeof window === "undefined") return;
1202
- const domNode = this.findNode(Array.from(this.element.childNodes), indices);
1203
- if (!domNode) return;
1204
- const tagName = domNode.tagName.toLowerCase();
1205
- const reference = document.createElement(tagName);
1206
- reference.style.visibility = "hidden";
1207
- reference.style.position = "absolute";
1208
- document.body.appendChild(reference);
1209
- const computed = window.getComputedStyle(domNode);
1210
- const defaults = window.getComputedStyle(reference);
1211
- for (const prop of SEMANTIC_STYLE_PROPERTIES) {
1212
- const value = computed.getPropertyValue(prop);
1213
- const defaultValue = defaults.getPropertyValue(prop);
1214
- if (value && value !== defaultValue) {
1215
- node.style.setProperty(prop, value);
1323
+ };
1324
+
1325
+ // src/richtext.ts
1326
+ var SENT_OPEN_START = String.fromCharCode(57344);
1327
+ var SENT_OPEN_END = String.fromCharCode(57345);
1328
+ var SENT_CLOSE_START = String.fromCharCode(57346);
1329
+ var SENT_CLOSE_END = String.fromCharCode(57347);
1330
+ var SENT_SCAN = /\uE000(\d+)\uE001|\uE002(\d+)\uE003/g;
1331
+ function encodeRichText(root) {
1332
+ const slots = [];
1333
+ const phrase = _encodeNodes(Array.from(root.childNodes), slots);
1334
+ return { phrase: phrase.replace(/\s+/g, " ").trim(), slots };
1335
+ }
1336
+ function _encodeNodes(nodes, slots) {
1337
+ let out = "";
1338
+ for (const node of nodes) {
1339
+ if (node.nodeType === Node.TEXT_NODE) {
1340
+ out += node.nodeValue ?? "";
1341
+ continue;
1342
+ }
1343
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
1344
+ const element = node;
1345
+ const index = slots.length;
1346
+ slots.push({ template: element.cloneNode(false) });
1347
+ out += `{m${index}o}` + _encodeNodes(Array.from(element.childNodes), slots) + `{m${index}c}`;
1348
+ }
1349
+ return out;
1350
+ }
1351
+ function markupTokenValues(slotCount) {
1352
+ const values = {};
1353
+ for (let i = 0; i < slotCount; i++) {
1354
+ values[`m${i}o`] = `${SENT_OPEN_START}${i}${SENT_OPEN_END}`;
1355
+ values[`m${i}c`] = `${SENT_CLOSE_START}${i}${SENT_CLOSE_END}`;
1356
+ }
1357
+ return values;
1358
+ }
1359
+ function reconstitute(resolved, slots, doc = document) {
1360
+ const root = doc.createDocumentFragment();
1361
+ const stack = [root];
1362
+ const top = () => stack[stack.length - 1];
1363
+ let lastIndex = 0;
1364
+ let match;
1365
+ SENT_SCAN.lastIndex = 0;
1366
+ try {
1367
+ while ((match = SENT_SCAN.exec(resolved)) !== null) {
1368
+ const text = resolved.slice(lastIndex, match.index);
1369
+ if (text) top().appendChild(doc.createTextNode(text));
1370
+ lastIndex = SENT_SCAN.lastIndex;
1371
+ const isOpen = match[1] !== void 0;
1372
+ const slotIndex = Number(isOpen ? match[1] : match[2]);
1373
+ if (isOpen) {
1374
+ const slot = slots[slotIndex];
1375
+ if (!slot) throw new Error("unknown markup slot");
1376
+ const el = slot.template.cloneNode(false);
1377
+ top().appendChild(el);
1378
+ stack.push(el);
1379
+ } else {
1380
+ if (stack.length <= 1) throw new Error("unbalanced close");
1381
+ stack.pop();
1216
1382
  }
1217
1383
  }
1218
- document.body.removeChild(reference);
1219
- node.removeAttribute("class");
1384
+ const tail = resolved.slice(lastIndex);
1385
+ if (tail) top().appendChild(doc.createTextNode(tail));
1386
+ if (stack.length !== 1) throw new Error("unbalanced open");
1387
+ } catch {
1388
+ return [doc.createTextNode(stripSentinels(resolved))];
1389
+ }
1390
+ return Array.from(root.childNodes);
1391
+ }
1392
+ function stripSentinels(value) {
1393
+ return value.replace(SENT_SCAN, "");
1394
+ }
1395
+
1396
+ // src/phrase.ts
1397
+ var PHRASE_MARKER_ATTR = "data-ls-phrase";
1398
+ var Phrase = class {
1399
+ constructor(host, options = {}) {
1400
+ this.phrase = "";
1401
+ this.slots = [];
1402
+ this.unsubscribers = [];
1403
+ this.ready = false;
1404
+ this.host = host;
1405
+ this.category = options.category ?? "";
1406
+ this.params = options.params ?? {};
1407
+ void this._init();
1408
+ const rerender = () => {
1409
+ if (this.ready) this._render();
1410
+ };
1411
+ this.unsubscribers.push(currentlyLoadedLocale.subscribe(rerender));
1412
+ this.unsubscribers.push(sTranslations.subscribe(rerender));
1413
+ }
1414
+ /** Update interpolation params (e.g. a changed count) and re-render. */
1415
+ setParams(params) {
1416
+ this.params = params ?? {};
1417
+ if (this.ready) this._render();
1418
+ }
1419
+ /** Stop reacting to locale/translation changes. Safe to call multiple times. */
1420
+ destroy() {
1421
+ this.unsubscribers.forEach((unsub) => unsub());
1422
+ this.unsubscribers = [];
1423
+ }
1424
+ async _init() {
1425
+ if (!this.host || !this.host.childNodes.length) {
1426
+ this.ready = true;
1427
+ return;
1428
+ }
1429
+ const { phrase, slots } = encodeRichText(this.host);
1430
+ this.phrase = phrase;
1431
+ this.slots = slots;
1432
+ await LangsysApp.Translations.ready();
1433
+ LangsysApp.Translations.t(this.phrase, this.category);
1434
+ this.ready = true;
1435
+ this._render();
1436
+ }
1437
+ _render() {
1438
+ if (!this.phrase) return;
1439
+ const raw = LangsysApp.Translations.lookup(this.phrase, this.category) ?? this.phrase;
1440
+ const params = { ...this.params, ...markupTokenValues(this.slots.length) };
1441
+ const resolved = interpolate(raw, params, currentlyLoadedLocale.get());
1442
+ const nodes = reconstitute(resolved, this.slots, this.host.ownerDocument ?? document);
1443
+ this.host.replaceChildren(...nodes);
1220
1444
  }
1221
1445
  };
1222
1446
 
1223
1447
  // src/index.ts
1224
- var t = ((category, phrase, ...args) => {
1225
- return LangsysApp.Translations.t(category, phrase, ...args);
1448
+ var t = ((phrase, ...rest) => {
1449
+ return LangsysApp.Translations.t(phrase, ...rest);
1226
1450
  });
1227
1451
  var tSignal = LangsysApp.Translations.tSignal;
1228
1452
 
1229
- export { LangsysApp, LangsysAppAPI, Logger, Translate, Translations, contentBlocks, createSignal, currentlyLoadedLocale, getValue, interpolate, isEmpty, logger, md5, persist, sTranslations, t, tSignal };
1453
+ export { LangsysApp, LangsysAppAPI, Logger, PHRASE_MARKER_ATTR, Phrase, Translate, Translations, createSignal, currentlyLoadedLocale, encodeRichText, generateCustomId, getValue, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
1230
1454
  //# sourceMappingURL=index.mjs.map
1231
1455
  //# sourceMappingURL=index.mjs.map