@ata-project/unplugin 0.1.0 → 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 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 typical schema compiles to about 1 KB gzipped, 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,56 @@ 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 call has one argument, and it is an object literal, a top-level `const`
187
+ bound to one, the default import of a relative `.json` file, or
188
+ `defineSchema(...)` around one of those;
189
+ - the result goes into a `const` that is not exported and is only used as
190
+ `name.validate(...)`, `name.isValidObject(...)`, `name.validateJSON(...)` or
191
+ `name.isValidJSON(...)`;
192
+ - ata-validator can compile the schema to the same results. It declines custom
193
+ `errorMessage`s and shapes its code generator cannot express, which the
194
+ runtime answers with its interpreted engine.
195
+
196
+ Anything with options (`new Validator(schema, { coerceTypes: true })`), a schema
197
+ built at run time, or an instance passed around stays as written.
148
198
 
149
199
  ## How it works
150
200
 
@@ -174,7 +224,10 @@ const { files, results } = await compile({ schemas: 'schemas/**/*.json', root: p
174
224
  ## Tests
175
225
 
176
226
  `npm test` compiles the same entry with Vite, Webpack, Rollup, Rolldown,
177
- esbuild and Rspack, imports each bundle and runs the validator it contains.
227
+ esbuild and Rspack, imports each bundle and runs the validator it contains. The
228
+ compileAway tests build a second entry with all six, check that neither the
229
+ runtime compiler nor the interpreter is in the output, and compare every answer
230
+ with `new Validator`; the decline rules are tested on their own.
178
231
 
179
232
  ## Package name
180
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ata-project/unplugin",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -33,7 +33,8 @@
33
33
  "types": "./src/rspack.d.ts",
34
34
  "default": "./src/rspack.js"
35
35
  },
36
- "./core": "./src/core.js"
36
+ "./core": "./src/core.js",
37
+ "./package.json": "./package.json"
37
38
  },
38
39
  "files": [
39
40
  "src/"
@@ -70,12 +71,14 @@
70
71
  "ata-validator": ">=1.0.0"
71
72
  },
72
73
  "dependencies": {
74
+ "@babel/parser": "^7.29.9",
73
75
  "jiti": "^2.7.0",
76
+ "magic-string": "^1.4.2",
74
77
  "unplugin": "^3.3.0"
75
78
  },
