@ata-project/unplugin 0.1.1 → 0.3.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 +59 -2
- package/package.json +4 -2
- package/src/compile-away.js +284 -0
- package/src/core.js +17 -1
- package/src/index.d.ts +9 -0
- package/src/index.js +26 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Compiles JSON Schema files into self-contained [ata-validator](https://ata-validator.com) modules at build time, with TypeScript declarations, in Vite, Webpack, Rollup, Rolldown, esbuild and Rspack. One plugin, built on [unplugin](https://github.com/unjs/unplugin).
|
|
4
4
|
|
|
5
|
-
The generated module imports nothing. A small schema compiles to about 1.
|
|
5
|
+
The generated module imports nothing. A small schema compiles to about 1.3 KB gzipped and a ten-field one to about 2.6 KB, full error detail included, on ata-validator 1.36.0; the module exports `validate`, `isValid` and the inferred type, and runs anywhere plain JavaScript runs.
|
|
6
6
|
|
|
7
7
|
Schemas can be authored as `.json`, `.js` or `.ts`.
|
|
8
8
|
|
|
@@ -145,6 +145,60 @@ Other sources (`.json` without the `.schema` suffix, `.js`, `.ts`) produce
|
|
|
145
145
|
| `nameFromFile` | file name in PascalCase | Type name for schemas without `title` or `$id`. |
|
|
146
146
|
| `root` | from the bundler | Where the globs resolve from. Vite's root, Webpack's and Rspack's `context` and esbuild's `absWorkingDir` are read; Rollup and Rolldown need it passed. |
|
|
147
147
|
| `alias` | Vite's `resolve.alias` | Import aliases inside `.ts` schema files. tsconfig `paths` work without configuration. |
|
|
148
|
+
| `compileAway` | `false` | Replace `new Validator(schema)` in your code with a validator compiled at build time, where that gives the same results. See below. Needs ata-validator 1.36.0. |
|
|
149
|
+
|
|
150
|
+
## compileAway: keep `new Validator`, drop the compiler
|
|
151
|
+
|
|
152
|
+
With `compileAway: true`, code written against the runtime API is compiled at
|
|
153
|
+
build time without being changed:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { Validator } from 'ata-validator'
|
|
157
|
+
|
|
158
|
+
const check = new Validator({
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: { id: { type: 'integer', minimum: 1 }, name: { type: 'string', minLength: 1 } },
|
|
161
|
+
required: ['id', 'name'],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
export const handle = (body) => check.validate(body)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The plugin puts a compiled validator in place of the `new Validator(...)` call,
|
|
168
|
+
and once nothing else in the file uses the `ata-validator` import, the import
|
|
169
|
+
goes with it, so the runtime compiler is not in the bundle. For the three-schema
|
|
170
|
+
entry in `test/fixtures/compile-away`, one of them with defaults, a minified Vite
|
|
171
|
+
library build is 115.7 KB gzipped without it and 15.5 KB with it, on
|
|
172
|
+
ata-validator 1.36.0. Across the 977 schemas of SchemaStore, 725 can be compiled
|
|
173
|
+
away this way.
|
|
174
|
+
|
|
175
|
+
The replacement answers `validate()`, `isValidObject()`, `validateJSON()` and
|
|
176
|
+
`isValidJSON()` as a `Validator` with default options does: the same verdicts,
|
|
177
|
+
defaults filled in, `data` on success, and the same errors, enriched the same
|
|
178
|
+
way, since they come from the same code in ata-validator. ata-validator's own
|
|
179
|
+
tests hold that over every case of the official JSON Schema test suite and over
|
|
180
|
+
seeded schemas with defaults at every depth.
|
|
181
|
+
|
|
182
|
+
A call is replaced only when all of this is true, and left to the runtime
|
|
183
|
+
otherwise:
|
|
184
|
+
|
|
185
|
+
- `Validator` is a named import from `ata-validator`;
|
|
186
|
+
- the schema argument is an object literal, a top-level `const` bound to one,
|
|
187
|
+
the default import of a relative `.json` file, or `defineSchema(...)` around
|
|
188
|
+
one of those;
|
|
189
|
+
- there is no second argument, or it is `{ useDefaults: false }` (or
|
|
190
|
+
`{ useDefaults: true }`), written as a literal or a top-level `const`; that
|
|
191
|
+
one option needs ata-validator 1.37.0, and older versions keep such calls on
|
|
192
|
+
the runtime;
|
|
193
|
+
- the result goes into a `const` that is not exported and is only used as
|
|
194
|
+
`name.validate(...)`, `name.isValidObject(...)`, `name.validateJSON(...)` or
|
|
195
|
+
`name.isValidJSON(...)`;
|
|
196
|
+
- ata-validator can compile the schema to the same results. It declines custom
|
|
197
|
+
`errorMessage`s and shapes its code generator cannot express, which the
|
|
198
|
+
runtime answers with its interpreted engine.
|
|
199
|
+
|
|
200
|
+
Any other option (`new Validator(schema, { coerceTypes: true })`), a schema
|
|
201
|
+
built at run time, or an instance passed around stays as written.
|
|
148
202
|
|
|
149
203
|
## How it works
|
|
150
204
|
|
|
@@ -174,7 +228,10 @@ const { files, results } = await compile({ schemas: 'schemas/**/*.json', root: p
|
|
|
174
228
|
## Tests
|
|
175
229
|
|
|
176
230
|
`npm test` compiles the same entry with Vite, Webpack, Rollup, Rolldown,
|
|
177
|
-
esbuild and Rspack, imports each bundle and runs the validator it contains.
|
|
231
|
+
esbuild and Rspack, imports each bundle and runs the validator it contains. The
|
|
232
|
+
compileAway tests build a second entry with all six, check that neither the
|
|
233
|
+
runtime compiler nor the interpreter is in the output, and compare every answer
|
|
234
|
+
with `new Validator`; the decline rules are tested on their own.
|
|
178
235
|
|
|
179
236
|
## Package name
|
|
180
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ata-project/unplugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Compile JSON, JS and TS schema files to ata-validator standalone modules at build time, in Vite, Webpack, Rollup, Rolldown, esbuild and Rspack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -71,12 +71,14 @@
|
|
|
71
71
|
"ata-validator": ">=1.0.0"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
+
"@babel/parser": "^7.29.9",
|
|
74
75
|
"jiti": "^2.7.0",
|
|
76
|
+
"magic-string": "^1.4.2",
|
|
75
77
|
"unplugin": "^3.3.0"
|
|
76
78
|
},
|
|
77
79
|
"devDependencies": {
|
|
78
80
|
"@rspack/core": "^1.7.0",
|
|
79
|
-
"ata-validator": "^1.
|
|
81
|
+
"ata-validator": "^1.37.0",
|
|
80
82
|
"esbuild": "^0.28.0",
|
|
81
83
|
"rolldown": "^1.2.8",
|
|
82
84
|
"rollup": "^4.60.0",
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Compile-away: `new Validator(schema)` from ata-validator, with a schema the
|
|
2
|
+
// build can read, becomes a validator compiled at build time, so the runtime
|
|
3
|
+
// compiler stays out of the bundle. The source is left as the author wrote it;
|
|
4
|
+
// only the emitted module changes.
|
|
5
|
+
//
|
|
6
|
+
// A call is replaced only when every one of these holds, and left alone
|
|
7
|
+
// otherwise, so turning this on can cost a saving but not change behaviour:
|
|
8
|
+
// - `Validator` is a named import from 'ata-validator', declared once;
|
|
9
|
+
// - the call has exactly one argument, and that argument is a value the
|
|
10
|
+
// build can read without running code: an object literal, a top-level
|
|
11
|
+
// `const` bound to one, the default import of a relative `.json` file, or
|
|
12
|
+
// `defineSchema(...)` around any of those;
|
|
13
|
+
// - the result is assigned to a `const` declared once in the file, not
|
|
14
|
+
// exported, and every other use of that name calls validate(),
|
|
15
|
+
// isValidObject(), validateJSON() or isValidJSON() on it;
|
|
16
|
+
// - ata-validator's compiledModuleFor() returns a module for the schema. It
|
|
17
|
+
// declines schemas the replacement would not answer exactly as the runtime
|
|
18
|
+
// does (custom error messages, shapes its error generator cannot express),
|
|
19
|
+
// and ata's own test suite holds the rest to the runtime's results. The
|
|
20
|
+
// wrapper gets the schema as the runtime reads it, compiledSchemaFor(), so
|
|
21
|
+
// it fills defaults and orders errors the same way.
|
|
22
|
+
// The compiled module is inlined as a function scope next to the imports; it
|
|
23
|
+
// imports nothing, so no virtual module or extra file is involved.
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { parse } from '@babel/parser'
|
|
28
|
+
import MagicString from 'magic-string'
|
|
29
|
+
|
|
30
|
+
const METHODS = new Set(['validate', 'isValidObject', 'validateJSON', 'isValidJSON'])
|
|
31
|
+
const NOT_STATIC = Symbol('not static')
|
|
32
|
+
|
|
33
|
+
// Keys that hold child nodes. Types are left out on purpose: they are erased
|
|
34
|
+
// before anything runs, so a name used in a type annotation says nothing about
|
|
35
|
+
// how the value is used.
|
|
36
|
+
function children(node) {
|
|
37
|
+
const out = []
|
|
38
|
+
for (const key in node) {
|
|
39
|
+
if (key === '__parent' || key === 'loc' || key === 'start' || key === 'end' || key === 'extra' || key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments') continue
|
|
40
|
+
if (key === 'typeAnnotation' || key === 'returnType' || key === 'typeParameters' || key === 'superTypeParameters' || key === 'implements') continue
|
|
41
|
+
const v = node[key]
|
|
42
|
+
if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') out.push([c, key]) }
|
|
43
|
+
else if (v && typeof v.type === 'string') out.push([v, key])
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isTypeOnly(node) {
|
|
49
|
+
return node.type.startsWith('TS') && !['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion', 'TSParameterProperty'].includes(node.type)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Every binding a pattern introduces.
|
|
53
|
+
function bindingNames(pattern, out) {
|
|
54
|
+
if (!pattern) return out
|
|
55
|
+
switch (pattern.type) {
|
|
56
|
+
case 'Identifier': out.push(pattern.name); break
|
|
57
|
+
case 'ObjectPattern': for (const p of pattern.properties) bindingNames(p.type === 'RestElement' ? p.argument : p.value, out); break
|
|
58
|
+
case 'ArrayPattern': for (const e of pattern.elements) bindingNames(e, out); break
|
|
59
|
+
case 'AssignmentPattern': bindingNames(pattern.left, out); break
|
|
60
|
+
case 'RestElement': bindingNames(pattern.argument, out); break
|
|
61
|
+
case 'TSParameterProperty': bindingNames(pattern.parameter, out); break
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One walk: how many times each name is declared anywhere in the file, and
|
|
67
|
+
// every identifier in a value position together with its parent.
|
|
68
|
+
function scan(ast) {
|
|
69
|
+
const declared = new Map()
|
|
70
|
+
const refs = []
|
|
71
|
+
const declare = (name) => declared.set(name, (declared.get(name) || 0) + 1)
|
|
72
|
+
const visit = (node, parent, key) => {
|
|
73
|
+
if (isTypeOnly(node)) return
|
|
74
|
+
switch (node.type) {
|
|
75
|
+
case 'VariableDeclarator': for (const n of bindingNames(node.id, [])) declare(n); break
|
|
76
|
+
case 'FunctionDeclaration': case 'FunctionExpression': case 'ArrowFunctionExpression': case 'ObjectMethod': case 'ClassMethod': case 'ClassPrivateMethod':
|
|
77
|
+
if (node.id && node.type !== 'FunctionExpression') declare(node.id.name)
|
|
78
|
+
if (node.type === 'FunctionExpression' && node.id) declare(node.id.name)
|
|
79
|
+
for (const p of node.params) for (const n of bindingNames(p, [])) declare(n)
|
|
80
|
+
break
|
|
81
|
+
case 'ClassDeclaration': case 'ClassExpression': if (node.id) declare(node.id.name); break
|
|
82
|
+
case 'ImportSpecifier': case 'ImportDefaultSpecifier': case 'ImportNamespaceSpecifier': declare(node.local.name); break
|
|
83
|
+
case 'CatchClause': for (const n of bindingNames(node.param, [])) declare(n); break
|
|
84
|
+
case 'Identifier': {
|
|
85
|
+
const binding = parent && ((parent.type === 'VariableDeclarator' && key === 'id') || (key === 'params') || ((parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier')) || ((parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression' || parent.type === 'ClassDeclaration' || parent.type === 'ClassExpression') && key === 'id'))
|
|
86
|
+
const propertyName = parent && (((parent.type === 'MemberExpression' || parent.type === 'OptionalMemberExpression') && key === 'property' && !parent.computed) || ((parent.type === 'ObjectProperty' || parent.type === 'ObjectMethod' || parent.type === 'ClassProperty' || parent.type === 'ClassMethod') && key === 'key' && !parent.computed && !parent.shorthand) || parent.type === 'LabeledStatement' || parent.type === 'BreakStatement' || parent.type === 'ContinueStatement' || (parent.type === 'ImportSpecifier' && key === 'imported') || (parent.type === 'ExportSpecifier' && key === 'exported'))
|
|
87
|
+
if (!binding && !propertyName) refs.push({ node, parent, key })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [child, k] of children(node)) { child.__parent = node; visit(child, node, k) }
|
|
92
|
+
}
|
|
93
|
+
visit(ast.program, null, null)
|
|
94
|
+
return { declared, refs }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The value an expression stands for, when reading it needs no code to run.
|
|
98
|
+
function staticValue(node, ctx, seen = new Set()) {
|
|
99
|
+
switch (node.type) {
|
|
100
|
+
case 'TSAsExpression': case 'TSSatisfiesExpression': case 'TSNonNullExpression': case 'TSTypeAssertion': case 'ParenthesizedExpression':
|
|
101
|
+
return staticValue(node.expression, ctx, seen)
|
|
102
|
+
case 'StringLiteral': case 'NumericLiteral': case 'BooleanLiteral': return node.value
|
|
103
|
+
case 'NullLiteral': return null
|
|
104
|
+
case 'TemplateLiteral': return node.expressions.length === 0 ? node.quasis[0].value.cooked : NOT_STATIC
|
|
105
|
+
case 'UnaryExpression': return node.operator === '-' && node.argument.type === 'NumericLiteral' ? -node.argument.value : NOT_STATIC
|
|
106
|
+
case 'ArrayExpression': {
|
|
107
|
+
const out = []
|
|
108
|
+
for (const el of node.elements) {
|
|
109
|
+
if (!el || el.type === 'SpreadElement') return NOT_STATIC
|
|
110
|
+
const v = staticValue(el, ctx, seen)
|
|
111
|
+
if (v === NOT_STATIC) return NOT_STATIC
|
|
112
|
+
out.push(v)
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
case 'ObjectExpression': {
|
|
117
|
+
const out = {}
|
|
118
|
+
for (const p of node.properties) {
|
|
119
|
+
if (p.type !== 'ObjectProperty' || p.computed) return NOT_STATIC
|
|
120
|
+
const k = p.key.type === 'Identifier' ? p.key.name : (p.key.type === 'StringLiteral' || p.key.type === 'NumericLiteral') ? String(p.key.value) : null
|
|
121
|
+
if (k === null || k === '__proto__') return NOT_STATIC
|
|
122
|
+
const v = staticValue(p.value, ctx, seen)
|
|
123
|
+
if (v === NOT_STATIC) return NOT_STATIC
|
|
124
|
+
out[k] = v
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
case 'Identifier': {
|
|
129
|
+
if (seen.has(node.name) || ctx.declared.get(node.name) !== 1) return NOT_STATIC
|
|
130
|
+
const source = ctx.constants.get(node.name)
|
|
131
|
+
if (!source) return NOT_STATIC
|
|
132
|
+
seen.add(node.name)
|
|
133
|
+
const v = source.json !== undefined ? source.json() : staticValue(source.init, ctx, seen)
|
|
134
|
+
seen.delete(node.name)
|
|
135
|
+
return v
|
|
136
|
+
}
|
|
137
|
+
case 'CallExpression':
|
|
138
|
+
if (node.callee.type === 'Identifier' && node.callee.name === ctx.defineSchema && node.arguments.length === 1) return staticValue(node.arguments[0], ctx, seen)
|
|
139
|
+
return NOT_STATIC
|
|
140
|
+
}
|
|
141
|
+
return NOT_STATIC
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Top level `const NAME = <expr>` and `import NAME from './x.json'`.
|
|
145
|
+
function constantsOf(ast, id) {
|
|
146
|
+
const constants = new Map()
|
|
147
|
+
for (const stmt of ast.program.body) {
|
|
148
|
+
const decl = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt
|
|
149
|
+
if (decl && decl.type === 'VariableDeclaration' && decl.kind === 'const') {
|
|
150
|
+
for (const d of decl.declarations) if (d.id.type === 'Identifier' && d.init) constants.set(d.id.name, { init: d.init })
|
|
151
|
+
}
|
|
152
|
+
if (stmt.type === 'ImportDeclaration' && stmt.importKind !== 'type' && /^\.{1,2}\/.*\.json$/.test(stmt.source.value)) {
|
|
153
|
+
const def = stmt.specifiers.find((s) => s.type === 'ImportDefaultSpecifier')
|
|
154
|
+
if (def) {
|
|
155
|
+
const file = path.resolve(path.dirname(id), stmt.source.value)
|
|
156
|
+
constants.set(def.local.name, { json: () => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return NOT_STATIC } } })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return constants
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `const name = new Validator(...)`, declared once, not exported, and used only
|
|
164
|
+
// as `name.<method>(...)` with a supported method.
|
|
165
|
+
function replaceableBinding(newExpr, ctx) {
|
|
166
|
+
const decl = newExpr.__parent
|
|
167
|
+
if (!decl || decl.type !== 'VariableDeclarator' || decl.init !== newExpr || decl.id.type !== 'Identifier') return null
|
|
168
|
+
const list = decl.__parent
|
|
169
|
+
if (!list || list.type !== 'VariableDeclaration' || list.kind !== 'const') return null
|
|
170
|
+
if (list.__parent && list.__parent.type === 'ExportNamedDeclaration') return null
|
|
171
|
+
const name = decl.id.name
|
|
172
|
+
if (ctx.declared.get(name) !== 1) return null
|
|
173
|
+
for (const r of ctx.refs) {
|
|
174
|
+
if (r.node.name !== name) continue
|
|
175
|
+
const p = r.parent
|
|
176
|
+
const member = p && (p.type === 'MemberExpression' || p.type === 'OptionalMemberExpression') && r.key === 'object' && !p.computed && p.property.type === 'Identifier' && METHODS.has(p.property.name)
|
|
177
|
+
const called = member && p.__parent && (p.__parent.type === 'CallExpression' || p.__parent.type === 'OptionalCallExpression') && p.__parent.callee === p
|
|
178
|
+
if (!called) return null
|
|
179
|
+
}
|
|
180
|
+
return name
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inlineModule(src, index) {
|
|
184
|
+
const body = src.split('\n').filter((l) => !/^export\s/.test(l)).join('\n')
|
|
185
|
+
return `const __ataCompiled${index} = (() => {\n${body}\nreturn { validate, isValid };\n})();\n`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The options of `new Validator(schema, options)` as a third argument for
|
|
189
|
+
// fromCompiled(), or NOT_STATIC when the call has to stay on the runtime: options
|
|
190
|
+
// that are not a literal the build can read, or that name anything this
|
|
191
|
+
// ata-validator's wrapper does not reproduce. `useDefaults: true` is the default
|
|
192
|
+
// and needs no argument.
|
|
193
|
+
function compiledOptionsArg(node, ctx, supported) {
|
|
194
|
+
const opts = staticValue(node, ctx)
|
|
195
|
+
if (opts === NOT_STATIC || opts === null || typeof opts !== 'object' || Array.isArray(opts)) return NOT_STATIC
|
|
196
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
197
|
+
if (!supported.includes(key)) return NOT_STATIC
|
|
198
|
+
if (key === 'useDefaults' && typeof value !== 'boolean') return NOT_STATIC
|
|
199
|
+
}
|
|
200
|
+
return opts.useDefaults === false ? '{"useDefaults":false}' : ''
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function compileAway(code, id, ata) {
|
|
204
|
+
const { compiledModuleFor, compiledSchemaFor } = ata
|
|
205
|
+
const supportedOptions = Array.isArray(ata.compiledOptions) ? ata.compiledOptions : []
|
|
206
|
+
if (!code.includes('ata-validator')) return null
|
|
207
|
+
let ast
|
|
208
|
+
try {
|
|
209
|
+
ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
|
|
210
|
+
} catch {
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
let validatorName = null
|
|
214
|
+
let importDecl = null
|
|
215
|
+
let defineSchema = null
|
|
216
|
+
let lastImportEnd = 0
|
|
217
|
+
for (const stmt of ast.program.body) {
|
|
218
|
+
if (stmt.type !== 'ImportDeclaration') continue
|
|
219
|
+
lastImportEnd = Math.max(lastImportEnd, stmt.end)
|
|
220
|
+
if (stmt.source.value !== 'ata-validator' || stmt.importKind === 'type') continue
|
|
221
|
+
for (const s of stmt.specifiers) {
|
|
222
|
+
if (s.type !== 'ImportSpecifier' || s.importKind === 'type') continue
|
|
223
|
+
const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value
|
|
224
|
+
if (imported === 'Validator') { validatorName = s.local.name; importDecl = stmt }
|
|
225
|
+
if (imported === 'defineSchema') defineSchema = s.local.name
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!validatorName) return null
|
|
229
|
+
|
|
230
|
+
const { declared, refs } = scan(ast)
|
|
231
|
+
if (declared.get(validatorName) !== 1) return null
|
|
232
|
+
const ctx = { declared, refs, constants: constantsOf(ast, id), defineSchema }
|
|
233
|
+
|
|
234
|
+
const s = new MagicString(code)
|
|
235
|
+
const modules = []
|
|
236
|
+
const done = new Set()
|
|
237
|
+
for (const r of refs) {
|
|
238
|
+
if (r.node.name !== validatorName) continue
|
|
239
|
+
const expr = r.parent
|
|
240
|
+
if (!expr || expr.type !== 'NewExpression' || r.key !== 'callee') continue
|
|
241
|
+
if (expr.arguments.length !== 1 && expr.arguments.length !== 2) continue
|
|
242
|
+
let optionsArg = ''
|
|
243
|
+
if (expr.arguments.length === 2) {
|
|
244
|
+
optionsArg = compiledOptionsArg(expr.arguments[1], ctx, supportedOptions)
|
|
245
|
+
if (optionsArg === NOT_STATIC) continue
|
|
246
|
+
}
|
|
247
|
+
if (!replaceableBinding(expr, ctx)) continue
|
|
248
|
+
const schema = staticValue(expr.arguments[0], ctx)
|
|
249
|
+
if (schema === NOT_STATIC || schema === null || typeof schema !== 'object' || Array.isArray(schema)) continue
|
|
250
|
+
let src = null
|
|
251
|
+
try { src = compiledModuleFor(schema, { format: 'esm' }) } catch { src = null }
|
|
252
|
+
if (!src) continue
|
|
253
|
+
const index = modules.length
|
|
254
|
+
modules.push(inlineModule(src, index))
|
|
255
|
+
s.overwrite(expr.start, expr.end, `__ataFromCompiled(__ataCompiled${index}, ${JSON.stringify(compiledSchemaFor(schema))}${optionsArg ? ', ' + optionsArg : ''})`)
|
|
256
|
+
done.add(expr)
|
|
257
|
+
}
|
|
258
|
+
if (done.size === 0) return null
|
|
259
|
+
|
|
260
|
+
// `Validator`, and `defineSchema` when it only wrapped a replaced schema,
|
|
261
|
+
// leave the import once nothing else uses them, and the import goes when it
|
|
262
|
+
// is left empty, so no bundler has to prove the runtime unused.
|
|
263
|
+
const inReplaced = (node) => { for (const e of done) if (node.start >= e.start && node.end <= e.end) return true; return false }
|
|
264
|
+
const unused = new Set()
|
|
265
|
+
for (const spec of importDecl.specifiers) {
|
|
266
|
+
if (spec.type !== 'ImportSpecifier') continue
|
|
267
|
+
const name = spec.local.name
|
|
268
|
+
if (name !== validatorName && name !== defineSchema) continue
|
|
269
|
+
if (ctx.declared.get(name) !== 1) continue
|
|
270
|
+
if (!refs.some((r) => r.node.name === name && !inReplaced(r.node))) unused.add(spec)
|
|
271
|
+
}
|
|
272
|
+
if (unused.size === importDecl.specifiers.length) s.remove(importDecl.start, importDecl.end)
|
|
273
|
+
else if (unused.size) {
|
|
274
|
+
const kept = importDecl.specifiers.filter((sp) => !unused.has(sp))
|
|
275
|
+
const text = (sp) => code.slice(sp.start, sp.end)
|
|
276
|
+
const def = kept.find((sp) => sp.type === 'ImportDefaultSpecifier')
|
|
277
|
+
const named = kept.filter((sp) => sp.type === 'ImportSpecifier')
|
|
278
|
+
const clause = [def ? text(def) : null, named.length ? `{ ${named.map(text).join(', ')} }` : null].filter(Boolean).join(', ')
|
|
279
|
+
s.overwrite(importDecl.start, importDecl.end, `import ${clause} from ${code.slice(importDecl.source.start, importDecl.source.end)}`)
|
|
280
|
+
}
|
|
281
|
+
s.prepend(`import { fromCompiled as __ataFromCompiled } from 'ata-validator/compiled';\n`)
|
|
282
|
+
s.appendLeft(lastImportEnd, '\n' + modules.join(''))
|
|
283
|
+
return { code: s.toString(), map: s.generateMap({ hires: true, source: id, includeContent: true }), replaced: done.size }
|
|
284
|
+
}
|
package/src/core.js
CHANGED
|
@@ -41,6 +41,7 @@ const DEFAULT_OPTIONS = {
|
|
|
41
41
|
format: 'esm',
|
|
42
42
|
abortEarly: false,
|
|
43
43
|
types: true,
|
|
44
|
+
compileAway: false,
|
|
44
45
|
nameFromFile: (file) => {
|
|
45
46
|
const base = path.basename(file, path.extname(file)).replace(/\.schema$/i, '')
|
|
46
47
|
return pascal(base)
|
|
@@ -60,7 +61,18 @@ async function loadAta() {
|
|
|
60
61
|
'unplugin-ata requires ata-validator >= 0.19.0 with the ata-validator/build entry.',
|
|
61
62
|
)
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
// compiledModuleFor exists from ata-validator 1.35.0; compile-away needs it
|
|
65
|
+
// and is skipped with a warning when it is missing.
|
|
66
|
+
const canCompileAway = typeof build.compiledModuleFor === 'function' && typeof build.compiledSchemaFor === 'function'
|
|
67
|
+
return {
|
|
68
|
+
...api,
|
|
69
|
+
toStandaloneModule: build.toStandaloneModule,
|
|
70
|
+
compiledModuleFor: canCompileAway ? build.compiledModuleFor : null,
|
|
71
|
+
compiledSchemaFor: canCompileAway ? build.compiledSchemaFor : null,
|
|
72
|
+
// The Validator options fromCompiled() reproduces, from ata-validator
|
|
73
|
+
// 1.37.0; before it, only calls without options are replaced.
|
|
74
|
+
compiledOptions: canCompileAway && Array.isArray(build.compiledOptions) ? build.compiledOptions : [],
|
|
75
|
+
}
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
function ensureArray(value) {
|
|
@@ -337,6 +349,10 @@ export function createSession(userOptions = {}) {
|
|
|
337
349
|
)
|
|
338
350
|
return { files, results }
|
|
339
351
|
},
|
|
352
|
+
// ata-validator itself, loaded once for every hook that needs it.
|
|
353
|
+
api() {
|
|
354
|
+
return (apiPromise ??= loadAta())
|
|
355
|
+
},
|
|
340
356
|
async compileIfMatching(file) {
|
|
341
357
|
if (!file) return null
|
|
342
358
|
const api = await (apiPromise ??= loadAta())
|
package/src/index.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ export interface Options {
|
|
|
21
21
|
root?: string
|
|
22
22
|
/** Aliases for imports inside `.ts` schema files. Vite's `resolve.alias` is picked up. */
|
|
23
23
|
alias?: Record<string, string>
|
|
24
|
+
/**
|
|
25
|
+
* Replace `new Validator(schema)` with a validator compiled at build time
|
|
26
|
+
* wherever the schema can be read without running code and the instance is
|
|
27
|
+
* only used through validate(), isValidObject(), validateJSON() and
|
|
28
|
+
* isValidJSON(), so the runtime compiler stays out of the bundle. Results are
|
|
29
|
+
* the ones the runtime gives. Needs ata-validator 1.35.0 or later.
|
|
30
|
+
* Default: `false`.
|
|
31
|
+
*/
|
|
32
|
+
compileAway?: boolean
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface CompileResult {
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { createUnplugin } from 'unplugin'
|
|
12
12
|
import { createSession, normalizeAlias, compile, __internal } from './core.js'
|
|
13
|
+
import { compileAway } from './compile-away.js'
|
|
14
|
+
|
|
15
|
+
const SOURCE = /\.[cm]?[jt]sx?$/
|
|
13
16
|
|
|
14
17
|
export const unpluginFactory = (userOptions = {}) => {
|
|
15
18
|
const session = createSession(userOptions)
|
|
@@ -28,6 +31,29 @@ export const unpluginFactory = (userOptions = {}) => {
|
|
|
28
31
|
await session.compileIfMatching(id)
|
|
29
32
|
},
|
|
30
33
|
|
|
34
|
+
// compileAway: `new Validator(schema)` with a schema the build can read
|
|
35
|
+
// becomes a validator compiled here, and the runtime compiler leaves the
|
|
36
|
+
// bundle. See src/compile-away.js for what is replaced and what is not.
|
|
37
|
+
transformInclude(id) {
|
|
38
|
+
if (!userOptions.compileAway) return false
|
|
39
|
+
const file = id.split('?')[0]
|
|
40
|
+
return SOURCE.test(file) && !file.split(/[\\/]/).includes('node_modules')
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async transform(code, id) {
|
|
44
|
+
if (!code.includes('ata-validator')) return null
|
|
45
|
+
const api = await session.api()
|
|
46
|
+
if (!api.compiledModuleFor) {
|
|
47
|
+
if (!session.warnedCompileAway) {
|
|
48
|
+
session.warnedCompileAway = true
|
|
49
|
+
session.logger?.warn?.('[unplugin-ata] compileAway needs ata-validator 1.36.0 or later; nothing was replaced')
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
const out = compileAway(code, id.split('?')[0], api)
|
|
54
|
+
return out ? { code: out.code, map: out.map } : null
|
|
55
|
+
},
|
|
56
|
+
|
|
31
57
|
// Where `schemas` globs resolve from. Vite says so in configResolved,
|
|
32
58
|
// Webpack and Rspack in compiler.context, esbuild in absWorkingDir; Rollup
|
|
33
59
|
// has no notion of a project root, so it takes the `root` option or the
|