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