@spintax/core 0.1.3 → 0.1.5
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 +59 -0
- package/README.md +27 -0
- package/dist/index.cjs +13 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,65 @@
|
|
|
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.1.5 — 2026-07-13
|
|
7
|
+
|
|
8
|
+
Spanish post-process fix. No API change.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`postProcess` no longer strips the capital from every Spanish sentence.** Spanish is the only
|
|
13
|
+
European language whose punctuation *opens* a sentence, and the capitalization passes upper-cased
|
|
14
|
+
the first **character** after a sentence boundary — which, for `¿cómo estás?`, is `¿`. An inverted
|
|
15
|
+
mark has no uppercase form, so the pass was a no-op and the real first letter stayed lowercase.
|
|
16
|
+
The spacing pass had the mirror-image gap: it knew "no space *before* closing punctuation" but had
|
|
17
|
+
no rule for an opener, so `¿ qué tal ?` only half-collapsed.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
hola. ¿cómo estás? ¡genial! → Hola. ¿Cómo estás? ¡Genial!
|
|
21
|
+
Hola. ¿ qué tal ? → Hola. ¿Qué tal?
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A new `SENTENCE_OPENERS = '¿¡'` concept drives both: an opener binds to the word it opens (before
|
|
25
|
+
capitalization, deliberately), and the four capitalization sites — start of text, after `.!?…`,
|
|
26
|
+
after a block-level tag, and after a newline — allow an optional opener between the boundary and
|
|
27
|
+
the letter. HTML paragraphs and multi-line templates were broken exactly like the bare `. ¿` case.
|
|
28
|
+
|
|
29
|
+
The opener set is deliberately **narrow**: quotes and brackets both open *and* close, so
|
|
30
|
+
capitalizing after them would mangle list markers (`Elige una. (a) primero`). Golden-corpus
|
|
31
|
+
fixtures guard both the fix and its narrowness, in **both** engines.
|
|
32
|
+
|
|
33
|
+
### Notes
|
|
34
|
+
|
|
35
|
+
- Mirrored into the PHP plugin engine to hold the post-process parity contract. Verified against the
|
|
36
|
+
shared golden corpus in both engines: TS 230 tests; the conformance runner against the real plugin
|
|
37
|
+
engine 99 tests / 112 assertions; the plugin's own suite 578 tests, no regressions.
|
|
38
|
+
- The plugin carries the same fix but ships it in a later release, so for a short window a published
|
|
39
|
+
plugin and this package can differ on Spanish output. The parity *contract* holds — the engines'
|
|
40
|
+
behavior agrees and the corpus proves it — only the release timing differs.
|
|
41
|
+
|
|
42
|
+
## 0.1.4 — 2026-07-13
|
|
43
|
+
|
|
44
|
+
A post-process bug fix (hit in production) plus docs. No API change.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`postProcess` no longer mangles `mailto:` / `tel:` URIs.** They carry no `//` authority,
|
|
49
|
+
so the URL shield missed them; the email shield then carved the address out from under the
|
|
50
|
+
prefix, and the "space after a colon" rule split the leftover into a malformed
|
|
51
|
+
`mailto: contact@example.com` href. They are now shielded as whole tokens (with the same
|
|
52
|
+
trailing-punctuation handling as URLs) before the email/domain passes. Mirrored into the PHP
|
|
53
|
+
engine and covered by 4 golden-corpus fixtures, so the post-process parity contract holds
|
|
54
|
+
in both engines. Reported in [#41](https://github.com/investblog/spintax-js/issues/41).
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Docs: a **Use Cases** section (cold email, notifications, chatbots, A/B copy, programmatic
|
|
59
|
+
SEO, spinning LLM-drafted templates locally) and the N-variants recipe — call `render` N
|
|
60
|
+
times with different seeds — with the caveat that distinct seeds are *independent draws, not
|
|
61
|
+
distinct results*, so a low-cardinality template will repeat. Batching stays a host concern.
|
|
62
|
+
- npm keywords broadened (`text-spinner`, `email-template`, `placeholders`, `variables`,
|
|
63
|
+
`conditionals`) to match how people actually search for this.
|
|
64
|
+
|
|
6
65
|
## 0.1.3 — 2026-07-08
|
|
7
66
|
|
|
8
67
|
Precise diagnostic positions. Backward-compatible — `validate()` verdicts (pass/fail),
|
package/README.md
CHANGED
|
@@ -43,6 +43,33 @@ validate('{a|b'); // → [{ severity: 'error', code: 'bracket.unclosed'
|
|
|
43
43
|
extract('%title% {?promo?Sale}'); // → { refs: ['title', 'promo'], sets: [], includes: [] }
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## Use cases
|
|
47
|
+
|
|
48
|
+
Anywhere one message has to go out many times without reading like a form letter:
|
|
49
|
+
|
|
50
|
+
- **Cold email & outreach** — one template, a distinct body per recipient, personalized through `context`.
|
|
51
|
+
- **SMS / push / notifications** — deterministic output in a few kilobytes, no API call per send.
|
|
52
|
+
- **Chatbots & agents** — vary canned replies so a bot doesn't repeat itself verbatim.
|
|
53
|
+
- **A/B and multivariate testing** — enumerate copy variants in the template instead of in application code.
|
|
54
|
+
- **Programmatic SEO / content generation** — thousands of pages from one authored source.
|
|
55
|
+
- **LLM output at scale** — have a model draft the template once, then spin it locally, forever, for free.
|
|
56
|
+
|
|
57
|
+
The core renders a **single** string per call — batching is a host concern. To emit N variants, call
|
|
58
|
+
`render` N times with different seeds; a seeded call is reproducible, so any variant can be
|
|
59
|
+
regenerated later from its seed alone:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const template = '{Hi|Hello|Hey} %name%, {quick|short|small} question about {pricing|billing}';
|
|
63
|
+
const variants = [1, 2, 3].map((seed) => render(template, { context: { name: 'Ada' }, seed }));
|
|
64
|
+
// → [ 'Hello Ada, quick question about billing',
|
|
65
|
+
// 'Hey Ada, quick question about pricing',
|
|
66
|
+
// 'Hey Ada, quick question about pricing' ] ← same as seed 2; see the caveat below
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Distinct seeds are **independent draws, not distinct results** — like any sampling they can repeat,
|
|
70
|
+
and the fewer combinations a template has, the more often they will. If you need N *unique* variants,
|
|
71
|
+
dedupe in the host and cap the retries: a template may simply not have N combinations to give.
|
|
72
|
+
|
|
46
73
|
## Spintax syntax
|
|
47
74
|
|
|
48
75
|
| Construct | Example | Meaning |
|
package/dist/index.cjs
CHANGED
|
@@ -906,19 +906,26 @@ var WS = " \\t\\r\\n\\f\\x0B";
|
|
|
906
906
|
var S = `[${WS}]`;
|
|
907
907
|
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})";
|
|
908
908
|
var URL_RE = new RegExp(`(?:https?|ftp):\\/\\/[^${WS}<>"')\\]]+`, "giu");
|
|
909
|
+
var MAILTEL_RE = new RegExp(`(?:mailto|tel):[^${WS}<>"')\\]]+`, "giu");
|
|
909
910
|
var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
|
|
910
911
|
var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
|
|
911
912
|
var DECIMAL_RE = /\b\d+\.\d+\b/gu;
|
|
912
913
|
var MULTI_ABBR_RE = new RegExp(`\\b(?:\\p{L}{1,2}\\.${S}*){2,}`, "gu");
|
|
913
914
|
var SINGLE_ABBR_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:${SINGLE_ABBREVS.join("|")})\\.(?=${S}|$|<)`, "giu");
|
|
914
915
|
var TRAILING_PUNCT_RE = /([.,;:!]+)$/u;
|
|
916
|
+
var SENTENCE_OPENERS = "\xBF\xA1";
|
|
917
|
+
var OPEN = `[${SENTENCE_OPENERS}]?${S}*`;
|
|
915
918
|
var SPACE_BEFORE_PUNCT_RE = new RegExp(`${S}+([,;:!?.])`, "gu");
|
|
916
919
|
var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\d)(?!${S}|$|<)`, "gu");
|
|
917
920
|
var SPACE_AFTER_SENTENCE_RE = new RegExp(`([.!?])(?!\\d)(?!${S}|$|<)`, "gu");
|
|
918
|
-
var
|
|
919
|
-
var
|
|
920
|
-
var
|
|
921
|
-
var
|
|
921
|
+
var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
|
|
922
|
+
var CAP_FIRST_RE = new RegExp(`^(${S}*(?:<[^>]+>${S}*)*${OPEN})(\\p{Ll})`, "u");
|
|
923
|
+
var CAP_AFTER_SENTENCE_RE = new RegExp(`([.!?\u2026])(${S}*(?:<\\/?[^>]+>${S}*)*${OPEN})(\\p{Ll})`, "gu");
|
|
924
|
+
var CAP_AFTER_BLOCK_RE = new RegExp(
|
|
925
|
+
`(<\\/?(?:p|h[1-6]|li|blockquote|div|td|th)[^>]*>${S}*${OPEN})(\\p{Ll})`,
|
|
926
|
+
"giu"
|
|
927
|
+
);
|
|
928
|
+
var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${S}*${OPEN})(\\p{Ll})`, "gu");
|
|
922
929
|
var up = (ch) => ch.toUpperCase();
|
|
923
930
|
function postProcess(input) {
|
|
924
931
|
const placeholders = /* @__PURE__ */ new Map();
|
|
@@ -940,6 +947,7 @@ function postProcess(input) {
|
|
|
940
947
|
};
|
|
941
948
|
let text = input;
|
|
942
949
|
text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
|
|
950
|
+
text = text.replace(MAILTEL_RE, (m) => storeWithTrailingPunct(m, "URI"));
|
|
943
951
|
text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
|
|
944
952
|
text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
|
|
945
953
|
text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
|
|
@@ -949,6 +957,7 @@ function postProcess(input) {
|
|
|
949
957
|
text = text.replace(SPACE_BEFORE_PUNCT_RE, "$1");
|
|
950
958
|
text = text.replace(SPACE_AFTER_COMMA_RE, "$1 ");
|
|
951
959
|
text = text.replace(SPACE_AFTER_SENTENCE_RE, "$1 ");
|
|
960
|
+
text = text.replace(SPACE_AFTER_OPENER_RE, "$1");
|
|
952
961
|
text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
|
|
953
962
|
text = text.replace(CAP_AFTER_SENTENCE_RE, (_m, p, gap, ch) => p + gap + up(ch));
|
|
954
963
|
text = text.replace(CAP_AFTER_BLOCK_RE, (_m, tag, ch) => tag + up(ch));
|