diff --git a/README.md b/README.md index 17ef41fd..a84488d1 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,16 @@ pnpm up --interactive --latest -r ## Publishing ```sh -# publish hypergraph pnpm build +# publish hypergraph cd packages/hypergraph/publish pnpm publish # publish hypergraph-react -pnpm build cd packages/hypergraph-react/publish pnpm publish +# publish typesync +cd apps/typesync +pnpm publish --tag latest ``` ## Deploying your own SyncServer to Fly.io (single instance) diff --git a/apps/typesync/package.json b/apps/typesync/package.json index 20f3f073..b832429f 100644 --- a/apps/typesync/package.json +++ b/apps/typesync/package.json @@ -1,6 +1,6 @@ { "name": "@graphprotocol/hypergraph-cli", - "version": "0.0.0-alpha.24", + "version": "0.0.0-alpha.26", "type": "module", "license": "MIT", "description": "CLI toolchain to view existing types, select, pick, extend to create schemas and generate a @graphprotocol/hypergraph schema.", diff --git a/apps/typesync/src/Generator.ts b/apps/typesync/src/Generator.ts index eaee75f9..52336ad2 100644 --- a/apps/typesync/src/Generator.ts +++ b/apps/typesync/src/Generator.ts @@ -78,6 +78,64 @@ export class SchemaGenerator extends Effect.Service()('/typesyn } }); + /** + * Recursively processes all files in the src directory and replaces + * "Address", "address", and "addresses" with the first schema type + */ + const replaceAddressTerms = ( + directory: string, + firstTypeName: string, + ): Effect.Effect => + Effect.gen(function* () { + const srcPath = path.join(directory, 'src'); + const srcExists = yield* fs.exists(srcPath); + + if (!srcExists) { + return; // No src directory to process + } + + const processDirectory = (dirPath: string): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dirPath); + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry); + const stat = yield* fs.stat(entryPath); + + if (stat.type === 'Directory') { + // Recursively process subdirectories + yield* processDirectory(entryPath); + } else if (stat.type === 'File') { + // Process files that are likely to contain code + const fileExtension = path.extname(entry); + const codeExtensions = ['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte']; + + if (codeExtensions.includes(fileExtension)) { + yield* Effect.gen(function* () { + // Read file content + const content = yield* fs.readFileString(entryPath); + + // Replace address-related terms with the first type name + const pascalCaseName = Utils.toPascalCase(firstTypeName); + const camelCaseName = Utils.toCamelCase(firstTypeName); + const pluralName = `${camelCaseName}s`; // Simple pluralization + + const updatedContent = content + .replace(/\bAddress\b/g, pascalCaseName) + .replace(/\baddress\b/g, camelCaseName) + .replace(/\baddresses\b/g, pluralName); + + // Write back the updated content + yield* fs.writeFileString(entryPath, updatedContent); + }); + } + } + } + }); + + yield* processDirectory(srcPath); + }); + return { /** * Generate on the users machine, at the directory they provided, @@ -103,7 +161,9 @@ export class SchemaGenerator extends Effect.Service()('/typesyn * 2. copy it to the output directory from the app request * 3. update the package.json * 4. update the schema.ts - * 5. cleanup the .tmp directory + * 5. update the mapping.ts + * 6. replace the address terms with the first type name + * 7. cleanup the .tmp directory */ yield* cloneTemplRepo.pipe( Effect.tapErrorCause((cause) => @@ -118,6 +178,7 @@ export class SchemaGenerator extends Effect.Service()('/typesyn Effect.andThen(() => updatePackageJson(app, directory)), Effect.andThen(() => fs.writeFileString(path.join(directory, 'src', 'schema.ts'), buildSchemaFile(app))), Effect.andThen(() => fs.writeFileString(path.join(directory, 'src', 'mapping.ts'), buildMappingFile(app))), + Effect.andThen(() => replaceAddressTerms(directory, app.types[0]?.name || 'Address')), Effect.andThen(() => cleanup), ); @@ -320,7 +381,8 @@ export function buildMappingFile(schema: Domain.InsertAppSchema) { } } - const typeMapping = ` ${type.name}: { + const typeName = Utils.toPascalCase(type.name); + const typeMapping = ` ${typeName}: { typeIds: [Id.Id('${type.knowledgeGraphId}')], properties: { ${properties.join(',\n')}, diff --git a/apps/typesync/src/Utils.ts b/apps/typesync/src/Utils.ts index c19b5452..b3595545 100644 --- a/apps/typesync/src/Utils.ts +++ b/apps/typesync/src/Utils.ts @@ -23,8 +23,8 @@ import { Data, String as EffectString } from 'effect'; * @returns camelCased value of the input string */ export function toCamelCase(str: string) { - if (EffectString.isEmpty(str)) { - throw new InvalidInputError({ input: str, cause: 'Input is empty' }); + if (EffectString.isEmpty(str) || /^\s+$/.test(str)) { + throw new InvalidInputError({ input: str, cause: 'Input is empty or contains only whitespace' }); } let result = ''; @@ -85,8 +85,8 @@ export function toCamelCase(str: string) { * @returns PascalCased value of the input string */ export function toPascalCase(str: string): string { - if (EffectString.isEmpty(str)) { - throw new InvalidInputError({ input: str, cause: 'Input is empty' }); + if (EffectString.isEmpty(str) || /^\s+$/.test(str)) { + throw new InvalidInputError({ input: str, cause: 'Input is empty or contains only whitespace' }); } let result = ''; diff --git a/apps/typesync/test/Utils.test.ts b/apps/typesync/test/Utils.test.ts new file mode 100644 index 00000000..902f4ba5 --- /dev/null +++ b/apps/typesync/test/Utils.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from '@effect/vitest'; + +// @ts-ignore - fix the ts setup +import { toCamelCase, toPascalCase } from '../src/Utils.js'; + +describe('toCamelCase', () => { + it('should convert space-separated words to camelCase', () => { + expect(toCamelCase('Address line 1')).toBe('addressLine1'); + expect(toCamelCase('Academic field')).toBe('academicField'); + expect(toCamelCase('User profile')).toBe('userProfile'); + expect(toCamelCase('Email address')).toBe('emailAddress'); + expect(toCamelCase('Phone number')).toBe('phoneNumber'); + }); + + it('should convert underscore-separated words to camelCase', () => { + expect(toCamelCase('address_line_1')).toBe('addressLine1'); + expect(toCamelCase('user_profile')).toBe('userProfile'); + expect(toCamelCase('email_address')).toBe('emailAddress'); + }); + + it('should convert hyphen-separated words to camelCase', () => { + expect(toCamelCase('address-line-1')).toBe('addressLine1'); + expect(toCamelCase('user-profile')).toBe('userProfile'); + expect(toCamelCase('email-address')).toBe('emailAddress'); + }); + + it('should convert mixed separators to camelCase', () => { + expect(toCamelCase('address-line_1')).toBe('addressLine1'); + expect(toCamelCase('address-line 1')).toBe('addressLine1'); + expect(toCamelCase('user_profile-name')).toBe('userProfileName'); + }); + + it('should handle already camelCase strings', () => { + expect(toCamelCase('addressLine1')).toBe('addressLine1'); + expect(toCamelCase('userProfile')).toBe('userProfile'); + expect(toCamelCase('emailAddress')).toBe('emailAddress'); + }); + + it('should handle PascalCase strings', () => { + expect(toCamelCase('AddressLine1')).toBe('addressLine1'); + expect(toCamelCase('UserProfile')).toBe('userProfile'); + expect(toCamelCase('EmailAddress')).toBe('emailAddress'); + }); + + it('should handle uppercase strings', () => { + expect(toCamelCase('ADDRESS_LINE_1')).toBe('addressLine1'); + expect(toCamelCase('USER_PROFILE')).toBe('userProfile'); + expect(toCamelCase('EMAIL_ADDRESS')).toBe('emailAddress'); + }); + + it('should handle single words', () => { + expect(toCamelCase('Address')).toBe('address'); + expect(toCamelCase('User')).toBe('user'); + expect(toCamelCase('Email')).toBe('email'); + }); + + it('should handle numbers', () => { + expect(toCamelCase('Address1')).toBe('address1'); + expect(toCamelCase('User2')).toBe('user2'); + expect(toCamelCase('Email3')).toBe('email3'); + }); + + it('should skip leading non-alphanumeric characters', () => { + expect(toCamelCase('!Address')).toBe('address'); + expect(toCamelCase('@User')).toBe('user'); + expect(toCamelCase('#Email')).toBe('email'); + expect(toCamelCase(' Address')).toBe('address'); + }); + + it('should throw error for empty input or whitespace-only input', () => { + expect(() => toCamelCase('')).toThrow(); + expect(() => toCamelCase(' ')).toThrow(); + expect(() => toCamelCase(' ')).toThrow(); + expect(() => toCamelCase('\t')).toThrow(); + expect(() => toCamelCase('\n')).toThrow(); + expect(() => toCamelCase(' \t \n ')).toThrow(); + }); +}); + +describe('toPascalCase', () => { + it('should convert space-separated words to PascalCase', () => { + expect(toPascalCase('Address line 1')).toBe('AddressLine1'); + expect(toPascalCase('Academic field')).toBe('AcademicField'); + expect(toPascalCase('User profile')).toBe('UserProfile'); + expect(toPascalCase('Email address')).toBe('EmailAddress'); + expect(toPascalCase('Phone number')).toBe('PhoneNumber'); + }); + + it('should convert underscore-separated words to PascalCase', () => { + expect(toPascalCase('address_line_1')).toBe('AddressLine1'); + expect(toPascalCase('user_profile')).toBe('UserProfile'); + expect(toPascalCase('email_address')).toBe('EmailAddress'); + }); + + it('should convert hyphen-separated words to PascalCase', () => { + expect(toPascalCase('address-line-1')).toBe('AddressLine1'); + expect(toPascalCase('user-profile')).toBe('UserProfile'); + expect(toPascalCase('email-address')).toBe('EmailAddress'); + }); + + it('should convert mixed separators to PascalCase', () => { + expect(toPascalCase('address-line_1')).toBe('AddressLine1'); + expect(toPascalCase('address-line 1')).toBe('AddressLine1'); + expect(toPascalCase('user_profile-name')).toBe('UserProfileName'); + }); + + it('should handle already PascalCase strings', () => { + expect(toPascalCase('AddressLine1')).toBe('AddressLine1'); + expect(toPascalCase('UserProfile')).toBe('UserProfile'); + expect(toPascalCase('EmailAddress')).toBe('EmailAddress'); + }); + + it('should handle camelCase strings', () => { + expect(toPascalCase('addressLine1')).toBe('AddressLine1'); + expect(toPascalCase('userProfile')).toBe('UserProfile'); + expect(toPascalCase('emailAddress')).toBe('EmailAddress'); + }); + + it('should handle uppercase strings', () => { + expect(toPascalCase('ADDRESS_LINE_1')).toBe('AddressLine1'); + expect(toPascalCase('USER_PROFILE')).toBe('UserProfile'); + expect(toPascalCase('EMAIL_ADDRESS')).toBe('EmailAddress'); + }); + + it('should handle single words', () => { + expect(toPascalCase('Address')).toBe('Address'); + expect(toPascalCase('User')).toBe('User'); + expect(toPascalCase('Email')).toBe('Email'); + }); + + it('should handle numbers', () => { + expect(toPascalCase('Address1')).toBe('Address1'); + expect(toPascalCase('User2')).toBe('User2'); + expect(toPascalCase('Email3')).toBe('Email3'); + }); + + it('should skip leading non-alphanumeric characters', () => { + expect(toPascalCase('!Address')).toBe('Address'); + expect(toPascalCase('@User')).toBe('User'); + expect(toPascalCase('#Email')).toBe('Email'); + expect(toPascalCase(' Address')).toBe('Address'); + }); + + it('should throw error for empty input or whitespace-only input', () => { + expect(() => toPascalCase('')).toThrow(); + expect(() => toPascalCase(' ')).toThrow(); + expect(() => toPascalCase(' ')).toThrow(); + expect(() => toPascalCase('\t')).toThrow(); + expect(() => toPascalCase('\n')).toThrow(); + expect(() => toPascalCase(' \t \n ')).toThrow(); + }); +});