@spintax/core 0.5.3 → 0.6.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 CHANGED
@@ -3,6 +3,92 @@
3
3
  All notable changes to `@spintax/core` are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.6.1 — 2026-08-19
7
+
8
+ **The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
9
+ `analyze()` raised `RangeError: Maximum call stack size exceeded` at about 2000 levels of nesting —
10
+ a **3.9 KB** template — while §9.2 promises a bad construct comes back as text with fullwidth
11
+ braces, never as an exception. No other engine in the family threw.
12
+
13
+ ### Fixed
14
+
15
+ Three walks recursed once per level of nesting, and each is a frame stack now:
16
+
17
+ - `parseSequence` — a construct returns a *plan* (its child texts, and how to assemble the node)
18
+ and a stack drives it. The shape mirrors the Python port's `_plan_*` functions, written that way
19
+ from the start for this reason.
20
+ - `renderNodes` — the same. **The RNG order is contract and did not move**: an enumeration picks
21
+ before descending, so an unpicked branch never consumes RNG; a permutation renders every element
22
+ first and consumes its own picks after. Seeded renders are reproducible within the engine (§3.2)
23
+ and corpus fixtures pin exact picks with `rng: { sequence }`.
24
+ - `walk` (`ast.ts`) — pre-order, children in source order, because `analyze`'s `constructs` counts
25
+ and `refs` order come out of it.
26
+
27
+ Fixing only the parser would have moved the wall rather than removed it: with parsing iterative,
28
+ `render` and `analyze` still threw at ~5000. All three now return at 2 000, 9 000 and 50 000 levels.
29
+
30
+ `validate()` and `extract()` never threw, which made this worse than it looked — a caller could not
31
+ pre-screen for it by validating first.
32
+
33
+ ### Notes
34
+
35
+ **Output is byte-identical.** 400 generated documents covering every construct, dumped across
36
+ parse / extract / validate×2 / analyze / render×2 / neutralize before and after — no difference.
37
+ The harness itself was corrected first: of three deliberate control mutations it caught two and
38
+ missed one, because the generator never emitted the per-element `[a < or > | b]` separator — which
39
+ is precisely the code this release moved. A green that cannot see the changed line is not evidence.
40
+
41
+ **Deep nesting is still super-linear**, in this engine and in the family: 9 000 levels cost seconds
42
+ here where the PHP engine costs 382 ms, because `findMatchingClose`, the inner slice and
43
+ `splitTopLevel` each rescan a construct's content once per level. Removing that means threading
44
+ spans through five helpers whose separator rules deliberately differ from one another, so it is
45
+ recorded rather than guessed at — the same call as #71. Bounding input remains a host job (§9.3);
46
+ the reference Worker caps a source at 8192 characters.
47
+
48
+ `AST_VERSION` is unchanged — the node shape did not move.
49
+
50
+ ## 0.6.0 — 2026-08-18
51
+
52
+ **`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
53
+ to, a cycle** — it used to emit one per PATH (issue #59). Minor rather than patch: diagnostic
54
+ output visibly changes, though no verdict moves and no API does.
55
+
56
+ ### Fixed
57
+
58
+ Per-path emission is exponential on a converging diamond, because the number of routes into a
59
+ cycle is exponential in the diamond's depth:
60
+
61
+ | shape | template | before | after |
62
+ |---|---|---|---|
63
+ | 20 definitions, each referencing the previous one twice, feeding a 2-cycle | **507 B** | **2 097 152 diagnostics, 5.9 s** | **22 diagnostics, 6 ms** |
64
+ | the same at depth 200 | 5 KB | (never finished) | 202 diagnostics, ms |
65
+ | live `POST /validate-template` | **547 B** | **HTTP 503** | 200, with a verdict |
66
+
67
+ The second half of the same issue: one cycle of N names printed an N-name route in each of N
68
+ messages. A 43 KB template of one giant cycle carried **29 MB of message text**; the route is now
69
+ capped at 8 names and becomes a count — `n0 → n1 → … → n7 → … (1992 more)` — which brings that to
70
+ 200 KB. A real cycle is two to five names and reads exactly as it did.
71
+
72
+ **Messages are unchanged for every shape a human writes.** The colour walk that already existed as
73
+ a prune now also records one witness edge per name, and following those edges reproduces the route
74
+ the per-path walk used to print: `a → b → a`, `d0 → c1 → c2 → c1`. Only the duplicates are gone.
75
+ `Diagnostic.data` carries `{ name }` so a consumer can group without parsing the message.
76
+
77
+ ### Notes
78
+
79
+ This reverses a deliberate decision, which is why it is recorded rather than quietly shipped. The
80
+ per-path shape was pinned on purpose in three engines' own suites — "an implementation that
81
+ deduplicates repeated edges, memoizes per root, or reorders roots goes red here, not silently
82
+ green" — and `spintax-win` aligned to it on 2026-08-07. It could not be kept and bounded: re-walking
83
+ every route *is* the emission. `spintax-core` (Python) already emitted per name and was the one
84
+ engine immune; the rest followed it, and its counts are now the reference — verified by diffing 400
85
+ generated definition graphs against it, and against `spintax/core`, with zero mismatches.
86
+
87
+ Mirrored into `spintax/core` 0.8.0 and the WordPress plugin. The corpus gains a fixture for the
88
+ diamond whose real assertion is that the engine *answers*; it cannot gate the count, because
89
+ diagnostics are asserted as a subset — and `packages/conformance/README.md` now says that
90
+ multiplicity is deliberately not part of the contract.
91
+
6
92
  ## 0.5.3 — 2026-08-18
7
93
 
8
94
  The expansion budget added in 0.5.2 was created per rendered AST, and an `#include` renders one
