@spintax/core 0.1.2 → 0.1.4
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 +48 -0
- package/README.md +28 -1
- package/dist/index.cjs +85 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +85 -40
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,54 @@
|
|
|
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.4 — 2026-07-13
|
|
7
|
+
|
|
8
|
+
A post-process bug fix (hit in production) plus docs. No API change.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`postProcess` no longer mangles `mailto:` / `tel:` URIs.** They carry no `//` authority,
|
|
13
|
+
so the URL shield missed them; the email shield then carved the address out from under the
|
|
14
|
+
prefix, and the "space after a colon" rule split the leftover into a malformed
|
|
15
|
+
`mailto: contact@example.com` href. They are now shielded as whole tokens (with the same
|
|
16
|
+
trailing-punctuation handling as URLs) before the email/domain passes. Mirrored into the PHP
|
|
17
|
+
engine and covered by 4 golden-corpus fixtures, so the post-process parity contract holds
|
|
18
|
+
in both engines. Reported in [#41](https://github.com/investblog/spintax-js/issues/41).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Docs: a **Use Cases** section (cold email, notifications, chatbots, A/B copy, programmatic
|
|
23
|
+
SEO, spinning LLM-drafted templates locally) and the N-variants recipe — call `render` N
|
|
24
|
+
times with different seeds — with the caveat that distinct seeds are *independent draws, not
|
|
25
|
+
distinct results*, so a low-cardinality template will repeat. Batching stays a host concern.
|
|
26
|
+
- npm keywords broadened (`text-spinner`, `email-template`, `placeholders`, `variables`,
|
|
27
|
+
`conditionals`) to match how people actually search for this.
|
|
28
|
+
|
|
29
|
+
## 0.1.3 — 2026-07-08
|
|
30
|
+
|
|
31
|
+
Precise diagnostic positions. Backward-compatible — `validate()` verdicts (pass/fail),
|
|
32
|
+
codes, and severities are unchanged (still not parity-gated per §3.1); only the
|
|
33
|
+
best-effort position fields are improved. No render/parse behavior change.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`validate()` diagnostics now carry accurate `line`/`column` for every code**, not just
|
|
38
|
+
brackets. Previously `plural.*`, `permutation.*`, `set.malformed`, and `include.*` reported
|
|
39
|
+
a line with `column: 1`, and `variable.*` had no position at all (defaulted to `1:1`). All
|
|
40
|
+
now point at the offending token.
|
|
41
|
+
- **`endLine`/`endColumn`** are populated so a consumer can underline the exact span (e.g. the
|
|
42
|
+
whole `%name%` reference or `{plural …}` block).
|
|
43
|
+
- **Structured `data`** on diagnostics: `variable.undefined` → `{ name }`; `plural.arity` →
|
|
44
|
+
`{ expected, got }`; `permutation.*` → `{ key }` / `{ value }`; `bracket.*` → `{ bracket }` /
|
|
45
|
+
`{ open, close }`; `include.unknown-target` → `{ target }`. Lets a bot/editor build UI without
|
|
46
|
+
parsing the (non-parity-gated) `message`.
|
|
47
|
+
- `PluralBlock.end` (internal) — exclusive end offset, so validation can span the full block.
|
|
48
|
+
|
|
49
|
+
### Notes
|
|
50
|
+
|
|
51
|
+
- `variable.undefined` still reports **once per unique name**, now anchored at its first
|
|
52
|
+
occurrence. Consumers that want to highlight every occurrence can expand via `data.name`.
|
|
53
|
+
|
|
6
54
|
## 0.1.2 — 2026-07-08
|
|
7
55
|
|
|
8
56
|
Docs. No engine or API changes.
|
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# @spintax/core
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@spintax/core)
|
|
4
|
-
[](https://www.npmjs.com/package/@spintax/core)
|
|
5
4
|
[](https://github.com/investblog/spintax-js/actions/workflows/ci.yml)
|
|
6
5
|
[](https://www.npmjs.com/package/@spintax/core)
|
|
7
6
|
[](https://github.com/investblog/spintax-js/blob/main/LICENSE)
|
|
@@ -44,6 +43,33 @@ validate('{a|b'); // → [{ severity: 'error', code: 'bracket.unclosed'
|
|
|
44
43
|
extract('%title% {?promo?Sale}'); // → { refs: ['title', 'promo'], sets: [], includes: [] }
|
|
45
44
|
```
|
|
46
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
|
+
|
|
47
73
|
## Spintax syntax
|
|
48
74
|
|
|
49
75
|
| Construct | Example | Meaning |
|
|
@@ -131,6 +157,7 @@ plural buckets, conditional truthiness, `#set` collapse, post-process output) is
|
|
|
131
157
|
## Links
|
|
132
158
|
|
|
133
159
|
- 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core)
|
|
160
|
+
- 🤖 Try it — [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
|
|
134
161
|
- 🧩 Source — [investblog/spintax-js](https://github.com/investblog/spintax-js)
|
|
135
162
|
- 🌐 Product — [spintax.net](https://spintax.net)
|
|
136
163
|
- 🏠 Maintainer — [301.st](https://301.st)
|
package/dist/index.cjs
CHANGED
|
@@ -330,7 +330,7 @@ function findPluralBlocks(text) {
|
|
|
330
330
|
i = j + 1;
|
|
331
331
|
continue;
|
|
332
332
|
}
|
|
333
|
-
blocks.push({ start, countSlot: inner.slice(0, colon), formsRaw: inner.slice(colon + 1) });
|
|
333
|
+
blocks.push({ start, end: j + 1, countSlot: inner.slice(0, colon), formsRaw: inner.slice(colon + 1) });
|
|
334
334
|
i = j + 1;
|
|
335
335
|
}
|
|
336
336
|
return blocks;
|
|
@@ -381,11 +381,28 @@ function validateTemplate(src, opts = {}) {
|
|
|
381
381
|
}
|
|
382
382
|
return diagnostics;
|
|
383
383
|
}
|
|
384
|
-
function err(code, message,
|
|
385
|
-
return { severity: "error", code, message,
|
|
384
|
+
function err(code, message, pos) {
|
|
385
|
+
return { severity: "error", code, message, ...pos };
|
|
386
386
|
}
|
|
387
|
-
function warn(code, message,
|
|
388
|
-
return { severity: "warning", code, message,
|
|
387
|
+
function warn(code, message, pos) {
|
|
388
|
+
return { severity: "warning", code, message, ...pos };
|
|
389
|
+
}
|
|
390
|
+
function offsetToLineCol(text, offset) {
|
|
391
|
+
let line = 1;
|
|
392
|
+
let lineStart = 0;
|
|
393
|
+
const end = Math.min(Math.max(offset, 0), text.length);
|
|
394
|
+
for (let i = 0; i < end; i += 1) {
|
|
395
|
+
if (text.charAt(i) === "\n") {
|
|
396
|
+
line += 1;
|
|
397
|
+
lineStart = i + 1;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { line, column: end - lineStart + 1 };
|
|
401
|
+
}
|
|
402
|
+
function span(text, offset, length) {
|
|
403
|
+
const s = offsetToLineCol(text, offset);
|
|
404
|
+
const e = offsetToLineCol(text, offset + length);
|
|
405
|
+
return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
|
|
389
406
|
}
|
|
390
407
|
function checkBrackets(text, out) {
|
|
391
408
|
const close = { "{": "}", "[": "]" };
|
|
@@ -403,15 +420,15 @@ function checkBrackets(text, out) {
|
|
|
403
420
|
} else if (ch === "}" || ch === "]") {
|
|
404
421
|
const top = stack.pop();
|
|
405
422
|
if (top === void 0) {
|
|
406
|
-
out.push(err("bracket.unexpected-closing", `Unexpected closing '${ch}'.`, line, column));
|
|
423
|
+
out.push(err("bracket.unexpected-closing", `Unexpected closing '${ch}'.`, { line, column, endLine: line, endColumn: column + 1, data: { bracket: ch } }));
|
|
407
424
|
} else if (top.expect !== ch) {
|
|
408
|
-
out.push(err("bracket.mismatched", `'${top.char}' closed by '${ch}'.`, line, column));
|
|
425
|
+
out.push(err("bracket.mismatched", `'${top.char}' closed by '${ch}'.`, { line, column, endLine: line, endColumn: column + 1, data: { open: top.char, close: ch } }));
|
|
409
426
|
}
|
|
410
427
|
}
|
|
411
428
|
column += 1;
|
|
412
429
|
}
|
|
413
430
|
for (const unclosed of stack) {
|
|
414
|
-
out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, unclosed.line, unclosed.column));
|
|
431
|
+
out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, { line: unclosed.line, column: unclosed.column, endLine: unclosed.line, endColumn: unclosed.column + 1, data: { bracket: unclosed.char } }));
|
|
415
432
|
}
|
|
416
433
|
}
|
|
417
434
|
function checkSetDirectives(text, out) {
|
|
@@ -420,7 +437,9 @@ function checkSetDirectives(text, out) {
|
|
|
420
437
|
const trimmed = lineText.replace(/^[ \t]+/, "");
|
|
421
438
|
if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
|
|
422
439
|
if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
|
|
423
|
-
|
|
440
|
+
const column = lineText.length - trimmed.length + 1;
|
|
441
|
+
const line = idx + 1;
|
|
442
|
+
out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
|
|
424
443
|
}
|
|
425
444
|
});
|
|
426
445
|
}
|
|
@@ -428,20 +447,32 @@ function checkPermutationConfigs(text, out) {
|
|
|
428
447
|
for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
|
|
429
448
|
const configStr = m[1] ?? "";
|
|
430
449
|
if (!/\w+\s*=/.test(configStr)) continue;
|
|
431
|
-
const
|
|
450
|
+
const configBase = (m.index ?? 0) + 2;
|
|
432
451
|
for (const km of configStr.matchAll(/(\w+)\s*=/gu)) {
|
|
433
452
|
const key = (km[1] ?? "").toLowerCase();
|
|
434
453
|
if (!KNOWN_CONFIG_KEYS.has(key)) {
|
|
435
|
-
out.push(err(
|
|
454
|
+
out.push(err(
|
|
455
|
+
"permutation.unknown-key",
|
|
456
|
+
`Unknown permutation config key: '${km[1]}'.`,
|
|
457
|
+
{ ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
|
|
458
|
+
));
|
|
436
459
|
}
|
|
437
460
|
}
|
|
438
461
|
const min = /minsize\s*=\s*([^;>\s]+)/i.exec(configStr);
|
|
439
462
|
if (min && !/^\d+$/.test(min[1] ?? "")) {
|
|
440
|
-
out.push(err(
|
|
463
|
+
out.push(err(
|
|
464
|
+
"permutation.minsize-not-integer",
|
|
465
|
+
`minsize must be a positive integer, got '${min[1]}'.`,
|
|
466
|
+
{ ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
|
|
467
|
+
));
|
|
441
468
|
}
|
|
442
469
|
const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
|
|
443
470
|
if (max && !/^\d+$/.test(max[1] ?? "")) {
|
|
444
|
-
out.push(err(
|
|
471
|
+
out.push(err(
|
|
472
|
+
"permutation.maxsize-not-integer",
|
|
473
|
+
`maxsize must be a positive integer, got '${max[1]}'.`,
|
|
474
|
+
{ ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
|
|
475
|
+
));
|
|
445
476
|
}
|
|
446
477
|
}
|
|
447
478
|
}
|
|
@@ -449,15 +480,19 @@ function checkPlurals(text, locale, out) {
|
|
|
449
480
|
const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
|
|
450
481
|
const arity = base !== "" ? pluralArity(base) : 0;
|
|
451
482
|
for (const block of findPluralBlocks(text)) {
|
|
452
|
-
const
|
|
483
|
+
const at = span(text, block.start, block.end - block.start);
|
|
453
484
|
if (/[{}[\]]/.test(block.formsRaw)) {
|
|
454
|
-
out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.",
|
|
485
|
+
out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
|
|
455
486
|
continue;
|
|
456
487
|
}
|
|
457
488
|
if (arity > 0) {
|
|
458
489
|
const count = block.formsRaw.split("|").length;
|
|
459
490
|
if (count !== arity) {
|
|
460
|
-
out.push(err(
|
|
491
|
+
out.push(err(
|
|
492
|
+
"plural.arity",
|
|
493
|
+
`{plural ...}: expected ${arity} forms, got ${count}.`,
|
|
494
|
+
{ ...at, data: { expected: arity, got: count } }
|
|
495
|
+
));
|
|
461
496
|
}
|
|
462
497
|
}
|
|
463
498
|
}
|
|
@@ -465,37 +500,49 @@ function checkPlurals(text, locale, out) {
|
|
|
465
500
|
function checkVariableReferences(text, known, out) {
|
|
466
501
|
const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
|
|
467
502
|
const defs = /* @__PURE__ */ new Map();
|
|
503
|
+
const defPos = /* @__PURE__ */ new Map();
|
|
468
504
|
for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
|
|
469
|
-
|
|
505
|
+
const name = (m[1] ?? "").toLowerCase();
|
|
506
|
+
defs.set(name, m[2] ?? "");
|
|
507
|
+
const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
|
|
508
|
+
defPos.set(name, span(text, nameOffset, name.length + 2));
|
|
470
509
|
}
|
|
510
|
+
const somewhere = { line: 1, column: 1 };
|
|
471
511
|
for (const [name, value] of defs) {
|
|
472
512
|
if (value.toLowerCase().includes(`%${name}%`)) {
|
|
473
|
-
out.push(err("variable.self-reference", `Variable '${name}' references itself
|
|
513
|
+
out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
|
|
474
514
|
}
|
|
475
515
|
}
|
|
476
516
|
for (const name of defs.keys()) {
|
|
477
|
-
detectCycle(name, defs, [name], out);
|
|
517
|
+
detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
|
|
478
518
|
}
|
|
479
|
-
const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, "");
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
520
|
+
const seen = /* @__PURE__ */ new Set();
|
|
521
|
+
const undefinedAt = (name, offset, length) => {
|
|
522
|
+
const key = name.toLowerCase();
|
|
523
|
+
if (defs.has(key) || knownSet.has(key) || seen.has(key)) return;
|
|
524
|
+
seen.add(key);
|
|
525
|
+
out.push(warn(
|
|
526
|
+
"variable.undefined",
|
|
527
|
+
`Variable '${name}' is not defined \u2014 may be a runtime variable.`,
|
|
528
|
+
{ ...span(text, offset, length), data: { name } }
|
|
529
|
+
));
|
|
530
|
+
};
|
|
531
|
+
for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
|
|
532
|
+
for (const m of body.matchAll(/\{\?!?([A-Za-z_]\w*)\?/gu)) {
|
|
533
|
+
undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
|
|
487
534
|
}
|
|
488
535
|
}
|
|
489
|
-
function detectCycle(current, defs, visited, out) {
|
|
536
|
+
function detectCycle(current, defs, visited, rootPos, out) {
|
|
490
537
|
const value = defs.get(current) ?? "";
|
|
491
538
|
for (const m of value.matchAll(/%(\w+)%/gu)) {
|
|
492
539
|
const ref = (m[1] ?? "").toLowerCase();
|
|
493
540
|
if (ref === current) continue;
|
|
494
541
|
if (visited.includes(ref)) {
|
|
495
|
-
out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}
|
|
542
|
+
out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
|
|
496
543
|
return;
|
|
497
544
|
}
|
|
498
|
-
if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], out);
|
|
545
|
+
if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
|
|
499
546
|
}
|
|
500
547
|
}
|
|
501
548
|
function checkIncludeTargets(text, known, out) {
|
|
@@ -504,19 +551,15 @@ function checkIncludeTargets(text, known, out) {
|
|
|
504
551
|
for (const m of text.matchAll(INCLUDE_RE)) {
|
|
505
552
|
const ref = m[1] ?? "";
|
|
506
553
|
if (!set.has(ref)) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
554
|
+
const refOffset = (m.index ?? 0) + m[0].indexOf('"') + 1;
|
|
555
|
+
out.push(err(
|
|
556
|
+
"include.unknown-target",
|
|
557
|
+
`#include target '${ref}' does not match any known template.`,
|
|
558
|
+
{ ...span(text, refOffset, ref.length), data: { target: ref } }
|
|
559
|
+
));
|
|
510
560
|
}
|
|
511
561
|
}
|
|
512
562
|
}
|
|
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
563
|
|
|
521
564
|
// src/internal/extract.ts
|
|
522
565
|
var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
|
|
@@ -863,6 +906,7 @@ var WS = " \\t\\r\\n\\f\\x0B";
|
|
|
863
906
|
var S = `[${WS}]`;
|
|
864
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})";
|
|
865
908
|
var URL_RE = new RegExp(`(?:https?|ftp):\\/\\/[^${WS}<>"')\\]]+`, "giu");
|
|
909
|
+
var MAILTEL_RE = new RegExp(`(?:mailto|tel):[^${WS}<>"')\\]]+`, "giu");
|
|
866
910
|
var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
|
|
867
911
|
var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
|
|
868
912
|
var DECIMAL_RE = /\b\d+\.\d+\b/gu;
|
|
@@ -897,6 +941,7 @@ function postProcess(input) {
|
|
|
897
941
|
};
|
|
898
942
|
let text = input;
|
|
899
943
|
text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
|
|
944
|
+
text = text.replace(MAILTEL_RE, (m) => storeWithTrailingPunct(m, "URI"));
|
|
900
945
|
text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
|
|
901
946
|
text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
|
|
902
947
|
text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
|