@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 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.4 KB gzipped and a ten-field one to about 4.8 KB, full error detail included; the module exports `validate`, `isValid` and the inferred type, and runs anywhere plain JavaScript runs.
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.1.1",
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.25.0",
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
- return { ...api, toStandaloneModule: build.toStandaloneModule }
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