@contractkit/plugin-typescript 0.17.5 → 0.19.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/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +15 -15
- package/CHANGELOG.md +21 -0
- package/README.md +10 -1
- package/coverage/clover.xml +795 -573
- package/coverage/coverage-final.json +6 -6
- package/coverage/index.html +19 -19
- package/coverage/src/codegen-contract.ts.html +3 -3
- package/coverage/src/codegen-operation.ts.html +1 -1
- package/coverage/src/codegen-plain-types.ts.html +1 -1
- package/coverage/src/codegen-sdk.ts.html +989 -236
- package/coverage/src/index.html +39 -39
- package/coverage/src/index.ts.html +1386 -306
- package/coverage/src/path-utils.ts.html +51 -51
- package/coverage/src/ts-render.ts.html +4 -4
- package/coverage/tests/helpers.ts.html +15 -15
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-sdk.d.ts +96 -2
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +8 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +685 -137
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-sdk.ts +258 -7
- package/src/index.ts +556 -196
- package/tests/codegen-sdk.test.ts +188 -16
- package/tests/codegen-server.test.ts +101 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
".": "./dist/index.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@contractkit/core": "0.
|
|
29
|
+
"@contractkit/core": "0.15.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@repo/config-eslint": "0.3.1",
|
package/src/codegen-sdk.ts
CHANGED
|
@@ -57,8 +57,11 @@ function classifyBodyStrategy(op: OpOperationNode): BodyStrategy {
|
|
|
57
57
|
|
|
58
58
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
59
59
|
|
|
60
|
+
/** Options shared by every SDK code-generation entry point. */
|
|
60
61
|
export interface SdkCodegenOptions {
|
|
62
|
+
/** Template for type import paths when `modelOutPaths` is not provided. Supports `{module}` and `{base}`. */
|
|
61
63
|
typeImportPathTemplate?: string;
|
|
64
|
+
/** Absolute path of the file currently being generated. Used to compute relative imports. */
|
|
62
65
|
outPath?: string;
|
|
63
66
|
/** Map from model name → absolute output file path (for cross-module type imports) */
|
|
64
67
|
modelOutPaths?: Map<string, string>;
|
|
@@ -74,6 +77,12 @@ export interface SdkCodegenOptions {
|
|
|
74
77
|
* to include them (e.g. for an internal-use SDK).
|
|
75
78
|
*/
|
|
76
79
|
includeInternal?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Override the generated client class name. When omitted, falls back to
|
|
82
|
+
* `deriveClientClassName(root.file)` (the legacy per-file name). The aggregator
|
|
83
|
+
* uses this to emit `<Area><Subarea>Client` for area+subarea leaf files.
|
|
84
|
+
*/
|
|
85
|
+
clientClassName?: string;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
|
@@ -90,12 +99,18 @@ export function hasPublicOperations(root: OpRootNode, includeInternal = false):
|
|
|
90
99
|
return false;
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Generate a complete `*.client.ts` file for one operation root: imports, the client class
|
|
104
|
+
* declaration, and one method per public operation. Used for top-level (no-area) files and
|
|
105
|
+
* for subarea-leaf files. Area-level files are NOT routed through this — their methods get
|
|
106
|
+
* inlined into the SDK aggregator via {@link generateClientMethods} + {@link generateSdkAggregator}.
|
|
107
|
+
*/
|
|
93
108
|
export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}): string {
|
|
94
109
|
const lines: string[] = [];
|
|
95
110
|
const includeInternal = options.includeInternal ?? false;
|
|
96
111
|
|
|
97
112
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput, includeInternal);
|
|
98
|
-
const clientClassName = deriveClientClassName(root.file);
|
|
113
|
+
const clientClassName = options.clientClassName ?? deriveClientClassName(root.file);
|
|
99
114
|
|
|
100
115
|
// Type-only imports
|
|
101
116
|
if (types.length > 0) {
|
|
@@ -207,6 +222,35 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
207
222
|
return lines.join('\n');
|
|
208
223
|
}
|
|
209
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Render the method-block lines for an operation file as if they were declared inside a
|
|
227
|
+
* client class. Returns one consolidated array of strings (each pre-indented for class body
|
|
228
|
+
* level, with leading blank lines between methods) plus the set of method names emitted —
|
|
229
|
+
* the caller uses the names to detect cross-file collisions when multiple files contribute
|
|
230
|
+
* to the same area-level client.
|
|
231
|
+
*
|
|
232
|
+
* Skips operations marked `internal` unless `options.includeInternal` is set.
|
|
233
|
+
*/
|
|
234
|
+
export function generateClientMethods(
|
|
235
|
+
root: OpRootNode,
|
|
236
|
+
options: SdkCodegenOptions,
|
|
237
|
+
): { lines: string[]; methodNames: string[] } {
|
|
238
|
+
const lines: string[] = [];
|
|
239
|
+
const methodNames: string[] = [];
|
|
240
|
+
const includeInternal = options.includeInternal ?? false;
|
|
241
|
+
for (const route of root.routes) {
|
|
242
|
+
for (const op of route.operations) {
|
|
243
|
+
const mods = resolveModifiers(route, op);
|
|
244
|
+
if (!includeInternal && mods.includes('internal')) continue;
|
|
245
|
+
lines.push('');
|
|
246
|
+
if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
|
|
247
|
+
lines.push(...generateMethod(route, op, root.file, options));
|
|
248
|
+
methodNames.push(deriveMethodName(op, route));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { lines, methodNames };
|
|
252
|
+
}
|
|
253
|
+
|
|
210
254
|
// ─── Method generation ────────────────────────────────────────────────────
|
|
211
255
|
|
|
212
256
|
function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, options: SdkCodegenOptions): string[] {
|
|
@@ -515,15 +559,59 @@ function deriveBaseName(file: string): string {
|
|
|
515
559
|
.join('');
|
|
516
560
|
}
|
|
517
561
|
|
|
562
|
+
/** Derive a client class name from a `.ck` file path, e.g. `users.ck` → `UsersClient`. Used for legacy flat (no-area) files. */
|
|
518
563
|
export function deriveClientClassName(file: string): string {
|
|
519
564
|
return `${deriveBaseName(file)}Client`;
|
|
520
565
|
}
|
|
521
566
|
|
|
567
|
+
/** Camel-cased property name for a flat client on the SDK aggregator, e.g. `users.ck` → `users`. */
|
|
522
568
|
export function deriveClientPropertyName(file: string): string {
|
|
523
569
|
const base = deriveBaseName(file);
|
|
524
570
|
return base.charAt(0).toLowerCase() + base.slice(1);
|
|
525
571
|
}
|
|
526
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Pull `area` / `subarea` from a file's `root.meta` (set via `options { keys: { ... } }`).
|
|
575
|
+
* Both are optional. `area` drives top-level SDK grouping; `subarea` drives nesting under
|
|
576
|
+
* an area's client class.
|
|
577
|
+
*/
|
|
578
|
+
export function getAreaSubarea(root: OpRootNode): { area?: string; subarea?: string } {
|
|
579
|
+
return { area: root.meta?.area, subarea: root.meta?.subarea };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function pascal(value: string): string {
|
|
583
|
+
return value
|
|
584
|
+
.split(/[-_\s]+/)
|
|
585
|
+
.filter(Boolean)
|
|
586
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
587
|
+
.join('');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function camel(value: string): string {
|
|
591
|
+
const p = pascal(value);
|
|
592
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Class name for the area-level client, e.g. `area=identity` → `IdentityClient`. */
|
|
596
|
+
export function deriveAreaClientClassName(area: string): string {
|
|
597
|
+
return `${pascal(area)}Client`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Property name on the SDK aggregator for an area, e.g. `area=identity` → `identity`. */
|
|
601
|
+
export function deriveAreaPropertyName(area: string): string {
|
|
602
|
+
return camel(area);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Class name for a leaf subarea client, e.g. `(identity, invitations)` → `IdentityInvitationsClient`. */
|
|
606
|
+
export function deriveSubareaClientClassName(area: string, subarea: string): string {
|
|
607
|
+
return `${pascal(area)}${pascal(subarea)}Client`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Property name on the area client for a subarea, e.g. `subarea=invitations` → `invitations`. */
|
|
611
|
+
export function deriveSubareaPropertyName(subarea: string): string {
|
|
612
|
+
return camel(subarea);
|
|
613
|
+
}
|
|
614
|
+
|
|
527
615
|
// ─── Type collection ──────────────────────────────────────────────────────
|
|
528
616
|
|
|
529
617
|
function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>, includeInternal = false): string[] {
|
|
@@ -869,31 +957,194 @@ export function generateSdkOptions(): string {
|
|
|
869
957
|
].join('\n');
|
|
870
958
|
}
|
|
871
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Reference to a per-file leaf client emitted to its own `*.client.ts`. Used by the
|
|
962
|
+
* aggregator to import the class and wire it as either a top-level `sdk.<prop>` or a
|
|
963
|
+
* nested `sdk.<area>.<subarea>` property.
|
|
964
|
+
*/
|
|
872
965
|
export interface SdkClientInfo {
|
|
966
|
+
/** Client class name (e.g. `UsersClient`, `IdentityInvitationsClient`). */
|
|
873
967
|
className: string;
|
|
968
|
+
/** Property name to expose this client under (e.g. `users`, `invitations`). */
|
|
874
969
|
propertyName: string;
|
|
970
|
+
/** Module specifier for the leaf file, relative to `sdk.ts` and `.js`-suffixed. */
|
|
875
971
|
importPath: string;
|
|
876
972
|
}
|
|
877
973
|
|
|
878
|
-
/**
|
|
879
|
-
|
|
880
|
-
|
|
974
|
+
/**
|
|
975
|
+
* One area-level (no-subarea) `.ck` file whose methods are inlined directly into the
|
|
976
|
+
* generated `<Area>Client` class instead of getting a standalone `*.client.ts`.
|
|
977
|
+
*/
|
|
978
|
+
export interface SdkAreaInlineFile {
|
|
979
|
+
/** Parsed AST. */
|
|
980
|
+
root: OpRootNode;
|
|
981
|
+
/** Codegen options for this file (must have `outPath` pointing at the SDK aggregator file so type-import paths resolve correctly). */
|
|
982
|
+
codegenOptions: SdkCodegenOptions;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** A grouping of files that share the same `keys.area`. */
|
|
986
|
+
export interface SdkAreaInfo {
|
|
987
|
+
area: string;
|
|
988
|
+
/** Files for which all methods are inlined into the area client (no subarea). */
|
|
989
|
+
inlineFiles: SdkAreaInlineFile[];
|
|
990
|
+
/** Per-file leaf clients exposed as named properties on the area client. */
|
|
991
|
+
subareaClients: { propertyName: string; client: SdkClientInfo }[];
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export interface SdkAggregatorInput {
|
|
995
|
+
/** Files with no `keys.area` — kept as flat `Sdk.<filename>` properties (legacy behavior). */
|
|
996
|
+
topLevelClients: SdkClientInfo[];
|
|
997
|
+
/** One entry per `keys.area`. */
|
|
998
|
+
areas: SdkAreaInfo[];
|
|
999
|
+
/** Path to `sdk-options.ts` to import `SdkOptions`/`createSdkFetch`/etc. from. */
|
|
1000
|
+
sdkOptionsImportPath?: string;
|
|
1001
|
+
/** Name of the top-level aggregator class. Defaults to `Sdk`. */
|
|
1002
|
+
sdkClassName?: string;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Generate the SDK aggregator (`sdk.ts`) — the entry-point file consumers import.
|
|
1007
|
+
*
|
|
1008
|
+
* Emits one `<Area>Client` class per area (combining inlined area-level methods with
|
|
1009
|
+
* subarea property wiring), one `class Sdk` exposing area properties + flat top-level
|
|
1010
|
+
* properties, plus all imports needed by the inlined methods.
|
|
1011
|
+
*
|
|
1012
|
+
* @throws if two area-level files in the same area produce the same method name.
|
|
1013
|
+
*/
|
|
1014
|
+
export function generateSdkAggregator(input: SdkAggregatorInput): string {
|
|
1015
|
+
const sdkOptionsImportPath = input.sdkOptionsImportPath ?? './sdk-options.js';
|
|
1016
|
+
const sdkClassName = input.sdkClassName ?? 'Sdk';
|
|
1017
|
+
|
|
1018
|
+
// ── Pre-render inline method blocks per area (also collects type/runtime needs) ──
|
|
1019
|
+
const inlinedByArea = new Map<string, { lines: string[]; methodNames: Set<string> }>();
|
|
1020
|
+
const typesByImportPath = new Map<string, Set<string>>(); // path → set of type names
|
|
1021
|
+
const unresolvedTypes = new Set<string>();
|
|
1022
|
+
let needsJson = false;
|
|
1023
|
+
let needsBigIntReplacer = false;
|
|
1024
|
+
let needsBigIntReviver = false;
|
|
1025
|
+
let needsQueryString = false;
|
|
1026
|
+
|
|
1027
|
+
for (const area of input.areas) {
|
|
1028
|
+
const collected: string[] = [];
|
|
1029
|
+
const seenMethods = new Set<string>();
|
|
1030
|
+
for (const inline of area.inlineFiles) {
|
|
1031
|
+
const includeInternal = inline.codegenOptions.includeInternal ?? false;
|
|
1032
|
+
// Collect method names + source lines
|
|
1033
|
+
const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
|
|
1034
|
+
for (const name of methodNames) {
|
|
1035
|
+
if (seenMethods.has(name)) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`[sdk] duplicate method '${name}' in area '${area.area}': two area-level files contribute the same method. Disambiguate via 'sdk:' or move one into a subarea.`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
seenMethods.add(name);
|
|
1041
|
+
}
|
|
1042
|
+
collected.push(...methodLines);
|
|
1043
|
+
// Side info for imports
|
|
1044
|
+
if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
|
|
1045
|
+
if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
|
|
1046
|
+
if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
|
|
1047
|
+
if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
|
|
1048
|
+
// Type imports: rebuild what generateTypeImports would produce, but key by import path so multiple files merge
|
|
1049
|
+
const typesForFile = collectTypes(inline.root, inline.codegenOptions.modelsWithInput, inline.codegenOptions.modelsWithOutput, includeInternal);
|
|
1050
|
+
const { modelOutPaths, outPath } = inline.codegenOptions;
|
|
1051
|
+
if (modelOutPaths && outPath) {
|
|
1052
|
+
const fromDir = dirname(outPath);
|
|
1053
|
+
for (const t of typesForFile) {
|
|
1054
|
+
const typeOutPath = modelOutPaths.get(t);
|
|
1055
|
+
if (typeOutPath) {
|
|
1056
|
+
let rel = relative(fromDir, typeOutPath).replace(/\.ts$/, '.js');
|
|
1057
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
1058
|
+
const set = typesByImportPath.get(rel) ?? new Set();
|
|
1059
|
+
set.add(t);
|
|
1060
|
+
typesByImportPath.set(rel, set);
|
|
1061
|
+
} else {
|
|
1062
|
+
unresolvedTypes.add(t);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
inlinedByArea.set(area.area, { lines: collected, methodNames: seenMethods });
|
|
1068
|
+
}
|
|
881
1069
|
|
|
1070
|
+
// ── Imports ──
|
|
1071
|
+
const lines: string[] = [];
|
|
1072
|
+
const jsonImport = needsJson ? ', JsonValue' : '';
|
|
1073
|
+
lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsImportPath}';`);
|
|
1074
|
+
const valueImports: string[] = [];
|
|
1075
|
+
if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
|
|
1076
|
+
if (needsBigIntReviver) valueImports.push('parseJson');
|
|
1077
|
+
if (needsQueryString) valueImports.push('buildQueryString');
|
|
1078
|
+
if (valueImports.length > 0) {
|
|
1079
|
+
lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsImportPath}';`);
|
|
1080
|
+
}
|
|
882
1081
|
lines.push(`import type { SdkOptions } from '${sdkOptionsImportPath}';`);
|
|
883
1082
|
lines.push(`import { createSdkFetch } from '${sdkOptionsImportPath}';`);
|
|
884
|
-
|
|
1083
|
+
|
|
1084
|
+
// Type imports inlined from area-level files
|
|
1085
|
+
const typeImportPaths = [...typesByImportPath.keys()].sort();
|
|
1086
|
+
for (const path of typeImportPaths) {
|
|
1087
|
+
const names = [...typesByImportPath.get(path)!].sort();
|
|
1088
|
+
lines.push(`import type { ${names.join(', ')} } from '${path}';`);
|
|
1089
|
+
}
|
|
1090
|
+
for (const t of [...unresolvedTypes].sort()) {
|
|
1091
|
+
lines.push(`import type { ${t} } from './${pascalToDotCase(t)}.js';`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Leaf client imports (top-level + subarea)
|
|
1095
|
+
const importedClients = new Set<string>();
|
|
1096
|
+
const pushClientImport = (c: SdkClientInfo): void => {
|
|
1097
|
+
const key = `${c.className}|${c.importPath}`;
|
|
1098
|
+
if (importedClients.has(key)) return;
|
|
1099
|
+
importedClients.add(key);
|
|
885
1100
|
lines.push(`import { ${c.className} } from '${c.importPath}';`);
|
|
1101
|
+
};
|
|
1102
|
+
for (const c of input.topLevelClients) pushClientImport(c);
|
|
1103
|
+
for (const area of input.areas) {
|
|
1104
|
+
for (const sc of area.subareaClients) pushClientImport(sc.client);
|
|
886
1105
|
}
|
|
887
1106
|
lines.push('');
|
|
888
1107
|
|
|
1108
|
+
// ── <Area>Client classes ──
|
|
1109
|
+
for (const area of input.areas) {
|
|
1110
|
+
const areaClassName = deriveAreaClientClassName(area.area);
|
|
1111
|
+
const inlined = inlinedByArea.get(area.area)!;
|
|
1112
|
+
lines.push(`class ${areaClassName} {`);
|
|
1113
|
+
// Subarea property declarations
|
|
1114
|
+
for (const sc of area.subareaClients) {
|
|
1115
|
+
lines.push(` readonly ${sc.propertyName}: ${sc.client.className};`);
|
|
1116
|
+
}
|
|
1117
|
+
if (area.subareaClients.length > 0) lines.push('');
|
|
1118
|
+
// Constructor
|
|
1119
|
+
if (inlined.lines.length > 0 || area.subareaClients.length > 0) {
|
|
1120
|
+
const fetchModifier = inlined.lines.length > 0 ? 'private ' : '';
|
|
1121
|
+
lines.push(` constructor(${fetchModifier}fetch: SdkFetch) {`);
|
|
1122
|
+
for (const sc of area.subareaClients) {
|
|
1123
|
+
lines.push(` this.${sc.propertyName} = new ${sc.client.className}(fetch);`);
|
|
1124
|
+
}
|
|
1125
|
+
lines.push(' }');
|
|
1126
|
+
}
|
|
1127
|
+
// Inlined methods (already class-body indented)
|
|
1128
|
+
for (const ln of inlined.lines) lines.push(ln);
|
|
1129
|
+
lines.push('}');
|
|
1130
|
+
lines.push('');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ── Sdk aggregator ──
|
|
889
1134
|
lines.push(`export class ${sdkClassName} {`);
|
|
890
|
-
for (const
|
|
1135
|
+
for (const area of input.areas) {
|
|
1136
|
+
lines.push(` readonly ${deriveAreaPropertyName(area.area)}: ${deriveAreaClientClassName(area.area)};`);
|
|
1137
|
+
}
|
|
1138
|
+
for (const c of input.topLevelClients) {
|
|
891
1139
|
lines.push(` readonly ${c.propertyName}: ${c.className};`);
|
|
892
1140
|
}
|
|
893
1141
|
lines.push('');
|
|
894
1142
|
lines.push(' constructor(options: SdkOptions) {');
|
|
895
1143
|
lines.push(' const sdkFetch = options.fetch ?? createSdkFetch(options);');
|
|
896
|
-
for (const
|
|
1144
|
+
for (const area of input.areas) {
|
|
1145
|
+
lines.push(` this.${deriveAreaPropertyName(area.area)} = new ${deriveAreaClientClassName(area.area)}(sdkFetch);`);
|
|
1146
|
+
}
|
|
1147
|
+
for (const c of input.topLevelClients) {
|
|
897
1148
|
lines.push(` this.${c.propertyName} = new ${c.className}(sdkFetch);`);
|
|
898
1149
|
}
|
|
899
1150
|
lines.push(' }');
|