@ic-reactor/codegen 0.7.2 → 0.9.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 +13 -2
- package/dist/index.cjs +75 -28
- package/dist/index.d.cts +31 -6
- package/dist/index.d.ts +31 -6
- package/dist/index.js +74 -28
- package/package.json +2 -2
- package/src/__snapshots__/reactor.test.ts.snap +24 -3
- package/src/generators/index.ts +1 -1
- package/src/generators/reactor.ts +51 -21
- package/src/index.ts +1 -0
- package/src/pipeline.test.ts +202 -10
- package/src/pipeline.ts +69 -15
- package/src/reactor.test.ts +37 -1
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ await runCanisterPipeline({
|
|
|
24
24
|
globalConfig: {
|
|
25
25
|
outDir: "src/declarations",
|
|
26
26
|
clientManagerPath: "../../clients",
|
|
27
|
+
target: "react",
|
|
27
28
|
},
|
|
29
|
+
generateReactor: true,
|
|
28
30
|
})
|
|
29
31
|
```
|
|
30
32
|
|
|
@@ -38,14 +40,23 @@ Set `canisterConfig.mode` to choose the generated reactor class:
|
|
|
38
40
|
- `CandidDisplayReactor`
|
|
39
41
|
- `MetadataDisplayReactor`
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
Set `target` to control whether generated files include React hooks:
|
|
44
|
+
|
|
45
|
+
- `react` (default): generates the reactor plus bound `createActorHooks` exports
|
|
46
|
+
- `core`: generates only the typed reactor exports with no `@ic-reactor/react` dependency
|
|
47
|
+
|
|
48
|
+
Codegen now writes two files per canister: a managed `index.generated.ts` implementation that is regenerated on every run, and an `index.ts` entry wrapper. The wrapper is created once, then preserved unless it still matches the default generated wrapper or an older generated scaffold that can be migrated automatically.
|
|
49
|
+
|
|
50
|
+
Set `generateReactor: false` if you only want the bindgen/declaration output and
|
|
51
|
+
need to skip `index.generated.ts` and `index.ts`.
|
|
42
52
|
|
|
43
53
|
## Generators
|
|
44
54
|
|
|
45
55
|
You can also use individual generators if you need more granular control:
|
|
46
56
|
|
|
47
57
|
- **`generateDeclarations`**: Generates `.js` (factory), `.d.ts` (types), and `.did` copy.
|
|
48
|
-
- **`generateReactorFile`**: Generates the `index.ts`
|
|
58
|
+
- **`generateReactorFile`**: Generates the managed `index.generated.ts` implementation using either `DisplayReactor` or `Reactor`.
|
|
59
|
+
- **`generateReactorEntryFile`**: Generates the stable `index.ts` wrapper that re-exports from `index.generated.ts`.
|
|
49
60
|
- **`generateClientFile`**: Generates a `ClientManager` boilerplate file.
|
|
50
61
|
|
|
51
62
|
## Utilities
|
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ __export(index_exports, {
|
|
|
34
34
|
extractMethods: () => extractMethods,
|
|
35
35
|
generateClientFile: () => generateClientFile,
|
|
36
36
|
generateDeclarations: () => generateDeclarations,
|
|
37
|
+
generateReactorEntryFile: () => generateReactorEntryFile,
|
|
37
38
|
generateReactorFile: () => generateReactorFile,
|
|
38
39
|
getReactorName: () => getReactorName,
|
|
39
40
|
getServiceTypeName: () => getServiceTypeName,
|
|
@@ -123,11 +124,11 @@ function getServiceTypeName(canisterName) {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
// src/generators/reactor.ts
|
|
126
|
-
function getReactorClassImportSource(reactorClass) {
|
|
127
|
+
function getReactorClassImportSource(reactorClass, runtimeTarget) {
|
|
127
128
|
switch (reactorClass) {
|
|
128
129
|
case "Reactor":
|
|
129
130
|
case "DisplayReactor":
|
|
130
|
-
return "@ic-reactor/react";
|
|
131
|
+
return runtimeTarget === "core" ? "@ic-reactor/core" : "@ic-reactor/react";
|
|
131
132
|
case "CandidReactor":
|
|
132
133
|
case "CandidDisplayReactor":
|
|
133
134
|
case "MetadataDisplayReactor":
|
|
@@ -139,6 +140,8 @@ function generateReactorFile(options) {
|
|
|
139
140
|
canisterName,
|
|
140
141
|
didFile,
|
|
141
142
|
clientManagerPath = "../../clients",
|
|
143
|
+
canisterId,
|
|
144
|
+
runtimeTarget = "react",
|
|
142
145
|
reactorClass = "DisplayReactor"
|
|
143
146
|
} = options;
|
|
144
147
|
const pascalName = toPascalCase(canisterName);
|
|
@@ -146,9 +149,24 @@ function generateReactorFile(options) {
|
|
|
146
149
|
const serviceName = getServiceTypeName(canisterName);
|
|
147
150
|
const baseName = import_node_path2.default.basename(didFile, ".did");
|
|
148
151
|
const declarationsPath = `./declarations/${baseName}`;
|
|
149
|
-
const reactorImportSource = getReactorClassImportSource(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
const reactorImportSource = getReactorClassImportSource(
|
|
153
|
+
reactorClass,
|
|
154
|
+
runtimeTarget
|
|
155
|
+
);
|
|
156
|
+
const canisterIdLine = canisterId ? ` canisterId: ${JSON.stringify(canisterId)},
|
|
157
|
+
` : "";
|
|
158
|
+
const hookExports = runtimeTarget === "react" ? `
|
|
159
|
+
|
|
160
|
+
export const {
|
|
161
|
+
useActorQuery: use${pascalName}Query,
|
|
162
|
+
useActorSuspenseQuery: use${pascalName}SuspenseQuery,
|
|
163
|
+
useActorInfiniteQuery: use${pascalName}InfiniteQuery,
|
|
164
|
+
useActorSuspenseInfiniteQuery: use${pascalName}SuspenseInfiniteQuery,
|
|
165
|
+
useActorMutation: use${pascalName}Mutation,
|
|
166
|
+
useActorMethod: use${pascalName}Method,
|
|
167
|
+
} = createActorHooks(${reactorName})
|
|
168
|
+
` : "";
|
|
169
|
+
return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from "${reactorImportSource}"
|
|
152
170
|
import { clientManager } from "${clientManagerPath}"
|
|
153
171
|
import { idlFactory, type _SERVICE } from "${declarationsPath}"
|
|
154
172
|
|
|
@@ -158,22 +176,22 @@ export type ${serviceName} = _SERVICE
|
|
|
158
176
|
* ${pascalName} Reactor
|
|
159
177
|
*
|
|
160
178
|
* Auto-generated by @ic-reactor/codegen \u2014 do not edit.
|
|
161
|
-
*
|
|
179
|
+
* This file is overwritten whenever generation runs.
|
|
162
180
|
*/
|
|
163
181
|
export const ${reactorName} = new ${reactorClass}<${serviceName}>({
|
|
164
182
|
clientManager,
|
|
165
183
|
idlFactory,
|
|
166
|
-
name: "${canisterName}",
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
${canisterIdLine} name: "${canisterName}",
|
|
185
|
+
})${hookExports || "\n"}`;
|
|
186
|
+
}
|
|
187
|
+
function generateReactorEntryFile() {
|
|
188
|
+
return `/**
|
|
189
|
+
* Canister entrypoint.
|
|
190
|
+
*
|
|
191
|
+
* Created once by @ic-reactor/codegen and safe to customize.
|
|
192
|
+
* Keep the re-export below if you want generated exports and types to stay in sync.
|
|
193
|
+
*/
|
|
194
|
+
export * from "./index.generated"
|
|
177
195
|
`;
|
|
178
196
|
}
|
|
179
197
|
|
|
@@ -181,11 +199,25 @@ export const {
|
|
|
181
199
|
function resolveReactorClass(canisterConfig) {
|
|
182
200
|
return canisterConfig.mode ?? "DisplayReactor";
|
|
183
201
|
}
|
|
184
|
-
function
|
|
202
|
+
function resolveRuntimeTarget(canisterConfig, globalConfig) {
|
|
203
|
+
return canisterConfig.target ?? globalConfig.target ?? "react";
|
|
204
|
+
}
|
|
205
|
+
function normalizeFileContent(content) {
|
|
206
|
+
return content.replace(/\r\n/g, "\n").trim();
|
|
207
|
+
}
|
|
208
|
+
function isLegacyGeneratedIndexFile(content) {
|
|
185
209
|
return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(");
|
|
186
210
|
}
|
|
211
|
+
function isManagedEntryWrapper(content, expectedEntryContent) {
|
|
212
|
+
return normalizeFileContent(content) === normalizeFileContent(expectedEntryContent);
|
|
213
|
+
}
|
|
187
214
|
async function runCanisterPipeline(options) {
|
|
188
|
-
const {
|
|
215
|
+
const {
|
|
216
|
+
canisterConfig,
|
|
217
|
+
projectRoot,
|
|
218
|
+
globalConfig,
|
|
219
|
+
generateReactor = true
|
|
220
|
+
} = options;
|
|
189
221
|
const { name, didFile, clientManagerPath } = canisterConfig;
|
|
190
222
|
const files = [];
|
|
191
223
|
const resolvedDidFile = import_node_path3.default.isAbsolute(didFile) ? didFile : import_node_path3.default.resolve(projectRoot, didFile);
|
|
@@ -222,26 +254,40 @@ async function runCanisterPipeline(options) {
|
|
|
222
254
|
error: `Declarations step failed: ${err instanceof Error ? err.message : String(err)}`
|
|
223
255
|
};
|
|
224
256
|
}
|
|
225
|
-
|
|
257
|
+
if (!generateReactor) {
|
|
258
|
+
return {
|
|
259
|
+
canisterName: name,
|
|
260
|
+
success: true,
|
|
261
|
+
files
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const reactorPath = import_node_path3.default.join(canisterOutDir, "index.generated.ts");
|
|
265
|
+
const entryPath = import_node_path3.default.join(canisterOutDir, "index.ts");
|
|
226
266
|
const reactorClass = resolveReactorClass(canisterConfig);
|
|
267
|
+
const runtimeTarget = resolveRuntimeTarget(canisterConfig, globalConfig);
|
|
227
268
|
try {
|
|
228
269
|
const reactorContent = generateReactorFile({
|
|
229
270
|
canisterName: name,
|
|
230
271
|
didFile: resolvedDidFile,
|
|
231
272
|
clientManagerPath: resolvedClientManagerPath,
|
|
273
|
+
canisterId: canisterConfig.canisterId,
|
|
274
|
+
runtimeTarget,
|
|
232
275
|
reactorClass
|
|
233
276
|
});
|
|
277
|
+
const entryContent = generateReactorEntryFile();
|
|
234
278
|
import_node_fs2.default.mkdirSync(canisterOutDir, { recursive: true });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
279
|
+
import_node_fs2.default.writeFileSync(reactorPath, reactorContent);
|
|
280
|
+
files.push({ success: true, filePath: reactorPath });
|
|
281
|
+
if (!import_node_fs2.default.existsSync(entryPath)) {
|
|
282
|
+
import_node_fs2.default.writeFileSync(entryPath, entryContent);
|
|
283
|
+
files.push({ success: true, filePath: entryPath });
|
|
238
284
|
} else {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
import_node_fs2.default.writeFileSync(
|
|
242
|
-
files.push({ success: true, filePath:
|
|
285
|
+
const existingEntryContent = import_node_fs2.default.readFileSync(entryPath, "utf-8");
|
|
286
|
+
if (isLegacyGeneratedIndexFile(existingEntryContent) || isManagedEntryWrapper(existingEntryContent, entryContent)) {
|
|
287
|
+
import_node_fs2.default.writeFileSync(entryPath, entryContent);
|
|
288
|
+
files.push({ success: true, filePath: entryPath });
|
|
243
289
|
} else {
|
|
244
|
-
files.push({ success: true, filePath:
|
|
290
|
+
files.push({ success: true, filePath: entryPath, skipped: true });
|
|
245
291
|
}
|
|
246
292
|
}
|
|
247
293
|
} catch (err) {
|
|
@@ -325,6 +371,7 @@ export const clientManager = new ClientManager({
|
|
|
325
371
|
extractMethods,
|
|
326
372
|
generateClientFile,
|
|
327
373
|
generateDeclarations,
|
|
374
|
+
generateReactorEntryFile,
|
|
328
375
|
generateReactorFile,
|
|
329
376
|
getReactorName,
|
|
330
377
|
getServiceTypeName,
|
package/dist/index.d.cts
CHANGED
|
@@ -24,10 +24,16 @@ interface CanisterConfig {
|
|
|
24
24
|
* Defaults to DisplayReactor for backward compatibility.
|
|
25
25
|
*/
|
|
26
26
|
mode?: ReactorClassName;
|
|
27
|
+
/**
|
|
28
|
+
* Generated runtime target.
|
|
29
|
+
* `react` emits bound React hooks, `core` emits only the typed reactor exports.
|
|
30
|
+
*/
|
|
31
|
+
target?: CodegenTarget;
|
|
27
32
|
/** Optional fixed canister ID */
|
|
28
33
|
canisterId?: string;
|
|
29
34
|
}
|
|
30
35
|
type ReactorClassName = "Reactor" | "DisplayReactor" | "CandidReactor" | "CandidDisplayReactor" | "MetadataDisplayReactor";
|
|
36
|
+
type CodegenTarget = "react" | "core";
|
|
31
37
|
/**
|
|
32
38
|
* Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
|
|
33
39
|
*/
|
|
@@ -44,6 +50,11 @@ interface CodegenConfig {
|
|
|
44
50
|
* Individual canisters can override via `CanisterConfig.clientManagerPath`.
|
|
45
51
|
*/
|
|
46
52
|
clientManagerPath?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Default generated runtime target.
|
|
55
|
+
* Individual canisters can override via `CanisterConfig.target`.
|
|
56
|
+
*/
|
|
57
|
+
target?: CodegenTarget;
|
|
47
58
|
/** Canister configurations, keyed by canister name */
|
|
48
59
|
canisters: Record<string, CanisterConfig>;
|
|
49
60
|
}
|
|
@@ -68,7 +79,8 @@ interface GeneratorResult {
|
|
|
68
79
|
* Pipeline steps (in order):
|
|
69
80
|
* 1. Resolve paths (didFile, outDir)
|
|
70
81
|
* 2. Generate declarations (JS + .d.ts + .did copy)
|
|
71
|
-
* 3.
|
|
82
|
+
* 3. Optionally generate reactor implementation (`index.generated.ts`)
|
|
83
|
+
* 4. Optionally create or migrate the user entry (`index.ts`)
|
|
72
84
|
*/
|
|
73
85
|
|
|
74
86
|
interface PipelineOptions {
|
|
@@ -82,7 +94,12 @@ interface PipelineOptions {
|
|
|
82
94
|
/**
|
|
83
95
|
* Global codegen config (for fallback outDir and clientManagerPath).
|
|
84
96
|
*/
|
|
85
|
-
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">;
|
|
97
|
+
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "target">;
|
|
98
|
+
/**
|
|
99
|
+
* Whether the managed reactor files should be generated.
|
|
100
|
+
* Defaults to true.
|
|
101
|
+
*/
|
|
102
|
+
generateReactor?: boolean;
|
|
86
103
|
}
|
|
87
104
|
interface PipelineResult {
|
|
88
105
|
canisterName: string;
|
|
@@ -192,8 +209,8 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
|
|
|
192
209
|
/**
|
|
193
210
|
* Reactor File Generator
|
|
194
211
|
*
|
|
195
|
-
* Generates the
|
|
196
|
-
*
|
|
212
|
+
* Generates the managed `index.generated.ts` implementation for a canister.
|
|
213
|
+
* React targets also emit the full set of typed hooks via `createActorHooks`.
|
|
197
214
|
*
|
|
198
215
|
* Generated output example (for canister "backend"):
|
|
199
216
|
*
|
|
@@ -224,6 +241,10 @@ interface ReactorGeneratorOptions {
|
|
|
224
241
|
* Default: "../../clients"
|
|
225
242
|
*/
|
|
226
243
|
clientManagerPath?: string;
|
|
244
|
+
/** Optional fixed canister ID for the generated reactor */
|
|
245
|
+
canisterId?: string;
|
|
246
|
+
/** Generated runtime target */
|
|
247
|
+
runtimeTarget?: CodegenTarget;
|
|
227
248
|
/**
|
|
228
249
|
* Which reactor class should back the generated hooks.
|
|
229
250
|
* Default: "DisplayReactor" (backward compatible)
|
|
@@ -231,9 +252,13 @@ interface ReactorGeneratorOptions {
|
|
|
231
252
|
reactorClass?: ReactorClassName;
|
|
232
253
|
}
|
|
233
254
|
/**
|
|
234
|
-
* Generate the content of a canister's
|
|
255
|
+
* Generate the content of a canister's managed implementation file.
|
|
235
256
|
*/
|
|
236
257
|
declare function generateReactorFile(options: ReactorGeneratorOptions): string;
|
|
258
|
+
/**
|
|
259
|
+
* Generate the user-facing `index.ts` wrapper content.
|
|
260
|
+
*/
|
|
261
|
+
declare function generateReactorEntryFile(): string;
|
|
237
262
|
|
|
238
263
|
/**
|
|
239
264
|
* Client Manager Generator
|
|
@@ -258,4 +283,4 @@ interface ClientGeneratorOptions {
|
|
|
258
283
|
*/
|
|
259
284
|
declare function generateClientFile(options?: ClientGeneratorOptions): string;
|
|
260
285
|
|
|
261
|
-
export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
|
|
286
|
+
export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type CodegenTarget, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorEntryFile, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
|
package/dist/index.d.ts
CHANGED
|
@@ -24,10 +24,16 @@ interface CanisterConfig {
|
|
|
24
24
|
* Defaults to DisplayReactor for backward compatibility.
|
|
25
25
|
*/
|
|
26
26
|
mode?: ReactorClassName;
|
|
27
|
+
/**
|
|
28
|
+
* Generated runtime target.
|
|
29
|
+
* `react` emits bound React hooks, `core` emits only the typed reactor exports.
|
|
30
|
+
*/
|
|
31
|
+
target?: CodegenTarget;
|
|
27
32
|
/** Optional fixed canister ID */
|
|
28
33
|
canisterId?: string;
|
|
29
34
|
}
|
|
30
35
|
type ReactorClassName = "Reactor" | "DisplayReactor" | "CandidReactor" | "CandidDisplayReactor" | "MetadataDisplayReactor";
|
|
36
|
+
type CodegenTarget = "react" | "core";
|
|
31
37
|
/**
|
|
32
38
|
* Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
|
|
33
39
|
*/
|
|
@@ -44,6 +50,11 @@ interface CodegenConfig {
|
|
|
44
50
|
* Individual canisters can override via `CanisterConfig.clientManagerPath`.
|
|
45
51
|
*/
|
|
46
52
|
clientManagerPath?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Default generated runtime target.
|
|
55
|
+
* Individual canisters can override via `CanisterConfig.target`.
|
|
56
|
+
*/
|
|
57
|
+
target?: CodegenTarget;
|
|
47
58
|
/** Canister configurations, keyed by canister name */
|
|
48
59
|
canisters: Record<string, CanisterConfig>;
|
|
49
60
|
}
|
|
@@ -68,7 +79,8 @@ interface GeneratorResult {
|
|
|
68
79
|
* Pipeline steps (in order):
|
|
69
80
|
* 1. Resolve paths (didFile, outDir)
|
|
70
81
|
* 2. Generate declarations (JS + .d.ts + .did copy)
|
|
71
|
-
* 3.
|
|
82
|
+
* 3. Optionally generate reactor implementation (`index.generated.ts`)
|
|
83
|
+
* 4. Optionally create or migrate the user entry (`index.ts`)
|
|
72
84
|
*/
|
|
73
85
|
|
|
74
86
|
interface PipelineOptions {
|
|
@@ -82,7 +94,12 @@ interface PipelineOptions {
|
|
|
82
94
|
/**
|
|
83
95
|
* Global codegen config (for fallback outDir and clientManagerPath).
|
|
84
96
|
*/
|
|
85
|
-
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">;
|
|
97
|
+
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "target">;
|
|
98
|
+
/**
|
|
99
|
+
* Whether the managed reactor files should be generated.
|
|
100
|
+
* Defaults to true.
|
|
101
|
+
*/
|
|
102
|
+
generateReactor?: boolean;
|
|
86
103
|
}
|
|
87
104
|
interface PipelineResult {
|
|
88
105
|
canisterName: string;
|
|
@@ -192,8 +209,8 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
|
|
|
192
209
|
/**
|
|
193
210
|
* Reactor File Generator
|
|
194
211
|
*
|
|
195
|
-
* Generates the
|
|
196
|
-
*
|
|
212
|
+
* Generates the managed `index.generated.ts` implementation for a canister.
|
|
213
|
+
* React targets also emit the full set of typed hooks via `createActorHooks`.
|
|
197
214
|
*
|
|
198
215
|
* Generated output example (for canister "backend"):
|
|
199
216
|
*
|
|
@@ -224,6 +241,10 @@ interface ReactorGeneratorOptions {
|
|
|
224
241
|
* Default: "../../clients"
|
|
225
242
|
*/
|
|
226
243
|
clientManagerPath?: string;
|
|
244
|
+
/** Optional fixed canister ID for the generated reactor */
|
|
245
|
+
canisterId?: string;
|
|
246
|
+
/** Generated runtime target */
|
|
247
|
+
runtimeTarget?: CodegenTarget;
|
|
227
248
|
/**
|
|
228
249
|
* Which reactor class should back the generated hooks.
|
|
229
250
|
* Default: "DisplayReactor" (backward compatible)
|
|
@@ -231,9 +252,13 @@ interface ReactorGeneratorOptions {
|
|
|
231
252
|
reactorClass?: ReactorClassName;
|
|
232
253
|
}
|
|
233
254
|
/**
|
|
234
|
-
* Generate the content of a canister's
|
|
255
|
+
* Generate the content of a canister's managed implementation file.
|
|
235
256
|
*/
|
|
236
257
|
declare function generateReactorFile(options: ReactorGeneratorOptions): string;
|
|
258
|
+
/**
|
|
259
|
+
* Generate the user-facing `index.ts` wrapper content.
|
|
260
|
+
*/
|
|
261
|
+
declare function generateReactorEntryFile(): string;
|
|
237
262
|
|
|
238
263
|
/**
|
|
239
264
|
* Client Manager Generator
|
|
@@ -258,4 +283,4 @@ interface ClientGeneratorOptions {
|
|
|
258
283
|
*/
|
|
259
284
|
declare function generateClientFile(options?: ClientGeneratorOptions): string;
|
|
260
285
|
|
|
261
|
-
export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
|
|
286
|
+
export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type CodegenTarget, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorEntryFile, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
|
package/dist/index.js
CHANGED
|
@@ -78,11 +78,11 @@ function getServiceTypeName(canisterName) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// src/generators/reactor.ts
|
|
81
|
-
function getReactorClassImportSource(reactorClass) {
|
|
81
|
+
function getReactorClassImportSource(reactorClass, runtimeTarget) {
|
|
82
82
|
switch (reactorClass) {
|
|
83
83
|
case "Reactor":
|
|
84
84
|
case "DisplayReactor":
|
|
85
|
-
return "@ic-reactor/react";
|
|
85
|
+
return runtimeTarget === "core" ? "@ic-reactor/core" : "@ic-reactor/react";
|
|
86
86
|
case "CandidReactor":
|
|
87
87
|
case "CandidDisplayReactor":
|
|
88
88
|
case "MetadataDisplayReactor":
|
|
@@ -94,6 +94,8 @@ function generateReactorFile(options) {
|
|
|
94
94
|
canisterName,
|
|
95
95
|
didFile,
|
|
96
96
|
clientManagerPath = "../../clients",
|
|
97
|
+
canisterId,
|
|
98
|
+
runtimeTarget = "react",
|
|
97
99
|
reactorClass = "DisplayReactor"
|
|
98
100
|
} = options;
|
|
99
101
|
const pascalName = toPascalCase(canisterName);
|
|
@@ -101,9 +103,24 @@ function generateReactorFile(options) {
|
|
|
101
103
|
const serviceName = getServiceTypeName(canisterName);
|
|
102
104
|
const baseName = path2.basename(didFile, ".did");
|
|
103
105
|
const declarationsPath = `./declarations/${baseName}`;
|
|
104
|
-
const reactorImportSource = getReactorClassImportSource(
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
const reactorImportSource = getReactorClassImportSource(
|
|
107
|
+
reactorClass,
|
|
108
|
+
runtimeTarget
|
|
109
|
+
);
|
|
110
|
+
const canisterIdLine = canisterId ? ` canisterId: ${JSON.stringify(canisterId)},
|
|
111
|
+
` : "";
|
|
112
|
+
const hookExports = runtimeTarget === "react" ? `
|
|
113
|
+
|
|
114
|
+
export const {
|
|
115
|
+
useActorQuery: use${pascalName}Query,
|
|
116
|
+
useActorSuspenseQuery: use${pascalName}SuspenseQuery,
|
|
117
|
+
useActorInfiniteQuery: use${pascalName}InfiniteQuery,
|
|
118
|
+
useActorSuspenseInfiniteQuery: use${pascalName}SuspenseInfiniteQuery,
|
|
119
|
+
useActorMutation: use${pascalName}Mutation,
|
|
120
|
+
useActorMethod: use${pascalName}Method,
|
|
121
|
+
} = createActorHooks(${reactorName})
|
|
122
|
+
` : "";
|
|
123
|
+
return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from "${reactorImportSource}"
|
|
107
124
|
import { clientManager } from "${clientManagerPath}"
|
|
108
125
|
import { idlFactory, type _SERVICE } from "${declarationsPath}"
|
|
109
126
|
|
|
@@ -113,22 +130,22 @@ export type ${serviceName} = _SERVICE
|
|
|
113
130
|
* ${pascalName} Reactor
|
|
114
131
|
*
|
|
115
132
|
* Auto-generated by @ic-reactor/codegen \u2014 do not edit.
|
|
116
|
-
*
|
|
133
|
+
* This file is overwritten whenever generation runs.
|
|
117
134
|
*/
|
|
118
135
|
export const ${reactorName} = new ${reactorClass}<${serviceName}>({
|
|
119
136
|
clientManager,
|
|
120
137
|
idlFactory,
|
|
121
|
-
name: "${canisterName}",
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
${canisterIdLine} name: "${canisterName}",
|
|
139
|
+
})${hookExports || "\n"}`;
|
|
140
|
+
}
|
|
141
|
+
function generateReactorEntryFile() {
|
|
142
|
+
return `/**
|
|
143
|
+
* Canister entrypoint.
|
|
144
|
+
*
|
|
145
|
+
* Created once by @ic-reactor/codegen and safe to customize.
|
|
146
|
+
* Keep the re-export below if you want generated exports and types to stay in sync.
|
|
147
|
+
*/
|
|
148
|
+
export * from "./index.generated"
|
|
132
149
|
`;
|
|
133
150
|
}
|
|
134
151
|
|
|
@@ -136,11 +153,25 @@ export const {
|
|
|
136
153
|
function resolveReactorClass(canisterConfig) {
|
|
137
154
|
return canisterConfig.mode ?? "DisplayReactor";
|
|
138
155
|
}
|
|
139
|
-
function
|
|
156
|
+
function resolveRuntimeTarget(canisterConfig, globalConfig) {
|
|
157
|
+
return canisterConfig.target ?? globalConfig.target ?? "react";
|
|
158
|
+
}
|
|
159
|
+
function normalizeFileContent(content) {
|
|
160
|
+
return content.replace(/\r\n/g, "\n").trim();
|
|
161
|
+
}
|
|
162
|
+
function isLegacyGeneratedIndexFile(content) {
|
|
140
163
|
return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(");
|
|
141
164
|
}
|
|
165
|
+
function isManagedEntryWrapper(content, expectedEntryContent) {
|
|
166
|
+
return normalizeFileContent(content) === normalizeFileContent(expectedEntryContent);
|
|
167
|
+
}
|
|
142
168
|
async function runCanisterPipeline(options) {
|
|
143
|
-
const {
|
|
169
|
+
const {
|
|
170
|
+
canisterConfig,
|
|
171
|
+
projectRoot,
|
|
172
|
+
globalConfig,
|
|
173
|
+
generateReactor = true
|
|
174
|
+
} = options;
|
|
144
175
|
const { name, didFile, clientManagerPath } = canisterConfig;
|
|
145
176
|
const files = [];
|
|
146
177
|
const resolvedDidFile = path3.isAbsolute(didFile) ? didFile : path3.resolve(projectRoot, didFile);
|
|
@@ -177,26 +208,40 @@ async function runCanisterPipeline(options) {
|
|
|
177
208
|
error: `Declarations step failed: ${err instanceof Error ? err.message : String(err)}`
|
|
178
209
|
};
|
|
179
210
|
}
|
|
180
|
-
|
|
211
|
+
if (!generateReactor) {
|
|
212
|
+
return {
|
|
213
|
+
canisterName: name,
|
|
214
|
+
success: true,
|
|
215
|
+
files
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const reactorPath = path3.join(canisterOutDir, "index.generated.ts");
|
|
219
|
+
const entryPath = path3.join(canisterOutDir, "index.ts");
|
|
181
220
|
const reactorClass = resolveReactorClass(canisterConfig);
|
|
221
|
+
const runtimeTarget = resolveRuntimeTarget(canisterConfig, globalConfig);
|
|
182
222
|
try {
|
|
183
223
|
const reactorContent = generateReactorFile({
|
|
184
224
|
canisterName: name,
|
|
185
225
|
didFile: resolvedDidFile,
|
|
186
226
|
clientManagerPath: resolvedClientManagerPath,
|
|
227
|
+
canisterId: canisterConfig.canisterId,
|
|
228
|
+
runtimeTarget,
|
|
187
229
|
reactorClass
|
|
188
230
|
});
|
|
231
|
+
const entryContent = generateReactorEntryFile();
|
|
189
232
|
fs2.mkdirSync(canisterOutDir, { recursive: true });
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
233
|
+
fs2.writeFileSync(reactorPath, reactorContent);
|
|
234
|
+
files.push({ success: true, filePath: reactorPath });
|
|
235
|
+
if (!fs2.existsSync(entryPath)) {
|
|
236
|
+
fs2.writeFileSync(entryPath, entryContent);
|
|
237
|
+
files.push({ success: true, filePath: entryPath });
|
|
193
238
|
} else {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
fs2.writeFileSync(
|
|
197
|
-
files.push({ success: true, filePath:
|
|
239
|
+
const existingEntryContent = fs2.readFileSync(entryPath, "utf-8");
|
|
240
|
+
if (isLegacyGeneratedIndexFile(existingEntryContent) || isManagedEntryWrapper(existingEntryContent, entryContent)) {
|
|
241
|
+
fs2.writeFileSync(entryPath, entryContent);
|
|
242
|
+
files.push({ success: true, filePath: entryPath });
|
|
198
243
|
} else {
|
|
199
|
-
files.push({ success: true, filePath:
|
|
244
|
+
files.push({ success: true, filePath: entryPath, skipped: true });
|
|
200
245
|
}
|
|
201
246
|
}
|
|
202
247
|
} catch (err) {
|
|
@@ -279,6 +324,7 @@ export {
|
|
|
279
324
|
extractMethods,
|
|
280
325
|
generateClientFile,
|
|
281
326
|
generateDeclarations,
|
|
327
|
+
generateReactorEntryFile,
|
|
282
328
|
generateReactorFile,
|
|
283
329
|
getReactorName,
|
|
284
330
|
getServiceTypeName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ic-reactor/codegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Shared code generation utilities for IC Reactor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@ic-reactor/parser": "0.4.6"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^25.3.
|
|
40
|
+
"@types/node": "^25.3.5",
|
|
41
41
|
"tsup": "^8.5.1",
|
|
42
42
|
"typescript": "^5.9.3",
|
|
43
43
|
"vitest": "^4.0.18"
|
|
@@ -12,7 +12,7 @@ export type BackendService = _SERVICE
|
|
|
12
12
|
* Backend Reactor
|
|
13
13
|
*
|
|
14
14
|
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
15
|
-
*
|
|
15
|
+
* This file is overwritten whenever generation runs.
|
|
16
16
|
*/
|
|
17
17
|
export const backendReactor = new DisplayReactor<BackendService>({
|
|
18
18
|
clientManager,
|
|
@@ -43,7 +43,7 @@ export type WorkflowEngineService = _SERVICE
|
|
|
43
43
|
* WorkflowEngine Reactor
|
|
44
44
|
*
|
|
45
45
|
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
46
|
-
*
|
|
46
|
+
* This file is overwritten whenever generation runs.
|
|
47
47
|
*/
|
|
48
48
|
export const workflowEngineReactor = new Reactor<WorkflowEngineService>({
|
|
49
49
|
clientManager,
|
|
@@ -74,7 +74,7 @@ export type LedgerService = _SERVICE
|
|
|
74
74
|
* Ledger Reactor
|
|
75
75
|
*
|
|
76
76
|
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
77
|
-
*
|
|
77
|
+
* This file is overwritten whenever generation runs.
|
|
78
78
|
*/
|
|
79
79
|
export const ledgerReactor = new MetadataDisplayReactor<LedgerService>({
|
|
80
80
|
clientManager,
|
|
@@ -92,3 +92,24 @@ export const {
|
|
|
92
92
|
} = createActorHooks(ledgerReactor)
|
|
93
93
|
"
|
|
94
94
|
`;
|
|
95
|
+
|
|
96
|
+
exports[`Reactor generator > supports core target generation without React hooks > core-display-reactor-index 1`] = `
|
|
97
|
+
"import { DisplayReactor } from "@ic-reactor/core"
|
|
98
|
+
import { clientManager } from "../../clients"
|
|
99
|
+
import { idlFactory, type _SERVICE } from "./declarations/backend"
|
|
100
|
+
|
|
101
|
+
export type BackendService = _SERVICE
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Backend Reactor
|
|
105
|
+
*
|
|
106
|
+
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
107
|
+
* This file is overwritten whenever generation runs.
|
|
108
|
+
*/
|
|
109
|
+
export const backendReactor = new DisplayReactor<BackendService>({
|
|
110
|
+
clientManager,
|
|
111
|
+
idlFactory,
|
|
112
|
+
name: "backend",
|
|
113
|
+
})
|
|
114
|
+
"
|
|
115
|
+
`;
|
package/src/generators/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type {
|
|
|
11
11
|
DeclarationsGeneratorResult,
|
|
12
12
|
} from "./declarations.js"
|
|
13
13
|
|
|
14
|
-
export { generateReactorFile } from "./reactor.js"
|
|
14
|
+
export { generateReactorFile, generateReactorEntryFile } from "./reactor.js"
|
|
15
15
|
export type { ReactorGeneratorOptions } from "./reactor.js"
|
|
16
16
|
|
|
17
17
|
export { generateClientFile } from "./client.js"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reactor File Generator
|
|
3
3
|
*
|
|
4
|
-
* Generates the
|
|
5
|
-
*
|
|
4
|
+
* Generates the managed `index.generated.ts` implementation for a canister.
|
|
5
|
+
* React targets also emit the full set of typed hooks via `createActorHooks`.
|
|
6
6
|
*
|
|
7
7
|
* Generated output example (for canister "backend"):
|
|
8
8
|
*
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import path from "node:path"
|
|
24
24
|
import { toPascalCase, getReactorName, getServiceTypeName } from "../naming.js"
|
|
25
|
-
import type { ReactorClassName } from "../types.js"
|
|
25
|
+
import type { CodegenTarget, ReactorClassName } from "../types.js"
|
|
26
26
|
|
|
27
27
|
export interface ReactorGeneratorOptions {
|
|
28
28
|
/** Canister name (e.g. "backend") */
|
|
@@ -37,6 +37,10 @@ export interface ReactorGeneratorOptions {
|
|
|
37
37
|
* Default: "../../clients"
|
|
38
38
|
*/
|
|
39
39
|
clientManagerPath?: string
|
|
40
|
+
/** Optional fixed canister ID for the generated reactor */
|
|
41
|
+
canisterId?: string
|
|
42
|
+
/** Generated runtime target */
|
|
43
|
+
runtimeTarget?: CodegenTarget
|
|
40
44
|
/**
|
|
41
45
|
* Which reactor class should back the generated hooks.
|
|
42
46
|
* Default: "DisplayReactor" (backward compatible)
|
|
@@ -45,12 +49,13 @@ export interface ReactorGeneratorOptions {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
function getReactorClassImportSource(
|
|
48
|
-
reactorClass: ReactorClassName
|
|
49
|
-
|
|
52
|
+
reactorClass: ReactorClassName,
|
|
53
|
+
runtimeTarget: CodegenTarget
|
|
54
|
+
): "@ic-reactor/react" | "@ic-reactor/core" | "@ic-reactor/candid" {
|
|
50
55
|
switch (reactorClass) {
|
|
51
56
|
case "Reactor":
|
|
52
57
|
case "DisplayReactor":
|
|
53
|
-
return "@ic-reactor/react"
|
|
58
|
+
return runtimeTarget === "core" ? "@ic-reactor/core" : "@ic-reactor/react"
|
|
54
59
|
case "CandidReactor":
|
|
55
60
|
case "CandidDisplayReactor":
|
|
56
61
|
case "MetadataDisplayReactor":
|
|
@@ -59,13 +64,15 @@ function getReactorClassImportSource(
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
|
-
* Generate the content of a canister's
|
|
67
|
+
* Generate the content of a canister's managed implementation file.
|
|
63
68
|
*/
|
|
64
69
|
export function generateReactorFile(options: ReactorGeneratorOptions): string {
|
|
65
70
|
const {
|
|
66
71
|
canisterName,
|
|
67
72
|
didFile,
|
|
68
73
|
clientManagerPath = "../../clients",
|
|
74
|
+
canisterId,
|
|
75
|
+
runtimeTarget = "react",
|
|
69
76
|
reactorClass = "DisplayReactor",
|
|
70
77
|
} = options
|
|
71
78
|
|
|
@@ -76,10 +83,29 @@ export function generateReactorFile(options: ReactorGeneratorOptions): string {
|
|
|
76
83
|
// Derive the declarations import path from the .did filename
|
|
77
84
|
const baseName = path.basename(didFile, ".did")
|
|
78
85
|
const declarationsPath = `./declarations/${baseName}`
|
|
79
|
-
const reactorImportSource = getReactorClassImportSource(
|
|
86
|
+
const reactorImportSource = getReactorClassImportSource(
|
|
87
|
+
reactorClass,
|
|
88
|
+
runtimeTarget
|
|
89
|
+
)
|
|
90
|
+
const canisterIdLine = canisterId
|
|
91
|
+
? ` canisterId: ${JSON.stringify(canisterId)},\n`
|
|
92
|
+
: ""
|
|
93
|
+
const hookExports =
|
|
94
|
+
runtimeTarget === "react"
|
|
95
|
+
? `
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
export const {
|
|
98
|
+
useActorQuery: use${pascalName}Query,
|
|
99
|
+
useActorSuspenseQuery: use${pascalName}SuspenseQuery,
|
|
100
|
+
useActorInfiniteQuery: use${pascalName}InfiniteQuery,
|
|
101
|
+
useActorSuspenseInfiniteQuery: use${pascalName}SuspenseInfiniteQuery,
|
|
102
|
+
useActorMutation: use${pascalName}Mutation,
|
|
103
|
+
useActorMethod: use${pascalName}Method,
|
|
104
|
+
} = createActorHooks(${reactorName})
|
|
105
|
+
`
|
|
106
|
+
: ""
|
|
107
|
+
|
|
108
|
+
return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from "${reactorImportSource}"
|
|
83
109
|
import { clientManager } from "${clientManagerPath}"
|
|
84
110
|
import { idlFactory, type _SERVICE } from "${declarationsPath}"
|
|
85
111
|
|
|
@@ -89,21 +115,25 @@ export type ${serviceName} = _SERVICE
|
|
|
89
115
|
* ${pascalName} Reactor
|
|
90
116
|
*
|
|
91
117
|
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
92
|
-
*
|
|
118
|
+
* This file is overwritten whenever generation runs.
|
|
93
119
|
*/
|
|
94
120
|
export const ${reactorName} = new ${reactorClass}<${serviceName}>({
|
|
95
121
|
clientManager,
|
|
96
122
|
idlFactory,
|
|
97
|
-
name: "${canisterName}",
|
|
98
|
-
})
|
|
123
|
+
${canisterIdLine} name: "${canisterName}",
|
|
124
|
+
})${hookExports || "\n"}`
|
|
125
|
+
}
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Generate the user-facing `index.ts` wrapper content.
|
|
129
|
+
*/
|
|
130
|
+
export function generateReactorEntryFile(): string {
|
|
131
|
+
return `/**
|
|
132
|
+
* Canister entrypoint.
|
|
133
|
+
*
|
|
134
|
+
* Created once by @ic-reactor/codegen and safe to customize.
|
|
135
|
+
* Keep the re-export below if you want generated exports and types to stay in sync.
|
|
136
|
+
*/
|
|
137
|
+
export * from "./index.generated"
|
|
108
138
|
`
|
|
109
139
|
}
|
package/src/index.ts
CHANGED
package/src/pipeline.test.ts
CHANGED
|
@@ -52,12 +52,177 @@ describe("Codegen pipeline", () => {
|
|
|
52
52
|
|
|
53
53
|
const indexPath = path.join(
|
|
54
54
|
projectRoot,
|
|
55
|
-
"src/declarations/workflow_engine/index.ts"
|
|
55
|
+
"src/declarations/workflow_engine/index.generated.ts"
|
|
56
56
|
)
|
|
57
57
|
const generated = fs.readFileSync(indexPath, "utf-8")
|
|
58
58
|
|
|
59
59
|
expect(generated).toContain("new Reactor<WorkflowEngineService>")
|
|
60
60
|
expect(generated).not.toContain("new DisplayReactor<WorkflowEngineService>")
|
|
61
|
+
expect(
|
|
62
|
+
fs.readFileSync(
|
|
63
|
+
path.join(projectRoot, "src/declarations/workflow_engine/index.ts"),
|
|
64
|
+
"utf-8"
|
|
65
|
+
)
|
|
66
|
+
).toContain('export * from "./index.generated"')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("writes a configured canisterId into the generated reactor", async () => {
|
|
70
|
+
const projectRoot = createTempProject()
|
|
71
|
+
writeDid(projectRoot, "workflow.did")
|
|
72
|
+
|
|
73
|
+
const result = await runCanisterPipeline({
|
|
74
|
+
canisterConfig: {
|
|
75
|
+
name: "workflow",
|
|
76
|
+
didFile: "workflow.did",
|
|
77
|
+
canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
|
|
78
|
+
},
|
|
79
|
+
projectRoot,
|
|
80
|
+
globalConfig: {
|
|
81
|
+
outDir: "src/declarations",
|
|
82
|
+
clientManagerPath: "../../clients",
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(true)
|
|
87
|
+
|
|
88
|
+
const indexPath = path.join(
|
|
89
|
+
projectRoot,
|
|
90
|
+
"src/declarations/workflow/index.generated.ts"
|
|
91
|
+
)
|
|
92
|
+
const generated = fs.readFileSync(indexPath, "utf-8")
|
|
93
|
+
|
|
94
|
+
expect(generated).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
|
|
95
|
+
expect(generated).toContain('name: "workflow"')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("can generate declarations without creating reactor files", async () => {
|
|
99
|
+
const projectRoot = createTempProject()
|
|
100
|
+
writeDid(projectRoot, "backend.did")
|
|
101
|
+
|
|
102
|
+
const result = await runCanisterPipeline({
|
|
103
|
+
canisterConfig: {
|
|
104
|
+
name: "backend",
|
|
105
|
+
didFile: "backend.did",
|
|
106
|
+
},
|
|
107
|
+
projectRoot,
|
|
108
|
+
globalConfig: {
|
|
109
|
+
outDir: "src/declarations",
|
|
110
|
+
clientManagerPath: "../../clients",
|
|
111
|
+
},
|
|
112
|
+
generateReactor: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(result.success).toBe(true)
|
|
116
|
+
expect(
|
|
117
|
+
fs.existsSync(
|
|
118
|
+
path.join(
|
|
119
|
+
projectRoot,
|
|
120
|
+
"src/declarations/backend/declarations/backend.js"
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
).toBe(true)
|
|
124
|
+
expect(
|
|
125
|
+
fs.existsSync(
|
|
126
|
+
path.join(
|
|
127
|
+
projectRoot,
|
|
128
|
+
"src/declarations/backend/declarations/backend.d.ts"
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
).toBe(true)
|
|
132
|
+
expect(
|
|
133
|
+
fs.existsSync(
|
|
134
|
+
path.join(
|
|
135
|
+
projectRoot,
|
|
136
|
+
"src/declarations/backend/declarations/backend.did"
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
).toBe(true)
|
|
140
|
+
expect(
|
|
141
|
+
fs.existsSync(
|
|
142
|
+
path.join(projectRoot, "src/declarations/backend/index.generated.ts")
|
|
143
|
+
)
|
|
144
|
+
).toBe(false)
|
|
145
|
+
expect(
|
|
146
|
+
fs.existsSync(path.join(projectRoot, "src/declarations/backend/index.ts"))
|
|
147
|
+
).toBe(false)
|
|
148
|
+
expect(
|
|
149
|
+
result.files.some((file) => file.filePath.endsWith("index.generated.ts"))
|
|
150
|
+
).toBe(false)
|
|
151
|
+
expect(
|
|
152
|
+
result.files.some((file) => file.filePath.endsWith("index.ts"))
|
|
153
|
+
).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("leaves existing reactor files untouched when reactor generation is disabled", async () => {
|
|
157
|
+
const projectRoot = createTempProject()
|
|
158
|
+
writeDid(projectRoot, "backend.did")
|
|
159
|
+
|
|
160
|
+
const canisterOutDir = path.join(projectRoot, "src/declarations/backend")
|
|
161
|
+
fs.mkdirSync(canisterOutDir, { recursive: true })
|
|
162
|
+
|
|
163
|
+
const existingGenerated = "// existing generated reactor"
|
|
164
|
+
const existingEntry = "// existing entry wrapper"
|
|
165
|
+
|
|
166
|
+
fs.writeFileSync(
|
|
167
|
+
path.join(canisterOutDir, "index.generated.ts"),
|
|
168
|
+
existingGenerated
|
|
169
|
+
)
|
|
170
|
+
fs.writeFileSync(path.join(canisterOutDir, "index.ts"), existingEntry)
|
|
171
|
+
|
|
172
|
+
const result = await runCanisterPipeline({
|
|
173
|
+
canisterConfig: {
|
|
174
|
+
name: "backend",
|
|
175
|
+
didFile: "backend.did",
|
|
176
|
+
},
|
|
177
|
+
projectRoot,
|
|
178
|
+
globalConfig: {
|
|
179
|
+
outDir: "src/declarations",
|
|
180
|
+
clientManagerPath: "../../clients",
|
|
181
|
+
},
|
|
182
|
+
generateReactor: false,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
expect(result.success).toBe(true)
|
|
186
|
+
expect(
|
|
187
|
+
fs.readFileSync(path.join(canisterOutDir, "index.generated.ts"), "utf-8")
|
|
188
|
+
).toBe(existingGenerated)
|
|
189
|
+
expect(
|
|
190
|
+
fs.readFileSync(path.join(canisterOutDir, "index.ts"), "utf-8")
|
|
191
|
+
).toBe(existingEntry)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("supports a core target without generating React hooks", async () => {
|
|
195
|
+
const projectRoot = createTempProject()
|
|
196
|
+
writeDid(projectRoot, "backend.did")
|
|
197
|
+
|
|
198
|
+
const result = await runCanisterPipeline({
|
|
199
|
+
canisterConfig: {
|
|
200
|
+
name: "backend",
|
|
201
|
+
didFile: "backend.did",
|
|
202
|
+
},
|
|
203
|
+
projectRoot,
|
|
204
|
+
globalConfig: {
|
|
205
|
+
outDir: "src/declarations",
|
|
206
|
+
clientManagerPath: "../../clients",
|
|
207
|
+
target: "core",
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(result.success).toBe(true)
|
|
212
|
+
|
|
213
|
+
const indexPath = path.join(
|
|
214
|
+
projectRoot,
|
|
215
|
+
"src/declarations/backend/index.generated.ts"
|
|
216
|
+
)
|
|
217
|
+
const generated = fs.readFileSync(indexPath, "utf-8")
|
|
218
|
+
|
|
219
|
+
expect(generated).toContain(
|
|
220
|
+
'import { DisplayReactor } from "@ic-reactor/core"'
|
|
221
|
+
)
|
|
222
|
+
expect(generated).not.toContain(
|
|
223
|
+
'import { createActorHooks } from "@ic-reactor/react"'
|
|
224
|
+
)
|
|
225
|
+
expect(generated).not.toContain("useBackendQuery")
|
|
61
226
|
})
|
|
62
227
|
|
|
63
228
|
it("does not overwrite user-modified index.ts on regenerate", async () => {
|
|
@@ -90,11 +255,22 @@ export const customBackendIndex = true
|
|
|
90
255
|
`
|
|
91
256
|
)
|
|
92
257
|
|
|
93
|
-
const second = await runCanisterPipeline(
|
|
258
|
+
const second = await runCanisterPipeline({
|
|
259
|
+
...options,
|
|
260
|
+
canisterConfig: {
|
|
261
|
+
...options.canisterConfig,
|
|
262
|
+
canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
|
|
263
|
+
},
|
|
264
|
+
})
|
|
94
265
|
expect(second.success).toBe(true)
|
|
95
266
|
|
|
96
267
|
const wrapper = fs.readFileSync(indexPath, "utf-8")
|
|
97
268
|
expect(wrapper).toContain("customBackendIndex = true")
|
|
269
|
+
const generatedImpl = fs.readFileSync(
|
|
270
|
+
path.join(projectRoot, "src/declarations/backend/index.generated.ts"),
|
|
271
|
+
"utf-8"
|
|
272
|
+
)
|
|
273
|
+
expect(generatedImpl).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
|
|
98
274
|
expect(second.files).toEqual(
|
|
99
275
|
expect.arrayContaining([
|
|
100
276
|
expect.objectContaining({
|
|
@@ -106,17 +282,14 @@ export const customBackendIndex = true
|
|
|
106
282
|
)
|
|
107
283
|
})
|
|
108
284
|
|
|
109
|
-
it("
|
|
285
|
+
it("migrates a legacy generated index.ts to the managed wrapper", async () => {
|
|
110
286
|
const projectRoot = createTempProject()
|
|
111
287
|
writeDid(projectRoot, "backend.did")
|
|
112
288
|
|
|
113
289
|
const canisterOutDir = path.join(projectRoot, "src/declarations/backend")
|
|
114
290
|
fs.mkdirSync(canisterOutDir, { recursive: true })
|
|
115
291
|
|
|
116
|
-
|
|
117
|
-
fs.writeFileSync(
|
|
118
|
-
path.join(canisterOutDir, "index.ts"),
|
|
119
|
-
`import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
|
|
292
|
+
const existingGeneratedIndex = `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
|
|
120
293
|
import { clientManager } from "../../clients"
|
|
121
294
|
import { idlFactory, type _SERVICE } from "./declarations/backend"
|
|
122
295
|
|
|
@@ -126,7 +299,6 @@ export type BackendService = _SERVICE
|
|
|
126
299
|
* Backend Reactor
|
|
127
300
|
*
|
|
128
301
|
* Auto-generated by @ic-reactor/codegen — do not edit.
|
|
129
|
-
* Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
|
|
130
302
|
*/
|
|
131
303
|
export const backendReactor = new DisplayReactor<BackendService>({
|
|
132
304
|
clientManager,
|
|
@@ -138,6 +310,9 @@ export const {
|
|
|
138
310
|
useActorQuery: useBackendQuery,
|
|
139
311
|
} = createActorHooks(backendReactor)
|
|
140
312
|
`
|
|
313
|
+
fs.writeFileSync(
|
|
314
|
+
path.join(canisterOutDir, "index.ts"),
|
|
315
|
+
existingGeneratedIndex
|
|
141
316
|
)
|
|
142
317
|
|
|
143
318
|
const result = await runCanisterPipeline({
|
|
@@ -155,9 +330,26 @@ export const {
|
|
|
155
330
|
expect(result.success).toBe(true)
|
|
156
331
|
|
|
157
332
|
const indexPath = path.join(canisterOutDir, "index.ts")
|
|
158
|
-
const
|
|
333
|
+
const entry = fs.readFileSync(indexPath, "utf-8")
|
|
334
|
+
const generated = fs.readFileSync(
|
|
335
|
+
path.join(canisterOutDir, "index.generated.ts"),
|
|
336
|
+
"utf-8"
|
|
337
|
+
)
|
|
338
|
+
expect(entry).toContain('export * from "./index.generated"')
|
|
339
|
+
expect(entry).not.toBe(existingGeneratedIndex)
|
|
159
340
|
expect(generated).toContain("new DisplayReactor<BackendService>")
|
|
160
341
|
expect(generated).toContain("useBackendMutation")
|
|
161
|
-
expect(
|
|
342
|
+
expect(result.files).toEqual(
|
|
343
|
+
expect.arrayContaining([
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
filePath: path.join(canisterOutDir, "index.generated.ts"),
|
|
346
|
+
success: true,
|
|
347
|
+
}),
|
|
348
|
+
expect.objectContaining({
|
|
349
|
+
filePath: indexPath,
|
|
350
|
+
success: true,
|
|
351
|
+
}),
|
|
352
|
+
])
|
|
353
|
+
)
|
|
162
354
|
})
|
|
163
355
|
})
|
package/src/pipeline.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Pipeline steps (in order):
|
|
8
8
|
* 1. Resolve paths (didFile, outDir)
|
|
9
9
|
* 2. Generate declarations (JS + .d.ts + .did copy)
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. Optionally generate reactor implementation (`index.generated.ts`)
|
|
11
|
+
* 4. Optionally create or migrate the user entry (`index.ts`)
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import fs from "node:fs"
|
|
@@ -15,11 +16,15 @@ import path from "node:path"
|
|
|
15
16
|
import type {
|
|
16
17
|
CanisterConfig,
|
|
17
18
|
CodegenConfig,
|
|
19
|
+
CodegenTarget,
|
|
18
20
|
GeneratorResult,
|
|
19
21
|
ReactorClassName,
|
|
20
22
|
} from "./types.js"
|
|
21
23
|
import { generateDeclarations } from "./generators/declarations.js"
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
generateReactorEntryFile,
|
|
26
|
+
generateReactorFile,
|
|
27
|
+
} from "./generators/reactor.js"
|
|
23
28
|
|
|
24
29
|
export interface PipelineOptions {
|
|
25
30
|
/** Canister name and config */
|
|
@@ -32,20 +37,45 @@ export interface PipelineOptions {
|
|
|
32
37
|
/**
|
|
33
38
|
* Global codegen config (for fallback outDir and clientManagerPath).
|
|
34
39
|
*/
|
|
35
|
-
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">
|
|
40
|
+
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "target">
|
|
41
|
+
/**
|
|
42
|
+
* Whether the managed reactor files should be generated.
|
|
43
|
+
* Defaults to true.
|
|
44
|
+
*/
|
|
45
|
+
generateReactor?: boolean
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
function resolveReactorClass(canisterConfig: CanisterConfig): ReactorClassName {
|
|
39
49
|
return canisterConfig.mode ?? "DisplayReactor"
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
function
|
|
52
|
+
function resolveRuntimeTarget(
|
|
53
|
+
canisterConfig: CanisterConfig,
|
|
54
|
+
globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "target">
|
|
55
|
+
): CodegenTarget {
|
|
56
|
+
return canisterConfig.target ?? globalConfig.target ?? "react"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeFileContent(content: string): string {
|
|
60
|
+
return content.replace(/\r\n/g, "\n").trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLegacyGeneratedIndexFile(content: string): boolean {
|
|
43
64
|
return (
|
|
44
65
|
content.includes("Auto-generated by @ic-reactor/codegen") &&
|
|
45
66
|
content.includes("createActorHooks(")
|
|
46
67
|
)
|
|
47
68
|
}
|
|
48
69
|
|
|
70
|
+
function isManagedEntryWrapper(
|
|
71
|
+
content: string,
|
|
72
|
+
expectedEntryContent: string
|
|
73
|
+
): boolean {
|
|
74
|
+
return (
|
|
75
|
+
normalizeFileContent(content) === normalizeFileContent(expectedEntryContent)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
49
79
|
export interface PipelineResult {
|
|
50
80
|
canisterName: string
|
|
51
81
|
success: boolean
|
|
@@ -62,7 +92,12 @@ export interface PipelineResult {
|
|
|
62
92
|
export async function runCanisterPipeline(
|
|
63
93
|
options: PipelineOptions
|
|
64
94
|
): Promise<PipelineResult> {
|
|
65
|
-
const {
|
|
95
|
+
const {
|
|
96
|
+
canisterConfig,
|
|
97
|
+
projectRoot,
|
|
98
|
+
globalConfig,
|
|
99
|
+
generateReactor = true,
|
|
100
|
+
} = options
|
|
66
101
|
const { name, didFile, clientManagerPath } = canisterConfig
|
|
67
102
|
|
|
68
103
|
const files: GeneratorResult[] = []
|
|
@@ -122,31 +157,50 @@ export async function runCanisterPipeline(
|
|
|
122
157
|
}
|
|
123
158
|
}
|
|
124
159
|
|
|
160
|
+
if (!generateReactor) {
|
|
161
|
+
return {
|
|
162
|
+
canisterName: name,
|
|
163
|
+
success: true,
|
|
164
|
+
files,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
125
168
|
// ── Step 2: Reactor file ───────────────────────────────────────────────────
|
|
126
169
|
|
|
127
|
-
const reactorPath = path.join(canisterOutDir, "index.ts")
|
|
170
|
+
const reactorPath = path.join(canisterOutDir, "index.generated.ts")
|
|
171
|
+
const entryPath = path.join(canisterOutDir, "index.ts")
|
|
128
172
|
const reactorClass = resolveReactorClass(canisterConfig)
|
|
173
|
+
const runtimeTarget = resolveRuntimeTarget(canisterConfig, globalConfig)
|
|
129
174
|
|
|
130
175
|
try {
|
|
131
176
|
const reactorContent = generateReactorFile({
|
|
132
177
|
canisterName: name,
|
|
133
178
|
didFile: resolvedDidFile,
|
|
134
179
|
clientManagerPath: resolvedClientManagerPath,
|
|
180
|
+
canisterId: canisterConfig.canisterId,
|
|
181
|
+
runtimeTarget,
|
|
135
182
|
reactorClass,
|
|
136
183
|
})
|
|
184
|
+
const entryContent = generateReactorEntryFile()
|
|
137
185
|
|
|
138
186
|
fs.mkdirSync(canisterOutDir, { recursive: true })
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
files.push({ success: true, filePath: reactorPath })
|
|
142
|
-
} else {
|
|
143
|
-
const existingIndexContent = fs.readFileSync(reactorPath, "utf-8")
|
|
187
|
+
fs.writeFileSync(reactorPath, reactorContent)
|
|
188
|
+
files.push({ success: true, filePath: reactorPath })
|
|
144
189
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
190
|
+
if (!fs.existsSync(entryPath)) {
|
|
191
|
+
fs.writeFileSync(entryPath, entryContent)
|
|
192
|
+
files.push({ success: true, filePath: entryPath })
|
|
193
|
+
} else {
|
|
194
|
+
const existingEntryContent = fs.readFileSync(entryPath, "utf-8")
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
isLegacyGeneratedIndexFile(existingEntryContent) ||
|
|
198
|
+
isManagedEntryWrapper(existingEntryContent, entryContent)
|
|
199
|
+
) {
|
|
200
|
+
fs.writeFileSync(entryPath, entryContent)
|
|
201
|
+
files.push({ success: true, filePath: entryPath })
|
|
148
202
|
} else {
|
|
149
|
-
files.push({ success: true, filePath:
|
|
203
|
+
files.push({ success: true, filePath: entryPath, skipped: true })
|
|
150
204
|
}
|
|
151
205
|
}
|
|
152
206
|
} catch (err) {
|
package/src/reactor.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { generateReactorFile } from "./generators"
|
|
2
|
+
import { generateReactorEntryFile, generateReactorFile } from "./generators"
|
|
3
3
|
|
|
4
4
|
describe("Reactor generator", () => {
|
|
5
5
|
it("keeps default behavior as DisplayReactor", () => {
|
|
@@ -42,4 +42,40 @@ describe("Reactor generator", () => {
|
|
|
42
42
|
)
|
|
43
43
|
expect(content).toContain("new MetadataDisplayReactor<LedgerService>")
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
it("supports core target generation without React hooks", () => {
|
|
47
|
+
const content = generateReactorFile({
|
|
48
|
+
canisterName: "backend",
|
|
49
|
+
didFile: "mock/backend.did",
|
|
50
|
+
runtimeTarget: "core",
|
|
51
|
+
reactorClass: "DisplayReactor",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(content).toMatchSnapshot("core-display-reactor-index")
|
|
55
|
+
expect(content).toContain(
|
|
56
|
+
'import { DisplayReactor } from "@ic-reactor/core"'
|
|
57
|
+
)
|
|
58
|
+
expect(content).not.toContain(
|
|
59
|
+
'import { createActorHooks } from "@ic-reactor/react"'
|
|
60
|
+
)
|
|
61
|
+
expect(content).not.toContain("useBackendQuery")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("writes a fixed canisterId when configured", () => {
|
|
65
|
+
const content = generateReactorFile({
|
|
66
|
+
canisterName: "workflow",
|
|
67
|
+
didFile: "mock/workflow.did",
|
|
68
|
+
canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(content).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
|
|
72
|
+
expect(content).toContain('name: "workflow"')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("generates a stable entry wrapper", () => {
|
|
76
|
+
const content = generateReactorEntryFile()
|
|
77
|
+
|
|
78
|
+
expect(content).toContain('export * from "./index.generated"')
|
|
79
|
+
expect(content).toContain("safe to customize")
|
|
80
|
+
})
|
|
45
81
|
})
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface CanisterConfig {
|
|
|
29
29
|
* Defaults to DisplayReactor for backward compatibility.
|
|
30
30
|
*/
|
|
31
31
|
mode?: ReactorClassName
|
|
32
|
+
/**
|
|
33
|
+
* Generated runtime target.
|
|
34
|
+
* `react` emits bound React hooks, `core` emits only the typed reactor exports.
|
|
35
|
+
*/
|
|
36
|
+
target?: CodegenTarget
|
|
32
37
|
/** Optional fixed canister ID */
|
|
33
38
|
canisterId?: string
|
|
34
39
|
}
|
|
@@ -40,6 +45,8 @@ export type ReactorClassName =
|
|
|
40
45
|
| "CandidDisplayReactor"
|
|
41
46
|
| "MetadataDisplayReactor"
|
|
42
47
|
|
|
48
|
+
export type CodegenTarget = "react" | "core"
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
|
|
45
52
|
*/
|
|
@@ -56,6 +63,11 @@ export interface CodegenConfig {
|
|
|
56
63
|
* Individual canisters can override via `CanisterConfig.clientManagerPath`.
|
|
57
64
|
*/
|
|
58
65
|
clientManagerPath?: string
|
|
66
|
+
/**
|
|
67
|
+
* Default generated runtime target.
|
|
68
|
+
* Individual canisters can override via `CanisterConfig.target`.
|
|
69
|
+
*/
|
|
70
|
+
target?: CodegenTarget
|
|
59
71
|
/** Canister configurations, keyed by canister name */
|
|
60
72
|
canisters: Record<string, CanisterConfig>
|
|
61
73
|
}
|