@spintax/core
v0.14.0
Published
Framework-agnostic spintax / text-spinning engine for JS & TS — turn one LLM-drafted {a|b|c} template into endless deterministic variations. Render, validate, extract. Zero-dependency; Node, Workers, browser. Also available as an n8n community node (n8n-n
Maintainers
Readme
@spintax/core
Framework-agnostic spintax engine for JavaScript / TypeScript — parse, render, validate, extract, analyze, and neutralize spintax templates.
Pairs naturally with LLMs. Have a model draft a spintax template once, then generate unlimited variations on-device — deterministic, free, and offline, with no per-generation API calls. The LLM handles creativity; the engine handles scale.
- Zero runtime dependencies. Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
- ESM-first, dual CJS. Ships
.d.tstypes for both. - One syntax contract, six independent engines.
This package,
spintax/core(PHP),spintax-core(Python), an Object Pascal engine,Spintax.Core(.NET), and the Spintax WordPress plugin are all held to the same behavior contract by a shared golden corpus of 486 fixtures — deterministic verdicts, plural buckets, conditionals,#set/#defsemantics and post-processing agree everywhere, so a template is an asset you can move between runtimes, not a lock-in. - MIT licensed.
Status: released & stable. Feature-complete — parse / render / validate / extract / analyze / neutralize — with the public API proven by real consumers: a reference Cloudflare Worker, the @spintaxnetbot Telegram bot, the spintax.net playground, and the n8n community node.
Install
npm install @spintax/coreQuick start
import { render, validate, extract } from '@spintax/core';
render('{Hello|Hi|Hey} %name%!', { context: { name: 'Ada' }, seed: 42 });
// → "Hi Ada!" (deterministic for a given seed; post-processed by default)
validate('{a|b'); // → [{ severity: 'error', code: 'bracket.unclosed', … }]
extract('%title% {?promo?Sale}'); // → { refs: ['title', 'promo'], sets: [], includes: [] }Use cases
Anywhere one message has to go out many times without reading like a form letter:
- Cold email & outreach — one template, a distinct body per recipient, personalized through
context. - SMS / push / notifications — deterministic output in a few kilobytes, no API call per send.
- Chatbots & agents — vary canned replies so a bot doesn't repeat itself verbatim.
- A/B and multivariate testing — enumerate copy variants in the template instead of in application code.
- Programmatic SEO / content generation — thousands of pages from one authored source.
- LLM output at scale — have a model draft the template once, then spin it locally, forever, for free.
The core renders a single string per call — batching is a host concern. To emit N variants, call
render N times with different seeds; a seeded call is reproducible, so any variant can be
regenerated later from its seed alone:
const template = '{Hi|Hello|Hey} %name%, {quick|short|small} question about {pricing|billing}';
const variants = [1, 2, 3].map((seed) => render(template, { context: { name: 'Ada' }, seed }));
// → [ 'Hello Ada, quick question about billing',
// 'Hey Ada, quick question about pricing',
// 'Hey Ada, quick question about pricing' ] ← same as seed 2; see the caveat belowDistinct seeds are independent draws, not distinct results — like any sampling they can repeat, and the fewer combinations a template has, the more often they will. If you need N unique variants, dedupe in the host and cap the retries: a template may simply not have N combinations to give.
Spintax syntax
| Construct | Example | Meaning |
| --- | --- | --- |
| Enumeration | {a\|b\|c} | pick one (nestable: {a\|{b\|c}}) |
| Permutation | [a\|b\|c] | pick N, shuffle, join — [<minsize=1;maxsize=2;sep=", ">a\|b\|c] |
| Variable | %var% | substitute a context value — inside {…}/[…] the value is spliced as text first, so a | it carries separates options |
| Local set | #set %v% = value | define a macro — re-picked at every use |
| Local def | #def %v% = value | define a value — picked once per render, held at every use |
| Conditional | {?VAR?then\|else} | then if VAR is truthy, else else — inside {…}/[…] it resolves first, so a \| in the taken branch separates options and an empty one drops its element |
| Plural | {plural %n%: one\|few\|many} | grammatical agreement by locale — 2 forms (en), 3 (ru/uk/be, sr/hr/bs), 6 (ar: zero|one|two|few|many|other) |
| Include | #include "slug-or-id" | embed another template (host-resolved) |
| Comment | /# … #/ | stripped before rendering |
| Verbatim | /! … !/ | emitted exactly as written — no spintax inside, no post-process: /!/, /!#file[D:\kw.txt,1,S]!/. The first !/ closes it; an unclosed /! is plain text and a verbatim.unclosed warning |
API
All functions accept a string or a parsed Ast (from parse) as their first argument.
render(input, options?): string
Renders a template to a single string. Lenient — never throws on malformed markup (a bad
block is emitted verbatim with fullwidth braces {…}). Cosmetic post-processing (spacing,
capitalization, URL/email shielding) is on by default.
render(input, {
context?: Record<string, string>, // variable map
seed?: number | string, // deterministic RNG; omit ⇒ random
locale?: string, // plural buckets: 'ru' 3-form, 'ar' 6-form
includeResolver?: (ref: string) => string | null, // host-injected, synchronous
postProcess?: boolean, // default true; false ⇒ raw pick
maxDepth?: number, // #include / nesting guard (default 20)
});#include is resolved only when you pass an includeResolver; child templates inherit the
runtime context but not the parent's #set locals. Circular / too-deep includes resolve to
'' (lenient).
validate(input, options?): Diagnostic[]
Returns diagnostics. A template is valid ⇔ no diagnostic has severity: 'error'. An
unresolved %var% is a warning, not an error.
validate(input, {
locale?: string, // locale-aware plural-arity verdicts
knownIncludes?: readonly string[], // enables "unknown #include target" errors
knownVariables?: readonly string[], // suppresses undefined-variable warnings
});
// Diagnostic: { severity, code, message, line, column, endLine?, endColumn?, data? }extract(input): { refs, sets, includes }
Variable references (%var%, {?…}, plural counts), #set names, and #include targets.
analyze(input, options?): Analysis
extract + validate + a best-effort constructs census ({ enumeration, permutation,
variable, conditional, plural, set, include }). The census counts author-visible constructs;
it is not a variant-cardinality promise.
parse(input): Ast
Parses once for reuse. Ast is opaque and versioned — pass it back to the other functions,
do not introspect or persist it across engine versions.
neutralize(value): string
Shields data-derived (untrusted) text so it can't be re-interpreted as spintax markup — use it
on any value you inject via context that isn't author-controlled. It is text-safe
(round-trips to literal glyphs in any sink), not HTML/XSS escaping.
render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProcess: false });
// → "Save {50|60}% today" (the braces stay literal, not a random pick)The pipe is deliberately not shielded: it means nothing outside a construct, and inside one it
is the separator. A neutralized value an author places in {…}/[…] is still split on its |, in
this engine as in every other in the family. Keep such a value out of a construct if it must stay
one option.
Using it without writing code
- n8n — install
n8n-nodes-spintax(Settings → Community Nodes). One node, five operations: Render, Render Many, a two-output Validate, and the LLM authoring/repair prompt builders — a Sheets-to-outbox personalization flow or a full AI authoring loop with no code and no credentials on the node's side. - Telegram — @spintaxnetbot: validate, preview, and AI-draft templates in chat.
- Browser — the spintax.net playground: live validation and N-variant rendering, entirely client-side (it runs this very package).
- Windows — Spintax Studio, a native editor in the Microsoft Store (renders via the family's Object Pascal engine; same corpus).
Determinism & RNG
With a seed, render is reproducible within this engine. Cross-engine RNG-sequence parity
with the PHP plugin is a non-goal — only the deterministic behavior (validation verdicts,
plural buckets, conditional truthiness, #set/#def semantics, post-process output) is parity-gated.
Links
- 📦 npm —
@spintax/core·n8n-nodes-spintax(the n8n node) - 🧪 Try it — playground ·
@spintaxnetbot (Telegram: validate + preview + AI
/draft) - 🖥️ Desktop — Spintax Studio (Microsoft Store)
- 🧬 The family — all five engines, one corpus (PHP · Python · Pascal · WordPress)
- 🧩 Source — investblog/spintax-js
- 🌐 Docs & guides — spintax.net
- 🏠 Maintainer — 301.st
License
MIT. The Spintax WordPress plugin remains GPL; MIT/Expat is GPL-compatible.
Part of the 301.st toolset. Product home: spintax.net.