76
79
  "devDependencies": {
77
80
  "@rspack/core": "^1.7.0",
78
- "ata-validator": "^1.15.0",
81
+ "ata-validator": "^1.36.0",
79
82
  "esbuild": "^0.28.0",
80
83
  "rolldown": "^1.2.8",
81
84
  "rollup": "^4.60.0",
@@ -0,0 +1,262 @@
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
+ export function compileAway(code, id, ata) {
189
+ const { compiledModuleFor, compiledSchemaFor } = ata
190
+ if (!code.includes('ata-validator')) return null
191
+ let ast
192
+ try {
193
+ ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: false })
194
+ } catch {
195
+ return null
196
+ }
197
+ let validatorName = null
198
+ let importDecl = null
199
+ let defineSchema = null
200
+ let lastImportEnd = 0
201
+ for (const stmt of ast.program.body) {
202
+ if (stmt.type !== 'ImportDeclaration') continue
203
+ lastImportEnd = Math.max(lastImportEnd, stmt.end)
204
+ if (stmt.source.value !== 'ata-validator' || stmt.importKind === 'type') continue
205
+ for (const s of stmt.specifiers) {
206
+ if (s.type !== 'ImportSpecifier' || s.importKind === 'type') continue
207
+ const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value
208
+ if (imported === 'Validator') { validatorName = s.local.name; importDecl = stmt }
209
+ if (imported === 'defineSchema') defineSchema = s.local.name
210
+ }
211
+ }
212
+ if (!validatorName) return null
213
+
214
+ const { declared, refs } = scan(ast)
215
+ if (declared.get(validatorName) !== 1) return null
216
+ const ctx = { declared, refs, constants: constantsOf(ast, id), defineSchema }
217
+
218
+ const s = new MagicString(code)
219
+ const modules = []
220
+ const done = new Set()
221
+ for (const r of refs) {
222
+ if (r.node.name !== validatorName) continue
223
+ const expr = r.parent
224
+ if (!expr || expr.type !== 'NewExpression' || r.key !== 'callee' || expr.arguments.length !== 1) continue
225
+ if (!replaceableBinding(expr, ctx)) continue
226
+ const schema = staticValue(expr.arguments[0], ctx)
227
+ if (schema === NOT_STATIC || schema === null || typeof schema !== 'object' || Array.isArray(schema)) continue
228
+ let src = null
229
+ try { src = compiledModuleFor(schema, { format: 'esm' }) } catch { src = null }
230
+ if (!src) continue
231
+ const index = modules.length
232
+ modules.push(inlineModule(src, index))
233
+ s.overwrite(expr.start, expr.end, `__ataFromCompiled(__ataCompiled${index}, ${JSON.stringify(compiledSchemaFor(schema))})`)
234
+ done.add(expr)
235
+ }
236
+ if (done.size === 0) return null
237
+
238
+ // `Validator`, and `defineSchema` when it only wrapped a replaced schema,
239
+ // leave the import once nothing else uses them, and the import goes when it
240
+ // is left empty, so no bundler has to prove the runtime unused.
241
+ const inReplaced = (node) => { for (const e of done) if (node.start >= e.start && node.end <= e.end) return true; return false }
242
+ const unused = new Set()
243
+ for (const spec of importDecl.specifiers) {
244
+ if (spec.type !== 'ImportSpecifier') continue
245
+ const name = spec.local.name
246
+ if (name !== validatorName && name !== defineSchema) continue
247
+ if (ctx.declared.get(name) !== 1) continue
248
+ if (!refs.some((r) => r.node.name === name && !inReplaced(r.node))) unused.add(spec)
249
+ }
250
+ if (unused.size === importDecl.specifiers.length) s.remove(importDecl.start, importDecl.end)
251
+ else if (unused.size) {
252
+ const kept = importDecl.specifiers.filter((sp) => !unused.has(sp))
253
+ const text = (sp) => code.slice(sp.start, sp.end)
254
+ const def = kept.find((sp) => sp.type === 'ImportDefaultSpecifier')
255
+ const named = kept.filter((sp) => sp.type === 'ImportSpecifier')
256
+ const clause = [def ? text(def) : null, named.length ? `{ ${named.map(text).join(', ')} }` : null].filter(Boolean).join(', ')
257
+ s.overwrite(importDecl.start, importDecl.end, `import ${clause} from ${code.slice(importDecl.source.start, importDecl.source.end)}`)
258
+ }
259
+ s.prepend(`import { fromCompiled as __ataFromCompiled } from 'ata-validator/compiled';\n`)
260
+ s.appendLeft(lastImportEnd, '\n' + modules.join(''))
261
+ return { code: s.toString(), map: s.generateMap({ hires: true, source: id, includeContent: true }), replaced: done.size }
262
+ }
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,15 @@ 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
+ }
64
73
  }
65
74
 
66
75
  function ensureArray(value) {
@@ -270,11 +279,13 @@ async function compileOne(schemaFile, options, root, api, logger, fresh = false)
270
279
  let outSrc = validatorSrc
271
280
  let defaultRewritten = false
272
281
  if (isSchemaConvention(schemaFile) && options.format !== 'cjs') {
273
- // toStandaloneModule emits `export default { validate, isValid };`.
282
+ // toStandaloneModule emits `export default { validate, isValid, schemaHash };`
283
+ // (schemaHash since ata-validator 1.25.0; earlier versions emit fewer names).
274
284
  // For the .schema convention we make the default the validate function so
275
- // `import validate from './x.schema'` works. Named exports stay intact.
285
+ // `import validate from './x.schema'` works. Named exports stay intact, so
286
+ // anything else in that object list is still importable by name.
276
287
  const replaced = outSrc.replace(
277
- /^export default \{\s*validate(?:\s*,\s*isValid)?\s*\};?\s*$/m,
288
+ /^export default \{\s*validate(?:\s*,\s*[A-Za-z_$][A-Za-z0-9_$]*)*\s*,?\s*\};?\s*$/m,
278
289
  'export default validate;',
279
290
  )
280
291
  if (replaced !== outSrc) {
@@ -335,6 +346,10 @@ export function createSession(userOptions = {}) {
335
346
  )
336
347
  return { files, results }
337
348
  },
349
+ // ata-validator itself, loaded once for every hook that needs it.
350
+ api() {
351
+ return (apiPromise ??= loadAta())
352
+ },
338
353
  async compileIfMatching(file) {
339
354
  if (!file) return null
340
355
  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