@amritk/mjst 0.4.0 → 0.5.1
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 +3 -1
- package/dist/cli-config.d.ts +12 -1
- package/dist/cli.js +90210 -279
- package/dist/load-schema-module.d.ts +16 -0
- package/package.json +6 -5
- package/src/cli-config.ts +13 -1
- package/src/cli.ts +14 -2
- package/src/load-config.ts +8 -0
- package/src/load-schema-module.test.ts +49 -0
- package/src/load-schema-module.ts +52 -0
- package/src/parse-cli-args.ts +31 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads a schema authored as a JS/TS module and returns the exported schema value.
|
|
3
|
+
*
|
|
4
|
+
* Export selection, in order of preference:
|
|
5
|
+
* 1. The export named by `exportName`, when provided (`--export <name>`).
|
|
6
|
+
* 2. The module's default export.
|
|
7
|
+
* 3. The sole named export, when there is exactly one.
|
|
8
|
+
*
|
|
9
|
+
* Anything else is ambiguous and throws, so the user can disambiguate explicitly
|
|
10
|
+
* rather than have mjst guess wrong.
|
|
11
|
+
*
|
|
12
|
+
* Importing a `.ts` module requires a TypeScript-capable runtime (Bun, or Node
|
|
13
|
+
* with a loader such as `tsx`). We surface a friendly hint when the import fails
|
|
14
|
+
* for a `.ts` path so the cause is not a cryptic module-resolution error.
|
|
15
|
+
*/
|
|
16
|
+
export declare const loadSchemaModule: (modulePath: string, exportName?: string) => Promise<unknown>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/mjst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
"generate-readme": "bun run ./scripts/generate-readme.ts"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@amritk/
|
|
46
|
-
"@amritk/generate-
|
|
47
|
-
"@amritk/
|
|
45
|
+
"@amritk/adapters": "0.2.1",
|
|
46
|
+
"@amritk/generate-parsers": "0.4.0",
|
|
47
|
+
"@amritk/generate-markdown": "0.2.1",
|
|
48
|
+
"@amritk/helpers": "0.4.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"json-schema-typed": "
|
|
51
|
+
"json-schema-typed": "^8.0.1"
|
|
51
52
|
}
|
|
52
53
|
}
|
package/src/cli-config.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import type { SourceFormat } from '@amritk/adapters/source-format'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Configuration for the CLI tool.
|
|
3
5
|
* These properties map 1:1 with CLI flags and config file keys.
|
|
4
6
|
*/
|
|
5
7
|
export type CliConfig = {
|
|
6
|
-
/** Path to the JSON Schema file
|
|
8
|
+
/** Path to the schema to process — a JSON Schema file, or a JS/TS module when `input` is set. */
|
|
7
9
|
readonly schema: string
|
|
10
|
+
/**
|
|
11
|
+
* The source format of the schema. Defaults to `'json'` (a plain JSON Schema file).
|
|
12
|
+
* Any other format loads `schema` as a module and converts it via the matching adapter.
|
|
13
|
+
*/
|
|
14
|
+
readonly input?: SourceFormat
|
|
15
|
+
/**
|
|
16
|
+
* Which export of the schema module to use when `input` is not `'json'`.
|
|
17
|
+
* Defaults to the default export, or the sole named export when there is exactly one.
|
|
18
|
+
*/
|
|
19
|
+
readonly export?: string
|
|
8
20
|
/** Output directory for generated TypeScript files. */
|
|
9
21
|
readonly outDir: string
|
|
10
22
|
/**
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
|
|
|
3
3
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join, resolve } from 'node:path'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
|
+
import { getAdapter } from '@amritk/adapters/get-adapter'
|
|
6
7
|
import { buildSchema } from '@amritk/generate-parsers'
|
|
7
8
|
import { deriveRootTypeName } from '@amritk/helpers/derive-root-type-name'
|
|
8
9
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
@@ -11,6 +12,7 @@ const execFileAsync = promisify(execFile)
|
|
|
11
12
|
|
|
12
13
|
import { detectHelpersMode } from './detect-helpers-mode'
|
|
13
14
|
import { loadConfig } from './load-config'
|
|
15
|
+
import { loadSchemaModule } from './load-schema-module'
|
|
14
16
|
import { parseCliArgs } from './parse-cli-args'
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -55,8 +57,18 @@ const run = async (): Promise<void> => {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
const schemaPath = resolve(config.schema)
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
+
const inputFormat = config.input ?? 'json'
|
|
61
|
+
|
|
62
|
+
// 'json' is read straight off disk; every other format loads the schema as a
|
|
63
|
+
// module and runs it through its adapter to produce a JSON Schema.
|
|
64
|
+
let schema: unknown
|
|
65
|
+
if (inputFormat === 'json') {
|
|
66
|
+
schema = JSON.parse(await readFile(schemaPath, 'utf-8'))
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`Input format: ${inputFormat}`)
|
|
69
|
+
const source = await loadSchemaModule(schemaPath, config.export)
|
|
70
|
+
schema = await getAdapter(inputFormat).toJSONSchema(source)
|
|
71
|
+
}
|
|
60
72
|
|
|
61
73
|
const outputDir = resolve(config.outDir)
|
|
62
74
|
await mkdir(outputDir, { recursive: true })
|
package/src/load-config.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
|
+
import type { SourceFormat } from '@amritk/adapters/source-format'
|
|
3
4
|
|
|
4
5
|
import type { CliConfig } from './cli-config'
|
|
5
6
|
|
|
7
|
+
const SOURCE_FORMATS: readonly string[] = ['json', 'typebox', 'zod', 'valibot', 'effect']
|
|
8
|
+
|
|
9
|
+
const isSourceFormat = (value: unknown): value is SourceFormat =>
|
|
10
|
+
typeof value === 'string' && SOURCE_FORMATS.includes(value)
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* Loads a JSON config file and returns the relevant CLI config properties.
|
|
8
14
|
* The config file should have the same keys as the CLI flags (schema, outDir).
|
|
@@ -21,6 +27,8 @@ export const loadConfig = async (configPath: string): Promise<Partial<CliConfig>
|
|
|
21
27
|
return {
|
|
22
28
|
...(typeof obj['schema'] === 'string' && { schema: obj['schema'] }),
|
|
23
29
|
...(typeof obj['outDir'] === 'string' && { outDir: obj['outDir'] }),
|
|
30
|
+
...(isSourceFormat(obj['input']) && { input: obj['input'] }),
|
|
31
|
+
...(typeof obj['export'] === 'string' && { export: obj['export'] }),
|
|
24
32
|
...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
|
|
25
33
|
...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
|
|
26
34
|
...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { loadSchemaModule } from './load-schema-module'
|
|
7
|
+
|
|
8
|
+
describe('loadSchemaModule', () => {
|
|
9
|
+
let dir: string
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
dir = await mkdtemp(join(tmpdir(), 'mjst-load-module-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await rm(dir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const write = async (name: string, contents: string): Promise<string> => {
|
|
20
|
+
const path = join(dir, name)
|
|
21
|
+
await writeFile(path, contents, 'utf-8')
|
|
22
|
+
return path
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('returns the default export when no export name is given', async () => {
|
|
26
|
+
const path = await write('default.mjs', 'export default { type: "string" }')
|
|
27
|
+
expect(await loadSchemaModule(path)).toEqual({ type: 'string' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns the sole named export when there is no default', async () => {
|
|
31
|
+
const path = await write('single.mjs', 'export const Schema = { type: "number" }')
|
|
32
|
+
expect(await loadSchemaModule(path)).toEqual({ type: 'number' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns the requested named export', async () => {
|
|
36
|
+
const path = await write('named.mjs', 'export const A = { type: "boolean" }; export const B = { type: "string" }')
|
|
37
|
+
expect(await loadSchemaModule(path, 'B')).toEqual({ type: 'string' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('throws when the requested export is missing', async () => {
|
|
41
|
+
const path = await write('missing.mjs', 'export const A = { type: "boolean" }')
|
|
42
|
+
await expect(loadSchemaModule(path, 'Nope')).rejects.toThrow(/no export named 'Nope'/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('throws when multiple exports are ambiguous', async () => {
|
|
46
|
+
const path = await write('ambiguous.mjs', 'export const A = {}; export const B = {}')
|
|
47
|
+
await expect(loadSchemaModule(path)).rejects.toThrow(/Specify which one to use with --export/)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads a schema authored as a JS/TS module and returns the exported schema value.
|
|
5
|
+
*
|
|
6
|
+
* Export selection, in order of preference:
|
|
7
|
+
* 1. The export named by `exportName`, when provided (`--export <name>`).
|
|
8
|
+
* 2. The module's default export.
|
|
9
|
+
* 3. The sole named export, when there is exactly one.
|
|
10
|
+
*
|
|
11
|
+
* Anything else is ambiguous and throws, so the user can disambiguate explicitly
|
|
12
|
+
* rather than have mjst guess wrong.
|
|
13
|
+
*
|
|
14
|
+
* Importing a `.ts` module requires a TypeScript-capable runtime (Bun, or Node
|
|
15
|
+
* with a loader such as `tsx`). We surface a friendly hint when the import fails
|
|
16
|
+
* for a `.ts` path so the cause is not a cryptic module-resolution error.
|
|
17
|
+
*/
|
|
18
|
+
export const loadSchemaModule = async (modulePath: string, exportName?: string): Promise<unknown> => {
|
|
19
|
+
let mod: Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
mod = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const hint = modulePath.endsWith('.ts')
|
|
25
|
+
? ' Importing a .ts schema requires a TypeScript-capable runtime — run mjst via `bunx`, or install `tsx` and run Node with `--import tsx`.'
|
|
26
|
+
: ''
|
|
27
|
+
throw new Error(`Failed to load schema module at ${modulePath}.${hint}\n${String(error)}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (exportName) {
|
|
31
|
+
if (!(exportName in mod)) {
|
|
32
|
+
throw new Error(`Schema module ${modulePath} has no export named '${exportName}'.`)
|
|
33
|
+
}
|
|
34
|
+
return mod[exportName]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ('default' in mod && mod['default'] !== undefined) {
|
|
38
|
+
return mod['default']
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const namedKeys = Object.keys(mod).filter((key) => key !== 'default')
|
|
42
|
+
|
|
43
|
+
if (namedKeys.length === 1) {
|
|
44
|
+
const key = namedKeys[0]
|
|
45
|
+
if (key) return mod[key]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Schema module ${modulePath} exports ${namedKeys.length} values (${namedKeys.join(', ') || 'none'}). ` +
|
|
50
|
+
`Specify which one to use with --export <name>.`,
|
|
51
|
+
)
|
|
52
|
+
}
|
package/src/parse-cli-args.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { SourceFormat } from '@amritk/adapters/source-format'
|
|
2
|
+
|
|
1
3
|
import type { CliConfig } from './cli-config'
|
|
2
4
|
|
|
5
|
+
const SOURCE_FORMATS: readonly SourceFormat[] = ['json', 'typebox', 'zod', 'valibot', 'effect']
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Parses command-line arguments into a partial CLI config.
|
|
5
9
|
* Supports --schema, --outDir, and --types-only flags with either `--flag value` or `--flag=value` syntax.
|
|
@@ -10,6 +14,8 @@ export const parseCliArgs = (args: readonly string[]): Partial<CliConfig> => {
|
|
|
10
14
|
const config: {
|
|
11
15
|
schema?: string
|
|
12
16
|
outDir?: string
|
|
17
|
+
input?: SourceFormat
|
|
18
|
+
export?: string
|
|
13
19
|
typesOnly?: boolean
|
|
14
20
|
build?: boolean
|
|
15
21
|
logWarnings?: boolean
|
|
@@ -22,6 +28,9 @@ export const parseCliArgs = (args: readonly string[]): Partial<CliConfig> => {
|
|
|
22
28
|
return undefined
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
const parseInputValue = (value: string): SourceFormat | undefined =>
|
|
32
|
+
(SOURCE_FORMATS as readonly string[]).includes(value) ? (value as SourceFormat) : undefined
|
|
33
|
+
|
|
25
34
|
for (let i = 0; i < args.length; i++) {
|
|
26
35
|
const arg = args[i]
|
|
27
36
|
|
|
@@ -39,6 +48,11 @@ export const parseCliArgs = (args: readonly string[]): Partial<CliConfig> => {
|
|
|
39
48
|
config.schema = value
|
|
40
49
|
} else if (key === 'outDir') {
|
|
41
50
|
config.outDir = value
|
|
51
|
+
} else if (key === 'input') {
|
|
52
|
+
const parsed = parseInputValue(value)
|
|
53
|
+
if (parsed) config.input = parsed
|
|
54
|
+
} else if (key === 'export') {
|
|
55
|
+
config.export = value
|
|
42
56
|
} else if (key === 'types-only') {
|
|
43
57
|
// Accept --types-only=false to explicitly opt out, otherwise treat as true
|
|
44
58
|
config.typesOnly = value !== 'false'
|
|
@@ -71,6 +85,23 @@ export const parseCliArgs = (args: readonly string[]): Partial<CliConfig> => {
|
|
|
71
85
|
config.outDir = value
|
|
72
86
|
i++
|
|
73
87
|
}
|
|
88
|
+
} else if (arg === '--input') {
|
|
89
|
+
const value = args[i + 1]
|
|
90
|
+
|
|
91
|
+
if (value && !value.startsWith('--')) {
|
|
92
|
+
const parsed = parseInputValue(value)
|
|
93
|
+
if (parsed) {
|
|
94
|
+
config.input = parsed
|
|
95
|
+
i++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} else if (arg === '--export') {
|
|
99
|
+
const value = args[i + 1]
|
|
100
|
+
|
|
101
|
+
if (value && !value.startsWith('--')) {
|
|
102
|
+
config.export = value
|
|
103
|
+
i++
|
|
104
|
+
}
|
|
74
105
|
} else if (arg === '--types-only') {
|
|
75
106
|
// Boolean flag: presence alone enables it, no value needed
|
|
76
107
|
config.typesOnly = true
|