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);
+ }
+}