@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 +21 -0
- package/README.md +192 -0
- package/package.json +90 -0
- package/src/core.js +351 -0
- package/src/esbuild.d.ts +4 -0
- package/src/esbuild.js +3 -0
- package/src/index.d.ts +35 -0
- package/src/index.js +75 -0
- package/src/rolldown.d.ts +4 -0
- package/src/rolldown.js +3 -0
- package/src/rollup.d.ts +4 -0
- package/src/rollup.js +3 -0
- package/src/rspack.d.ts +4 -0
- package/src/rspack.js +3 -0
- package/src/vite.d.ts +4 -0
- package/src/vite.js +3 -0
- package/src/webpack.d.ts +4 -0
- package/src/webpack.js +3 -0
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 }
|
package/src/esbuild.d.ts
ADDED
package/src/esbuild.js
ADDED
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 }
|
package/src/rolldown.js
ADDED
package/src/rollup.d.ts
ADDED
package/src/rollup.js
ADDED
package/src/rspack.d.ts
ADDED
package/src/rspack.js
ADDED
package/src/vite.d.ts
ADDED
package/src/vite.js
ADDED
package/src/webpack.d.ts
ADDED
package/src/webpack.js
ADDED