@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.
@@ -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.4.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/generate-parsers": "workspace:*",
46
- "@amritk/generate-markdown": "workspace:*",
47
- "@amritk/helpers": "workspace:*"
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": "catalog:"
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 to process. */
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 raw = await readFile(schemaPath, 'utf-8')
59
- const schema: unknown = JSON.parse(raw)
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 })
@@ -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
+ }
@@ -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