apify-schema-tools 2.0.4 → 2.1.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.
Files changed (54) hide show
  1. package/.cspell/custom-dictionary.txt +1 -0
  2. package/.husky/pre-commit +0 -8
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +109 -45
  5. package/biome.json +121 -9
  6. package/dist/apify-schema-tools.d.ts +1 -0
  7. package/dist/apify-schema-tools.d.ts.map +1 -1
  8. package/dist/apify-schema-tools.js +133 -106
  9. package/dist/apify-schema-tools.js.map +1 -1
  10. package/dist/apify.d.ts +1 -1
  11. package/dist/apify.d.ts.map +1 -1
  12. package/dist/apify.js +4 -2
  13. package/dist/apify.js.map +1 -1
  14. package/dist/configuration.d.ts +17 -16
  15. package/dist/configuration.d.ts.map +1 -1
  16. package/dist/configuration.js +8 -1
  17. package/dist/configuration.js.map +1 -1
  18. package/dist/json-schemas.d.ts.map +1 -1
  19. package/dist/json-schemas.js +31 -21
  20. package/dist/json-schemas.js.map +1 -1
  21. package/dist/typescript.d.ts +1 -0
  22. package/dist/typescript.d.ts.map +1 -1
  23. package/dist/typescript.js +294 -194
  24. package/dist/typescript.js.map +1 -1
  25. package/package.json +16 -5
  26. package/samples/all-defaults/src/generated/dataset.ts +1 -0
  27. package/samples/all-defaults/src/generated/input-utils.ts +5 -1
  28. package/samples/all-defaults/src/generated/input.ts +2 -0
  29. package/samples/all-defaults/src-schemas/input.json +6 -3
  30. package/samples/deep-merged-schemas/src/generated/dataset.ts +1 -0
  31. package/samples/deep-merged-schemas/src/generated/input-utils.ts +5 -1
  32. package/samples/deep-merged-schemas/src/generated/input.ts +2 -0
  33. package/samples/deep-merged-schemas/src-schemas/input.json +6 -3
  34. package/samples/merged-schemas/src/generated/dataset.ts +1 -0
  35. package/samples/merged-schemas/src/generated/input-utils.ts +5 -1
  36. package/samples/merged-schemas/src/generated/input.ts +3 -0
  37. package/samples/merged-schemas/src-schemas/input.json +6 -3
  38. package/samples/package-json-config/custom-src-schemas/input.json +6 -3
  39. package/samples/package-json-config/package.json +8 -2
  40. package/samples/package-json-config/src/custom-generated/dataset.ts +1 -0
  41. package/samples/package-json-config/src/custom-generated/input-utils.ts +5 -1
  42. package/samples/package-json-config/src/custom-generated/input.ts +2 -0
  43. package/src/apify-schema-tools.ts +179 -150
  44. package/src/apify.ts +6 -4
  45. package/src/configuration.ts +14 -5
  46. package/src/json-schemas.ts +44 -22
  47. package/src/typescript.ts +370 -207
  48. package/test/apify-schema-tools.test.ts +122 -124
  49. package/test/apify.test.ts +8 -6
  50. package/test/common.ts +2 -2
  51. package/test/configuration.test.ts +1 -1
  52. package/test/json-schemas.test.ts +56 -40
  53. package/test/typescript.test.ts +181 -31
  54. package/tsconfig.json +1 -1
package/src/typescript.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { JSONSchema4 } from "json-schema";
2
- import * as R from "ramda";
2
+ import { equals } from "ramda";
3
3
  import { readFile, writeFile } from "./filesystem.js";
4
- import { type ObjectSchema, isArraySchema, isObjectSchema } from "./json-schemas.js";
4
+ import { isArraySchema, isObjectSchema, type ObjectSchema } from "./json-schemas.js";
5
5
 