package/dist/index.cjs CHANGED
@@ -3,19 +3,32 @@
3
3
  // src/internal/ast.ts
4
4
  var AST_VERSION = 2;
5
5
  function walk(nodes, visit) {
6
- for (const n of nodes) {
6
+ const stack = [{ list: nodes, i: 0 }];
7
+ while (stack.length > 0) {
8
+ const top = stack[stack.length - 1];
9
+ if (top.i >= top.list.length) {
10
+ stack.pop();
11
+ continue;
12
+ }
13
+ const n = top.list[top.i];
14
+ top.i += 1;
7
15
  visit(n);
16
+ let children;
8
17
  switch (n.type) {
9
18
  case "enumeration":
10
- for (const opt of n.options) walk(opt, visit);
19
+ children = n.options;
11
20
  break;
12
21
  case "conditional":
13
- walk(n.then, visit);
14
- walk(n.else, visit);
22
+ children = [n.then, n.else];
15
23
  break;
16
24
  case "permutation":
17
- for (const opt of n.options) walk(opt.nodes, visit);
25
+ children = n.options.map((opt) => opt.nodes);
18
26
  break;
27
+ default:
28
+ continue;
29
+ }
30
+ for (let k = children.length - 1; k >= 0; k -= 1) {
31
+ stack.push({ list: children[k], i: 0 });
19
32
  }
20
33
  }
21
34
  }
@@ -92,65 +105,102 @@ function stripComments(text) {
92
105
  return text.replace(/\/#[\s\S]*?#\//g, "");
93
106
  }
94
107
  function parseSequence(text) {
95
- const nodes = [];
96
- let literal = "";
97
- let i = 0;
98
- const flushLiteral = () => {
99
- if (literal !== "") {
100
- nodes.push({ type: "literal", value: literal });
101
- literal = "";
102
- }
103
- };
104
- while (i < text.length) {
105
- const ch = text.charAt(i);
106
- if (ch === "{") {
107
- const end = findMatchingClose(text, i, "{", "}");
108
- if (end === -1) {
109
- literal += ch;
110
- i += 1;
108
+ const frame = (t) => ({ text: t, i: 0, literal: "", nodes: [], plan: null, parts: [] });
109
+ const stack = [frame(text)];
110
+ while (stack.length > 0) {
111
+ const f = stack[stack.length - 1];
112
+ if (f.plan !== null) {
113
+ if (f.parts.length < f.plan.texts.length) {
114
+ stack.push(frame(f.plan.texts[f.parts.length]));
111
115
  continue;
112
116
  }
113
- const content = text.slice(i + 1, end);
114
- flushLiteral();
115
- nodes.push(parseBraceConstruct(content));
116
- i = end + 1;
117
+ f.nodes.push(f.plan.build(f.parts));
118
+ f.plan = null;
119
+ f.parts = [];
117
120
  continue;
118
121
  }
119
- if (ch === "[") {
120
- const end = findMatchingClose(text, i, "[", "]");
121
- if (end === -1) {
122
- literal += ch;
123
- i += 1;
124
- continue;
122
+ const flushLiteral = () => {
123
+ if (f.literal !== "") {
124
+ f.nodes.push({ type: "literal", value: f.literal });
125
+ f.literal = "";
125
126
  }
126
- flushLiteral();
127
- nodes.push(parsePermutation(text.slice(i + 1, end)));
128
- i = end + 1;
129
- continue;
130
- }
131
- if (ch === "%") {
132
- const name = VARIABLE_RE.exec(text.slice(i))?.[1];
133
- if (name !== void 0) {
127
+ };
128
+ let planned = null;
129
+ while (f.i < f.text.length && planned === null) {
130
+ const ch = f.text.charAt(f.i);
131
+ if (ch === "{" || ch === "[") {
132
+ const close = ch === "{" ? "}" : "]";
133
+ const end = findMatchingClose(f.text, f.i, ch, close);
134
+ if (end === -1) {
135
+ f.literal += ch;
136
+ f.i += 1;
137
+ continue;
138
+ }
139
+ const inner = f.text.slice(f.i + 1, end);
134
140
  flushLiteral();
135
- nodes.push({ type: "variable", name });
136
- i += name.length + 2;
141
+ planned = ch === "{" ? planBraceConstruct(inner) : planPermutation(inner);
142
+ f.i = end + 1;
137
143
  continue;
138
144
  }
145
+ if (ch === "%") {
146
+ const name = VARIABLE_RE.exec(f.text.slice(f.i))?.[1];
147
+ if (name !== void 0) {
148
+ flushLiteral();
149
+ f.nodes.push({ type: "variable", name });
150
+ f.i += name.length + 2;
151
+ continue;
152
+ }
153
+ }
154
+ f.literal += ch;
155
+ f.i += 1;
156
+ }
157
+ if (planned !== null) {
158
+ if ("node" in planned) f.nodes.push(planned.node);
159
+ else f.plan = planned;
160
+ continue;
139
161
  }
140
- literal += ch;
141
- i += 1;
162
+ flushLiteral();
163
+ stack.pop();
164
+ const parent = stack[stack.length - 1];
165
+ if (parent !== void 0) parent.parts.push(f.nodes);
166
+ else return f.nodes;
142
167
  }
143
- flushLiteral();
144
- return nodes;
168
+ return [];
145
169
  }
146
- function parseBraceConstruct(content) {
170
+ function planBraceConstruct(content) {
147
171
  if (content.charAt(0) === "?") {
148
- const cond = tryParseConditional(content);
149
- if (cond) return cond;
172
+ const parts = splitConditional(content);
173
+ if (parts !== null) {
174
+ return {
175
+ texts: [parts.thenRaw, parts.elseRaw],
176
+ build: (children) => ({
177
+ type: "conditional",
178
+ name: parts.name,
179
+ inverted: parts.inverted,
180
+ then: children[0] ?? [],
181
+ else: children[1] ?? []
182
+ })
183
+ };
184
+ }
150
185
  } else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
151
- return parsePlural(content.slice(PLURAL_PREFIX.length));
186
+ return { node: parsePlural(content.slice(PLURAL_PREFIX.length)) };
152
187
  }
153
- return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
188
+ return {
189
+ texts: splitTopLevel(content),
190
+ build: (children) => ({ type: "enumeration", options: children })
191
+ };
192
+ }
193
+ function planPermutation(rawInner) {
194
+ const { config, content } = extractPermutationConfig(rawInner);
195
+ const { texts, separators } = permutationElements(splitTopLevel(content));
196
+ return {
197
+ texts,
198
+ build: (children) => ({
199
+ type: "permutation",
200
+ config,
201
+ options: children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }))
202
+ })
203
+ };
154
204
  }
155
205
  function recognizeConditional(text, contentStart, contentEnd) {
156
206
  let p = contentStart + 1;
@@ -179,17 +229,6 @@ function splitConditional(content) {
179
229
  elseRaw: sep < 0 ? "" : body.slice(sep + 1)
180
230
  };
181
231
  }
182
- function tryParseConditional(content) {
183
- const parts = splitConditional(content);
184
- if (parts === null) return null;
185
- return {
186
- type: "conditional",
187
- name: parts.name,
188
- inverted: parts.inverted,
189
- then: parseSequence(parts.thenRaw),
190
- else: parseSequence(parts.elseRaw)
191
- };
192
- }
193
232
  function parsePlural(afterPrefix) {
194
233
  const colon = afterPrefix.indexOf(":");
195
234
  return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
@@ -204,11 +243,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
204
243
  function defaultPermConfig() {
205
244
  return { minsize: null, maxsize: null, sep: " ", lastsep: null };
206
245
  }
207
- function parsePermutation(rawInner) {
208
- const { config, content } = extractPermutationConfig(rawInner);
209
- const options = extractPerElementSeparators(splitTopLevel(content));
210
- return { type: "permutation", config, options };
211
- }
212
246
  function extractPermutationConfig(content) {
213
247
  const trimmed = phpLtrim(content);
214
248
  if (trimmed === "" || trimmed.charAt(0) !== "<") {
@@ -252,8 +286,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
252
286
  const tagName = (m[1] ?? "").toLowerCase();
253
287
  return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
254
288
  }
255
- function extractPerElementSeparators(rawParts) {
256
- const options = [];
289
+ function permutationElements(rawParts) {
290
+ const texts = [];
291
+ const separators = [];
257
292
  let pendingSep = null;
258
293
  rawParts.forEach((part, i) => {
259
294
  let text = part;
@@ -267,11 +302,12 @@ function extractPerElementSeparators(rawParts) {
267
302
  }
268
303
  const trimmed = phpTrim(text);
269
304
  if (trimmed !== "") {
270
- options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
305
+ texts.push(trimmed);
306
+ separators.push(pendingSep);
271
307
  }
272
308
  pendingSep = trailingSep;
273
309
  });
274
- return options;
310
+ return { texts, separators };
275
311
  }
276
312
  function extractTrailingSep(part) {
277
313
  const trimmed = phpRtrim(part);
@@ -750,9 +786,14 @@ function checkVariableReferences(text, idx, known, out) {
750
786
  for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
751
787
  refsOf.set(name, refs);
752
788
  }
753
- const reachesCycle = namesThatReachACycle(defs, refsOf);
789
+ const { reaches: reachesCycle, via } = namesThatReachACycle(defs, refsOf);
754
790
  for (const name of defs.keys()) {
755
- detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
791
+ if (!reachesCycle.has(name)) continue;
792
+ out.push(err(
793
+ "variable.circular-reference",
794
+ `Circular variable reference: ${cyclePath(name, via)}.`,
795
+ { ...defPos.get(name) ?? somewhere, data: { name } }
796
+ ));
756
797
  }
757
798
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
758
799
  const seen = /* @__PURE__ */ new Set();
@@ -776,6 +817,12 @@ function namesThatReachACycle(defs, refsOf) {
776
817
  const BLACK = 2;
777
818
  const color = /* @__PURE__ */ new Map();
778
819
  const reaches = /* @__PURE__ */ new Set();
820
+ const via = /* @__PURE__ */ new Map();
821
+ const mark = (name, witness) => {
822
+ if (reaches.has(name)) return;
823
+ reaches.add(name);
824
+ via.set(name, witness);
825
+ };
779
826
  for (const root of defs.keys()) {
780
827
  if (color.has(root)) continue;
781
828
  const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
@@ -787,9 +834,9 @@ function namesThatReachACycle(defs, refsOf) {
787
834
  top.i += 1;
788
835
  if (ref === top.name || !defs.has(ref)) continue;
789
836
  const c = color.get(ref);
790
- if (c === GREY) reaches.add(top.name);
837
+ if (c === GREY) mark(top.name, ref);
791
838
  else if (c === BLACK) {
792
- if (reaches.has(ref)) reaches.add(top.name);
839
+ if (reaches.has(ref)) mark(top.name, ref);
793
840
  } else {
794
841
  stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
795
842
  color.set(ref, GREY);
@@ -797,42 +844,41 @@ function namesThatReachACycle(defs, refsOf) {
797
844
  } else {
798
845
  stack.pop();
799
846
  color.set(top.name, BLACK);
800
- if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
847
+ if (stack.length > 0 && reaches.has(top.name)) mark(stack[stack.length - 1].name, top.name);
801
848
  }
802
849
  }
803
850
  }
804
- return reaches;
805
- }
806
- function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
807
- if (!reachesCycle.has(root)) return;
808
- const path = [root];
809
- const onPath = /* @__PURE__ */ new Set([root]);
810
- const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
811
- const leave = (frame) => {
812
- stack.pop();
813
- onPath.delete(frame.name);
814
- path.pop();
815
- };
816
- while (stack.length > 0) {
817
- const top = stack[stack.length - 1];
818
- if (top.i >= top.refs.length) {
819
- leave(top);
820
- continue;
851
+ return { reaches, via };
852
+ }
853
+ var CYCLE_PATH_LIMIT = 8;
854
+ function cyclePath(name, via) {
855
+ const seen = /* @__PURE__ */ new Set([name]);
856
+ const shown = [name];
857
+ let current = name;
858
+ for (; ; ) {
859
+ const next = via.get(current);
860
+ if (next === void 0) break;
861
+ if (seen.has(next)) {
862
+ shown.push(next);
863
+ return shown.join(" \u2192 ");
821
864
  }
822
- const ref = top.refs[top.i];
823
- top.i += 1;
824
- if (ref === top.name) continue;
825
- if (onPath.has(ref)) {
826
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
827
- leave(top);
828
- continue;
829
- }
830
- if (defs.has(ref) && reachesCycle.has(ref)) {
831
- stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
832
- onPath.add(ref);
833
- path.push(ref);
865
+ if (shown.length >= CYCLE_PATH_LIMIT) {
866
+ let more = 0;
867
+ let walk2 = next;
868
+ while (!seen.has(walk2)) {
869
+ seen.add(walk2);
870
+ more += 1;
871
+ const step = via.get(walk2);
872
+ if (step === void 0) break;
873
+ walk2 = step;
874
+ }
875
+ return `${shown.join(" \u2192 ")} \u2192 \u2026 (${more} more)`;
834
876
  }
877
+ seen.add(next);
878
+ shown.push(next);
879
+ current = next;
835
880
  }
881
+ return shown.join(" \u2192 ");
836
882
  }
837
883
  function checkIncludeTargets(text, idx, known, out) {
838
884
  const set = new Set(known);
@@ -1006,9 +1052,34 @@ function directReferences(text) {
1006
1052
  return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
1007
1053
  }
1008
1054
  function renderNodes(nodes, opts) {
1009
- let out = "";
1010
- for (const node of nodes) out += renderNode(node, opts);
1011
- return out;
1055
+ const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
1056
+ const stack = [frame(nodes)];
1057
+ while (stack.length > 0) {
1058
+ const f = stack[stack.length - 1];
1059
+ if (f.pending !== null) {
1060
+ if (f.pending.done.length < f.pending.lists.length) {
1061
+ stack.push(frame(f.pending.lists[f.pending.done.length]));
1062
+ continue;
1063
+ }
1064
+ f.out.push(f.pending.assemble(f.pending.done));
1065
+ f.pending = null;
1066
+ continue;
1067
+ }
1068
+ if (f.i >= f.nodes.length) {
1069
+ const text = f.out.join("");
1070
+ stack.pop();
1071
+ const parent = stack[stack.length - 1];
1072
+ if (parent === void 0) return text;
1073
+ parent.pending.done.push(text);
1074
+ continue;
1075
+ }
1076
+ const node = f.nodes[f.i];
1077
+ f.i += 1;
1078
+ const step = renderNode(node, opts);
1079
+ if (typeof step === "string") f.out.push(step);
1080
+ else f.pending = step;
1081
+ }
1082
+ return "";
1012
1083
  }
1013
1084
  function renderNode(node, opts) {
1014
1085
  switch (node.type) {
@@ -1060,7 +1131,7 @@ function conditionalTakesThen(name, inverted, opts) {
1060
1131
  }
1061
1132
  function renderConditional(node, opts) {
1062
1133
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1063
- return renderNodes(truthy ? node.then : node.else, opts);
1134
+ return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1064
1135
  }
1065
1136
  function resolveCountConditionals(text, opts) {
1066
1137
  if (!text.includes("{?")) return text;
@@ -1145,7 +1216,7 @@ function renderPlural(node, opts) {
1145
1216
  return fullwidthVerbatim(countRaw, formsRaw);
1146
1217
  }
1147
1218
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1148
- return renderNodes(parseSequence(picked), opts);
1219
+ return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1149
1220
  }
1150
1221
  function rawConstruct(countRaw, formsRaw) {
1151
1222
  return `{plural ${countRaw}:${formsRaw}}`;
@@ -1156,13 +1227,19 @@ function fullwidthVerbatim(countRaw, formsRaw) {
1156
1227
  function renderEnumeration(options, opts) {
1157
1228
  if (options.length === 0) return "";
1158
1229
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1159
- return picked ? renderNodes(picked, opts) : "";
1230
+ if (!picked) return "";
1231
+ return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
1160
1232
  }
1161
1233
  function renderPermutation(node, opts) {
1162
- const elements = node.options.map((o) => ({
1163
- text: renderNodes(o.nodes, opts),
1164
- sep: o.separator
1165
- }));
1234
+ if (node.options.length === 0) return "";
1235
+ return {
1236
+ lists: node.options.map((o) => o.nodes),
1237
+ done: [],
1238
+ assemble: (parts) => assemblePermutation(node, parts, opts)
1239
+ };
1240
+ }
1241
+ function assemblePermutation(node, rendered, opts) {
1242
+ const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
1166
1243
  const total = elements.length;
1167
1244
  if (total === 0) return "";
1168
1245
  const { config } = node;