@amritk/mjst 0.5.0 → 0.6.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,13 @@
1
+ import type { GeneratedFile } from '@amritk/generate-parsers';
2
+ /**
3
+ * Merges the per-schema {@link GeneratedFile}s into a single self-contained
4
+ * TypeScript module. Each generated file imports its sibling definitions via
5
+ * relative paths; since everything now lives in one file those imports are
6
+ * dropped, while any external (package) imports are hoisted to the top and
7
+ * deduplicated. The `index.ts` barrel is skipped because it only re-exports the
8
+ * declarations that are already inlined here.
9
+ *
10
+ * Intended for types-only output, where files contain only type definitions and
11
+ * type-only relative imports (no runtime helpers).
12
+ */
13
+ export declare const combineGeneratedFiles: (files: readonly GeneratedFile[]) => string;
@@ -1,7 +1,9 @@
1
1
  import type { CliConfig } from './cli-config';
2
2
  /**
3
3
  * Parses command-line arguments into a partial CLI config.
4
- * Supports --schema, --outDir, and --types-only flags with either `--flag value` or `--flag=value` syntax.
5
- * Only returns the flags that were explicitly provided so we can layer them on top of config file values.
4
+ * Every flag accepts both kebab-case and camelCase (e.g. `--out-dir` and `--outDir`)
5
+ * and supports either `--flag value` or `--flag=value` syntax. Boolean flags toggle
6
+ * on by presence and accept `--flag=false` to opt out. Only flags that were explicitly
7
+ * provided are returned so they can be layered on top of config file values.
6
8
  */
7
9
  export declare const parseCliArgs: (args: readonly string[]) => Partial<CliConfig>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,10 +42,10 @@
42
42
  "generate-readme": "bun run ./scripts/generate-readme.ts"
43
43
  },
