@spintax/core 0.1.6 → 0.2.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
@@ -22,6 +22,63 @@ interface Ast {
22
22
  readonly astVersion: typeof AST_VERSION;
23
23
  }
24
24
 
25
+ /**
26
+ * Tree-walk renderer with the plugin's staged semantics layered on (parity with
27
+ * Renderer::process_template). Not a naive walk — the deterministic stages are
28
+ * reproduced in order:
29
+ * - #set is a global pre-pass (parser) → setDefs; those enum values are
30
+ * COLLAPSED ONCE here (buildVars), skipping values with `{?`/`{plural ` (they
31
+ * reference vars and defer to the body) — plugin Stage 4b.
32
+ * - variables resolve against the merged map (runtime context > #set); a value
33
+ * that itself contains constructs is re-parsed and rendered (recursive, depth
34
+ * capped) — this covers conditionals/plurals introduced by a variable value
35
+ * (Stages 6b–6c), so a separate pre/post conditional pass isn't needed.
36
+ * - conditionals test truthiness against the raw var map; plurals resolve the
37
+ * count (vars already expanded) then pick the bucket, lenient fullwidth
38
+ * fallback (Stage 6d, after vars).
39
+ * - #include (post-tree string pass) and post-process are later PRs.
40
+ *
41
+ * RNG note: cross-engine RNG-sequence parity is a non-goal (§3.2). Enumerations
42
+ * render outer-first and LAZILY (only the picked branch's nested RNG is used) vs
43
+ * the plugin's eager innermost-out; corpus-safe because deterministic nested-enum
44
+ * cases use order-independent sequences. Permutation matches the plugin's exact
45
+ * pick→Fisher-Yates, so its rng-strategy cases are exact.
46
+ */
47
+
48
+ /** Document-level render context (threads through nested #include resolution). */
49
+ /**
50
+ * A `{plural …}` block the renderer could not resolve, reported through
51
+ * {@link RenderCtx.onPluralError}. Observation only — the render still degrades
52
+ * exactly as it always has (§0.1 lenient contract); the host decides whether a
53
+ * report is fatal. Mirrors the plugin's `on_error` callable.
54
+ */
55
+ interface PluralIssue {
56
+ /**
57
+ * `plural.nested-brackets` and `plural.arity` are the same codes `validate()`
58
+ * emits. `plural.count` has no validate counterpart on purpose — an unresolved
59
+ * count is a runtime-value fact, invisible to static analysis.
60
+ */
61
+ readonly code: 'plural.nested-brackets' | 'plural.arity' | 'plural.count';
62
+ readonly message: string;
63
+ /** The construct as the renderer saw it, AFTER variable expansion. */
64
+ readonly construct: string;
65
+ /** Normalized base language the arity was judged against. */
66
+ readonly locale: string;
67
+ /** Arity verdicts only. */
68
+ readonly expected?: number;
69
+ readonly got?: number;
70
+ }
71
+
72
+ /**
73
+ * The full render pipeline with an INJECTED rng — the seam shared by the public
74
+ * render() (which seeds the rng from RenderOptions.seed) and the corpus harness
75
+ * (which injects a fixture's `rng` strategy). Keeping the rng a parameter is what
76
+ * lets the deterministic parity fixtures assert exact output.
77
+ *
78
+ * Order: strip stray sentinels → collapse-once #set + tree-walk (+ #include) →
79
+ * cosmetic post-process (if on) → mandatory neutralize safety-restore (always).
80
+ */
81
+
25
82
  /** Default for {@link PipelineOptions.maxDepth} (#include + parse-nesting guard). */
26
83
  declare const DEFAULT_MAX_DEPTH = 20;
27
84
 
@@ -69,6 +126,17 @@ interface RenderOptions {
69
126
  postProcess?: boolean;
70
127
  /** #include + parse-nesting guard; defaults to {@link DEFAULT_MAX_DEPTH}. */
71
128
  maxDepth?: number;
129
+ /**
130
+ * Observer for `{plural …}` blocks the renderer could not resolve — the port of
131
+ * the plugin's `on_error` callable. Observation ONLY: output degrades exactly as
132
+ * it does without it (§0.1 lenient contract — `render()` never throws on template
133
+ * content), and the host decides whether a report is fatal.
134
+ *
135
+ * Worth wiring when a render is PERSISTED rather than shown: an unresolved count
136
+ * erases its block, which is indistinguishable in the output from copy that was
137
+ * meant to be empty.
138
+ */
139
+ onPluralError?: (issue: PluralIssue) => void;
72
140
  }
