@amritk/mjst 0.2.1 → 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.
package/README.md CHANGED
@@ -69,7 +69,8 @@ npx mjst --config ./mjst.config.json
69
69
  | 🔨 | `build` | `--build` | `boolean` | — | `false` | Compile the generated TypeScript files to .js and .d.ts output. A temporary tsconfig is written to the output directory, tsc is invoked, and the intermediate .ts source files are removed when compilation succeeds. |
70
70
  | ⚠️ | `logWarnings` | `--log-warnings` | `boolean` | — | `false` | Emit a console.warn in the generated parsers for every input key that is not declared in the schema's properties. Useful for detecting schema drift or unexpected data shapes at runtime. |
71
71
  | 🚫 | `strict` | `--strict` | `boolean` | — | `false` | Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. Unknown extra keys are still allowed. |
72
- | ⚙️ | `config` | `--config <path>` | `string` | — | — | Path to a JSON config file. Keys match the option names in this schema (schema, outDir, typesOnly, build, logWarnings, strict). CLI flags take precedence over config file values. |
72
+ | 🧰 | `helpers` | `--helpers <mode>` | `string` | — | — | Controls how generated parsers reference their runtime helpers. 'package' emits imports from @amritk/helpers (requires it to be installed in the consumer project). 'embedded' ships the helper source under outDir/_helpers/ so the output is self-contained. When omitted, the CLI auto-detects: it picks 'package' if @amritk/helpers resolves from outDir, otherwise 'embedded'. |
73
+ | ⚙️ | `config` | `--config <path>` | `string` | — | — | Path to a JSON config file. Keys match the option names in this schema (schema, outDir, typesOnly, build, logWarnings, strict, helpers). CLI flags take precedence over config file values. |
73
74
  <!-- config-table-end -->
74
75
 
75
76
  ---
@@ -28,4 +28,14 @@ export type CliConfig = {
28
28
  * instead of coercing invalid input to default values.
29
29
  */
30
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';
31
41
  };
package/dist/cli.js CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { execFile } from "node:child_process";
5
- import { mkdir, readFile as readFile2, unlink, writeFile } from "node:fs/promises";
6
- import { dirname, join, relative, resolve as resolve2 } from "node:path";
7
- import { fileURLToPath } from "node:url";
5
+ import { mkdir, readFile as readFile3, unlink, writeFile } from "node:fs/promises";
6
+ import { dirname as dirname2, join, resolve as resolve3 } from "node:path";
8
7
  import { promisify } from "node:util";
9
8
 
10
9
  // ../generate-parsers/dist/index.js
10
+ import { readFile } from "node:fs/promises";
11
+ import { createRequire } from "node:module";
12
+ import { dirname, resolve as resolvePath } from "node:path";
11
13
  var isSchemaObject = (schema) => {
12
14
  return typeof schema === "object" && schema !== null && typeof schema !== "boolean";
13
15
  };
@@ -783,21 +785,43 @@ var generateTypeDefinition = (schema, typeName) => {
783
785
  }
784
786
  return "export type " + typeName + " = unknown;";
785
787
  };
