From f3fa1f73af5dc1b6fd345e6b0fbe35d4811b4e19 Mon Sep 17 00:00:00 2001 From: Seb Molenaar Date: Sun, 12 Jul 2026 21:40:40 +0100 Subject: [PATCH] Initial British Artisan package --- .gitignore | 20 +++ LICENSE | 23 +++ README.md | 96 ++++++++++ composer.json | 45 +++++ src/ArtisanPatcher.php | 168 ++++++++++++++++++ src/BritishArtisanPackageServiceProvider.php | 25 +++ src/BritishArtisanSpelling.php | 100 +++++++++++ src/Console/BritishArtisanInstallCommand.php | 38 ++++ .../BritishArtisanUninstallCommand.php | 38 ++++ 9 files changed, 553 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/ArtisanPatcher.php create mode 100644 src/BritishArtisanPackageServiceProvider.php create mode 100644 src/BritishArtisanSpelling.php create mode 100644 src/Console/BritishArtisanInstallCommand.php create mode 100644 src/Console/BritishArtisanUninstallCommand.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2f02e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Composer +/vendor/ +composer.phar + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache + +# IDEs +/.idea/ +/.vscode/ + +# macOS +.DS_Store + +# Logs +*.log + +# Library dependency lock +/composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9dd045d --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ + + +MIT License + +Copyright (c) 2026 Seb Molenaar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6eabd1 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ + + +# British Artisan + +British English command spellings for Laravel Artisan, with suitably dry corrections when the American spelling is used. + +## What it does + +British Artisan lets you use: + +```bash +php artisan optimise +php artisan optimise:clear +``` + +Laravel still receives and runs its native commands: + +```bash +php artisan optimize +php artisan optimize:clear +``` + +The British spelling runs silently. The American spelling still works, but receives a randomly selected reminder that the proper spelling exists. + +## Requirements + +- PHP 8.2 or newer +- Laravel 11, 12, or 13 + +## Installation + +Install the package with Composer: + +```bash +composer require sebs-space/british-artisan +``` + +Then run the one-off installer: + +```bash +php artisan british-artisan:install +``` + +The installer updates the project-root `artisan` file so command-line arguments are translated before Laravel resolves the command. + +## Usage + +Use the British spelling as normal: + +```bash +php artisan optimise +php artisan optimise:clear +``` + +Any command beginning with `optimise` is translated to the matching `optimize` command before Laravel runs it. + +The original American spelling continues to work: + +```bash +php artisan optimize:clear +``` + +It simply receives a polite correction first. + +## Uninstalling + +Restore the original Laravel `artisan` bootstrap with: + +```bash +php artisan british-artisan:uninstall +``` + +Then remove the package: + +```bash +composer remove sebs-space/british-artisan +``` + +## Supported command translations + +| British English | Laravel command | +|---|---| +| `optimise` | `optimize` | +| `optimise:*` | `optimize:*` | + +Additional command translations can be added in future releases. + +## Planned improvements + +- Publishable configuration +- Custom correction messages +- Additional British command spellings where real Artisan commands use American English + +## Licence + +British Artisan is open-source software licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a4ba0b2 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ + + +{ + "name": "sebs-space/british-artisan", + "description": "British English aliases for Laravel Artisan commands, with suitably dry corrections for American spellings.", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Seb Molenaar", + "email": "seb@sebs.space" + } + ], + "require": { + "php": "^8.2", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0" + }, + "require-dev": { + "orchestra/testbench": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^11.0|^12.0" + }, + "autoload": { + "psr-4": { + "SebsSpace\\BritishArtisan\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SebsSpace\\BritishArtisan\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "SebsSpace\\BritishArtisan\\BritishArtisanPackageServiceProvider" + ] + } + }, + "scripts": { + "test": "phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true +} \ No newline at end of file diff --git a/src/ArtisanPatcher.php b/src/ArtisanPatcher.php new file mode 100644 index 0000000..0536718 --- /dev/null +++ b/src/ArtisanPatcher.php @@ -0,0 +1,168 @@ +handleCommand(new ArgvInput(BritishArtisanSpelling::prepareArguments($_SERVER[\'argv\'])));'; + + private const ORIGINAL_COMMANDS = [ + '$status = $app->handleCommand(new ArgvInput);', + '$status = $app->handleCommand(new ArgvInput());', + ]; + + public function __construct(private readonly string $artisanPath) + { + } + + public function install(): string + { + $contents = $this->read(); + + if ($this->isInstalled($contents)) { + return self::ALREADY_INSTALLED; + } + + if (! str_contains($contents, self::ARG_INPUT_IMPORT)) { + throw new RuntimeException( + 'Could not find the expected ArgvInput import in the project-root artisan file. No changes were made.' + ); + } + + $originalCommand = $this->findOriginalCommand($contents); + + if ($originalCommand === null) { + throw new RuntimeException( + 'Could not find the expected handleCommand call in the project-root artisan file. No changes were made.' + ); + } + + $updated = str_replace( + self::ARG_INPUT_IMPORT, + self::IMPORT.PHP_EOL.self::ARG_INPUT_IMPORT, + $contents, + $importReplacements, + ); + + $updated = str_replace( + $originalCommand, + self::PATCHED_COMMAND, + $updated, + $commandReplacements, + ); + + if ($importReplacements !== 1 || $commandReplacements !== 1) { + throw new RuntimeException( + 'The artisan file did not match the expected structure. No changes were made.' + ); + } + + $this->write($updated); + + return self::INSTALLED; + } + + public function uninstall(): string + { + $contents = $this->read(); + + if (! str_contains($contents, self::IMPORT) && ! str_contains($contents, self::PATCHED_COMMAND)) { + return self::NOT_INSTALLED; + } + + if (! $this->isInstalled($contents)) { + throw new RuntimeException( + 'The artisan file contains only part of the British Artisan installation. No changes were made.' + ); + } + + $updated = str_replace( + self::IMPORT.PHP_EOL, + '', + $contents, + $importReplacements, + ); + + $updated = str_replace( + self::PATCHED_COMMAND, + self::ORIGINAL_COMMANDS[0], + $updated, + $commandReplacements, + ); + + if ($importReplacements !== 1 || $commandReplacements !== 1) { + throw new RuntimeException( + 'The artisan file did not match the expected British Artisan installation. No changes were made.' + ); + } + + $this->write($updated); + + return self::UNINSTALLED; + } + + private function isInstalled(string $contents): bool + { + return str_contains($contents, self::IMPORT) + && str_contains($contents, self::PATCHED_COMMAND); + } + + private function findOriginalCommand(string $contents): ?string + { + foreach (self::ORIGINAL_COMMANDS as $candidate) { + if (str_contains($contents, $candidate)) { + return $candidate; + } + } + + return null; + } + + private function read(): string + { + if (! is_file($this->artisanPath) || ! is_readable($this->artisanPath) || ! is_writable($this->artisanPath)) { + throw new RuntimeException('The project-root artisan file could not be read and written.'); + } + + $contents = file_get_contents($this->artisanPath); + + if ($contents === false) { + throw new RuntimeException('The project-root artisan file could not be read.'); + } + + return $contents; + } + + private function write(string $contents): void + { + $temporaryPath = $this->artisanPath.'.british-artisan.tmp'; + + try { + if (file_put_contents($temporaryPath, $contents, LOCK_EX) === false) { + throw new RuntimeException('Could not write the temporary artisan file.'); + } + + if (! rename($temporaryPath, $this->artisanPath)) { + throw new RuntimeException('Could not replace the artisan file.'); + } + } catch (RuntimeException $exception) { + @unlink($temporaryPath); + + throw $exception; + } + } +} \ No newline at end of file diff --git a/src/BritishArtisanPackageServiceProvider.php b/src/BritishArtisanPackageServiceProvider.php new file mode 100644 index 0000000..10996f6 --- /dev/null +++ b/src/BritishArtisanPackageServiceProvider.php @@ -0,0 +1,25 @@ +app->runningInConsole()) { + return; + } + + $this->commands([ + BritishArtisanInstallCommand::class, + BritishArtisanUninstallCommand::class, + ]); + } +} \ No newline at end of file diff --git a/src/BritishArtisanSpelling.php b/src/BritishArtisanSpelling.php new file mode 100644 index 0000000..ca8146d --- /dev/null +++ b/src/BritishArtisanSpelling.php @@ -0,0 +1,100 @@ + + */ + private const COMMAND_TRANSLATIONS = [ + 'optimise' => 'optimize', + ]; + + /** + * Messages shown when American spellings are used. + * A random message is selected each time. + * + * @var array + */ + private const AMERICAN_SPELLING_MESSAGES = [ + 'Refusing to acknowledge that spelling. The proper command is "%s". Running it anyway…', + 'Detected simplified English. Translating to "%s"…', + 'Converting from American to British English: "%s".', + 'The Monarch would prefer "%s". Continuing…', + 'Tea has been consulted. The correct command is "%s".', + 'Correcting a minor transatlantic spelling incident: "%s".', + 'British English engaged. Continuing with "%s".', + 'Converting to proper English: "%s".', + 'Applying the Oxford English Dictionary… "%s" selected.', + 'The Oxford English Dictionary suggests "%s".', + 'A translator has been dispatched. Continuing with "%s".', + 'The command has been politely translated to "%s".', + 'The Commonwealth appreciates "%s".', + 'Keeping calm and carrying on with "%s".', + 'The tea kettle whistled approvingly at "%s".', + 'A brief linguistic correction has been applied: "%s".', + 'Replacing American spelling with "%s".', + 'Civilisation restored. Executing "%s".', + ]; + + /** + * Prepare the Artisan arguments before Laravel resolves the command. + * + * @param array $arguments + * @return array + */ + public static function prepareArguments(array $arguments): array + { + $originalCommand = $arguments[1] ?? null; + + if (is_string($originalCommand) && ($properCommand = self::properSpellingFor($originalCommand))) { + fwrite( + STDERR, + sprintf( + self::AMERICAN_SPELLING_MESSAGES[array_rand(self::AMERICAN_SPELLING_MESSAGES)], + $properCommand, + ).PHP_EOL, + ); + } + + if (isset($arguments[1])) { + $arguments[1] = self::translateCommand($arguments[1]); + } + + return $arguments; + } + + public static function translateCommand(string $command): string + { + foreach (self::COMMAND_TRANSLATIONS as $british => $american) { + if ($command === $british) { + return $american; + } + + if (str_starts_with($command, $british.':')) { + return $american.substr($command, strlen($british)); + } + } + + return $command; + } + + public static function properSpellingFor(string $command): ?string + { + foreach (self::COMMAND_TRANSLATIONS as $british => $american) { + if ($command === $american) { + return $british; + } + + if (str_starts_with($command, $american.':')) { + return $british.substr($command, strlen($american)); + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Console/BritishArtisanInstallCommand.php b/src/Console/BritishArtisanInstallCommand.php new file mode 100644 index 0000000..285a5b0 --- /dev/null +++ b/src/Console/BritishArtisanInstallCommand.php @@ -0,0 +1,38 @@ +install(); + } catch (RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + if ($result === ArtisanPatcher::ALREADY_INSTALLED) { + $this->info('British Artisan is already installed.'); + + return self::SUCCESS; + } + + $this->info('British Artisan installed successfully.'); + $this->line('You may now use commands such as: php artisan optimise:clear'); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Console/BritishArtisanUninstallCommand.php b/src/Console/BritishArtisanUninstallCommand.php new file mode 100644 index 0000000..1961149 --- /dev/null +++ b/src/Console/BritishArtisanUninstallCommand.php @@ -0,0 +1,38 @@ +uninstall(); + } catch (RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + if ($result === ArtisanPatcher::NOT_INSTALLED) { + $this->info('British Artisan is not installed.'); + + return self::SUCCESS; + } + + $this->info('British Artisan uninstalled successfully.'); + $this->line('Laravel Artisan has been restored to its original command handling.'); + + return self::SUCCESS; + } +} \ No newline at end of file