@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/CHANGELOG.md +114 -0
- package/dist/index.cjs +172 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +172 -26
- 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;
|
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|
952
|
-
const value = opts.vars[
|
|
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
|
-
|
|
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);
|