44
44
  "dependencies": {
45
- "@amritk/adapters": "0.2.0",
46
- "@amritk/generate-parsers": "0.4.0",
45
+ "@amritk/adapters": "0.2.2",
46
+ "@amritk/generate-parsers": "0.5.0",
47
47
  "@amritk/generate-markdown": "0.2.1",
48
- "@amritk/helpers": "0.4.0"
48
+ "@amritk/helpers": "0.5.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "json-schema-typed": "^8.0.1"
package/src/cli-config.ts CHANGED
@@ -6,7 +6,15 @@ import type { SourceFormat } from '@amritk/adapters/source-format'
6
6
  */
7
7
  export type CliConfig = {
8
8
  /** Path to the schema to process — a JSON Schema file, or a JS/TS module when `input` is set. */
9
- readonly schema: string
9
+ readonly schema?: string
10
+ /**
11
+ * Path to a directory of JSON Schema files. When set, the CLI walks the directory
12
+ * recursively, generates parsers for every `*.json` schema it finds, and mirrors the
13
+ * directory layout under `outDir`. A single shared `_helpers/` directory is emitted at
14
+ * the root of `outDir` (in embedded mode) and every nested parser imports from it.
15
+ * Mutually exclusive with `schema`; when both are present `schemaDir` wins.
16
+ */
17
+ readonly schemaDir?: string
10
18
  /**
11
19
  * The source format of the schema. Defaults to `'json'` (a plain JSON Schema file).
12
20
  * Any other format loads `schema` as a module and converts it via the matching adapter.
@@ -17,8 +25,13 @@ export type CliConfig = {
17
25
  * Defaults to the default export, or the sole named export when there is exactly one.
18
26
  */
19
27
  readonly export?: string
20
- /** Output directory for generated TypeScript files. */
21
- readonly outDir: string
28
+ /** Output directory for generated TypeScript files. Mutually exclusive with `outFile`. */
29
+ readonly outDir?: string
30
+ /**
31
+ * Output everything to a single TypeScript file instead of a directory of files.
32
+ * Mutually exclusive with `outDir`. Currently supported only with `typesOnly`.
33
+ */
34
+ readonly outFile?: string
22
35
  /**
23
36
  * When true, only generate TypeScript type definitions without parser functions.
24
37
  * Useful when you only need the type shapes and do not need runtime validation.
@@ -50,4 +63,9 @@ export type CliConfig = {
50
63
  * resolves from outDir, otherwise falls back to `'embedded'`.
51
64
  */
52
65
  readonly helpers?: 'package' | 'embedded'
66
+ /**
67
+ * When true, every property, array, and record in the generated type
68
+ * definitions is emitted as `readonly`, producing deeply immutable types.
69
+ */
70
+ readonly readonly?: boolean
53
71
  }
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
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'
3
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
5
5
  import { promisify } from 'node:util'
6
6
  import { getAdapter } from '@amritk/adapters/get-adapter'
7
7
  import { buildSchema } from '@amritk/generate-parsers'
@@ -10,6 +10,8 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
10
10
 
11
11
  const execFileAsync = promisify(execFile)
12
12
 
13
+ import type { CliConfig } from './cli-config'
14
+ import { combineGeneratedFiles } from './combine-files'
13
15
  import { detectHelpersMode } from './detect-helpers-mode'
14
16
  import { loadConfig } from './load-config'
15
17
  import { loadSchemaModule } from './load-schema-module'
@@ -35,42 +37,93 @@ const extractConfigPath = (args: readonly string[]): string | undefined => {
35
37
  return undefined
36
38
  }
37
39
 
38
- const run = async (): Promise<void> => {
39
- // Skip the first two args (node executable and script path)
40
- const args = process.argv.slice(2)
40
+ /** Reads a JSON Schema off disk, or loads a module and converts it via its adapter. */
41
+ const loadSchema = async (config: Partial<CliConfig>, schemaPath: string): Promise<unknown> => {
42
+ const inputFormat = config.input ?? 'json'
41
43
 
42
- const configPath = extractConfigPath(args)
44
+ if (inputFormat === 'json') {
45
+ return JSON.parse(await readFile(schemaPath, 'utf-8'))
46
+ }
43
47
 
44
- // Start with config file values if provided, then overlay CLI flags on top
45
- const fileConfig = configPath ? await loadConfig(configPath) : {}
46
- const cliConfig = parseCliArgs(args)
47
- const config = { ...fileConfig, ...cliConfig }
48
+ console.log(`Input format: ${inputFormat}`)
49
+ const source = await loadSchemaModule(schemaPath, config.export)
50
+ return getAdapter(inputFormat).toJSONSchema(source)
51
+ }
48
52
 
49
- if (!config.schema) {
50
- console.error('Error: --schema is required. Provide a path to a JSON Schema file.')
51
- process.exit(1)
53
+ /** Writes a generated file, creating any parent directories the filename implies. */
54
+ const writeGeneratedFile = async (baseDir: string, filename: string, content: string): Promise<void> => {
55
+ const filePath = join(baseDir, filename)
56
+ await mkdir(dirname(filePath), { recursive: true })
57
+ await writeFile(filePath, content, 'utf-8')
58
+ }
59
+
60
+ /**
61
+ * Compiles the generated `.ts` files to `.js`/`.d.ts` with a temporary tsconfig,
62
+ * then removes the intermediate sources. `tsFiles` are paths relative to `outputDir`.
63
+ */
64
+ const buildOutput = async (outputDir: string, tsFiles: readonly string[], typesOnly?: boolean): Promise<void> => {
65
+ // Write a minimal tsconfig so tsc can compile the generated files without
66
+ // inheriting settings like allowImportingTsExtensions that block emission.
67
+ // In 'package' mode tsc finds @amritk/helpers via the consumer's node_modules.
68
+ // In 'embedded' mode the helpers live under ./_helpers and need no resolution.
69
+ const tsconfigContent = JSON.stringify(
70
+ {
71
+ compilerOptions: {
72
+ target: 'ESNext',
73
+ module: 'ESNext',
74
+ moduleResolution: 'bundler',
75
+ declaration: true,
76
+ emitDeclarationOnly: typesOnly,
77
+ skipLibCheck: true,
78
+ },
79
+ include: tsFiles.map((file) => `./${file}`),
80
+ },
81
+ null,
82
+ 2,
83
+ )
84
+
85
+ const tsconfigPath = join(outputDir, '.tsconfig-mjst-build.json')
86
+ await writeFile(tsconfigPath, tsconfigContent, 'utf-8')
87
+
88
+ try {
89
+ await execFileAsync('npx', ['tsc', '--project', tsconfigPath])
90
+ } catch {
91
+ throw new Error('TypeScript compilation failed. Check the generated files for errors.')
92
+ } finally {
93
+ await unlink(tsconfigPath)
52
94
  }
53
95
 
54
- if (!config.outDir) {
55
- console.error('Error: --outDir is required. Provide an output directory for generated files.')
56
- process.exit(1)
96
+ // Remove the intermediate .ts files now that .js and .d.ts have been produced
97
+ for (const file of tsFiles) {
98
+ await unlink(join(outputDir, file))
99
+ console.log(`Built: ${file.replace(/\.ts$/, '.js')}`)
100
+ console.log(`Built: ${file.replace(/\.ts$/, '.d.ts')}`)
57
101
  }
58
102
 
59
- const schemaPath = resolve(config.schema)
60
- const inputFormat = config.input ?? 'json'
103
+ console.log(`\nTotal files built: ${tsFiles.length * 2}`)
104
+ }
61
105
 
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)
106
+ /** Recursively collects every `*.json` file under `dir`, returning absolute paths. */
107
+ const findJsonSchemas = async (dir: string): Promise<string[]> => {
108
+ const entries = await readdir(dir, { withFileTypes: true })
109
+ const results: string[] = []
110
+
111
+ for (const entry of entries) {
112
+ const full = join(dir, entry.name)
113
+ if (entry.isDirectory()) {
114
+ results.push(...(await findJsonSchemas(full)))
115
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
116
+ results.push(full)
117
+ }
71
118
  }
72
119
 
73
- const outputDir = resolve(config.outDir)
120
+ return results
121
+ }
122
+
123
+ /** Generates parsers for a single schema (the original one-schema-in, one-outDir-out flow). */
124
+ const runSingle = async (config: Partial<CliConfig>, schemaPath: string, outputDir: string): Promise<void> => {
125
+ const schema = await loadSchema(config, schemaPath)
126
+
74
127
  await mkdir(outputDir, { recursive: true })
75
128
 
76
129
  const helpersMode = config.helpers ?? detectHelpersMode(outputDir)
@@ -87,63 +140,199 @@ const run = async (): Promise<void> => {
87
140
  config.logWarnings,
88
141
  config.strict,
89
142
  helpersMode,
143
+ undefined,
144
+ config.readonly,
90
145
  )
91
146
 
92
147
  for (const file of files) {
93
- const filePath = join(outputDir, file.filename)
94
- // Create subdirectories if the filename contains a path
95
- await mkdir(dirname(filePath), { recursive: true })
96
- await writeFile(filePath, file.content, 'utf-8')
148
+ await writeGeneratedFile(outputDir, file.filename, file.content)
97
149
  console.log(`Generated: ${file.filename}`)
98
150
  }
99
151
 
100
152
  if (config.build) {
101
- // Write a minimal tsconfig so tsc can compile the generated files without
102
- // inheriting settings like allowImportingTsExtensions that block emission.
103
- // In 'package' mode tsc finds @amritk/helpers via the consumer's node_modules.
104
- // In 'embedded' mode the helpers live under ./_helpers and need no resolution.
105
- const tsconfigContent = JSON.stringify(
106
- {
107
- compilerOptions: {
108
- target: 'ESNext',
109
- module: 'ESNext',
110
- moduleResolution: 'bundler',
111
- declaration: true,
112
- emitDeclarationOnly: config.typesOnly,
113
- skipLibCheck: true,
114
- },
115
- include: ['./**/*.ts'],
116
- },
117
- null,
118
- 2,
153
+ await buildOutput(
154
+ outputDir,
155
+ files.map((f) => f.filename),
156
+ config.typesOnly,
119
157
  )
158
+ } else {
159
+ console.log(`\nTotal files generated: ${files.length}`)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Generates a single self-contained file from one schema, concatenating every
165
+ * generated definition (and dropping the cross-file imports that are no longer
166
+ * needed) into `outFilePath`. Currently used for types-only output.
167
+ */
168
+ const runSingleFile = async (config: Partial<CliConfig>, schemaPath: string, outFilePath: string): Promise<void> => {
169
+ const schema = await loadSchema(config, schemaPath)
170
+ const outputDir = dirname(outFilePath)
171
+ await mkdir(outputDir, { recursive: true })
120
172
 
121
- const tsconfigPath = join(outputDir, '.tsconfig-mjst-build.json')
122
- await writeFile(tsconfigPath, tsconfigContent, 'utf-8')
173
+ const rootTypeName = deriveRootTypeName(schema)
174
+ console.log(`Root type: ${rootTypeName}`)
123
175
 
124
- try {
125
- await execFileAsync('npx', ['tsc', '--project', tsconfigPath])
126
- } catch {
127
- throw new Error('TypeScript compilation failed. Check the generated files for errors.')
128
- } finally {
129
- await unlink(tsconfigPath)
130
- }
176
+ const files = await buildSchema(
177
+ schema as JSONSchema,
178
+ rootTypeName,
179
+ undefined,
180
+ config.typesOnly,
181
+ config.logWarnings,
182
+ config.strict,
183
+ config.helpers ?? 'package',
184
+ undefined,
185
+ config.readonly,
186
+ )
187
+
188
+ await writeFile(outFilePath, combineGeneratedFiles(files), 'utf-8')
189
+ console.log(`Generated: ${relative(process.cwd(), outFilePath)}`)
190
+
191
+ if (config.build) {
192
+ await buildOutput(outputDir, [basename(outFilePath)], config.typesOnly)
193
+ } else {
194
+ console.log(`\nTotal files generated: 1`)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Generates parsers for every JSON Schema under `schemaDir`, mirroring the directory
200
+ * layout into `outDir`. Each schema's output lands in its own subdirectory (so files
201
+ * from different schemas never collide) and the runtime helpers are emitted once into a
202
+ * shared `outDir/_helpers/` that every nested parser imports from.
203
+ */
204
+ const runRecursive = async (config: Partial<CliConfig>, schemaDir: string, outputDir: string): Promise<void> => {
205
+ if (config.input && config.input !== 'json') {
206
+ console.error("Error: --schemaDir only supports JSON Schema files; remove --input or set it to 'json'.")
207
+ process.exit(1)
208
+ }
209
+
210
+ const schemaFiles = await findJsonSchemas(schemaDir)
211
+
212
+ if (schemaFiles.length === 0) {
213
+ console.error(`Error: no .json schema files found under ${schemaDir}.`)
214
+ process.exit(1)
215
+ }
216
+
217
+ await mkdir(outputDir, { recursive: true })
218
+
219
+ const helpersMode = config.helpers ?? detectHelpersMode(outputDir)
220
+ console.log(`Helpers mode: ${helpersMode}${config.helpers ? ' (explicit)' : ' (auto-detected)'}`)
221
+
222
+ // Helper sources are identical across schemas, so collect them by filename and
223
+ // write the deduplicated set once at the output root.
224
+ const sharedHelpers = new Map<string, string>()
225
+ const writtenTsFiles: string[] = []
226
+
227
+ for (const schemaFile of schemaFiles) {
228
+ const relPath = relative(schemaDir, schemaFile)
229
+ const relNoExt = relPath.slice(0, -'.json'.length)
230
+ const schemaSubDir = join(outputDir, relNoExt)
231
+ // Depth of the schema's output subdirectory, used to build the relative import
232
+ // path back to the shared `_helpers/` at the output root (../, ../../, ...).
233
+ const depth = relNoExt.split(sep).filter(Boolean).length
234
+ const helpersImportPrefix = '../'.repeat(depth)
235
+
236
+ const schema = await loadSchema(config, schemaFile)
237
+ const rootTypeName = deriveRootTypeName(schema)
238
+ console.log(`\n${relPath} → ${relNoExt}/ (root type: ${rootTypeName})`)
239
+
240
+ const files = await buildSchema(
241
+ schema as JSONSchema,
242
+ rootTypeName,
243
+ undefined,
244
+ config.typesOnly,
245
+ config.logWarnings,
246
+ config.strict,
247
+ helpersMode,
248
+ helpersImportPrefix,
249
+ config.readonly,
250
+ )
131
251
 
132
- // Remove the intermediate .ts files now that .js and .d.ts have been produced
133
252
  for (const file of files) {
134
- await unlink(join(outputDir, file.filename))
135
- const jsFilename = file.filename.replace(/\.ts$/, '.js')
136
- const dtsFilename = file.filename.replace(/\.ts$/, '.d.ts')
137
- console.log(`Built: ${jsFilename}`)
138
- console.log(`Built: ${dtsFilename}`)
253
+ if (file.filename.startsWith('_helpers/')) {
254
+ sharedHelpers.set(file.filename, file.content)
255
+ continue
256
+ }
257
+
258
+ await writeGeneratedFile(schemaSubDir, file.filename, file.content)
259
+ const relativeFilename = join(relNoExt, file.filename)
260
+ writtenTsFiles.push(relativeFilename)
261
+ console.log(`Generated: ${relativeFilename}`)
139
262
  }
263
+ }
264
+
265
+ for (const [filename, content] of sharedHelpers) {
266
+ await writeGeneratedFile(outputDir, filename, content)
267
+ writtenTsFiles.push(filename)
268
+ console.log(`Generated: ${filename}`)
269
+ }
140
270
 
141
- console.log(`\nTotal files built: ${files.length * 2}`)
271
+ if (config.build) {
272
+ await buildOutput(outputDir, writtenTsFiles, config.typesOnly)
142
273
  } else {
143
- console.log(`\nTotal files generated: ${files.length}`)
274
+ console.log(`\nTotal files generated: ${writtenTsFiles.length}`)
144
275
  }
145
276
  }
146
277
 
278
+ const run = async (): Promise<void> => {
279
+ // Skip the first two args (node executable and script path)
280
+ const args = process.argv.slice(2)
281
+
282
+ const configPath = extractConfigPath(args)
283
+
284
+ // Start with config file values if provided, then overlay CLI flags on top
285
+ const fileConfig = configPath ? await loadConfig(configPath) : {}
286
+ const cliConfig = parseCliArgs(args)
287
+ const config = { ...fileConfig, ...cliConfig }
288
+
289
+ if (config.outDir && config.outFile) {
290
+ console.error('Error: provide only one of --out-dir or --out-file, not both.')
291
+ process.exit(1)
292
+ }
293
+
294
+ // Single-file output is the alternative to a directory of files.
295
+ if (config.outFile) {
296
+ if (!config.typesOnly) {
297
+ console.error('Error: --out-file currently supports only --types-only output. Add --types-only or use --out-dir.')
298
+ process.exit(1)
299
+ }
300
+
301
+ if (config.schemaDir) {
302
+ console.error('Error: --out-file cannot be combined with --schema-dir; use --out-dir for recursive output.')
303
+ process.exit(1)
304
+ }
305
+
306
+ if (!config.schema) {
307
+ console.error('Error: --schema is required. Provide a path to a JSON Schema.')
308
+ process.exit(1)
309
+ }
310
+
311
+ await runSingleFile(config, resolve(config.schema), resolve(config.outFile))
312
+ return
313
+ }
314
+
315
+ if (!config.outDir) {
316
+ console.error('Error: --out-dir or --out-file is required. Provide an output destination for generated files.')
317
+ process.exit(1)
318
+ }
319
+
320
+ const outputDir = resolve(config.outDir)
321
+
322
+ // schemaDir activates recursive mode and takes precedence over a single schema.
323
+ if (config.schemaDir) {
324
+ await runRecursive(config, resolve(config.schemaDir), outputDir)
325
+ return
326
+ }
327
+
328
+ if (!config.schema) {
329
+ console.error('Error: --schema (or --schemaDir) is required. Provide a path to a JSON Schema.')
330
+ process.exit(1)
331
+ }
332
+
333
+ await runSingle(config, resolve(config.schema), outputDir)
334
+ }
335
+
147
336
  run().catch((error: unknown) => {
148
337
  console.error(error instanceof Error ? error.message : error)
149
338
  process.exit(1)
@@ -0,0 +1,49 @@
1
+ import type { GeneratedFile } from '@amritk/generate-parsers'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { combineGeneratedFiles } from './combine-files'
5
+
6
+ describe('combineGeneratedFiles', () => {
7
+ it('drops relative imports and the index barrel, inlining every definition', () => {
8
+ const files: GeneratedFile[] = [
9
+ {
10
+ filename: 'document.ts',
11
+ content: "import type { Person } from './person';\n\nexport type Document = {\n owner?: Person;\n};",
12
+ },
13
+ {
14
+ filename: 'person.ts',
15
+ content: 'export type Person = {\n name: string;\n};',
16
+ },
17
+ {
18
+ filename: 'index.ts',
19
+ content: "export type { Document } from './document';\nexport type { Person } from './person';\n",
20
+ },
21
+ ]
22
+
23
+ const result = combineGeneratedFiles(files)
24
+
25
+ expect(result).not.toContain('import')
26
+ expect(result).not.toContain('./document')
27
+ expect(result).toContain('export type Document =')
28
+ expect(result).toContain('export type Person =')
29
+ })
30
+
31
+ it('hoists and deduplicates external (non-relative) imports', () => {
32
+ const files: GeneratedFile[] = [
33
+ {
34
+ filename: 'a.ts',
35
+ content: "import { isObject } from '@amritk/helpers/is-object';\n\nexport const a = isObject;",
36
+ },
37
+ {
38
+ filename: 'b.ts',
39
+ content: "import { isObject } from '@amritk/helpers/is-object';\n\nexport const b = isObject;",
40
+ },
41
+ ]
42
+
43
+ const result = combineGeneratedFiles(files)
44
+ const importCount = result.split('\n').filter((line) => line.startsWith('import')).length
45
+
46
+ expect(importCount).toBe(1)
47
+ expect(result).toContain("import { isObject } from '@amritk/helpers/is-object';")
48
+ })
49
+ })
@@ -0,0 +1,43 @@
1
+ import type { GeneratedFile } from '@amritk/generate-parsers'
2
+
3
+ // Require whitespace after `import` so a property literally named `import` (e.g.
4
+ // `import?: string`) is not mistaken for an import statement.
5
+ const IMPORT_RE = /^import\s/
6
+ const RELATIVE_IMPORT_RE = /\bfrom\s+['"]\.\.?\//
7
+
8
+ /**
9
+ * Merges the per-schema {@link GeneratedFile}s into a single self-contained
10
+ * TypeScript module. Each generated file imports its sibling definitions via
11
+ * relative paths; since everything now lives in one file those imports are
12
+ * dropped, while any external (package) imports are hoisted to the top and
13
+ * deduplicated. The `index.ts` barrel is skipped because it only re-exports the
14
+ * declarations that are already inlined here.
15
+ *
16
+ * Intended for types-only output, where files contain only type definitions and
17
+ * type-only relative imports (no runtime helpers).
18
+ */
19
+ export const combineGeneratedFiles = (files: readonly GeneratedFile[]): string => {
20
+ const externalImports = new Set<string>()
21
+ const bodies: string[] = []
22
+
23
+ for (const file of files) {
24
+ if (file.filename === 'index.ts') continue
25
+
26
+ const bodyLines: string[] = []
27
+ for (const line of file.content.split('\n')) {
28
+ if (IMPORT_RE.test(line.trimStart())) {
29
+ // Relative imports point at sibling files that are now inlined — drop them.
30
+ if (RELATIVE_IMPORT_RE.test(line)) continue
31
+ externalImports.add(line.trim())
32
+ continue
33
+ }
34
+ bodyLines.push(line)
35
+ }
36
+
37
+ const body = bodyLines.join('\n').trim()
38
+ if (body) bodies.push(body)
39
+ }
40
+
41
+ const importBlock = [...externalImports].sort().join('\n')
42
+ return [importBlock, bodies.join('\n\n')].filter(Boolean).join('\n\n') + '\n'
43
+ }
@@ -58,6 +58,15 @@ describe('load-config', () => {
58
58
  expect(result).toEqual({ schema: 's.json', outDir: 'o', strict: true })
59
59
  })
60
60
 
61
+ it('loads schemaDir from a config file', async () => {
62
+ const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
63
+ await writeFile(configPath, JSON.stringify({ schemaDir: './schemas', outDir: 'output' }))
64
+
65
+ const result = await loadConfig(configPath)
66
+
67
+ expect(result).toEqual({ schemaDir: './schemas', outDir: 'output' })
68
+ })
69
+
61
70
  it('ignores non-boolean strict value', async () => {
62
71
  const configPath = join(tmpdir(), `test-config-${Date.now()}.json`)
63
72
  await writeFile(configPath, JSON.stringify({ schema: 's.json', strict: 'yes' }))
@@ -26,12 +26,15 @@ export const loadConfig = async (configPath: string): Promise<Partial<CliConfig>
26
26
 
27
27
  return {
28
28
  ...(typeof obj['schema'] === 'string' && { schema: obj['schema'] }),
29
+ ...(typeof obj['schemaDir'] === 'string' && { schemaDir: obj['schemaDir'] }),
29
30
  ...(typeof obj['outDir'] === 'string' && { outDir: obj['outDir'] }),
31
+ ...(typeof obj['outFile'] === 'string' && { outFile: obj['outFile'] }),
30
32
  ...(isSourceFormat(obj['input']) && { input: obj['input'] }),
31
33
  ...(typeof obj['export'] === 'string' && { export: obj['export'] }),
32
34
  ...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
33
35
  ...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
34
36
  ...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
35
37
  ...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
38
+ ...(typeof obj['readonly'] === 'boolean' && { readonly: obj['readonly'] }),
36
39
  }
37
40
  }
@@ -43,6 +43,26 @@ describe('parse-cli-args', () => {
43
43
  })
44
44
  })
45
45
 
46
+ it('parses --schemaDir with space-separated and equals syntax', () => {
47
+ expect(parseCliArgs(['--schemaDir', './schemas', '--outDir', 'dist'])).toEqual({
48
+ schemaDir: './schemas',
49
+ outDir: 'dist',
50
+ })
51
+ expect(parseCliArgs(['--schemaDir=./schemas', '--outDir=dist'])).toEqual({
52
+ schemaDir: './schemas',
53
+ outDir: 'dist',
54
+ })
55
+ })
56
+
57
+ it('keeps --schema and --schemaDir distinct', () => {
58
+ const result = parseCliArgs(['--schema', 'a.json', '--schemaDir', './schemas'])
59
+
60
+ expect(result).toEqual({
61
+ schema: 'a.json',
62
+ schemaDir: './schemas',
63
+ })
64
+ })
65
+
46
66
  it('handles only --outDir without --schema', () => {
47
67
  const result = parseCliArgs(['--outDir', 'output'])
48
68
 
@@ -223,4 +243,32 @@ describe('parse-cli-args', () => {
223
243
  const result = parseCliArgs(['--helpers=foo', '--schema', 's.json'])
224
244
  expect(result).toEqual({ schema: 's.json' })
225
245
  })
246
+
247
+ it('accepts kebab-case flag names', () => {
248
+ const result = parseCliArgs(['--schema-dir', './schemas', '--out-dir', 'dist'])
249
+
250
+ expect(result).toEqual({
251
+ schemaDir: './schemas',
252
+ outDir: 'dist',
253
+ })
254
+ })
255
+
256
+ it('treats kebab-case and camelCase variants as equivalent', () => {
257
+ const kebab = parseCliArgs(['--out-dir', 'dist', '--types-only', '--log-warnings'])
258
+ const camel = parseCliArgs(['--outDir', 'dist', '--typesOnly', '--logWarnings'])
259
+
260
+ expect(kebab).toEqual(camel)
261
+ expect(camel).toEqual({ outDir: 'dist', typesOnly: true, logWarnings: true })
262
+ })
263
+
264
+ it('parses --out-file with space-separated and equals syntax', () => {
265
+ expect(parseCliArgs(['--out-file', './out/schema.ts'])).toEqual({ outFile: './out/schema.ts' })
266
+ expect(parseCliArgs(['--outFile=./out/schema.ts'])).toEqual({ outFile: './out/schema.ts' })
267
+ })
268
+
269
+ it('parses --readonly boolean flag and its equals forms', () => {
270
+ expect(parseCliArgs(['--readonly'])).toEqual({ readonly: true })
271
+ expect(parseCliArgs(['--readonly=true'])).toEqual({ readonly: true })
272
+ expect(parseCliArgs(['--readonly=false'])).toEqual({ readonly: false })
273
+ })
226
274
  })