langsys-js-typescript 0.1.0 → 0.2.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/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) {
@@ -85,7 +91,18 @@ var LangsysAppAPIClass = class {
85
91
  return await this.get("authorize-project/[projectid]");
86
92
  }
87
93
  async getTranslations(locale) {
88
- return await this.get("projects/[projectid]/translations", { locale });
94
+ return await this.get("translations", { project_id: this.config.projectid, locale });
95
+ }
96
+ /**
97
+ * Register phrases and/or content blocks via the current unified endpoint.
98
+ * Each item carries `type: 'phrase' | 'content_block'`. Replaces the
99
+ * deprecated `projects/{id}/tokens` and `projects/{id}/content-blocks` routes.
100
+ */
101
+ async createTranslatableItems(items) {
102
+ return await this.post("translatable-items", {
103
+ project_id: this.config.projectid,
104
+ translatable_items: items
105
+ });
89
106
  }
90
107
  async post(path, data = {}) {
91
108
  return await this.send("POST", path, data);
@@ -209,7 +226,6 @@ var initialTranslations = {
209
226
  };
210
227
  var sTranslations = persist("translations", initialTranslations);
211
228
  var currentlyLoadedLocale = createSignal("");
212
- var contentBlocks = createSignal([]);
213
229
  var config = {
214
230
  projectid: "",
215
231
  key: "",
@@ -218,10 +234,22 @@ var config = {
218
234
  debug: false,
219
235
  key_type: "read"
220
236
  };
221
-
222
- // src/interpolate.ts
223
- function interpolate(template, params) {
224
- return template.replace(/\{([^{}]+)\}/g, (match, rawKey) => {
237
+ function isICU(template) {
238
+ return ICU_PATTERN.test(template);
239
+ }
240
+ var ICU_PATTERN = /\{[^{}]+,\s*(plural|select|selectordinal|number|date|time)\s*,/;
241
+ function interpolate(template, params, locale) {
242
+ if (isICU(template)) {
243
+ try {
244
+ return new IntlMessageFormat(template, locale ?? "en").format(params);
245
+ } catch {
246
+ return simpleInterpolate(template, params);
247
+ }
248
+ }
249
+ return simpleInterpolate(template, params);
250
+ }
251
+ function simpleInterpolate(template, params) {
252
+ return template.replace(/\{([^{},]+)\}/g, (match, rawKey) => {
225
253
  const key = rawKey.trim();
226
254
  if (!(key in params)) return match;
227
255
  const value = params[key];
@@ -245,6 +273,11 @@ var Translations = class {
245
273
  this.timer = null;
246
274
  this.flushScheduled = false;
247
275
  this.isFirstClientRun = true;
276
+ this.readyResolve = () => {
277
+ };
278
+ this.readyPromise = new Promise((resolve) => {
279
+ this.readyResolve = resolve;
280
+ });
248
281
  this.config = config2;
249
282
  this.debug = new Logger(!!config2.debug);
250
283
  this.locale = config2.baseLocale || "";
@@ -253,6 +286,16 @@ var Translations = class {
253
286
  currentlyLoadedLocale.subscribe(() => this.tSignal.set(this.buildTFn()));
254
287
  if (config2.key && config2.projectid) this.setup(config2);
255
288
  }
289
+ /**
290
+ * Resolves once the first `getTranslations` call has completed (or
291
+ * immediately if no fetch is configured). Callers that need to read
292
+ * `sTranslations` before issuing a write — e.g. the `Translate` DOM
293
+ * class deciding whether to POST a content block — should await this
294
+ * to avoid acting on an empty cache during the cold-start race.
295
+ */
296
+ ready() {
297
+ return this.readyPromise;
298
+ }
256
299
  /** Current translation function. Reads fresh state on every call. */
257
300
  get t() {
258
301
  return this.tSignal.get();
@@ -275,8 +318,22 @@ var Translations = class {
275
318
  const value = contentData[token];
276
319
  return typeof value === "string" ? value : null;
277
320
  }
321
+ /**
322
+ * Raw stored translation for a phrase, without interpolation or
323
+ * missing-token registration. Returns null when no (non-empty) translation
324
+ * is cached. Used by the `Phrase` rich-text handler, which needs the
325
+ * un-interpolated template (it supplies its own markup-token values).
326
+ */
327
+ lookup(phrase, category) {
328
+ const cats = sTranslations.get();
329
+ const lookupCat = category || "__uncategorized__";
330
+ const value = cats[lookupCat]?.[phrase];
331
+ return typeof value === "string" && value.length > 0 ? value : null;
332
+ }
278
333
  buildTFn() {
279
- return (category, phrase, ...args) => {
334
+ const fn = ((phrase, ...rest) => {
335
+ const category = typeof rest[0] === "string" ? rest[0] : "";
336
+ const params = typeof rest[0] === "object" ? rest[0] : rest[1];
280
337
  const cats = sTranslations.get();
281
338
  const lookupCat = category || "__uncategorized__";
282
339
  const value = cats[lookupCat]?.[phrase];
@@ -285,12 +342,12 @@ var Translations = class {
285
342
  this.debug.log("TRANSLATION FOUND", [lookupCat, phrase, value]);
286
343
  translated = value;
287
344
  } else {
288
- this.missingToken(lookupCat, phrase);
345
+ this.missingToken(category, phrase);
289
346
  translated = phrase;
290
347
  }
291
- const params = args[0];
292
- return params ? interpolate(translated, params) : translated;
293
- };
348
+ return params ? interpolate(translated, params, currentlyLoadedLocale.get()) : translated;
349
+ });
350
+ return fn;
294
351
  }
295
352
  missingToken(category, token) {
296
353
  if (token === void 0 || token === null) {
@@ -418,11 +475,12 @@ var Translations = class {
418
475
  const currentData = sTranslations.get();
419
476
  this.missingTokens = this.missingTokens.filter((tokenObj) => {
420
477
  tokenObj.projectid = this.config.projectid;
421
- if (tokenObj.category in currentData && tokenObj.token in currentData[tokenObj.category]) {
478
+ const lookupCat = tokenObj.category || "__uncategorized__";
479
+ if (lookupCat in currentData && tokenObj.token in currentData[lookupCat]) {
422
480
  this.debug.log("Missing token already exists! Skipping:", {
423
481
  category: tokenObj.category,
424
482
  token: tokenObj.token,
425
- existing: currentData[tokenObj.category][tokenObj.token]
483
+ existing: currentData[lookupCat][tokenObj.token]
426
484
  });
427
485
  return false;
428
486
  }
@@ -431,25 +489,34 @@ var Translations = class {
431
489
  if (!this.missingTokens.length) return false;
432
490
  this.debug.log("CREATE MISSING TOKENS", this.missingTokens);
433
491
  try {
434
- const response = await LangsysAppAPI.post("projects/[projectid]/tokens", {
435
- tokens: this.missingTokens
436
- });
437
- if (!response.status) {
438
- if (response.errors) {
439
- this.debug.log("TOKEN UPDATE FAIL", this.missingTokens);
440
- this.debug.error("Error updating project tokens", response.errors);
492
+ const phraseItems = this.missingTokens.map((tokenObj) => ({
493
+ type: "phrase",
494
+ phrase: tokenObj.token,
495
+ category: tokenObj.category || null
496
+ }));
497
+ const BATCH_SIZE = 200;
498
+ for (let offset = 0; offset < phraseItems.length; offset += BATCH_SIZE) {
499
+ const response = await LangsysAppAPI.createTranslatableItems(
500
+ phraseItems.slice(offset, offset + BATCH_SIZE)
501
+ );
502
+ if (!response.status) {
503
+ if (response.errors) {
504
+ this.debug.log("TOKEN UPDATE FAIL", this.missingTokens);
505
+ this.debug.error("Error updating project tokens", response.errors);
506
+ }
507
+ return false;
441
508
  }
442
- return false;
443
509
  }
444
510
  this.missingTokens.forEach((tokenObj) => {
445
- if (!currentData[tokenObj.category]) {
446
- currentData[tokenObj.category] = {
447
- __category__: tokenObj.category,
448
- __symbol__: tokenObj.category
511
+ const lookupCat = tokenObj.category || "__uncategorized__";
512
+ if (!currentData[lookupCat]) {
513
+ currentData[lookupCat] = {
514
+ __category__: lookupCat,
515
+ __symbol__: lookupCat
449
516
  };
450
517
  }
451
- currentData[tokenObj.category][tokenObj.token] = tokenObj.token;
452
- currentData[tokenObj.category]["__category__"] = tokenObj.category;
518
+ currentData[lookupCat][tokenObj.token] = tokenObj.token;
519
+ currentData[lookupCat]["__category__"] = lookupCat;
453
520
  });
454
521
  sTranslations.set({ ...currentData });
455
522
  this.missingTokens = [];
@@ -468,6 +535,7 @@ var Translations = class {
468
535
  this.debug.log("GET TRANSLATIONS API RESPONSE", response);
469
536
  if (response.errors) {
470
537
  this.debug.error("Error", response.errors[0]);
538
+ this.readyResolve();
471
539
  return;
472
540
  }
473
541
  const trans = response.data;
@@ -484,6 +552,7 @@ var Translations = class {
484
552
  sTranslations.set(trans);
485
553
  setTimeout(() => currentlyLoadedLocale.set(this.locale), 100);
486
554
  this.lastLoaded[this.locale] = (/* @__PURE__ */ new Date()).getTime() / 1e3;
555
+ this.readyResolve();
487
556
  }
488
557
  };
489
558
 
@@ -513,7 +582,8 @@ var LangsysAppClass = class {
513
582
  }
514
583
  /**
515
584
  * Initialize Langsys. Configuration must include at least `projectid`,
516
- * `key`, and `UserLocaleStore` (any Signal<string>).
585
+ * `key`, and `UserLocaleStore` (anything satisfying `LocaleSource` — see
586
+ * its docstring for framework-specific adapters).
517
587
  */
518
588
  async init(initConfig) {
519
589
  const projectid = initConfig.projectid;
@@ -542,9 +612,9 @@ var LangsysAppClass = class {
542
612
  this.debug.error("LangsysApp.init missing API key in configuration object!");
543
613
  return { status: false, errors: ["Missing API key"] };
544
614
  }
545
- if (!UserLocaleStore?.subscribe) {
615
+ if (!UserLocaleStore?.subscribe || typeof UserLocaleStore.get !== "function") {
546
616
  this.debug.error(
547
- "LangsysApp.init missing UserLocaleStore \u2014 pass a Signal<string> (createSignal('en-us') works if you have nothing of your own)."
617
+ "LangsysApp.init missing UserLocaleStore \u2014 pass any object satisfying LocaleSource (get + subscribe). createSignal('en-us') works if you have nothing of your own."
548
618
  );
549
619
  return { status: false, errors: ["Missing UserLocaleStore"] };
550
620
  }
@@ -908,7 +978,7 @@ function md5(inputString) {
908
978
  return rh(a) + rh(b) + rh(c) + rh(d);
909
979
  }
910
980
 
911
- // src/translate.ts
981
+ // src/content-block.ts
912
982
  var TRANSLATABLE_ATTRIBUTES = [
913
983
  "placeholder",
914
984
  "alt",
@@ -973,9 +1043,147 @@ var SEMANTIC_STYLE_PROPERTIES = [
973
1043
  "list-style-type",
974
1044
  "cursor"
975
1045
  ];
1046
+ function generateCustomId(category, tokens) {
1047
+ return md5(JSON.stringify([category, tokens]));
1048
+ }
1049
+ function isContentBlockKnown(category, customId) {
1050
+ const cats = sTranslations.get();
1051
+ const lookupCat = category || "__uncategorized__";
1052
+ const cbData = cats[lookupCat]?.[customId];
1053
+ return typeof cbData === "object" && cbData !== null;
1054
+ }
1055
+ async function registerContentBlock(contentBlock) {
1056
+ if (config.key_type !== "write") {
1057
+ if (config.debug) {
1058
+ logger.log(`Skipping content block save (API key is ${config.key_type || "unknown"})`);
1059
+ }
1060
+ return { status: true };
1061
+ }
1062
+ try {
1063
+ const response = await LangsysAppAPI.createTranslatableItems([
1064
+ {
1065
+ type: "content_block",
1066
+ custom_id: contentBlock.custom_id,
1067
+ category: contentBlock.category,
1068
+ content: contentBlock.content,
1069
+ label: contentBlock.label,
1070
+ phrases: contentBlock.tokens.map((phrase) => ({ phrase }))
1071
+ }
1072
+ ]);
1073
+ if (!response.status) {
1074
+ logger.error("Could not save content block", response.errors);
1075
+ return { status: false, errors: response.errors };
1076
+ }
1077
+ _writeKnownContentBlockToCache(contentBlock.category, contentBlock.custom_id);
1078
+ return { status: true };
1079
+ } catch (err) {
1080
+ logger.error("Could not save content block", err);
1081
+ return { status: false, errors: [err] };
1082
+ }
1083
+ }
1084
+ function tokenizeElement(element) {
1085
+ const tokens = [];
1086
+ const clone = element.cloneNode(true);
1087
+ _walkForTokens(element, Array.from(clone.childNodes), tokens, []);
1088
+ return { tokens, content: clone.outerHTML };
1089
+ }
1090
+ function _writeKnownContentBlockToCache(category, customId) {
1091
+ const lookupCat = category || "__uncategorized__";
1092
+ sTranslations.update((current) => {
1093
+ if (!current[lookupCat]) {
1094
+ current[lookupCat] = {
1095
+ __category__: lookupCat,
1096
+ __symbol__: lookupCat
1097
+ };
1098
+ }
1099
+ current[lookupCat][customId] = {};
1100
+ return { ...current };
1101
+ });
1102
+ }
1103
+ function _walkForTokens(liveRoot, cloneNodes, tokens, indices) {
1104
+ cloneNodes.forEach((node, index) => {
1105
+ if (node.nodeType === Node.ELEMENT_NODE) {
1106
+ const el = node;
1107
+ if (el.getAttribute("translate") === "no") return;
1108
+ if (el.hasAttribute("data-ls-phrase")) return;
1109
+ }
1110
+ if (node.hasChildNodes()) {
1111
+ _applyStylesToClone(liveRoot, node, [...indices, index]);
1112
+ }
1113
+ if (node.nodeType === Node.ELEMENT_NODE) {
1114
+ _tokenizeAttributes(node, tokens);
1115
+ }
1116
+ const contentToken = node.nodeValue?.replace(/\s+/g, " ").trim();
1117
+ if (node.nodeType === Node.TEXT_NODE && contentToken) {
1118
+ tokens.push(contentToken);
1119
+ return;
1120
+ }
1121
+ if (!node.hasChildNodes()) return;
1122
+ _walkForTokens(liveRoot, Array.from(node.childNodes), tokens, [...indices, index]);
1123
+ });
1124
+ }
1125
+ function _tokenizeAttributes(element, tokens) {
1126
+ const tagName = element.tagName.toLowerCase();
1127
+ if (tagName === "img") {
1128
+ const img = element;
1129
+ if (img.src) element.setAttribute("src", img.src);
1130
+ }
1131
+ for (const attr of TRANSLATABLE_ATTRIBUTES) {
1132
+ const value = element.getAttribute(attr)?.trim();
1133
+ if (value) tokens.push(value);
1134
+ }
1135
+ if (VALUE_TRANSLATABLE_ELEMENTS.includes(tagName)) {
1136
+ const value = element.getAttribute("value")?.trim();
1137
+ if (value) tokens.push(value);
1138
+ }
1139
+ if (tagName === "input") {
1140
+ const inputType = element.getAttribute("type")?.toLowerCase();
1141
+ if (inputType && VALUE_TRANSLATABLE_INPUT_TYPES.includes(inputType)) {
1142
+ const value = element.getAttribute("value")?.trim();
1143
+ if (value) tokens.push(value);
1144
+ }
1145
+ }
1146
+ if (tagName === "select") {
1147
+ const options = element.querySelectorAll("option");
1148
+ options.forEach((option) => {
1149
+ const optionText = option.textContent?.trim();
1150
+ if (optionText) tokens.push(optionText);
1151
+ });
1152
+ }
1153
+ }
1154
+ function _applyStylesToClone(liveRoot, cloneNode, indices) {
1155
+ if (typeof window === "undefined") return;
1156
+ const domNode = _findNode(Array.from(liveRoot.childNodes), indices);
1157
+ if (!domNode) return;
1158
+ const tagName = domNode.tagName.toLowerCase();
1159
+ const reference = document.createElement(tagName);
1160
+ reference.style.visibility = "hidden";
1161
+ reference.style.position = "absolute";
1162
+ document.body.appendChild(reference);
1163
+ const computed = window.getComputedStyle(domNode);
1164
+ const defaults = window.getComputedStyle(reference);
1165
+ for (const prop of SEMANTIC_STYLE_PROPERTIES) {
1166
+ const value = computed.getPropertyValue(prop);
1167
+ const defaultValue = defaults.getPropertyValue(prop);
1168
+ if (value && value !== defaultValue) {
1169
+ cloneNode.style.setProperty(prop, value);
1170
+ }
1171
+ }
1172
+ document.body.removeChild(reference);
1173
+ cloneNode.removeAttribute("class");
1174
+ }
1175
+ function _findNode(nodes, indices) {
1176
+ let currentNode = nodes[indices[0]];
1177
+ for (let i = 1; i < indices.length; i++) {
1178
+ if (!currentNode || !currentNode.hasChildNodes()) return void 0;
1179
+ currentNode = Array.from(currentNode.childNodes)[indices[i]];
1180
+ }
1181
+ return currentNode;
1182
+ }
1183
+
1184
+ // src/translate.ts
976
1185
  var Translate = class {
977
1186
  constructor(element, options = {}) {
978
- this.contentClone = null;
979
1187
  this.tokens = [];
980
1188
  this.parseComplete = false;
981
1189
  this.isTokenizing = false;
@@ -1001,7 +1209,7 @@ var Translate = class {
1001
1209
  if (this.lastTranslatedLocale === currentLocale) return;
1002
1210
  const { category = "" } = this.options;
1003
1211
  if (this.tokens.length === 1) {
1004
- this.element.innerText = LangsysApp.Translations.t(category, this.tokens[0]);
1212
+ this.element.innerText = LangsysApp.Translations.t(this.tokens[0], category);
1005
1213
  } else {
1006
1214
  this.translate(Array.from(this.element.childNodes));
1007
1215
  this.lastTranslatedLocale = currentLocale;
@@ -1015,18 +1223,17 @@ var Translate = class {
1015
1223
  return false;
1016
1224
  }
1017
1225
  this.isTokenizing = true;
1018
- this.contentClone = this.element.cloneNode(true);
1019
- const nodes = Array.from(this.contentClone.childNodes || []);
1020
- this.tokenize(nodes);
1226
+ const { tokens, content } = tokenizeElement(this.element);
1227
+ this.tokens = tokens;
1021
1228
  const { category = "" } = this.options;
1022
1229
  if (this.tokens.length === 1) {
1023
- this.element.innerText = LangsysApp.Translations.t(category, this.tokens[0]);
1230
+ this.element.innerText = LangsysApp.Translations.t(this.tokens[0], category);
1024
1231
  } else {
1025
1232
  const contentBlock = {
1026
1233
  custom_id: "",
1027
1234
  category,
1028
1235
  label: this.options.label,
1029
- content: this.contentClone.outerHTML,
1236
+ content,
1030
1237
  tokens: this.tokens
1031
1238
  };
1032
1239
  await this.handleContentBlock(contentBlock);
@@ -1036,85 +1243,23 @@ var Translate = class {
1036
1243
  return true;
1037
1244
  }
1038
1245
  async handleContentBlock(contentBlock) {
1039
- if (isEmpty(this.custom_id)) this.custom_id = this.generateCustomId(contentBlock);
1246
+ if (isEmpty(this.custom_id)) {
1247
+ this.custom_id = generateCustomId(contentBlock.category, contentBlock.tokens);
1248
+ }
1040
1249
  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
- }
1250
+ await LangsysApp.Translations.ready();
1251
+ if (isContentBlockKnown(contentBlock.category, this.custom_id)) {
1055
1252
  if (this.tokens.length > 1 && this.element) {
1056
1253
  this.translate(Array.from(this.element.childNodes));
1057
1254
  this.lastTranslatedLocale = currentlyLoadedLocale.get();
1058
1255
  }
1256
+ return;
1059
1257
  }
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]];
1258
+ void registerContentBlock(contentBlock);
1259
+ if (this.tokens.length > 1 && this.element) {
1260
+ this.translate(Array.from(this.element.childNodes));
1261
+ this.lastTranslatedLocale = currentlyLoadedLocale.get();
1116
1262
  }
1117
- return currentNode;
1118
1263
  }
1119
1264
  translate(nodes) {
1120
1265
  const currentLocale = currentlyLoadedLocale.get();
@@ -1125,6 +1270,7 @@ var Translate = class {
1125
1270
  if (node?.nodeType === Node.ELEMENT_NODE) {
1126
1271
  const element = node;
1127
1272
  if (element.getAttribute("translate") === "no") return;
1273
+ if (element.hasAttribute("data-ls-phrase")) return;
1128
1274
  this.translateAttributes(node);
1129
1275
  }
1130
1276
  if (node?.nodeType !== Node.TEXT_NODE) {
@@ -1194,38 +1340,136 @@ var Translate = class {
1194
1340
  if (translation) element.setAttribute(attr, translation);
1195
1341
  else element.setAttribute(attr, element.originalAttributes[attr]);
1196
1342
  }
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);
1343
+ };
1344
+
1345
+ // src/richtext.ts
1346
+ var SENT_OPEN_START = String.fromCharCode(57344);
1347
+ var SENT_OPEN_END = String.fromCharCode(57345);
1348
+ var SENT_CLOSE_START = String.fromCharCode(57346);
1349
+ var SENT_CLOSE_END = String.fromCharCode(57347);
1350
+ var SENT_SCAN = /\uE000(\d+)\uE001|\uE002(\d+)\uE003/g;
1351
+ function encodeRichText(root) {
1352
+ const slots = [];
1353
+ const phrase = _encodeNodes(Array.from(root.childNodes), slots);
1354
+ return { phrase: phrase.replace(/\s+/g, " ").trim(), slots };
1355
+ }
1356
+ function _encodeNodes(nodes, slots) {
1357
+ let out = "";
1358
+ for (const node of nodes) {
1359
+ if (node.nodeType === Node.TEXT_NODE) {
1360
+ out += node.nodeValue ?? "";
1361
+ continue;
1362
+ }
1363
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
1364
+ const element = node;
1365
+ const index = slots.length;
1366
+ slots.push({ template: element.cloneNode(false) });
1367
+ out += `{m${index}o}` + _encodeNodes(Array.from(element.childNodes), slots) + `{m${index}c}`;
1368
+ }
1369
+ return out;
1370
+ }
1371
+ function markupTokenValues(slotCount) {
1372
+ const values = {};
1373
+ for (let i = 0; i < slotCount; i++) {
1374
+ values[`m${i}o`] = `${SENT_OPEN_START}${i}${SENT_OPEN_END}`;
1375
+ values[`m${i}c`] = `${SENT_CLOSE_START}${i}${SENT_CLOSE_END}`;
1376
+ }
1377
+ return values;
1378
+ }
1379
+ function reconstitute(resolved, slots, doc = document) {
1380
+ const root = doc.createDocumentFragment();
1381
+ const stack = [root];
1382
+ const top = () => stack[stack.length - 1];
1383
+ let lastIndex = 0;
1384
+ let match;
1385
+ SENT_SCAN.lastIndex = 0;
1386
+ try {
1387
+ while ((match = SENT_SCAN.exec(resolved)) !== null) {
1388
+ const text = resolved.slice(lastIndex, match.index);
1389
+ if (text) top().appendChild(doc.createTextNode(text));
1390
+ lastIndex = SENT_SCAN.lastIndex;
1391
+ const isOpen = match[1] !== void 0;
1392
+ const slotIndex = Number(isOpen ? match[1] : match[2]);
1393
+ if (isOpen) {
1394
+ const slot = slots[slotIndex];
1395
+ if (!slot) throw new Error("unknown markup slot");
1396
+ const el = slot.template.cloneNode(false);
1397
+ top().appendChild(el);
1398
+ stack.push(el);
1399
+ } else {
1400
+ if (stack.length <= 1) throw new Error("unbalanced close");
1401
+ stack.pop();
1216
1402
  }
1217
1403
  }
1218
- document.body.removeChild(reference);
1219
- node.removeAttribute("class");
1404
+ const tail = resolved.slice(lastIndex);
1405
+ if (tail) top().appendChild(doc.createTextNode(tail));
1406
+ if (stack.length !== 1) throw new Error("unbalanced open");
1407
+ } catch {
1408
+ return [doc.createTextNode(stripSentinels(resolved))];
1409
+ }
1410
+ return Array.from(root.childNodes);
1411
+ }
1412
+ function stripSentinels(value) {
1413
+ return value.replace(SENT_SCAN, "");
1414
+ }
1415
+
1416
+ // src/phrase.ts
1417
+ var PHRASE_MARKER_ATTR = "data-ls-phrase";
1418
+ var Phrase = class {
1419
+ constructor(host, options = {}) {
1420
+ this.phrase = "";
1421
+ this.slots = [];
1422
+ this.unsubscribers = [];
1423
+ this.ready = false;
1424
+ this.host = host;
1425
+ this.category = options.category ?? "";
1426
+ this.params = options.params ?? {};
1427
+ void this._init();
1428
+ const rerender = () => {
1429
+ if (this.ready) this._render();
1430
+ };
1431
+ this.unsubscribers.push(currentlyLoadedLocale.subscribe(rerender));
1432
+ this.unsubscribers.push(sTranslations.subscribe(rerender));
1433
+ }
1434
+ /** Update interpolation params (e.g. a changed count) and re-render. */
1435
+ setParams(params) {
1436
+ this.params = params ?? {};
1437
+ if (this.ready) this._render();
1438
+ }
1439
+ /** Stop reacting to locale/translation changes. Safe to call multiple times. */
1440
+ destroy() {
1441
+ this.unsubscribers.forEach((unsub) => unsub());
1442
+ this.unsubscribers = [];
1443
+ }
1444
+ async _init() {
1445
+ if (!this.host || !this.host.childNodes.length) {
1446
+ this.ready = true;
1447
+ return;
1448
+ }
1449
+ const { phrase, slots } = encodeRichText(this.host);
1450
+ this.phrase = phrase;
1451
+ this.slots = slots;
1452
+ await LangsysApp.Translations.ready();
1453
+ LangsysApp.Translations.t(this.phrase, this.category);
1454
+ this.ready = true;
1455
+ this._render();
1456
+ }
1457
+ _render() {
1458
+ if (!this.phrase) return;
1459
+ const raw = LangsysApp.Translations.lookup(this.phrase, this.category) ?? this.phrase;
1460
+ const params = { ...this.params, ...markupTokenValues(this.slots.length) };
1461
+ const resolved = interpolate(raw, params, currentlyLoadedLocale.get());
1462
+ const nodes = reconstitute(resolved, this.slots, this.host.ownerDocument ?? document);
1463
+ this.host.replaceChildren(...nodes);
1220
1464
  }
1221
1465
  };
1222
1466
 
1223
1467
  // src/index.ts
1224
- var t = ((category, phrase, ...args) => {
1225
- return LangsysApp.Translations.t(category, phrase, ...args);
1468
+ var t = ((phrase, ...rest) => {
1469
+ return LangsysApp.Translations.t(phrase, ...rest);
1226
1470
  });
1227
1471
  var tSignal = LangsysApp.Translations.tSignal;
1228
1472
 
1229
- export { LangsysApp, LangsysAppAPI, Logger, Translate, Translations, contentBlocks, createSignal, currentlyLoadedLocale, getValue, interpolate, isEmpty, logger, md5, persist, sTranslations, t, tSignal };
1473
+ 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
1474
  //# sourceMappingURL=index.mjs.map
1231
1475
  //# sourceMappingURL=index.mjs.map