@spintax/core 0.6.0 → 0.7.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.d.cts CHANGED
@@ -21,8 +21,12 @@
21
21
  * 2 — `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries no `#def` map, so
22
22
  * rendering it would silently drop every definition; the version guard turns that into an
23
23
  * `AstVersionError` instead.
24
+ *
25
+ * 3 — `EnumerationNode` and `PermutationNode` gained `raw`, the body a direct `%var%` reference
26
+ * is spliced into at render time. An `Ast` cached by version 2 carries no `raw`, so rendering it
27
+ * would silently keep a pipe-joined value as ONE option — the defect 0.7.0 fixed.
24
28
  */
25
- declare const AST_VERSION = 2;
29
+ declare const AST_VERSION = 3;
26
30
  /** Opaque public handle (re-exported as `Ast` from the package index). */
27
31
  interface Ast {
28
32
  readonly astVersion: typeof AST_VERSION;
@@ -42,6 +46,10 @@ interface Ast {
42
46
  * - conditionals test truthiness against the raw var map; plurals resolve the
43
47
  * count (vars already expanded) then pick the bucket, lenient fullwidth
44
48
  * fallback (Stage 6d, after vars).
49
+ * - a `%var%` that sits DIRECTLY in an enumeration/permutation body is spliced as
50
+ * TEXT and the construct re-read (spliceConstruct, 0.7.0): a `|` inside such a
51
+ * value separates options, exactly as in the plugin, whose expansion runs before
52
+ * any bracket is read. Every other construct keeps the tree it was parsed into.
45
53
  * - #include (post-tree string pass) and post-process are later PRs.
46
54
  *
47
55
  * RNG note: cross-engine RNG-sequence parity is a non-goal (§3.2). Enumerations
package/dist/index.d.ts CHANGED
@@ -21,8 +21,12 @@
21
21
  * 2 — `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries no `#def` map, so
22
22
  * rendering it would silently drop every definition; the version guard turns that into an
23
23
  * `AstVersionError` instead.
24
+ *
25
+ * 3 — `EnumerationNode` and `PermutationNode` gained `raw`, the body a direct `%var%` reference
26
+ * is spliced into at render time. An `Ast` cached by version 2 carries no `raw`, so rendering it
27
+ * would silently keep a pipe-joined value as ONE option — the defect 0.7.0 fixed.
24
28
  */
25
- declare const AST_VERSION = 2;
29
+ declare const AST_VERSION = 3;
26
30
  /** Opaque public handle (re-exported as `Ast` from the package index). */
27
31
  interface Ast {
28
32
  readonly astVersion: typeof AST_VERSION;
@@ -42,6 +46,10 @@ interface Ast {
42
46
  * - conditionals test truthiness against the raw var map; plurals resolve the
43
47
  * count (vars already expanded) then pick the bucket, lenient fullwidth
44
48
  * fallback (Stage 6d, after vars).
49
+ * - a `%var%` that sits DIRECTLY in an enumeration/permutation body is spliced as
50
+ * TEXT and the construct re-read (spliceConstruct, 0.7.0): a `|` inside such a
51
+ * value separates options, exactly as in the plugin, whose expansion runs before
52
+ * any bracket is read. Every other construct keeps the tree it was parsed into.
45
53
  * - #include (post-tree string pass) and post-process are later PRs.
46
54
  *
47
55
  * RNG note: cross-engine RNG-sequence parity is a non-goal (§3.2). Enumerations
package/dist/index.js CHANGED
@@ -1,19 +1,32 @@
1
1
  // src/internal/ast.ts
2
- var AST_VERSION = 2;
2
+ var AST_VERSION = 3;
3
3
  function walk(nodes, visit) {
4
- for (const n of nodes) {
4
+ const stack = [{ list: nodes, i: 0 }];
5
+ while (stack.length > 0) {
6
+ const top = stack[stack.length - 1];
7
+ if (top.i >= top.list.length) {
8
+ stack.pop();
9
+ continue;
10
+ }
11
+ const n = top.list[top.i];
12
+ top.i += 1;
5
13
  visit(n);
14
+ let children;
6
15
  switch (n.type) {
7
16
  case "enumeration":
8
- for (const opt of n.options) walk(opt, visit);
17
+ children = n.options;
9
18
  break;
10
19
  case "conditional":
11
- walk(n.then, visit);
12
- walk(n.else, visit);
20
+ children = [n.then, n.else];
13
21
  break;
14
22
  case "permutation":
15
- for (const opt of n.options) walk(opt.nodes, visit);
23
+ children = n.options.map((opt) => opt.nodes);
16
24
  break;
25
+ default:
26
+ continue;
27
+ }
28
+ for (let k = children.length - 1; k >= 0; k -= 1) {
29
+ stack.push({ list: children[k], i: 0 });
17
30
  }
18
31
  }
19
32
  }
@@ -90,65 +103,114 @@ function stripComments(text) {
90
103
  return text.replace(/\/#[\s\S]*?#\//g, "");
91
104
  }
92
105
  function parseSequence(text) {
93
- const nodes = [];
94
- let literal = "";
95
- let i = 0;
96
- const flushLiteral = () => {
97
- if (literal !== "") {
98
- nodes.push({ type: "literal", value: literal });
99
- literal = "";
100
- }
101
- };
102
- while (i < text.length) {
103
- const ch = text.charAt(i);
104
- if (ch === "{") {
105
- const end = findMatchingClose(text, i, "{", "}");
106
- if (end === -1) {
107
- literal += ch;
108
- i += 1;
106
+ const frame = (t) => ({ text: t, i: 0, literal: "", nodes: [], plan: null, parts: [] });
107
+ const stack = [frame(text)];
108
+ while (stack.length > 0) {
109
+ const f = stack[stack.length - 1];
110
+ if (f.plan !== null) {
111
+ if (f.parts.length < f.plan.texts.length) {
112
+ stack.push(frame(f.plan.texts[f.parts.length]));
109
113
  continue;
110
114
  }
111
- const content = text.slice(i + 1, end);
112
- flushLiteral();
113
- nodes.push(parseBraceConstruct(content));
114
- i = end + 1;
115
+ f.nodes.push(f.plan.build(f.parts));
116
+ f.plan = null;
117
+ f.parts = [];
115
118
  continue;
116
119
  }
117
- if (ch === "[") {
118
- const end = findMatchingClose(text, i, "[", "]");
119
- if (end === -1) {
120
- literal += ch;
121
- i += 1;
122
- continue;
120
+ const flushLiteral = () => {
121
+ if (f.literal !== "") {
122
+ f.nodes.push({ type: "literal", value: f.literal });
123
+ f.literal = "";
123
124
  }
124
- flushLiteral();
125
- nodes.push(parsePermutation(text.slice(i + 1, end)));
126
- i = end + 1;
127
- continue;
128
- }
129
- if (ch === "%") {
130
- const name = VARIABLE_RE.exec(text.slice(i))?.[1];
131
- if (name !== void 0) {
125
+ };
126
+ let planned = null;
127
+ while (f.i < f.text.length && planned === null) {
128
+ const ch = f.text.charAt(f.i);
129
+ if (ch === "{" || ch === "[") {
130
+ const close = ch === "{" ? "}" : "]";
131
+ const end = findMatchingClose(f.text, f.i, ch, close);
132
+ if (end === -1) {
133
+ f.literal += ch;
134
+ f.i += 1;
135
+ continue;
136
+ }
137
+ const inner = f.text.slice(f.i + 1, end);
132
138
  flushLiteral();
133
- nodes.push({ type: "variable", name });
134
- i += name.length + 2;
139
+ planned = ch === "{" ? planBraceConstruct(inner) : planPermutation(inner);
140
+ f.i = end + 1;
135
141
  continue;
136
142
  }
143
+ if (ch === "%") {
144
+ const name = VARIABLE_RE.exec(f.text.slice(f.i))?.[1];
145
+ if (name !== void 0) {
146
+ flushLiteral();
147
+ f.nodes.push({ type: "variable", name });
148
+ f.i += name.length + 2;
149
+ continue;
150
+ }
151
+ }
152
+ f.literal += ch;
153
+ f.i += 1;
137
154
  }
138
- literal += ch;
139
- i += 1;
155
+ if (planned !== null) {
156
+ if ("node" in planned) f.nodes.push(planned.node);
157
+ else f.plan = planned;
158
+ continue;
159
+ }
160
+ flushLiteral();
161
+ stack.pop();
162
+ const parent = stack[stack.length - 1];
163
+ if (parent !== void 0) parent.parts.push(f.nodes);
164
+ else return f.nodes;
140
165
  }
141
- flushLiteral();
142
- return nodes;
166
+ return [];
143
167
  }
144
- function parseBraceConstruct(content) {
168
+ function planBraceConstruct(content) {
145
169
  if (content.charAt(0) === "?") {
146
- const cond = tryParseConditional(content);
147
- if (cond) return cond;
170
+ const parts = splitConditional(content);
171
+ if (parts !== null) {
172
+ return {
173
+ texts: [parts.thenRaw, parts.elseRaw],
174
+ build: (children) => ({
175
+ type: "conditional",
176
+ name: parts.name,
177
+ inverted: parts.inverted,
178
+ then: children[0] ?? [],
179
+ else: children[1] ?? []
180
+ })
181
+ };
182
+ }
148
183
  } else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
149
- return parsePlural(content.slice(PLURAL_PREFIX.length));
184
+ return { node: parsePlural(content.slice(PLURAL_PREFIX.length)) };
150
185
  }
151
- return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
186
+ return {
187
+ texts: splitTopLevel(content),
188
+ build: (children) => hasDirectReference(children) ? { type: "enumeration", options: children, raw: content } : { type: "enumeration", options: children }
189
+ };
190
+ }
191
+ function hasDirectReference(lists) {
192
+ const stack = [...lists];
193
+ while (stack.length > 0) {
194
+ const list = stack.pop();
195
+ for (const node of list) {
196
+ if (node.type === "variable") return true;
197
+ if (node.type === "conditional") stack.push(node.then, node.else);
198
+ }
199
+ }
200
+ return false;
201
+ }
202
+ var REFERENCE_RE = /%\w+%/u;
203
+ function planPermutation(rawInner) {
204
+ const { config, content } = extractPermutationConfig(rawInner);
205
+ const { texts, separators } = permutationElements(splitTopLevel(content));
206
+ const separatorHasRef = REFERENCE_RE.test(config.sep) || config.lastsep !== null && REFERENCE_RE.test(config.lastsep) || separators.some((sep) => sep !== null && REFERENCE_RE.test(sep));
207
+ return {
208
+ texts,
209
+ build: (children) => {
210
+ const options = children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }));
211
+ return separatorHasRef || hasDirectReference(children) ? { type: "permutation", config, options, raw: rawInner } : { type: "permutation", config, options };
212
+ }
213
+ };
152
214
  }
153
215
  function recognizeConditional(text, contentStart, contentEnd) {
154
216
  let p = contentStart + 1;
@@ -177,17 +239,6 @@ function splitConditional(content) {
177
239
  elseRaw: sep < 0 ? "" : body.slice(sep + 1)
178
240
  };
179
241
  }
180
- function tryParseConditional(content) {
181
- const parts = splitConditional(content);
182
- if (parts === null) return null;
183
- return {
184
- type: "conditional",
185
- name: parts.name,
186
- inverted: parts.inverted,
187
- then: parseSequence(parts.thenRaw),
188
- else: parseSequence(parts.elseRaw)
189
- };
190
- }
191
242
  function parsePlural(afterPrefix) {
192
243
  const colon = afterPrefix.indexOf(":");
193
244
  return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
@@ -202,11 +253,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
202
253
  function defaultPermConfig() {
203
254
  return { minsize: null, maxsize: null, sep: " ", lastsep: null };
204
255
  }
205
- function parsePermutation(rawInner) {
206
- const { config, content } = extractPermutationConfig(rawInner);
207
- const options = extractPerElementSeparators(splitTopLevel(content));
208
- return { type: "permutation", config, options };
209
- }
210
256
  function extractPermutationConfig(content) {
211
257
  const trimmed = phpLtrim(content);
212
258
  if (trimmed === "" || trimmed.charAt(0) !== "<") {
@@ -250,8 +296,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
250
296
  const tagName = (m[1] ?? "").toLowerCase();
251
297
  return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
252
298
  }
253
- function extractPerElementSeparators(rawParts) {
254
- const options = [];
299
+ function permutationElements(rawParts) {
300
+ const texts = [];
301
+ const separators = [];
255
302
  let pendingSep = null;
256
303
  rawParts.forEach((part, i) => {
257
304
  let text = part;
@@ -265,11 +312,12 @@ function extractPerElementSeparators(rawParts) {
265
312
  }
266
313
  const trimmed = phpTrim(text);
267
314
  if (trimmed !== "") {
268
- options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
315
+ texts.push(trimmed);
316
+ separators.push(pendingSep);
269
317
  }
270
318
  pendingSep = trailingSep;
271
319
  });
272
- return options;
320
+ return { texts, separators };
273
321
  }
274
322
  function extractTrailingSep(part) {
275
323
  const trimmed = phpRtrim(part);
@@ -1014,9 +1062,34 @@ function directReferences(text) {
1014
1062
  return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
1015
1063
  }
1016
1064
  function renderNodes(nodes, opts) {
1017
- let out = "";
1018
- for (const node of nodes) out += renderNode(node, opts);
1019
- return out;
1065
+ const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
1066
+ const stack = [frame(nodes)];
1067
+ while (stack.length > 0) {
1068
+ const f = stack[stack.length - 1];
1069
+ if (f.pending !== null) {
1070
+ if (f.pending.done.length < f.pending.lists.length) {
1071
+ stack.push(frame(f.pending.lists[f.pending.done.length]));
1072
+ continue;
1073
+ }
1074
+ f.out.push(f.pending.assemble(f.pending.done));
1075
+ f.pending = null;
1076
+ continue;
1077
+ }
1078
+ if (f.i >= f.nodes.length) {
1079
+ const text = f.out.join("");
1080
+ stack.pop();
1081
+ const parent = stack[stack.length - 1];
1082
+ if (parent === void 0) return text;
1083
+ parent.pending.done.push(text);
1084
+ continue;
1085
+ }
1086
+ const node = f.nodes[f.i];
1087
+ f.i += 1;
1088
+ const step = renderNode(node, opts);
1089
+ if (typeof step === "string") f.out.push(step);
1090
+ else f.pending = step;
1091
+ }
1092
+ return "";
1020
1093
  }
1021
1094
  function renderNode(node, opts) {
1022
1095
  switch (node.type) {
@@ -1025,7 +1098,7 @@ function renderNode(node, opts) {
1025
1098
  case "variable":
1026
1099
  return resolveVariable(node.name, opts);
1027
1100
  case "enumeration":
1028
- return renderEnumeration(node.options, opts);
1101
+ return renderEnumeration(node, opts);
1029
1102
  case "permutation":
1030
1103
  return renderPermutation(node, opts);
1031
1104
  case "conditional":
@@ -1039,15 +1112,20 @@ function randomInt(rng, min, max) {
1039
1112
  }
1040
1113
  function resolveVariable(name, opts) {
1041
1114
  const value = opts.vars[name.toLowerCase()];
1042
- if (value === void 0) return `%${name}%`;
1043
- if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1115
+ if (value === void 0 || opts.frozen === true) return `%${name}%`;
1044
1116
  if (opts.budget.left <= 0) return `%${name}%`;
1045
1117
  opts.budget.left -= value.length;
1118
+ if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1046
1119
  return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
1047
1120
  }
1048
- function expandVarsOnly(text, opts) {
1121
+ function passesLeft(opts) {
1122
+ return Math.max(1, MAX_VARIABLE_DEPTH - opts.depth + 1);
1123
+ }
1124
+ function expandVarsFixpoint(text, opts, passes) {
1125
+ if (opts.frozen === true) return { text, changed: false, converged: true };
1049
1126
  let out = text;
1050
- for (let i = 0; i < MAX_VARIABLE_DEPTH; i += 1) {
1127
+ let changedAny = false;
1128
+ for (let i = 0; i < passes; i += 1) {
1051
1129
  let changed = false;
1052
1130
  out = out.replace(/%(\w+)%/gu, (m, name) => {
1053
1131
  const value = opts.vars[name.toLowerCase()];
@@ -1057,9 +1135,10 @@ function expandVarsOnly(text, opts) {
1057
1135
  changed = true;
1058
1136
  return value;
1059
1137
  });
1060
- if (!changed) break;
1138
+ if (!changed) return { text: out, changed: changedAny, converged: true };
1139
+ changedAny = true;
1061
1140
  }
1062
- return out;
1141
+ return { text: out, changed: changedAny, converged: false };
1063
1142
  }
1064
1143
  function conditionalTakesThen(name, inverted, opts) {
1065
1144
  const value = opts.vars[name.toLowerCase()];
@@ -1068,9 +1147,9 @@ function conditionalTakesThen(name, inverted, opts) {
1068
1147
  }
1069
1148
  function renderConditional(node, opts) {
1070
1149
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1071
- return renderNodes(truthy ? node.then : node.else, opts);
1150
+ return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1072
1151
  }
1073
- function resolveCountConditionals(text, opts) {
1152
+ function resolveConditionalsInText(text, opts) {
1074
1153
  if (!text.includes("{?")) return text;
1075
1154
  const close = matchBraces(text);
1076
1155
  const out = [];
@@ -1117,8 +1196,11 @@ function matchBraces(text) {
1117
1196
  return close;
1118
1197
  }
1119
1198
  function renderPlural(node, opts) {
1120
- const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
1121
- const formsRaw = expandVarsOnly(node.formsRaw, opts);
1199
+ const passes = passesLeft(opts);
1200
+ const countPass = expandVarsFixpoint(node.countRaw, opts, passes);
1201
+ const formsPass = expandVarsFixpoint(node.formsRaw, opts, passes);
1202
+ const countRaw = resolveConditionalsInText(countPass.text, opts);
1203
+ const formsRaw = formsPass.text;
1122
1204
  const base = normalizeBaseLang(opts.locale);
1123
1205
  const report = (issue) => opts.onPluralError?.(issue);
1124
1206
  if (/[{}[\]]/u.test(formsRaw)) {
@@ -1153,7 +1235,8 @@ function renderPlural(node, opts) {
1153
1235
  return fullwidthVerbatim(countRaw, formsRaw);
1154
1236
  }
1155
1237
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1156
- return renderNodes(parseSequence(picked), opts);
1238
+ if (!formsPass.converged) return renderNodes(parseSequence(picked), { ...opts, frozen: true });
1239
+ return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1157
1240
  }
1158
1241
  function rawConstruct(countRaw, formsRaw) {
1159
1242
  return `{plural ${countRaw}:${formsRaw}}`;
@@ -1161,16 +1244,36 @@ function rawConstruct(countRaw, formsRaw) {
1161
1244
  function fullwidthVerbatim(countRaw, formsRaw) {
1162
1245
  return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
1163
1246
  }
1164
- function renderEnumeration(options, opts) {
1247
+ function renderEnumeration(node, opts) {
1248
+ const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "{", "}", opts);
1249
+ if (spliced !== null) return spliced;
1250
+ const { options } = node;
1165
1251
  if (options.length === 0) return "";
1166
1252
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1167
- return picked ? renderNodes(picked, opts) : "";
1253
+ if (!picked) return "";
1254
+ return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
1255
+ }
1256
+ function spliceConstruct(raw, open, close, opts) {
1257
+ if (opts.frozen === true) return null;
1258
+ const expanded = expandVarsFixpoint(resolveConditionalsInText(raw, opts), opts, passesLeft(opts));
1259
+ const body = resolveConditionalsInText(expanded.text, opts);
1260
+ if (body === raw) return null;
1261
+ const nodes = parseSequence(open + body + close);
1262
+ if (!expanded.converged) return renderNodes(nodes, { ...opts, frozen: true });
1263
+ return { lists: [nodes], done: [], assemble: (parts) => parts[0] ?? "" };
1168
1264
  }
1169
1265
  function renderPermutation(node, opts) {
1170
- const elements = node.options.map((o) => ({
1171
- text: renderNodes(o.nodes, opts),
1172
- sep: o.separator
1173
- }));
1266
+ const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "[", "]", opts);
1267
+ if (spliced !== null) return spliced;
1268
+ if (node.options.length === 0) return "";
1269
+ return {
1270
+ lists: node.options.map((o) => o.nodes),
1271
+ done: [],
1272
+ assemble: (parts) => assemblePermutation(node, parts, opts)
1273
+ };
1274
+ }
1275
+ function assemblePermutation(node, rendered, opts) {
1276
+ const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
1174
1277
  const total = elements.length;
1175
1278
  if (total === 0) return "";
1176
1279
  const { config } = node;