@supalive/codegen 1.1.0 → 1.2.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/dist/cli.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -1
- package/dist/{src-BD-RIuCu.js → src-DIj2CWMc.js} +157 -11
- package/package.json +3 -2
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -62,10 +62,14 @@ interface FieldIR {
|
|
|
62
62
|
optional: boolean;
|
|
63
63
|
/** May be explicitly null (Zod `.nullable()` / TS `| null`). */
|
|
64
64
|
nullable: boolean;
|
|
65
|
+
/** Doc comment copied from the TS source (JSDoc or Zod `.describe()`). */
|
|
66
|
+
doc?: string;
|
|
65
67
|
}
|
|
66
68
|
interface ModelIR {
|
|
67
69
|
name: string;
|
|
68
70
|
fields: FieldIR[];
|
|
71
|
+
/** Doc comment copied from the TS type declaration, when it has one. */
|
|
72
|
+
doc?: string;
|
|
69
73
|
}
|
|
70
74
|
interface EnumValueIR {
|
|
71
75
|
dartName: string;
|
|
@@ -86,6 +90,8 @@ interface ProcedureIR {
|
|
|
86
90
|
result: DartType;
|
|
87
91
|
/** Whether the result may be null (TS `T | null`). */
|
|
88
92
|
resultNullable: boolean;
|
|
93
|
+
/** Doc comment copied from the procedure's TS declaration (JSDoc). */
|
|
94
|
+
doc?: string;
|
|
89
95
|
}
|
|
90
96
|
interface ClientIR {
|
|
91
97
|
clientClassName: string;
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as emit, i as resolveConfig, n as generateToDisk, o as extractClient, r as readEntryConfig, t as generate } from "./src-
|
|
1
|
+
import { a as emit, i as resolveConfig, n as generateToDisk, o as extractClient, r as readEntryConfig, t as generate } from "./src-DIj2CWMc.js";
|
|
2
2
|
export { emit, extractClient, generate, generateToDisk, readEntryConfig, resolveConfig };
|
|
@@ -2,6 +2,49 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Node, Project, ts } from "ts-morph";
|
|
4
4
|
//#region src/zod-schema.ts
|
|
5
|
+
/**
|
|
6
|
+
* Map each procedure to the doc comment on its declaration, so the generated
|
|
7
|
+
* client carries the same descriptions the backend documents. Reads the JSDoc
|
|
8
|
+
* on the procedures-map entry, or (for a shorthand like `{ staffLogin }`)
|
|
9
|
+
* follows the identifier to its `export const staffLogin = query({...})` and
|
|
10
|
+
* reads that statement's JSDoc. Best-effort: any failure yields no doc.
|
|
11
|
+
*/
|
|
12
|
+
function scanProcedureDocs(routerDecl) {
|
|
13
|
+
const result = /* @__PURE__ */ new Map();
|
|
14
|
+
try {
|
|
15
|
+
const proceduresObj = getProceduresObjectLiteral(routerDecl);
|
|
16
|
+
if (!proceduresObj) return result;
|
|
17
|
+
for (const prop of proceduresObj.getProperties()) {
|
|
18
|
+
const key = getPropertyKey(prop);
|
|
19
|
+
if (!key) continue;
|
|
20
|
+
const doc = procedureDocText(prop);
|
|
21
|
+
if (doc) result.set(key, doc);
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/** JSDoc on a procedures-map entry, or on the const it references. */
|
|
27
|
+
function procedureDocText(prop) {
|
|
28
|
+
const own = jsDocText(prop);
|
|
29
|
+
if (own) return own;
|
|
30
|
+
let idNode;
|
|
31
|
+
if (Node.isShorthandPropertyAssignment(prop)) idNode = prop.getNameNode();
|
|
32
|
+
else if (Node.isPropertyAssignment(prop)) idNode = prop.getInitializer();
|
|
33
|
+
if (idNode && Node.isIdentifier(idNode)) {
|
|
34
|
+
for (const def of idNode.getDefinitionNodes()) if (Node.isVariableDeclaration(def)) {
|
|
35
|
+
const stmt = def.getVariableStatement();
|
|
36
|
+
const doc = stmt ? jsDocText(stmt) : void 0;
|
|
37
|
+
if (doc) return doc;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The trimmed description of a node's last JSDoc block, if any. */
|
|
42
|
+
function jsDocText(node) {
|
|
43
|
+
const docs = Node.isJSDocable(node) ? node.getJsDocs() : [];
|
|
44
|
+
if (docs.length === 0) return void 0;
|
|
45
|
+
const desc = docs[docs.length - 1].getDescription().trim();
|
|
46
|
+
return desc.length > 0 ? desc : void 0;
|
|
47
|
+
}
|
|
5
48
|
function scanZodArgMeta(routerDecl) {
|
|
6
49
|
const result = /* @__PURE__ */ new Map();
|
|
7
50
|
try {
|
|
@@ -76,7 +119,9 @@ function scanObjectSchema(schema) {
|
|
|
76
119
|
if (methods.has("bigint")) meta.numeric = "bigint";
|
|
77
120
|
else if (methods.has("int")) meta.numeric = "int";
|
|
78
121
|
if (methods.has("default") || methods.has("catch") || methods.has("prefault")) meta.optional = true;
|
|
79
|
-
|
|
122
|
+
const description = topLevelZodDescription(init);
|
|
123
|
+
if (description) meta.description = description;
|
|
124
|
+
if (meta.numeric || meta.optional || meta.description) map.set(key, meta);
|
|
80
125
|
}
|
|
81
126
|
return map;
|
|
82
127
|
}
|
|
@@ -97,6 +142,34 @@ function topLevelZodMethods(node) {
|
|
|
97
142
|
}
|
|
98
143
|
return methods;
|
|
99
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The description string from a field's top-level `.describe("...")` or
|
|
147
|
+
* `.meta({ description: "..." })`, walking the method chain (outermost first,
|
|
148
|
+
* so the outermost description wins).
|
|
149
|
+
*/
|
|
150
|
+
function topLevelZodDescription(node) {
|
|
151
|
+
let cur = node;
|
|
152
|
+
while (cur && Node.isCallExpression(cur)) {
|
|
153
|
+
const expr = cur.getExpression();
|
|
154
|
+
if (!Node.isPropertyAccessExpression(expr)) break;
|
|
155
|
+
const method = expr.getName();
|
|
156
|
+
if (method === "describe") {
|
|
157
|
+
const arg = cur.getArguments()[0];
|
|
158
|
+
if (arg && Node.isStringLiteral(arg)) return arg.getLiteralValue();
|
|
159
|
+
}
|
|
160
|
+
if (method === "meta") {
|
|
161
|
+
const arg = cur.getArguments()[0];
|
|
162
|
+
if (arg && Node.isObjectLiteralExpression(arg)) {
|
|
163
|
+
const d = arg.getProperty("description");
|
|
164
|
+
if (d && Node.isPropertyAssignment(d)) {
|
|
165
|
+
const init = d.getInitializer();
|
|
166
|
+
if (init && Node.isStringLiteral(init)) return init.getLiteralValue();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
cur = expr.getExpression();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
100
173
|
function findZodObjectLiteral(node) {
|
|
101
174
|
let cur = node;
|
|
102
175
|
while (cur && Node.isCallExpression(cur)) {
|
|
@@ -115,20 +188,26 @@ function findZodObjectLiteral(node) {
|
|
|
115
188
|
//#region src/extract.ts
|
|
116
189
|
/** Load the router from the entry file and extract a ClientIR. */
|
|
117
190
|
function extractClient(config) {
|
|
118
|
-
const
|
|
191
|
+
const project = new Project({
|
|
119
192
|
tsConfigFilePath: config.tsconfig,
|
|
120
193
|
skipAddingFilesFromTsConfig: config.tsconfig ? false : true,
|
|
121
194
|
compilerOptions: config.tsconfig ? void 0 : {
|
|
122
195
|
strict: true,
|
|
123
196
|
target: ts.ScriptTarget.ES2022
|
|
124
197
|
}
|
|
125
|
-
})
|
|
198
|
+
});
|
|
199
|
+
const entry = project.addSourceFileAtPath(path.resolve(config.entry));
|
|
200
|
+
const opts = project.getCompilerOptions();
|
|
201
|
+
if (!(opts.strictNullChecks ?? opts.strict ?? false)) console.warn("[supalive-codegen] strictNullChecks is disabled" + (config.tsconfig ? ` in ${config.tsconfig}` : "") + ". Nullable/optional fields (`T | null`, `field?`) will be generated as non-nullable, because TypeScript erases those unions when strictNullChecks is off. Set `strict: true` (or `strictNullChecks: true`) in your tsconfig for correct output.");
|
|
202
|
+
const exported = entry.getExportedDeclarations().get(config.router);
|
|
126
203
|
if (!exported || exported.length === 0) throw new Error(`Could not find an export named "${config.router}" in ${config.entry}. Set { router: "<name>" } in your config or pass --router.`);
|
|
127
204
|
const routerDecl = exported[0];
|
|
128
205
|
const proceduresSym = routerDecl.getType().getProperty("procedures");
|
|
129
206
|
if (!proceduresSym) throw new Error(`Export "${config.router}" does not look like a Supalive router (no "procedures" property).`);
|
|
130
207
|
const proceduresType = proceduresSym.getTypeAtLocation(routerDecl);
|
|
131
|
-
const
|
|
208
|
+
const argMeta = scanZodArgMeta(routerDecl);
|
|
209
|
+
const procDocs = scanProcedureDocs(routerDecl);
|
|
210
|
+
const extractor = new Extractor(routerDecl, config, argMeta, project.getTypeChecker());
|
|
132
211
|
const procedures = [];
|
|
133
212
|
for (const procSym of proceduresType.getProperties()) {
|
|
134
213
|
const name = procSym.getName();
|
|
@@ -136,6 +215,8 @@ function extractClient(config) {
|
|
|
136
215
|
const ir = extractor.procedure(name, procType);
|
|
137
216
|
if (!ir) continue;
|
|
138
217
|
if (ir.internal && !config.includeInternal) continue;
|
|
218
|
+
const doc = procDocs.get(name);
|
|
219
|
+
if (doc) ir.doc = doc;
|
|
139
220
|
procedures.push(ir);
|
|
140
221
|
}
|
|
141
222
|
procedures.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -150,16 +231,28 @@ var Extractor = class {
|
|
|
150
231
|
locationNode;
|
|
151
232
|
config;
|
|
152
233
|
argMeta;
|
|
234
|
+
tc;
|
|
153
235
|
modelRegistry = /* @__PURE__ */ new Map();
|
|
154
236
|
enumRegistry = /* @__PURE__ */ new Map();
|
|
155
237
|
/** Value-set signature → already-registered enum name, for structural dedup. */
|
|
156
238
|
enumBySignature = /* @__PURE__ */ new Map();
|
|
157
239
|
nameCounts = /* @__PURE__ */ new Map();
|
|
158
240
|
visiting = /* @__PURE__ */ new Set();
|
|
159
|
-
constructor(locationNode, config, argMeta) {
|
|
241
|
+
constructor(locationNode, config, argMeta, tc) {
|
|
160
242
|
this.locationNode = locationNode;
|
|
161
243
|
this.config = config;
|
|
162
244
|
this.argMeta = argMeta;
|
|
245
|
+
this.tc = tc;
|
|
246
|
+
}
|
|
247
|
+
/** Doc comment attached to a symbol's declaration (resolves TS aliases). */
|
|
248
|
+
symbolDoc(sym) {
|
|
249
|
+
try {
|
|
250
|
+
const parts = sym.compilerSymbol.getDocumentationComment(this.tc.compilerObject);
|
|
251
|
+
const text = ts.displayPartsToString(parts).trim();
|
|
252
|
+
return text.length > 0 ? text : void 0;
|
|
253
|
+
} catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
163
256
|
}
|
|
164
257
|
models() {
|
|
165
258
|
return [...this.modelRegistry.values()];
|
|
@@ -186,6 +279,7 @@ var Extractor = class {
|
|
|
186
279
|
if (!meta) continue;
|
|
187
280
|
if (meta.numeric && (f.type.kind === "double" || f.type.kind === "int")) f.type = { kind: meta.numeric };
|
|
188
281
|
if (meta.optional) f.optional = true;
|
|
282
|
+
if (meta.description && !f.doc) f.doc = meta.description;
|
|
189
283
|
}
|
|
190
284
|
}
|
|
191
285
|
const ret = unwrapPromise(sig.getReturnType());
|
|
@@ -285,13 +379,17 @@ var Extractor = class {
|
|
|
285
379
|
jsonKey,
|
|
286
380
|
type: dartType,
|
|
287
381
|
optional,
|
|
288
|
-
nullable
|
|
382
|
+
nullable,
|
|
383
|
+
doc: this.symbolDoc(prop)
|
|
289
384
|
});
|
|
290
385
|
}
|
|
291
386
|
this.visiting.delete(name);
|
|
387
|
+
const modelSym = type.getAliasSymbol() ?? type.getSymbol();
|
|
388
|
+
const doc = modelSym ? this.symbolDoc(modelSym) : void 0;
|
|
292
389
|
this.modelRegistry.set(name, {
|
|
293
390
|
name,
|
|
294
|
-
fields
|
|
391
|
+
fields,
|
|
392
|
+
doc
|
|
295
393
|
});
|
|
296
394
|
return {
|
|
297
395
|
kind: "model",
|
|
@@ -457,10 +555,53 @@ function singular(hint) {
|
|
|
457
555
|
if (hint.endsWith("s") && !hint.endsWith("ss")) return hint.slice(0, -1);
|
|
458
556
|
return hint + "Item";
|
|
459
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Dart reserved words — none may be used as a bare identifier (e.g. an enum
|
|
560
|
+
* constant), so a wire value like `void` must be escaped.
|
|
561
|
+
*/
|
|
562
|
+
const DART_RESERVED = /* @__PURE__ */ new Set([
|
|
563
|
+
"assert",
|
|
564
|
+
"break",
|
|
565
|
+
"case",
|
|
566
|
+
"catch",
|
|
567
|
+
"class",
|
|
568
|
+
"const",
|
|
569
|
+
"continue",
|
|
570
|
+
"default",
|
|
571
|
+
"do",
|
|
572
|
+
"else",
|
|
573
|
+
"enum",
|
|
574
|
+
"extends",
|
|
575
|
+
"false",
|
|
576
|
+
"final",
|
|
577
|
+
"finally",
|
|
578
|
+
"for",
|
|
579
|
+
"if",
|
|
580
|
+
"in",
|
|
581
|
+
"is",
|
|
582
|
+
"new",
|
|
583
|
+
"null",
|
|
584
|
+
"rethrow",
|
|
585
|
+
"return",
|
|
586
|
+
"super",
|
|
587
|
+
"switch",
|
|
588
|
+
"this",
|
|
589
|
+
"throw",
|
|
590
|
+
"true",
|
|
591
|
+
"try",
|
|
592
|
+
"var",
|
|
593
|
+
"void",
|
|
594
|
+
"while",
|
|
595
|
+
"with"
|
|
596
|
+
]);
|
|
597
|
+
/** Suffix a trailing `_` when [name] is a Dart reserved word. */
|
|
598
|
+
function escapeDartId(name) {
|
|
599
|
+
return DART_RESERVED.has(name) ? `${name}_` : name;
|
|
600
|
+
}
|
|
460
601
|
function enumValueName(wire) {
|
|
461
602
|
const c = camel(wire);
|
|
462
603
|
if (!c || /^[0-9]/.test(c)) return "v" + pascal(wire);
|
|
463
|
-
return c;
|
|
604
|
+
return escapeDartId(c);
|
|
464
605
|
}
|
|
465
606
|
//#endregion
|
|
466
607
|
//#region src/emit.ts
|
|
@@ -474,6 +615,11 @@ function emit(ir) {
|
|
|
474
615
|
contents: emitClient(ir)
|
|
475
616
|
}];
|
|
476
617
|
}
|
|
618
|
+
/** Render source doc text as Dart `///` lines at [indent], or "" when absent. */
|
|
619
|
+
function docComment(text, indent) {
|
|
620
|
+
if (!text) return "";
|
|
621
|
+
return text.replace(/\r\n?/g, "\n").split("\n").map((line) => `${indent}///${line.length ? ` ${line}` : ""}`).join("\n") + "\n";
|
|
622
|
+
}
|
|
477
623
|
/** Dart type name for a non-null [DartType]. */
|
|
478
624
|
function typeName(t) {
|
|
479
625
|
switch (t.kind) {
|
|
@@ -601,7 +747,7 @@ function emitModel(m) {
|
|
|
601
747
|
if (f.optional && f.nullable) return ` this.${f.name} = const Optional.absent()`;
|
|
602
748
|
return !(f.optional || f.nullable) ? ` required this.${f.name}` : ` this.${f.name}`;
|
|
603
749
|
}).join(",\n");
|
|
604
|
-
const fields = m.fields.map((f) =>
|
|
750
|
+
const fields = m.fields.map((f) => `${docComment(f.doc, " ")} final ${fieldType(f)} ${f.name};`).join("\n");
|
|
605
751
|
const fromJson = m.fields.map((f) => emitFromJsonField(f)).join("\n");
|
|
606
752
|
const toJson = m.fields.map((f) => emitToJsonField(f)).join("\n");
|
|
607
753
|
const copyParams = m.fields.map((f) => ` Object? ${f.name} = _undefined`).join(",\n");
|
|
@@ -621,7 +767,7 @@ function emitModel(m) {
|
|
|
621
767
|
}).join(" &&\n ");
|
|
622
768
|
const hashParts = m.fields.map((f) => isDeep(f) ? `deepHashCode(${f.name})` : f.name).join(", ");
|
|
623
769
|
const hashBody = m.fields.length === 0 ? "runtimeType.hashCode" : m.fields.length <= 19 ? `Object.hash(runtimeType, ${hashParts})` : `Object.hashAll([runtimeType, ${hashParts}])`;
|
|
624
|
-
return
|
|
770
|
+
return `${docComment(m.doc, "")}class ${m.name} implements SupaliveModel {
|
|
625
771
|
const ${m.name}(${m.fields.length ? "{\n" + ctorParams + ",\n }" : ""});
|
|
626
772
|
|
|
627
773
|
${fields}
|
|
@@ -725,7 +871,7 @@ function emitEndpoint(p) {
|
|
|
725
871
|
const endpointClass = p.kind === "query" ? "QueryEndpoint" : p.kind === "mutation" ? "MutationEndpoint" : "ActionEndpoint";
|
|
726
872
|
const args = argsTypeName(p);
|
|
727
873
|
const result = resultTypeName(p);
|
|
728
|
-
return
|
|
874
|
+
return `${docComment(p.doc, " ")} late final ${p.name} = ${endpointClass}<${args}, ${result}>(
|
|
729
875
|
_client,
|
|
730
876
|
'${p.name}',
|
|
731
877
|
${encodeArgs(p)},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supalive/codegen",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Generate type-safe client code (Dart, and more) from a Supalive TypeScript router.",
|
|
5
5
|
"author": "Rebaz Raouf",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsdown",
|
|
43
43
|
"dev": "node --import tsx ./src/cli.ts",
|
|
44
|
-
"typecheck": "tsc --noEmit"
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest --run"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"ts-morph": "^24.0.0"
|