Skip to content

Commit 5686d44

Browse files
[9.x] Discover anonymous Blade components in other folders (#41637)
* Register anonymous component folders * Look up components in their namespaces * Update comment * Add tests * Apply style * Clarify little comment * Update facade docblock * formatting Co-authored-by: Taylor Otwell <[email protected]>
1 parent c195fd9 commit 5686d44

File tree

5 files changed

+164
-11
lines changed

5 files changed

+164
-11
lines changed

src/Illuminate/Support/Facades/Blade.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
* @method static void compile(string|null $path = null)
1818
* @method static void component(string $class, string|null $alias = null, string $prefix = '')
1919
* @method static void components(array $components, string $prefix = '')
20-
* @method static void componentNamespace(string $namespace, string $prefix)
20+
* @method static void anonymousComponentNamespace(string $directory, string $prefix)
21+
* @method static void componentNamespace(string $prefix, string $directory = null)
2122
* @method static void directive(string $name, callable $handler)
2223
* @method static void extend(callable $compiler)
2324
* @method static void if(string $name, callable $callback)

src/Illuminate/View/Compilers/BladeCompiler.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ class BladeCompiler extends Compiler implements CompilerInterface
122122
*/
123123
protected $rawBlocks = [];
124124

125+
/**
126+
* The array of anonymous component namespaces to autoload from.
127+
*
128+
* @var array
129+
*/
130+
protected $anonymousComponentNamespaces = [];
131+
125132
/**
126133
* The array of class component aliases and their class names.
127134
*
@@ -672,6 +679,23 @@ public function getClassComponentAliases()
672679
return $this->classComponentAliases;
673680
}
674681

682+
/**
683+
* Register an anonymous component namespace.
684+
*
685+
* @param string $directory
686+
* @param string|null $prefix
687+
* @return void
688+
*/
689+
public function anonymousComponentNamespace(string $directory, string $prefix = null)
690+
{
691+
$prefix ??= $directory;
692+
693+
$this->anonymousComponentNamespaces[$prefix] = Str::of($directory)
694+
->replace('/', '.')
695+
->trim('. ')
696+
->toString();
697+
}
698+
675699
/**
676700
* Register a class-based component namespace.
677701
*
@@ -684,6 +708,16 @@ public function componentNamespace($namespace, $prefix)
684708
$this->classComponentNamespaces[$prefix] = $namespace;
685709
}
686710

711+
/**
712+
* Get the registered anonymous component namespaces.
713+
*
714+
* @return array
715+
*/
716+
public function getAnonymousComponentNamespaces()
717+
{
718+
return $this->anonymousComponentNamespaces;
719+
}
720+
687721
/**
688722
* Get the registered class component namespaces.
689723
*

src/Illuminate/View/Compilers/ComponentTagCompiler.php

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,29 @@ public function componentClass(string $component)
271271
return $class;
272272
}
273273

274-
if ($viewFactory->exists($view = $this->guessViewName($component))) {
275-
return $view;
276-
}
277-
278-
if ($viewFactory->exists($view = $this->guessViewName($component).'.index')) {
279-
return $view;
274+
$guess = collect($this->blade->getAnonymousComponentNamespaces())
275+
->filter(function ($directory, $prefix) use ($component) {
276+
return Str::startsWith($component, $prefix.'::');
277+
})
278+
->prepend('components', $component)
279+
->reduce(function ($carry, $directory, $prefix) use ($component, $viewFactory) {
280+
if (! is_null($carry)) {
281+
return $carry;
282+
}
283+
284+
$componentName = Str::after($component, $prefix.'::');
285+
286+
if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory))) {
287+
return $view;
288+
}
289+
290+
if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory).'.index')) {
291+
return $view;
292+
}
293+
});
294+
295+
if (! is_null($guess)) {
296+
return $guess;
280297
}
281298

282299
throw new InvalidArgumentException(
@@ -341,11 +358,14 @@ public function formatClassName(string $component)
341358
* Guess the view name for the given component.
342359
*
343360
* @param string $name
361+
* @param string $prefix
344362
* @return string
345363
*/
346-
public function guessViewName($name)
364+
public function guessViewName($name, $prefix = 'components.')
347365
{
348-
$prefix = 'components.';
366+
if (! Str::endsWith($prefix, '.')) {
367+
$prefix .= '.';
368+
}
349369

350370
$delimiter = ViewFinderInterface::HINT_PATH_DELIMITER;
351371

tests/View/Blade/BladeComponentTagCompilerTest.php

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,74 @@ public function testPackagesClasslessComponents()
362362
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
363363
}
364364

365+
public function testClasslessComponentsWithAnonymousComponentNamespace()
366+
{
367+
$container = new Container;
368+
369+
$container->instance(Application::class, $app = m::mock(Application::class));
370+
$container->instance(Factory::class, $factory = m::mock(Factory::class));
371+
372+
$app->shouldReceive('getNamespace')->andReturn('App\\');
373+
$factory->shouldReceive('exists')->andReturnUsing(function ($arg) {
374+
// In our test, we'll do as if the 'public.frontend.anonymous-component'
375+
// view exists and not the others.
376+
return $arg === 'public.frontend.anonymous-component';
377+
});
378+
379+
Container::setInstance($container);
380+
381+
$blade = m::mock(BladeCompiler::class)->makePartial();
382+
383+
$blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([
384+
'frontend' => 'public.frontend',
385+
]);
386+
387+
$compiler = $this->compiler([], [], $blade);
388+
389+
$result = $compiler->compileTags('<x-frontend::anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />');
390+
391+
$this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'frontend::anonymous-component', ['view' => 'public.frontend.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']])
392+
<?php if (isset(\$attributes) && \$constructor = (new ReflectionClass(Illuminate\View\AnonymousComponent::class))->getConstructor()): ?>
393+
<?php \$attributes = \$attributes->except(collect(\$constructor->getParameters())->map->getName()->all()); ?>
394+
<?php endif; ?>
395+
<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n".
396+
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
397+
}
398+
399+
public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexView()
400+
{
401+
$container = new Container;
402+
403+
$container->instance(Application::class, $app = m::mock(Application::class));
404+
$container->instance(Factory::class, $factory = m::mock(Factory::class));
405+
406+
$app->shouldReceive('getNamespace')->andReturn('App\\');
407+
$factory->shouldReceive('exists')->andReturnUsing(function (string $viewNameBeingCheckedForExistence) {
408+
// In our test, we'll do as if the 'public.frontend.anonymous-component'
409+
// view exists and not the others.
410+
return $viewNameBeingCheckedForExistence === 'admin.auth.components.anonymous-component.index';
411+
});
412+
413+
Container::setInstance($container);
414+
415+
$blade = m::mock(BladeCompiler::class)->makePartial();
416+
417+
$blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([
418+
'admin.auth' => 'admin.auth.components',
419+
]);
420+
421+
$compiler = $this->compiler([], [], $blade);
422+
423+
$result = $compiler->compileTags('<x-admin.auth::anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />');
424+
425+
$this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'admin.auth::anonymous-component', ['view' => 'admin.auth.components.anonymous-component.index','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']])
426+
<?php if (isset(\$attributes) && \$constructor = (new ReflectionClass(Illuminate\View\AnonymousComponent::class))->getConstructor()): ?>
427+
<?php \$attributes = \$attributes->except(collect(\$constructor->getParameters())->map->getName()->all()); ?>
428+
<?php endif; ?>
429+
<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n".
430+
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
431+
}
432+
365433
public function testAttributeSanitization()
366434
{
367435
$class = new class
@@ -451,10 +519,10 @@ protected function mockViewFactory($existsSucceeds = true)
451519
Container::setInstance($container);
452520
}
453521

