@spintax/core 0.1.6 → 0.3.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
@@ -1,5 +1,5 @@
1
1
  // src/internal/ast.ts
2
- var AST_VERSION = 1;
2
+ var AST_VERSION = 2;
3
3
  function walk(nodes, visit) {
4
4
  for (const n of nodes) {
5
5
  visit(n);
@@ -23,21 +23,39 @@ function isParsedAst(value) {
23
23
 
24
24
  // src/internal/parser.ts
25
25
  var VARIABLE_RE = /^%(\w+)%/;
26
- var SET_DIRECTIVE_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
26
+ var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
27
27
  var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
28
28
  var PLURAL_PREFIX = "plural ";
29
29
  function parseTemplate(src) {
30
- const { body, setDefs } = extractSetDirectives(stripComments(src));
31
- return { astVersion: AST_VERSION, source: src, setDefs, nodes: parseSequence(body) };
30
+ const { body, setDefs, defDefs } = extractDirectives(stripComments(src));
31
+ return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
32
32
  }
33
- function extractSetDirectives(text) {
33
+ function extractDirectives(text) {
34
34
  const setDefs = {};
35
- SET_DIRECTIVE_RE.lastIndex = 0;
36
- const stripped = text.replace(SET_DIRECTIVE_RE, (_full, name, value) => {
37
- setDefs[name.toLowerCase()] = value;
38
- return "";
39
- });
40
- return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs };
35
+ const defDefs = {};
36
+ const occurrences = [];
37
+ DIRECTIVE_RE.lastIndex = 0;
38
+ const stripped = text.replace(
39
+ DIRECTIVE_RE,
40
+ (full, kind, rawName, value, offset) => {
41
+ const name = rawName.toLowerCase();
42
+ occurrences.push({
43
+ kind,
44
+ name,
45
+ value,
46
+ line: countLines(text, offset)
47
+ });
48
+ if (kind === "def") defDefs[name] = value;
49
+ else setDefs[name] = value;
50
+ return "";
51
+ }
52
+ );
53
+ return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
54
+ }
55
+ function countLines(text, offset) {
56
+ let line = 1;
57
+ for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
58
+ return line;
41
59
  }
42
60
  function stripComments(text) {
43
61
  return text.replace(/\/#[\s\S]*?#\//g, "");
@@ -342,6 +360,9 @@ function pluralArity(baseLang) {
342
360
  case "ru":
343
361
  case "uk":
344
362
  case "be":
363
+ case "sr":
364
+ case "hr":
365
+ case "bs":
345
366
  return 3;
346
367
  default:
347
368
  return 2;
@@ -355,6 +376,9 @@ function pluralFor(baseLang, n, forms) {
355
376
  case "ru":
356
377
  case "uk":
357
378
  case "be":
379
+ case "sr":
380
+ case "hr":
381
+ case "bs":
358
382
  if (mod10 === 1 && mod100 !== 11) return forms[0] ?? "";
359
383
  if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1] ?? "";
360
384
  return forms[2] ?? "";
@@ -370,7 +394,7 @@ function validateTemplate(src, opts = {}) {
370
394
  const diagnostics = [];
371
395
  const text = stripComments(src);
372
396
  checkBrackets(text, diagnostics);
373
- checkSetDirectives(text, diagnostics);
397
+ checkDirectives(text, diagnostics);
374
398
  checkPermutationConfigs(text, diagnostics);
375
399
  checkPlurals(text, opts.locale, diagnostics);
376
400
  checkVariableReferences(text, opts.knownVariables, diagnostics);
@@ -429,17 +453,43 @@ function checkBrackets(text, out) {
429
453
  out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, { line: unclosed.line, column: unclosed.column, endLine: unclosed.line, endColumn: unclosed.column + 1, data: { bracket: unclosed.char } }));
430
454
  }
431
455
  }
432
- function checkSetDirectives(text, out) {
456
+ function checkDirectives(text, out) {
433
457
  const lines = text.split("\n");
434
458
  lines.forEach((lineText, idx) => {
435
459
  const trimmed = lineText.replace(/^[ \t]+/, "");
436
- if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
437
- if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
460
+ const kind = ["#set", "#def"].find(
461
+ (candidate) => trimmed.startsWith(`${candidate} `) || trimmed.startsWith(`${candidate} `)
462
+ );
463
+ if (!kind) return;
464
+ DIRECTIVE_RE.lastIndex = 0;
465
+ if (!DIRECTIVE_RE.test(trimmed)) {
438
466
  const column = lineText.length - trimmed.length + 1;
439
467
  const line = idx + 1;
440
- out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
468
+ const code = kind === "#def" ? "def.malformed" : "set.malformed";
469
+ out.push(err(code, `Malformed ${kind}. Expected: ${kind} %name% = value`, { line, column, endLine: line, endColumn: lineText.length + 1 }));
441
470
  }
442
471
  });
472
+ const { occurrences } = extractDirectives(text);
473
+ const seen = /* @__PURE__ */ new Map();
474
+ for (const occurrence of occurrences) {
475
+ const first = seen.get(occurrence.name);
476
+ if (first !== void 0) {
477
+ out.push(err(
478
+ "definition.duplicate-name",
479
+ `Variable '${occurrence.name}' is defined more than once (first on line ${first}). A name belongs to one directive, once.`,
480
+ { line: occurrence.line, column: 1 }
481
+ ));
482
+ } else {
483
+ seen.set(occurrence.name, occurrence.line);
484
+ }
485
+ if (occurrence.kind === "def" && /#include\b/u.test(occurrence.value)) {
486
+ out.push(err(
487
+ "def.include-in-value",
488
+ `#include cannot appear in a #def value ('${occurrence.name}'): includes resolve after the value is frozen. Use #set, or put the #include in the body.`,
489
+ { line: occurrence.line, column: 1 }
490
+ ));
491
+ }
492
+ }
443
493
  }
444
494
  function checkPermutationConfigs(text, out) {
445
495
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
@@ -477,10 +527,20 @@ function checkPermutationConfigs(text, out) {
477
527
  function checkPlurals(text, locale, out) {
478
528
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
479
529
  const arity = base !== "" ? pluralArity(base) : 0;
530
+ const tainted = macroTaintedNames(text);
480
531
  for (const block of findPluralBlocks(text)) {
481
532
  const at = span(text, block.start, block.end - block.start);
533
+ for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
534
+ const name = (m[1] ?? "").toLowerCase();
535
+ if (!tainted.has(name)) continue;
536
+ out.push(err(
537
+ "plural.count-macro",
538
+ `{plural ...}: the count '${m[1]}' is a #set macro, so it is still unresolved spintax when the plural is decided and the block renders empty. Define it with #def instead.`,
539
+ at
540
+ ));
541
+ }
482
542
  if (/[{}[\]]/.test(block.formsRaw)) {
483
- out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
543
+ 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));
484
544
  continue;
485
545
  }
486
546
  if (arity > 0) {
@@ -495,11 +555,33 @@ function checkPlurals(text, locale, out) {
495
555
  }
496
556
  }
497
557
  }
558
+ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
559
+ function macroTaintedNames(text) {
560
+ const macros = extractDirectives(text).setDefs;
561
+ const tainted = /* @__PURE__ */ new Set();
562
+ for (const [name, value] of Object.entries(macros)) {
563
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
564
+ }
565
+ let grew = true;
566
+ while (grew) {
567
+ grew = false;
568
+ for (const [name, value] of Object.entries(macros)) {
569
+ if (tainted.has(name)) continue;
570
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
571
+ if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
572
+ tainted.add(name);
573
+ grew = true;
574
+ break;
575
+ }
576
+ }
577
+ }
578
+ return tainted;
579
+ }
498
580
  function checkVariableReferences(text, known, out) {
499
581
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
500
582
  const defs = /* @__PURE__ */ new Map();
501
583
  const defPos = /* @__PURE__ */ new Map();
502
- for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
584
+ for (const m of text.matchAll(/^[ \t]*#(?:set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
503
585
  const name = (m[1] ?? "").toLowerCase();
504
586
  defs.set(name, m[2] ?? "");
505
587
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
@@ -514,7 +596,7 @@ function checkVariableReferences(text, known, out) {
514
596
  for (const name of defs.keys()) {
515
597
  detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
516
598
  }
517
- const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
599
+ const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
518
600
  const seen = /* @__PURE__ */ new Set();
519
601
  const undefinedAt = (name, offset, length) => {
520
602
  const key = name.toLowerCase();
@@ -561,19 +643,21 @@ function checkIncludeTargets(text, known, out) {
561
643
 
562
644
  // src/internal/extract.ts
563
645
  var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
564
- var SET_LHS_RE = /^[ \t]*#set[ \t]+%\w+%[ \t]*=/gmu;
646
+ var DEF_DEF_RE = /^[ \t]*#def[ \t]+%(\w+)%[ \t]*=/gmu;
647
+ var DEFINITION_LHS_RE = /^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=/gmu;
565
648
  var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
566
649
  var VARIABLE_RE2 = /%(\w+)%/gu;
567
650
  var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
568
651
  function extractFromSource(src) {
569
652
  const text = stripComments(src);
570
653
  const sets = collect(text, SET_DEF_RE, true);
654
+ const defs = collect(text, DEF_DEF_RE, true);
571
655
  const includes = collect(text, INCLUDE_RE2, false);
572
- const body = text.replace(SET_LHS_RE, "");
656
+ const body = text.replace(DEFINITION_LHS_RE, "");
573
657
  const refs = /* @__PURE__ */ new Set();
574
658
  addAll(refs, body, VARIABLE_RE2, true);
575
659
  addAll(refs, body, CONDITIONAL_REF_RE, true);
576
- return { refs: [...refs], sets, includes };
660
+ return { refs: [...refs], sets, defs, includes };
577
661
  }
578
662
  function collect(text, re, fold) {
579
663
  const seen = /* @__PURE__ */ new Set();
@@ -645,8 +729,15 @@ function stripSentinels(text) {
645
729
  var MAX_VARIABLE_DEPTH = 50;
646
730
  var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
647
731
  function renderAst(ast, ctx) {
648
- const vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
649
- const text = renderNodes(ast.nodes, { vars, rng: ctx.rng, locale: ctx.locale, depth: 0 });
732
+ const base = buildVars(ast.setDefs, ctx.runtimeContext);
733
+ const walkOpts = {
734
+ rng: ctx.rng,
735
+ locale: ctx.locale,
736
+ depth: 0,
737
+ onPluralError: ctx.onPluralError
738
+ };
739
+ const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
740
+ const text = renderNodes(ast.nodes, { ...walkOpts, vars });
650
741
  return ctx.resolver ? resolveIncludes(text, ctx) : text;
651
742
  }
652
743
  function resolveIncludes(text, ctx) {
@@ -668,33 +759,65 @@ function resolveIncludes(text, ctx) {
668
759
  });
669
760
  });
670
761
  }
671
- function buildVars(setDefs, context, rng) {
762
+ function buildVars(setDefs, context) {
672
763
  const vars = {};
673
764
  for (const [name, value] of Object.entries(setDefs)) {
674
- vars[name] = collapseSetValue(value, rng);
765
+ vars[name] = value;
675
766
  }
676
767
  for (const [name, value] of Object.entries(context)) {
677
768
  vars[name.toLowerCase()] = value;
678
769
  }
679
770
  return vars;
680
771
  }
681
- function collapseSetValue(value, rng) {
682
- if (!value.includes("{")) return value;
683
- if (value.includes("{?") || value.includes("{plural ")) return value;
684
- return resolveEnumerationsString(value, rng);
685
- }
686
- function resolveEnumerationsString(text, rng) {
687
- let out = text;
688
- for (let guard = 0; guard < 1e3; guard += 1) {
689
- let changed = false;
690
- out = out.replace(/\{([^{}]*)\}/gu, (_m, inner) => {
691
- changed = true;
692
- const options = splitTopLevelPipes(inner);
693
- return options.length === 0 ? "" : options[randomInt(rng, 0, options.length - 1)] ?? "";
772
+ function rollDefinitions(defDefs, vars, context, opts) {
773
+ const outranked = new Set(Object.keys(context).map((key) => key.toLowerCase()));
774
+ const rolled = {};
775
+ const aliases = {};
776
+ for (const [name, value] of Object.entries(vars)) {
777
+ if (name in defDefs && !outranked.has(name)) continue;
778
+ aliases[name] = value;
779
+ }
780
+ for (const name of orderDefinitions(defDefs, aliases)) {
781
+ if (outranked.has(name)) continue;
782
+ const value = defDefs[name] ?? "";
783
+ rolled[name] = renderNodes(parseSequence(value), { ...opts, vars: { ...vars, ...rolled } });
784
+ }
785
+ return rolled;
786
+ }
787
+ function orderDefinitions(defDefs, aliases) {
788
+ const names = Object.keys(defDefs);
789
+ const blocked = /* @__PURE__ */ new Map();
790
+ for (const name of names) {
791
+ const reached = referencedNames(defDefs[name] ?? "", aliases);
792
+ blocked.set(name, new Set(names.filter((candidate) => reached.has(candidate))));
793
+ }
794
+ const ordered = [];
795
+ let pending = names;
796
+ while (pending.length > 0) {
797
+ const ready = pending.filter((name) => {
798
+ const deps = blocked.get(name);
799
+ return !deps || ![...deps].some((dep) => dep !== name && pending.includes(dep));
694
800
  });
695
- if (!changed) break;
801
+ if (ready.length === 0) return [...ordered, ...pending];
802
+ ordered.push(...ready);
803
+ pending = pending.filter((name) => !ready.includes(name));
696
804
  }
697
- return out;
805
+ return ordered;
806
+ }
807
+ function referencedNames(value, aliases) {
808
+ const seen = /* @__PURE__ */ new Set();
809
+ const queue = directReferences(value);
810
+ while (queue.length > 0) {
811
+ const name = queue.shift();
812
+ if (seen.has(name)) continue;
813
+ seen.add(name);
814
+ const alias = aliases[name];
815
+ if (alias !== void 0) queue.push(...directReferences(alias));
816
+ }
817
+ return seen;
818
+ }
819
+ function directReferences(text) {
820
+ return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
698
821
  }
699
822
  function renderNodes(nodes, opts) {
700
823
  let out = "";
@@ -749,17 +872,47 @@ function renderConditional(node, opts) {
749
872
  function renderPlural(node, opts) {
750
873
  const countRaw = expandVarsOnly(node.countRaw, opts);
751
874
  const formsRaw = expandVarsOnly(node.formsRaw, opts);
752
- if (/[{}[\]]/u.test(formsRaw)) return fullwidthVerbatim(countRaw, formsRaw);
753
- const count = phpTrim2(countRaw);
754
- if (!/^-?\d+$/u.test(count)) return "";
755
875
  const base = normalizeBaseLang(opts.locale);
876
+ const report = (issue) => opts.onPluralError?.(issue);
877
+ if (/[{}[\]]/u.test(formsRaw)) {
878
+ report({
879
+ code: "plural.nested-brackets",
880
+ message: "Plural form slot contains nested spintax brackets; extract via #set first.",
881
+ construct: rawConstruct(countRaw, formsRaw),
882
+ locale: base
883
+ });
884
+ return fullwidthVerbatim(countRaw, formsRaw);
885
+ }
886
+ const count = phpTrim2(countRaw);
887
+ if (!/^-?\d+$/u.test(count)) {
888
+ report({
889
+ code: "plural.count",
890
+ message: `Plural count slot is empty or non-numeric (${JSON.stringify(count)}); block erased.`,
891
+ construct: rawConstruct(countRaw, formsRaw),
892
+ locale: base
893
+ });
894
+ return "";
895
+ }
756
896
  const forms = formsRaw.split("|").map((f) => phpTrim2(f));
757
- if (forms.length !== pluralArity(base)) return fullwidthVerbatim(countRaw, formsRaw);
897
+ if (forms.length !== pluralArity(base)) {
898
+ report({
899
+ code: "plural.arity",
900
+ message: `Plural has ${forms.length} form(s); locale "${base}" takes ${pluralArity(base)}.`,
901
+ construct: rawConstruct(countRaw, formsRaw),
902
+ locale: base,
903
+ expected: pluralArity(base),
904
+ got: forms.length
905
+ });
906
+ return fullwidthVerbatim(countRaw, formsRaw);
907
+ }
758
908
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
759
909
  return renderNodes(parseSequence(picked), opts);
760
910
  }
911
+ function rawConstruct(countRaw, formsRaw) {
912
+ return `{plural ${countRaw}:${formsRaw}}`;
913
+ }
761
914
  function fullwidthVerbatim(countRaw, formsRaw) {
762
- return `{plural ${countRaw}:${formsRaw}}`.replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
915
+ return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
763
916
  }
764
917
  function renderEnumeration(options, opts) {
765
918
  if (options.length === 0) return "";
@@ -823,26 +976,6 @@ function padSeparator(sep) {
823
976
  if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
824
977
  return sep;
825
978
  }
826
- function splitTopLevelPipes(inner) {
827
- const parts = [];
828
- let brace = 0;
829
- let bracket = 0;
830
- let cur = "";
831
- for (const ch of inner) {
832
- if (ch === "{") brace += 1;
833
- else if (ch === "}") brace -= 1;
834
- else if (ch === "[") bracket += 1;
835
- else if (ch === "]") bracket -= 1;
836
- if (ch === "|" && brace === 0 && bracket === 0) {
837
- parts.push(cur);
838
- cur = "";
839
- } else {
840
- cur += ch;
841
- }
842
- }
843
- parts.push(cur);
844
- return parts;
845
- }
846
979
  var PHP_LTRIM_RE2 = /^[ \t\n\r\0\x0B]+/u;
847
980
  var PHP_RTRIM_RE2 = /[ \t\n\r\0\x0B]+$/u;
848
981
  function phpTrim2(s) {
@@ -975,7 +1108,8 @@ function renderWith(input, rng, opts = {}) {
975
1108
  locale: opts.locale ?? "",
976
1109
  resolver: opts.includeResolver,
977
1110
  maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
978
- includeStack: []
1111
+ includeStack: [],
1112
+ onPluralError: opts.onPluralError
979
1113
  };
980
1114
  const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
981
1115
  let out = renderAst(ast, ctx);
@@ -1012,6 +1146,12 @@ function hashString(s) {
1012
1146
  }
1013
1147
 
1014
1148
  // src/index.ts
1149
+ function normalizeBaseLang2(locale) {
1150
+ return normalizeBaseLang(locale ?? "");
1151
+ }
1152
+ function pluralArity2(locale) {
1153
+ return pluralArity(normalizeBaseLang2(locale));
1154
+ }
1015
1155
  function parse(src) {
1016
1156
  return parseTemplate(src);
1017
1157
  }
@@ -1062,6 +1202,6 @@ function neutralize2(value) {
1062
1202
  return neutralize(value);
1063
1203
  }
1064
1204
 
1065
- export { AstVersionError, DEFAULT_MAX_DEPTH, IncludeResolverError, NotImplementedError, SpintaxError, analyze, extract, neutralize2 as neutralize, parse, render, validate };
1205
+ export { AstVersionError, DEFAULT_MAX_DEPTH, IncludeResolverError, NotImplementedError, SpintaxError, analyze, extract, neutralize2 as neutralize, normalizeBaseLang2 as normalizeBaseLang, parse, pluralArity2 as pluralArity, render, validate };
1066
1206
  //# sourceMappingURL=index.js.map
1067
1207
  //# sourceMappingURL=index.js.map