@weipertda/sigiljs 0.0.1 → 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/README.md +157 -155
- package/docs/README.md +20 -0
- package/docs/arrays.md +55 -0
- package/docs/cli.md +48 -0
- package/docs/compiled-validators.md +42 -0
- package/docs/contributing.md +43 -0
- package/docs/exact-mode.md +82 -0
- package/docs/examples.md +92 -0
- package/docs/introduction.md +29 -0
- package/docs/license.md +23 -0
- package/docs/named-sigils.md +93 -0
- package/docs/objects.md +44 -0
- package/docs/optional.md +48 -0
- package/docs/quickstart.md +65 -0
- package/docs/realtype.md +36 -0
- package/docs/roadmap.md +40 -0
- package/docs/sigils.md +94 -0
- package/docs/{functions.md,maps.md,sets.md,tuples.md,intersection.md,rest.md,examples.md,performance.md,contributing.md,license.md +0 -0
- package/examples/api-response.js +21 -0
- package/examples/api-response.md +27 -0
- package/examples/api-validation.js +0 -0
- package/examples/api-validation.md +0 -0
- package/examples/basic-user.js +16 -0
- package/examples/basic-user.md +22 -0
- package/examples/basic-validation.js +0 -0
- package/examples/basic-validation.md +0 -0
- package/examples/config-file.md +16 -0
- package/examples/config-validation.js +18 -0
- package/examples/config-validation.md +24 -0
- package/examples/login-request.js +19 -0
- package/examples/login-request.md +25 -0
- package/examples/nested-order.js +28 -0
- package/examples/nested-order.md +34 -0
- package/examples/realtype-demo.js +6 -0
- package/examples/realtype-demo.md +12 -0
- package/examples/scripts/config-file.js +0 -0
- package/package.json +18 -10
- package/src/core/assert.js +173 -50
- package/src/core/compile.js +43 -9
- package/src/core/errors.js +29 -6
- package/src/core/normalize.js +1 -1
- package/src/core/parser.js +4 -3
- package/src/core/partial.js +1 -0
- package/src/core/registry.js +33 -0
- package/src/core/validate.js +3 -0
- package/src/index.js +1 -0
- package/src/playground/playground.js +1 -1
- package/src/sigil.js +56 -16
- package/CHANGELOG.md +0 -28
package/src/core/compile.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { realType } from '../core/realType.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
canonicalize,
|
|
4
|
+
validatorCache,
|
|
5
|
+
} from './cache.js';
|
|
6
|
+
import { resolve } from './registry.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Compiles a normalized+partially-evaluated SigilJS AST into a fast validator function.
|
|
@@ -25,9 +29,10 @@ export function compile(ast) {
|
|
|
25
29
|
/**
|
|
26
30
|
* Recursively builds a validator closure from an AST node.
|
|
27
31
|
* @param {object} ast
|
|
32
|
+
* @param {Set<string>} [visited] - Track visited identifiers for recursion safety
|
|
28
33
|
* @returns {Function}
|
|
29
34
|
*/
|
|
30
|
-
function build(ast) {
|
|
35
|
+
function build(ast, visited = new Set()) {
|
|
31
36
|
switch (ast.kind) {
|
|
32
37
|
case 'primitive': {
|
|
33
38
|
const p = ast.name;
|
|
@@ -80,7 +85,7 @@ function build(ast) {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
case 'union': {
|
|
83
|
-
const fns = ast.members.map(build);
|
|
88
|
+
const fns = ast.members.map((m) => build(m, visited));
|
|
84
89
|
const len = fns.length;
|
|
85
90
|
return (v, opts) => {
|
|
86
91
|
for (let i = 0; i < len; i++) {
|
|
@@ -91,7 +96,7 @@ function build(ast) {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
case 'array': {
|
|
94
|
-
const el = build(ast.element);
|
|
99
|
+
const el = build(ast.element, visited);
|
|
95
100
|
return (v, opts) => {
|
|
96
101
|
if (!Array.isArray(v)) return false;
|
|
97
102
|
for (let i = 0; i < v.length; i++) {
|
|
@@ -102,7 +107,7 @@ function build(ast) {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
case 'optional': {
|
|
105
|
-
const inner = build(ast.inner);
|
|
110
|
+
const inner = build(ast.inner, visited);
|
|
106
111
|
return (v, opts) => v === undefined || inner(v, opts);
|
|
107
112
|
}
|
|
108
113
|
|
|
@@ -114,27 +119,35 @@ function build(ast) {
|
|
|
114
119
|
hints ?
|
|
115
120
|
hints.requiredKeys.map((k) => ({
|
|
116
121
|
key: k,
|
|
117
|
-
check: build(properties.find((p) => p.key === k).value),
|
|
122
|
+
check: build(properties.find((p) => p.key === k).value, visited),
|
|
118
123
|
}))
|
|
119
124
|
: properties
|
|
120
125
|
.filter((p) => !p.optional)
|
|
121
|
-
.map((p) => ({ key: p.key, check: build(p.value) }));
|
|
126
|
+
.map((p) => ({ key: p.key, check: build(p.value, visited) }));
|
|
122
127
|
const opt =
|
|
123
128
|
hints ?
|
|
124
129
|
hints.optionalKeys.map((k) => ({
|
|
125
130
|
key: k,
|
|
126
|
-
check: build(properties.find((p) => p.key === k).value),
|
|
131
|
+
check: build(properties.find((p) => p.key === k).value, visited),
|
|
127
132
|
}))
|
|
128
133
|
: properties
|
|
129
134
|
.filter((p) => p.optional)
|
|
130
|
-
.map((p) => ({ key: p.key, check: build(p.value) }));
|
|
135
|
+
.map((p) => ({ key: p.key, check: build(p.value, visited) }));
|
|
131
136
|
|
|
132
137
|
const reqLen = req.length;
|
|
133
138
|
const optLen = opt.length;
|
|
139
|
+
const allowedKeys = ast.exact ? new Set(properties.map((p) => p.key)) : null;
|
|
134
140
|
|
|
135
141
|
return (v, opts) => {
|
|
136
142
|
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
137
143
|
return false;
|
|
144
|
+
|
|
145
|
+
if (ast.exact) {
|
|
146
|
+
for (const key in v) {
|
|
147
|
+
if (!allowedKeys.has(key)) return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
for (let i = 0; i < reqLen; i++) {
|
|
139
152
|
const p = req[i];
|
|
140
153
|
if (!(p.key in v) || !p.check(v[p.key], opts)) return false;
|
|
@@ -148,6 +161,27 @@ function build(ast) {
|
|
|
148
161
|
};
|
|
149
162
|
}
|
|
150
163
|
|
|
164
|
+
case 'identifier': {
|
|
165
|
+
const name = ast.name;
|
|
166
|
+
const sigil = resolve(name);
|
|
167
|
+
|
|
168
|
+
// If the sigil isn't registered yet, or if we are currently visiting it (circularity),
|
|
169
|
+
// we return a lazy wrapper that resolves the sigil at validation time.
|
|
170
|
+
if (!sigil || visited.has(name)) {
|
|
171
|
+
return (val, o) => {
|
|
172
|
+
const resolved = resolve(name);
|
|
173
|
+
if (!resolved) throw new Error(`Unknown sigil reference: ${name}`);
|
|
174
|
+
return resolved.check(val, o);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
visited.add(name);
|
|
179
|
+
const target = sigil.normalized || sigil.ast;
|
|
180
|
+
const check = build(target, visited);
|
|
181
|
+
visited.delete(name);
|
|
182
|
+
return check;
|
|
183
|
+
}
|
|
184
|
+
|
|
151
185
|
default:
|
|
152
186
|
return () => false;
|
|
153
187
|
}
|
package/src/core/errors.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thrown by `assert()` when a value fails validation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Shape:
|
|
5
|
+
* {
|
|
6
|
+
* code: "SIGIL_VALIDATION_FAILED", // always — machine-readable
|
|
7
|
+
* message: "Expected property \"age\" to be number, got string",
|
|
8
|
+
* path: ["user", "age"], // key path to the failing field
|
|
9
|
+
* expected: "number", // type that was required
|
|
10
|
+
* actual: "string" // type that was received
|
|
11
|
+
* }
|
|
10
12
|
*/
|
|
11
13
|
export class SigilValidationError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} message - Human-readable description of the failure
|
|
16
|
+
* @param {string[]} path - Property path to the failure (e.g. ["user", "age"])
|
|
17
|
+
* @param {string} expected - Type that was expected
|
|
18
|
+
* @param {string} actual - Type that was actually received
|
|
19
|
+
*/
|
|
12
20
|
constructor(message, path, expected, actual) {
|
|
13
21
|
super(message);
|
|
14
22
|
this.name = 'SigilValidationError';
|
|
@@ -23,6 +31,21 @@ export class SigilValidationError extends Error {
|
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Returns the canonical JSON shape for structured logging / API responses.
|
|
36
|
+
*
|
|
37
|
+
* @returns {{ code: string, message: string, path: string[], expected: string, actual: string }}
|
|
38
|
+
*/
|
|
39
|
+
toJSON() {
|
|
40
|
+
return {
|
|
41
|
+
code: this.code,
|
|
42
|
+
message: this.message,
|
|
43
|
+
path: this.path,
|
|
44
|
+
expected: this.expected,
|
|
45
|
+
actual: this.actual,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Consistent devtools / console.log labeling */
|
|
27
50
|
get [Symbol.toStringTag]() {
|
|
28
51
|
return 'SigilValidationError';
|
package/src/core/normalize.js
CHANGED
package/src/core/parser.js
CHANGED
|
@@ -29,13 +29,14 @@ const PRIMITIVES = new Set([
|
|
|
29
29
|
* { kind: 'union', members }
|
|
30
30
|
* { kind: 'array', element }
|
|
31
31
|
* { kind: 'optional', inner }
|
|
32
|
-
* { kind: 'object', properties: [{ key, optional, value }] }
|
|
32
|
+
* { kind: 'object', properties: [{ key, optional, value }], exact }
|
|
33
33
|
* { kind: 'identifier', name }
|
|
34
34
|
*
|
|
35
35
|
* @param {string|Array} input
|
|
36
|
+
* @param {object} [options]
|
|
36
37
|
* @returns {object} AST node
|
|
37
38
|
*/
|
|
38
|
-
export function parse(input) {
|
|
39
|
+
export function parse(input, options = {}) {
|
|
39
40
|
const tokens = typeof input === 'string' ? tokenize(input) : input;
|
|
40
41
|
let pos = 0;
|
|
41
42
|
|
|
@@ -212,7 +213,7 @@ export function parse(input) {
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
consume('}', 'Expected closing block brace');
|
|
215
|
-
return { kind: 'object', properties };
|
|
216
|
+
return { kind: 'object', properties, exact: !!options.exact };
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
return parseSigil();
|
package/src/core/partial.js
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global registry for named sigils.
|
|
3
|
+
* Allows sigils to be referenced by name in other sigils.
|
|
4
|
+
*/
|
|
5
|
+
const registry = new Map();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers a sigil by name.
|
|
9
|
+
* @param {string} name
|
|
10
|
+
* @param {object} sigil
|
|
11
|
+
*/
|
|
12
|
+
export function register(name, sigil) {
|
|
13
|
+
if (typeof name !== 'string') {
|
|
14
|
+
throw new Error('Sigil name must be a string');
|
|
15
|
+
}
|
|
16
|
+
registry.set(name, sigil);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves a sigil by name.
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @returns {object|undefined}
|
|
23
|
+
*/
|
|
24
|
+
export function resolve(name) {
|
|
25
|
+
return registry.get(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Clears the registry (mainly for testing).
|
|
30
|
+
*/
|
|
31
|
+
export function clear() {
|
|
32
|
+
registry.clear();
|
|
33
|
+
}
|
package/src/core/validate.js
CHANGED
|
@@ -2,6 +2,9 @@ import { compile } from './compile.js';
|
|
|
2
2
|
|
|
3
3
|
export function validate(astOrSigil, value, opts) {
|
|
4
4
|
// Can be called with a raw AST, or a Sigil object possessing `.ast` / `.normalized`
|
|
5
|
+
if (astOrSigil && typeof astOrSigil.validator === 'function') {
|
|
6
|
+
return astOrSigil.validator(value, opts);
|
|
7
|
+
}
|
|
5
8
|
const targetAst = astOrSigil.normalized || astOrSigil.ast || astOrSigil;
|
|
6
9
|
const check = compile(targetAst);
|
|
7
10
|
return check(value, opts);
|
package/src/index.js
CHANGED
package/src/sigil.js
CHANGED
|
@@ -3,6 +3,7 @@ import { compile } from './core/compile.js';
|
|
|
3
3
|
import { normalize } from './core/normalize.js';
|
|
4
4
|
import { parse } from './core/parser.js';
|
|
5
5
|
import { partial } from './core/partial.js';
|
|
6
|
+
import { register } from './core/registry.js';
|
|
6
7
|
import { validate } from './core/validate.js';
|
|
7
8
|
|
|
8
9
|
// Memoize fully-constructed Sigil objects by raw schema string.
|
|
@@ -16,40 +17,79 @@ const _sigilCache = new Map();
|
|
|
16
17
|
* Each unique schema string is parsed, normalized, and compiled exactly once.
|
|
17
18
|
* Subsequent calls with the same schema string return the cached sigil object.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* User.check({ name: 'Alice' }) // true
|
|
26
|
-
* User.assert({ name: 42 }) // throws SigilValidationError
|
|
20
|
+
* The returned sigil exposes:
|
|
21
|
+
* .check(value) → boolean
|
|
22
|
+
* .assert(value) → void | throws
|
|
23
|
+
* .validator → compiled validator function (same stable reference always)
|
|
24
|
+
* .compile() → same as .validator (method form, for back-compat)
|
|
25
|
+
* .raw / .ast / .normalized / .options
|
|
27
26
|
*/
|
|
28
|
-
|
|
27
|
+
function createSigil(options, strings, ...values) {
|
|
29
28
|
// Reconstruct raw string from template parts (supports interpolations)
|
|
30
29
|
let raw = strings[0];
|
|
31
30
|
for (let i = 0; i < values.length; i++) raw += values[i] + strings[i + 1];
|
|
32
31
|
|
|
33
|
-
//
|
|
34
|
-
const
|
|
32
|
+
// Cache key includes options to distinguish exact vs non-exact versions of same schema
|
|
33
|
+
const cacheKey = JSON.stringify(options) + raw;
|
|
34
|
+
const cached = _sigilCache.get(cacheKey);
|
|
35
35
|
if (cached !== undefined) return cached;
|
|
36
36
|
|
|
37
37
|
// Parse → Normalize → Partial-evaluate → Compile pipeline
|
|
38
|
-
const ast = parse(raw);
|
|
38
|
+
const ast = parse(raw, options);
|
|
39
39
|
const normalized = partial(normalize(ast));
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
41
|
+
// Pre-compile eagerly and capture the function reference.
|
|
42
|
+
// This warms the cache on creation and ensures sigil.validator always
|
|
43
|
+
// returns the exact same cached function — no re-compilation ever.
|
|
44
|
+
const validator = compile(normalized);
|
|
43
45
|
|
|
44
46
|
const sigil = Object.freeze({
|
|
45
47
|
raw,
|
|
46
48
|
ast,
|
|
47
49
|
normalized,
|
|
50
|
+
options,
|
|
51
|
+
validator,
|
|
48
52
|
check: (value, opts) => validate(sigil, value, opts),
|
|
49
53
|
assert: (value, opts) => assert(sigil, value, opts),
|
|
50
|
-
compile: () =>
|
|
54
|
+
compile: () => validator,
|
|
51
55
|
});
|
|
52
56
|
|
|
53
|
-
_sigilCache.set(
|
|
57
|
+
_sigilCache.set(cacheKey, sigil);
|
|
54
58
|
return sigil;
|
|
55
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default Sigil tagged template (non-strict objects).
|
|
63
|
+
*/
|
|
64
|
+
export function Sigil(strings, ...values) {
|
|
65
|
+
return createSigil({ exact: false }, strings, ...values);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strict Sigil tagged template (exact objects, fails on extra properties).
|
|
70
|
+
*/
|
|
71
|
+
Sigil.exact = function (strings, ...values) {
|
|
72
|
+
return createSigil({ exact: true }, strings, ...values);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates a named Sigil and registers it in the global registry.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @returns {Function} Tagged template function
|
|
80
|
+
*/
|
|
81
|
+
Sigil.named = function (name) {
|
|
82
|
+
return function (strings, ...values) {
|
|
83
|
+
const sigil = createSigil({ exact: false, named: name }, strings, ...values);
|
|
84
|
+
register(name, sigil);
|
|
85
|
+
return sigil;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Alias for Sigil.named — preferred ergonomic name for defining reusable sigils.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} name
|
|
93
|
+
* @returns {Function} Tagged template function
|
|
94
|
+
*/
|
|
95
|
+
Sigil.define = Sigil.named;
|
package/CHANGELOG.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## [0.0.1] — 2026-03-10
|
|
11
|
-
|
|
12
|
-
### Added
|
|
13
|
-
- **`realType(value, opts?)`** — cross-realm-safe runtime type detection fixing all `typeof` gaps (`null`, `array`, `nan`, `map`, `set`, `date`, `regexp`, `promise`, `asyncfunction`, `generatorfunction`, `asyncgeneratorfunction`, `weakmap`, `weakset`); extensible via hook array.
|
|
14
|
-
- **`Sigil` / `T` / `S` tagged template** — compile a schema string into a fast, memoized validator object exposing `.check()` and `.assert()`.
|
|
15
|
-
- **Schema language** — primitives, literals, unions (`|`), arrays (`[]`), optionals (`?`), and nested object schemas.
|
|
16
|
-
- **`SigilValidationError`** — structured error with `code`, `path`, `expected`, and `actual` properties.
|
|
17
|
-
- **Two-level memoization** — Sigil-level cache (identical schema strings share one object reference) and compiled-validator cache (structural AST identity).
|
|
18
|
-
- **Partial evaluation** — literal unions optimize to `Set` membership checks; pure primitive unions skip `realType` entirely; object hints pre-compute required/optional key sets.
|
|
19
|
-
- **CLI playground** — `bun run src/playground.js '<json>' '<schema>'` for shell-level validation.
|
|
20
|
-
|
|
21
|
-
### Engineering
|
|
22
|
-
- Single-source-of-truth `realType` implementation in `src/core/realType.js`.
|
|
23
|
-
- Module-scope frozen `Set` for O(1) tokenizer punctuation lookup.
|
|
24
|
-
- Array-join accumulation in tokenizer hot string loops (no `+=` reallocation).
|
|
25
|
-
- Module-scope frozen `Set` for parser primitive name lookup (not rebuilt per call).
|
|
26
|
-
- Sigil objects are `Object.freeze()`-d — immutable public API surface.
|
|
27
|
-
- `Error.captureStackTrace` on `SigilValidationError` for clean V8/Bun stack traces.
|
|
28
|
-
- Full `package.json` with `exports`, `files`, `sideEffects: false`, `engines`, `keywords`.
|