@ata-project/unplugin 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mert Can Altin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # @ata-project/unplugin
2
+
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
+
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.
6
+
7
+ Schemas can be authored as `.json`, `.js` or `.ts`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install --save-dev @ata-project/unplugin ata-validator
13
+ ```
14
+
15
+ `ata-validator` is a peer dependency and is only used at build time.
16
+
17
+ ## Setup
18
+
19
+ Vite:
20
+
21
+ ```ts
22
+ // vite.config.ts
23
+ import ata from '@ata-project/unplugin/vite'
24
+
25
+ export default {
26
+ plugins: [ata({ schemas: 'src/**/*.schema.json' })],
27
+ }
28
+ ```
29
+
30
+ Webpack:
31
+
32
+ ```js
33
+ // webpack.config.js
34
+ const ata = require('@ata-project/unplugin/webpack')
35
+
36
+ module.exports = {
37
+ plugins: [ata({ schemas: 'src/**/*.schema.json' })],
38
+ }
39
+ ```
40
+
41
+ Rollup:
42
+
43
+ ```js
44
+ // rollup.config.js
45
+ import ata from '@ata-project/unplugin/rollup'
46
+
47
+ export default {
48
+ // Rollup has no project root; tell the plugin where the globs start.
49
+ plugins: [ata({ schemas: 'src/**/*.schema.json', root: process.cwd() })],
50
+ }
51
+ ```
52
+
53
+ esbuild:
54
+
55
+ ```js
56
+ import { build } from 'esbuild'
57
+ import ata from '@ata-project/unplugin/esbuild'
58
+
59
+ await build({
60
+ entryPoints: ['src/main.ts'],
61
+ bundle: true,
62
+ plugins: [ata({ schemas: 'src/**/*.schema.json' })],
63
+ })
64
+ ```
65
+
66
+ Rspack:
67
+
68
+ ```js
69
+ // rspack.config.js
70
+ const ata = require('@ata-project/unplugin/rspack')
71
+
72
+ module.exports = {
73
+ plugins: [ata({ schemas: 'src/**/*.schema.json' })],
74
+ }
75
+ ```
76
+
77
+ Rolldown:
78
+
79
+ ```js
80
+ // rolldown.config.js
81
+ import ata from '@ata-project/unplugin/rolldown'
82
+
83
+ export default {
84
+ plugins: [ata({ schemas: 'src/**/*.schema.json', root: process.cwd() })],
85
+ }
86
+ ```
87
+
88
+ Next.js uses Webpack, so the Webpack entry goes into `next.config.js`:
89
+
90
+ ```js
91
+ const ata = require('@ata-project/unplugin/webpack')
92
+
93
+ module.exports = {
94
+ webpack(config) {
95
+ config.plugins.push(ata({ schemas: 'schemas/**/*.schema.json' }))
96
+ return config
97
+ },
98
+ }
99
+ ```
100
+
101
+ Turbopack has no plugin interface this could attach to. Run the compile as a
102
+ step before the build instead: `ata build 'schemas/**/*.schema.json'` from
103
+ ata-validator's CLI, or the `compile()` function below in a script.
104
+
105
+ ## The `.schema.json` convention
106
+
107
+ Name a schema `user.schema.json` and import a typed validator by its name:
108
+
109
+ ```ts
110
+ import validate, { type User, isValid } from './user.schema'
111
+
112
+ const r = validate(input)
113
+ if (r.valid) {
114
+ // input matched the schema
115
+ }
116
+ if (isValid(input)) {
117
+ input.id // narrowed to User
118
+ }
119
+ ```
120
+
121
+ The plugin writes `user.schema.js` and `user.schema.d.ts` next to the schema.
122
+ The default export is the `validate` function; `validate`, `isValid` and the
123
+ inferred type are also named exports. The type name comes from the schema's
124
+ `title`, then its `$id`, then the file name.
125
+
126
+ Add the generated files to `.gitignore`:
127
+
128
+ ```
129
+ *.schema.js
130
+ *.schema.d.ts
131
+ ```
132
+
133
+ Other sources (`.json` without the `.schema` suffix, `.js`, `.ts`) produce
134
+ `<name>.validator.mjs` and `<name>.validator.d.mts`.
135
+
136
+ ## Options
137
+
138
+ | Option | Default | Description |
139
+ |---|---|---|
140
+ | `schemas` | `**/*.schema.json` | Glob or globs, relative to the root. `node_modules` is skipped. |
141
+ | `outDir` | next to each schema | Directory for generated files, mirroring the source layout. |
142
+ | `format` | `'esm'` | `'esm'` or `'cjs'`. |
143
+ | `abortEarly` | `false` | Emit the smaller validator that stops at the first failure. |
144
+ | `types` | `true` | Emit a `.d.ts` next to each validator. |
145
+ | `nameFromFile` | file name in PascalCase | Type name for schemas without `title` or `$id`. |
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
+ | `alias` | Vite's `resolve.alias` | Import aliases inside `.ts` schema files. tsconfig `paths` work without configuration. |
148
+
149
+ ## How it works
150
+
151
+ Output goes to disk, not to virtual modules. That is what makes the plugin
152
+ identical across bundlers and what lets TypeScript see the generated
153
+ declarations without any bundler-specific type plumbing.
154
+
155
+ Schemas compile on build start. On a schema change, Vite recompiles through
156
+ its HMR hook and Webpack, Rspack, Rollup and Rolldown through `watchChange`.
157
+ esbuild has no watch hook, so under `esbuild --watch` a schema edit alone does
158
+ not trigger a rebuild; the next build start compiles it.
159
+
160
+ `.ts` schemas load through [jiti](https://github.com/unjs/jiti). A schema
161
+ module exports the schema as `default` or as a named `schema`.
162
+
163
+ A schema the standalone compiler cannot represent is reported with a warning
164
+ and skipped; the ata-validator runtime API still validates it.
165
+
166
+ ## Programmatic use
167
+
168
+ ```js
169
+ import { compile } from '@ata-project/unplugin'
170
+
171
+ const { files, results } = await compile({ schemas: 'schemas/**/*.json', root: process.cwd() })
172
+ ```
173
+
174
+ ## Tests
175
+
176
+ `npm test` compiles the same entry with Vite, Webpack, Rollup, Rolldown,
177
+ esbuild and Rspack, imports each bundle and runs the validator it contains.
178
+
179
+ ## Package name
180
+
181
+ The package is `@ata-project/unplugin`; the plugin registers itself in the
182
+ bundler as `unplugin-ata`, which is the name in log lines and in
183
+ `plugin.name`. The unscoped name is not available on npm.
184
+
185
+ ## Relation to ata-vite
186
+
187
+ `ata-vite` is this plugin's Vite entry with the old name. It keeps working;
188
+ new projects can use either.
189
+
190
+ ## License
191
+
192
+ MIT
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@ata-project/unplugin",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "default": "./src/index.js"
11
+ },
12
+ "./vite": {
13
+ "types": "./src/vite.d.ts",
14
+ "default": "./src/vite.js"
15
+ },
16
+ "./webpack": {
17
+ "types": "./src/webpack.d.ts",
18
+ "default": "./src/webpack.js"
19
+ },
20
+ "./rollup": {
21
+ "types": "./src/rollup.d.ts",
22
+ "default": "./src/rollup.js"
23
+ },
24
+ "./rolldown": {
25
+ "types": "./src/rolldown.d.ts",
26
+ "default": "./src/rolldown.js"
27
+ },
28
+ "./esbuild": {
29
+ "types": "./src/esbuild.d.ts",
30
+ "default": "./src/esbuild.js"
31
+ },
32
+ "./rspack": {
33
+ "types": "./src/rspack.d.ts",
34
+ "default": "./src/rspack.js"
35
+ },
36
+ "./core": "./src/core.js"
37
+ },
38
+ "files": [
39
+ "src/"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test test/*.test.js"
43
+ },
44
+ "keywords": [
45
+ "unplugin",
46
+ "vite-plugin",
47
+ "webpack-plugin",
48
+ "rollup-plugin",
49
+ "esbuild-plugin",
50
+ "rspack-plugin",
51
+ "json-schema",
52
+ "ata-validator",
53
+ "typescript",
54
+ "codegen"
55
+ ],
56
+ "author": "Mert Can Altin <mertgold60@gmail.com>",
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/ata-core/unplugin-ata.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/ata-core/unplugin-ata/issues"
64
+ },
65
+ "homepage": "https://github.com/ata-core/unplugin-ata#readme",
66
+ "engines": {
67
+ "node": ">=18.0.0"
68
+ },
69
+ "peerDependencies": {
70
+ "ata-validator": ">=1.0.0"
71
+ },
72
+ "dependencies": {
73
+ "jiti": "^2.7.0",
74
+ "unplugin": "^3.3.0"
75
+ },
76
+ "devDependencies": {
77
+ "@rspack/core": "^1.7.0",
78
+ "ata-validator": "^1.15.0",
79
+ "esbuild": "^0.28.0",
80
+ "rolldown": "^1.2.8",
81
+ "rollup": "^4.60.0",
82
+ "typescript": "^5.9.3",
83
+ "vite": "^7.3.2",
84
+ "webpack": "^5.105.0"
85
+ },
86
+ "types": "./src/index.d.ts",
87
+ "publishConfig": {
88
+ "access": "public"
89
+ }
90
+ }
package/src/core.js ADDED
@@ -0,0 +1,351 @@
1
+ // unplugin-ata core: build-time schema compilation, independent of any
2
+ // bundler. src/index.js wraps this in an unplugin; ata-vite delegates here.
3
+ //
4
+ // Schemas may be authored as .json (read as text), .js (native import), or
5
+ // .ts (loaded through jiti). For each schema matched by `schemas`, emit:
6
+ // - <base>.validator.mjs (self-contained validator)
7
+ // - <base>.validator.d.mts (TypeScript declarations, opt-in)
8
+ // or, for the `<name>.schema.json` convention, `<name>.schema.js` and
9
+ // `<name>.schema.d.ts` with the validate function as the default export.
10
+ //
11
+ // Output is written to disk, never served as virtual modules, so every
12
+ // bundler sees the same files and so does TypeScript.
13
+
14
+ import fs from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+
18
+ function pascal(str) {
19
+ const cleaned = String(str)
20
+ .replace(/[^A-Za-z0-9_]+/g, '_')
21
+ .replace(/^_+|_+$/g, '')
22
+ .replace(/^[0-9]/, '_$&')
23
+ if (!cleaned) return 'Schema'
24
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
25
+ }
26
+
27
+ function deriveTypeName(schema, file, options) {
28
+ if (schema && typeof schema.title === 'string' && schema.title.trim()) {
29
+ return pascal(schema.title)
30
+ }
31
+ if (schema && typeof schema.$id === 'string' && schema.$id.trim()) {
32
+ const base = path.basename(schema.$id).replace(/\.[^.]+$/, '')
33
+ if (base) return pascal(base)
34
+ }
35
+ return options.nameFromFile(file)
36
+ }
37
+
38
+ const DEFAULT_OPTIONS = {
39
+ schemas: '**/*.schema.json',
40
+ outDir: null, // default: alongside each input
41
+ format: 'esm',
42
+ abortEarly: false,
43
+ types: true,
44
+ nameFromFile: (file) => {
45
+ const base = path.basename(file, path.extname(file)).replace(/\.schema$/i, '')
46
+ return pascal(base)
47
+ },
48
+ }
49
+
50
+ async function loadAta() {
51
+ // Resolve ata-validator at runtime so peer-dep works cleanly across package managers.
52
+ // Standalone codegen comes from the ata-validator/build entry: the instance
53
+ // method it replaced was removed in ata-validator 1.0.
54
+ const mod = await import('ata-validator')
55
+ const api = mod.default ?? mod
56
+ const buildMod = await import('ata-validator/build')
57
+ const build = buildMod.default ?? buildMod
58
+ if (!api.Validator || !api.toTypeScript || typeof build.toStandaloneModule !== 'function') {
59
+ throw new Error(
60
+ 'unplugin-ata requires ata-validator >= 0.19.0 with the ata-validator/build entry.',
61
+ )
62
+ }
63
+ return { ...api, toStandaloneModule: build.toStandaloneModule }
64
+ }
65
+
66
+ function ensureArray(value) {
67
+ return Array.isArray(value) ? value : [value]
68
+ }
69
+
70
+ async function resolveSchemaFiles(patterns, root) {
71
+ const isVendor = (abs) =>
72
+ path.relative(root, abs).split(path.sep).includes('node_modules')
73
+
74
+ // Node 22+ ships fs.glob. Fall back to a recursive walk for older runtimes.
75
+ if (typeof fs.glob === 'function') {
76
+ const found = []
77
+ for (const pattern of ensureArray(patterns)) {
78
+ for await (const hit of fs.glob(pattern, { cwd: root })) {
79
+ const abs = path.resolve(root, hit)
80
+ if (!isVendor(abs)) found.push(abs)
81
+ }
82
+ }
83
+ return [...new Set(found)]
84
+ }
85
+
86
+ const matched = new Set()
87
+ for (const pattern of ensureArray(patterns)) {
88
+ const anchor = path.resolve(root, pattern.split('*')[0] || '.')
89
+ let stack
90
+ try {
91
+ const stat = await fs.stat(anchor)
92
+ stack = stat.isDirectory() ? [anchor] : [path.dirname(anchor)]
93
+ } catch {
94
+ continue
95
+ }
96
+ const re = globToRegExp(pattern)
97
+ while (stack.length > 0) {
98
+ const dir = stack.pop()
99
+ let entries
100
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { continue }
101
+ for (const e of entries) {
102
+ const abs = path.join(dir, e.name)
103
+ if (e.isDirectory()) {
104
+ if (e.name !== 'node_modules') stack.push(abs)
105
+ continue
106
+ }
107
+ const rel = path.relative(root, abs).split(path.sep).join('/')
108
+ if (re.test(rel) && !isVendor(abs)) matched.add(abs)
109
+ }
110
+ }
111
+ }
112
+ return [...matched]
113
+ }
114
+
115
+ function globToRegExp(pattern) {
116
+ let re = '^'
117
+ for (let i = 0; i < pattern.length; i++) {
118
+ const c = pattern[i]
119
+ // `**/` matches zero or more path segments, so schemas/**/*.json
120
+ // also picks up files that live directly in schemas/.
121
+ if (c === '*' && pattern[i + 1] === '*') {
122
+ if (pattern[i + 2] === '/') {
123
+ re += '(?:.*/)?'
124
+ i += 2
125
+ } else {
126
+ re += '.*'
127
+ i++
128
+ }
129
+ } else if (c === '*') {
130
+ re += '[^/]*'
131
+ } else if (c === '?') {
132
+ re += '[^/]'
133
+ } else if ('.+^${}()|[]\\'.includes(c)) {
134
+ re += '\\' + c
135
+ } else {
136
+ re += c
137
+ }
138
+ }
139
+ return new RegExp(re + '$')
140
+ }
141
+
142
+ function isSchemaConvention(file) {
143
+ return file.toLowerCase().endsWith('.schema.json')
144
+ }
145
+
146
+ function outputPaths(schemaFile, options, root) {
147
+ const dir = options.outDir
148
+ ? path.resolve(root, options.outDir, path.dirname(path.relative(root, schemaFile)))
149
+ : path.dirname(schemaFile)
150
+ if (isSchemaConvention(schemaFile)) {
151
+ // user.schema.json -> base "user.schema" -> import './user.schema'
152
+ const base = path.basename(schemaFile.replace(/\.json$/i, ''))
153
+ const cjs = options.format === 'cjs'
154
+ const mjs = path.join(dir, `${base}.${cjs ? 'cjs' : 'js'}`)
155
+ const dts = path.join(dir, `${base}.${cjs ? 'd.cts' : 'd.ts'}`)
156
+ return { dir, mjs, dts }
157
+ }
158
+ const base = path.basename(schemaFile, path.extname(schemaFile))
159
+ const mjs = path.join(dir, `${base}.validator.${options.format === 'cjs' ? 'cjs' : 'mjs'}`)
160
+ const dts = path.join(dir, `${base}.validator.${options.format === 'cjs' ? 'd.cts' : 'd.mts'}`)
161
+ return { dir, mjs, dts }
162
+ }
163
+
164
+ async function readJson(file) {
165
+ const text = await fs.readFile(file, 'utf8')
166
+ return JSON.parse(text)
167
+ }
168
+
169
+ // Vite normalizes resolve.alias to an array of { find, replacement } by the time
170
+ // configResolved runs, but accept the object form too. jiti's `alias` is a
171
+ // Record<string, string>, so only string finds carry over; RegExp finds are
172
+ // dropped (they cannot be expressed as a record key).
173
+ function normalizeAlias(viteAlias) {
174
+ if (!viteAlias) return undefined
175
+ const entries = Array.isArray(viteAlias)
176
+ ? viteAlias
177
+ : Object.entries(viteAlias).map(([find, replacement]) => ({ find, replacement }))
178
+ const out = {}
179
+ for (const { find, replacement } of entries) {
180
+ if (typeof find === 'string' && typeof replacement === 'string') out[find] = replacement
181
+ }
182
+ return Object.keys(out).length ? out : undefined
183
+ }
184
+
185
+ // jiti instances are created on first .ts schema and reused, keyed by alias map
186
+ // so different alias sets do not share an instance. fsCache keeps transpilation
187
+ // on disk; moduleCache:false re-evaluates each import so HMR picks up edits.
188
+ // tsconfigPaths:true resolves TypeScript `paths` aliases from tsconfig.
189
+ const jitiInstances = new Map()
190
+ function getJiti(alias) {
191
+ const key = alias ? JSON.stringify(alias) : ''
192
+ let p = jitiInstances.get(key)
193
+ if (!p) {
194
+ p = (async () => {
195
+ let mod
196
+ try {
197
+ mod = await import('jiti')
198
+ } catch {
199
+ throw new Error(
200
+ 'unplugin-ata: compiling .ts/.mts schema files needs "jiti". Install it with: npm install jiti',
201
+ )
202
+ }
203
+ const createJiti = mod.createJiti ?? mod.default
204
+ return createJiti(import.meta.url, {
205
+ fsCache: true,
206
+ moduleCache: false,
207
+ tsconfigPaths: true,
208
+ ...(alias ? { alias } : {}),
209
+ })
210
+ })()
211
+ jitiInstances.set(key, p)
212
+ }
213
+ return p
214
+ }
215
+
216
+ // A schema module exports the schema as `default` (or a named `schema`).
217
+ // For CJS loaded over the ESM interop, `default` holds module.exports.
218
+ function pickSchema(mod) {
219
+ return mod?.default ?? mod?.schema ?? mod
220
+ }
221
+
222
+ // JSON is read as inert text. JS goes through native import. TS goes through
223
+ // jiti. `fresh` busts the native module cache on the HMR/watch path only, so
224
+ // the one-shot buildStart keeps the registry clean.
225
+ async function loadSchema(file, fresh = false, alias) {
226
+ const ext = path.extname(file).toLowerCase()
227
+ if (ext === '.json' || ext === '') {
228
+ return readJson(file)
229
+ }
230
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
231
+ const url = pathToFileURL(file).href + (fresh ? `?t=${Date.now()}` : '')
232
+ return pickSchema(await import(url))
233
+ }
234
+ const jiti = await getJiti(alias)
235
+ return pickSchema(await jiti.import(file))
236
+ }
237
+
238
+ async function writeIfChanged(file, contents) {
239
+ try {
240
+ const existing = await fs.readFile(file, 'utf8')
241
+ if (existing === contents) return false
242
+ } catch { /* file missing, write fresh */ }
243
+ await fs.mkdir(path.dirname(file), { recursive: true })
244
+ await fs.writeFile(file, contents)
245
+ return true
246
+ }
247
+
248
+ async function compileOne(schemaFile, options, root, api, logger, fresh = false) {
249
+ let schema
250
+ try {
251
+ schema = await loadSchema(schemaFile, fresh, options.alias)
252
+ } catch (err) {
253
+ logger?.warn?.(`[unplugin-ata] cannot load ${path.relative(root, schemaFile)}: ${err.message}`)
254
+ return { changed: false, typeName: null, paths: null }
255
+ }
256
+ if (!schema || typeof schema !== 'object') {
257
+ logger?.warn?.(`[unplugin-ata] ${path.relative(root, schemaFile)} did not export a schema object`)
258
+ return { changed: false, typeName: null, paths: null }
259
+ }
260
+
261
+ const v = new api.Validator(schema)
262
+ const validatorSrc = api.toStandaloneModule(v, { format: options.format, abortEarly: options.abortEarly })
263
+ if (!validatorSrc) {
264
+ logger?.warn?.(`[unplugin-ata] schema ${path.relative(root, schemaFile)} is too complex for standalone compilation`)
265
+ return { changed: false, typeName: null, paths: null }
266
+ }
267
+
268
+ const typeName = deriveTypeName(schema, schemaFile, options)
269
+ const paths = outputPaths(schemaFile, options, root)
270
+ let outSrc = validatorSrc
271
+ let defaultRewritten = false
272
+ if (isSchemaConvention(schemaFile) && options.format !== 'cjs') {
273
+ // toStandaloneModule emits `export default { validate, isValid };`.
274
+ // For the .schema convention we make the default the validate function so
275
+ // `import validate from './x.schema'` works. Named exports stay intact.
276
+ const replaced = outSrc.replace(
277
+ /^export default \{\s*validate(?:\s*,\s*isValid)?\s*\};?\s*$/m,
278
+ 'export default validate;',
279
+ )
280
+ if (replaced !== outSrc) {
281
+ outSrc = replaced
282
+ defaultRewritten = true
283
+ } else {
284
+ // ata's standalone output format changed: do NOT rewrite the .d.ts default
285
+ // either, or the type would claim a callable default the module does not have.
286
+ logger?.warn?.(`[unplugin-ata] could not set default export to validate for ${path.relative(root, schemaFile)} (ata output format changed); keeping the { validate, isValid } default`)
287
+ }
288
+ }
289
+ const mjsChanged = await writeIfChanged(paths.mjs, outSrc)
290
+
291
+ let dtsChanged = false
292
+ if (options.types) {
293
+ let dtsSrc = api.toTypeScript(schema, { name: typeName })
294
+ // Only rewrite the .d.ts default when the .js default was actually rewritten,
295
+ // so the declared default and the runtime default can never disagree.
296
+ if (defaultRewritten) {
297
+ dtsSrc = dtsSrc.replace(
298
+ /^declare const _default:[^\n]*\nexport default _default;?\s*$/m,
299
+ 'export { validate as default };',
300
+ )
301
+ }
302
+ dtsChanged = await writeIfChanged(paths.dts, dtsSrc)
303
+ }
304
+
305
+ return { changed: mjsChanged || dtsChanged, typeName, paths }
306
+ }
307
+
308
+ // Programmatic entry for custom build scripts.
309
+ export async function compile(options = {}) {
310
+ const opts = { ...DEFAULT_OPTIONS, ...options }
311
+ const root = options.root ?? process.cwd()
312
+ const api = await loadAta()
313
+ const files = await resolveSchemaFiles(opts.schemas, root)
314
+ const results = await Promise.all(
315
+ files.map((file) => compileOne(file, opts, root, api, null)),
316
+ )
317
+ return { files, results }
318
+ }
319
+
320
+ // One compilation session: a root, a logger, a lazily loaded ata, and the
321
+ // two operations a bundler hook needs.
322
+ export function createSession(userOptions = {}) {
323
+ const options = { ...DEFAULT_OPTIONS, ...userOptions }
324
+ let apiPromise = null
325
+ const session = {
326
+ options,
327
+ root: userOptions.root ?? process.cwd(),
328
+ logger: null,
329
+ async compileAll() {
330
+ const api = await (apiPromise ??= loadAta())
331
+ const files = await resolveSchemaFiles(options.schemas, session.root)
332
+ // Compile in parallel so reads, transpiles and writes overlap.
333
+ const results = await Promise.all(
334
+ files.map((file) => compileOne(file, options, session.root, api, session.logger)),
335
+ )
336
+ return { files, results }
337
+ },
338
+ async compileIfMatching(file) {
339
+ if (!file) return null
340
+ const api = await (apiPromise ??= loadAta())
341
+ const files = await resolveSchemaFiles(options.schemas, session.root)
342
+ if (!files.some((f) => path.resolve(f) === path.resolve(file))) return null
343
+ // A change event: bypass the JS module cache so edits are seen.
344
+ return compileOne(file, options, session.root, api, session.logger, true)
345
+ },
346
+ }
347
+ return session
348
+ }
349
+
350
+ export { normalizeAlias, DEFAULT_OPTIONS }
351
+ export const __internal = { loadAta, resolveSchemaFiles, compileOne, outputPaths, globToRegExp, isSchemaConvention }
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.esbuild
4
+ export default plugin
package/src/esbuild.js ADDED
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.esbuild
package/src/index.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { UnpluginInstance } from 'unplugin'
2
+
3
+ export interface Options {
4
+ /** Glob or globs, relative to the project root. Default: `**​/*.schema.json`. */
5
+ schemas?: string | string[]
6
+ /** Where generated files go. Default: next to each schema. */
7
+ outDir?: string | null
8
+ /** Module format of the generated validator. Default: `'esm'`. */
9
+ format?: 'esm' | 'cjs'
10
+ /** Emit the smaller abort-early validator. Default: `false`. */
11
+ abortEarly?: boolean
12
+ /** Emit a `.d.ts` next to each validator. Default: `true`. */
13
+ types?: boolean
14
+ /** Type name for schemas without `title` or `$id`. */
15
+ nameFromFile?: (file: string) => string
16
+ /**
17
+ * Project root the globs resolve from. Read from the bundler when it has
18
+ * one (Vite's root, Webpack's and Rspack's `context`, esbuild's
19
+ * `absWorkingDir`); Rollup has none, so pass it there.
20
+ */
21
+ root?: string
22
+ /** Aliases for imports inside `.ts` schema files. Vite's `resolve.alias` is picked up. */
23
+ alias?: Record<string, string>
24
+ }
25
+
26
+ export interface CompileResult {
27
+ files: string[]
28
+ results: Array<{ changed: boolean; typeName: string | null; paths: { dir: string; mjs: string; dts: string } | null }>
29
+ }
30
+
31
+ /** Compile once, outside any bundler. */
32
+ export function compile(options?: Options): Promise<CompileResult>
33
+
34
+ declare const unplugin: UnpluginInstance<Options | undefined, boolean>
35
+ export default unplugin
package/src/index.js ADDED
@@ -0,0 +1,75 @@
1
+ // unplugin-ata: build-time schema compilation for Vite, Webpack, Rollup,
2
+ // Rolldown, esbuild and Rspack, through unplugin. The work happens in
3
+ // src/core.js; this file maps it onto the hooks every bundler shares
4
+ // (buildStart, watchChange) and the two Vite has on top (configResolved for
5
+ // the root, the logger and resolve.alias; handleHotUpdate for dev).
6
+ //
7
+ // esbuild has no watch hook in unplugin, so under `esbuild --watch` schemas
8
+ // compile on each build start but a schema edit alone does not trigger one.
9
+
10
+ import path from 'node:path'
11
+ import { createUnplugin } from 'unplugin'
12
+ import { createSession, normalizeAlias, compile, __internal } from './core.js'
13
+
14
+ export const unpluginFactory = (userOptions = {}) => {
15
+ const session = createSession(userOptions)
16
+
17
+ return {
18
+ name: 'unplugin-ata',
19
+ enforce: 'pre',
20
+
21
+ async buildStart() {
22
+ const { files, results } = await session.compileAll()
23
+ const changed = results.filter((r) => r.changed).length
24
+ session.logger?.info?.(`[unplugin-ata] compiled ${files.length} schema(s), ${changed} file(s) written`)
25
+ },
26
+
27
+ async watchChange(id) {
28
+ await session.compileIfMatching(id)
29
+ },
30
+
31
+ // Where `schemas` globs resolve from. Vite says so in configResolved,
32
+ // Webpack and Rspack in compiler.context, esbuild in absWorkingDir; Rollup
33
+ // has no notion of a project root, so it takes the `root` option or the
34
+ // working directory. `root` given explicitly wins everywhere.
35
+ webpack(compiler) {
36
+ if (!userOptions.root && compiler.context) session.root = compiler.context
37
+ session.logger = compiler.getInfrastructureLogger?.('unplugin-ata') ?? null
38
+ },
39
+
40
+ rspack(compiler) {
41
+ if (!userOptions.root && compiler.context) session.root = compiler.context
42
+ session.logger = compiler.getInfrastructureLogger?.('unplugin-ata') ?? null
43
+ },
44
+
45
+ esbuild: {
46
+ setup(build) {
47
+ const cwd = build.initialOptions && build.initialOptions.absWorkingDir
48
+ if (!userOptions.root && cwd) session.root = cwd
49
+ },
50
+ },
51
+
52
+ vite: {
53
+ configResolved(config) {
54
+ if (!userOptions.root) session.root = config.root || session.root
55
+ session.logger = config.logger
56
+ // Carry Vite's resolve.alias into the .ts loader so aliased imports in
57
+ // schema files resolve. tsconfig `paths` are handled by jiti directly.
58
+ session.options.alias = normalizeAlias(config.resolve && config.resolve.alias)
59
+ },
60
+
61
+ async handleHotUpdate(ctx) {
62
+ const result = await session.compileIfMatching(ctx.file)
63
+ if (result && result.changed) {
64
+ session.logger?.info?.(`[unplugin-ata] recompiled ${path.relative(session.root, ctx.file)}`)
65
+ }
66
+ return undefined
67
+ },
68
+ },
69
+ }
70
+ }
71
+
72
+ const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)
73
+
74
+ export default unplugin
75
+ export { compile, __internal }
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.rolldown
4
+ export default plugin
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.rolldown
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.rollup
4
+ export default plugin
package/src/rollup.js ADDED
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.rollup
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.rspack
4
+ export default plugin
package/src/rspack.js ADDED
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.rspack
package/src/vite.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.vite
4
+ export default plugin
package/src/vite.js ADDED
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.vite
@@ -0,0 +1,4 @@
1
+ import unplugin from './index.js'
2
+
3
+ declare const plugin: typeof unplugin.webpack
4
+ export default plugin
package/src/webpack.js ADDED
@@ -0,0 +1,3 @@
1
+ import unplugin from './index.js'
2
+
3
+ export default unplugin.webpack