diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php index b4c985c413..c5d9c22de7 100644 --- a/resources/lang/en/messages.php +++ b/resources/lang/en/messages.php @@ -219,6 +219,7 @@ 'taxonomy_configure_term_template_instructions' => 'Set this taxonomy\'s default template. Terms can override this setting with a `template` field.', 'taxonomies_preview_targets_instructions' => 'The URLs to be viewable within Live Preview. Learn more in the [documentation](https://statamic.dev/live-preview#preview-targets).', 'taxonomies_preview_target_refresh_instructions' => 'Automatically refresh the preview while editing. Disabling this will use postMessage.', + 'taxonomies_route_instructions' => 'The route controls show and index URL patterns. Learn more in the [documentation](https://statamic.dev/taxonomies#routing).', 'taxonomy_configure_handle_instructions' => 'Used to reference this taxonomy on the frontend. It\'s non-trivial to change later.', 'taxonomy_configure_intro' => 'A taxonomy is a system of classifying data around a set of unique characteristics, such as categories, tags, or colors.', 'taxonomy_configure_title_instructions' => 'We recommend using a plural noun, like "Categories" or "Tags".', diff --git a/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php b/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php index 15b352d4a2..2a53a7297a 100644 --- a/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php +++ b/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php @@ -136,6 +136,9 @@ public function edit($taxonomy) 'collections' => $taxonomy->collections()->map->handle()->all(), 'sites' => $taxonomy->sites()->all(), 'preview_targets' => $taxonomy->basePreviewTargets(), + 'routes' => $taxonomy->routes()->unique()->count() === 1 + ? $taxonomy->routes()->first() + : $taxonomy->routes()->all(), 'term_template' => $taxonomy->hasCustomTermTemplate() ? $taxonomy->termTemplate() : null, 'template' => $taxonomy->hasCustomTemplate() ? $taxonomy->template() : null, 'layout' => $taxonomy->layout(), @@ -169,6 +172,7 @@ public function update(Request $request, $taxonomy) $taxonomy ->title($values['title']) ->previewTargets($values['preview_targets']) + ->routes($values['routes']) ->termTemplate($values['term_template'] ?? null) ->template($values['template'] ?? null) ->layout($values['layout'] ?? null); @@ -285,6 +289,11 @@ protected function editFormBlueprint($taxonomy) 'routing' => [ 'display' => __('Routing & URLs'), 'fields' => [ + 'routes' => [ + 'display' => __('Route'), + 'instructions' => __('statamic::messages.taxonomies_route_instructions'), + 'type' => 'collection_routes', + ], 'preview_targets' => [ 'display' => __('Preview Targets'), 'instructions' => __('statamic::messages.taxonomies_preview_targets_instructions'), diff --git a/src/Stache/Repositories/TaxonomyRepository.php b/src/Stache/Repositories/TaxonomyRepository.php index 50bf566696..a5fde0c417 100644 --- a/src/Stache/Repositories/TaxonomyRepository.php +++ b/src/Stache/Repositories/TaxonomyRepository.php @@ -12,11 +12,13 @@ class TaxonomyRepository implements RepositoryContract { + protected $stache; protected $store; protected $additionalPreviewTargets = []; public function __construct(Stache $stache) { + $this->stache = $stache; $this->store = $stache->store('taxonomies'); } @@ -90,7 +92,7 @@ public function findByUri(string $uri, ?string $site = null): ?Taxonomy // the slash trimmed off at this point. We'll make sure it's there. $uri = Str::ensureLeft($uri, '/'); - if (! $key = $this->findTaxonomyHandleByUri($uri)) { + if (! $key = $this->findTaxonomyHandleByUri($uri, $site)) { return null; } @@ -104,9 +106,17 @@ public static function bindings(): array ]; } - private function findTaxonomyHandleByUri($uri) + private function findTaxonomyHandleByUri($uri, $site) { - return $this->store->index('uri')->items()->flip()->get($uri); + $site = $site ?? $this->stache->sites()->first(); + + $routes = $this->store->index('routes')->items()->map(fn ($item) => $item->get($site))->filter()->flip(); + + if ($handle = $routes->get($uri)) { + return $handle; + } + + return $routes->get(Str::removeLeft($uri, '/')); } public function addPreviewTargets($handle, $targets) diff --git a/src/Stache/Repositories/TermRepository.php b/src/Stache/Repositories/TermRepository.php index 2c0ec7e227..0b8b42500d 100644 --- a/src/Stache/Repositories/TermRepository.php +++ b/src/Stache/Repositories/TermRepository.php @@ -78,13 +78,16 @@ public function findByUri(string $uri, ?string $site = null): ?Term $uri = Str::removeLeft($uri, '/'); - [$taxonomy, $slug] = array_pad(explode('/', $uri), 2, null); + $uriParts = array_pad(explode('/', $uri), 2, null); + + $slug = array_pop($uriParts); + $taxonomy = implode('/', $uriParts); if (! $slug) { return null; } - if (! $taxonomy = $this->findTaxonomyHandleByUri($taxonomy)) { + if (! $taxonomy = $this->findTaxonomyHandleByUri($taxonomy, $site)) { return null; } @@ -170,9 +173,15 @@ public static function bindings(): array ]; } - private function findTaxonomyHandleByUri($uri) + private function findTaxonomyHandleByUri($uri, $site) { - return $this->stache->store('taxonomies')->index('uri')->items()->flip()->get(Str::ensureLeft($uri, '/')); + $routes = $this->stache->store('taxonomies')->index('routes')->items()->map(fn ($item) => $item->get($site))->filter()->flip(); + + if ($handle = $routes->get($uri)) { + return $handle; + } + + return $routes->get(Str::ensureLeft($uri, '/')); } public function substitute($item) diff --git a/src/Stache/Stores/TaxonomiesStore.php b/src/Stache/Stores/TaxonomiesStore.php index 4978d9993b..d0ee45cdd1 100644 --- a/src/Stache/Stores/TaxonomiesStore.php +++ b/src/Stache/Stores/TaxonomiesStore.php @@ -45,6 +45,7 @@ public function makeItemFromFile($path, $contents) ->cascade(Arr::get($data, 'inject', [])) ->revisionsEnabled(Arr::get($data, 'revisions', false)) ->searchIndex(Arr::get($data, 'search_index')) + ->routes(Arr::get($data, 'route')) ->defaultPublishState($this->getDefaultPublishState($data)) ->sites($sites) ->previewTargets($this->normalizePreviewTargets(Arr::get($data, 'preview_targets', []))) diff --git a/src/Taxonomies/LocalizedTerm.php b/src/Taxonomies/LocalizedTerm.php index 15808c4083..fcd7e75a68 100644 --- a/src/Taxonomies/LocalizedTerm.php +++ b/src/Taxonomies/LocalizedTerm.php @@ -344,7 +344,8 @@ public function apiUrl() public function route() { - $route = '/'.str_replace('_', '-', $this->taxonomyHandle()).'/{slug}'; + $route = '/'.$this->taxonomy()->routes()->get($this->locale()).'/'; + $route .= Str::contains($route, '{{') ? '{{ slug }}' : '{slug}'; if ($this->collection()) { $collectionUrl = $this->collection()->uri($this->locale()) ?? $this->collection()->handle(); diff --git a/src/Taxonomies/Taxonomy.php b/src/Taxonomies/Taxonomy.php index a118c0f45d..25e4f78ae3 100644 --- a/src/Taxonomies/Taxonomy.php +++ b/src/Taxonomies/Taxonomy.php @@ -38,6 +38,7 @@ class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, Contract, protected $handle; protected $title; protected $blueprints = []; + protected $routes = []; protected $sites = []; protected $collection; protected $defaultPublishState = true; @@ -274,6 +275,7 @@ public function fileData() 'title' => $this->title, 'blueprints' => $this->blueprints, 'preview_targets' => $this->previewTargetsForFile(), + 'route' => $this->routes, 'template' => $this->template, 'term_template' => $this->termTemplate, 'layout' => $this->layout, @@ -344,7 +346,7 @@ public function uri() $prefix = $this->collection() ? $this->collection()->uri($site->handle()) : '/'; - return URL::tidy($prefix.str_replace('_', '-', '/'.$this->handle)); + return URL::tidy($prefix.$this->routes()->get($site->handle())); } public function collection($collection = null) @@ -362,6 +364,25 @@ public function collections() })->values(); } + public function routes($routes = null) + { + return $this + ->fluentlyGetOrSet('routes') + ->getter(function ($routes) { + return $this->sites()->mapWithKeys(function ($site) use ($routes) { + $siteRoute = is_string($routes) ? $routes : ($routes[$site] ?? str_replace('_', '-', '/'.$this->handle)); + + return [$site => $siteRoute]; + }); + }) + ->args(func_get_args()); + } + + public function route($site) + { + return $this->routes()->get($site); + } + public function toResponse($request) { if (! view()->exists($this->template())) { diff --git a/tests/Data/Taxonomies/TaxonomyTest.php b/tests/Data/Taxonomies/TaxonomyTest.php index 7c76a3c91c..32285fca56 100644 --- a/tests/Data/Taxonomies/TaxonomyTest.php +++ b/tests/Data/Taxonomies/TaxonomyTest.php @@ -390,6 +390,72 @@ public function if_saving_event_returns_false_the_taxonomy_doesnt_save() Event::assertNotDispatched(TaxonomySaved::class); } + #[Test] + public function it_gets_and_sets_the_routes() + { + $this->setSites([ + 'en' => ['url' => 'http://domain.com/'], + 'fr' => ['url' => 'http://domain.com/fr/'], + 'de' => ['url' => 'http://domain.com/de/'], + ]); + + // A taxonomy with no sites uses the default site. + $taxonomy = new Taxonomy; + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $taxonomy->routes()); + $this->assertEquals(['en' => '/'], $taxonomy->routes()->all()); + + $return = $taxonomy->routes([ + 'en' => 'blog/', + 'fr' => 'le-blog/', + 'de' => 'das-blog/', + ]); + + $this->assertEquals($taxonomy, $return); + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $taxonomy->routes()); + + // Only routes corresponding to the collection's sites will be returned. + $this->assertEquals(['en' => 'blog/'], $taxonomy->routes()->all()); + $this->assertEquals('blog/', $taxonomy->route('en')); + $this->assertNull($taxonomy->route('fr')); + $this->assertNull($taxonomy->route('de')); + $this->assertNull($taxonomy->route('unknown')); + + $taxonomy->sites(['en', 'fr']); + + $this->assertEquals([ + 'en' => 'blog/', + 'fr' => 'le-blog/', + ], $taxonomy->routes()->all()); + $this->assertEquals('blog/', $taxonomy->route('en')); + $this->assertEquals('le-blog/', $taxonomy->route('fr')); + $this->assertNull($taxonomy->route('de')); + $this->assertNull($taxonomy->route('unknown')); + } + + #[Test] + public function it_sets_all_the_routes_identically() + { + $this->setSites([ + 'en' => ['url' => 'http://domain.com/'], + 'fr' => ['url' => 'http://domain.com/fr/'], + 'de' => ['url' => 'http://domain.com/de/'], + ]); + + $taxonomy = (new Taxonomy)->sites(['en', 'fr']); + + $return = $taxonomy->routes('{slug}'); + + $this->assertEquals($taxonomy, $return); + $this->assertEquals([ + 'en' => '{slug}', + 'fr' => '{slug}', + ], $taxonomy->routes()->all()); + $this->assertEquals('{slug}', $taxonomy->route('en')); + $this->assertEquals('{slug}', $taxonomy->route('fr')); + $this->assertNull($taxonomy->route('de')); + $this->assertNull($taxonomy->route('unknown')); + } + #[Test] public function it_gets_and_sets_the_layout() { diff --git a/tests/Data/Taxonomies/TermTest.php b/tests/Data/Taxonomies/TermTest.php index b2352c3bb7..2541defbf2 100644 --- a/tests/Data/Taxonomies/TermTest.php +++ b/tests/Data/Taxonomies/TermTest.php @@ -317,6 +317,40 @@ public function it_gets_preview_targets() ], $termDe->previewTargets()->all()); } + #[Test] + public function it_gets_routes() + { + $this->setSites([ + 'en' => ['url' => 'http://domain.com/'], + 'fr' => ['url' => 'http://domain.com/fr/'], + 'de' => ['url' => 'http://domain.de/'], + ]); + + $taxonomy = tap(Taxonomy::make('tags')->sites(['en', 'fr', 'de'])->routes('tags'))->save(); + + $term = (new Term)->taxonomy('tags'); + + $termEn = $term->in('en')->slug('foo'); + $termFr = $term->in('fr')->slug('le-foo'); + $termDe = $term->in('de')->slug('das-foo'); + + $this->assertEquals('/tags/{slug}', $termEn->route()); + $this->assertEquals('/tags/{slug}', $termFr->route()); + $this->assertEquals('/tags/{slug}', $termDe->route()); + + $taxonomy->routes([ + 'en' => 'blog', + 'fr' => 'le-blog', + 'de' => 'das-blog', + ]); + + $taxonomy->save(); + + $this->assertEquals('/blog/{slug}', $termEn->route()); + $this->assertEquals('/le-blog/{slug}', $termFr->route()); + $this->assertEquals('/das-blog/{slug}', $termDe->route()); + } + #[Test] public function it_has_a_dirty_state() {