6
6
  const TYPESCRIPT_FILE_HEADER = `\
7
7
  /**
@@ -57,6 +57,7 @@ function isTypeScriptEnum(schema: TypeScriptSchema): schema is TypeScriptEnum {
57
57
 
58
58
  export interface TypeScriptInterface extends TypeScriptBasicData {
59
59
  properties: Record<string, TypeScriptSchema>;
60
+ additionalProperties?: TypeScriptSchema;
60
61
  }
61
62
 
62
63
  function isTypeScriptInterface(schema: TypeScriptSchema): schema is TypeScriptInterface {
@@ -65,34 +66,51 @@ function isTypeScriptInterface(schema: TypeScriptSchema): schema is TypeScriptIn
65
66
 
66
67
  export type TypeScriptSchema = TypeScriptBasicType | TypeScriptEnum | TypeScriptInterface;
67
68
 
68
- function jsonSchemaToTypeScriptSchema(
69
- schema: JSONSchema4,
69
+ function createTypeScriptInterface(
70
+ schema: ObjectSchema,
71
+ doc: string | undefined,
70
72
  isArray: boolean,
71
73
  isRequired: boolean,
72
- inheritedDoc?: string,
73
- ): TypeScriptSchema {
74
- const doc = inheritedDoc ?? schema.description;
75
- if (isObjectSchema(schema)) {
76
- const requiredProperties = schema.required ?? [];
77
- const properties = Object.fromEntries(
78
- Object.entries(schema.properties ?? {}).map(([key, value]) => {
79
- return [key, jsonSchemaToTypeScriptSchema(value, false, requiredProperties.includes(key))];
80
- }),
81
- );
82
- return { doc, isArray, isRequired, properties };
74
+ ): TypeScriptInterface {
75
+ const requiredProperties = schema.required ?? [];
76
+
77
+ const properties = Object.fromEntries(
78
+ Object.entries(schema.properties ?? {}).map(([key, value]) => {
79
+ return [key, jsonSchemaToTypeScriptSchema(value, false, requiredProperties.includes(key))];
80
+ }),
81
+ );
82
+
83
+ let additionalProperties: TypeScriptSchema | undefined;
84
+ if (schema.additionalProperties === true || schema.additionalProperties === undefined) {
85
+ // If additionalProperties is true or undefined, we parse it as an "empty" schema
86
+ additionalProperties = { doc: undefined, isArray: false, isRequired: true, type: "unknown" };
87
+ } else if (schema.additionalProperties !== false) {
88
+ additionalProperties = jsonSchemaToTypeScriptSchema(schema.additionalProperties, false, true);
83
89
  }
84
- if (isArraySchema(schema)) {
85
- if (Array.isArray(schema.items)) {
86
- throw new Error("Array schema with multiple items is not supported");
87
- }
88
- if (!schema.items) {
89
- return { doc, isArray, isRequired, type: "unknown" };
90
- }
91
- return jsonSchemaToTypeScriptSchema(schema.items, true, isRequired, doc);
90
+
91
+ return { doc, isArray, isRequired, properties, additionalProperties };
92
+ }
93
+
94
+ function createTypeScriptArraySchema(
95
+ schema: JSONSchema4 & { type: "array" },
96
+ doc: string | undefined,
97
+ isRequired: boolean,
98
+ ): TypeScriptSchema {
99
+ if (Array.isArray(schema.items)) {
100
+ throw new Error("Array schema with multiple items is not supported");
92
101
  }
93
- if (schema.enum) {
94
- return { doc, isArray, isRequired, enum: schema.enum };
102
+ if (!schema.items) {
103
+ return { doc, isArray: true, isRequired, type: "unknown" };
95
104
  }
105
+ return jsonSchemaToTypeScriptSchema(schema.items, true, isRequired, doc);
106
+ }
107
+
108
+ function createTypeScriptBasicType(
109
+ schema: JSONSchema4,
110
+ doc: string | undefined,
111
+ isArray: boolean,
112
+ isRequired: boolean,
113
+ ): TypeScriptBasicType {
96
114
  const types: AllowedType[] = [];
97
115
  for (const type of Array.isArray(schema.type) ? schema.type : [schema.type ?? "unknown"]) {
98
116
  const tsType = JSON_SCHEMA_TYPE_TO_TS_TYPE[type] ?? type;
@@ -112,41 +130,76 @@ function jsonSchemaToTypeScriptSchema(
112
130
  };
113
131
  }
114
132
 
133
+ function jsonSchemaToTypeScriptSchema(
134
+ schema: JSONSchema4,
135
+ isArray: boolean,
136
+ isRequired: boolean,
137
+ inheritedDoc?: string,
138
+ ): TypeScriptSchema {
139
+ const doc = inheritedDoc ?? schema.description;
140
+
141
+ if (isObjectSchema(schema)) {
142
+ return createTypeScriptInterface(schema, doc, isArray, isRequired);
143
+ }
144
+
145
+ if (isArraySchema(schema)) {
146
+ return createTypeScriptArraySchema(schema, doc, isRequired);
147
+ }
148
+
149
+ if (schema.enum) {
150
+ return { doc, isArray, isRequired, enum: schema.enum };
151
+ }
152
+
153
+ return createTypeScriptBasicType(schema, doc, isArray, isRequired);
154
+ }
155
+
115
156
  export function jsonSchemaToTypeScriptInterface(schema: ObjectSchema): TypeScriptInterface {
116
157
  return jsonSchemaToTypeScriptSchema(schema, false, true) as TypeScriptInterface;
117
158
  }
118
159
 
119
- export function serializeTypeScriptSchema(schema: TypeScriptSchema, indentLevel = 0): string {
160
+ function serializeInterfaceSchema(schema: TypeScriptInterface, indentLevel: number): string {
120
161
  const indent = "\t".repeat(indentLevel);
121
- let result = "";
162
+ const properties = Object.entries(schema.properties);
163
+ if (properties.length === 0) {
164
+ return "{}";
165
+ }
166
+ const innerIndentLevel = indentLevel + 1;
167
+ const innerIndent = "\t".repeat(innerIndentLevel);
168
+ let result = "{";
169
+ for (const [key, value] of properties) {
170
+ if (value.doc) {
171
+ result += `\n${innerIndent}/**\n${innerIndent} * ${value.doc}\n${innerIndent} */`;
172
+ }
173
+ result += `\n${innerIndent}${key}${value.isRequired ? "" : "?"}: ${serializeTypeScriptSchema(value, innerIndentLevel)};`;
174
+ }
175
+ if (schema.additionalProperties) {
176
+ result += `\n${innerIndent}[key: string]: ${serializeTypeScriptSchema(schema.additionalProperties, innerIndentLevel)};`;
177
+ }
178
+ result += `\n${indent}}`;
179
+ return result;
180
+ }
181
+
182
+ function serializeEnumSchema(schema: TypeScriptEnum): string {
183
+ const serializedEnum = JSON.stringify(schema.enum).replace(/\[|\]/g, "").replace(/,/g, " | ");
184
+ return schema.isArray && schema.enum.length > 1 ? `(${serializedEnum})` : serializedEnum;
185
+ }
186
+
187
+ function serializeBasicTypeSchema(schema: TypeScriptBasicType): string {
188
+ if (Array.isArray(schema.type)) {
189
+ return schema.isArray ? `(${schema.type.join(" | ")})` : schema.type.join(" | ");
190
+ }
191
+ return schema.type;
192
+ }
122
193
 
194
+ export function serializeTypeScriptSchema(schema: TypeScriptSchema, indentLevel = 0): string {
195
+ let result = "";
123
196
  if (isTypeScriptInterface(schema)) {
124
- const properties = Object.entries(schema.properties);
125
- if (properties.length === 0) {
126
- return "{}";
127
- }
128
- const innerIndentLevel = indentLevel + 1;
129
- const innerIndent = "\t".repeat(innerIndentLevel);
130
- result += "{";
131
- for (const [key, value] of properties) {
132
- if (value.doc) {
133
- result += `\n${innerIndent}/**\n${innerIndent} * ${value.doc}\n${innerIndent} */`;
134
- }
135
- result += `\n${innerIndent}${key}${
136
- value.isRequired ? "" : "?"
137
- }: ${serializeTypeScriptSchema(value, innerIndentLevel)};`;
138
- }
139
- result += `\n${indent}}`;
197
+ result = serializeInterfaceSchema(schema, indentLevel);
140
198
  } else if (isTypeScriptEnum(schema)) {
141
- const serializedEnum = JSON.stringify(schema.enum).replace(/\[|\]/g, "").replace(/,/g, " | ");
142
- result += schema.isArray && schema.enum.length > 1 ? `(${serializedEnum})` : serializedEnum;
143
- } else if (Array.isArray(schema.type)) {
144
- const serializedTypes = schema.type.join(" | ");
145
- result += schema.isArray ? `(${serializedTypes})` : serializedTypes;
146
- } else {
147
- result += schema.type;
199
+ result = serializeEnumSchema(schema);
200
+ } else if (isTypeScriptBasicType(schema)) {
201
+ result = serializeBasicTypeSchema(schema);
148
202
  }
149
-
150
203
  if (schema.isArray) {
151
204
  result += "[]";
152
205
  }
@@ -159,9 +212,11 @@ export function serializeTypeScriptInterface(name: string, schema: TypeScriptInt
159
212
  return `${docComment}export interface ${name} ${serializedSchema}${schema.isRequired ? "" : " | undefined"}`;
160
213
  }
161
214
 
215
+ const TYPESCRIPT_HEADER_REGEX = /\/\*\*[\s\S]*?\*\//;
216
+
162
217
  export function removeTypeScriptHeader(fileContent: string): string {
163
218
  // Match the first jsdoc and remove it
164
- const headerMatch = fileContent.match(/\/\*\*[\s\S]*?\*\//);
219
+ const headerMatch = fileContent.match(TYPESCRIPT_HEADER_REGEX);
165
220
  if (!headerMatch || headerMatch.index === undefined) {
166
221
  throw new Error("No header found in the TypeScript file");
167
222
  }
@@ -169,149 +224,293 @@ export function removeTypeScriptHeader(fileContent: string): string {
169
224
  return fileContent.slice(headerEndIndex).trim();
170
225
  }
171
226
 
172
- function parseInterfaceProperties(body: string): Record<string, TypeScriptSchema> {
173
- const properties: Record<string, TypeScriptSchema> = {};
174
- let i = 0;
175
- let currentDoc: string | undefined;
227
+ const WHITESPACE_REGEX = /\s/;
176
228
 
177
- while (i < body.length) {
178
- const char = body[i];
229
+ function skipWhitespace(body: string, i: number): number {
230
+ let cursor = i;
231
+ while (cursor < body.length && WHITESPACE_REGEX.test(body[cursor])) {
232
+ cursor++;
233
+ }
234
+ return cursor;
235
+ }
179
236
 
180
- // Skip whitespace
181
- if (/\s/.test(char)) {
182
- i++;
183
- continue;
237
+ const TYPESCRIPT_DOC_DECORATIONS_REGEX = /^\s*\*\s?/;
238
+ const TYPESCRIPT_DOC_PREFIX = "/**";
239
+
240
+ function tryParseJsDoc(body: string, i: number): { doc: string | undefined; nextIndex: number } | null {
241
+ if (body.slice(i, i + TYPESCRIPT_DOC_PREFIX.length) === TYPESCRIPT_DOC_PREFIX) {
242
+ const docEnd = body.indexOf("*/", i);
243
+ if (docEnd !== -1) {
244
+ const docContent = body.slice(i + TYPESCRIPT_DOC_PREFIX.length, docEnd);
245
+ const docLines = docContent.split("\n");
246
+ const cleanDoc = docLines
247
+ .map((line) => line.replace(TYPESCRIPT_DOC_DECORATIONS_REGEX, "").trim())
248
+ .filter((line) => line.length > 0)
249
+ .join(" ");
250
+ return { doc: cleanDoc || undefined, nextIndex: docEnd + 2 };
184
251
  }
252
+ }
253
+ return null;
254
+ }
185
255
 
186
- // Parse JSDoc comment
187
- if (body.slice(i, i + 3) === "/**") {
188
- const docEnd = body.indexOf("*/", i);
189
- if (docEnd !== -1) {
190
- const docContent = body.slice(i + 3, docEnd);
191
- const docLines = docContent.split("\n");
192
- const cleanDoc = docLines
193
- .map((line) => line.replace(/^\s*\*\s?/, "").trim())
194
- .filter((line) => line.length > 0)
195
- .join(" ");
196
- currentDoc = cleanDoc || undefined;
197
- i = docEnd + 2;
198
- continue;
256
+ function extractTypeString(body: string, i: number): { typeStr: string; isArray: boolean; typeEnd: number } {
257
+ let braceDepth = 0;
258
+ let typeEnd = i;
259
+
260
+ while (typeEnd < body.length) {
261
+ const char = body[typeEnd];
262
+ if (char === "{") {
263
+ braceDepth++;
264
+ } else if (char === "}") {
265
+ braceDepth--;
266
+ if (braceDepth < 0) {
267
+ break;
199
268
  }
269
+ } else if (char === ";" && braceDepth === 0) {
270
+ break;
200
271
  }
272
+ typeEnd++;
273
+ }
201
274
 
202
- // Parse property
203
- const propMatch = body.slice(i).match(/^(\w+)(\?)?:\s*/);
204
- if (propMatch) {
205
- const propName = propMatch[1];
206
- const isRequired = !propMatch[2];
207
- i += propMatch[0].length;
208
-
209
- // Find the type
210
- const typeStart = i;
211
- let braceDepth = 0;
212
- let typeEnd = i;
213
-
214
- while (typeEnd < body.length) {
215
- const char = body[typeEnd];
216
- if (char === "{") {
217
- braceDepth++;
218
- } else if (char === "}") {
219
- braceDepth--;
220
- if (braceDepth < 0) {
221
- break;
222
- }
223
- } else if (char === ";" && braceDepth === 0) {
224
- break;
225
- }
226
- typeEnd++;
227
- }
228
-
229
- let typeStr = body.slice(typeStart, typeEnd).trim();
230
- const isArray = typeStr.endsWith("[]");
231
- if (isArray) {
232
- typeStr = typeStr.slice(0, -2).trim(); // Remove the "[]" part
233
- }
275
+ let typeStr = body.slice(i, typeEnd).trim();
276
+ const isArray = typeStr.endsWith("[]");
277
+ if (isArray) {
278
+ typeStr = typeStr.slice(0, -2).trim();
279
+ }
280
+ return { typeStr, isArray, typeEnd };
281
+ }
234
282
 
235
- // Parse the type
236
- let schema: TypeScriptSchema;
237
- if (typeStr.startsWith("{") && typeStr.endsWith("}")) {
238
- // Object type
239
- const objectBody = typeStr.slice(1, -1); // Remove { and }
240
- const nestedProperties = parseInterfaceProperties(objectBody);
241
- schema = {
242
- doc: currentDoc,
243
- isArray,
244
- isRequired,
245
- properties: nestedProperties,
246
- };
247
- } else if (typeStr.includes("|")) {
248
- const unionBody = isArray
249
- ? typeStr.slice(1, -1) // Remove ( and )
250
- : typeStr;
251
- const unionParts = unionBody.split("|").map((part) => part.trim());
252
- if (unionParts.every((part) => isAllowedType(part))) {
253
- // Multiple primitive types
254
- schema = {
255
- doc: currentDoc,
256
- isArray,
257
- isRequired,
258
- type: unionParts,
259
- };
260
- } else {
261
- // Enum
262
- try {
263
- const enumValues = JSON.parse(`[${unionBody.replace(/\|/g, ",")}]`);
264
- schema = {
265
- doc: currentDoc,
266
- isArray,
267
- isRequired,
268
- enum: enumValues,
269
- };
270
- } catch {
271
- throw new Error(`Invalid enum format for property "${propName}": ${typeStr}`);
272
- }
273
- }
274
- } else {
275
- // Primitive type
283
+ function extractSchemaFromText(
284
+ text: string,
285
+ currentDoc: string | undefined,
286
+ isRequired: boolean,
287
+ isArray: boolean,
288
+ ): TypeScriptSchema {
289
+ let schema: TypeScriptSchema;
290
+
291
+ if (text.startsWith("{") && text.endsWith("}")) {
292
+ const objectBody = text.slice(1, -1);
293
+ const { properties: nestedProperties, additionalProperties: additionalNestedProperties } =
294
+ parseInterfaceProperties(objectBody);
295
+ schema = {
296
+ doc: currentDoc,
297
+ isArray,
298
+ isRequired,
299
+ properties: nestedProperties,
300
+ additionalProperties: additionalNestedProperties,
301
+ };
302
+ } else if (text.includes("|")) {
303
+ const unionBody = isArray ? text.slice(1, -1) : text;
304
+ const unionParts = unionBody.split("|").map((part) => part.trim());
305
+ if (unionParts.every((part) => isAllowedType(part))) {
306
+ schema = {
307
+ doc: currentDoc,
308
+ isArray,
309
+ isRequired,
310
+ type: unionParts,
311
+ };
312
+ } else {
313
+ try {
314
+ const enumValues = JSON.parse(`[${unionBody.replace(/\|/g, ",")}]`);
276
315
  schema = {
277
316
  doc: currentDoc,
278
317
  isArray,
279
318
  isRequired,
280
- type: typeStr,
319
+ enum: enumValues,
281
320
  };
321
+ } catch {
322
+ throw new Error(`Invalid enum format for TypeScript text: ${text}`);
282
323
  }
324
+ }
325
+ } else {
326
+ schema = {
327
+ doc: currentDoc,
328
+ isArray,
329
+ isRequired,
330
+ type: text,
331
+ };
332
+ }
283
333
 
284
- properties[propName] = schema;
285
- currentDoc = undefined;
286
- i = typeEnd;
334
+ return schema;
335
+ }
336
+
337
+ const TYPESCRIPT_ADDITIONAL_PROPERTIES_REGEX = /^\s*\[\s*key\s*:\s*string\s*\]\s*:\s*/;
338
+ const TYPESCRIPT_PROPERTY_REGEX = /^(\w+)(\?)?:\s*/;
339
+
340
+ function tryParseProperty(
341
+ body: string,
342
+ i: number,
343
+ currentDoc: string | undefined,
344
+ ): { propName?: string; schema: TypeScriptSchema; nextIndex: number } | null {
345
+ let cursor = i;
346
+
347
+ let propName: string | undefined;
348
+ let isRequired: boolean;
349
+
350
+ const bodySlice = body.slice(cursor);
351
+ const additionalPropertiesMatch = bodySlice.match(TYPESCRIPT_ADDITIONAL_PROPERTIES_REGEX);
352
+ if (additionalPropertiesMatch) {
353
+ cursor += additionalPropertiesMatch[0].length;
354
+ isRequired = true;
355
+ } else {
356
+ const propMatch = bodySlice.match(TYPESCRIPT_PROPERTY_REGEX);
357
+ if (!propMatch) {
358
+ return null;
359
+ }
360
+
361
+ propName = propMatch[1];
362
+ isRequired = !propMatch[2];
363
+ cursor += propMatch[0].length;
364
+ }
365
+
366
+ const { typeStr, isArray, typeEnd } = extractTypeString(body, cursor);
367
+ const schema = extractSchemaFromText(typeStr, currentDoc, isRequired, isArray);
368
+
369
+ let nextIndex = typeEnd;
370
+ if (body[nextIndex] === ";") {
371
+ nextIndex++;
372
+ }
373
+
374
+ return { propName, schema, nextIndex };
375
+ }
376
+
377
+ function parseInterfaceProperties(body: string): {
378
+ properties: Record<string, TypeScriptSchema>;
379
+ additionalProperties?: TypeScriptSchema;
380
+ } {
381
+ const properties: Record<string, TypeScriptSchema> = {};
382
+ let additionalProperties: TypeScriptSchema | undefined;
383
+ let i = 0;
384
+ let currentDoc: string | undefined;
385
+
386
+ while (i < body.length) {
387
+ i = skipWhitespace(body, i);
287
388
 
288
- // Skip semicolon
289
- if (body[i] === ";") {
290
- i++;
389
+ const docResult = tryParseJsDoc(body, i);
390
+ if (docResult) {
391
+ currentDoc = docResult.doc;
392
+ i = docResult.nextIndex;
393
+ continue;
394
+ }
395
+
396
+ const propResult = tryParseProperty(body, i, currentDoc);
397
+ if (propResult) {
398
+ if (propResult.propName) {
399
+ properties[propResult.propName] = propResult.schema;
400
+ } else {
401
+ additionalProperties = propResult.schema;
291
402
  }
292
- } else {
293
- i++;
403
+ currentDoc = undefined;
404
+ i = propResult.nextIndex;
405
+ continue;
294
406
  }
407
+
408
+ i++;
295
409
  }
296
410
 
297
- return properties;
411
+ return { properties, additionalProperties };
298
412
  }
299
413
 
414
+ const TYPESCRIPT_JSDOC_REGEX = /\/\*\*\s*\n\s*\*\s*(.*?)\s*\n\s*\*\//;
415
+ const TYPESCRIPT_INTERFACE_REGEX = /export interface (\w+) \{([\s\S]*)\}$/;
416
+
300
417
  export function parseTypeScriptInterface(text: string): TypeScriptInterface {
301
418
  // Extract JSDoc comment
302
- const docMatch = text.match(/\/\*\*\s*\n\s*\*\s*(.*?)\s*\n\s*\*\//);
419
+ const docMatch = text.match(TYPESCRIPT_JSDOC_REGEX);
303
420
  const doc = docMatch ? docMatch[1] : undefined;
304
421
 
305
422
  // Extract interface declaration
306
- const interfaceMatch = text.match(/export interface (\w+) \{([\s\S]*)\}$/);
423
+ const interfaceMatch = text.match(TYPESCRIPT_INTERFACE_REGEX);
307
424
  if (!interfaceMatch) {
308
425
  throw new Error("Invalid TypeScript interface format");
309
426
  }
310
427
 
311
428
  const body = interfaceMatch[2];
312
- const properties = parseInterfaceProperties(body);
429
+ const { properties, additionalProperties } = parseInterfaceProperties(body);
430
+
431
+ return { doc, isArray: false, isRequired: true, properties, additionalProperties };
432
+ }
433
+
434
+ function compareInterfaceProperty(
435
+ key: string,
436
+ propA: TypeScriptInterface,
437
+ propB: TypeScriptSchema,
438
+ ignoreDocs?: boolean,
439
+ ): boolean {
440
+ if (!isTypeScriptInterface(propB)) {
441
+ console.error(`Property "${key}" is an interface in one schema but not in the other.`);
442
+ return false;
443
+ }
444
+ if (!compareTypescriptInterfaces(propA, propB, ignoreDocs)) {
445
+ console.error(`Property "${key}" interfaces do not match.`);
446
+ return false;
447
+ }
448
+ return true;
449
+ }
450
+
451
+ function compareEnumProperty(key: string, propA: TypeScriptSchema, propB: TypeScriptSchema): boolean {
452
+ if (!isTypeScriptEnum(propB)) {
453
+ console.error(`Property "${key}" is an enum in one schema but not in the other.`);
454
+ return false;
455
+ }
456
+ if (JSON.stringify((propA as TypeScriptEnum).enum) !== JSON.stringify((propB as TypeScriptEnum).enum)) {
457
+ console.error(
458
+ `Property "${key}" enums do not match: ${JSON.stringify((propA as TypeScriptEnum).enum)} vs ${JSON.stringify((propB as TypeScriptEnum).enum)}`,
459
+ );
460
+ return false;
461
+ }
462
+ return true;
463
+ }
313
464
 
314
- return { doc, isArray: false, isRequired: true, properties };
465
+ function compareBasicTypeProperty(key: string, propA: TypeScriptSchema, propB: TypeScriptSchema): boolean {
466
+ if (!isTypeScriptBasicType(propB)) {
467
+ console.error(`Property "${key}" is a basic type in one schema but not in the other.`);
468
+ return false;
469
+ }
470
+ if (!equals((propA as TypeScriptBasicType).type, (propB as TypeScriptBasicType).type)) {
471
+ console.error(
472
+ `Property "${key}" types do not match: ${JSON.stringify((propA as TypeScriptBasicType).type)} vs ${JSON.stringify((propB as TypeScriptBasicType).type)}`,
473
+ );
474
+ return false;
475
+ }
476
+ return true;
477
+ }
478
+
479
+ function compareProperty(key: string, propA: TypeScriptSchema, propB: TypeScriptSchema, ignoreDocs?: boolean): boolean {
480
+ if (propA.isArray !== propB.isArray) {
481
+ console.error(`Property "${key}" has different array status: ${propA.isArray} vs ${propB.isArray}`);
482
+ return false;
483
+ }
484
+
485
+ if (propA.isRequired !== propB.isRequired) {
486
+ console.error(`Property "${key}" has different required status: ${propA.isRequired} vs ${propB.isRequired}`);
487
+ return false;
488
+ }
489
+
490
+ if (!ignoreDocs && propA.doc !== propB.doc) {
491
+ console.error(`Property "${key}" has different documentation: "${propA.doc}" vs "${propB.doc}"`);
492
+ return false;
493
+ }
494
+
495
+ if (isTypeScriptInterface(propA)) {
496
+ return compareInterfaceProperty(key, propA, propB, ignoreDocs);
497
+ }
498
+ if (isTypeScriptEnum(propA)) {
499
+ return compareEnumProperty(key, propA, propB);
500
+ }
501
+ return compareBasicTypeProperty(key, propA, propB);
502
+ }
503
+
504
+ function compareAdditionalProperties(a: TypeScriptInterface, b: TypeScriptInterface, ignoreDocs?: boolean): boolean {
505
+ if (!(a.additionalProperties || b.additionalProperties)) {
506
+ return true; // Both have no additional properties
507
+ }
508
+ if (!(a.additionalProperties && b.additionalProperties)) {
509
+ console.error(`One interface has additionalProperties defined, but the other does not.
510
+ If you generated an interface from a JSON schema, pay attention that "additionalProperties" is set to an empty schema by default.`);
511
+ return false;
512
+ }
513
+ return compareProperty("[key: string]", a.additionalProperties, b.additionalProperties, ignoreDocs);
315
514
  }
316
515
 
317
516
  export function compareTypescriptInterfaces(
@@ -325,57 +524,21 @@ export function compareTypescriptInterfaces(
325
524
  }
326
525
 
327
526
  for (const key in a.properties) {
328
- if (!(key in b.properties)) {
329
- return false;
330
- }
331
- const propA = a.properties[key];
332
- const propB = b.properties[key];
333
-
334
- if (propA.isArray !== propB.isArray) {
335
- console.error(`Property "${key}" has different array status: ${propA.isArray} vs ${propB.isArray}`);
336
- return false;
337
- }
338
-
339
- if (propA.isRequired !== propB.isRequired) {
340
- console.error(`Property "${key}" has different required status: ${propA.isRequired} vs ${propB.isRequired}`);
341
- return false;
342
- }
343
-
344
- if (!ignoreDocs && propA.doc !== propB.doc) {
345
- console.error(`Property "${key}" has different documentation: "${propA.doc}" vs "${propB.doc}"`);
346
- return false;
347
- }
348
-
349
- if (isTypeScriptInterface(propA)) {
350
- if (!isTypeScriptInterface(propB)) {
351
- console.error(`Property "${key}" is an interface in one schema but not in the other.`);
352
- return false;
353
- }
354
- if (!compareTypescriptInterfaces(propA, propB, ignoreDocs)) {
355
- console.error(`Property "${key}" interfaces do not match.`);
527
+ if (Object.hasOwn(a.properties, key)) {
528
+ if (!(key in b.properties)) {
356
529
  return false;
357
530
  }
358
- } else if (isTypeScriptEnum(propA)) {
359
- if (!isTypeScriptEnum(propB)) {
360
- console.error(`Property "${key}" is an enum in one schema but not in the other.`);
531
+ const propA = a.properties[key];
532
+ const propB = b.properties[key];
533
+ if (!compareProperty(key, propA, propB, ignoreDocs)) {
361
534
  return false;
362
535
  }
363
- if (JSON.stringify(propA.enum) !== JSON.stringify(propB.enum)) {
364
- console.error(
365
- `Property "${key}" enums do not match: ${JSON.stringify(propA.enum)} vs ${JSON.stringify(propB.enum)}`,
366
- );
367
- return false;
368
- }
369
- } else if (!isTypeScriptBasicType(propB)) {
370
- console.error(`Property "${key}" is a basic type in one schema but not in the other.`);
371
- return false;
372
- } else if (!R.equals(propA.type, propB.type)) {
373
- console.error(
374
- `Property "${key}" types do not match: ${JSON.stringify(propA.type)} vs ${JSON.stringify(propB.type)}`,
375
- );
376
- return false;
377
536
  }
378
537
  }
379
538
 
539
+ if (!compareAdditionalProperties(a, b, ignoreDocs)) {
540
+ return false;
541
+ }
542
+
380
543
  return true;
381
544
  }