@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/CHANGELOG.md +97 -0
- package/dist/index.cjs +165 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +165 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 =
|
|
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
|
|
154
|
-
let p = 1;
|
|
153
|
+
function recognizeConditional(text, contentStart, contentEnd) {
|
|
154
|
+
let p = contentStart + 1;
|
|
155
155
|
let inverted = false;
|
|
156
|
-
if (
|
|
156
|
+
if (text.charAt(p) === "!") {
|
|
157
157
|
inverted = true;
|
|
158
158
|
p += 1;
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
164
|
+
if (text.charAt(p) !== "?") return null;
|
|
164
165
|
p += 1;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
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 =
|
|
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
|
|
945
|
-
const value = opts.vars[
|
|
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
|
-
|
|
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);
|