@spintax/core 0.3.4 → 0.5.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.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;
@@ -397,6 +411,7 @@ function pluralArity(baseLang) {
397
411
  return 2;
398
412
  }
399
413
  }
414
+ var DEFAULT_PLURAL_ARITY = pluralArity("");
400
415
  function pluralFor(baseLang, n, forms) {
401
416
  const abs = Math.abs(n);
402
417
  const mod10 = abs % 10;
@@ -426,7 +441,7 @@ function validateTemplate(src, opts = {}) {
426
441
  checkBrackets(text, diagnostics);
427
442
  checkDirectives(text, diagnostics);
428
443
  checkPermutationConfigs(text, idx, diagnostics);
429
- checkPlurals(text, idx, opts.locale, diagnostics);
444
+ checkPlurals(text, idx, opts.locale, opts.knownVariables, diagnostics);
430
445
  checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
431
446
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
432
447
  checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
@@ -560,10 +575,12 @@ function checkPermutationConfigs(text, idx, out) {
560
575
  }
561
576
  }
562
577
  }
563
- function checkPlurals(text, idx, locale, out) {
578
+ function checkPlurals(text, idx, locale, knownVariables, out) {
564
579
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
565
580
  const arity = base !== "" ? pluralArity(base) : 0;
566
581
  const tainted = macroTaintedNames(text);
582
+ const { setDefs: macros, defDefs: defs } = extractDirectives(text);
583
+ const hostNames = new Set((knownVariables ?? []).map((n) => n.toLowerCase()));
567
584
  for (const block of findPluralBlocks(text)) {
568
585
  const at = span(idx, block.start, block.end - block.start);
569
586
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
@@ -579,8 +596,14 @@ function checkPlurals(text, idx, locale, out) {
579
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));
580
597
  continue;
581
598
  }
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;
582
606
  if (arity > 0) {
583
- const count = block.formsRaw.split("|").length;
584
607
  if (count !== arity) {
585
608
  out.push(err(
586
609
  "plural.arity",
@@ -588,10 +611,79 @@ function checkPlurals(text, idx, locale, out) {
588
611
  { ...at, data: { expected: arity, got: count } }
589
612
  ));
590
613
  }
614
+ } else if (count !== DEFAULT_PLURAL_ARITY) {
615
+ const unusable = locale !== void 0 && locale !== "";
616
+ out.push(warn(
617
+ "plural.locale-missing",
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
+ }
627
+ ));
591
628
  }
592
629
  }
593
630
  }
594
631
  var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
632
+ var ANY_BRACKET = /[[\]{}]/u;
633
+ var FORM_EXPANSION_PASSES = 51;
634
+ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
635
+ const unknown = (directMacroSpintax = false) => ({
636
+ forms: 0,
637
+ unresolved: true,
638
+ directMacroSpintax
639
+ });
640
+ const seenMacro = /* @__PURE__ */ new Set();
641
+ const walkMacros = (source) => {
642
+ for (const m of source.matchAll(/%(\w+)%/gu)) {
643
+ const name = (m[1] ?? "").toLowerCase();
644
+ if (defs[name] !== void 0 || hostNames.has(name)) continue;
645
+ const macro = macros[name];
646
+ if (macro === void 0 || seenMacro.has(name)) continue;
647
+ seenMacro.add(name);
648
+ if (/\{\?/u.test(macro)) return "opaque";
649
+ if (ANY_BRACKET.test(macro)) return "brackets";
650
+ const deeper = walkMacros(macro);
651
+ if (deeper !== "clean") return deeper;
652
+ }
653
+ return "clean";
654
+ };
655
+ const verbatim = walkMacros(formsRaw);
656
+ if (verbatim === "brackets") return unknown(true);
657
+ if (verbatim === "opaque") return unknown();
658
+ let text = formsRaw;
659
+ for (let pass = 0; pass < FORM_EXPANSION_PASSES; pass++) {
660
+ let sawReference = false;
661
+ let bailed = false;
662
+ text = text.replace(/%(\w+)%/gu, (whole, raw) => {
663
+ sawReference = true;
664
+ const name = raw.toLowerCase();
665
+ if (hostNames.has(name)) {
666
+ bailed = true;
667
+ return whole;
668
+ }
669
+ const value = defs[name] ?? macros[name];
670
+ if (value === void 0) {
671
+ bailed = true;
672
+ return whole;
673
+ }
674
+ if (ANY_BRACKET.test(value)) {
675
+ bailed = true;
676
+ return whole;
677
+ }
678
+ return value;
679
+ });
680
+ if (bailed) return unknown();
681
+ if (!sawReference) {
682
+ return { forms: text.split("|").length, unresolved: false, directMacroSpintax: false };
683
+ }
684
+ }
685
+ return unknown();
686
+ }
595
687
  function macroTaintedNames(text) {
596
688
  const macros = extractDirectives(text).setDefs;
597
689
  const tainted = /* @__PURE__ */ new Set();
@@ -941,14 +1033,63 @@ function expandVarsOnly(text, opts) {
941
1033
  }
942
1034
  return out;
943
1035
  }