73
141
  interface ValidateOptions {
74
142
  /** Plural-bucket locale for arity verdicts (§3.1); omit ⇒ default 2-form. */
@@ -107,6 +175,31 @@ interface Analysis extends ExtractResult {
107
175
  constructs: Record<string, number>;
108
176
  }
109
177
 
178
+ /**
179
+ * A locale's base language tag: `pt-BR`→`pt`, `uk_UA`→`uk`, `RU`→`ru`. Absent or
180
+ * empty gives `''`.
181
+ *
182
+ * Knows nothing of ISO-639-3, so a 3-letter tag is returned whole (`srp`→`srp`)
183
+ * and is NOT recognized as its 2-letter language.
184
+ */
185
+ declare function normalizeBaseLang(locale?: string | null): string;
186
+ /**
187
+ * How many `{plural …}` forms this locale takes — 3 for the Slavic one/few/other
188
+ * family (ru/uk/be + sr/hr/bs), 2 for everything else.
189
+ *
190
+ * Takes a RAW locale and normalizes internally, unlike the internal helper of the
191
+ * same name: a public function that silently answered 2 for `sr-Latn` would be a
192
+ * trap. Accepts an absent locale, and unknown tags fall back to 2 rather than
193
+ * throwing (§3.1), so the same optional value `RenderOptions.locale` carries can be
194
+ * passed straight through.
195
+ *
196
+ * NOTE the asymmetry with {@link validate}: an absent or empty locale makes the
197
+ * validator skip the arity check ENTIRELY (structural verdicts only), whereas this
198
+ * answers the 2-form default that {@link render} would then apply. Ask this what
199
+ * render will do; do not read it as "what validate will enforce".
200
+ */
201
+ declare function pluralArity(locale?: string | null): number;
202
+
110
203
  declare function parse(src: string): Ast;
111
204
  declare function render(input: string | Ast, opts?: RenderOptions): string;
112
205
  declare function validate(input: string | Ast, opts?: ValidateOptions): Diagnostic[];
@@ -114,4 +207,4 @@ declare function extract(input: string | Ast): ExtractResult;
114
207
  declare function analyze(input: string | Ast, opts?: ValidateOptions): Analysis;
115
208
  declare function neutralize(value: string): string;
116
209
 
117
- export { type Analysis, type Ast, AstVersionError, DEFAULT_MAX_DEPTH, type Diagnostic, type ExtractResult, IncludeResolverError, NotImplementedError, type RenderOptions, SpintaxError, type ValidateOptions, analyze, extract, neutralize, parse, render, validate };
210
+ export { type Analysis, type Ast, AstVersionError, DEFAULT_MAX_DEPTH, type Diagnostic, type ExtractResult, IncludeResolverError, NotImplementedError, type PluralIssue, type RenderOptions, SpintaxError, type ValidateOptions, analyze, extract, neutralize, normalizeBaseLang, parse, pluralArity, render, validate };
package/dist/index.d.ts CHANGED
@@ -22,6 +22,63 @@ interface Ast {
22
22
  readonly astVersion: typeof AST_VERSION;
23
23
  }
24
24
 
25
+ /**
26
+ * Tree-walk renderer with the plugin's staged semantics layered on (parity with
27
+ * Renderer::process_template). Not a naive walk — the deterministic stages are
28
+ * reproduced in order:
29
+ * - #set is a global pre-pass (parser) → setDefs; those enum values are
30
+ * COLLAPSED ONCE here (buildVars), skipping values with `{?`/`{plural ` (they
31
+ * reference vars and defer to the body) — plugin Stage 4b.
32
+ * - variables resolve against the merged map (runtime context > #set); a value
33
+ * that itself contains constructs is re-parsed and rendered (recursive, depth
34
+ * capped) — this covers conditionals/plurals introduced by a variable value
35
+ * (Stages 6b–6c), so a separate pre/post conditional pass isn't needed.
36
+ * - conditionals test truthiness against the raw var map; plurals resolve the
37
+ * count (vars already expanded) then pick the bucket, lenient fullwidth
38
+ * fallback (Stage 6d, after vars).
39
+ * - #include (post-tree string pass) and post-process are later PRs.
40
+ *
41
+ * RNG note: cross-engine RNG-sequence parity is a non-goal (§3.2). Enumerations
42
+ * render outer-first and LAZILY (only the picked branch's nested RNG is used) vs
43
+ * the plugin's eager innermost-out; corpus-safe because deterministic nested-enum
44
+ * cases use order-independent sequences. Permutation matches the plugin's exact
45
+ * pick→Fisher-Yates, so its rng-strategy cases are exact.
46
+ */
47
+
48
+ /** Document-level render context (threads through nested #include resolution). */
49
+ /**
50
+ * A `{plural …}` block the renderer could not resolve, reported through
51
+ * {@link RenderCtx.onPluralError}. Observation only — the render still degrades
52
+ * exactly as it always has (§0.1 lenient contract); the host decides whether a
53
+ * report is fatal. Mirrors the plugin's `on_error` callable.
54
+ */
55
+ interface PluralIssue {
56
+ /**
57
+ * `plural.nested-brackets` and `plural.arity` are the same codes `validate()`
58
+ * emits. `plural.count` has no validate counterpart on purpose — an unresolved
59
+ * count is a runtime-value fact, invisible to static analysis.
60
+ */
61
+ readonly code: 'plural.nested-brackets' | 'plural.arity' | 'plural.count';
62
+ readonly message: string;
63
+ /** The construct as the renderer saw it, AFTER variable expansion. */
64
+ readonly construct: string;
65
+ /** Normalized base language the arity was judged against. */
66
+ readonly locale: string;
67
+ /** Arity verdicts only. */
68
+ readonly expected?: number;
69
+ readonly got?: number;
70
+ }
71
+
72
+ /**
73
+ * The full render pipeline with an INJECTED rng — the seam shared by the public
74
+ * render() (which seeds the rng from RenderOptions.seed) and the corpus harness
75
+ * (which injects a fixture's `rng` strategy). Keeping the rng a parameter is what
76
+ * lets the deterministic parity fixtures assert exact output.
77
+ *
78
+ * Order: strip stray sentinels → collapse-once #set + tree-walk (+ #include) →
79
+ * cosmetic post-process (if on) → mandatory neutralize safety-restore (always).
80
+ */
81
+
25
82
  /** Default for {@link PipelineOptions.maxDepth} (#include + parse-nesting guard). */
26
83
  declare const DEFAULT_MAX_DEPTH = 20;
27
84
 
@@ -69,6 +126,17 @@ interface RenderOptions {
69
126
  postProcess?: boolean;
70
127
  /** #include + parse-nesting guard; defaults to {@link DEFAULT_MAX_DEPTH}. */
71
128
  maxDepth?: number;
129
+ /**
130
+ * Observer for `{plural …}` blocks the renderer could not resolve — the port of
131
+ * the plugin's `on_error` callable. Observation ONLY: output degrades exactly as
132
+ * it does without it (§0.1 lenient contract — `render()` never throws on template
133
+ * content), and the host decides whether a report is fatal.
134
+ *
135
+ * Worth wiring when a render is PERSISTED rather than shown: an unresolved count
136
+ * erases its block, which is indistinguishable in the output from copy that was
137
+ * meant to be empty.
138
+ */
139
+ onPluralError?: (issue: PluralIssue) => void;
72
140
  }
73
141
  interface ValidateOptions {
74
142
  /** Plural-bucket locale for arity verdicts (§3.1); omit ⇒ default 2-form. */
@@ -107,6 +175,31 @@ interface Analysis extends ExtractResult {
107
175
  constructs: Record<string, number>;
108
176
  }
109
177
 
178
+ /**
179
+ * A locale's base language tag: `pt-BR`→`pt`, `uk_UA`→`uk`, `RU`→`ru`. Absent or
180
+ * empty gives `''`.
181
+ *
182
+ * Knows nothing of ISO-639-3, so a 3-letter tag is returned whole (`srp`→`srp`)
183
+ * and is NOT recognized as its 2-letter language.
184
+ */
185
+ declare function normalizeBaseLang(locale?: string | null): string;
186
+ /**
187
+ * How many `{plural …}` forms this locale takes — 3 for the Slavic one/few/other
188
+ * family (ru/uk/be + sr/hr/bs), 2 for everything else.
189
+ *
190
+ * Takes a RAW locale and normalizes internally, unlike the internal helper of the
191
+ * same name: a public function that silently answered 2 for `sr-Latn` would be a
192
+ * trap. Accepts an absent locale, and unknown tags fall back to 2 rather than
193
+ * throwing (§3.1), so the same optional value `RenderOptions.locale` carries can be
194
+ * passed straight through.
195
+ *
196
+ * NOTE the asymmetry with {@link validate}: an absent or empty locale makes the
197
+ * validator skip the arity check ENTIRELY (structural verdicts only), whereas this
198
+ * answers the 2-form default that {@link render} would then apply. Ask this what
199
+ * render will do; do not read it as "what validate will enforce".
200
+ */
201
+ declare function pluralArity(locale?: string | null): number;
202
+
110
203
  declare function parse(src: string): Ast;
111
204
  declare function render(input: string | Ast, opts?: RenderOptions): string;
112
205
  declare function validate(input: string | Ast, opts?: ValidateOptions): Diagnostic[];
@@ -114,4 +207,4 @@ declare function extract(input: string | Ast): ExtractResult;
114
207
  declare function analyze(input: string | Ast, opts?: ValidateOptions): Analysis;
115
208
  declare function neutralize(value: string): string;
116
209
 
117
- export { type Analysis, type Ast, AstVersionError, DEFAULT_MAX_DEPTH, type Diagnostic, type ExtractResult, IncludeResolverError, NotImplementedError, type RenderOptions, SpintaxError, type ValidateOptions, analyze, extract, neutralize, parse, render, validate };
210
+ export { type Analysis, type Ast, AstVersionError, DEFAULT_MAX_DEPTH, type Diagnostic, type ExtractResult, IncludeResolverError, NotImplementedError, type PluralIssue, type RenderOptions, SpintaxError, type ValidateOptions, analyze, extract, neutralize, normalizeBaseLang, parse, pluralArity, render, validate };
package/dist/index.js CHANGED
@@ -342,6 +342,9 @@ function pluralArity(baseLang) {
342
342
  case "ru":
343
343
  case "uk":
344
344
  case "be":
345
+ case "sr":
346
+ case "hr":
347
+ case "bs":
345
348
  return 3;
346
349
  default:
347
350
  return 2;
@@ -355,6 +358,9 @@ function pluralFor(baseLang, n, forms) {
355
358
  case "ru":
356
359
  case "uk":
357
360
  case "be":
361
+ case "sr":
362
+ case "hr":
363
+ case "bs":
358
364
  if (mod10 === 1 && mod100 !== 11) return forms[0] ?? "";
359
365
  if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1] ?? "";
360
366
  return forms[2] ?? "";
@@ -646,7 +652,13 @@ var MAX_VARIABLE_DEPTH = 50;
646
652
  var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
647
653
  function renderAst(ast, ctx) {
648
654
  const vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
649
- const text = renderNodes(ast.nodes, { vars, rng: ctx.rng, locale: ctx.locale, depth: 0 });
655
+ const text = renderNodes(ast.nodes, {
656
+ vars,
657
+ rng: ctx.rng,
658
+ locale: ctx.locale,
659
+ depth: 0,
660
+ onPluralError: ctx.onPluralError
661
+ });
650
662
  return ctx.resolver ? resolveIncludes(text, ctx) : text;
651
663
  }
652
664
  function resolveIncludes(text, ctx) {
@@ -749,17 +761,47 @@ function renderConditional(node, opts) {
749
761
  function renderPlural(node, opts) {
750
762
  const countRaw = expandVarsOnly(node.countRaw, opts);
751
763
  const formsRaw = expandVarsOnly(node.formsRaw, opts);
752
- if (/[{}[\]]/u.test(formsRaw)) return fullwidthVerbatim(countRaw, formsRaw);
753
- const count = phpTrim2(countRaw);
754
- if (!/^-?\d+$/u.test(count)) return "";
755
764
  const base = normalizeBaseLang(opts.locale);
765
+ const report = (issue) => opts.onPluralError?.(issue);
766
+ if (/[{}[\]]/u.test(formsRaw)) {
767
+ report({
768
+ code: "plural.nested-brackets",
769
+ message: "Plural form slot contains nested spintax brackets; extract via #set first.",
770
+ construct: rawConstruct(countRaw, formsRaw),
771
+ locale: base
772
+ });
773
+ return fullwidthVerbatim(countRaw, formsRaw);
774
+ }
775
+ const count = phpTrim2(countRaw);
776
+ if (!/^-?\d+$/u.test(count)) {
777
+ report({
778
+ code: "plural.count",
779
+ message: `Plural count slot is empty or non-numeric (${JSON.stringify(count)}); block erased.`,
780
+ construct: rawConstruct(countRaw, formsRaw),
781
+ locale: base
782
+ });
783
+ return "";
784
+ }
756
785
  const forms = formsRaw.split("|").map((f) => phpTrim2(f));
757
- if (forms.length !== pluralArity(base)) return fullwidthVerbatim(countRaw, formsRaw);
786
+ if (forms.length !== pluralArity(base)) {
787
+ report({
788
+ code: "plural.arity",
789
+ message: `Plural has ${forms.length} form(s); locale "${base}" takes ${pluralArity(base)}.`,
790
+ construct: rawConstruct(countRaw, formsRaw),
791
+ locale: base,
792
+ expected: pluralArity(base),
793
+ got: forms.length
794
+ });
795
+ return fullwidthVerbatim(countRaw, formsRaw);
796
+ }
758
797
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
759
798
  return renderNodes(parseSequence(picked), opts);
760
799
  }
800
+ function rawConstruct(countRaw, formsRaw) {
801
+ return `{plural ${countRaw}:${formsRaw}}`;
802
+ }
761
803
  function fullwidthVerbatim(countRaw, formsRaw) {
762
- return `{plural ${countRaw}:${formsRaw}}`.replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
804
+ return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
763
805
  }
764
806
  function renderEnumeration(options, opts) {
765
807
  if (options.length === 0) return "";
@@ -975,7 +1017,8 @@ function renderWith(input, rng, opts = {}) {
975
1017
  locale: opts.locale ?? "",
976
1018
  resolver: opts.includeResolver,
977
1019
  maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
978
- includeStack: []
1020
+ includeStack: [],
1021
+ onPluralError: opts.onPluralError
979
1022
  };
980
1023
  const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
981
1024
  let out = renderAst(ast, ctx);
@@ -1012,6 +1055,12 @@ function hashString(s) {
1012
1055
  }
1013
1056
 
1014
1057
  // src/index.ts
1058
+ function normalizeBaseLang2(locale) {
1059
+ return normalizeBaseLang(locale ?? "");
1060
+ }
1061
+ function pluralArity2(locale) {
1062
+ return pluralArity(normalizeBaseLang2(locale));
1063
+ }
1015
1064
  function parse(src) {
1016
1065
  return parseTemplate(src);
1017
1066
  }
@@ -1062,6 +1111,6 @@ function neutralize2(value) {
1062
1111
  return neutralize(value);
1063
1112
  }
1064
1113
 
1065
- export { AstVersionError, DEFAULT_MAX_DEPTH, IncludeResolverError, NotImplementedError, SpintaxError, analyze, extract, neutralize2 as neutralize, parse, render, validate };
1114
+ export { AstVersionError, DEFAULT_MAX_DEPTH, IncludeResolverError, NotImplementedError, SpintaxError, analyze, extract, neutralize2 as neutralize, normalizeBaseLang2 as normalizeBaseLang, parse, pluralArity2 as pluralArity, render, validate };
1066
1115
  //# sourceMappingURL=index.js.map
1067
1116
  //# sourceMappingURL=index.js.map