@game-infra/valibot-to-csharp 0.0.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 ADDED
@@ -0,0 +1,136 @@
1
+ # @game-infra/valibot-to-csharp
2
+
3
+ Generate idiomatic C# record types from TypeScript [valibot](https://valibot.dev) schemas.
4
+
5
+ ## Why
6
+
7
+ We author JSON schemas in TypeScript (in editors and web tooling) but consume that JSON from a .NET game using `System.Text.Json`. Keeping a hand-maintained C# mirror of every `object({...})` is tedious and drifts the moment someone renames a field. This package walks the valibot source with the TypeScript compiler API and emits a `.cs` file whose records deserialise the same JSON, so the mirror is never hand-maintained.
8
+
9
+ ## Install
10
+
11
+ This package is private and consumed as source from a sibling `game-infra` checkout (see `docs/CONSUMING.md` in the repo root). Add it to your game repo as a linked dependency:
12
+
13
+ ```jsonc
14
+ // pnpm
15
+ "devDependencies": {
16
+ "@game-infra/valibot-to-csharp": "link:../../../game-infra/packages/valibot-to-csharp"
17
+ }
18
+
19
+ // npm
20
+ "devDependencies": {
21
+ "@game-infra/valibot-to-csharp": "file:../../../game-infra/packages/valibot-to-csharp"
22
+ }
23
+ ```
24
+
25
+ ## CLI
26
+
27
+ There is no published dist, so run the CLI straight from the sibling checkout's source with a TS runner such as tsx. Plain `node src/cli.ts` does not work: Node's type stripping refuses to map the `.js` relative import specifiers this repo uses onto their `.ts` sources.
28
+
29
+ ```sh
30
+ npx tsx ../game-infra/packages/valibot-to-csharp/src/cli.ts \
31
+ --input ./schemas/events.ts \
32
+ --input ./schemas/common.ts \
33
+ --output ./Generated \
34
+ --namespace MyGame.Events
35
+ ```
36
+
37
+ Alternatively, build once inside `game-infra` (`pnpm --filter @game-infra/valibot-to-csharp build`) and run the compiled entry point, which keeps its shebang:
38
+
39
+ ```sh
40
+ node ../game-infra/packages/valibot-to-csharp/dist/cli.js --input ./schemas/events.ts --output ./Generated --namespace MyGame.Events
41
+ ```
42
+
43
+ Flags:
44
+
45
+ | Flag | Meaning |
46
+ | ---------------- | ------------------------------------------------------------- |
47
+ | `--input`, `-i` | Repeatable. Path to a `.ts` file containing valibot schemas. |
48
+ | `--output`, `-o` | Output directory for generated `.cs` files. |
49
+ | `--namespace` | C# namespace for emitted types. Defaults to `Generated`. |
50
+ | `--bundle` | Write a single bundle `.cs` file with the given name. |
51
+ | `--union-name` | Override a union base name. Format: `SchemaConst:CSharpName`. |
52
+ | `--help`, `-h` | Show usage. |
53
+
54
+ ## Programmatic
55
+
56
+ Generate and write files in one call:
57
+
58
+ ```ts
59
+ import { generateToFile } from "@game-infra/valibot-to-csharp";
60
+
61
+ await generateToFile({
62
+ inputs: ["./schemas/events.ts"],
63
+ outputDir: "./Generated",
64
+ namespace: "MyGame.Events",
65
+ });
66
+ ```
67
+
68
+ Inspect the output without touching disk (handy for embedding into other build pipelines):
69
+
70
+ ```ts
71
+ import { generate } from "@game-infra/valibot-to-csharp";
72
+
73
+ const result = await generate({
74
+ inputs: ["./schemas/events.ts"],
75
+ outputDir: "./Generated",
76
+ namespace: "MyGame.Events",
77
+ bundle: true,
78
+ bundleName: "Events.cs",
79
+ });
80
+ for (const file of result.files) {
81
+ console.log(file.path, file.content.length);
82
+ }
83
+ ```
84
+
85
+ Go lower-level with the parser and emitter directly:
86
+
87
+ ```ts
88
+ import { emitFileHeader, emitModule, parseFiles } from "@game-infra/valibot-to-csharp";
89
+
90
+ const module = parseFiles(["./schemas/events.ts"]);
91
+ const emit = emitModule(module, { namespace: "MyGame.Events" });
92
+ const csSource = emitFileHeader(module.notes) + emit.source;
93
+ ```
94
+
95
+ ## Supported valibot surface
96
+
97
+ | valibot | C# |
98
+ | ------------------------------------ | -------------------------------------------------------- |
99
+ | `string()` | `string` |
100
+ | `number()` | `double` |
101
+ | `boolean()` | `bool` |
102
+ | `unknown()` | `JsonElement` |
103
+ | `array(X)` | `IReadOnlyList<X>` |
104
+ | `record(string(), V)` | `IReadOnlyDictionary<string, V>` |
105
+ | `object({...})` | `public sealed record` |
106
+ | `optional(X)` / `nullable(X)` | Nullable field (`X?`) |
107
+ | `literal('a')` | String literal (merged into enums/variant tags) |
108
+ | `picklist(['a','b'])` | `[JsonConverter(typeof(JsonStringEnumConverter))] enum` |
109
+ | `union([literal(...), ...])` | Enum |
110
+ | `union([string(), array(string())])` | `StringOrStringList` helper (auto-emitted) |
111
+ | `union([{type:'A',...}, ...])` | `[JsonPolymorphic] abstract record` with derived records |
112
+ | `variant('type', [...])` | Same as discriminated union |
113
+ | `pipe(inner, ...validators)` | `inner` (validators are ignored) |
114
+
115
+ Cross-file imports (relative paths only) are followed automatically so imported schemas land in the same output file. Imports from `node_modules` are not traversed.
116
+
117
+ ## Caveats
118
+
119
+ - Not evaluated: the generator parses the source and never executes it. Schemas built at runtime (e.g. produced from a function) are invisible to it.
120
+ - String literal kludge: if you write `string('foo')` inside a `union(...)` (a shorthand some schemas use as a literal placeholder), the parser treats it as `literal('foo')` and notes the substitution in the generated file.
121
+ - Unsupported features: `lazy`, `tuple`, `intersect`, `custom` transforms, and non-string record keys. Heterogeneous unions with no common discriminator fall back to `JsonElement`.
122
+ - Nested anonymous objects are named `<ParentType><FieldName>` to avoid collisions with sibling inline types.
123
+ - `DTO` becomes `Dto` in emitted type names (e.g. `EventDefinitionDTOSchema` becomes `EventDefinitionDto`).
124
+
125
+ ## Extension points
126
+
127
+ - New valibot features: add a node kind to `src/ir.ts`, parse it in `src/parser.ts`, and teach `src/emitter.ts` how to render it. The IR is a closed union, so the compiler points at every switch that needs a new case.
128
+ - Naming: `--union-name` (CLI) / `unionRenames` (API) override the C# base-type name of a union or variant schema without touching the TS source.
129
+
130
+ ## Dependencies
131
+
132
+ - `@typescript/typescript6`: the classic in-process TypeScript compiler API (`ts.createSourceFile` and friends). TypeScript 7 no longer exposes that API from the `typescript` package root, so the parser uses this compatibility package while the repo compiles with TS 7.
133
+
134
+ ## Consumers
135
+
136
+ The `*-schemas` contract packages in this repo, and game repos that need C# mirrors of their JSON contracts for .NET clients.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { generateToFile } from "./generate.js";
5
+ const USAGE = `valibot-to-csharp: generate C# record types from valibot schemas
6
+
7
+ Usage:
8
+ valibot-to-csharp --input <file.ts> [--input <file.ts>...] --output <dir> \\
9
+ --namespace <Ns> [--bundle <BundleName.cs>] \\
10
+ [--union-name <SchemaConst>:<CSharpName>]
11
+
12
+ Options:
13
+ --input, -i TS file containing valibot schemas (repeatable)
14
+ --output, -o Output directory for generated .cs files
15
+ --namespace, -n C# namespace for emitted types (default: Generated)
16
+ --bundle Write a single bundle .cs file with the given name
17
+ --union-name Override the C# name of a union/variant schema
18
+ (repeatable, format: SchemaConst:CSharpName)
19
+ --help, -h Show this help
20
+ `;
21
+ function parseArgs(argv) {
22
+ const out = { inputs: [], unionRenames: {}, help: false };
23
+ let i = 0;
24
+ while (i < argv.length) {
25
+ const a = argv[i];
26
+ const next = () => {
27
+ const v = argv[i + 1];
28
+ if (v === undefined)
29
+ throw new Error(`Missing value for ${a}`);
30
+ i += 2;
31
+ return v;
32
+ };
33
+ switch (a) {
34
+ case "--help":
35
+ case "-h":
36
+ out.help = true;
37
+ i++;
38
+ break;
39
+ case "--input":
40
+ case "-i":
41
+ out.inputs.push(next());
42
+ break;
43
+ case "--output":
44
+ case "-o":
45
+ out.output = next();
46
+ break;
47
+ case "--namespace":
48
+ case "-n":
49
+ out.namespace = next();
50
+ break;
51
+ case "--bundle":
52
+ out.bundle = next();
53
+ break;
54
+ case "--union-name": {
55
+ const v = next();
56
+ const sep = v.indexOf(":");
57
+ if (sep <= 0)
58
+ throw new Error(`--union-name expects SchemaConst:CSharpName, got '${v}'`);
59
+ const key = v.slice(0, sep);
60
+ const value = v.slice(sep + 1);
61
+ out.unionRenames[key] = value;
62
+ break;
63
+ }
64
+ default:
65
+ // Positional arguments are treated as inputs for convenience.
66
+ if (a.startsWith("-")) {
67
+ throw new Error(`Unknown option: ${a}`);
68
+ }
69
+ out.inputs.push(a);
70
+ i++;
71
+ break;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ async function main() {
77
+ let args;
78
+ try {
79
+ args = parseArgs(process.argv.slice(2));
80
+ }
81
+ catch (e) {
82
+ process.stderr.write(`${e.message}\n\n${USAGE}`);
83
+ process.exit(2);
84
+ }
85
+ if (args.help) {
86
+ process.stdout.write(USAGE);
87
+ return;
88
+ }
89
+ if (args.inputs.length === 0 || !args.output) {
90
+ process.stderr.write(`Missing required --input and/or --output.\n\n${USAGE}`);
91
+ process.exit(2);
92
+ }
93
+ const absInputs = args.inputs.map((f) => path.resolve(f));
94
+ for (const f of absInputs) {
95
+ if (!fs.existsSync(f)) {
96
+ process.stderr.write(`Input file not found: ${f}\n`);
97
+ process.exit(2);
98
+ }
99
+ }
100
+ const result = await generateToFile({
101
+ inputs: absInputs,
102
+ outputDir: path.resolve(args.output),
103
+ namespace: args.namespace ?? "Generated",
104
+ bundle: Boolean(args.bundle),
105
+ bundleName: args.bundle,
106
+ unionRenames: args.unionRenames,
107
+ });
108
+ const names = result.files.map((f) => path.basename(f.path)).join(", ");
109
+ process.stdout.write(`Generated ${result.files.length} file(s) in ${path.resolve(args.output)}: ${names}\n`);
110
+ }
111
+ main().catch((e) => {
112
+ process.stderr.write(`Error: ${e.stack ?? e}\n`);
113
+ process.exit(1);
114
+ });
115
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ import type { SchemaModule } from "./ir.js";
2
+ /**
3
+ * Options accepted by {@link emitModule}.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import type { EmitOptions } from "@game-infra/valibot-to-csharp";
8
+ *
9
+ * const opts: EmitOptions = {
10
+ * namespace: "MyGame.Events",
11
+ * unionRenames: new Map([["ActionSchema", "GameAction"]]),
12
+ * };
13
+ * ```
14
+ */
15
+ export interface EmitOptions {
16
+ namespace: string;
17
+ /** Optional renames for union/variant base types. Keyed by source const name. */
18
+ unionRenames?: Map<string, string>;
19
+ }
20
+ /**
21
+ * Result of {@link emitModule}.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { emitModule, parseFiles } from "@game-infra/valibot-to-csharp";
26
+ * import type { EmitResult } from "@game-infra/valibot-to-csharp";
27
+ *
28
+ * const result: EmitResult = emitModule(parseFiles(["./schemas/events.ts"]), {
29
+ * namespace: "MyGame.Events",
30
+ * });
31
+ * console.log(result.source);
32
+ * ```
33
+ */
34
+ export interface EmitResult {
35
+ /** Full C# source body (namespace + types). */
36
+ source: string;
37
+ /** True if any StringOrStringList helper was referenced. */
38
+ usesStringOrStringList: boolean;
39
+ }
40
+ /**
41
+ * Turn a {@link SchemaModule} into a C# source file body. The caller wraps
42
+ * this with the using-block header (see {@link emitFileHeader}) and writes it
43
+ * to disk.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { emitFileHeader, emitModule, parseFiles } from "@game-infra/valibot-to-csharp";
48
+ *
49
+ * const module = parseFiles(["./schemas/events.ts"]);
50
+ * const emit = emitModule(module, { namespace: "MyGame.Events" });
51
+ * const csFile = emitFileHeader(module.notes) + emit.source;
52
+ * ```
53
+ */
54
+ export declare function emitModule(module: SchemaModule, opts: EmitOptions): EmitResult;
55
+ /**
56
+ * Render the file-level header: auto-generated banner + using-block. Split out
57
+ * so callers that want to embed the emitter output in larger files can skip it.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import { emitFileHeader } from "@game-infra/valibot-to-csharp";
62
+ *
63
+ * const header = emitFileHeader([]);
64
+ * // "// <auto-generated>..." plus the System.Text.Json using-block
65
+ * ```
66
+ */
67
+ export declare function emitFileHeader(notes: string[]): string;
68
+ //# sourceMappingURL=emitter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emitter.d.ts","sourceRoot":"","sources":["../src/emitter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,YAAY,EAGb,MAAM,SAAS,CAAC;AAGjB;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,UAAU;IACzB,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,sBAAsB,EAAE,OAAO,CAAC;CACjC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CA0B9E;AAgBD;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAgBtD"}