786
- var collectHelpers = (parserFunction) => {
788
+ var PACKAGE_IMPORTS = {
789
+ "is-object": "import { isObject } from '@amritk/helpers/is-object';",
790
+ "validate-array": "import { validateArray } from '@amritk/helpers/validate-array';",
791
+ "validate-record": "import { validateRecord } from '@amritk/helpers/validate-record';",
792
+ "has-ref": "import { hasRef } from '@amritk/helpers/schema-guards';"
793
+ };
794
+ var EMBEDDED_IMPORTS = {
795
+ "is-object": "import { isObject } from './_helpers/is-object';",
796
+ "validate-array": "import { validateArray } from './_helpers/validate-array';",
797
+ "validate-record": "import { validateRecord } from './_helpers/validate-record';",
798
+ "has-ref": "import { hasRef } from './_helpers/has-ref';"
799
+ };
800
+ var collectHelpers = (parserFunction, mode) => {
787
801
  const imports = [];
802
+ const used = new Set;
803
+ const table = mode === "embedded" ? EMBEDDED_IMPORTS : PACKAGE_IMPORTS;
788
804
  if (parserFunction.includes("validateArray")) {
789
- imports.push("import { validateArray } from '@amritk/helpers/validate-array';");
805
+ imports.push(table["validate-array"]);
806
+ used.add("validate-array");
790
807
  }
791
808
  if (parserFunction.includes("validateRecord")) {
792
- imports.push("import { validateRecord } from '@amritk/helpers/validate-record';");
809
+ imports.push(table["validate-record"]);
810
+ used.add("validate-record");
811
+ used.add("is-object");
793
812
  }
794
813
  if (parserFunction.includes("isObject")) {
795
- imports.push("import { isObject } from '@amritk/helpers/is-object';");
814
+ imports.push(table["is-object"]);
815
+ used.add("is-object");
796
816
  }
797
817
  if (parserFunction.includes("hasRef(")) {
798
- imports.push("import { hasRef } from '@amritk/helpers/schema-guards';");
818
+ imports.push(table["has-ref"]);
819
+ used.add("has-ref");
799
820
  }
800
- return imports;
821
+ return { imports, used };
822
+ };
823
+ var hasRef = (value) => {
824
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "$ref" in value && typeof value.$ref === "string";
801
825
  };
802
826
  var isSchemaObject3 = (schema) => {
803
827
  return typeof schema === "object" && schema !== null && typeof schema !== "boolean";
@@ -880,9 +904,6 @@ var hasMinProperties = (schema) => {
880
904
  var hasMaxProperties = (schema) => {
881
905
  return isSchemaObject3(schema) && "maxProperties" in schema && typeof schema.maxProperties === "number";
882
906
  };
883
- var hasRef = (schema) => {
884
- return isSchemaObject3(schema) && "$ref" in schema && typeof schema.$ref === "string";
885
- };
886
907
  var getImportPathForFilename = (filename) => `./${filename}`;
887
908
  var collectImports = (schema, options) => {
888
909
  const typesOnly = options?.typesOnly === true;
@@ -2379,6 +2400,7 @@ var generateFile = (schema, typeName, options) => {
2379
2400
  const typesOnly = options?.typesOnly === true;
2380
2401
  const selfRef = options?.selfRef;
2381
2402
  const rootSchema = options?.rootSchema;
2403
+ const helpersMode = options?.helpersMode ?? "package";
2382
2404
  const typeDefinition = generateTypeDefinition(schema, typeName);
2383
2405
  if (typesOnly) {
2384
2406
  const imports2 = collectImports(schema, { typesOnly: true, selfRef, rootSchema });
@@ -2393,7 +2415,7 @@ var generateFile = (schema, typeName, options) => {
2393
2415
 
2394
2416
  `;
2395
2417
  }
2396
- return result2 + typeDefinition;
2418
+ return { content: result2 + typeDefinition, usedHelpers: new Set };
2397
2419
  }
2398
2420
  const parserFunction = generateParserFunction(schema, typeName, {
2399
2421
  useRefImports: true,
@@ -2404,7 +2426,8 @@ var generateFile = (schema, typeName, options) => {
2404
2426
  const combinedFunctions = `${shapeValidator}
2405
2427
 
2406
2428
  ${parserFunction}`;
2407
- const imports = [...collectImports(schema, { selfRef, rootSchema }), ...collectHelpers(combinedFunctions)];
2429
+ const helpers = collectHelpers(combinedFunctions, helpersMode);
2430
+ const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports];
2408
2431
  let result = "";
2409
2432
  if (imports.length > 0) {
2410
2433
  result += imports[0];
@@ -2416,9 +2439,15 @@ ${parserFunction}`;
2416
2439
 
2417
2440
  `;
2418
2441
  }
2419
- return result + typeDefinition + `
2442
+ return { content: result + typeDefinition + `
2420
2443
 
2421
- ` + combinedFunctions;
2444
+ ` + combinedFunctions, usedHelpers: helpers.used };
2445
+ };
2446
+ var readHelperSource = async (helper) => {
2447
+ const require2 = createRequire(import.meta.url);
2448
+ const helpersPkgPath = require2.resolve("@amritk/helpers/package.json");
2449
+ const helpersRoot = dirname(helpersPkgPath);
2450
+ return readFile(resolvePath(helpersRoot, "src", `${helper}.ts`), "utf-8");
2422
2451
  };
2423
2452
  var extractDynamicAnchorDefs = (schema) => {
2424
2453
  const refs = [];
@@ -2439,18 +2468,20 @@ var extractDynamicAnchorDefs = (schema) => {
2439
2468
  }
2440
2469
  return refs;
2441
2470
  };
2442
- var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict) => {
2471
+ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package") => {
2443
2472
  rootSchema = upgradeDraft07Schema(rootSchema);
2444
2473
  const files = [];
2445
2474
  const processedRefs = new Set;
2446
2475
  const processedFilenames = new Set;
2447
2476
  const refsToProcess = [];
2477
+ const usedHelpers = new Set;
2448
2478
  const dynamicRefMap = buildDynamicRefMap(rootSchema);
2449
2479
  const processedRootSchema = resolveDynamicRefs(rootSchema, dynamicRefMap);
2450
2480
  const extendedRootSchema = extensions ? applySchemaExtensions(processedRootSchema, rootTypeName.toLowerCase(), extensions) : processedRootSchema;
2451
- const rootContent = generateFile(extendedRootSchema, rootTypeName, {
2481
+ const rootResult = generateFile(extendedRootSchema, rootTypeName, {
2452
2482
  typesOnly: typesOnly ?? false,
2453
2483
  rootSchema,
2484
+ helpersMode,
2454
2485
  ...logWarnings !== undefined ? { logWarnings } : {},
2455
2486
  ...strict !== undefined ? { strict } : {}
2456
2487
  });
@@ -2458,8 +2489,10 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2458
2489
  processedFilenames.add(rootFilename);
2459
2490
  files.push({
2460
2491
  filename: `${rootFilename}.ts`,
2461
- content: rootContent
2492
+ content: rootResult.content
2462
2493
  });
2494
+ for (const helper of rootResult.usedHelpers)
2495
+ usedHelpers.add(helper);
2463
2496
  const rootRefs = extractRefs(rootSchema);
2464
2497
  refsToProcess.push(...rootRefs);
2465
2498
  const dynamicAnchorRefs = extractDynamicAnchorDefs(rootSchema);
@@ -2479,10 +2512,11 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2479
2512
  const filename = refToFilename(ref);
2480
2513
  const processedSchema = resolveDynamicRefs(resolvedSchema, dynamicRefMap);
2481
2514
  const extendedSchema = extensions ? applySchemaExtensions(processedSchema, filename, extensions) : processedSchema;
2482
- const content = generateFile(extendedSchema, typeName, {
2515
+ const fileResult = generateFile(extendedSchema, typeName, {
2483
2516
  typesOnly: typesOnly ?? false,
2484
2517
  selfRef: ref,
2485
2518
  rootSchema,
2519
+ helpersMode,
2486
2520
  ...logWarnings !== undefined ? { logWarnings } : {},
2487
2521
  ...strict !== undefined ? { strict } : {}
2488
2522
  });
@@ -2490,9 +2524,11 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2490
2524
  processedFilenames.add(filename);
2491
2525
  files.push({
2492
2526
  filename: `${filename}.ts`,
2493
- content
2527
+ content: fileResult.content
2494
2528
  });
2495
2529
  }
2530
+ for (const helper of fileResult.usedHelpers)
2531
+ usedHelpers.add(helper);
2496
2532
  const nestedRefs = extractRefs(resolvedSchema);
2497
2533
  for (const nestedRef of nestedRefs) {
2498
2534
  if (!processedRefs.has(nestedRef)) {
@@ -2500,9 +2536,15 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2500
2536
  }
2501
2537
  }
2502
2538
  }
2539
+ if (helpersMode === "embedded" && !typesOnly) {
2540
+ for (const helper of usedHelpers) {
2541
+ const source = await readHelperSource(helper);
2542
+ files.push({ filename: `_helpers/${helper}.ts`, content: source });
2543
+ }
2544
+ }
2503
2545
  const TYPE_EXPORT_RE = /^export type (\w+)/gm;
2504
2546
  const CONST_EXPORT_RE = /^export const (\w+)/gm;
2505
- const sortedFiles = [...files].sort((a, b) => a.filename.localeCompare(b.filename));
2547
+ const sortedFiles = [...files].filter((f) => !f.filename.startsWith("_helpers/")).sort((a, b) => a.filename.localeCompare(b.filename));
2506
2548
  let indexContent = "";
2507
2549
  for (const file of sortedFiles) {
2508
2550
  const moduleName = file.filename.replace(/\.ts$/, "");
@@ -2533,12 +2575,25 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
2533
2575
  return files;
2534
2576
  };
2535
2577
 
2536
- // src/load-config.ts
2537
- import { readFile } from "node:fs/promises";
2578
+ // src/detect-helpers-mode.ts
2579
+ import { createRequire as createRequire2 } from "node:module";
2538
2580
  import { resolve } from "node:path";
2581
+ var detectHelpersMode = (outputDir) => {
2582
+ const req = createRequire2(resolve(outputDir, "noop.js"));
2583
+ try {
2584
+ req.resolve("@amritk/helpers/is-object");
2585
+ return "package";
2586
+ } catch {
2587
+ return "embedded";
2588
+ }
2589
+ };
2590
+
2591
+ // src/load-config.ts
2592
+ import { readFile as readFile2 } from "node:fs/promises";
2593
+ import { resolve as resolve2 } from "node:path";
2539
2594
  var loadConfig = async (configPath) => {
2540
- const absolutePath = resolve(configPath);
2541
- const raw = await readFile(absolutePath, "utf-8");
2595
+ const absolutePath = resolve2(configPath);
2596
+ const raw = await readFile2(absolutePath, "utf-8");
2542
2597
  const parsed = JSON.parse(raw);
2543
2598
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2544
2599
  throw new Error(`Config file must be a JSON object: ${configPath}`);
@@ -2557,6 +2612,11 @@ var loadConfig = async (configPath) => {
2557
2612
  // src/parse-cli-args.ts
2558
2613
  var parseCliArgs = (args) => {
2559
2614
  const config = {};
2615
+ const parseHelpersValue = (value) => {
2616
+ if (value === "package" || value === "embedded")
2617
+ return value;
2618
+ return;
2619
+ };
2560
2620
  for (let i = 0;i < args.length; i++) {
2561
2621
  const arg = args[i];
2562
2622
  if (!arg) {
@@ -2578,6 +2638,10 @@ var parseCliArgs = (args) => {
2578
2638
  config.logWarnings = value !== "false";
2579
2639
  } else if (key === "strict") {
2580
2640
  config.strict = value !== "false";
2641
+ } else if (key === "helpers") {
2642
+ const parsed = parseHelpersValue(value);
2643
+ if (parsed)
2644
+ config.helpers = parsed;
2581
2645
  }
2582
2646
  continue;
2583
2647
  }
@@ -2601,6 +2665,15 @@ var parseCliArgs = (args) => {
2601
2665
  config.logWarnings = true;
2602
2666
  } else if (arg === "--strict") {
2603
2667
  config.strict = true;
2668
+ } else if (arg === "--helpers") {
2669
+ const value = args[i + 1];
2670
+ if (value && !value.startsWith("--")) {
2671
+ const parsed = parseHelpersValue(value);
2672
+ if (parsed) {
2673
+ config.helpers = parsed;
2674
+ i++;
2675
+ }
2676
+ }
2604
2677
  }
2605
2678
  }
2606
2679
  return config;
@@ -2634,21 +2707,21 @@ var run = async () => {
2634
2707
  console.error("Error: --outDir is required. Provide an output directory for generated files.");
2635
2708
  process.exit(1);
2636
2709
  }
2637
- const schemaPath = resolve2(config.schema);
2638
- const raw = await readFile2(schemaPath, "utf-8");
2710
+ const schemaPath = resolve3(config.schema);
2711
+ const raw = await readFile3(schemaPath, "utf-8");
2639
2712
  const schema = JSON.parse(raw);
2640
- const files = await buildSchema(schema, "Document", undefined, config.typesOnly, config.logWarnings, config.strict);
2641
- const outputDir = resolve2(config.outDir);
2713
+ const outputDir = resolve3(config.outDir);
2642
2714
  await mkdir(outputDir, { recursive: true });
2715
+ const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
2716
+ console.log(`Helpers mode: ${helpersMode}${config.helpers ? " (explicit)" : " (auto-detected)"}`);
2717
+ const files = await buildSchema(schema, "Document", undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode);
2643
2718
  for (const file of files) {
2644
2719
  const filePath = join(outputDir, file.filename);
2645
- await mkdir(dirname(filePath), { recursive: true });
2720
+ await mkdir(dirname2(filePath), { recursive: true });
2646
2721
  await writeFile(filePath, file.content, "utf-8");
2647
2722
  console.log(`Generated: ${file.filename}`);
2648
2723
  }
2649
2724
  if (config.build) {
2650
- const helpersDir = resolve2(dirname(fileURLToPath(import.meta.url)), "../../helpers/dist");
2651
- const helpersRelative = relative(outputDir, helpersDir);
2652
2725
  const tsconfigContent = JSON.stringify({
2653
2726
  compilerOptions: {
2654
2727
  target: "ESNext",
@@ -2656,10 +2729,7 @@ var run = async () => {
2656
2729
  moduleResolution: "bundler",
2657
2730
  declaration: true,
2658
2731
  emitDeclarationOnly: config.typesOnly,
2659
- skipLibCheck: true,
2660
- paths: {
2661
- "@amritk/helpers/*": [`${helpersRelative}/*`]
2662
- }
2732
+ skipLibCheck: true
2663
2733
  },
2664
2734
  include: ["./**/*.ts"]
2665
2735
  }, null, 2);
@@ -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.2.1",
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,7 +27,8 @@
27
27
  "mjst": "./dist/cli.js"
28
28
  },
29
29
  "files": [
30
- "dist"
30
+ "dist",
31
+ "src"
31
32
  ],
32
33
  "publishConfig": {
33
34
  "access": "public"
@@ -42,7 +43,8 @@
42
43
  },
43
44
  "dependencies": {
44
45
  "@amritk/generate-parsers": "workspace:*",
45
- "@amritk/generate-markdown": "workspace:*"
46
+ "@amritk/generate-markdown": "workspace:*",
47
+ "@amritk/helpers": "workspace:*"
46
48
  },
47
49
  "devDependencies": {
48
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
+ })
@@ -0,0 +1,97 @@
1
+ import type { CliConfig } from './cli-config'
2
+
3
+ /**
4
+ * Parses command-line arguments into a partial CLI config.
5
+ * Supports --schema, --outDir, and --types-only flags with either `--flag value` or `--flag=value` syntax.
6
+ * Only returns the flags that were explicitly provided so we can layer them on top of config file values.
7
+ */
8
+ export const parseCliArgs = (args: readonly string[]): Partial<CliConfig> => {
9
+ // Use a mutable local type for building, then return as Partial<CliConfig>
10
+ const config: {
11
+ schema?: string
12
+ outDir?: string
13
+ typesOnly?: boolean
14
+ build?: boolean
15
+ logWarnings?: boolean
16
+ strict?: boolean
17
+ helpers?: 'package' | 'embedded'
18
+ } = {}
19
+
20
+ const parseHelpersValue = (value: string): 'package' | 'embedded' | undefined => {
21
+ if (value === 'package' || value === 'embedded') return value
22
+ return undefined
23
+ }
24
+
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i]
27
+
28
+ if (!arg) {
29
+ continue
30
+ }
31
+
32
+ // Handle --flag=value syntax
33
+ if (arg.startsWith('--') && arg.includes('=')) {
34
+ const equalsIndex = arg.indexOf('=')
35
+ const key = arg.slice(2, equalsIndex)
36
+ const value = arg.slice(equalsIndex + 1)
37
+
38
+ if (key === 'schema') {
39
+ config.schema = value
40
+ } else if (key === 'outDir') {
41
+ config.outDir = value
42
+ } else if (key === 'types-only') {
43
+ // Accept --types-only=false to explicitly opt out, otherwise treat as true
44
+ config.typesOnly = value !== 'false'
45
+ } else if (key === 'build') {
46
+ config.build = value !== 'false'
47
+ } else if (key === 'log-warnings') {
48
+ config.logWarnings = value !== 'false'
49
+ } else if (key === 'strict') {
50
+ config.strict = value !== 'false'
51
+ } else if (key === 'helpers') {
52
+ const parsed = parseHelpersValue(value)
53
+ if (parsed) config.helpers = parsed
54
+ }
55
+
56
+ continue
57
+ }
58
+
59
+ // Handle --flag value syntax
60
+ if (arg === '--schema') {
61
+ const value = args[i + 1]
62
+
63
+ if (value && !value.startsWith('--')) {
64
+ config.schema = value
65
+ i++
66
+ }
67
+ } else if (arg === '--outDir') {
68
+ const value = args[i + 1]
69
+
70
+ if (value && !value.startsWith('--')) {
71
+ config.outDir = value
72
+ i++
73
+ }
74
+ } else if (arg === '--types-only') {
75
+ // Boolean flag: presence alone enables it, no value needed
76
+ config.typesOnly = true
77
+ } else if (arg === '--build') {
78
+ config.build = true
79
+ } else if (arg === '--log-warnings') {
80
+ config.logWarnings = true
81
+ } else if (arg === '--strict') {
82
+ config.strict = true
83
+ } else if (arg === '--helpers') {
84
+ const value = args[i + 1]
85
+
86
+ if (value && !value.startsWith('--')) {
87
+ const parsed = parseHelpersValue(value)
88
+ if (parsed) {
89
+ config.helpers = parsed
90
+ i++
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ return config
97
+ }