944
- function renderConditional(node, opts) {
945
- const value = opts.vars[node.name.toLowerCase()];
1036
+ function conditionalTakesThen(name, inverted, opts) {
1037
+ const value = opts.vars[name.toLowerCase()];
946
1038
  const baseTruthy = value !== void 0 && /\S/u.test(value);
947
- const truthy = node.inverted ? !baseTruthy : baseTruthy;
1039
+ return inverted ? !baseTruthy : baseTruthy;
1040
+ }
1041
+ function renderConditional(node, opts) {
1042
+ const truthy = conditionalTakesThen(node.name, node.inverted, opts);
948
1043
  return renderNodes(truthy ? node.then : node.else, opts);
949
1044
  }
1045
+ function resolveCountConditionals(text, opts) {
1046
+ if (!text.includes("{?")) return text;
1047
+ const close = matchBraces(text);
1048
+ const out = [];
1049
+ const pending = [[0, text.length]];
1050
+ while (pending.length > 0) {
1051
+ const segment = pending.pop();
1052
+ const segEnd = segment[1];
1053
+ let i = segment[0];
1054
+ while (i < segEnd) {
1055
+ const open = text.indexOf("{?", i);
1056
+ if (open === -1 || open + 1 >= segEnd) {
1057
+ out.push(text.slice(i, segEnd));
1058
+ break;
1059
+ }
1060
+ const shut = close[open] ?? -1;
1061
+ const head = shut === -1 || shut >= segEnd ? null : recognizeConditional(text, open + 1, shut);
1062
+ if (head === null) {
1063
+ out.push(text.slice(i, Math.min(open + 2, segEnd)));
1064
+ i = open + 2;
1065
+ continue;
1066
+ }
1067
+ out.push(text.slice(i, open));
1068
+ const branchEnd = head.sepIndex < 0 ? shut : head.sepIndex;
1069
+ const [from, to] = conditionalTakesThen(head.name, head.inverted, opts) ? [head.bodyStart, branchEnd] : [head.sepIndex < 0 ? shut : head.sepIndex + 1, shut];
1070
+ pending.push([shut + 1, segEnd]);
1071
+ pending.push([from, to]);
1072
+ break;
1073
+ }
1074
+ }
1075
+ return out.join("");
1076
+ }
1077
+ function matchBraces(text) {
1078
+ const close = new Int32Array(text.length).fill(-1);
1079
+ const opens = [];
1080
+ for (let i = 0; i < text.length; i += 1) {
1081
+ const ch = text.charAt(i);
1082
+ if (ch === "{") {
1083
+ opens.push(i);
1084
+ } else if (ch === "}") {
1085
+ const open = opens.pop();
1086
+ if (open !== void 0) close[open] = i;
1087
+ }
1088
+ }
1089
+ return close;
1090
+ }
950
1091
  function renderPlural(node, opts) {
951
- const countRaw = expandVarsOnly(node.countRaw, opts);
1092
+ const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
952
1093
  const formsRaw = expandVarsOnly(node.formsRaw, opts);
953
1094
  const base = normalizeBaseLang(opts.locale);
954
1095
  const report = (issue) => opts.onPluralError?.(issue);