diff --git a/CHANGELOG.md b/CHANGELOG.md index b01ddf4..5b4ce92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Deprecated command aliases.** A command lists old names in `$deprecatedCommandAliases`; + they stay registered, and invoking the command by one prints a single line naming the + canonical command before anything runs. The canonical name and `$commandAliases` entries + print nothing. The base `Command` composes the new `WarnsOnDeprecatedAlias` trait, and a + command on any other base can `use` it directly. This is the one sanctioned way for a bare + name to stay registered beside a `laranail::.` one. See + `docs/tools/commands.md`. +- **`laranail/console::` is the canonical translation namespace**, the composer package name, + and `vendor:publish --tag=laranail::console-lang` now publishes into + `lang/vendor/laranail/console/`. + +### Changed + +- The package's own strings are looked up through `laranail/console::`. Overrides made + against `laranail-console::` (files under the old `vendor/laranail-console/` directory, or + `addLines()` on that namespace) still apply whenever the canonical namespace has no override + for the same key. +- `composer.json` declares `illuminate/http`, which `Tools\Events\CommandEvents` imports, and + excludes `laranail/*` from Packagist so the family resolves only through its VCS + repositories. + +### Deprecated + +- **The `laranail-console::` translation namespace.** It is still registered over the same + files, so existing `__('laranail-console::…')` calls resolve; use `laranail/console::`. + Removal no earlier than the next minor after 0.1. + ## [0.1.4] - 2026-09-27 ### Added diff --git a/composer.json b/composer.json index 68e283a..aef48c4 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "ext-mbstring": "*", "illuminate/console": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/http": "^13.0", "illuminate/support": "^13.0", "laravel/prompts": "^0.3.18|^1.0", "symfony/console": "^8.0" @@ -148,6 +149,16 @@ { "type": "vcs", "url": "https://github.com/laranail/package-tools" + }, + { + "type": "composer", + "url": "https://repo.packagist.org", + "exclude": [ + "laranail/*" + ] + }, + { + "packagist.org": false } ] } diff --git a/docs/i18n.md b/docs/i18n.md index bb91ff9..7534347 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -1,7 +1,7 @@ # Internationalization Every user-facing string in the package is a translation key under the -`laranail-console::` namespace, with English defaults in `resources/lang/en/`: +`laranail/console::` namespace, with English defaults in `resources/lang/en/`: - `console.php` — Tools strings (status labels, interaction prompts, errors) - `prompter.php` — Prompter exception/flow strings @@ -13,14 +13,14 @@ Every user-facing string in the package is a translation key under the php artisan vendor:publish --tag=laranail::console-lang ``` -This copies the language files into `lang/vendor/laranail-console/`, which is -where the `laranail-console::` namespace reads overrides from. Edit them there, or -add new locales (e.g. `lang/vendor/laranail-console/fr/console.php`). A file placed +This copies the language files into `lang/vendor/laranail/console/`, which is +where the `laranail/console::` namespace reads overrides from. Edit them there, or +add new locales (e.g. `lang/vendor/laranail/console/fr/console.php`). A file placed in the `lang/` root is never read for a namespaced key. ## Adding a locale -Create the matching files under `lang/vendor/laranail-console//` and set +Create the matching files under `lang/vendor/laranail/console//` and set the locale: ```php @@ -28,7 +28,7 @@ the locale: 'locale' => 'fr', // or null to follow the application locale ``` -Strings are resolved with `__('laranail-console::validators.email')` and friends, with +Strings are resolved with `__('laranail/console::validators.email')` and friends, with `:placeholder` replacements. Exceptions resolve their messages the same way via `ConsoleException::fromKey()`, falling back to a humanised key if a translation is missing — so a missing key never produces an empty error message. @@ -36,11 +36,25 @@ is missing — so a missing key never produces an empty error message. ## Widget strings User-facing widget text (the `Summary`, `Header`, `Menu`, `TaskProgress` and -`Callout` labels) lives under `laranail-console::console.widgets.*` and is resolved via +`Callout` labels) lives under `laranail/console::console.widgets.*` and is resolved via `Support\Lang`, which honours `config('laranail.console.locale')` **without** mutating the host application's global locale. Each string keeps its English default as a fallback, so widgets render correctly even outside a booted application. Override them by publishing the lang files and editing `console.php`'s `widgets` array (or -adding a `lang/vendor/laranail-console//console.php`). +adding a `lang/vendor/laranail/console//console.php`). + +## The deprecated `laranail-console::` namespace + +Before 0.1.5 the package registered its strings as `laranail-console::`. That namespace is +now a deprecated alias, registered over the same files, so `__('laranail-console::…')` in +your code still resolves; it will be removed no earlier than the next minor after 0.1. +Move calls to `laranail/console::`. + +Overrides made against the old namespace keep working too. Files published under the old +`vendor/laranail-console/` directory of your `lang` path, and lines added at runtime with +`addLines(…, 'laranail-console')`, still apply to the package's own strings: the package +uses an override of the old namespace whenever the canonical one has not been overridden for +the same key. Move them to `vendor/laranail/console/` at your convenience; when both exist for +a key, the canonical one wins. [← Docs index](../README.md#documentation) diff --git a/docs/tools/commands.md b/docs/tools/commands.md index 9a6287a..2b796d7 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -60,8 +60,8 @@ base — one obvious access path, no Middle-Man indirection. ## Use the trait (when you can't extend the base) The base is just `extends Illuminate\Console\Command` + the -`Tools\Commands\Concerns\InteractsWithConsoleServices` and `InteractsWithConsoleWriter` -traits. It does **not** compose `SupportsNamespacedNames`; see +`Tools\Commands\Concerns\InteractsWithConsoleServices`, `InteractsWithConsoleWriter` and +`WarnsOnDeprecatedAlias` traits. It does **not** compose `SupportsNamespacedNames`; see [Namespaced command names](#namespaced-command-names). If your command must extend a different base (a vendor command, Laravel's `GeneratorCommand`, …), `use` the trait directly to get the **same** full support — `$this->services`, the managed @@ -235,7 +235,8 @@ the trait alone never reads it. An alias must itself be vendor-scoped. A bare `make:crud` beside `laranail::your-package.make-crud` hands back exactly the flat-registry collision the -namespaced name exists to prevent. +namespaced name exists to prevent. The one exception is an old name a command must keep +answering to, and that goes in `$deprecatedCommandAliases` (below), never here. ```php use Simtabi\Laranail\Console\Tools\Commands\Command; @@ -257,6 +258,79 @@ final class MakeCrudCommand extends Command } ``` +### Deprecated aliases (`$deprecatedCommandAliases`) + +When a command is renamed to the `laranail::.` shape, its old name +stays registered as a deprecated alias so existing scripts, scheduler entries and +`Artisan::call()` keep working. Invoking the command by that alias prints one line naming +the canonical command, before any prompt and before `handle()` runs: + +```text +Deprecated: [make:crud] is a deprecated alias and will be removed in the next minor after 0.1. Use [laranail::your-package.make-crud] instead. +``` + +The canonical name and the aliases in `$commandAliases` print nothing. On a real terminal +the line goes to stderr, so piped output is unchanged. + +This is the only way a bare generic name may stay registered beside a vendor-scoped one: the +warning names the replacement, which makes the alias a migration path rather than a second +name. + +```php +use Simtabi\Laranail\Console\Tools\Commands\Command; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames; + +final class MakeCrudCommand extends Command +{ + use SupportsNamespacedNames; + + protected $signature = 'laranail::your-package.make-crud'; + + /** @var list */ + protected array $deprecatedCommandAliases = ['make:crud']; + + public function handle(): int + { + return self::SUCCESS; + } +} +``` + +The base `Command` composes the `Tools\Commands\Concerns\WarnsOnDeprecatedAlias` trait. +A command on any other base gets the same behaviour by using the trait itself, and the +trait registers the deprecated aliases on its own, so no `$commandAliases` support is needed: + +```php +use Illuminate\Console\Command; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\WarnsOnDeprecatedAlias; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames; + +final class SyncCommand extends Command +{ + use SupportsNamespacedNames; + use WarnsOnDeprecatedAlias; + + protected $signature = 'laranail::your-package.sync'; + + /** @var list */ + protected array $deprecatedCommandAliases = ['your-package:sync']; + + public function handle(): int + { + return self::SUCCESS; + } +} +``` + +| Member | What it does | +|---|---| +| `getAliases()` | every alias, the deprecated ones included, so the application registers them all | +| `deprecatedCommandAliases()` | the declared deprecated aliases, without the command's own name | +| `initialize()` | prints the warning when the command was invoked by a deprecated alias | + +A command that overrides `initialize()` must call `parent::initialize()`, or the warning +never prints. The behaviour is pinned by `tests/Tools/Unit/Commands/DeprecatedAliasTest.php`. + ## `laranail::console.check` Validates the `console.*` config and reports any problems, exiting non-zero on a bad diff --git a/docs/tools/prompter.md b/docs/tools/prompter.md index c1aef8c..7a49efd 100644 --- a/docs/tools/prompter.md +++ b/docs/tools/prompter.md @@ -77,7 +77,7 @@ gets an options-aware default validator unless you set your own. Each implements `ValidatorInterface::validate(mixed): ?string` (null = valid, otherwise the error message). All are **total** — non-string input returns the -error rather than throwing. Messages default to `laranail-console::validators.*` (see +error rather than throwing. Messages default to `laranail/console::validators.*` (see [i18n](../i18n.md)). Length checks count **characters** (`mb_strlen`), not bytes. **Constructor convention (2.0).** Constructors take **only validator-specific @@ -92,7 +92,7 @@ new RadioFieldValidator(['a', 'b'])->errorMessage('Pick one'); ``` `->errorMessage()` sets a fixed string. Without it, the default -`laranail-console::validators.*` message is used; `->replace([...])` substitutes placeholders +`laranail/console::validators.*` message is used; `->replace([...])` substitutes placeholders into that translated default and `->locale(...)` resolves it in a specific locale (see [i18n](../i18n.md)). All three resolve at validate-time, so the active locale is honoured: @@ -167,7 +167,7 @@ new V\UuidOrIntegerOrSlugValidator('uuid4'); new V\PathFieldValidator()->replace(['attribute' => 'config path'])->locale('fr'); ``` -The default messages live under `laranail-console::validators.*` (see [i18n](../i18n.md)): +The default messages live under `laranail/console::validators.*` (see [i18n](../i18n.md)): `->errorMessage()` replaces the message outright, while `->replace([...])` / `->locale(...)` feed the translated default. Resolution happens at validate-time. diff --git a/docs/tools/support.md b/docs/tools/support.md index 9e84f55..34b384b 100644 --- a/docs/tools/support.md +++ b/docs/tools/support.md @@ -159,7 +159,7 @@ vocabulary. It has nine cases: `Success`, `Failed`, `Warning`, `Pending`, | `symbol(?Capabilities)` | the [Symbols](#symbols) glyph (`✓` / `[OK]`); `●`/`[on]` and `○`/`[off]` for `Active`/`Inactive`; `''` for `Unknown` | | `color()` | the formatter colour used in markup (`green`, `red`, …) | | `role()` | the palette role (`success`, `danger`, `warning`, `info`, `muted`) for background-painting widgets such as `Badge` | -| `label()` | the translated label, from `laranail-console::console.status.` | +| `label()` | the translated label, from `laranail/console::console.status.` | `Status::fromBool($ok)` maps a boolean to `Success` or `Failed`. `TaskStatus::toStatus()` maps a task state onto the same vocabulary. Widgets @@ -348,7 +348,7 @@ Validate the `console.*` config with [`Console::validateConfig()`](../configurat ## Lang -Resolves widget strings from the `laranail-console::console.*` translations, honouring +Resolves widget strings from the `laranail/console::console.*` translations, honouring `console.locale` **without** mutating the host app's global locale; falls back to the given default (with `:placeholder` interpolation) when no translation exists. diff --git a/src/Exceptions/ConsoleException.php b/src/Exceptions/ConsoleException.php index e2f45cc..4f44357 100644 --- a/src/Exceptions/ConsoleException.php +++ b/src/Exceptions/ConsoleException.php @@ -6,12 +6,13 @@ use Throwable; use RuntimeException; +use Simtabi\Laranail\Console\Tools\Support\Translations; /** * Base exception for the laranail/console package. * * All Tools and Prompter exceptions extend this, so consumers can catch the - * whole package with a single type. Messages are resolved from the `laranail-console::` + * whole package with a single type. Messages are resolved from the `laranail/console::` * translation namespace with a safe fallback, so a missing key never produces * an empty-message exception. * @@ -35,16 +36,12 @@ public static function fromKey(string $key, array $replace = [], ?Throwable $pre */ protected static function resolveMessage(string $key, array $replace = []): string { - $namespaced = "laranail-console::{$key}"; + // Translations::get() only uses the translator when a container has + // bound it, so the exception is still usable outside a booted application. + $message = Translations::get($key, $replace); - // Only use the translator when a container has actually bound it, so - // the exception is still usable outside a booted Laravel application. - if (function_exists('app') && app()->bound('translator')) { - $message = trans($namespaced, $replace); - - if (is_string($message) && $message !== $namespaced) { - return $message; - } + if ($message !== Translations::key($key)) { + return $message; } // Fallback: humanise the leaf key and interpolate replacements. diff --git a/src/Prompter/Exceptions/PrompterException.php b/src/Prompter/Exceptions/PrompterException.php index 8b3970c..d32bf8e 100644 --- a/src/Prompter/Exceptions/PrompterException.php +++ b/src/Prompter/Exceptions/PrompterException.php @@ -7,7 +7,7 @@ use Simtabi\Laranail\Console\Exceptions\ConsoleException; /** - * Prompter-domain exception. Messages resolve from `laranail-console::prompter.*` with a + * Prompter-domain exception. Messages resolve from `laranail/console::prompter.*` with a * safe fallback (via {@see ConsoleException::fromKey()}), so a missing key never * produces an empty-message exception. */ diff --git a/src/Prompter/Services/FormBuilder/FormBuilderService.php b/src/Prompter/Services/FormBuilder/FormBuilderService.php index 372d9a6..1433893 100644 --- a/src/Prompter/Services/FormBuilder/FormBuilderService.php +++ b/src/Prompter/Services/FormBuilder/FormBuilderService.php @@ -7,6 +7,7 @@ use Closure; use Laravel\Prompts\FormBuilder as PromptsFormBuilder; use Simtabi\Laranail\Console\Prompter\Enums\FieldType; +use Simtabi\Laranail\Console\Tools\Support\Translations; use Simtabi\Laranail\Console\Prompter\Contracts\ValidatorInterface; use Simtabi\Laranail\Console\Prompter\Exceptions\PrompterException; use Simtabi\Laranail\Console\Prompter\Validators\RadioFieldValidator; @@ -132,7 +133,7 @@ protected function makeValidator(FormFieldService $formField): Closure $isEmpty = in_array($value, [null, '', []], true); if ($formField->required && $isEmpty) { - return $formField->customErrorMessage ?? __('laranail-console::prompter.field_required'); + return $formField->customErrorMessage ?? Translations::get('prompter.field_required'); } if ($isEmpty) { diff --git a/src/Prompter/Validators/AbstractValidator.php b/src/Prompter/Validators/AbstractValidator.php index 96408d2..964e172 100644 --- a/src/Prompter/Validators/AbstractValidator.php +++ b/src/Prompter/Validators/AbstractValidator.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\Console\Prompter\Validators; +use Simtabi\Laranail\Console\Tools\Support\Translations; use Simtabi\Laranail\Console\Prompter\Contracts\ValidatorInterface; /** @@ -90,7 +91,7 @@ protected static function configuredLocale(): ?string protected function resolvedMessage(): string { return $this->customMessage - ?? __('laranail-console::validators.' . $this->messageKey, $this->replace, $this->locale ?? self::configuredLocale()); + ?? Translations::get('validators.' . $this->messageKey, $this->replace, $this->locale ?? self::configuredLocale()); } /** diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index c3cc11f..ff6d2d8 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -7,6 +7,7 @@ use Override; use Illuminate\Support\ServiceProvider; use Simtabi\Laranail\Console\ConsoleManager; +use Simtabi\Laranail\Console\Tools\Support\Translations; use Simtabi\Laranail\Console\Exceptions\ConsoleException; use Simtabi\Laranail\Console\Tools\Support\ConfigValidator; use Simtabi\Laranail\Console\Tools\Providers\ToolsServiceProvider; @@ -40,18 +41,23 @@ public function register(): void public function boot(): void { - $this->loadTranslationsFrom(self::LANG_PATH, 'laranail-console'); + // The canonical namespace is the composer package name, so overrides land in + // lang/vendor/laranail/console. The hyphen form is the pre-0.1.5 namespace, + // kept over the same files so `__('laranail-console::…')` still resolves; + // Translations::get() also honours overrides a host made against it. + $this->loadTranslationsFrom(self::LANG_PATH, Translations::NAMESPACE); + $this->loadTranslationsFrom(self::LANG_PATH, Translations::LEGACY_NAMESPACE); if ($this->app->runningInConsole()) { $this->publishes([ self::CONFIG_PATH => $this->app->configPath('laranail/console.php'), ], 'laranail::console-config'); - // vendor/laranail-console, matching the namespace registered above. - // Publishing to the lang root put the files where the namespaced - // loader never looks, so every published override was ignored. + // vendor/laranail/console, matching the canonical namespace above + // (Laravel reads overrides from lang/vendor/{namespace}). Publishing to + // the lang root put the files where the namespaced loader never looks. $this->publishes([ - self::LANG_PATH => $this->app->langPath('vendor/laranail-console'), + self::LANG_PATH => $this->app->langPath('vendor/' . Translations::NAMESPACE), ], 'laranail::console-lang'); // Opt-in fail-fast: validate console.* config at boot (console only, so diff --git a/src/Tools/Commands/Command.php b/src/Tools/Commands/Command.php index 2b38dea..270536b 100644 --- a/src/Tools/Commands/Command.php +++ b/src/Tools/Commands/Command.php @@ -5,6 +5,7 @@ namespace Simtabi\Laranail\Console\Tools\Commands; use Illuminate\Console\Command as BaseCommand; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\WarnsOnDeprecatedAlias; use Simtabi\Laranail\Console\Tools\Commands\Concerns\InteractsWithConsoleWriter; use Simtabi\Laranail\Console\Tools\Commands\Concerns\InteractsWithConsoleServices; @@ -25,6 +26,10 @@ * All of that behaviour lives in {@see InteractsWithConsoleServices} — `use` that * trait directly when you must extend a different command base. * + * Old names a command must keep answering to go in `$deprecatedCommandAliases` + * ({@see WarnsOnDeprecatedAlias}): they stay registered and print a one-line + * warning naming the canonical command when used. + * * @see https://laravel.com/docs/artisan * * @api Stable command base (SemVer-covered). @@ -33,6 +38,7 @@ abstract class Command extends BaseCommand { use InteractsWithConsoleServices; use InteractsWithConsoleWriter; + use WarnsOnDeprecatedAlias; public function __construct() { @@ -63,7 +69,9 @@ public function __construct() * `::`-namespaced aliases here; otherwise standard Symfony validation * applies. An alias must itself be vendor-scoped -- a bare `make:crud` * beside `laranail::toolkit.make-crud` hands back exactly the global - * collision the namespaced name exists to prevent. + * collision the namespaced name exists to prevent. The one exception is + * an old name kept for compatibility, which belongs in + * `$deprecatedCommandAliases` so it warns ({@see WarnsOnDeprecatedAlias}). * * @return list */ diff --git a/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php new file mode 100644 index 0000000..677483c --- /dev/null +++ b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php @@ -0,0 +1,98 @@ +call()` alike, while `getName()` is + * always the canonical name. Symfony calls `initialize()` after binding the input and before + * `interact()`, so the warning prints before any prompt. On a real terminal it goes to stderr, + * so piped output is unchanged. + * + * @api Stable extension point (SemVer-covered). + */ +trait WarnsOnDeprecatedAlias +{ + /** + * Every alias, including the deprecated ones, so the application registers them all. + * + * @return list + */ + public function getAliases(): array + { + return array_values(array_unique([...parent::getAliases(), ...$this->deprecatedCommandAliases()])); + } + + /** + * The aliases this command keeps only for backwards compatibility. + * + * Read from the consuming command's own `$deprecatedCommandAliases`, for the same reason + * `$commandAliases` is: declaring the property on a trait makes a using class that declares + * it with a different default a fatal at composition, and reading an undeclared one throws. + * The command's own name is never treated as deprecated. + * + * @return list + */ + public function deprecatedCommandAliases(): array + { + if (! property_exists($this, 'deprecatedCommandAliases') || ! is_array($this->deprecatedCommandAliases)) { + return []; + } + + $name = $this->getName(); + + return array_values(array_unique(array_filter( + $this->deprecatedCommandAliases, + static fn (mixed $alias): bool => is_string($alias) && $alias !== '' && $alias !== $name, + ))); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + parent::initialize($input, $output); + + $invokedAs = $input->getFirstArgument(); + + if (! is_string($invokedAs) || ! in_array($invokedAs, $this->deprecatedCommandAliases(), true)) { + return; + } + + $target = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + + $target->writeln(sprintf( + 'Deprecated: [%s] is a deprecated alias and will be removed in the next minor after 0.1. Use [%s] instead.', + $invokedAs, + (string) $this->getName(), + )); + } +} diff --git a/src/Tools/Commands/Services/CommandInteractionService.php b/src/Tools/Commands/Services/CommandInteractionService.php index 582c2b3..fda2481 100644 --- a/src/Tools/Commands/Services/CommandInteractionService.php +++ b/src/Tools/Commands/Services/CommandInteractionService.php @@ -13,6 +13,7 @@ use function Laravel\Prompts\multiselect; use Simtabi\Laranail\Console\Tools\Support\Config; +use Simtabi\Laranail\Console\Tools\Support\Translations; use Simtabi\Laranail\Console\Tools\Exceptions\NonInteractiveException; /** @@ -141,7 +142,7 @@ public function askWithValidation(string $question, ?callable $validator = null, return $answer; } - error(__('laranail-console::console.invalid_input')); + error(Translations::get('console.invalid_input')); } while (true); } diff --git a/src/Tools/Exceptions/FontException.php b/src/Tools/Exceptions/FontException.php index 4c88fd5..9d5d446 100644 --- a/src/Tools/Exceptions/FontException.php +++ b/src/Tools/Exceptions/FontException.php @@ -8,7 +8,7 @@ /** * Thrown by {@see Figlet} when a font - * can't be loaded. Messages resolve from `laranail-console::console.font_*`. + * can't be loaded. Messages resolve from `laranail/console::console.font_*`. */ class FontException extends ConsoleException { diff --git a/src/Tools/Support/Lang.php b/src/Tools/Support/Lang.php index d9d8dc8..04ed941 100644 --- a/src/Tools/Support/Lang.php +++ b/src/Tools/Support/Lang.php @@ -5,7 +5,7 @@ namespace Simtabi\Laranail\Console\Tools\Support; /** - * Resolves widget strings from the `laranail-console::console.*` translation namespace, + * Resolves widget strings from the `laranail/console::console.*` translation namespace, * honouring `config('laranail.console.locale')` WITHOUT mutating the host app's global * locale. Falls back to the supplied English default (with `:placeholder` * interpolation) when no translator or key is available — so widgets render @@ -18,14 +18,10 @@ final class Lang */ public static function get(string $key, string $default, array $replace = []): string { - $namespaced = "laranail-console::console.{$key}"; + $message = Translations::get("console.{$key}", $replace, Config::locale()); - if (function_exists('app') && app()->bound('translator')) { - $message = trans($namespaced, $replace, Config::locale()); - - if (is_string($message) && $message !== $namespaced) { - return $message; - } + if ($message !== Translations::key("console.{$key}")) { + return $message; } return self::interpolate($default, $replace); diff --git a/src/Tools/Support/Status.php b/src/Tools/Support/Status.php index 2771448..35abda8 100644 --- a/src/Tools/Support/Status.php +++ b/src/Tools/Support/Status.php @@ -10,7 +10,7 @@ * consumer never hand-writes a `match` from a status string to a styled label. * * Glyphs come from {@see Symbols} (Unicode or ASCII per {@see Capabilities}); - * labels resolve through {@see Lang} under `laranail-console::console.status.*` + * labels resolve through {@see Lang} under `laranail/console::console.status.*` * with an English fallback, so the enum works outside a booted application. * * @api Stable enum (SemVer-covered). diff --git a/src/Tools/Support/Translations.php b/src/Tools/Support/Translations.php new file mode 100644 index 0000000..ad70639 --- /dev/null +++ b/src/Tools/Support/Translations.php @@ -0,0 +1,111 @@ +.`. + */ + public static function key(string $key): string + { + return self::NAMESPACE . '::' . $key; + } + + /** + * Translate `.` from this package's strings. Returns the canonical key itself when + * no translator is bound or the key is missing, matching what `trans()` does for a miss. + * + * @param array $replace + */ + public static function get(string $key, array $replace = [], ?string $locale = null): string + { + $canonicalKey = self::key($key); + + if (! function_exists('app') || ! app()->bound('translator')) { + return $canonicalKey; + } + + $canonical = trans($canonicalKey, [], $locale); + $legacy = trans(self::LEGACY_NAMESPACE . '::' . $key, [], $locale); + + $useLegacy = $canonical !== $legacy + && is_string($legacy) + && $legacy !== self::LEGACY_NAMESPACE . '::' . $key + && ($canonical === $canonicalKey || $canonical === self::packaged($key, $locale)); + + $message = trans($useLegacy ? self::LEGACY_NAMESPACE . '::' . $key : $canonicalKey, $replace, $locale); + + return is_string($message) ? $message : $canonicalKey; + } + + /** + * The line as shipped in `resources/lang`, before any override, in the locale the translator + * would use (requested, else current, else fallback). + */ + private static function packaged(string $key, ?string $locale): mixed + { + [$group, $item] = array_pad(explode('.', $key, 2), 2, null); + + $translator = app('translator'); + $locales = array_unique(array_filter([ + $locale, + method_exists($translator, 'getLocale') ? $translator->getLocale() : null, + method_exists($translator, 'getFallback') ? $translator->getFallback() : null, + ], is_string(...))); + + foreach ($locales as $candidate) { + $file = self::LANG_PATH . '/' . $candidate . '/' . $group . '.php'; + + if (! is_file($file)) { + continue; + } + + $lines = require $file; + + if (! is_array($lines)) { + continue; + } + + $line = $item === null ? $lines : Arr::get($lines, $item); + + if ($line !== null) { + return $line; + } + } + + return null; + } +} diff --git a/tests/Tools/Feature/TranslationNamespaceTest.php b/tests/Tools/Feature/TranslationNamespaceTest.php new file mode 100644 index 0000000..10b5447 --- /dev/null +++ b/tests/Tools/Feature/TranslationNamespaceTest.php @@ -0,0 +1,128 @@ +namespaces(); + + self::assertArrayHasKey('laranail/console', $hints); + self::assertArrayHasKey('laranail-console', $hints); + self::assertSame(realpath(self::LANG_PATH), realpath($hints['laranail/console'])); + self::assertSame(realpath($hints['laranail/console']), realpath($hints['laranail-console'])); + } + + public function test_no_bare_namespace_is_registered(): void + { + self::assertArrayNotHasKey('console', Lang::getLoader()->namespaces()); + } + + public function test_both_forms_resolve_the_same_string(): void + { + self::assertSame('Invalid input. Please try again.', __('laranail/console::console.invalid_input')); + self::assertSame(__('laranail/console::console.invalid_input'), __('laranail-console::console.invalid_input')); + self::assertSame(__('laranail/console::validators.email'), __('laranail-console::validators.email')); + } + + public function test_the_package_resolves_through_the_canonical_namespace(): void + { + $this->app['translator']->addLines(['console.invalid_input' => 'canonical override'], 'en', 'laranail/console'); + + self::assertSame('canonical override', Translations::get('console.invalid_input')); + } + + public function test_an_override_made_against_the_old_namespace_still_applies(): void + { + // A host that published to lang/vendor/laranail-console, or added lines to it, before 0.1.5. + $this->app['translator']->addLines(['validators.email' => 'legacy override'], 'en', 'laranail-console'); + + self::assertSame('legacy override', Translations::get('validators.email')); + self::assertSame('legacy override', new EmailFieldValidator()->validate('nope')); + } + + public function test_the_canonical_override_wins_when_both_are_overridden(): void + { + $this->app['translator']->addLines(['console.invalid_input' => 'legacy'], 'en', 'laranail-console'); + $this->app['translator']->addLines(['console.invalid_input' => 'canonical'], 'en', 'laranail/console'); + + self::assertSame('canonical', Translations::get('console.invalid_input')); + } + + public function test_replacements_apply_to_either_source(): void + { + $this->app['translator']->addLines(['console.custom' => 'hello :name'], 'en', 'laranail-console'); + + self::assertSame('hello Ada', Translations::get('console.custom', ['name' => 'Ada'])); + } + + public function test_a_missing_key_returns_the_canonical_key(): void + { + self::assertSame('laranail/console::console.no_such_key', Translations::get('console.no_such_key')); + self::assertSame('No such key', ConsoleException::fromKey('console.no_such_key')->getMessage()); + } + + public function test_no_internal_lookup_uses_the_old_namespace(): void + { + // Source, not behaviour: a lookup that slips back to the hyphen form still resolves, so + // nothing else would notice it bypassing Translations::get(). + $root = dirname(__DIR__, 3) . '/src'; + $files = 0; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $files++; + + // String literals only (plain and interpolated), so a docblock naming the old + // namespace for a reader is not mistaken for a lookup. + foreach (token_get_all((string) file_get_contents($file->getPathname())) as $token) { + if (is_array($token) && in_array($token[0], [T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE], true)) { + self::assertStringNotContainsString( + 'laranail-console::', + $token[1], + "{$file->getPathname()}:{$token[2]} looks up a string through the deprecated laranail-console:: namespace.", + ); + } + } + } + + // 200+ files on 2026-10-05; a walk that stops matching must fail, not pass empty. + self::assertGreaterThan(150, $files, 'too few source files scanned; the walk no longer matches.'); + } + + public function test_the_lang_files_publish_to_the_canonical_directory(): void + { + $paths = ServiceProvider::pathsToPublish(ConsoleServiceProvider::class, 'laranail::console-lang'); + + self::assertCount(1, $paths); + self::assertSame( + str_replace('\\', '/', $this->app->langPath('vendor/laranail/console')), + str_replace('\\', '/', (string) array_values($paths)[0]), + ); + } +} diff --git a/tests/Tools/Unit/Commands/DeprecatedAliasTest.php b/tests/Tools/Unit/Commands/DeprecatedAliasTest.php new file mode 100644 index 0000000..c4bf97f --- /dev/null +++ b/tests/Tools/Unit/Commands/DeprecatedAliasTest.php @@ -0,0 +1,161 @@ + */ + protected array $commandAliases = ['laranail::console-test.rn']; + + /** @var list */ + protected array $deprecatedCommandAliases = ['console-test:old-name', 'laranail::console-test.renamed']; + + public function handle(): int + { + $this->line('handled'); + + return self::SUCCESS; + } +} + +/** + * The same, on Laravel's own base: the trait has to work without the package's command base. + */ +final class DeprecatedAliasTraitOnlyCommand extends IlluminateCommand +{ + use SupportsNamespacedNames; + use WarnsOnDeprecatedAlias; + + protected $signature = 'laranail::console-test.trait-only'; + + protected $description = 'Deprecated-alias trait-only test command'; + + /** @var list */ + protected array $deprecatedCommandAliases = ['console-test:trait-old']; + + public function handle(): int + { + $this->line('handled'); + + return self::SUCCESS; + } +} + +final class DeprecatedAliasTest extends TestCase +{ + private const string WARNING = 'is a deprecated alias'; + + protected function setUp(): void + { + parent::setUp(); + + $kernel = $this->app->make(Kernel::class); + $kernel->registerCommand(new DeprecatedAliasBaseCommand); + $kernel->registerCommand(new DeprecatedAliasTraitOnlyCommand); + } + + public function test_the_deprecated_alias_is_registered_in_the_live_application(): void + { + $commands = Artisan::all(); + + self::assertArrayHasKey('console-test:old-name', $commands); + self::assertInstanceOf(DeprecatedAliasBaseCommand::class, $commands['console-test:old-name']); + self::assertArrayHasKey('console-test:trait-old', $commands); + self::assertInstanceOf(DeprecatedAliasTraitOnlyCommand::class, $commands['console-test:trait-old']); + } + + public function test_a_bare_deprecated_alias_runs_and_warns_naming_the_canonical_command(): void + { + self::assertSame(0, Artisan::call('console-test:old-name')); + + $output = Artisan::output(); + + self::assertStringContainsString('handled', $output); + self::assertStringContainsString( + 'Deprecated: [console-test:old-name] is a deprecated alias and will be removed in the next minor after 0.1. Use [laranail::console-test.renamed] instead.', + $output, + ); + } + + public function test_the_warning_prints_before_the_command_runs(): void + { + Artisan::call('console-test:old-name'); + + $output = Artisan::output(); + + self::assertLessThan(strpos($output, 'handled'), strpos($output, self::WARNING)); + } + + public function test_the_canonical_name_does_not_warn(): void + { + self::assertSame(0, Artisan::call('laranail::console-test.renamed')); + + $output = Artisan::output(); + + self::assertStringContainsString('handled', $output); + self::assertStringNotContainsString(self::WARNING, $output); + } + + public function test_a_vendor_scoped_alias_that_is_not_deprecated_does_not_warn(): void + { + self::assertSame(0, Artisan::call('laranail::console-test.rn')); + + $output = Artisan::output(); + + self::assertStringContainsString('handled', $output); + self::assertStringNotContainsString(self::WARNING, $output); + } + + public function test_the_trait_works_without_the_package_base(): void + { + self::assertSame(0, Artisan::call('console-test:trait-old')); + self::assertStringContainsString('Use [laranail::console-test.trait-only] instead.', Artisan::output()); + + self::assertSame(0, Artisan::call('laranail::console-test.trait-only')); + self::assertStringNotContainsString(self::WARNING, Artisan::output()); + } + + public function test_the_command_name_is_never_treated_as_deprecated(): void + { + self::assertSame(['console-test:old-name'], new DeprecatedAliasBaseCommand()->deprecatedCommandAliases()); + self::assertSame( + ['laranail::console-test.rn', 'console-test:old-name'], + new DeprecatedAliasBaseCommand()->getAliases(), + ); + } + + public function test_a_command_without_the_property_has_no_deprecated_aliases(): void + { + $command = new class extends Command + { + protected $signature = 'laranail-test:no-deprecated'; + + public function handle(): int + { + return self::SUCCESS; + } + }; + + self::assertSame([], $command->deprecatedCommandAliases()); + self::assertSame([], $command->getAliases()); + } +}