@spintax/core 0.4.0 → 0.5.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
@@ -49,7 +49,7 @@ function stripSentinels(text) {
49
49
  // src/internal/parser.ts
50
50
  var VARIABLE_RE = /^%(\w+)%/;
51
51
  var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
52
- var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
52
+ var CONDITIONAL_NAME_RE = /[A-Za-z_]\w*/y;
53
53
  var PLURAL_PREFIX = "plural ";
54
54
  function parseTemplate(src) {
55
55
  const { body, setDefs, defDefs } = extractDirectives(stripComments(stripSentinels(src)));
@@ -150,28 +150,42 @@ function parseBraceConstruct(content) {
150
150
  }
151
151
  return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
152
152
  }
153
- function tryParseConditional(content) {
154
- let p = 1;
153
+ function recognizeConditional(text, contentStart, contentEnd) {
154
+ let p = contentStart + 1;
155
155
  let inverted = false;
156
- if (content.charAt(p) === "!") {
156
+ if (text.charAt(p) === "!") {
157
157
  inverted = true;
158
158
  p += 1;
159
159
  }
160
- const name = CONDITIONAL_NAME_RE.exec(content.slice(p))?.[0];
161
- if (name === void 0) return null;
160
+ CONDITIONAL_NAME_RE.lastIndex = p;
161
+ const name = CONDITIONAL_NAME_RE.exec(text)?.[0];
162
+ if (name === void 0 || p + name.length > contentEnd) return null;
162
163
  p += name.length;
163
- if (content.charAt(p) !== "?") return null;
164
+ if (text.charAt(p) !== "?") return null;
164
165
  p += 1;
165
- const body = content.slice(p);
166
- const sep = firstTopLevelPipe(body);
167
- const thenRaw = sep < 0 ? body : body.slice(0, sep);
168
- const elseRaw = sep < 0 ? "" : body.slice(sep + 1);
166
+ return { name, inverted, bodyStart: p, sepIndex: firstTopLevelPipe(text, p, contentEnd) };
167
+ }
168
+ function splitConditional(content) {
169
+ const head = recognizeConditional(content, 0, content.length);
170
+ if (head === null) return null;
171
+ const body = content.slice(head.bodyStart);
172
+ const sep = head.sepIndex < 0 ? -1 : head.sepIndex - head.bodyStart;
173
+ return {
174
+ name: head.name,
175
+ inverted: head.inverted,
176
+ thenRaw: sep < 0 ? body : body.slice(0, sep),
177
+ elseRaw: sep < 0 ? "" : body.slice(sep + 1)
178
+ };
179
+ }
180
+ function tryParseConditional(content) {
181
+ const parts = splitConditional(content);
182
+ if (parts === null) return null;
169
183
  return {
170
184
  type: "conditional",
171
- name,
172
- inverted,
173
- then: parseSequence(thenRaw),
174
- else: parseSequence(elseRaw)
185
+ name: parts.name,
186
+ inverted: parts.inverted,
187
+ then: parseSequence(parts.thenRaw),
188
+ else: parseSequence(parts.elseRaw)
175
189
  };
176
190
  }
177
191
  function parsePlural(afterPrefix) {
@@ -331,9 +345,9 @@ function splitTopLevel(inner) {
331
345
  parts.push(cur);
332
346
  return parts;
333
347
  }
334
- function firstTopLevelPipe(body) {
348
+ function firstTopLevelPipe(body, from = 0, to = body.length) {
335
349
  let depth = 0;
336
- for (let j = 0; j < body.length; j += 1) {
350
+ for (let j = from; j < to; j += 1) {
337
351
  const ch = body.charAt(j);
338
352
  if (ch === "{" || ch === "[") {
339
353
  depth += 1;
@@ -427,7 +441,7 @@ function validateTemplate(src, opts = {}) {
427
441
  checkBrackets(text, diagnostics);
428
442
  checkDirectives(text, diagnostics);
429
443
  checkPermutationConfigs(text, idx, diagnostics);
430
- checkPlurals(text, idx, opts.locale, diagnostics);
444
+ checkPlurals(text, idx, opts.locale, opts.knownVariables, diagnostics);
431
445
  checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
432
446
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
433
447
  checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
@@ -561,10 +575,12 @@ function checkPermutationConfigs(text, idx, out) {
561
575
  }
562
576
  }
563
577
  }
564
- function checkPlurals(text, idx, locale, out) {
578
+ function checkPlurals(text, idx, locale, knownVariables, out) {
565
579
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
566
580
  const arity = base !== "" ? pluralArity(base) : 0;
567
581
  const tainted = macroTaintedNames(text);
582
+ const { setDefs: macros, defDefs: defs } = extractDirectives(text);
583
+ const hostNames = new Set((knownVariables ?? []).map((n) => n.toLowerCase()));
568
584
  for (const block of findPluralBlocks(text)) {
569
585
  const at = span(idx, block.start, block.end - block.start);
570
586
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
@@ -580,7 +596,13 @@ function checkPlurals(text, idx, locale, out) {
580
596
  out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets. Extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.", at));
581
597
  continue;
582
598
  }
583
- const count = block.formsRaw.split("|").length;
599
+ const expanded = expandFormsForCounting(block.formsRaw, defs, macros, hostNames);
600
+ if (expanded.directMacroSpintax) {
601
+ out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets. Extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.", at));
602
+ continue;
603
+ }
604
+ if (expanded.unresolved) continue;
605
+ const count = expanded.forms;
584
606
  if (arity > 0) {
585
607
  if (count !== arity) {
586
608
  out.push(err(
@@ -590,15 +612,90 @@ function checkPlurals(text, idx, locale, out) {
590
612
  ));
591
613
  }
592
614
  } else if (count !== DEFAULT_PLURAL_ARITY) {
615
+ const unusable = locale !== void 0 && locale !== "";
593
616
  out.push(warn(
594
617
  "plural.locale-missing",
595
- `{plural ...}: ${count} forms, but no locale was supplied. render defaults to ${DEFAULT_PLURAL_ARITY} forms and leaves this block unresolved \u2014 pass the locale you will render with.`,
596
- { ...at, data: { got: count, defaultArity: DEFAULT_PLURAL_ARITY } }
618
+ unusable ? `{plural ...}: ${count} forms, and the locale '${locale}' does not normalize to a language the engine knows, so render falls back to ${DEFAULT_PLURAL_ARITY} forms and leaves this block unresolved \u2014 pass a base tag such as 'en' or 'ru'.` : `{plural ...}: ${count} forms, but no locale was supplied. render defaults to ${DEFAULT_PLURAL_ARITY} forms and leaves this block unresolved \u2014 pass the locale you will render with.`,
619
+ {
620
+ ...at,
621
+ data: {
622
+ got: count,
623
+ defaultArity: DEFAULT_PLURAL_ARITY,
624
+ ...unusable ? { locale } : {}
625
+ }
626
+ }
597
627
  ));
598
628
  }
599
629
  }
600
630
  }
601
631
  var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
632
+ var ANY_BRACKET = /[[\]{}]/u;
633
+ var FORM_EXPANSION_PASSES = 51;
634
+ var FORM_EXPANSION_MAX_GROWTH = 64 * 1024;
635
+ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
636
+ const unknown = (directMacroSpintax = false) => ({
637
+ forms: 0,
638
+ unresolved: true,
639
+ directMacroSpintax
640
+ });
641
+ const seenMacro = /* @__PURE__ */ new Set();
642
+ const refsOf = (text2) => [...text2.matchAll(/%(\w+)%/gu)].map((m) => (m[1] ?? "").toLowerCase());
643
+ const walkMacros = (source) => {
644
+ const stack = [{ refs: refsOf(source), i: 0 }];
645
+ while (stack.length > 0) {
646
+ const frame = stack[stack.length - 1];
647
+ if (frame.i >= frame.refs.length) {
648
+ stack.pop();
649
+ continue;
650
+ }
651
+ const name = frame.refs[frame.i++];
652
+ if (defs[name] !== void 0 || hostNames.has(name)) continue;
653
+ const macro = macros[name];
654
+ if (macro === void 0 || seenMacro.has(name)) continue;
655
+ seenMacro.add(name);
656
+ if (/\{\?/u.test(macro)) return "opaque";
657
+ if (ANY_BRACKET.test(macro)) return "brackets";
658
+ stack.push({ refs: refsOf(macro), i: 0 });
659
+ }
660
+ return "clean";
661
+ };
662
+ const verbatim = walkMacros(formsRaw);
663
+ if (verbatim === "brackets") return unknown(true);
664
+ if (verbatim === "opaque") return unknown();
665
+ let text = formsRaw;
666
+ const budget = formsRaw.length + FORM_EXPANSION_MAX_GROWTH;
667
+ for (let pass = 0; pass < FORM_EXPANSION_PASSES; pass++) {
668
+ let sawReference = false;
669
+ let bailed = false;
670
+ const parts = [];
671
+ let total = 0;
672
+ let cursor = 0;
673
+ const re = /%(\w+)%/gu;
674
+ let m;
675
+ while ((m = re.exec(text)) !== null) {
676
+ sawReference = true;
677
+ const name = (m[1] ?? "").toLowerCase();
678
+ const value = hostNames.has(name) ? void 0 : defs[name] ?? macros[name];
679
+ if (value === void 0 || ANY_BRACKET.test(value)) {
680
+ bailed = true;
681
+ break;
682
+ }
683
+ parts.push(text.slice(cursor, m.index), value);
684
+ total += m.index - cursor + value.length;
685
+ cursor = m.index + m[0].length;
686
+ if (total > budget) return unknown();
687
+ }
688
+ if (bailed) return unknown();
689
+ parts.push(text.slice(cursor));
690
+ total += text.length - cursor;
691
+ if (total > budget) return unknown();
692
+ text = parts.join("");
693
+ if (!sawReference) {
694
+ return { forms: text.split("|").length, unresolved: false, directMacroSpintax: false };
695
+ }
696
+ }
697
+ return unknown();
698
+ }
602
699
  function macroTaintedNames(text) {
603
700
  const macros = extractDirectives(text).setDefs;
604
701
  const tainted = /* @__PURE__ */ new Set();
@@ -948,14 +1045,63 @@ function expandVarsOnly(text, opts) {
948
1045
  }
949
1046
  return out;
950
1047
  }
951
- function renderConditional(node, opts) {
952
- const value = opts.vars[node.name.toLowerCase()];
1048
+ function conditionalTakesThen(name, inverted, opts) {
1049
+ const value = opts.vars[name.toLowerCase()];
953
1050
  const baseTruthy = value !== void 0 && /\S/u.test(value);
954
- const truthy = node.inverted ? !baseTruthy : baseTruthy;
1051
+ return inverted ? !baseTruthy : baseTruthy;
1052
+ }
1053
+ function renderConditional(node, opts) {
1054
+ const truthy = conditionalTakesThen(node.name, node.inverted, opts);
955
1055
  return renderNodes(truthy ? node.then : node.else, opts);
956
1056
  }
1057
+ function resolveCountConditionals(text, opts) {
1058
+ if (!text.includes("{?")) return text;
1059
+ const close = matchBraces(text);
1060
+ const out = [];
1061
+ const pending = [[0, text.length]];
1062
+ while (pending.length > 0) {
1063
+ const segment = pending.pop();
1064
+ const segEnd = segment[1];
1065
+ let i = segment[0];
1066
+ while (i < segEnd) {
1067
+ const open = text.indexOf("{?", i);
1068
+ if (open === -1 || open + 1 >= segEnd) {
1069
+ out.push(text.slice(i, segEnd));
1070
+ break;
1071
+ }
1072
+ const shut = close[open] ?? -1;
1073
+ const head = shut === -1 || shut >= segEnd ? null : recognizeConditional(text, open + 1, shut);
1074
+ if (head === null) {
1075
+ out.push(text.slice(i, Math.min(open + 2, segEnd)));
1076
+ i = open + 2;
1077
+ continue;
1078
+ }
1079
+ out.push(text.slice(i, open));
1080
+ const branchEnd = head.sepIndex < 0 ? shut : head.sepIndex;
1081
+ const [from, to] = conditionalTakesThen(head.name, head.inverted, opts) ? [head.bodyStart, branchEnd] : [head.sepIndex < 0 ? shut : head.sepIndex + 1, shut];
1082
+ pending.push([shut + 1, segEnd]);
1083
+ pending.push([from, to]);
1084
+ break;
1085
+ }
1086
+ }
1087
+ return out.join("");
1088
+ }
1089
+ function matchBraces(text) {
1090
+ const close = new Int32Array(text.length).fill(-1);
1091
+ const opens = [];
1092
+ for (let i = 0; i < text.length; i += 1) {
1093
+ const ch = text.charAt(i);
1094
+ if (ch === "{") {
1095
+ opens.push(i);
1096
+ } else if (ch === "}") {
1097
+ const open = opens.pop();
1098
+ if (open !== void 0) close[open] = i;
1099
+ }
1100
+ }
1101
+ return close;
1102
+ }
957
1103
  function renderPlural(node, opts) {
958
- const countRaw = expandVarsOnly(node.countRaw, opts);
1104
+ const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
959
1105
  const formsRaw = expandVarsOnly(node.formsRaw, opts);
960
1106
  const base = normalizeBaseLang(opts.locale);
961
1107
  const report = (issue) => opts.onPluralError?.(issue);