diff --git a/src/Fieldtypes/TemplateFolder.php b/src/Fieldtypes/TemplateFolder.php index 217c426c93..d7002c8766 100644 --- a/src/Fieldtypes/TemplateFolder.php +++ b/src/Fieldtypes/TemplateFolder.php @@ -2,6 +2,8 @@ namespace Statamic\Fieldtypes; +use FilesystemIterator; +use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Statamic\Support\Str; @@ -18,20 +20,18 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return collect(config('view.paths')) - ->flatMap(function ($path) { - $directories = collect(); - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::SELF_FIRST); - - foreach ($iterator as $file) { - if ($file->isDir() && ! $iterator->isDot() && ! $iterator->isLink()) { - $directories->push(Str::replaceFirst($path.DIRECTORY_SEPARATOR, '', $file->getPathname())); - } - } - - return $directories->filter()->values(); - }) - ->map(fn ($folder) => ['id' => $folder, 'title' => $folder]) - ->values(); + return collect(config('view.paths'))->flatMap(function ($path) { + return collect(new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS), + fn ($file) => $file->isDir() && ! str_starts_with($file->getFilename(), '.') && ! in_array($file->getBaseName(), ['node_modules']) + ), + RecursiveIteratorIterator::SELF_FIRST + ))->map(fn ($file) => Str::of($file->getPathname()) + ->after($path.DIRECTORY_SEPARATOR) + ->replace('\\', '/') + ->toString() + ); + })->map(fn ($folder) => ['id' => $folder, 'title' => $folder])->sort()->values(); } } diff --git a/src/Http/Controllers/CP/API/TemplatesController.php b/src/Http/Controllers/CP/API/TemplatesController.php index fc42c5d732..7ed3fde0f3 100644 --- a/src/Http/Controllers/CP/API/TemplatesController.php +++ b/src/Http/Controllers/CP/API/TemplatesController.php @@ -2,6 +2,7 @@ namespace Statamic\Http\Controllers\CP\API; +use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Statamic\Http\Controllers\CP\CpController; @@ -11,25 +12,17 @@ class TemplatesController extends CpController { public function index() { - return collect(config('view.paths')) - ->flatMap(function ($path) { - $views = collect(); - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); - - foreach ($iterator as $file) { - if ($file->isFile()) { - $viewPath = Str::of($file->getPathname()) - ->after($path.DIRECTORY_SEPARATOR) - ->before('.') - ->replace('\\', '/') - ->toString(); - - $views->push($viewPath); - } - } - - return $views->filter()->sort()->values(); - }) - ->values(); + return collect(config('view.paths'))->flatMap(function ($path) { + return collect(new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS), + fn ($file) => ! str_starts_with($file->getFilename(), '.') && ! in_array($file->getBaseName(), ['node_modules']) + ) + ))->map(fn ($file) => Str::of($file->getPathname()) + ->after($path.DIRECTORY_SEPARATOR) + ->before('.') + ->replace('\\', '/') + )->sort()->values(); + }); } } diff --git a/tests/Fieldtypes/TemplateFolderTest.php b/tests/Fieldtypes/TemplateFolderTest.php new file mode 100644 index 0000000000..cb1ed2d26f --- /dev/null +++ b/tests/Fieldtypes/TemplateFolderTest.php @@ -0,0 +1,108 @@ +makeDirectory($this->dir = __DIR__.'/templates-test-tmp', force: true); + + $this->app['config']->set('view.paths', [$this->dir.'/views']); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->dir); + + parent::tearDown(); + } + + /** @test */ + public function it_returns_a_list_of_directories() + { + $this->createFiles(); + + $fieldtype = $this->fieldtype(); + + $items = $fieldtype->getIndexItems(request()); + + // A collection with identical id/title keys are returned but we're only really concerned about the content. + $actual = $items->map->id->all(); + + $this->assertEquals([ + 'empty', + 'empty-symlink', + 'empty-symlink/three', + 'one', + 'one/two', + 'symlink-dir', + 'symlink-dir/five', + 'symlink-dir/four', + ], $actual); + } + + private function createFiles() + { + $files = [ + // Regular files, these should all be shown. + 'alfa.html', + 'one/bravo.html', + 'one/two/charlie.html', + 'one/two/delta.html', + + // .git directories at any level should get filtered out + '.git/echo.html', + 'one/.git/foxtrot.html', + 'one/two/.git/golf.html', + + // node_modules at any level should get filtered out + 'node_modules/hotel.html', + 'one/node_modules/india.html', + 'one/two/node_modules/juliett.html', + + // dotfiles at any level should get filtered out + '.kilo.html', + 'one/.lima.html', + 'one/two/.mike.html', + ]; + + foreach ($files as $path) { + File::put($this->dir.'/views/'.$path, ''); + } + + // Empty directories should also be shown. + File::makeDirectory($this->dir.'/views/empty'); + + // Symlinked directories (even empties) should be shown. + File::makeDirectory($this->dir.'/empty-symlink-target'); + File::makeDirectory($this->dir.'/empty-symlink-target/three'); + File::put($this->dir.'/symlink-target-dir/tango.html', ''); + File::put($this->dir.'/symlink-target-dir/four/uniform.html', ''); + File::makeDirectory($this->dir.'/symlink-target-dir/five'); + symlink($this->dir.'/empty-symlink-target', $this->dir.'/views/empty-symlink'); + symlink($this->dir.'/symlink-target-dir', $this->dir.'/views/symlink-dir'); + + // Symlinked files should not. + File::put($this->dir.'/foo.html', ''); + symlink($this->dir.'/foo.html', $this->dir.'/views/victor.html'); + } + + private function fieldtype() + { + $field = new Field('test', array_merge([ + 'type' => 'template_folder', + ])); + + return (new TemplateFolder)->setField($field); + } +} diff --git a/tests/Fieldtypes/TemplatesTest.php b/tests/Fieldtypes/TemplatesTest.php index 5848c325f3..4c55f7d3db 100644 --- a/tests/Fieldtypes/TemplatesTest.php +++ b/tests/Fieldtypes/TemplatesTest.php @@ -2,6 +2,7 @@ namespace Tests\Fieldtypes; +use Statamic\Facades\File; use Statamic\Facades\User; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -10,26 +11,86 @@ class TemplatesTest extends TestCase { use PreventSavingStacheItemsToDisk; + private string $dir; + public function setUp(): void { parent::setUp(); - $this->app['config']->set('view.paths', [ - __DIR__.'/../__fixtures__/templates', - ]); + app('files')->makeDirectory($this->dir = __DIR__.'/templates-test-tmp', force: true); + + $this->app['config']->set('view.paths', [$this->dir.'/views']); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->dir); + + parent::tearDown(); } /** @test */ public function it_returns_a_list_of_templates() { + $files = [ + // Regular files, these should all be shown. + 'alfa.html', + 'one/bravo.html', + 'one/two/charlie.html', + 'one/two/delta.html', + + // .git directories at any level should get filtered out + '.git/echo.html', + 'one/.git/foxtrot.html', + 'one/two/.git/golf.html', + + // node_modules at any level should get filtered out + 'node_modules/hotel.html', + 'one/node_modules/india.html', + 'one/two/node_modules/juliett.html', + + // dot directories at any level should get filtered out + '.kilo/lima.html', + 'one/.mike/november.html', + 'one/two/.oscar/papa.html', + + // dotfiles at any level should get filtered out + '.quebec.html', + 'one/.rome.html', + 'one/two/.sierra.html', + ]; + + foreach ($files as $path) { + File::put($this->dir.'/views/'.$path, ''); + } + + // Empty directories should be ignored. + File::makeDirectory($this->dir.'/views/empty'); + + // Empty symlinked directories should be ignored. + File::makeDirectory($this->dir.'/empty-symlink-target'); + app('files')->link($this->dir.'/empty-symlink-target', $this->dir.'/views/empty-symlink'); + + // Files in symlinked directories should be shown. + File::put($this->dir.'/symlink-target-dir/tango.html', ''); + File::put($this->dir.'/symlink-target-dir/three/uniform.html', ''); + app('files')->link($this->dir.'/symlink-target-dir', $this->dir.'/views/symlink-dir'); + + // Symlinked files should be shown. + File::put($this->dir.'/foo.html', ''); + app('files')->link($this->dir.'/foo.html', $this->dir.'/views/victor.html'); + $this ->actingAs(User::make()->makeSuper()->save()) ->get(cp_route('api.templates.index')) ->assertJson([ - 'blog/index', - 'conditions-literals', - 'five_hundred_nested_ifs', - 'nested-conditionals', + 'alfa', + 'one/bravo', + 'one/two/charlie', + 'one/two/delta', + 'symlink-dir/tango', + 'symlink-dir/three/uniform', + 'victor', ]); } }