diff --git a/README.md b/README.md index cdb3fd0..ce9a27e 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,14 @@ $name = Console::prompter()->text('Your name', required: true)->getResult(); The full walkthrough is in [Getting started](docs/getting-started.md); everything else is in the [documentation index](#documentation). -## Documentation +## Documentation Full documentation is at **[opensource.simtabi.com/documentation/laranail/console](https://opensource.simtabi.com/documentation/laranail/console/)** — installation, getting started, the design system, architecture, configuration, and per-subsystem reference (theming, colours, typography, Markdown, charts, widgets, banners, panels, menus, the full-screen TUI, prompts & forms, and more). ## Contributing & security Issues and PRs are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities per -[SECURITY.md](SECURITY.md) (opensource@simtabi.com); participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). +[SECURITY.md](SECURITY.md) (security@simtabi.com); participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). ## License diff --git a/docs/getting-started.md b/docs/getting-started.md index add19e3..028d05e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,9 +30,19 @@ echo Console::box(['Name: app', 'Env: prod'])->title('Config')->render(); echo Console::tree('project')->child('src', fn ($t) => $t->child('Console'))->render(); ``` -Know which of the [three output styles](../README.md#writing-through-an-output) a helper returns — it +Know which of the [three output styles](#writing-through-an-output) a helper returns — it decides whether you `writeln()` or `echo`. +### Writing through an output + +| Style | Examples | How to print it | +|---|---|---| +| Symfony Console markup (`…`) | `Console::status()->success()`, `ConsoleUIFormatter::success()`, `badge()`, `link()` | `$output->writeln(...)` (in a command, `$this->line(...)`). `echo` prints the tags literally. | +| Finished string | `render()` on `Console::box()`, `Console::tree()`, `Console::table()` and the other string-returning widgets; `colorize()` | `echo`, or `writeln()` | +| Self-writing | `Spinner`, `ProgressBar`, `TaskProgress` | Nothing to print: they write to the `OutputInterface` they are given and have no `render()`. | + +See [Output widgets](tools/widgets.md) and [Formatting](tools/formatting.md) for which helper is which. + ## 3. Progress + a prompt ```php diff --git a/docs/tools/commands.md b/docs/tools/commands.md index 21b91da..9a6287a 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -60,7 +60,9 @@ 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` trait. If your command must +`Tools\Commands\Concerns\InteractsWithConsoleServices` and `InteractsWithConsoleWriter` +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 lifecycle, signals, structured exceptions and the verbosity helpers: @@ -188,10 +190,13 @@ final class PurgeCommand extends Command ## Namespaced command names -This package ships its own `Tools\Commands\Concerns\SupportsNamespacedNames` trait -so commands can use the `laranail::.` separator (Symfony's -validator otherwise rejects the empty `::` segment). `use` it on a command and set a -`::` name in the `$signature`: +A `laranail::.` name needs **two** things on the command class: +extend a command base **and** `use` the `Tools\Commands\Concerns\SupportsNamespacedNames` +trait. Extending `Tools\Commands\Command` alone is not enough: the base does not compose +the trait, so the `::` name reaches Symfony's `validateName()` (`^[^:]++(:[^:]++)*$`, which +rejects the empty segment in `::`) and the constructor throws +`Command name "laranail::your-package.sync" is invalid.` For a command your provider +registers, that is when the application boots. ```php use Simtabi\Laranail\Console\Tools\Commands\Command; @@ -202,17 +207,40 @@ final class SyncCommand extends Command use SupportsNamespacedNames; protected $signature = 'laranail::your-package.sync'; + + public function handle(): int + { + return self::SUCCESS; + } } ``` -### Convenience aliases (`$commandAliases`) +| Command class | `laranail::your-package.sync` | +|---|---| +| `extends Command` | throws `InvalidArgumentException` at construction | +| `extends Command` + `use SupportsNamespacedNames` | accepted, and `php artisan laranail::your-package.sync` dispatches | +| any other base (e.g. `Illuminate\Console\Command`) + `use SupportsNamespacedNames` | accepted; the trait has no dependency on this package's base | + +The trait writes the name and aliases past the validator. Dispatch still works because +Symfony resolves an exact command name before its `:`-splitting namespace lookup. Both +cases are pinned by `tests/Tools/Unit/Commands/NamespacedNameRequirementTest.php`. + +### Aliases (`$commandAliases`) + +The base `Command` applies an optional `$commandAliases` list after construction. It +writes them through whatever `setAliases()` is in scope, so with +`SupportsNamespacedNames` an alias may use the `::` separator too; without it, standard +Symfony validation applies. `$commandAliases` is a feature of this package's base only: +the trait alone never reads it. -The base `Command` applies an optional `$commandAliases` list after construction — -handy for exposing a short, familiar alias alongside the namespaced name (e.g. a bare -`make:crud` next to `laranail::your-package.make-crud`). Combined with -`SupportsNamespacedNames`, the aliases may themselves use the `::` separator. +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. ```php +use Simtabi\Laranail\Console\Tools\Commands\Command; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames; + final class MakeCrudCommand extends Command { use SupportsNamespacedNames; @@ -220,7 +248,12 @@ final class MakeCrudCommand extends Command protected $signature = 'laranail::your-package.make-crud'; /** @var list */ - protected array $commandAliases = ['make:crud']; + protected array $commandAliases = ['laranail::your-package.crud']; + + public function handle(): int + { + return self::SUCCESS; + } } ``` diff --git a/docs/tools/widgets.md b/docs/tools/widgets.md index f168740..e9b95b5 100644 --- a/docs/tools/widgets.md +++ b/docs/tools/widgets.md @@ -5,7 +5,7 @@ All widgets live under `Simtabi\Laranail\Console\Tools\Widgets` and most have a [Support backbone](support.md), so Unicode/colour degrade to ASCII/plain automatically. A runnable demo is at `examples/tools/widgets.php`. -**Two output shapes** (see the README's "Writing through an output"): +**Two output shapes** (see [Writing through an output](../getting-started.md#writing-through-an-output)): - **Self-writing** — `Spinner`, `ProgressBar`, `TaskProgress` write to an `OutputInterface` you give them (no `render()`). diff --git a/tests/Tools/Feature/DocumentedNamesTest.php b/tests/Tools/Feature/DocumentedNamesTest.php index 779763a..64a00d4 100644 --- a/tests/Tools/Feature/DocumentedNamesTest.php +++ b/tests/Tools/Feature/DocumentedNamesTest.php @@ -86,6 +86,46 @@ public function test_documented_paths_are_where_the_package_publishes(): void self::assertGreaterThan(0, $seen, 'no config/ or lang/ path found in the docs; the pattern no longer matches.'); } + public function test_intra_repo_fragment_links_resolve(): void + { + // getting-started linked ../README.md#writing-through-an-output for months: the file + // resolves, only the fragment is wrong, and no link checker reports that. + $root = dirname(__DIR__, 3); + $seen = 0; + + foreach ($this->pages() as $page => $markdown) { + preg_match_all('/\]\(([^)\s#]*)#([^)\s]+)\)/', $this->withoutFences($markdown), $matches, PREG_SET_ORDER); + + foreach ($matches as [, $path, $fragment]) { + if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $path) === 1) { + continue; // an external URL + } + + $seen++; + $target = $path === '' ? $root . '/' . $page : dirname($root . '/' . $page) . '/' . $path; + + self::assertFileExists($target, "{$page} links {$path}#{$fragment}, whose file does not exist."); + self::assertContains( + $fragment, + $this->anchors((string) file_get_contents($target)), + "{$page} links {$path}#{$fragment}, but that page has no such heading or anchor.", + ); + } + } + + // 62 on 2026-10-04; a pattern that stops matching must fail, not pass empty. + self::assertGreaterThanOrEqual(40, $seen, 'too few fragment links found; the pattern no longer matches.'); + } + + public function test_the_anchor_slugger_matches_github(): void + { + // The explicit and the heading slug both yield "documentation"; a fenced line is skipped. + self::assertSame( + ['quick-start-guide-and-usage', 'documentation', 'documentation', 'contributing--security', 'laranailconsolecheck', 'usage', 'usage-1'], + $this->anchors("## Quick start guide and usage\n## Documentation\n## Contributing & security\n## `laranail::console.check`\n### Usage\n### Usage\n```\n# not a heading\n```\n"), + ); + } + public function test_publish_destinations_compare_separator_insensitively(): void { // Windows joins with a backslash, so the docs' forward-slash paths must match either way. @@ -104,6 +144,43 @@ private function relative(string $path, string $base): string return ltrim(str_starts_with($path, $base) ? substr($path, strlen($base)) : $path, '/'); } + /** + * Every anchor a page exposes: GitHub's heading slugs (duplicates suffixed -1, -2, ...) + * plus explicit `` / `` targets. Headings inside code fences are not headings. + * + * @return list + */ + private function anchors(string $markdown): array + { + $anchors = []; + $counts = []; + + foreach (explode("\n", $this->withoutFences($markdown)) as $line) { + preg_match_all('/]+>/', '', $heading[1])); + $slug = str_replace(' ', '-', (string) preg_replace('/[^\p{L}\p{N}_\- ]/u', '', mb_strtolower(trim($text)))); + $n = $counts[$slug] ?? 0; + $counts[$slug] = $n + 1; + $anchors[] = $n === 0 ? $slug : "{$slug}-{$n}"; + } + + return $anchors; + } + + private function withoutFences(string $markdown): string + { + return (string) preg_replace('/^[ \t]*```.*?^[ \t]*```[^\n]*$/ms', '', $markdown); + } + /** * @return array relative path => markdown */ diff --git a/tests/Tools/Unit/Commands/NamespacedNameRequirementTest.php b/tests/Tools/Unit/Commands/NamespacedNameRequirementTest.php new file mode 100644 index 0000000..3fc848b --- /dev/null +++ b/tests/Tools/Unit/Commands/NamespacedNameRequirementTest.php @@ -0,0 +1,83 @@ +.` name: + * extending the base `Command` is not enough, the `SupportsNamespacedNames` trait is required too. + * + * The base does not compose the trait, so a `::` signature on a plain subclass reaches Symfony's + * `validateName()` and throws while the command is being constructed -- which, for a command + * registered by a provider, is when the application boots. + */ +final class NamespacedNameRequirementTest extends TestCase +{ + public function test_the_base_alone_rejects_a_namespaced_name_at_construction(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Command name "laranail::console-test.base-only" is invalid.'); + + new class extends Command + { + protected $signature = 'laranail::console-test.base-only'; + + public function handle(): int + { + return self::SUCCESS; + } + }; + } + + public function test_the_base_with_the_trait_accepts_and_dispatches_a_namespaced_name(): void + { + $command = new class extends Command + { + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.with-trait'; + + public function handle(): int + { + $this->line('dispatched'); + + return self::SUCCESS; + } + }; + + self::assertSame('laranail::console-test.with-trait', $command->getName()); + + $this->app->make(Kernel::class)->registerCommand($command); + + $this->artisan('laranail::console-test.with-trait') + ->expectsOutput('dispatched') + ->assertExitCode(0); + } + + public function test_the_trait_works_on_a_base_from_outside_this_package(): void + { + $command = new class extends IlluminateCommand + { + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.plain-base'; + + public function handle(): int + { + return self::SUCCESS; + } + }; + + $this->app->make(Kernel::class)->registerCommand($command); + + $this->artisan('laranail::console-test.plain-base')->assertExitCode(0); + } +}