diff --git a/src/Enum/JavascriptConverter.php b/src/Enum/JavascriptConverter.php new file mode 100644 index 0000000..ce114e3 --- /dev/null +++ b/src/Enum/JavascriptConverter.php @@ -0,0 +1,286 @@ +useSymbol, + $this->useImmutability, + $casing, + $this->indentSize, + $this->export, + ); + } + + public function useImmutability(): self + { + return match ($this->useImmutability) { + true => $this, + default => new self( + $this->useSymbol, + true, + $this->propertyNameCasing, + $this->indentSize, + $this->export, + ), + }; + } + + public function ignoreImmutability(): self + { + return match ($this->useImmutability) { + false => $this, + default => new self( + $this->useSymbol, + false, + $this->propertyNameCasing, + $this->indentSize, + $this->export, + ), + }; + } + + public function useSymbol(): self + { + return match ($this->useSymbol) { + true => $this, + default => new self( + true, + $this->useImmutability, + $this->propertyNameCasing, + $this->indentSize, + $this->export, + ), + }; + } + + public function ignoreSymbol(): self + { + return match ($this->useSymbol) { + false => $this, + default => new self( + false, + $this->useImmutability, + $this->propertyNameCasing, + $this->indentSize, + $this->export, + ), + }; + } + + public function useExportDefault(): self + { + return match ($this->export) { + self::EXPORT_DEFAULT => $this, + default => new self( + $this->useSymbol, + $this->useImmutability, + $this->propertyNameCasing, + $this->indentSize, + self::EXPORT_DEFAULT, + ), + }; + } + + public function useExport(): self + { + return match ($this->export) { + self::EXPORT_SIMPLE => $this, + default => new self( + $this->useSymbol, + $this->useImmutability, + $this->propertyNameCasing, + $this->indentSize, + self::EXPORT_SIMPLE, + ), + }; + } + + public function ignoreExport(): self + { + return match ($this->export) { + self::EXPORT_NONE => $this, + default => new self( + $this->useSymbol, + $this->useImmutability, + $this->propertyNameCasing, + $this->indentSize, + self::EXPORT_NONE, + ), + }; + } + + public function intendSize(int $indentSize): self + { + return match (true) { + $indentSize < 0 => throw new ValueError('indentation size can no be negative.'), + $indentSize === $this->indentSize => $this, + default => new self( + $this->useSymbol, + $this->useImmutability, + $this->propertyNameCasing, + $indentSize, + $this->export, + ), + }; + } + + /** + * Converts the Enum into a Javascript object. + * + * + * + * @param class-string $enumClass + */ + public function convertToObject(string $enumClass, ?string $objectName = null): string + { + $this->filterBackedEnum($enumClass); + + $space = ''; + $eol = ''; + if (0 < $this->indentSize) { + $space = str_repeat(' ', $this->indentSize); + $eol = "\n"; + } + + $output = array_reduce( + $enumClass::cases(), + fn (string $output, BackedEnum $enum): string => $output.$space.$this->formatPropertyName($enum).': '.$this->formatPropertyValue($enum).','.$eol, + '' + ); + + $output = '{'.$eol.$output.'}'; + if ($this->useImmutability) { + $output = "Object.freeze($output)"; + } + + $objectName = $this->sanitizeName($objectName, $enumClass); + if (null !== $objectName) { + $output = "const $objectName = $output"; + if (self::EXPORT_DEFAULT === $this->export) { + return $output.';'.$eol.$this->export.$objectName.';'.$eol; + } + } + + return $this->export.$output.$eol; + } + + /** + * Converts the Enum into a Javascript class. + * + * + * + * @param class-string $enumClass + */ + public function convertToClass(string $enumClass, string $className = ''): string + { + $this->filterBackedEnum($enumClass); + + $space = ''; + $eol = ''; + if (0 < $this->indentSize) { + $space = str_repeat(' ', $this->indentSize); + $eol = "\n"; + } + + $className = $this->sanitizeName($className, $enumClass); + $output = array_reduce( + $enumClass::cases(), + fn (string $output, BackedEnum $enum): string => $output.$space."static {$this->formatPropertyName($enum)} = new $className({$this->formatPropertyValue($enum)})$eol", + '' + ); + + $output = 'class '.$className.' {'.$eol.$output.$eol.$space.'constructor(name) {'.$eol.$space.$space.'this.name = name'.$eol.$space.'}'.$eol.'}'; + + return $this->export.$output.$eol; + ; + } + + /** + * @param class-string $enumClass + * + * @throws ValueError If the given string does not represent a Backed Enum class + */ + private function filterBackedEnum(string $enumClass): void + { + if (!enum_exists($enumClass)) { + throw new ValueError($enumClass.' is not a valid PHP Enum.'); + } + + $reflection = new ReflectionEnum($enumClass); + if (!$reflection->isBacked()) { + throw new ValueError($enumClass.' is not a PHP backed enum.'); + } + } + + private function sanitizeName(?string $className, string $enumClass): ?string + { + if ('' !== $className) { + return $className; + } + + $parts = explode('\\', $enumClass); + + return (string) array_pop($parts); + } + + private function formatPropertyName(BackedEnum $enum): string + { + return match ($this->propertyNameCasing) { + null => $enum->name, + default => ($this->propertyNameCasing)($enum->name), + }; + } + + private function formatPropertyValue(BackedEnum $enum): string|int + { + $value = $enum->value; + $value = is_string($value) ? '"'.$value.'"' : $value; + + return match ($this->useSymbol) { + true => 'Symbol('.$value.')', + default => $value, + }; + } +} diff --git a/src/Enum/JavascriptConverterTest.php b/src/Enum/JavascriptConverterTest.php new file mode 100644 index 0000000..21346ff --- /dev/null +++ b/src/Enum/JavascriptConverterTest.php @@ -0,0 +1,204 @@ +expectException(ValueError::class); + + JavascriptConverter::new()->intendSize(-1); + } + + #[Test] + public function it_will_fails_converting_a_non_backed_enum(): void + { + $this->expectException(ValueError::class); + + JavascriptConverter::new()->convertToObject(HttpMethod::class); /* @phpstan-ignore-line */ + } + + #[Test] + public function it_will_fails_converting_a_non_enum(): void + { + $this->expectException(ValueError::class); + + JavascriptConverter::new()->convertToObject(self::class); /* @phpstan-ignore-line */ + } + + #[Test] + public function it_will_convert_to_a_javascript_immutable_object_by_default(): void + { + $expected = <<useImmutability() + ->ignoreExport() + ->ignoreSymbol() + ->intendSize(2); + + self::assertSame($expected, $converter->convertToObject(HttpStatusCode::class)); + self::assertSame($expected, $altConverter->convertToObject(HttpStatusCode::class)); + } + + #[Test] + public function it_can_convert_to_a_javascript_mutable_object(): void + { + $expected = <<ignoreImmutability() + ->propertyNameCase(strtolower(...)) + ->useSymbol() + ->useExport() + ->intendSize(4) + ->convertToObject(HttpStatusCode::class, 'Foobar') + ); + } + + #[Test] + public function it_will_convert_to_a_javascript_class_by_default(): void + { + $expected = <<useImmutability() + ->ignoreExport() + ->ignoreSymbol() + ->intendSize(2); + + self::assertSame($expected, $converter->convertToClass(HttpStatusCode::class)); + self::assertSame($expected, $altConverter->convertToClass(HttpStatusCode::class)); + } + + #[Test] + public function it_can_convert_to_a_javascript_class(): void + { + $pascalCase = fn (string $word): string => implode('', array_map( + ucfirst(...), + explode( + ' ', + strtolower(str_replace(['_', '-'], [' ', ' '], $word)) + ) + )); + + $expected = <<useImmutability() + ->ignoreSymbol() + ->intendSize(4) + ->useExportDefault() + ->propertyNameCase(fn (string $name) => $pascalCase(strtolower(str_replace('HTTP_', '', $name)))); + + self::assertSame( + $expected, + $converter->convertToClass(HttpStatusCode::class, 'Foobar') + ); + } + + #[Test] + public function it_can_convert_to_a_javascript_object_with_export_default_and_a_variable_name(): void + { + $pascalCase = fn (string $word): string => implode('', array_map( + ucfirst(...), + explode( + ' ', + strtolower(str_replace(['_', '-'], [' ', ' '], $word)) + ) + )); + + $expected = <<useImmutability() + ->useExportDefault() + ->useSymbol() + ->intendSize(4) + ->propertyNameCase(fn (string $name) => $pascalCase(strtolower(str_replace('HTTP_', '', $name)))) + ->convertToObject(HttpStatusCode::class, 'StatusCode'); + + self::assertSame($expected, $actual); + } + + #[Test] + public function it_can_convert_to_a_javascript_object_with_export_default_and_no_variable(): void + { + $pascalCase = fn (string $word): string => implode('', array_map( + ucfirst(...), + explode( + ' ', + strtolower(str_replace(['_', '-'], [' ', ' '], $word)) + ) + )); + + $expected = <<useImmutability() + ->useExportDefault() + ->useSymbol() + ->intendSize(0) + ->propertyNameCase(fn (string $name) => $pascalCase(strtolower(str_replace('HTTP_', '', $name)))) + ->convertToObject(HttpStatusCode::class); + + self::assertSame($expected, $actual); + } +} diff --git a/src/Enum/README.md b/src/Enum/README.md index 4c5473b..aa85a8a 100644 --- a/src/Enum/README.md +++ b/src/Enum/README.md @@ -193,6 +193,109 @@ enum HttpMethod: string } ``` +### Converting the Enum into a Javascript structure + +The `JavascriptConverter` enables converting your PHP Backed Enum into an equivalent structure in Javascript. +Because there are two (2) ways to create an Enum like structure in Javascript, the class provides +two (2) methods, `convertToObject` and `convertToClass` to allow the conversion. In both cases, +the conversion is configurable via wither methods to control the formatting and the +Javascript structure properties. For instance, given I have the following enum: + +```php +enum HttpStatusCode: int +{ + case HTTP_OK = 200; + case HTTP_REDIRECTION = 302; + case HTTP_NOT_FOUND = 404; + case HTTP_SERVER_ERROR = 500; +} +``` + +It can be converted into an object using the `convertToObject` method: + +```php +use Bakame\Aide\Enum\JavascriptConverter; + +echo JavascriptConverter::new()->convertToObject(HttpStatusCode::class); +``` + +will produce the following javascript code snippet: + +```javascript +Object.freeze({ + HTTP_OK: 200, + HTTP_REDIRECTION: 302, + HTTP_NOT_FOUND: 404, + HTTP_SERVER_ERROR: 500, +}) +``` + +conversely using `convertToClass` as follows: + +```php +echo JavascriptConverter::new()->convertToClass(HttpStatusCode::class); +``` + +will produce the following javascript code snippet: + +```javascript +class HttpStatusCode { + static HTTP_OK = new HttpStatusCode(200) + static HTTP_REDIRECTION = new HttpStatusCode(302) + static HTTP_NOT_FOUND = new HttpStatusCode(404) + static HTTP_SERVER_ERROR = new HttpStatusCode(500) + + constructor(name) { + this.name = name + } +} +``` + +Of course there are ways to improve the output depending on your use case you can + +- ignore or use object immutability; +- ignore or use Javascript `export` or `export default`; +- change the class name or add and/or change the object variable name; +- use `Symbol` when declaring the object property value; +- define indentation spaces and thus end of line; + +Here's a more advance usage of the converter to highlight how you can configure it. + +```php +useImmutability() + ->useExportDefault() + ->useSymbol() + ->intendSize(4) + ->propertyNameCase( + fn (string $name) => Str::of($name)->replace('HTTP_', '')->lower()->studly()->toString() + ); + +echo $converter->convertToObject(HttpStatusCode::class, 'StatusCode'); +``` + +will return the following Javascript code: + +```javascript +const StatusCode = Object.freeze({ + Ok: Symbol(200), + Redirection: Symbol(302), + NotFound: Symbol(404), + ServerError: Symbol(500), +}); +export default StatusCode; +``` + +The converter will not store the resulting string into a Javascriot file as this part is +left to the discretion of the implementor. There are several ways to do so: + +- using vanilla PHP with `file_put_contents` or `SplFileObject` +- using more robust and battle tested packages you can find on packagist for instance. + ## Credits - [ignace nyamagana butera](https://github.com/nyamsprod)