@spintax/core 0.1.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.cjs ADDED
@@ -0,0 +1,1027 @@
1
+ 'use strict';
2
+
3
+ // src/internal/ast.ts
4
+ var AST_VERSION = 1;
5
+ function walk(nodes, visit) {
6
+ for (const n of nodes) {
7
+ visit(n);
8
+ switch (n.type) {
9
+ case "enumeration":
10
+ for (const opt of n.options) walk(opt, visit);
11
+ break;
12
+ case "conditional":
13
+ walk(n.then, visit);
14
+ walk(n.else, visit);
15
+ break;
16
+ case "permutation":
17
+ for (const opt of n.options) walk(opt.nodes, visit);
18
+ break;
19
+ }
20
+ }
21
+ }
22
+ function isParsedAst(value) {
23
+ return typeof value === "object" && value !== null && value.astVersion === AST_VERSION && typeof value.source === "string" && Array.isArray(value.nodes);
24
+ }
25
+
26
+ // src/internal/parser.ts
27
+ var VARIABLE_RE = /^%(\w+)%/;
28
+ var SET_DIRECTIVE_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
29
+ var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
30
+ var PLURAL_PREFIX = "plural ";
31
+ function parseTemplate(src) {
32
+ const { body, setDefs } = extractSetDirectives(stripComments(src));
33
+ return { astVersion: AST_VERSION, source: src, setDefs, nodes: parseSequence(body) };
34
+ }
35
+ function extractSetDirectives(text) {
36
+ const setDefs = {};
37
+ SET_DIRECTIVE_RE.lastIndex = 0;
38
+ const stripped = text.replace(SET_DIRECTIVE_RE, (_full, name, value) => {
39
+ setDefs[name.toLowerCase()] = value;
40
+ return "";
41
+ });
42
+ return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs };
43
+ }
44
+ function stripComments(text) {
45
+ return text.replace(/\/#[\s\S]*?#\//g, "");
46
+ }
47
+ function parseSequence(text) {
48
+ const nodes = [];
49
+ let literal = "";
50
+ let i = 0;
51
+ const flushLiteral = () => {
52
+ if (literal !== "") {
53
+ nodes.push({ type: "literal", value: literal });
54
+ literal = "";
55
+ }
56
+ };
57
+ while (i < text.length) {
58
+ const ch = text.charAt(i);
59
+ if (ch === "{") {
60
+ const end = findMatchingClose(text, i, "{", "}");
61
+ if (end === -1) {
62
+ literal += ch;
63
+ i += 1;
64
+ continue;
65
+ }
66
+ const content = text.slice(i + 1, end);
67
+ flushLiteral();
68
+ nodes.push(parseBraceConstruct(content));
69
+ i = end + 1;
70
+ continue;
71
+ }
72
+ if (ch === "[") {
73
+ const end = findMatchingClose(text, i, "[", "]");
74
+ if (end === -1) {
75
+ literal += ch;
76
+ i += 1;
77
+ continue;
78
+ }
79
+ flushLiteral();
80
+ nodes.push(parsePermutation(text.slice(i + 1, end)));
81
+ i = end + 1;
82
+ continue;
83
+ }
84
+ if (ch === "%") {
85
+ const name = VARIABLE_RE.exec(text.slice(i))?.[1];
86
+ if (name !== void 0) {
87
+ flushLiteral();
88
+ nodes.push({ type: "variable", name });
89
+ i += name.length + 2;
90
+ continue;
91
+ }
92
+ }
93
+ literal += ch;
94
+ i += 1;
95
+ }
96
+ flushLiteral();
97
+ return nodes;
98
+ }
99
+ function parseBraceConstruct(content) {
100
+ if (content.charAt(0) === "?") {
101
+ const cond = tryParseConditional(content);
102
+ if (cond) return cond;
103
+ } else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
104
+ return parsePlural(content.slice(PLURAL_PREFIX.length));
105
+ }
106
+ return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
107
+ }
108
+ function tryParseConditional(content) {
109
+ let p = 1;
110
+ let inverted = false;
111
+ if (content.charAt(p) === "!") {
112
+ inverted = true;
113
+ p += 1;
114
+ }
115
+ const name = CONDITIONAL_NAME_RE.exec(content.slice(p))?.[0];
116
+ if (name === void 0) return null;
117
+ p += name.length;
118
+ if (content.charAt(p) !== "?") return null;
119
+ p += 1;
120
+ const body = content.slice(p);
121
+ const sep = firstTopLevelPipe(body);
122
+ const thenRaw = sep < 0 ? body : body.slice(0, sep);
123
+ const elseRaw = sep < 0 ? "" : body.slice(sep + 1);
124
+ return {
125
+ type: "conditional",
126
+ name,
127
+ inverted,
128
+ then: parseSequence(thenRaw),
129
+ else: parseSequence(elseRaw)
130
+ };
131
+ }
132
+ function parsePlural(afterPrefix) {
133
+ const colon = afterPrefix.indexOf(":");
134
+ return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
135
+ }
136
+ var CONFIG_KEY_RE = /\b(?:minsize|maxsize|sep|lastsep)\s*=/i;
137
+ var MINSIZE_RE = /minsize\s*=\s*(\d+)/i;
138
+ var MAXSIZE_RE = /maxsize\s*=\s*(\d+)/i;
139
+ var SEP_RE = /(?<!last)sep\s*=\s*"([^"]*)"/i;
140
+ var LASTSEP_RE = /lastsep\s*=\s*"([^"]*)"/i;
141
+ var HTML_TAG_RE = /^([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?\/?$/;
142
+ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
143
+ function defaultPermConfig() {
144
+ return { minsize: null, maxsize: null, sep: " ", lastsep: null };
145
+ }
146
+ function parsePermutation(rawInner) {
147
+ const { config, content } = extractPermutationConfig(rawInner);
148
+ const options = extractPerElementSeparators(splitTopLevel(content));
149
+ return { type: "permutation", config, options };
150
+ }
151
+ function extractPermutationConfig(content) {
152
+ const trimmed = phpLtrim(content);
153
+ if (trimmed === "" || trimmed.charAt(0) !== "<") {
154
+ return { config: defaultPermConfig(), content };
155
+ }
156
+ const end = findConfigEnd(trimmed);
157
+ if (end === -1) return { config: defaultPermConfig(), content };
158
+ const configStr = trimmed.slice(1, end);
159
+ const remaining = trimmed.slice(end + 1);
160
+ if (looksLikeHtmlStartTag(configStr, remaining)) {
161
+ return { config: defaultPermConfig(), content };
162
+ }
163
+ return { config: parseConfigString(configStr), content: remaining };
164
+ }
165
+ function findConfigEnd(text) {
166
+ let inQuote = false;
167
+ for (let i = 1; i < text.length; i += 1) {
168
+ const ch = text.charAt(i);
169
+ if (ch === '"') inQuote = !inQuote;
170
+ if (ch === ">" && !inQuote) return i;
171
+ }
172
+ return -1;
173
+ }
174
+ function parseConfigString(str) {
175
+ if (!CONFIG_KEY_RE.test(str)) {
176
+ return { minsize: null, maxsize: null, sep: str, lastsep: str };
177
+ }
178
+ return {
179
+ minsize: intGroup(MINSIZE_RE.exec(str)),
180
+ maxsize: intGroup(MAXSIZE_RE.exec(str)),
181
+ sep: strGroup(SEP_RE.exec(str)) ?? " ",
182
+ lastsep: strGroup(LASTSEP_RE.exec(str))
183
+ };
184
+ }
185
+ function looksLikeHtmlStartTag(tagText, remaining) {
186
+ const trimmed = phpTrim(tagText);
187
+ if (trimmed === "") return false;
188
+ const m = HTML_TAG_RE.exec(trimmed);
189
+ if (!m) return false;
190
+ if (trimmed.endsWith("/")) return true;
191
+ const tagName = (m[1] ?? "").toLowerCase();
192
+ return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
193
+ }
194
+ function extractPerElementSeparators(rawParts) {
195
+ const options = [];
196
+ let pendingSep = null;
197
+ rawParts.forEach((part, i) => {
198
+ let text = part;
199
+ let trailingSep = null;
200
+ if (i < rawParts.length - 1) {
201
+ const extracted = extractTrailingSep(part);
202
+ if (extracted) {
203
+ text = extracted.text;
204
+ trailingSep = extracted.sep;
205
+ }
206
+ }
207
+ const trimmed = phpTrim(text);
208
+ if (trimmed !== "") {
209
+ options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
210
+ }
211
+ pendingSep = trailingSep;
212
+ });
213
+ return options;
214
+ }
215
+ function extractTrailingSep(part) {
216
+ const trimmed = phpRtrim(part);
217
+ const len = trimmed.length;
218
+ if (len === 0 || trimmed.charAt(len - 1) !== ">") return null;
219
+ let openPos = -1;
220
+ for (let i = len - 2; i >= 0; i -= 1) {
221
+ const ch = trimmed.charAt(i);
222
+ if (ch === "<") {
223
+ openPos = i;
224
+ break;
225
+ }
226
+ if (ch === ">") return null;
227
+ }
228
+ if (openPos === -1) return null;
229
+ const inner = trimmed.slice(openPos + 1, len - 1);
230
+ const innerTrimmed = phpTrim(inner);
231
+ if (innerTrimmed.startsWith("/") || innerTrimmed.endsWith("/") || PER_ELEM_HTML_RE.test(innerTrimmed)) {
232
+ return null;
233
+ }
234
+ return { text: trimmed.slice(0, openPos), sep: inner };
235
+ }
236
+ function intGroup(m) {
237
+ return m && m[1] !== void 0 ? Number.parseInt(m[1], 10) : null;
238
+ }
239
+ function strGroup(m) {
240
+ return m && m[1] !== void 0 ? m[1] : null;
241
+ }
242
+ function escapeRegExp(s) {
243
+ return s.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
244
+ }
245
+ var PHP_LTRIM_RE = /^[ \t\n\r\0\x0B]+/u;
246
+ var PHP_RTRIM_RE = /[ \t\n\r\0\x0B]+$/u;
247
+ function phpTrim(s) {
248
+ return s.replace(PHP_LTRIM_RE, "").replace(PHP_RTRIM_RE, "");
249
+ }
250
+ function phpLtrim(s) {
251
+ return s.replace(PHP_LTRIM_RE, "");
252
+ }
253
+ function phpRtrim(s) {
254
+ return s.replace(PHP_RTRIM_RE, "");
255
+ }
256
+ function findMatchingClose(text, openPos, open, close) {
257
+ let depth = 0;
258
+ for (let i = openPos; i < text.length; i += 1) {
259
+ const ch = text.charAt(i);
260
+ if (ch === open) {
261
+ depth += 1;
262
+ } else if (ch === close) {
263
+ depth -= 1;
264
+ if (depth === 0) return i;
265
+ }
266
+ }
267
+ return -1;
268
+ }
269
+ function splitTopLevel(inner) {
270
+ const parts = [];
271
+ let brace = 0;
272
+ let bracket = 0;
273
+ let cur = "";
274
+ for (const ch of inner) {
275
+ if (ch === "{") brace += 1;
276
+ else if (ch === "}") brace -= 1;
277
+ else if (ch === "[") bracket += 1;
278
+ else if (ch === "]") bracket -= 1;
279
+ if (ch === "|" && brace === 0 && bracket === 0) {
280
+ parts.push(cur);
281
+ cur = "";
282
+ } else {
283
+ cur += ch;
284
+ }
285
+ }
286
+ parts.push(cur);
287
+ return parts;
288
+ }
289
+ function firstTopLevelPipe(body) {
290
+ let depth = 0;
291
+ for (let j = 0; j < body.length; j += 1) {
292
+ const ch = body.charAt(j);
293
+ if (ch === "{" || ch === "[") {
294
+ depth += 1;
295
+ } else if (ch === "}" || ch === "]") {
296
+ if (depth > 0) depth -= 1;
297
+ } else if (ch === "|" && depth === 0) {
298
+ return j;
299
+ }
300
+ }
301
+ return -1;
302
+ }
303
+
304
+ // src/internal/plurals.ts
305
+ var PLURAL_PREFIX2 = "{plural ";
306
+ function findPluralBlocks(text) {
307
+ const blocks = [];
308
+ let i = 0;
309
+ while (i < text.length) {
310
+ const start = text.indexOf(PLURAL_PREFIX2, i);
311
+ if (start === -1) break;
312
+ let depth = 1;
313
+ let j = start + PLURAL_PREFIX2.length;
314
+ while (j < text.length) {
315
+ const ch = text.charAt(j);
316
+ if (ch === "{") depth += 1;
317
+ else if (ch === "}") {
318
+ depth -= 1;
319
+ if (depth === 0) break;
320
+ }
321
+ j += 1;
322
+ }
323
+ if (depth !== 0) {
324
+ i = start + PLURAL_PREFIX2.length;
325
+ continue;
326
+ }
327
+ const inner = text.slice(start + PLURAL_PREFIX2.length, j);
328
+ const colon = inner.indexOf(":");
329
+ if (colon === -1) {
330
+ i = j + 1;
331
+ continue;
332
+ }
333
+ blocks.push({ start, countSlot: inner.slice(0, colon), formsRaw: inner.slice(colon + 1) });
334
+ i = j + 1;
335
+ }
336
+ return blocks;
337
+ }
338
+ function normalizeBaseLang(locale) {
339
+ const first = locale.toLowerCase().split(/[-_]/, 1)[0];
340
+ return first ?? "";
341
+ }
342
+ function pluralArity(baseLang) {
343
+ switch (baseLang) {
344
+ case "ru":
345
+ case "uk":
346
+ case "be":
347
+ return 3;
348
+ default:
349
+ return 2;
350
+ }
351
+ }
352
+ function pluralFor(baseLang, n, forms) {
353
+ const abs = Math.abs(n);
354
+ const mod10 = abs % 10;
355
+ const mod100 = abs % 100;
356
+ switch (baseLang) {
357
+ case "ru":
358
+ case "uk":
359
+ case "be":
360
+ if (mod10 === 1 && mod100 !== 11) return forms[0] ?? "";
361
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1] ?? "";
362
+ return forms[2] ?? "";
363
+ default:
364
+ return (abs === 1 ? forms[0] : forms[1]) ?? "";
365
+ }
366
+ }
367
+
368
+ // src/internal/validator.ts
369
+ var KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set(["minsize", "maxsize", "sep", "lastsep"]);
370
+ var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
371
+ function validateTemplate(src, opts = {}) {
372
+ const diagnostics = [];
373
+ const text = stripComments(src);
374
+ checkBrackets(text, diagnostics);
375
+ checkSetDirectives(text, diagnostics);
376
+ checkPermutationConfigs(text, diagnostics);
377
+ checkPlurals(text, opts.locale, diagnostics);
378
+ checkVariableReferences(text, opts.knownVariables, diagnostics);
379
+ if (opts.knownIncludes && opts.knownIncludes.length > 0) {
380
+ checkIncludeTargets(text, opts.knownIncludes, diagnostics);
381
+ }
382
+ return diagnostics;
383
+ }
384
+ function err(code, message, line = 1, column = 1) {
385
+ return { severity: "error", code, message, line, column };
386
+ }
387
+ function warn(code, message, line = 1, column = 1) {
388
+ return { severity: "warning", code, message, line, column };
389
+ }
390
+ function checkBrackets(text, out) {
391
+ const close = { "{": "}", "[": "]" };
392
+ const stack = [];
393
+ let line = 1;
394
+ let column = 1;
395
+ for (const ch of text) {
396
+ if (ch === "\n") {
397
+ line += 1;
398
+ column = 1;
399
+ continue;
400
+ }
401
+ if (ch === "{" || ch === "[") {
402
+ stack.push({ char: ch, expect: close[ch], line, column });
403
+ } else if (ch === "}" || ch === "]") {
404
+ const top = stack.pop();
405
+ if (top === void 0) {
406
+ out.push(err("bracket.unexpected-closing", `Unexpected closing '${ch}'.`, line, column));
407
+ } else if (top.expect !== ch) {
408
+ out.push(err("bracket.mismatched", `'${top.char}' closed by '${ch}'.`, line, column));
409
+ }
410
+ }
411
+ column += 1;
412
+ }
413
+ for (const unclosed of stack) {
414
+ out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, unclosed.line, unclosed.column));
415
+ }
416
+ }
417
+ function checkSetDirectives(text, out) {
418
+ const lines = text.split("\n");
419
+ lines.forEach((lineText, idx) => {
420
+ const trimmed = lineText.replace(/^[ \t]+/, "");
421
+ if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
422
+ if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
423
+ out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", idx + 1));
424
+ }
425
+ });
426
+ }
427
+ function checkPermutationConfigs(text, out) {
428
+ for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
429
+ const configStr = m[1] ?? "";
430
+ if (!/\w+\s*=/.test(configStr)) continue;
431
+ const line = countLines(text, m.index ?? 0);
432
+ for (const km of configStr.matchAll(/(\w+)\s*=/gu)) {
433
+ const key = (km[1] ?? "").toLowerCase();
434
+ if (!KNOWN_CONFIG_KEYS.has(key)) {
435
+ out.push(err("permutation.unknown-key", `Unknown permutation config key: '${km[1]}'.`, line));
436
+ }
437
+ }
438
+ const min = /minsize\s*=\s*([^;>\s]+)/i.exec(configStr);
439
+ if (min && !/^\d+$/.test(min[1] ?? "")) {
440
+ out.push(err("permutation.minsize-not-integer", `minsize must be a positive integer, got '${min[1]}'.`, line));
441
+ }
442
+ const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
443
+ if (max && !/^\d+$/.test(max[1] ?? "")) {
444
+ out.push(err("permutation.maxsize-not-integer", `maxsize must be a positive integer, got '${max[1]}'.`, line));
445
+ }
446
+ }
447
+ }
448
+ function checkPlurals(text, locale, out) {
449
+ const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
450
+ const arity = base !== "" ? pluralArity(base) : 0;
451
+ for (const block of findPluralBlocks(text)) {
452
+ const line = countLines(text, block.start);
453
+ if (/[{}[\]]/.test(block.formsRaw)) {
454
+ out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", line));
455
+ continue;
456
+ }
457
+ if (arity > 0) {
458
+ const count = block.formsRaw.split("|").length;
459
+ if (count !== arity) {
460
+ out.push(err("plural.arity", `{plural ...}: expected ${arity} forms, got ${count}.`, line));
461
+ }
462
+ }
463
+ }
464
+ }
465
+ function checkVariableReferences(text, known, out) {
466
+ const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
467
+ const defs = /* @__PURE__ */ new Map();
468
+ for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
469
+ defs.set((m[1] ?? "").toLowerCase(), m[2] ?? "");
470
+ }
471
+ for (const [name, value] of defs) {
472
+ if (value.toLowerCase().includes(`%${name}%`)) {
473
+ out.push(err("variable.self-reference", `Variable '${name}' references itself.`));
474
+ }
475
+ }
476
+ for (const name of defs.keys()) {
477
+ detectCycle(name, defs, [name], out);
478
+ }
479
+ const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, "");
480
+ const refs = /* @__PURE__ */ new Set();
481
+ for (const m of body.matchAll(/%(\w+)%/gu)) refs.add((m[1] ?? "").toLowerCase());
482
+ for (const m of body.matchAll(/\{\?!?([A-Za-z_]\w*)\?/gu)) refs.add((m[1] ?? "").toLowerCase());
483
+ for (const ref of refs) {
484
+ if (!defs.has(ref) && !knownSet.has(ref)) {
485
+ out.push(warn("variable.undefined", `Variable '${ref}' is not defined \u2014 may be a runtime variable.`));
486
+ }
487
+ }
488
+ }
489
+ function detectCycle(current, defs, visited, out) {
490
+ const value = defs.get(current) ?? "";
491
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
492
+ const ref = (m[1] ?? "").toLowerCase();
493
+ if (ref === current) continue;
494
+ if (visited.includes(ref)) {
495
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`));
496
+ return;
497
+ }
498
+ if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], out);
499
+ }
500
+ }
501
+ function checkIncludeTargets(text, known, out) {
502
+ const set = new Set(known);
503
+ INCLUDE_RE.lastIndex = 0;
504
+ for (const m of text.matchAll(INCLUDE_RE)) {
505
+ const ref = m[1] ?? "";
506
+ if (!set.has(ref)) {
507
+ out.push(
508
+ err("include.unknown-target", `#include target '${ref}' does not match any known template.`, countLines(text, m.index ?? 0))
509
+ );
510
+ }
511
+ }
512
+ }
513
+ function countLines(text, offset) {
514
+ let n = 1;
515
+ for (let i = 0; i < offset && i < text.length; i += 1) {
516
+ if (text.charAt(i) === "\n") n += 1;
517
+ }
518
+ return n;
519
+ }
520
+
521
+ // src/internal/extract.ts
522
+ var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
523
+ var SET_LHS_RE = /^[ \t]*#set[ \t]+%\w+%[ \t]*=/gmu;
524
+ var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
525
+ var VARIABLE_RE2 = /%(\w+)%/gu;
526
+ var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
527
+ function extractFromSource(src) {
528
+ const text = stripComments(src);
529
+ const sets = collect(text, SET_DEF_RE, true);
530
+ const includes = collect(text, INCLUDE_RE2, false);
531
+ const body = text.replace(SET_LHS_RE, "");
532
+ const refs = /* @__PURE__ */ new Set();
533
+ addAll(refs, body, VARIABLE_RE2, true);
534
+ addAll(refs, body, CONDITIONAL_REF_RE, true);
535
+ return { refs: [...refs], sets, includes };
536
+ }
537
+ function collect(text, re, fold) {
538
+ const seen = /* @__PURE__ */ new Set();
539
+ addAll(seen, text, re, fold);
540
+ return [...seen];
541
+ }
542
+ function addAll(target, text, re, fold) {
543
+ for (const m of text.matchAll(re)) {
544
+ const value = m[1];
545
+ if (value !== void 0 && value !== "") target.add(fold ? value.toLowerCase() : value);
546
+ }
547
+ }
548
+
549
+ // src/internal/errors.ts
550
+ var SpintaxError = class extends Error {
551
+ constructor(message) {
552
+ super(message);
553
+ this.name = "SpintaxError";
554
+ }
555
+ };
556
+ var IncludeResolverError = class extends SpintaxError {
557
+ constructor(message, options) {
558
+ super(message);
559
+ this.name = "IncludeResolverError";
560
+ if (options && "cause" in options) {
561
+ this.cause = options.cause;
562
+ }
563
+ }
564
+ };
565
+ var AstVersionError = class extends SpintaxError {
566
+ constructor(message) {
567
+ super(message);
568
+ this.name = "AstVersionError";
569
+ }
570
+ };
571
+ var NotImplementedError = class extends SpintaxError {
572
+ constructor(what) {
573
+ super(`${what} is not implemented yet.`);
574
+ this.name = "NotImplementedError";
575
+ }
576
+ };
577
+
578
+ // src/internal/neutralize.ts
579
+ var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
580
+ var SENTINEL_BASE = 57344;
581
+ var SHIELD = /* @__PURE__ */ new Map();
582
+ var RESTORE = /* @__PURE__ */ new Map();
583
+ STRUCTURAL.forEach((ch, i) => {
584
+ const sentinel = String.fromCharCode(SENTINEL_BASE + i);
585
+ SHIELD.set(ch, sentinel);
586
+ RESTORE.set(sentinel, ch);
587
+ });
588
+ var SHIELD_RE = /[{}[\]%#]/gu;
589
+ var RESTORE_RE = new RegExp(
590
+ `[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
591
+ "gu"
592
+ );
593
+ function neutralize(value) {
594
+ return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
595
+ }
596
+ function safetyRestore(text) {
597
+ return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
598
+ }
599
+ function stripSentinels(text) {
600
+ return text.replace(RESTORE_RE, "");
601
+ }
602
+
603
+ // src/internal/render.ts
604
+ var MAX_VARIABLE_DEPTH = 50;
605
+ var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
606
+ function renderAst(ast, ctx) {
607
+ const vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
608
+ const text = renderNodes(ast.nodes, { vars, rng: ctx.rng, locale: ctx.locale, depth: 0 });
609
+ return ctx.resolver ? resolveIncludes(text, ctx) : text;
610
+ }
611
+ function resolveIncludes(text, ctx) {
612
+ INCLUDE_LINE_RE.lastIndex = 0;
613
+ return text.replace(INCLUDE_LINE_RE, (_m, ref) => {
614
+ if (ctx.includeStack.includes(ref) || ctx.includeStack.length >= ctx.maxDepth) return "";
615
+ let included;
616
+ try {
617
+ included = ctx.resolver(ref);
618
+ } catch (cause) {
619
+ throw new IncludeResolverError(`includeResolver threw for "${ref}"`, { cause });
620
+ }
621
+ if (included === null) return "";
622
+ return renderAst(parseTemplate(stripSentinels(included)), {
623
+ ...ctx,
624
+ runtimeContext: ctx.runtimeContext,
625
+ // child inherits runtime, not parent #set
626
+ includeStack: [...ctx.includeStack, ref]
627
+ });
628
+ });
629
+ }
630
+ function buildVars(setDefs, context, rng) {
631
+ const vars = {};
632
+ for (const [name, value] of Object.entries(setDefs)) {
633
+ vars[name] = collapseSetValue(value, rng);
634
+ }
635
+ for (const [name, value] of Object.entries(context)) {
636
+ vars[name.toLowerCase()] = value;
637
+ }
638
+ return vars;
639
+ }
640
+ function collapseSetValue(value, rng) {
641
+ if (!value.includes("{")) return value;
642
+ if (value.includes("{?") || value.includes("{plural ")) return value;
643
+ return resolveEnumerationsString(value, rng);
644
+ }
645
+ function resolveEnumerationsString(text, rng) {
646
+ let out = text;
647
+ for (let guard = 0; guard < 1e3; guard += 1) {
648
+ let changed = false;
649
+ out = out.replace(/\{([^{}]*)\}/gu, (_m, inner) => {
650
+ changed = true;
651
+ const options = splitTopLevelPipes(inner);
652
+ return options.length === 0 ? "" : options[randomInt(rng, 0, options.length - 1)] ?? "";
653
+ });
654
+ if (!changed) break;
655
+ }
656
+ return out;
657
+ }
658
+ function renderNodes(nodes, opts) {
659
+ let out = "";
660
+ for (const node of nodes) out += renderNode(node, opts);
661
+ return out;
662
+ }
663
+ function renderNode(node, opts) {
664
+ switch (node.type) {
665
+ case "literal":
666
+ return node.value;
667
+ case "variable":
668
+ return resolveVariable(node.name, opts);
669
+ case "enumeration":
670
+ return renderEnumeration(node.options, opts);
671
+ case "permutation":
672
+ return renderPermutation(node, opts);
673
+ case "conditional":
674
+ return renderConditional(node, opts);
675
+ case "plural":
676
+ return renderPlural(node, opts);
677
+ }
678
+ }
679
+ function randomInt(rng, min, max) {
680
+ return min === max ? min : rng(min, max);
681
+ }
682
+ function resolveVariable(name, opts) {
683
+ const value = opts.vars[name.toLowerCase()];
684
+ if (value === void 0) return `%${name}%`;
685
+ if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
686
+ return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
687
+ }
688
+ function expandVarsOnly(text, opts) {
689
+ let out = text;
690
+ for (let i = 0; i < MAX_VARIABLE_DEPTH; i += 1) {
691
+ let changed = false;
692
+ out = out.replace(/%(\w+)%/gu, (m, name) => {
693
+ const value = opts.vars[name.toLowerCase()];
694
+ if (value === void 0) return m;
695
+ changed = true;
696
+ return value;
697
+ });
698
+ if (!changed) break;
699
+ }
700
+ return out;
701
+ }
702
+ function renderConditional(node, opts) {
703
+ const value = opts.vars[node.name.toLowerCase()];
704
+ const baseTruthy = value !== void 0 && /\S/u.test(value);
705
+ const truthy = node.inverted ? !baseTruthy : baseTruthy;
706
+ return renderNodes(truthy ? node.then : node.else, opts);
707
+ }
708
+ function renderPlural(node, opts) {
709
+ const countRaw = expandVarsOnly(node.countRaw, opts);
710
+ const formsRaw = expandVarsOnly(node.formsRaw, opts);
711
+ if (/[{}[\]]/u.test(formsRaw)) return fullwidthVerbatim(countRaw, formsRaw);
712
+ const count = phpTrim2(countRaw);
713
+ if (!/^-?\d+$/u.test(count)) return "";
714
+ const base = normalizeBaseLang(opts.locale);
715
+ const forms = formsRaw.split("|").map((f) => phpTrim2(f));
716
+ if (forms.length !== pluralArity(base)) return fullwidthVerbatim(countRaw, formsRaw);
717
+ const picked = pluralFor(base, Number.parseInt(count, 10), forms);
718
+ return renderNodes(parseSequence(picked), opts);
719
+ }
720
+ function fullwidthVerbatim(countRaw, formsRaw) {
721
+ return `{plural ${countRaw}:${formsRaw}}`.replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
722
+ }
723
+ function renderEnumeration(options, opts) {
724
+ if (options.length === 0) return "";
725
+ const picked = options[randomInt(opts.rng, 0, options.length - 1)];
726
+ return picked ? renderNodes(picked, opts) : "";
727
+ }
728
+ function renderPermutation(node, opts) {
729
+ const elements = node.options.map((o) => ({
730
+ text: renderNodes(o.nodes, opts),
731
+ sep: o.separator
732
+ }));
733
+ const total = elements.length;
734
+ if (total === 0) return "";
735
+ const { config } = node;
736
+ const hasMin = config.minsize !== null;
737
+ const hasMax = config.maxsize !== null;
738
+ let min;
739
+ let max;
740
+ if (hasMin && hasMax) {
741
+ min = config.minsize;
742
+ max = config.maxsize;
743
+ } else if (hasMin) {
744
+ min = config.minsize;
745
+ max = total;
746
+ } else if (hasMax) {
747
+ min = 1;
748
+ max = config.maxsize;
749
+ } else {
750
+ min = total;
751
+ max = total;
752
+ }
753
+ min = Math.max(1, Math.min(min, total));
754
+ max = Math.max(min, Math.min(max, total));
755
+ const pick = randomInt(opts.rng, min, max);
756
+ shuffle(elements, opts.rng);
757
+ return joinWithSeparators(elements.slice(0, pick), config.sep, config.lastsep ?? config.sep);
758
+ }
759
+ function shuffle(arr, rng) {
760
+ for (let i = arr.length - 1; i > 0; i -= 1) {
761
+ const j = randomInt(rng, 0, i);
762
+ const tmp = arr[i];
763
+ arr[i] = arr[j];
764
+ arr[j] = tmp;
765
+ }
766
+ }
767
+ function joinWithSeparators(elements, globalSep, globalLastsep) {
768
+ const count = elements.length;
769
+ if (count === 0) return "";
770
+ if (count === 1) return elements[0].text;
771
+ let out = elements[0].text;
772
+ for (let i = 1; i < count; i += 1) {
773
+ const el = elements[i];
774
+ const sep = el.sep ?? (i === count - 1 ? globalLastsep : globalSep);
775
+ out += padSeparator(sep) + el.text;
776
+ }
777
+ return out;
778
+ }
779
+ function padSeparator(sep) {
780
+ const trimmed = phpTrim2(sep);
781
+ if (trimmed === "") return sep;
782
+ if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
783
+ return sep;
784
+ }
785
+ function splitTopLevelPipes(inner) {
786
+ const parts = [];
787
+ let brace = 0;
788
+ let bracket = 0;
789
+ let cur = "";
790
+ for (const ch of inner) {
791
+ if (ch === "{") brace += 1;
792
+ else if (ch === "}") brace -= 1;
793
+ else if (ch === "[") bracket += 1;
794
+ else if (ch === "]") bracket -= 1;
795
+ if (ch === "|" && brace === 0 && bracket === 0) {
796
+ parts.push(cur);
797
+ cur = "";
798
+ } else {
799
+ cur += ch;
800
+ }
801
+ }
802
+ parts.push(cur);
803
+ return parts;
804
+ }
805
+ var PHP_LTRIM_RE2 = /^[ \t\n\r\0\x0B]+/u;
806
+ var PHP_RTRIM_RE2 = /[ \t\n\r\0\x0B]+$/u;
807
+ function phpTrim2(s) {
808
+ return s.replace(PHP_LTRIM_RE2, "").replace(PHP_RTRIM_RE2, "");
809
+ }
810
+
811
+ // src/internal/postprocess.ts
812
+ var SINGLE_ABBREVS = [
813
+ // Russian editorial / address / unit shorthands.
814
+ "\u0441\u043E\u0446",
815
+ "\u044D\u043B",
816
+ "\u0441\u043C",
817
+ "\u0441\u0440",
818
+ "\u0441\u0442",
819
+ "\u0443\u043B",
820
+ "\u043F\u0440",
821
+ "\u043F\u0435\u0440",
822
+ "\u0433",
823
+ "\u0440",
824
+ "\u0440\u0443\u0431",
825
+ "\u043A\u043E\u043F",
826
+ "\u0442\u044B\u0441",
827
+ "\u043C\u043B\u043D",
828
+ "\u043C\u043B\u0440\u0434",
829
+ "\u0442\u0440\u043B\u043D",
830
+ "\u0434\u043E\u043F",
831
+ "\u043D\u0430\u043F\u0440",
832
+ "\u043F\u0440\u0438\u043C",
833
+ "\u0438\u0437\u0434",
834
+ "\u043E\u0431\u043B",
835
+ "\u0440\u0435\u0441\u043F",
836
+ "\u0441\u0442\u0440",
837
+ "\u0442\u0430\u0431\u043B",
838
+ "\u0440\u0438\u0441",
839
+ "\u043C\u0438\u043D",
840
+ "\u043C\u0430\u043A\u0441",
841
+ "\u0442\u0435\u043B",
842
+ "\u0444\u0430\u043A\u0441",
843
+ // English titles / business suffixes / editorial.
844
+ "etc",
845
+ "vs",
846
+ "Mr",
847
+ "Mrs",
848
+ "Ms",
849
+ "Dr",
850
+ "Prof",
851
+ "Sr",
852
+ "Jr",
853
+ "Inc",
854
+ "Ltd",
855
+ "Co",
856
+ "Corp",
857
+ "No",
858
+ "St",
859
+ "Ave",
860
+ "Blvd"
861
+ ];
862
+ var WS = " \\t\\r\\n\\f\\x0B";
863
+ var S = `[${WS}]`;
864
+ var DOMAIN_PART = "(?:(?:(?:xn--)?[\\p{L}\\p{N}]+(?:-[\\p{L}\\p{N}]+)*)\\.)+(?:xn--[a-z0-9\\-]{2,59}|[\\p{L}][\\p{L}\\p{N}-]{1,62})";
865
+ var URL_RE = new RegExp(`(?:https?|ftp):\\/\\/[^${WS}<>"')\\]]+`, "giu");
866
+ var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
867
+ var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
868
+ var DECIMAL_RE = /\b\d+\.\d+\b/gu;
869
+ var MULTI_ABBR_RE = new RegExp(`\\b(?:\\p{L}{1,2}\\.${S}*){2,}`, "gu");
870
+ var SINGLE_ABBR_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:${SINGLE_ABBREVS.join("|")})\\.(?=${S}|$|<)`, "giu");
871
+ var TRAILING_PUNCT_RE = /([.,;:!]+)$/u;
872
+ var SPACE_BEFORE_PUNCT_RE = new RegExp(`${S}+([,;:!?.])`, "gu");
873
+ var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\d)(?!${S}|$|<)`, "gu");
874
+ var SPACE_AFTER_SENTENCE_RE = new RegExp(`([.!?])(?!\\d)(?!${S}|$|<)`, "gu");
875
+ var CAP_FIRST_RE = new RegExp(`^(${S}*(?:<[^>]+>${S}*)*)(\\p{Ll})`, "u");
876
+ var CAP_AFTER_SENTENCE_RE = new RegExp(`([.!?\u2026])(${S}*(?:<\\/?[^>]+>${S}*)*)(\\p{Ll})`, "gu");
877
+ var CAP_AFTER_BLOCK_RE = new RegExp(`(<\\/?(?:p|h[1-6]|li|blockquote|div|td|th)[^>]*>${S}*)(\\p{Ll})`, "giu");
878
+ var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${S}*)(\\p{Ll})`, "gu");
879
+ var up = (ch) => ch.toUpperCase();
880
+ function postProcess(input) {
881
+ const placeholders = /* @__PURE__ */ new Map();
882
+ let counter = 0;
883
+ const store = (value, prefix) => {
884
+ const key = `\0${prefix}_${counter}\0`;
885
+ placeholders.set(key, value);
886
+ counter += 1;
887
+ return key;
888
+ };
889
+ const storeWithTrailingPunct = (value, prefix) => {
890
+ const m = TRAILING_PUNCT_RE.exec(value);
891
+ if (m) {
892
+ const suffix = m[1] ?? "";
893
+ const body = value.slice(0, value.length - suffix.length);
894
+ return body === "" ? suffix : store(body, prefix) + suffix;
895
+ }
896
+ return store(value, prefix);
897
+ };
898
+ let text = input;
899
+ text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
900
+ text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
901
+ text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
902
+ text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
903
+ text = text.replace(MULTI_ABBR_RE, (m) => store(m, "ABBR"));
904
+ text = text.replace(SINGLE_ABBR_RE, (m) => store(m, "ABBR"));
905
+ text = text.replace(/[ \t]{2,}/gu, " ");
906
+ text = text.replace(SPACE_BEFORE_PUNCT_RE, "$1");
907
+ text = text.replace(SPACE_AFTER_COMMA_RE, "$1 ");
908
+ text = text.replace(SPACE_AFTER_SENTENCE_RE, "$1 ");
909
+ text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
910
+ text = text.replace(CAP_AFTER_SENTENCE_RE, (_m, p, gap, ch) => p + gap + up(ch));
911
+ text = text.replace(CAP_AFTER_BLOCK_RE, (_m, tag, ch) => tag + up(ch));
912
+ text = text.replace(CAP_AFTER_BREAK_RE, (_m, br, ch) => br + up(ch));
913
+ for (const [key, value] of placeholders) {
914
+ text = text.split(key).join(value);
915
+ }
916
+ return text.trim();
917
+ }
918
+
919
+ // src/internal/pipeline.ts
920
+ var DEFAULT_MAX_DEPTH = 20;
921
+ function renderWith(input, rng, opts = {}) {
922
+ const ctx = {
923
+ runtimeContext: opts.context ?? {},
924
+ rng,
925
+ locale: opts.locale ?? "",
926
+ resolver: opts.includeResolver,
927
+ maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
928
+ includeStack: []
929
+ };
930
+ const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
931
+ let out = renderAst(ast, ctx);
932
+ if (opts.postProcess !== false) out = postProcess(out);
933
+ return safetyRestore(out);
934
+ }
935
+ function resolveAst(input) {
936
+ if (typeof input === "string") return parseTemplate(input);
937
+ if (isParsedAst(input)) return input;
938
+ throw new AstVersionError("Ast was not produced by this engine version.");
939
+ }
940
+
941
+ // src/internal/rng.ts
942
+ function makeRng(seed) {
943
+ const next = seed === void 0 ? Math.random : mulberry32(typeof seed === "number" ? seed >>> 0 : hashString(seed));
944
+ return (min, max) => min + Math.floor(next() * (max - min + 1));
945
+ }
946
+ function mulberry32(seed) {
947
+ let a = seed >>> 0;
948
+ return () => {
949
+ a = a + 1831565813 | 0;
950
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
951
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
952
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
953
+ };
954
+ }
955
+ function hashString(s) {
956
+ let h = 2166136261;
957
+ for (let i = 0; i < s.length; i += 1) {
958
+ h ^= s.charCodeAt(i);
959
+ h = Math.imul(h, 16777619);
960
+ }
961
+ return h >>> 0;
962
+ }
963
+
964
+ // src/index.ts
965
+ function parse(src) {
966
+ return parseTemplate(src);
967
+ }
968
+ function render(input, opts = {}) {
969
+ return renderWith(input, makeRng(opts.seed), opts);
970
+ }
971
+ function validate(input, opts = {}) {
972
+ return validateTemplate(resolveSource(input), opts);
973
+ }
974
+ function resolveSource(input) {
975
+ if (typeof input === "string") return input;
976
+ if (isParsedAst(input)) return input.source;
977
+ throw new AstVersionError("Ast was not produced by this engine version.");
978
+ }
979
+ function extract(input) {
980
+ return extractFromSource(resolveSource(input));
981
+ }
982
+ function analyze(input, opts = {}) {
983
+ const ast = resolveAst2(input);
984
+ const extracted = extractFromSource(ast.source);
985
+ return {
986
+ ...extracted,
987
+ diagnostics: validateTemplate(ast.source, opts),
988
+ constructs: countConstructs(ast, extracted.includes.length)
989
+ };
990
+ }
991
+ function resolveAst2(input) {
992
+ if (typeof input === "string") return parseTemplate(input);
993
+ if (isParsedAst(input)) return input;
994
+ throw new AstVersionError("Ast was not produced by this engine version.");
995
+ }
996
+ function countConstructs(ast, includeCount) {
997
+ const counts = {
998
+ enumeration: 0,
999
+ permutation: 0,
1000
+ variable: 0,
1001
+ conditional: 0,
1002
+ plural: 0
1003
+ };
1004
+ walk(ast.nodes, (n) => {
1005
+ if (n.type in counts) counts[n.type] = (counts[n.type] ?? 0) + 1;
1006
+ });
1007
+ counts.set = Object.keys(ast.setDefs).length;
1008
+ counts.include = includeCount;
1009
+ return counts;
1010
+ }
1011
+ function neutralize2(value) {
1012
+ return neutralize(value);
1013
+ }
1014
+
1015
+ exports.AstVersionError = AstVersionError;
1016
+ exports.DEFAULT_MAX_DEPTH = DEFAULT_MAX_DEPTH;
1017
+ exports.IncludeResolverError = IncludeResolverError;
1018
+ exports.NotImplementedError = NotImplementedError;
1019
+ exports.SpintaxError = SpintaxError;
1020
+ exports.analyze = analyze;
1021
+ exports.extract = extract;
1022
+ exports.neutralize = neutralize2;
1023
+ exports.parse = parse;
1024
+ exports.render = render;
1025
+ exports.validate = validate;
1026
+ //# sourceMappingURL=index.cjs.map
1027
+ //# sourceMappingURL=index.cjs.map