@amritk/mjst 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Detects whether `@amritk/helpers` is resolvable from the consumer's output
3
+ * directory using standard Node module resolution (walks up `node_modules`).
4
+ *
5
+ * Returns `'package'` when generated parsers can safely
6
+ * `import { ... } from '@amritk/helpers/...'` at runtime, or `'embedded'`
7
+ * when the helper sources need to be shipped alongside the generated files
8
+ * for the output to be self-contained.
9
+ */
10
+ export declare const detectHelpersMode: (outputDir: string) => "package" | "embedded";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,8 +27,12 @@
27
27
  "mjst": "./dist/cli.js"
28
28
  },
29
29
  "files": [
30
- "dist"
30
+ "dist",
31
+ "src"
31
32
  ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
32
36
  "scripts": {
33
37
  "dev": "bun run --conditions=development ./src/cli.ts",
34
38
  "start": "bun run ./src/cli.ts",
@@ -39,7 +43,8 @@
39
43
  },
40
44
  "dependencies": {
41
45
  "@amritk/generate-parsers": "workspace:*",
42
- "@amritk/generate-markdown": "workspace:*"
46
+ "@amritk/generate-markdown": "workspace:*",
47
+ "@amritk/helpers": "workspace:*"
43
48
  },
44
49
  "devDependencies": {
45
50
  "json-schema-typed": "catalog:"
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Configuration for the CLI tool.
3
+ * These properties map 1:1 with CLI flags and config file keys.
4
+ */
5
+ export type CliConfig = {
6
+ /** Path to the JSON Schema file to process. */
7
+ readonly schema: string
8
+ /** Output directory for generated TypeScript files. */
9
+ readonly outDir: string
10
+ /**
11
+ * When true, only generate TypeScript type definitions without parser functions.
12
+ * Useful when you only need the type shapes and do not need runtime validation.
13
+ */
14
+ readonly typesOnly?: boolean
15
+ /**
16
+ * When true, compile the generated TypeScript files to .js and .d.ts output.
17
+ * The .ts source files are removed after compilation.
18
+ */
19
+ readonly build?: boolean
20
+ /**
21
+ * When true, the generated parsers emit a console.warn for every input key
22
+ * that is not declared in the schema's properties.
23
+ */
24
+ readonly logWarnings?: boolean
25
+ /**
26
+ * When true, the generated parsers throw on type/shape mismatches
27
+ * (wrong type, missing required property, enum/pattern/min/max violations)
28
+ * instead of coercing invalid input to default values.
29
+ */
30
+ readonly strict?: boolean
31
+ /**
32
+ * Controls how generated parsers reference their runtime helpers.
33
+ * - `'package'`: emit `import ... from '@amritk/helpers/...'`.
34
+ * - `'embedded'`: ship the helper source under `outDir/_helpers/` and emit
35
+ * relative imports so the output directory is self-contained.
36
+ *
37
+ * When omitted, the CLI auto-detects: it picks `'package'` if `@amritk/helpers`
38
+ * resolves from outDir, otherwise falls back to `'embedded'`.
39
+ */
40
+ readonly helpers?: 'package' | 'embedded'
41
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from 'node:child_process'
3
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+ import { buildSchema } from '@amritk/generate-parsers'
7
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ import { detectHelpersMode } from './detect-helpers-mode'
12
+ import { loadConfig } from './load-config'
13
+ import { parseCliArgs } from './parse-cli-args'
14
+
15
+ /**
16
+ * Extracts the --config flag value from process args before full parsing.
17
+ * We need this early so we can load the config file first, then overlay CLI flags.
18
+ */
19
+ const extractConfigPath = (args: readonly string[]): string | undefined => {
20
+ for (let i = 0; i < args.length; i++) {
21
+ const arg = args[i]
22
+
23
+ if (arg === '--config' && args[i + 1] && !args[i + 1]?.startsWith('--')) {
24
+ return args[i + 1]
25
+ }
26
+
27
+ if (arg?.startsWith('--config=')) {
28
+ return arg.slice('--config='.length)
29
+ }
30
+ }
31
+
32
+ return undefined
33
+ }
34
+
35
+ const run = async (): Promise<void> => {
36
+ // Skip the first two args (node executable and script path)
37
+ const args = process.argv.slice(2)
38
+
39
+ const configPath = extractConfigPath(args)
40
+
41
+ // Start with config file values if provided, then overlay CLI flags on top
42
+ const fileConfig = configPath ? await loadConfig(configPath) : {}
43
+ const cliConfig = parseCliArgs(args)
44
+ const config = { ...fileConfig, ...cliConfig }
45
+
46
+ if (!config.schema) {
47
+ console.error('Error: --schema is required. Provide a path to a JSON Schema file.')
48
+ process.exit(1)
49
+ }
50
+
51
+ if (!config.outDir) {
52
+ console.error('Error: --outDir is required. Provide an output directory for generated files.')
53
+ process.exit(1)
54
+ }
55
+
56
+ const schemaPath = resolve(config.schema)
57
+ const raw = await readFile(schemaPath, 'utf-8')
58
+ const schema: unknown = JSON.parse(raw)
59
+
60
+ const outputDir = resolve(config.outDir)
61
+ await mkdir(outputDir, { recursive: true })
62
+
63
+ const helpersMode = config.helpers ?? detectHelpersMode(outputDir)
64
+ console.log(`Helpers mode: ${helpersMode}${config.helpers ? ' (explicit)' : ' (auto-detected)'}`)
65
+
66
+ const files = await buildSchema(
67
+ schema as JSONSchema,
68
+ 'Document',
69
+ undefined,
70
+ config.typesOnly,
71
+ config.logWarnings,
72
+ config.strict,
73
+ helpersMode,
74
+ )
75
+
76
+ for (const file of files) {
77
+ const filePath = join(outputDir, file.filename)
78
+ // Create subdirectories if the filename contains a path
79
+ await mkdir(dirname(filePath), { recursive: true })
80
+ await writeFile(filePath, file.content, 'utf-8')
81
+ console.log(`Generated: ${file.filename}`)
82
+ }
83
+
84
+ if (config.build) {
85
+ // Write a minimal tsconfig so tsc can compile the generated files without
86
+ // inheriting settings like allowImportingTsExtensions that block emission.
87
+ // In 'package' mode tsc finds @amritk/helpers via the consumer's node_modules.
88
+ // In 'embedded' mode the helpers live under ./_helpers and need no resolution.
89
+ const tsconfigContent = JSON.stringify(
90
+ {
91
+ compilerOptions: {
92
+ target: 'ESNext',
93
+ module: 'ESNext',
94
+ moduleResolution: 'bundler',
95
+ declaration: true,
96
+ emitDeclarationOnly: config.typesOnly,
97
+ skipLibCheck: true,
98
+ },
99
+ include: ['./**/*.ts'],
100
+ },
101
+ null,
102
+ 2,
103
+ )
104
+
105
+ const tsconfigPath = join(outputDir, '.tsconfig-mjst-build.json')
106
+ await writeFile(tsconfigPath, tsconfigContent, 'utf-8')
107
+
108
+ try {
109
+ await execFileAsync('npx', ['tsc', '--project', tsconfigPath])
110
+ } catch {
111
+ throw new Error('TypeScript compilation failed. Check the generated files for errors.')
112
+ } finally {
113
+ await unlink(tsconfigPath)
114
+ }
115
+
116
+ // Remove the intermediate .ts files now that .js and .d.ts have been produced
117
+ for (const file of files) {
118
+ await unlink(join(outputDir, file.filename))
119
+ const jsFilename = file.filename.replace(/\.ts$/, '.js')
120
+ const dtsFilename = file.filename.replace(/\.ts$/, '.d.ts')
121
+ console.log(`Built: ${jsFilename}`)
122
+ console.log(`Built: ${dtsFilename}`)
123
+ }
124
+
125
+ console.log(`\nTotal files built: ${files.length * 2}`)
126
+ } else {
127
+ console.log(`\nTotal files generated: ${files.length}`)
128
+ }
129
+ }
130
+
131
+ run().catch((error: unknown) => {
132
+ console.error(error instanceof Error ? error.message : error)
133
+ process.exit(1)
134
+ })
@@ -0,0 +1,45 @@
1
+ import { mkdir, 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 { detectHelpersMode } from './detect-helpers-mode'
7
+
8
+ describe('detect-helpers-mode', () => {
9
+ let tmpRoot: string
10
+
11
+ beforeAll(async () => {
12
+ tmpRoot = await mkdtemp(join(tmpdir(), 'mjst-detect-'))
13
+ })
14
+
15
+ afterAll(async () => {
16
+ await rm(tmpRoot, { recursive: true, force: true })
17
+ })
18
+
19
+ it("returns 'package' when @amritk/helpers is resolvable from outDir", async () => {
20
+ const outDir = join(tmpRoot, 'with-helpers/out')
21
+ const helpersPkgDir = join(tmpRoot, 'with-helpers/node_modules/@amritk/helpers')
22
+ await mkdir(outDir, { recursive: true })
23
+ await mkdir(helpersPkgDir, { recursive: true })
24
+ await writeFile(join(helpersPkgDir, 'is-object.js'), 'export const isObject = () => true\n', 'utf-8')
25
+ await writeFile(
26
+ join(helpersPkgDir, 'package.json'),
27
+ JSON.stringify({
28
+ name: '@amritk/helpers',
29
+ version: '0.0.0',
30
+ type: 'module',
31
+ exports: { './is-object': './is-object.js' },
32
+ }),
33
+ 'utf-8',
34
+ )
35
+
36
+ expect(detectHelpersMode(outDir)).toBe('package')
37
+ })
38
+
39
+ it("returns 'embedded' when @amritk/helpers cannot be resolved from outDir", async () => {
40
+ const outDir = join(tmpRoot, 'no-helpers/out')
41
+ await mkdir(outDir, { recursive: true })
42
+
43
+ expect(detectHelpersMode(outDir)).toBe('embedded')
44
+ })
45
+ })
@@ -0,0 +1,21 @@
1
+ import { createRequire } from 'node:module'
2
+ import { resolve } from 'node:path'
3
+
4
+ /**
5
+ * Detects whether `@amritk/helpers` is resolvable from the consumer's output
6
+ * directory using standard Node module resolution (walks up `node_modules`).
7
+ *
8
+ * Returns `'package'` when generated parsers can safely
9
+ * `import { ... } from '@amritk/helpers/...'` at runtime, or `'embedded'`
10
+ * when the helper sources need to be shipped alongside the generated files
11
+ * for the output to be self-contained.
12
+ */
13
+ export const detectHelpersMode = (outputDir: string): 'package' | 'embedded' => {
14
+ const req = createRequire(resolve(outputDir, 'noop.js'))
15
+ try {
16
+ req.resolve('@amritk/helpers/is-object')
17
+ return 'package'
18
+ } catch {
19
+ return 'embedded'
20
+ }
21
+ }
@@ -0,0 +1,69 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { loadConfig } from './load-config'
7
+
8
+ describe('load-config', () => {
9
+ it('loads schema and outDir from a JSON config file', async () => {
10
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
11
+ await writeFile(configPath, JSON.stringify({ schema: 'my-schema.json', outDir: 'output' }))
12
+
13
+ const result = await loadConfig(configPath)
14
+
15
+ expect(result).toEqual({
16
+ schema: 'my-schema.json',
17
+ outDir: 'output',
18
+ })
19
+ })
20
+
21
+ it('returns partial config when only some keys are present', async () => {
22
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
23
+ await writeFile(configPath, JSON.stringify({ schema: 'my-schema.json' }))
24
+
25
+ const result = await loadConfig(configPath)
26
+
27
+ expect(result).toEqual({
28
+ schema: 'my-schema.json',
29
+ })
30
+ })
31
+
32
+ it('ignores non-string values for known keys', async () => {
33
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
34
+ await writeFile(configPath, JSON.stringify({ schema: 123, outDir: true }))
35
+
36
+ const result = await loadConfig(configPath)
37
+
38
+ expect(result).toEqual({})
39
+ })
40
+
41
+ it('throws for non-object config files', async () => {
42
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
43
+ await writeFile(configPath, JSON.stringify([1, 2, 3]))
44
+
45
+ await expect(loadConfig(configPath)).rejects.toThrow('Config file must be a JSON object')
46
+ })
47
+
48
+ it('throws for missing config files', async () => {
49
+ await expect(loadConfig('/nonexistent/config.json')).rejects.toThrow()
50
+ })
51
+
52
+ it('loads strict boolean from config file', async () => {
53
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
54
+ await writeFile(configPath, JSON.stringify({ schema: 's.json', outDir: 'o', strict: true }))
55
+
56
+ const result = await loadConfig(configPath)
57
+
58
+ expect(result).toEqual({ schema: 's.json', outDir: 'o', strict: true })
59
+ })
60
+
61
+ it('ignores non-boolean strict value', async () => {
62
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
63
+ await writeFile(configPath, JSON.stringify({ schema: 's.json', strict: 'yes' }))
64
+
65
+ const result = await loadConfig(configPath)
66
+
67
+ expect(result).toEqual({ schema: 's.json' })
68
+ })
69
+ })
@@ -0,0 +1,29 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+
4
+ import type { CliConfig } from './cli-config'
5
+
6
+ /**
7
+ * Loads a JSON config file and returns the relevant CLI config properties.
8
+ * The config file should have the same keys as the CLI flags (schema, outDir).
9
+ */
10
+ export const loadConfig = async (configPath: string): Promise<Partial<CliConfig>> => {
11
+ const absolutePath = resolve(configPath)
12
+ const raw = await readFile(absolutePath, 'utf-8')
13
+ const parsed: unknown = JSON.parse(raw)
14
+
15
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
16
+ throw new Error(`Config file must be a JSON object: ${configPath}`)
17
+ }
18
+
19
+ const obj = parsed as Record<string, unknown>
20
+
21
+ return {
22
+ ...(typeof obj['schema'] === 'string' && { schema: obj['schema'] }),
23
+ ...(typeof obj['outDir'] === 'string' && { outDir: obj['outDir'] }),
24
+ ...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
25
+ ...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
26
+ ...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
27
+ ...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
28
+ }
29
+ }
@@ -0,0 +1,226 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { parseCliArgs } from './parse-cli-args'
4
+
5
+ describe('parse-cli-args', () => {
6
+ it('parses --schema and --outDir with space-separated values', () => {
7
+ const result = parseCliArgs(['--schema', 'path/to/schema.json', '--outDir', 'dist'])
8
+
9
+ expect(result).toEqual({
10
+ schema: 'path/to/schema.json',
11
+ outDir: 'dist',
12
+ })
13
+ })
14
+
15
+ it('parses --schema and --outDir with equals syntax', () => {
16
+ const result = parseCliArgs(['--schema=path/to/schema.json', '--outDir=dist'])
17
+
18
+ expect(result).toEqual({
19
+ schema: 'path/to/schema.json',
20
+ outDir: 'dist',
21
+ })
22
+ })
23
+
24
+ it('returns empty object when no flags are provided', () => {
25
+ const result = parseCliArgs([])
26
+
27
+ expect(result).toEqual({})
28
+ })
29
+
30
+ it('ignores unknown flags', () => {
31
+ const result = parseCliArgs(['--unknown', 'value', '--schema', 'schema.json'])
32
+
33
+ expect(result).toEqual({
34
+ schema: 'schema.json',
35
+ })
36
+ })
37
+
38
+ it('handles only --schema without --outDir', () => {
39
+ const result = parseCliArgs(['--schema', 'schema.json'])
40
+
41
+ expect(result).toEqual({
42
+ schema: 'schema.json',
43
+ })
44
+ })
45
+
46
+ it('handles only --outDir without --schema', () => {
47
+ const result = parseCliArgs(['--outDir', 'output'])
48
+
49
+ expect(result).toEqual({
50
+ outDir: 'output',
51
+ })
52
+ })
53
+
54
+ it('does not treat a flag as a value for the previous flag', () => {
55
+ const result = parseCliArgs(['--schema', '--outDir', 'output'])
56
+
57
+ expect(result).toEqual({
58
+ outDir: 'output',
59
+ })
60
+ })
61
+
62
+ it('handles equals syntax with empty value', () => {
63
+ const result = parseCliArgs(['--schema=', '--outDir=dist'])
64
+
65
+ expect(result).toEqual({
66
+ schema: '',
67
+ outDir: 'dist',
68
+ })
69
+ })
70
+
71
+ it('parses --types-only boolean flag', () => {
72
+ const result = parseCliArgs(['--types-only'])
73
+
74
+ expect(result).toEqual({
75
+ typesOnly: true,
76
+ })
77
+ })
78
+
79
+ it('parses --types-only=true with equals syntax', () => {
80
+ const result = parseCliArgs(['--types-only=true'])
81
+
82
+ expect(result).toEqual({
83
+ typesOnly: true,
84
+ })
85
+ })
86
+
87
+ it('parses --types-only=false with equals syntax', () => {
88
+ const result = parseCliArgs(['--types-only=false'])
89
+
90
+ expect(result).toEqual({
91
+ typesOnly: false,
92
+ })
93
+ })
94
+
95
+ it('parses --types-only alongside other flags', () => {
96
+ const result = parseCliArgs(['--schema', 'schema.json', '--outDir', 'dist', '--types-only'])
97
+
98
+ expect(result).toEqual({
99
+ schema: 'schema.json',
100
+ outDir: 'dist',
101
+ typesOnly: true,
102
+ })
103
+ })
104
+
105
+ it('parses --build boolean flag', () => {
106
+ const result = parseCliArgs(['--build'])
107
+
108
+ expect(result).toEqual({
109
+ build: true,
110
+ })
111
+ })
112
+
113
+ it('parses --build=true with equals syntax', () => {
114
+ const result = parseCliArgs(['--build=true'])
115
+
116
+ expect(result).toEqual({
117
+ build: true,
118
+ })
119
+ })
120
+
121
+ it('parses --build=false with equals syntax', () => {
122
+ const result = parseCliArgs(['--build=false'])
123
+
124
+ expect(result).toEqual({
125
+ build: false,
126
+ })
127
+ })
128
+
129
+ it('parses --build alongside other flags', () => {
130
+ const result = parseCliArgs(['--schema', 'schema.json', '--outDir', 'dist', '--build'])
131
+
132
+ expect(result).toEqual({
133
+ schema: 'schema.json',
134
+ outDir: 'dist',
135
+ build: true,
136
+ })
137
+ })
138
+
139
+ it('parses --log-warnings boolean flag', () => {
140
+ const result = parseCliArgs(['--log-warnings'])
141
+
142
+ expect(result).toEqual({
143
+ logWarnings: true,
144
+ })
145
+ })
146
+
147
+ it('parses --log-warnings=true with equals syntax', () => {
148
+ const result = parseCliArgs(['--log-warnings=true'])
149
+
150
+ expect(result).toEqual({
151
+ logWarnings: true,
152
+ })
153
+ })
154
+
155
+ it('parses --log-warnings=false with equals syntax', () => {
156
+ const result = parseCliArgs(['--log-warnings=false'])
157
+
158
+ expect(result).toEqual({
159
+ logWarnings: false,
160
+ })
161
+ })
162
+
163
+ it('parses --log-warnings alongside other flags', () => {
164
+ const result = parseCliArgs(['--schema', 'schema.json', '--outDir', 'dist', '--log-warnings'])
165
+
166
+ expect(result).toEqual({
167
+ schema: 'schema.json',
168
+ outDir: 'dist',
169
+ logWarnings: true,
170
+ })
171
+ })
172
+
173
+ it('parses --strict boolean flag', () => {
174
+ const result = parseCliArgs(['--strict'])
175
+
176
+ expect(result).toEqual({
177
+ strict: true,
178
+ })
179
+ })
180
+
181
+ it('parses --strict=true with equals syntax', () => {
182
+ const result = parseCliArgs(['--strict=true'])
183
+
184
+ expect(result).toEqual({
185
+ strict: true,
186
+ })
187
+ })
188
+
189
+ it('parses --strict=false with equals syntax', () => {
190
+ const result = parseCliArgs(['--strict=false'])
191
+
192
+ expect(result).toEqual({
193
+ strict: false,
194
+ })
195
+ })
196
+
197
+ it('parses --strict alongside other flags', () => {
198
+ const result = parseCliArgs(['--schema', 'schema.json', '--outDir', 'dist', '--strict'])
199
+
200
+ expect(result).toEqual({
201
+ schema: 'schema.json',
202
+ outDir: 'dist',
203
+ strict: true,
204
+ })
205
+ })
206
+
207
+ it('parses --helpers package with space-separated value', () => {
208
+ const result = parseCliArgs(['--helpers', 'package'])
209
+ expect(result).toEqual({ helpers: 'package' })
210
+ })
211
+
212
+ it('parses --helpers=embedded with equals syntax', () => {
213
+ const result = parseCliArgs(['--helpers=embedded'])
214
+ expect(result).toEqual({ helpers: 'embedded' })
215
+ })
216
+
217
+ it('ignores invalid --helpers values', () => {
218
+ const result = parseCliArgs(['--helpers', 'bogus', '--schema', 's.json'])
219
+ expect(result).toEqual({ schema: 's.json' })
220
+ })
221
+
222
+ it('ignores invalid --helpers=foo values', () => {
223
+ const result = parseCliArgs(['--helpers=foo', '--schema', 's.json'])
224
+ expect(result).toEqual({ schema: 's.json' })
225
+ })
226
+ })