Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
## <a name="documentation"></a>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

Expand Down
12 changes: 11 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<fg=green>…</>`) | `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
Expand Down
55 changes: 44 additions & 11 deletions docs/tools/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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::<package-slug>.<command>` separator (Symfony's
validator otherwise rejects the empty `::` segment). `use` it on a command and set a
`::` name in the `$signature`:
A `laranail::<package-slug>.<command>` 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;
Expand All @@ -202,25 +207,53 @@ 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;

protected $signature = 'laranail::your-package.make-crud';

/** @var list<string> */
protected array $commandAliases = ['make:crud'];
protected array $commandAliases = ['laranail::your-package.crud'];

public function handle(): int
{
return self::SUCCESS;
}
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/tools/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`).
Expand Down
77 changes: 77 additions & 0 deletions tests/Tools/Feature/DocumentedNamesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a name> 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## <a name=\"documentation\"></a>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.
Expand All @@ -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 `<a name>` / `<a id>` targets. Headings inside code fences are not headings.
*
* @return list<string>
*/
private function anchors(string $markdown): array
{
$anchors = [];
$counts = [];

foreach (explode("\n", $this->withoutFences($markdown)) as $line) {
preg_match_all('/<a\s+(?:name|id)="([^"]+)"/', $line, $explicit);

foreach ($explicit[1] as $name) {
$anchors[] = $name;
}

if (preg_match('/^#{1,6}\s+(.*?)\s*#*\s*$/', $line, $heading) !== 1) {
continue;
}

$text = str_replace('`', '', (string) preg_replace('/<[^>]+>/', '', $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<string, string> relative path => markdown
*/
Expand Down
83 changes: 83 additions & 0 deletions tests/Tools/Unit/Commands/NamespacedNameRequirementTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Tools\Tests\Unit\Commands;

use Illuminate\Contracts\Console\Kernel;
use Simtabi\Laranail\Console\Tools\Tests\TestCase;
use Illuminate\Console\Command as IlluminateCommand;
use Simtabi\Laranail\Console\Tools\Commands\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames;

/**
* Pins what `docs/tools/commands.md` says a command needs for a `laranail::<slug>.<command>` 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);
}
}
Loading