diff --git a/README.md b/README.md index bf90f99..55a4ff6 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,8 @@ function displayUser(user: App.Models.User) { } ``` +Attributes listed in `$hidden` are left out, as is anything marked with the `WayfinderIgnore` attribute — see [Leaving Things Out](#leaving-things-out). + ## PHP Enums Wayfinder converts PHP enums to TypeScript types and constants. @@ -339,6 +341,8 @@ function setStatus(status: App.Enums.PostStatus) { } ``` +A case can be left out of the generated enum, either always or only for some builds — see [Leaving Things Out](#leaving-things-out). + ### Enum Methods Enums often carry methods alongside their cases: @@ -616,6 +620,158 @@ This provides autocomplete and type-checking for `import.meta.env`: const appName = import.meta.env.VITE_APP_NAME; ``` +## Leaving Things Out + +Some of what Wayfinder can see should not reach the browser. Mark it with the `WayfinderIgnore` attribute and nothing is generated for it: + +```php +use Laravel\Wayfinder\Attributes\WayfinderIgnore; + +#[WayfinderIgnore] +class InternalController +{ + // No action file, no route helper, no request type. +} +``` + +The attribute works on a controller class or a single action, a model, an enum or one of its cases, a broadcast event or channel, and on a model's accessors and relations: + +```php +class UserController +{ + public function index() { /* generated */ } + + #[WayfinderIgnore] + public function impersonate() { /* not generated */ } +} +``` + +```php +class User extends Model +{ + #[WayfinderIgnore] + public function auditEntries(): HasMany + { + return $this->hasMany(AuditEntry::class); + } +} +``` + +Dropping an action drops everything that hangs off it: the route helper, the form variant, the page type, and the request type. Dropping a model drops relations that point at it, since there is no type left to point to. + +### Keeping Something For Some Builds Only + +Some declarations belong in local builds and nowhere else: a fake source provider, a seeding endpoint, a debug page. Pass `unless` and the declaration is kept only while the condition holds: + +```php +// config/services.php +'fake_source_provider' => env('FAKE_SOURCE_PROVIDER', false), + +// App\Enums\SourceProvider +enum SourceProvider: string +{ + case Github = 'github'; + case Gitlab = 'gitlab'; + + #[WayfinderIgnore(unless: 'services.fake_source_provider')] + case GitFake = 'gitfake'; +} +``` + +Locally, with the flag on: + +```typescript +export type SourceProvider = "github" | "gitlab" | "gitfake"; +``` + +In production, with the flag off or missing: + +```typescript +export type SourceProvider = "github" | "gitlab"; +``` + +`when` is the other way round, for something to leave out while a condition holds rather than keep: + +```php +class DebugController +{ + #[WayfinderIgnore(when: 'services.hide_debug_tools')] + public function dump() { /* ... */ } +} +``` + +Either takes a config key, or a `[class, method]` callable for a condition with real logic behind it: + +```php +#[WayfinderIgnore(unless: [SourceProviders::class, 'fakeEnabled'])] +``` + +Whatever you pass is answered when the files are generated, by the environment generating them. Wayfinder's output is regenerated per build, so each build gets the answer for where it runs. Nothing is remembered between builds: the analysis cache holds the condition, never the answer to it. + +| Condition | `unless` | `when` | +| --------- | -------- | ------ | +| Passes | Kept | Left out | +| Fails, or the config key is missing | Left out | Kept | +| Cannot be read | Left out | Left out | + +A condition Wayfinder cannot read leaves the declaration out whichever argument it was written with, so a typo shows up as a type error rather than shipping something. Only a config key or a callable counts as a condition, so `#[WayfinderIgnore(true)]` is not a way to switch a marker on or off — it is a marker with no condition, which leaves the declaration out. Passing both arguments is allowed and each can only add hiding, so `unless` keeping something does not override `when` leaving it out. Reach for one or the other. + +One consequence worth planning for: the production build genuinely does not have the member, so code that reads it has to sit somewhere the production build never typechecks. + +### Payload Keys + +An attribute cannot go on an array key, so mark those with a comment instead. Put `@wayfinder-ignore` above the key or at the end of its line: + +```php +return Inertia::render('Profile', [ + 'name' => $user->name, + 'apiToken' => $user->api_token, // @wayfinder-ignore + 'billing' => [ + 'plan' => $user->plan, + // @wayfinder-ignore + 'stripeId' => $user->stripe_id, + ], +]); +``` + +```typescript +export type Profile = Inertia.SharedData & { + name: string; + billing: { plan: string }; +}; +``` + +The key is gone, not emptied, so code that still reads it fails to compile. This works anywhere Wayfinder reads an array: page props, `toArray()` on a resource, `broadcastWith()`, and the rules of a form request. + +A marker hides a member from its own type. If something else hands the same value out under a key of its own, that key needs its own marker. + +For a plain database column on a model, Eloquent's `$hidden` and `#[Hidden]` already keep it out, and Wayfinder follows them. + +### Traits + +Nothing is generated for a trait, so a marker on the trait itself has nothing to hide. Mark the members inside it, which works whether the trait is used by one model or twenty: + +```php +trait HasAvatar +{ + #[WayfinderIgnore] + public function avatarPath(): Attribute { /* ... */ } +} +``` + +### Markers From Other Packages + +To honor an attribute you cannot change, or a different comment tag, list them in `config/wayfinder.php`: + +```php +'ignore' => [ + 'attributes' => [\Vendor\Package\Attributes\Internal::class], + 'tags' => ['wayfinder-ignore', 'ignore'], +], +``` + +Your own attributes need no registration. Any attribute implementing `Laravel\Surveyor\Contracts\Ignored` is honored. + ## Configuration The configuration file is located at `config/wayfinder.php`: @@ -623,6 +779,10 @@ The configuration file is located at `config/wayfinder.php`: ```php return [ 'generate' => [ + 'ignore' => [ + 'attributes' => [], + 'tags' => ['wayfinder-ignore'], + ], 'route' => [ 'actions' => env('WAYFINDER_GENERATE_ROUTE_ACTIONS', true), 'named' => env('WAYFINDER_GENERATE_NAMED_ROUTES', true), @@ -660,6 +820,8 @@ return [ | Option | Description | Default | | -------------------------------- | -------------------------------------- | ------------------------- | +| `generate.ignore.attributes` | Extra attributes that leave a declaration out | `[]` | +| `generate.ignore.tags` | Comment tags that leave an array key out | `['wayfinder-ignore']` | | `generate.route.actions` | Generate controller action files | `true` | | `generate.route.named` | Generate named route files | `true` | | `generate.route.form_variant` | Include `.form` method variants | `true` | diff --git a/composer.json b/composer.json index f75c27e..8d81bee 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "illuminate/filesystem": "^12.0|^13.0", "illuminate/routing": "^12.0|^13.0", "illuminate/support": "^12.0|^13.0", - "laravel/ranger": "^0.4.0", - "laravel/surveyor": "^0.2.7", + "laravel/ranger": "^0.5.0", + "laravel/surveyor": "^0.3.0", "phpstan/phpdoc-parser": "^2.3" }, "require-dev": { diff --git a/config/wayfinder.php b/config/wayfinder.php index edc89c4..93abbb3 100644 --- a/config/wayfinder.php +++ b/config/wayfinder.php @@ -2,6 +2,13 @@ return [ 'generate' => [ + // Leave declarations out of the generated files + 'ignore' => [ + // Attribute classes treated the same as Laravel\Wayfinder\Attributes\WayfinderIgnore + 'attributes' => [], + // Comment tags that leave out the array key they sit on + 'tags' => ['wayfinder-ignore'], + ], 'route' => [ 'actions' => env('WAYFINDER_GENERATE_ROUTE_ACTIONS', true), 'named' => env('WAYFINDER_GENERATE_NAMED_ROUTES', true), diff --git a/src/Attributes/WayfinderIgnore.php b/src/Attributes/WayfinderIgnore.php new file mode 100644 index 0000000..2cdee75 --- /dev/null +++ b/src/Attributes/WayfinderIgnore.php @@ -0,0 +1,44 @@ +unless; + } + + public function when(): string|array|null + { + return $this->when; + } +} diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php index 1fe422d..232b50a 100644 --- a/src/Console/GenerateCommand.php +++ b/src/Console/GenerateCommand.php @@ -10,6 +10,7 @@ use Laravel\Ranger\Support\Config as RangerConfig; use Laravel\Ranger\Support\Inventory; use Laravel\Surveyor\Analyzer\AnalyzedCache; +use Laravel\Surveyor\Support\Markers; use Laravel\Wayfinder\Converters\BroadcastChannels; use Laravel\Wayfinder\Converters\BroadcastEvents; use Laravel\Wayfinder\Converters\Enums; @@ -66,6 +67,8 @@ public function handle( AnalyzedCache::setCacheDirectory($cacheDirectory); RangerConfig::set('cache.directory', $cacheDirectory); + $this->registerIgnoreMarkers(); + $cacheEnabled = $this->config->get('wayfinder.cache.enabled'); if ($this->option('fresh') || ! $cacheEnabled) { @@ -153,6 +156,22 @@ public function handle( $this->writeFiles(); } + /** + * Tell the analyzer which attributes and comment tags mean "leave this + * out", and fold them into the cache key: they decide what ends up in the + * generated files, so a cached run must not answer for a different set. + */ + protected function registerIgnoreMarkers(): void + { + $attributes = $this->config->get('wayfinder.generate.ignore.attributes', []); + $tags = $this->config->get('wayfinder.generate.ignore.tags', ['wayfinder-ignore']); + + Markers::registerAttributes(...$attributes); + Markers::registerTags(...$tags); + + AnalyzedCache::setKey(hash('sha256', serialize([$attributes, $tags]))); + } + protected function getBasePaths(): array { if ($this->option('base-path')) { diff --git a/tests/Feature/ConditionalIgnoreTest.php b/tests/Feature/ConditionalIgnoreTest.php new file mode 100644 index 0000000..5d3d17c --- /dev/null +++ b/tests/Feature/ConditionalIgnoreTest.php @@ -0,0 +1,102 @@ +files = new Filesystem; + $this->rootPath = realpath(join_paths(__DIR__, '..', '..')); + $this->cachePath = join_paths(sys_get_temp_dir(), 'wayfinder-condition-cache-'.uniqid()); + + $envExample = join_paths($this->rootPath, 'workbench', '.env.example'); + $envFile = join_paths($this->rootPath, 'workbench', '.env'); + + if ($this->files->exists($envExample) && ! $this->files->exists($envFile)) { + $this->files->copy($envExample, $envFile); + } + } + + protected function tearDown(): void + { + foreach ([...$this->generatedPaths, $this->cachePath] as $path) { + $this->files->deleteDirectory($path); + } + + parent::tearDown(); + } + + public function test_a_condition_decides_per_build_even_when_the_analysis_is_cached(): void + { + $off = $this->generate(fake: false); + + $this->assertStringContainsString('export const Gitlab = "gitlab"', $off); + $this->assertStringNotContainsString('GitFake', $off); + + // Same cache directory, so the second run reads the analysis written by + // the first: the condition has to be answered after the cache, not + // baked into it. + $on = $this->generate(fake: true); + + $this->assertStringContainsString('export const GitFake = "gitfake"', $on); + + $this->assertStringNotContainsString('GitFake', $this->generate(fake: false)); + } + + public function test_a_when_condition_leaves_a_case_out_only_while_it_passes(): void + { + $hidden = $this->generate(hideRetired: true); + + $this->assertStringNotContainsString('GitRetired', $hidden); + + $shown = $this->generate(hideRetired: false); + + $this->assertStringContainsString('export const GitRetired = "gitretired"', $shown); + } + + private function generate(bool $fake = false, bool $hideRetired = true): string + { + $path = join_paths(sys_get_temp_dir(), 'wayfinder-condition-'.uniqid()); + $this->generatedPaths[] = $path; + + $process = new Process([ + join_paths($this->rootPath, 'vendor', 'bin', 'testbench'), + 'wayfinder:generate', + '--path='.$path, + '--app-path='.join_paths($this->rootPath, 'workbench', 'app'), + '--base-path='.join_paths($this->rootPath, 'workbench'), + ], $this->rootPath, [ + 'WAYFINDER_CACHE_ENABLED' => 'true', + 'WAYFINDER_CACHE_DIRECTORY' => $this->cachePath, + 'WORKBENCH_FAKE_SOURCE_PROVIDER' => $fake ? '1' : '', + 'WORKBENCH_HIDE_RETIRED_SOURCE_PROVIDER' => $hideRetired ? '1' : '0', + ]); + + $process->setTimeout(120); + $process->run(); + + $this->assertTrue( + $process->isSuccessful(), + 'wayfinder:generate failed: '.$process->getErrorOutput().$process->getOutput() + ); + + return $this->files->get(join_paths($path, 'App', 'Enums', 'SourceProvider.ts')); + } +} diff --git a/tests/Ignore.test.ts b/tests/Ignore.test.ts new file mode 100644 index 0000000..6580cb4 --- /dev/null +++ b/tests/Ignore.test.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { describe, expect, test } from "vitest"; + +describe("Ignore", () => { + const wayfinderPath = join(__dirname, "../workbench/resources/js/wayfinder"); + const types = () => + readFileSync(join(wayfinderPath, "types.d.ts"), "utf-8"); + + test("nothing is generated for a marked controller", () => { + expect(types()).not.toContain("IgnoredController"); + expect( + existsSync( + join(wayfinderPath, "App/Http/Controllers/IgnoredController.ts"), + ), + ).toBe(false); + }); + + test("a marked method is dropped and its siblings are kept", () => { + const controller = readFileSync( + join(wayfinderPath, "App/Http/Controllers/SecretsController.ts"), + "utf-8", + ); + + expect(controller).toContain("export const index"); + expect(controller).not.toContain("reveal"); + expect(types()).not.toContain("Inertia.Pages.Reveal"); + }); + + test("a marked page prop is dropped from the page type", () => { + const secrets = types() + .split("export type Secrets")[1] + ?.split("export type")[0]; + + expect(secrets).toBeDefined(); + expect(secrets).toContain("name: string"); + expect(secrets).toContain("email: string"); + expect(secrets).not.toContain("socialSecurityNumber"); + }); + + test("a marked prop nested in a page prop is dropped", () => { + const secrets = types() + .split("export type Secrets")[1] + ?.split("export type")[0]; + + expect(secrets).toContain("label: string"); + expect(secrets).not.toContain("routingNumber"); + }); + + test("a marked toArray leaves the action without a response shape", () => { + const controller = + types().split("export namespace SecretsController")[1] ?? ""; + const resource = controller + .split("export namespace Resource {")[1] + ?.split("export namespace")[0]; + + expect(resource).toBeDefined(); + expect(resource).toContain("export type Request"); + expect(resource).not.toContain("export type Response"); + expect(types()).not.toContain("socialSecurityNumber"); + }); + + test("nothing is generated for a marked model", () => { + expect(types()).not.toContain("AuditLog"); + }); + + test("a marked relation is dropped from the model type", () => { + const user = types() + .split("export type User")[1] + ?.split("export type")[0]; + + expect(user).toBeDefined(); + expect(user).not.toContain("auditEntries"); + }); + + test("a case is dropped when its unless condition fails", () => { + const sourceProvider = readFileSync( + join(wayfinderPath, "App/Enums/SourceProvider.ts"), + "utf-8", + ); + + expect(sourceProvider).toContain('export const Github = "github"'); + expect(sourceProvider).toContain("{ Github, Gitlab }"); + expect(sourceProvider).not.toContain("GitFake"); + expect(types()).toContain( + 'export type SourceProvider = "github" | "gitlab"', + ); + }); + + test("a case is dropped when its when condition passes", () => { + const sourceProvider = readFileSync( + join(wayfinderPath, "App/Enums/SourceProvider.ts"), + "utf-8", + ); + + expect(sourceProvider).not.toContain("GitRetired"); + expect(types()).not.toContain("gitretired"); + }); + + test("routes for marked actions are not registered", () => { + const routeFiles = readFileSync( + join(wayfinderPath, "routes/index.ts"), + "utf-8", + ); + + expect(routeFiles).not.toContain("ignored"); + }); +}); diff --git a/workbench/app/Enums/SourceProvider.php b/workbench/app/Enums/SourceProvider.php new file mode 100644 index 0000000..e596dee --- /dev/null +++ b/workbench/app/Enums/SourceProvider.php @@ -0,0 +1,18 @@ +json([ + 'internalOnly' => 'value', + ]); + } +} diff --git a/workbench/app/Http/Controllers/SecretsController.php b/workbench/app/Http/Controllers/SecretsController.php new file mode 100644 index 0000000..c7761b7 --- /dev/null +++ b/workbench/app/Http/Controllers/SecretsController.php @@ -0,0 +1,39 @@ + 'Taylor', + 'socialSecurityNumber' => '000-00-0000', // @wayfinder-ignore + 'account' => [ + 'label' => 'Primary', + // @wayfinder-ignore + 'routingNumber' => '000000000', + ], + 'email' => 'taylor@laravel.com', + ]); + } + + public function resource(): JsonResource + { + return new SecretResource(null); + } + + #[WayfinderIgnore] + public function reveal(): Response + { + return Inertia::render('Reveal', [ + 'internalOnly' => 'value', + ]); + } +} diff --git a/workbench/app/Http/Resources/SecretResource.php b/workbench/app/Http/Resources/SecretResource.php new file mode 100644 index 0000000..0c57163 --- /dev/null +++ b/workbench/app/Http/Resources/SecretResource.php @@ -0,0 +1,18 @@ + '000-00-0000', + ]; + } +} diff --git a/workbench/app/Models/AuditLog.php b/workbench/app/Models/AuditLog.php new file mode 100644 index 0000000..3e5cb7e --- /dev/null +++ b/workbench/app/Models/AuditLog.php @@ -0,0 +1,16 @@ +hasMany(Category::class); } + + /** + * @return HasMany + */ + #[WayfinderIgnore] + public function auditEntries(): HasMany + { + return $this->hasMany(AuditLog::class); + } } diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 7d38627..2db7f1a 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -22,6 +22,11 @@ public function register(): void ], ]); + Config::set([ + 'features.fake_source_provider' => env('WORKBENCH_FAKE_SOURCE_PROVIDER', false), + 'features.hide_retired_source_provider' => env('WORKBENCH_HIDE_RETIRED_SOURCE_PROVIDER', true), + ]); + URL::defaults([ 'defaultDomain' => 'tim.macdonald', ]); diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 93501cc..f792533 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\DomainController; use App\Http\Controllers\DuplicateInertiaController; use App\Http\Controllers\EloquentProductController; +use App\Http\Controllers\IgnoredController; use App\Http\Controllers\InertiaController; use App\Http\Controllers\InvokableController; use App\Http\Controllers\InvokablePlusController; @@ -23,6 +24,7 @@ use App\Http\Controllers\Prism\Prism\PrismController as NestedPrismController; use App\Http\Controllers\Prism\PrismController; use App\Http\Controllers\ResourceTestController; +use App\Http\Controllers\SecretsController; use App\Http\Controllers\TwoRoutesSameActionController; use App\Http\Controllers\UrlDefaultsController; use App\Http\Middleware\UrlDefaultsMiddleware; @@ -159,3 +161,8 @@ Route::get('items/{item}', [MixedRouteController::class, 'edit'])->name('items.edit'); Route::patch('items/{item}', [MixedRouteController::class, 'update'])->name('items.update'); }); + +Route::get('ignored-controller', [IgnoredController::class, 'index'])->name('ignored.index'); +Route::get('secrets', [SecretsController::class, 'index'])->name('secrets.index'); +Route::get('secrets/reveal', [SecretsController::class, 'reveal'])->name('secrets.reveal'); +Route::get('secrets/resource', [SecretsController::class, 'resource'])->name('secrets.resource');