UNPKG

zomoc

Version:

A type-safe API mocking tool for frontend development, powered by axios and Zod.

312 lines (306 loc) 12.1 kB
#!/usr/bin/env node // src/bin.ts import { Command } from "commander"; // src/core.ts import { glob } from "glob"; import { generate } from "ts-to-zod"; import fs from "fs/promises"; import camelCase from "camelcase"; async function createInterfaceIndex(projectRoot, interfacePaths) { const interfaceLocationMap = /* @__PURE__ */ new Map(); const files = await glob(interfacePaths, { cwd: projectRoot, absolute: true, ignore: "node_modules/**" }); const interfaceRegex = /export\s+(?:interface|type)\s+([A-Za-z0-9_]+)/g; for (const file of files) { try { const content = await fs.readFile(file, "utf-8"); let match; while ((match = interfaceRegex.exec(content)) !== null) { if (interfaceLocationMap.has(match[1])) { console.warn( `[Zomoc] Warning: Duplicate interface name "${match[1]}" found in ${file}. The one in ${interfaceLocationMap.get( match[1] )} will be used.` ); } else { interfaceLocationMap.set(match[1], file); } } } catch (e) { console.warn(`[Zomoc] Failed to read or parse ${file}`, e); } } return interfaceLocationMap; } async function generateSurvivedSchemas(interfaceLocationMap) { const survivedSchemaDeclarations = /* @__PURE__ */ new Map(); const uniqueFilePaths = [...new Set(interfaceLocationMap.values())]; for (const filePath of uniqueFilePaths) { try { const sourceText = await fs.readFile(filePath, "utf-8"); let sanitizedSourceText = sourceText; const enumRegex = /export (?:const\s+)?enum\s+(\w+)\s*{([\s\S]+?)}/g; sanitizedSourceText = sanitizedSourceText.replace( enumRegex, (_match, enumName, enumBody) => { const enumMemberRegex = /['"]([^'"]+)['"]/g; const enumMembers = []; let memberMatch; while ((memberMatch = enumMemberRegex.exec(enumBody)) !== null) { enumMembers.push(`'${memberMatch[1]}'`); } if (enumMembers.length === 0) { return `/* Zomoc: Non-string enum '${enumName}' is not supported and has been commented out. */`; } return `export type ${enumName} = ${enumMembers.join(" | ")};`; } ); const genericTypeRegex = /export\s+(interface|type)\s+\w+<.+?>\s*=.+?;|export\s+(interface|type)\s+\w+<.+?>\s*{[\s\S]*?}/gs; sanitizedSourceText = sanitizedSourceText.replace( genericTypeRegex, (match) => `/* Zomoc: Generic type commented out ${match} */` ); const zodSchemaFileContent = generate({ sourceText: sanitizedSourceText }).getZodSchemasFile(""); const sanitizedContent = zodSchemaFileContent.replace( /z\.literal\(React\.\w+\)/g, "z.any()" ); const declarations = sanitizedContent.split(/\n(?=export const)/g); for (const declaration of declarations) { const match = declaration.match(/export const (\w+Schema) =/); if (match && match[1]) { const schemaName = match[1]; const trimmedDeclaration = declaration.trim(); if (trimmedDeclaration && !survivedSchemaDeclarations.has(schemaName)) { survivedSchemaDeclarations.set(schemaName, trimmedDeclaration); } } } } catch (error) { console.warn( `[Zomoc] Warning: Skipped processing file '${filePath}' due to an error.`, error.message ); } } return survivedSchemaDeclarations; } async function buildInterfaceToSchemaNameMap(filePaths) { const map = /* @__PURE__ */ new Map(); const interfaceRegex = /\bexport\s+(?:interface|type)\s+([A-Z]\w*)/g; for (const filePath of filePaths) { try { const content = await fs.readFile(filePath, "utf-8"); let match; while ((match = interfaceRegex.exec(content)) !== null) { const interfaceName = match[1]; const schemaName = camelCase(interfaceName, { preserveConsecutiveUppercase: true }) + "Schema"; if (!map.has(interfaceName)) { map.set(interfaceName, schemaName); } } } catch (e) { } } return map; } async function generateRegistryString(projectRoot, options) { const interfacePaths = options?.interfacePaths || []; const interfaceLocationMap = await createInterfaceIndex( projectRoot, interfacePaths ); const survivedSchemaDeclarations = await generateSurvivedSchemas(interfaceLocationMap); const interfaceToSchemaNameMap = await buildInterfaceToSchemaNameMap([ ...interfaceLocationMap.values() ]); const schemaNameToInterfaceNameMap = /* @__PURE__ */ new Map(); for (const [iName, sName] of interfaceToSchemaNameMap.entries()) { schemaNameToInterfaceNameMap.set(sName, iName); } const allInterfaceNames = new Set(interfaceLocationMap.keys()); const allGeneratedSchemaNames = new Set(survivedSchemaDeclarations.keys()); const allExpectedSchemaNames = new Set(interfaceToSchemaNameMap.values()); const failedInterfaceNames = []; for (const interfaceName of allInterfaceNames) { const schemaName = `${camelCase(interfaceName, { preserveConsecutiveUppercase: true })}Schema`; if (!allGeneratedSchemaNames.has(schemaName)) { failedInterfaceNames.push(interfaceName); } } if (failedInterfaceNames.length > 0) { console.warn( `\x1B[33m[Zomoc] The following types were skipped (often due to unsupported generics):\x1B[0m` ); console.warn(failedInterfaceNames.map((name) => ` - ${name}`).join("\n")); } const typeMapEntries = []; for (const schemaName of survivedSchemaDeclarations.keys()) { const interfaceName = schemaNameToInterfaceNameMap.get(schemaName); if (interfaceName) { typeMapEntries.push(`'${interfaceName}': ${schemaName},`); } } const urlMapEntries = []; const mockPaths = options?.mockPaths || []; const mockFiles = await glob(mockPaths, { cwd: projectRoot, absolute: true, ignore: "node_modules/**" }); if (mockFiles.length > 0) { for (const mockFile of mockFiles) { try { const mockContent = await fs.readFile(mockFile, "utf-8"); const mockMap = JSON.parse(mockContent); for (const [key, value] of Object.entries(mockMap)) { const mockConfig = value; if (typeof mockConfig === "object" && mockConfig !== null && "responses" in mockConfig) { const responseMap = mockConfig; const activeStatusKey = String(responseMap.status); let activeResponse = responseMap.responses[activeStatusKey]; let finalStatus = responseMap.status; if (!activeResponse) { const fallbackStatusKey = Object.keys(responseMap.responses)[0]; if (fallbackStatusKey) { activeResponse = responseMap.responses[fallbackStatusKey]; finalStatus = Number(fallbackStatusKey); console.warn( `[Zomoc] Warning: status ${activeStatusKey} not found in responses for '${key}'. Falling back to the first available status: ${finalStatus}.` ); } } if (!activeResponse) continue; const schemaName = activeResponse.responseType ? `${camelCase(activeResponse.responseType, { preserveConsecutiveUppercase: true })}Schema` : void 0; if (schemaName && !survivedSchemaDeclarations.has(schemaName)) { continue; } const registryValueObject = `{ schema: ${schemaName || "undefined"}, responseBody: ${activeResponse.responseBody ? JSON.stringify(activeResponse.responseBody) : "undefined"}, status: ${finalStatus}, pagination: ${activeResponse.pagination ? JSON.stringify(activeResponse.pagination) : "undefined"}, strategy: '${activeResponse.mockingStrategy || "random"}', repeatCount: ${activeResponse.repeatCount ?? "undefined"} }`; const urlEntry = `'${key}': ${registryValueObject},`; urlMapEntries.push(urlEntry); } else { let responseDef = {}; if (typeof mockConfig === "string") { responseDef = { responseType: mockConfig, status: 200 }; } else if (typeof mockConfig === "object" && mockConfig !== null) { const { responseBody, ...rest } = mockConfig; if (responseBody) { console.warn( `[Zomoc] Warning: 'responseBody' is not supported in simple mode for '${key}'. Please use the 'responses' map structure.` ); } responseDef = { ...rest, status: rest.status || 200 }; } else { continue; } const schemaName = responseDef.responseType ? `${camelCase(responseDef.responseType, { preserveConsecutiveUppercase: true })}Schema` : void 0; if (schemaName && !survivedSchemaDeclarations.has(schemaName)) { continue; } const registryValueObject = `{ schema: ${schemaName || "undefined"}, status: ${responseDef.status}, pagination: ${responseDef.pagination ? JSON.stringify(responseDef.pagination) : "undefined"}, strategy: '${responseDef.mockingStrategy || "random"}', repeatCount: ${responseDef.repeatCount ?? "undefined"} }`; const urlEntry = `'${key}': ${registryValueObject},`; urlMapEntries.push(urlEntry); } } } catch (e) { console.error(`[Zomoc] Error processing mock file ${mockFile}:`, e); } } } let finalRegistryString = `// Zomoc: Auto-generated mock registry. Do not edit. import { z } from 'zod'; `; finalRegistryString += [...survivedSchemaDeclarations.values()].join("\n"); const urlSchemaEntries = urlMapEntries.join("\n"); const typeSchemaEntries = typeMapEntries.join("\n"); finalRegistryString += ` export const finalSchemaUrlMap = { ${urlSchemaEntries} } as const; `; finalRegistryString += ` export const finalSchemaTypeMap = { ${typeSchemaEntries} } as const; `; return finalRegistryString; } // src/bin.ts import fs2 from "fs/promises"; import path from "path"; var program = new Command(); program.name("zomoc").description("Zomoc: A Type-Safe Mocking Plugin CLI").version("0.1.0"); program.command("generate").description("Generates the mock registry file (.zomoc/registry.ts)").option( "-m, --mock-paths <paths...>", "Glob patterns for mock definition files", ["**/mock.json"] ).option( "-i, --interface-paths <paths...>", "Glob patterns for TypeScript interface files", ["**/interface.ts", "**/type.ts"] ).action(async (options) => { try { console.log("\u{1F504} Generating mock registry..."); const projectRoot = process.cwd(); const registryContent = await generateRegistryString(projectRoot, { mockPaths: options.mockPaths, interfacePaths: options.interfacePaths }); const zomocDir = path.join(projectRoot, ".zomoc"); const registryPath = path.join(zomocDir, "registry.ts"); await fs2.mkdir(zomocDir, { recursive: true }); await fs2.writeFile(registryPath, registryContent); console.log(`\u2705 Mock registry generated successfully at: ${registryPath}`); const gitignorePath = path.join(projectRoot, ".gitignore"); try { const gitignoreContent = await fs2.readFile(gitignorePath, "utf-8"); if (!gitignoreContent.includes(".zomoc")) { await fs2.appendFile( gitignorePath, "\n\n# Zomoc auto-generated files\n.zomoc\n" ); console.log("\u{1F4DD} Added .zomoc to .gitignore"); } } catch (e) { if (e.code === "ENOENT") { await fs2.writeFile( gitignorePath, "# Zomoc auto-generated files\n.zomoc\n" ); console.log("\u{1F4DD} Created .gitignore and added .zomoc"); } } } catch (error) { console.error("\u274C An error occurred during registry generation:", error); process.exit(1); } }); program.parse(process.argv);