454-
protected function compiler($aliases = [])
522+
protected function compiler(array $aliases = [], array $namespaces = [], ?BladeCompiler $blade = null)
455523
{
456524
return new ComponentTagCompiler(
457-
$aliases
525+
$aliases, $namespaces, $blade
458526
);
459527
}
460528
}

tests/View/ViewBladeCompilerTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,36 @@ public function testComponentAliasesCanBeConventionallyDetermined()
233233
$this->assertEquals(['prefix-forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases());
234234
}
235235

236+
public function testAnonymousComponentNamespacesCanBeStored()
237+
{
238+
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
239+
240+
$compiler->anonymousComponentNamespace(' public/frontend ', 'frontend');
241+
$this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces());
242+
243+
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
244+
245+
$compiler->anonymousComponentNamespace('public/frontend/', 'frontend');
246+
$this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces());
247+
248+
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
249+
250+
$compiler->anonymousComponentNamespace('/admin/components', 'admin');
251+
$this->assertEquals(['admin' => 'admin.components'], $compiler->getAnonymousComponentNamespaces());
252+
253+
// Test directory is automatically inferred from the prefix if not given.
254+
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
255+
256+
$compiler->anonymousComponentNamespace('frontend');
257+
$this->assertEquals(['frontend' => 'frontend'], $compiler->getAnonymousComponentNamespaces());
258+
259+
// Test that the prefix can also contain dots.
260+
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
261+
262+
$compiler->anonymousComponentNamespace('frontend/auth', 'frontend.auth');
263+
$this->assertEquals(['frontend.auth' => 'frontend.auth'], $compiler->getAnonymousComponentNamespaces());
264+
}
265+
236266
protected function getFiles()
237267
{
238268
return m::mock(Filesystem::class);

0 commit comments

Comments
 (0)