From 113f42196747d5f391f89abf57a07644f6f587c7 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 13:06:34 +0100 Subject: [PATCH 1/9] Add public plugin namespace pages --- config/routes.php | 8 + src/Application.php | 2 +- src/Controller/PackagesController.php | 26 ++- src/Island/CatalogueIsland.php | 18 +- src/Model/Table/PackagesTable.php | 56 +++++- templates/Packages/by_namespace.php | 45 +++++ .../element/Packages/category_browser.php | 3 +- templates/element/Packages/package-row.php | 11 ++ templates/element/Packages/plugin-details.php | 10 +- templates/island/catalogue.php | 27 +-- tests/TestCase/ApplicationTest.php | 2 + .../Controller/PackagesControllerTest.php | 34 ++++ tests/TestCase/Island/CatalogueIslandTest.php | 47 +++++ .../Model/Table/PackagesTableTest.php | 49 +++++ tests/browser/namespaces.spec.js | 169 ++++++++++++++++++ 15 files changed, 486 insertions(+), 21 deletions(-) create mode 100644 templates/Packages/by_namespace.php create mode 100644 tests/browser/namespaces.spec.js diff --git a/config/routes.php b/config/routes.php index 996e4414..e88d10d8 100644 --- a/config/routes.php +++ b/config/routes.php @@ -59,6 +59,14 @@ * to use (in this case, templates/Pages/home.php)... */ $builder->connect('/', ['controller' => 'Packages', 'action' => 'index']); + $builder->get('/plugins/by/{namespace}', [ + 'controller' => 'Packages', + 'action' => 'byNamespace', + ], 'plugin:namespace') + ->setPatterns([ + 'namespace' => '[A-Za-z0-9][A-Za-z0-9_.-]*', + ]) + ->setPass(['namespace']); $builder->get('/plugins/{id}', ['controller' => 'Packages', 'action' => 'view'], 'plugin:view') ->setPatterns(['id' => '[0-9]+'])->setPass(['id']); $builder->connect('/requirements', ['controller' => 'Pages', 'action' => 'display', 'requirements']); diff --git a/src/Application.php b/src/Application.php index 103a7f8a..c9bd51e9 100644 --- a/src/Application.php +++ b/src/Application.php @@ -203,7 +203,7 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue } return !in_array($request->getMethod(), ['GET', 'HEAD'], true) - || !($path === '' || $path === '/packages' || preg_match('#^/plugins/[0-9]+$#', $path)); + || !($path === '' || $path === '/packages' || preg_match('#^/plugins/(?:[0-9]+|by/[A-Za-z0-9][A-Za-z0-9_.-]*)$#', $path)); }, ]), ); diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index aa0cd245..1d5f4fb4 100644 --- a/src/Controller/PackagesController.php +++ b/src/Controller/PackagesController.php @@ -4,6 +4,8 @@ namespace App\Controller; use App\Model\Table\PackagesTable; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Response; /** * Hosts the public catalogue; islands own subsequent interactions. @@ -16,7 +18,7 @@ class PackagesController extends AppController public function initialize(): void { parent::initialize(); - $this->Authentication->allowUnauthenticated(['index', 'view']); + $this->Authentication->allowUnauthenticated(['index', 'view', 'byNamespace']); } /** @@ -26,6 +28,28 @@ public function index(): void { } + /** + * Host a scoped catalogue with totals that do not change while filtering. + */ + public function byNamespace(string $namespace): ?Response + { + $canonicalNamespace = strtolower($namespace); + $stats = $this->fetchTable(PackagesTable::class)->namespaceStats($canonicalNamespace); + if ($stats['plugins'] === 0) { + throw new NotFoundException('Namespace not found.'); + } + if ($namespace !== $canonicalNamespace) { + return $this->redirect([ + '_name' => 'plugin:namespace', + 'namespace' => $canonicalNamespace, + '?' => $this->getRequest()->getQueryParams(), + ], 301); + } + $this->set(compact('namespace', 'stats')); + + return null; + } + /** * Provide a normal detail URL for direct links and browsers without JavaScript. */ diff --git a/src/Island/CatalogueIsland.php b/src/Island/CatalogueIsland.php index 77910955..6ccb8206 100644 --- a/src/Island/CatalogueIsland.php +++ b/src/Island/CatalogueIsland.php @@ -7,6 +7,7 @@ use Cake\Http\Exception\BadRequestException; use Interact\Attribute\Action; use Interact\Attribute\Pagination; +use Interact\Attribute\Prop; use Interact\Attribute\State; use Interact\Island; use Interact\Pagination\PaginationState; @@ -18,6 +19,9 @@ */ class CatalogueIsland extends Island { + #[Prop] + public ?string $namespace = null; + #[State(writable: true, url: true)] public string $search = ''; @@ -148,7 +152,7 @@ public function viewData(): array throw new BadRequestException('Search query is too long.'); } $options = $this->packages->filterOptions(); - $categoryOptions = $this->packages->categoryOptions(); + $categoryOptions = $this->packages->categoryOptions($this->namespace); if (!array_key_exists($this->category, $categoryOptions)) { $this->category = ''; } @@ -164,8 +168,8 @@ public function viewData(): array 'direction' => $this->paging->direction, ]; $hasFilters = trim($this->search) !== '' || $this->cakephpSlugs !== [] || $this->phpSlugs !== [] || $this->category !== '' || $this->awesomeOnly; - $showFeatured = !$hasFilters && $this->paging->page === 1; - $packages = $this->paginate($this->packages->find('catalogue', filters: $query)); + $showFeatured = $this->namespace === null && !$hasFilters && $this->paging->page === 1; + $packages = $this->paginate($this->packages->find('catalogue', filters: $query, namespace: $this->namespace)); return [ 'packages' => $packages, @@ -175,6 +179,14 @@ public function viewData(): array 'phpTags' => $options['php'], 'catalogueQuery' => $query, 'categoryOptions' => $categoryOptions, + 'catalogueRoute' => $this->namespace !== null ? [ + '_name' => 'plugin:namespace', + 'namespace' => $this->namespace, + ] : [ + 'plugin' => null, + 'controller' => 'Packages', + 'action' => 'index', + ], ]; } } diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php index 77924cdc..1f1399ef 100644 --- a/src/Model/Table/PackagesTable.php +++ b/src/Model/Table/PackagesTable.php @@ -112,8 +112,11 @@ public function validationDefault(Validator $validator): Validator * @param array $filters Accepted search and version filters. * @return \Cake\ORM\Query\SelectQuery<\App\Model\Entity\Package> */ - public function findCatalogue(SelectQuery $query, array $filters): SelectQuery + public function findCatalogue(SelectQuery $query, array $filters, ?string $namespace = null): SelectQuery { + if ($namespace !== null) { + $query->find('namespace', namespace: $namespace); + } $query->find('search', search: $filters) ->contain(['Categories', 'Tags' => static fn(SelectQuery $tags) => $tags->orderByDesc('Tags.label')]); if (!empty($filters['category']) || !empty($filters['awesome'])) { @@ -131,6 +134,48 @@ public function findCatalogue(SelectQuery $query, array $filters): SelectQuery return $query; } + /** + * Match a literal Composer vendor prefix, including namespaces containing underscores. + * + * @param \Cake\ORM\Query\SelectQuery<\App\Model\Entity\Package> $query Package query. + * @return \Cake\ORM\Query\SelectQuery<\App\Model\Entity\Package> + */ + public function findNamespace(SelectQuery $query, string $namespace): SelectQuery + { + $prefix = strtr($namespace, [ + '!' => '!!', + '%' => '!%', + '_' => '!_', + ]) . '/%'; + + // Expression-owned bindings survive the ORM's eager-loading subqueries. + return $query->where(new QueryExpression([ + $query->expr()->like($this->aliasField('package'), $prefix), + "ESCAPE '!'", + ], [], '')); + } + + /** + * Hero totals cover the namespace before any interactive filters are applied. + * + * @return array{plugins: int, downloads: int, stars: int} + */ + public function namespaceStats(string $namespace): array + { + $query = $this->find('namespace', namespace: $namespace); + $totals = $query->select([ + 'plugins' => $query->func()->count('*'), + 'downloads' => $query->func()->sum('Packages.downloads'), + 'stars' => $query->func()->sum('Packages.stars'), + ])->first(); + + return [ + 'plugins' => (int)($totals?->get('plugins') ?? 0), + 'downloads' => (int)($totals?->get('downloads') ?? 0), + 'stars' => (int)($totals?->get('stars') ?? 0), + ]; + } + /** * Load configured featured packages without changing their display order. * @@ -158,15 +203,20 @@ public function findFeaturedCandidates(SelectQuery $query, string $search = ''): } /** - * @return array Imported categories and their directory-wide package counts. + * @return array Categories and counts within the current catalogue scope. */ - public function categoryOptions(): array + public function categoryOptions(?string $namespace = null): array { $query = $this->Categories->find(); $query->select(['Categories.slug', 'Categories.name', 'package_count' => $query->func()->count('CategoryMemberships.package_id')]) ->leftJoin(['CategoryMemberships' => 'categories_packages'], ['CategoryMemberships.category_id = Categories.id']) ->groupBy(['Categories.id', 'Categories.slug', 'Categories.name']) ->orderByAsc('Categories.name'); + if ($namespace !== null) { + $query->where([ + 'CategoryMemberships.package_id IN' => $this->find('namespace', namespace: $namespace)->select(['id']), + ]); + } $options = []; foreach ($query->all() as $category) { $options[$category->get('slug')] = ['name' => $category->get('name'), 'count' => (int)$category->get('package_count')]; diff --git a/templates/Packages/by_namespace.php b/templates/Packages/by_namespace.php new file mode 100644 index 00000000..a5d21b72 --- /dev/null +++ b/templates/Packages/by_namespace.php @@ -0,0 +1,45 @@ +assign('title', __('Plugins by {0}', $namespace)); +$statLabels = [ + 'plugins' => __('Plugins'), + 'downloads' => __('Downloads'), + 'stars' => __('Stars'), +]; +?> +
+ Html->link('← ' . __('Browse all plugins'), '/', [ + 'class' => 'mb-5 inline-flex min-h-8 items-center text-sm text-muted', + 'x-interact:navigate' => '', + ]) ?> +
+
+
+ +
+

+

+

+
+
+
+ $label) : ?> +
+
+
Number->format($stats[$key]) ?>
+
+ +
+
+
+
+Interact->render('Catalogue', props: compact('namespace'), key: 'namespace-catalogue-' . $namespace) ?> +element('Packages/plugin-modal') ?> diff --git a/templates/element/Packages/category_browser.php b/templates/element/Packages/category_browser.php index 0cd279b8..d0fa5170 100644 --- a/templates/element/Packages/category_browser.php +++ b/templates/element/Packages/category_browser.php @@ -7,6 +7,7 @@ * @var string $category * @var array $catalogueQuery * @var string $clearCategoryUrl + * @var array $catalogueRoute */ $allCategoriesSelected = $category === ''; ?> @@ -46,7 +47,7 @@ $categoryQuery = array_replace($catalogueQuery, ['category' => $slug]); $categorySelected = $slug === $category; ?> - > diff --git a/templates/element/Packages/package-row.php b/templates/element/Packages/package-row.php index 2704ffcc..9736bd73 100644 --- a/templates/element/Packages/package-row.php +++ b/templates/element/Packages/package-row.php @@ -2,6 +2,7 @@ /** * @var \App\View\IslandTemplateView $this * @var \App\Model\Entity\Package $package + * @var string|null $namespace */ $summaries = []; foreach (['CakePHP' => $package->cake_php_tag_groups, 'PHP' => $package->php_tag_groups] as $label => $groups) { @@ -11,6 +12,7 @@ } } $detailsUrl = $this->Url->build(['_name' => 'plugin:view', 'id' => $package->id]); +$packageNamespace = explode('/', $package->package, 2)[0]; ?>
@@ -20,6 +22,15 @@

description ?: __('No description available.')) ?>

+ + Html->link(__('More by {0}', $packageNamespace), [ + '_name' => 'plugin:namespace', + 'namespace' => $packageNamespace, + ], [ + 'class' => 'mt-1 inline-flex min-h-6 items-center text-xs text-muted hover:text-primary', + 'x-interact:navigate' => '', + ]) ?> + categories) : ?>
element('Packages/awesome_badge', compact('package')) ?> diff --git a/templates/element/Packages/plugin-details.php b/templates/element/Packages/plugin-details.php index c319f0d3..f643054f 100644 --- a/templates/element/Packages/plugin-details.php +++ b/templates/element/Packages/plugin-details.php @@ -14,7 +14,15 @@ ?>
-

/

+

+ Html->link($vendor, [ + '_name' => 'plugin:namespace', + 'namespace' => $vendor, + ], [ + 'aria-label' => __('View all plugins by {0}', $vendor), + 'x-interact:navigate' => '', + ]) ?> / +

element('Packages/awesome_badge', compact('package')) ?>

description ?: __('No description available.')) ?>

diff --git a/templates/island/catalogue.php b/templates/island/catalogue.php index f712dea7..a4c468e9 100644 --- a/templates/island/catalogue.php +++ b/templates/island/catalogue.php @@ -13,6 +13,8 @@ * @var array $categoryOptions * @var string $category * @var bool $awesomeOnly + * @var string|null $namespace + * @var array $catalogueRoute */ $sortLabels = [ 'latest_stable_release_date' => __('Latest release'), @@ -20,24 +22,27 @@ 'stars' => __('Stars'), ]; $sortLabel = $sortLabels[$catalogueQuery['sort']] ?? __('Latest release'); -$clearUrl = $this->Url->build([ - 'controller' => 'Packages', 'action' => 'index', 'plugin' => null, - '?' => ['sort' => $catalogueQuery['sort'], 'direction' => $catalogueQuery['direction']], +$clearUrl = $this->Url->build($catalogueRoute + [ + '?' => [ + 'sort' => $catalogueQuery['sort'], + 'direction' => $catalogueQuery['direction'], + ], ]); -$clearCategoryUrl = $this->Url->build([ - 'controller' => 'Packages', 'action' => 'index', 'plugin' => null, +$clearCategoryUrl = $this->Url->build($catalogueRoute + [ '?' => array_replace($catalogueQuery, ['category' => '']), ]); ?> diff --git a/tests/TestCase/ApplicationTest.php b/tests/TestCase/ApplicationTest.php index 6b216614..590ccb3a 100644 --- a/tests/TestCase/ApplicationTest.php +++ b/tests/TestCase/ApplicationTest.php @@ -131,6 +131,8 @@ public function testRateLimitProfiles(): void ['/', 'GET', [], 90], ['/', 'GET', ['search' => 'users'], 15], ['/plugins/1', 'GET', [], 90], + ['/plugins/by/dereuromark', 'GET', [], 90], + ['/plugins/by/dereuromark', 'GET', ['search' => 'tools'], 15], ['/_interact/update', 'POST', [], 30], ]; foreach ($requests as [$path, $method, $query, $limit]) { diff --git a/tests/TestCase/Controller/PackagesControllerTest.php b/tests/TestCase/Controller/PackagesControllerTest.php index df33d2f7..67da7840 100644 --- a/tests/TestCase/Controller/PackagesControllerTest.php +++ b/tests/TestCase/Controller/PackagesControllerTest.php @@ -3,6 +3,7 @@ namespace App\Test\TestCase\Controller; +use Cake\Routing\Router; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use Override; @@ -24,6 +25,7 @@ class PackagesControllerTest extends TestCase #[Override] protected array $fixtures = [ 'app.FeaturedPlugins', 'app.Packages', + 'app.Categories', 'app.CategoriesPackages', 'plugin.Tags.Tags', 'plugin.Tags.Tagged', ]; @@ -102,6 +104,38 @@ public function testMissingPluginDetails(): void $this->assertResponseCode(404); } + /** + * Namespace pages are public, scoped, and expose totals unaffected by filters. + */ + public function testNamespacePage(): void + { + $this->get('/plugins/by/dereuromark?search=no-match'); + $this->assertResponseOk(); + $this->assertResponseContains('Plugins by dereuromark'); + $this->assertResponseContains('data-namespace-stat="downloads">20,000'); + $this->assertResponseContains('data-namespace-stat="stars">300'); + $this->assertResponseContains('No plugins found'); + $this->assertResponseContains('action="/plugins/by/dereuromark"'); + $this->assertResponseContains('rel="canonical" href="' . Router::url([ + '_name' => 'plugin:namespace', + 'namespace' => 'dereuromark', + ], true) . '"'); + $this->assertResponseNotContains('data-featured-plugins'); + $this->assertResponseNotContains('markstory/asset_compress'); + } + + /** + * Canonical casing retains query strings, while unknown namespaces return 404. + */ + public function testNamespaceCanonicalUrlAndMissingNamespace(): void + { + $this->get('/plugins/by/Dereuromark?search=tools&sort=stars'); + $this->assertResponseCode(301); + $this->assertRedirect('/plugins/by/dereuromark?search=tools&sort=stars'); + $this->get('/plugins/by/not-a-namespace'); + $this->assertResponseCode(404); + } + /** * Public catalogue documents provide the runtime and navigation metadata. */ diff --git a/tests/TestCase/Island/CatalogueIslandTest.php b/tests/TestCase/Island/CatalogueIslandTest.php index 244c9c61..8f1673b5 100644 --- a/tests/TestCase/Island/CatalogueIslandTest.php +++ b/tests/TestCase/Island/CatalogueIslandTest.php @@ -8,6 +8,7 @@ use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; use Interact\TestSuite\IslandTestTrait; +use InvalidArgumentException; use Override; use Tags\Model\Table\TaggedTable; use Tags\Model\Table\TagsTable; @@ -57,6 +58,52 @@ public function testUnknownCategoryIsCleared(): void $this->testIsland('Catalogue')->call('selectCategory', 'not-a-category')->assertSet('category', ''); } + /** + * Paging, filtering and resetting retain the server-owned namespace scope. + */ + public function testNamespaceScopeSurvivesUpdates(): void + { + $request = new ServerRequest(['url' => 'http://localhost/plugins/by/vendor']); + $island = $this->testIsland('Catalogue', ['namespace' => 'vendor'], request: $request) + ->assertDontSee('data-featured-plugins') + ->assertDontSee('markstory/asset_compress') + ->set('paging.page', 2) + ->assertSet('paging.page', 2) + ->set('search', 'package-01') + ->assertSet('paging.page', 1) + ->assertSee('vendor/package-01') + ->assertDontSee('vendor/package-02') + ->call('clearFilters') + ->assertSet('namespace', 'vendor') + ->assertDontSee('data-featured-plugins') + ->assertDontSee('markstory/asset_compress'); + $this->assertStringContainsString('/plugins/by/vendor?', $island->result()->html); + $this->assertStringNotContainsString('/_interact/update?', $island->result()->html); + } + + /** + * Namespaced category choices exclude unrelated categories and remain stable while searching. + */ + public function testNamespaceCategoryChoices(): void + { + $this->testIsland('Catalogue', ['namespace' => 'markstory']) + ->assertSee('Asset Management') + ->assertDontSee('Authentication & Authorization') + ->set('search', 'no-match') + ->assertSee('Asset Management') + ->assertSee('No plugins found'); + } + + /** + * Browser updates cannot replace the namespace supplied by the hosting page. + */ + public function testNamespaceCannotBeChangedByUpdates(): void + { + $island = $this->testIsland('Catalogue', ['namespace' => 'vendor']); + $this->expectException(InvalidArgumentException::class); + $island->set('namespace', 'cakedc'); + } + /** * Keep featured selection and cached options deterministic. */ diff --git a/tests/TestCase/Model/Table/PackagesTableTest.php b/tests/TestCase/Model/Table/PackagesTableTest.php index 2dd75ff5..4871c728 100644 --- a/tests/TestCase/Model/Table/PackagesTableTest.php +++ b/tests/TestCase/Model/Table/PackagesTableTest.php @@ -81,4 +81,53 @@ public function testFeaturedCandidates(): void 'vendor/package-07', 'vendor/package-08', 'vendor/package-09', ], $results->extract('package')->toList()); } + + /** + * Namespace matching is literal and ends at the Composer vendor separator. + */ + public function testNamespaceMatching(): void + { + $packages = $this->fetchTable(PackagesTable::class); + $packages->updateAll(['package' => 'vendor_extra/one'], ['id' => 2]); + $packages->updateAll(['package' => 'vendorXextra/two'], ['id' => 3]); + $packages->updateAll(['package' => 'vendor_extra_more/three'], ['id' => 4]); + $packages->updateAll(['package' => 'vendor.extra/four'], ['id' => 5]); + $this->assertSame([2], $packages->find('namespace', namespace: 'vendor_extra')->all()->extract('id')->toList()); + $this->assertSame([5], $packages->find('namespace', namespace: 'vendor.extra')->all()->extract('id')->toList()); + $this->assertSame(0, $packages->find('namespace', namespace: 'vendor%')->count()); + $this->assertSame(0, $packages->find('namespace', namespace: "vendor' OR 1=1")->count()); + $this->assertSame(19, $packages->find('namespace', namespace: 'vendor')->count()); + } + + /** + * Totals and category counts cover only the requested namespace. + */ + public function testNamespaceStatisticsAndCategories(): void + { + $packages = $this->fetchTable(PackagesTable::class); + $this->assertSame([ + 'plugins' => 23, + 'downloads' => 22701, + 'stars' => 2001, + ], $packages->namespaceStats('vendor')); + $this->assertSame([ + 'plugins' => 0, + 'downloads' => 0, + 'stars' => 0, + ], $packages->namespaceStats('missing')); + $this->assertSame([ + 'asset-management' => [ + 'name' => 'Asset Management', + 'count' => 1, + ], + 'code-analysis' => [ + 'name' => 'Code Analysis', + 'count' => 1, + ], + ], $packages->categoryOptions('markstory')); + $this->assertSame([], $packages->categoryOptions('vendor')); + $this->assertSame(1, $packages->find('catalogue', filters: ['awesome' => true], namespace: 'markstory')->count()); + $this->assertSame(0, $packages->find('catalogue', filters: ['search' => 'users'], namespace: 'markstory')->count()); + $this->assertSame(0, $packages->find('catalogue', filters: ['category' => 'authentication-authorization'], namespace: 'markstory')->count()); + } } diff --git a/tests/browser/namespaces.spec.js b/tests/browser/namespaces.spec.js new file mode 100644 index 00000000..317a523b --- /dev/null +++ b/tests/browser/namespaces.spec.js @@ -0,0 +1,169 @@ +import { test, expect } from '@playwright/test' + +const rows = page => page.locator('[data-package-row]') +const hero = page => page.locator('[data-namespace-hero]') +const pageErrors = new WeakMap() +const chooseSort = async (page, label) => { + await page.getByLabel('Sort plugins', { exact: true }).click() + await page.locator('.sort-options').getByRole('link', { name: label, exact: true }).click() +} + +test.beforeEach(async ({ page }) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + pageErrors.set(page, errors) +}) + +test.afterEach(async ({ page }) => { + expect(pageErrors.get(page)).toEqual([]) +}) + +test('namespace hero totals and catalogue results use the Composer prefix', async ({ page }) => { + await page.goto('/plugins/by/vendor') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Plugins by vendor') + await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') + await expect(hero(page).locator('[data-namespace-stat="downloads"]')).toHaveText('22,701') + await expect(hero(page).locator('[data-namespace-stat="stars"]')).toHaveText('2,001') + await expect(rows(page)).toHaveCount(21) + expect(await rows(page).evaluateAll(elements => elements.every(element => element.textContent.includes('vendor/package-')))).toBe(true) + await expect(page.locator('[data-featured-plugins]')).toHaveCount(0) + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'http://127.0.0.1:8765/plugins/by/vendor') + await expect(page.locator('meta[name="robots"]')).toHaveCount(0) + await page.screenshot({ path: 'tmp/namespace-desktop.png', fullPage: true, animations: 'disabled' }) +}) + +test('search, version filters, sorting and reset retain the namespace and hero', async ({ page }) => { + await page.goto('/plugins/by/vendor') + await page.evaluate(() => { window.namespaceMarker = 'retained' }) + await chooseSort(page, 'Downloads') + await page.getByRole('searchbox', { name: 'Search plugins', exact: true }).fill('package-01') + await expect(rows(page)).toHaveCount(1) + await expect(rows(page)).toContainText('vendor/package-01') + await page.getByRole('button', { name: 'CakePHP 4.4', exact: true }).click() + await expect(rows(page)).toHaveCount(0) + await expect(page.getByText('No plugins found', { exact: true })).toBeVisible() + await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex,nofollow') + await page.getByRole('link', { name: 'Reset filters', exact: true }).click() + await expect(rows(page)).toHaveCount(21) + await expect(page).toHaveURL('/plugins/by/vendor?sort=downloads') + expect(await page.evaluate(() => window.namespaceMarker)).toBe('retained') + await expect(page.getByRole('search')).toHaveAttribute('action', '/plugins/by/vendor') +}) + +test('pagination and history stay on the namespace page', async ({ page }) => { + await page.goto('/plugins/by/vendor') + await page.evaluate(() => { window.namespaceMarker = 'retained' }) + await page.getByRole('link', { name: 'Last', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/vendor?page=2') + await expect(rows(page)).toHaveCount(2) + await page.getByRole('link', { name: 'First', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/vendor') + await expect(rows(page)).toHaveCount(21) + await page.goBack() + await expect(page).toHaveURL('/plugins/by/vendor?page=2') + await expect(rows(page)).toHaveCount(2) + await page.goForward() + await expect(rows(page)).toHaveCount(21) + expect(await page.evaluate(() => window.namespaceMarker)).toBe('retained') + await page.reload() + await expect(rows(page)).toHaveCount(21) +}) + +test('category choices and detail filters stay within the namespace', async ({ page }) => { + await page.goto('/plugins/by/markstory') + await page.getByText('Browse categories', { exact: true }).click() + const categories = page.getByRole('group', { name: 'Plugin categories', exact: true }) + await expect(categories.getByRole('link', { name: 'Asset Management, 1 plugin', exact: true })).toBeVisible() + await expect(categories.getByRole('link', { name: /Authentication/ })).toHaveCount(0) + await categories.getByRole('link', { name: 'Asset Management, 1 plugin', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/markstory?category=asset-management') + await expect(rows(page)).toHaveCount(1) + await page.getByRole('button', { name: 'Awesome only', exact: true }).click() + await expect(rows(page)).toHaveCount(1) + await rows(page).getByRole('link', { name: 'markstory/asset_compress', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('link', { name: 'Code Analysis', exact: true }).click() + await expect(dialog).not.toBeVisible() + await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) + await expect(page).toHaveURL(/category=code-analysis/) + await expect(rows(page)).toHaveCount(1) + await expect(hero(page).locator('[data-namespace-stat="downloads"]')).toHaveText('5,000') + await rows(page).getByRole('link', { name: 'markstory/asset_compress', exact: true }).click() + await dialog.getByRole('link', { name: 'PHP 8.5', exact: true }).click() + await expect(rows(page)).toHaveCount(1) + await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) + await expect(page).toHaveURL(/php_slugs/) + await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') +}) + +test('namespace links navigate from rows, modal details and standalone details', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { window.namespaceMarker = 'retained' }) + await page.getByRole('link', { name: 'More by dereuromark', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/dereuromark') + expect(await page.evaluate(() => window.namespaceMarker)).toBe('retained') + await page.goBack() + await expect(page).toHaveURL('/') + await page.locator('#package-26').getByRole('link', { name: 'dereuromark/cakephp-tools', exact: true }).click() + await page.getByRole('dialog').getByRole('link', { name: 'View all plugins by dereuromark', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/dereuromark') + await expect(page.getByRole('dialog')).not.toBeVisible() + await expect(hero(page)).toBeVisible() + expect(await page.evaluate(() => window.namespaceMarker)).toBe('retained') + await page.goto('/plugins/26') + await page.evaluate(() => { window.namespaceMarker = 'retained' }) + await page.getByRole('link', { name: 'View all plugins by dereuromark', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/dereuromark') + expect(await page.evaluate(() => window.namespaceMarker)).toBe('retained') +}) + +test('namespace pages support native links and search without JavaScript', async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }) + const page = await context.newPage() + try { + await page.goto('/plugins/by/markstory') + await page.getByText('Browse categories', { exact: true }).click() + await page.getByRole('link', { name: 'Asset Management, 1 plugin', exact: true }).click() + await expect(page).toHaveURL(/\/plugins\/by\/markstory\?.*category=asset-management/) + await page.getByRole('searchbox', { name: 'Search plugins', exact: true }).fill('no-match') + await page.getByRole('searchbox', { name: 'Search plugins', exact: true }).press('Enter') + await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) + await expect(rows(page)).toHaveCount(0) + await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') + await page.getByRole('link', { name: 'Reset filters', exact: true }).click() + await expect(rows(page)).toHaveCount(1) + await page.goto('/plugins/by/vendor') + await page.getByRole('link', { name: 'Last', exact: true }).click() + await expect(page).toHaveURL('/plugins/by/vendor?page=2') + await expect(rows(page)).toHaveCount(2) + } finally { + await context.close() + } +}) + +test('namespace hero fits mobile screens in both themes', async ({ page, context }) => { + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }) + for (const theme of ['cakephp', 'cakephp-dark']) { + await context.addCookies([{ name: 'theme', value: theme, domain: '127.0.0.1', path: '/' }]) + await page.goto('/plugins/by/dereuromark') + await expect(page.locator('html')).toHaveAttribute('data-theme', theme) + await expect(hero(page)).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: `tmp/namespace-mobile-${theme}-${width}.png`, fullPage: true, animations: 'disabled' }) + // Real catalogue totals reach tens of millions; ensure values fit their individual cells. + await hero(page).locator('[data-namespace-stat="downloads"]').evaluate(element => { element.textContent = '62,789,075' }) + await hero(page).locator('[data-namespace-stat="stars"]').evaluate(element => { element.textContent = '1,740' }) + expect(await hero(page).locator('[data-namespace-stat]').evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth))).toBe(true) + } + } +}) + +test('namespace URLs canonicalize casing and unknown namespaces return 404', async ({ page }) => { + await page.goto('/plugins/by/Dereuromark?search=tools&sort=stars') + await expect(page).toHaveURL('/plugins/by/dereuromark?search=tools&sort=stars') + await expect(rows(page)).toHaveCount(1) + const response = await page.goto('/plugins/by/unknown-namespace') + expect(response.status()).toBe(404) +}) From ff32f4817ece735851606a7881a113dfe313b347 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 13:40:51 +0100 Subject: [PATCH 2/9] Separate namespace statistics and link GitHub profiles --- src/Controller/PackagesController.php | 6 +++-- src/Model/Table/PackagesTable.php | 26 +++++++++++++++++++ templates/Packages/by_namespace.php | 25 ++++++++++++------ .../Controller/PackagesControllerTest.php | 13 ++++++++++ .../Model/Table/PackagesTableTest.php | 26 +++++++++++++++++++ tests/browser/namespaces.spec.js | 23 +++++++++------- 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index 1d5f4fb4..fd4e3a23 100644 --- a/src/Controller/PackagesController.php +++ b/src/Controller/PackagesController.php @@ -34,7 +34,8 @@ public function index(): void public function byNamespace(string $namespace): ?Response { $canonicalNamespace = strtolower($namespace); - $stats = $this->fetchTable(PackagesTable::class)->namespaceStats($canonicalNamespace); + $packages = $this->fetchTable(PackagesTable::class); + $stats = $packages->namespaceStats($canonicalNamespace); if ($stats['plugins'] === 0) { throw new NotFoundException('Namespace not found.'); } @@ -45,7 +46,8 @@ public function byNamespace(string $namespace): ?Response '?' => $this->getRequest()->getQueryParams(), ], 301); } - $this->set(compact('namespace', 'stats')); + $githubUrl = $packages->namespaceGithubUrl($namespace); + $this->set(compact('namespace', 'stats', 'githubUrl')); return null; } diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php index 1f1399ef..7d304f59 100644 --- a/src/Model/Table/PackagesTable.php +++ b/src/Model/Table/PackagesTable.php @@ -176,6 +176,32 @@ public function namespaceStats(string $namespace): array ]; } + /** + * Link only an unambiguous GitHub repository owner, never guess from the Composer prefix. + */ + public function namespaceGithubUrl(string $namespace): ?string + { + $owners = []; + $repositories = $this->find('namespace', namespace: $namespace)->select(['repo_url'])->distinct()->all(); + foreach ($repositories as $package) { + $url = preg_replace('#^git@github\.com:#i', 'https://github.com/', trim($package->repo_url)) ?? $package->repo_url; + $host = strtolower((string)parse_url($url, PHP_URL_HOST)); + if (!in_array($host, ['github.com', 'www.github.com'], true)) { + continue; + } + $parts = explode('/', trim((string)parse_url($url, PHP_URL_PATH), '/')); + if (count($parts) < 2 || $parts[1] === '' || !preg_match('/^[a-z0-9][a-z0-9-]*$/iD', $parts[0])) { + continue; + } + $owners[strtolower($parts[0])] = true; + if (count($owners) > 1) { + return null; + } + } + + return $owners !== [] ? 'https://github.com/' . array_key_first($owners) : null; + } + /** * Load configured featured packages without changing their display order. * diff --git a/templates/Packages/by_namespace.php b/templates/Packages/by_namespace.php index a5d21b72..1cbbcf3e 100644 --- a/templates/Packages/by_namespace.php +++ b/templates/Packages/by_namespace.php @@ -3,6 +3,7 @@ * @var \App\View\AppView $this * @var string $namespace * @var array{plugins: int, downloads: int, stars: int} $stats + * @var string|null $githubUrl */ $this->assign('title', __('Plugins by {0}', $namespace)); $statLabels = [ @@ -30,16 +31,24 @@

-
- $label) : ?> -
-
-
Number->format($stats[$key]) ?>
-
- -
+ +
+ element('github_icon') ?> + + + +
+
+ $label) : ?> +
+
+
Number->format($stats[$key]) ?>
+
+ +
+
Interact->render('Catalogue', props: compact('namespace'), key: 'namespace-catalogue-' . $namespace) ?> element('Packages/plugin-modal') ?> diff --git a/tests/TestCase/Controller/PackagesControllerTest.php b/tests/TestCase/Controller/PackagesControllerTest.php index 67da7840..3ac1adc1 100644 --- a/tests/TestCase/Controller/PackagesControllerTest.php +++ b/tests/TestCase/Controller/PackagesControllerTest.php @@ -3,6 +3,7 @@ namespace App\Test\TestCase\Controller; +use App\Model\Table\PackagesTable; use Cake\Routing\Router; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; @@ -112,6 +113,7 @@ public function testNamespacePage(): void $this->get('/plugins/by/dereuromark?search=no-match'); $this->assertResponseOk(); $this->assertResponseContains('Plugins by dereuromark'); + $this->assertResponseContains('href="https://github.com/dereuromark"'); $this->assertResponseContains('data-namespace-stat="downloads">20,000'); $this->assertResponseContains('data-namespace-stat="stars">300'); $this->assertResponseContains('No plugins found'); @@ -136,6 +138,17 @@ public function testNamespaceCanonicalUrlAndMissingNamespace(): void $this->assertResponseCode(404); } + /** + * A namespace spanning different GitHub owners must not advertise an inferred profile. + */ + public function testNamespaceOmitsAmbiguousGithubProfile(): void + { + $this->fetchTable(PackagesTable::class)->updateAll(['repo_url' => 'https://github.com/another-owner/plugin'], ['id' => 2]); + $this->get('/plugins/by/vendor'); + $this->assertResponseOk(); + $this->assertResponseNotContains('GitHub profile'); + } + /** * Public catalogue documents provide the runtime and navigation metadata. */ diff --git a/tests/TestCase/Model/Table/PackagesTableTest.php b/tests/TestCase/Model/Table/PackagesTableTest.php index 4871c728..ac7cad21 100644 --- a/tests/TestCase/Model/Table/PackagesTableTest.php +++ b/tests/TestCase/Model/Table/PackagesTableTest.php @@ -130,4 +130,30 @@ public function testNamespaceStatisticsAndCategories(): void $this->assertSame(0, $packages->find('catalogue', filters: ['search' => 'users'], namespace: 'markstory')->count()); $this->assertSame(0, $packages->find('catalogue', filters: ['category' => 'authentication-authorization'], namespace: 'markstory')->count()); } + + /** + * Repository URLs identify a profile independently of the Composer namespace. + */ + public function testNamespaceGithubProfile(): void + { + $packages = $this->fetchTable(PackagesTable::class); + $this->assertSame('https://github.com/vendor', $packages->namespaceGithubUrl('vendor')); + $packages->updateAll(['repo_url' => 'https://www.github.com/Actual-Owner/plugin.git'], ['id' => 1]); + $this->assertSame('https://github.com/actual-owner', $packages->namespaceGithubUrl('markstory')); + $packages->updateAll(['repo_url' => 'git@github.com:Actual-Owner/plugin.git'], ['id' => 1]); + $this->assertSame('https://github.com/actual-owner', $packages->namespaceGithubUrl('markstory')); + $packages->updateAll(['repo_url' => 'https://github.com/another-owner/plugin'], ['id' => 2]); + $this->assertNull($packages->namespaceGithubUrl('vendor')); + $unsupportedUrls = [ + 'https://gitlab.com/markstory/plugin', + 'https://github.com.example.com/markstory/plugin', + 'https://github.com/markstory', + 'https://github.com/invalid_owner/plugin', + ]; + foreach ($unsupportedUrls as $url) { + $packages->updateAll(['repo_url' => $url], ['id' => 1]); + $this->assertNull($packages->namespaceGithubUrl('markstory')); + } + $this->assertNull($packages->namespaceGithubUrl('missing')); + } } diff --git a/tests/browser/namespaces.spec.js b/tests/browser/namespaces.spec.js index 317a523b..b44db998 100644 --- a/tests/browser/namespaces.spec.js +++ b/tests/browser/namespaces.spec.js @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test' const rows = page => page.locator('[data-package-row]') const hero = page => page.locator('[data-namespace-hero]') +const stats = page => page.getByRole('region', { name: 'Namespace statistics', exact: true }) const pageErrors = new WeakMap() const chooseSort = async (page, label) => { await page.getByLabel('Sort plugins', { exact: true }).click() @@ -21,9 +22,11 @@ test.afterEach(async ({ page }) => { test('namespace hero totals and catalogue results use the Composer prefix', async ({ page }) => { await page.goto('/plugins/by/vendor') await expect(page.getByRole('heading', { level: 1 })).toHaveText('Plugins by vendor') - await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') - await expect(hero(page).locator('[data-namespace-stat="downloads"]')).toHaveText('22,701') - await expect(hero(page).locator('[data-namespace-stat="stars"]')).toHaveText('2,001') + await expect(hero(page).locator('[data-namespace-stat]')).toHaveCount(0) + await expect(hero(page).getByRole('link', { name: 'GitHub profile' })).toHaveAttribute('href', 'https://github.com/vendor') + await expect(stats(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') + await expect(stats(page).locator('[data-namespace-stat="downloads"]')).toHaveText('22,701') + await expect(stats(page).locator('[data-namespace-stat="stars"]')).toHaveText('2,001') await expect(rows(page)).toHaveCount(21) expect(await rows(page).evaluateAll(elements => elements.every(element => element.textContent.includes('vendor/package-')))).toBe(true) await expect(page.locator('[data-featured-plugins]')).toHaveCount(0) @@ -42,7 +45,7 @@ test('search, version filters, sorting and reset retain the namespace and hero', await page.getByRole('button', { name: 'CakePHP 4.4', exact: true }).click() await expect(rows(page)).toHaveCount(0) await expect(page.getByText('No plugins found', { exact: true })).toBeVisible() - await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') + await expect(stats(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex,nofollow') await page.getByRole('link', { name: 'Reset filters', exact: true }).click() await expect(rows(page)).toHaveCount(21) @@ -88,13 +91,13 @@ test('category choices and detail filters stay within the namespace', async ({ p await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) await expect(page).toHaveURL(/category=code-analysis/) await expect(rows(page)).toHaveCount(1) - await expect(hero(page).locator('[data-namespace-stat="downloads"]')).toHaveText('5,000') + await expect(stats(page).locator('[data-namespace-stat="downloads"]')).toHaveText('5,000') await rows(page).getByRole('link', { name: 'markstory/asset_compress', exact: true }).click() await dialog.getByRole('link', { name: 'PHP 8.5', exact: true }).click() await expect(rows(page)).toHaveCount(1) await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) await expect(page).toHaveURL(/php_slugs/) - await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') + await expect(stats(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') }) test('namespace links navigate from rows, modal details and standalone details', async ({ page }) => { @@ -130,7 +133,7 @@ test('namespace pages support native links and search without JavaScript', async await page.getByRole('searchbox', { name: 'Search plugins', exact: true }).press('Enter') await expect(page).toHaveURL(/\/plugins\/by\/markstory\?/) await expect(rows(page)).toHaveCount(0) - await expect(hero(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') + await expect(stats(page).locator('[data-namespace-stat="plugins"]')).toHaveText('1') await page.getByRole('link', { name: 'Reset filters', exact: true }).click() await expect(rows(page)).toHaveCount(1) await page.goto('/plugins/by/vendor') @@ -153,9 +156,9 @@ test('namespace hero fits mobile screens in both themes', async ({ page, context expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) await page.screenshot({ path: `tmp/namespace-mobile-${theme}-${width}.png`, fullPage: true, animations: 'disabled' }) // Real catalogue totals reach tens of millions; ensure values fit their individual cells. - await hero(page).locator('[data-namespace-stat="downloads"]').evaluate(element => { element.textContent = '62,789,075' }) - await hero(page).locator('[data-namespace-stat="stars"]').evaluate(element => { element.textContent = '1,740' }) - expect(await hero(page).locator('[data-namespace-stat]').evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth))).toBe(true) + await stats(page).locator('[data-namespace-stat="downloads"]').evaluate(element => { element.textContent = '62,789,075' }) + await stats(page).locator('[data-namespace-stat="stars"]').evaluate(element => { element.textContent = '1,740' }) + expect(await stats(page).locator('[data-namespace-stat]').evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth))).toBe(true) } } }) From b31b58eac62547f55fb2ca0744a65075a26c3cef Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 13:46:48 +0100 Subject: [PATCH 3/9] Refine namespace header and add Packagist link --- templates/Packages/by_namespace.php | 70 +++++++++++------- .../Controller/PackagesControllerTest.php | 2 + tests/browser/namespaces.spec.js | 10 ++- webroot/img/packagist-logo.png | Bin 0 -> 4573 bytes 4 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 webroot/img/packagist-logo.png diff --git a/templates/Packages/by_namespace.php b/templates/Packages/by_namespace.php index 1cbbcf3e..1ba045d5 100644 --- a/templates/Packages/by_namespace.php +++ b/templates/Packages/by_namespace.php @@ -6,6 +6,7 @@ * @var string|null $githubUrl */ $this->assign('title', __('Plugins by {0}', $namespace)); +$packagistUrl = 'https://packagist.org/packages/' . rawurlencode($namespace) . '/'; $statLabels = [ 'plugins' => __('Plugins'), 'downloads' => __('Downloads'), @@ -17,38 +18,51 @@ 'class' => 'mb-5 inline-flex min-h-8 items-center text-sm text-muted', 'x-interact:navigate' => '', ]) ?> -
-
-
+
+
+ $label) : ?> +
+
+
Number->format($stats[$key]) ?>
+
+ +
+
+ Interact->render('Catalogue', props: compact('namespace'), key: 'namespace-catalogue-' . $namespace) ?> element('Packages/plugin-modal') ?> diff --git a/tests/TestCase/Controller/PackagesControllerTest.php b/tests/TestCase/Controller/PackagesControllerTest.php index 3ac1adc1..6ed85ea1 100644 --- a/tests/TestCase/Controller/PackagesControllerTest.php +++ b/tests/TestCase/Controller/PackagesControllerTest.php @@ -114,6 +114,7 @@ public function testNamespacePage(): void $this->assertResponseOk(); $this->assertResponseContains('Plugins by dereuromark'); $this->assertResponseContains('href="https://github.com/dereuromark"'); + $this->assertResponseContains('href="https://packagist.org/packages/dereuromark/"'); $this->assertResponseContains('data-namespace-stat="downloads">20,000'); $this->assertResponseContains('data-namespace-stat="stars">300'); $this->assertResponseContains('No plugins found'); @@ -147,6 +148,7 @@ public function testNamespaceOmitsAmbiguousGithubProfile(): void $this->get('/plugins/by/vendor'); $this->assertResponseOk(); $this->assertResponseNotContains('GitHub profile'); + $this->assertResponseContains('href="https://packagist.org/packages/vendor/"'); } /** diff --git a/tests/browser/namespaces.spec.js b/tests/browser/namespaces.spec.js index b44db998..30e1a12e 100644 --- a/tests/browser/namespaces.spec.js +++ b/tests/browser/namespaces.spec.js @@ -24,6 +24,14 @@ test('namespace hero totals and catalogue results use the Composer prefix', asyn await expect(page.getByRole('heading', { level: 1 })).toHaveText('Plugins by vendor') await expect(hero(page).locator('[data-namespace-stat]')).toHaveCount(0) await expect(hero(page).getByRole('link', { name: 'GitHub profile' })).toHaveAttribute('href', 'https://github.com/vendor') + await expect(hero(page).getByRole('link', { name: 'Packagist', exact: true })).toHaveAttribute('href', 'https://packagist.org/packages/vendor/') + await expect(hero(page).getByRole('link', { name: 'Packagist', exact: true })).toHaveAttribute('target', '_blank') + await expect(hero(page).getByRole('link', { name: 'GitHub profile' })).toHaveText('') + await expect(hero(page).getByRole('link', { name: 'Packagist', exact: true })).toHaveText('') + const headingBounds = await hero(page).boundingBox() + const statsBounds = await stats(page).boundingBox() + expect(statsBounds.x).toBeGreaterThan(headingBounds.x + headingBounds.width) + expect(statsBounds.y).toBe(headingBounds.y) await expect(stats(page).locator('[data-namespace-stat="plugins"]')).toHaveText('23') await expect(stats(page).locator('[data-namespace-stat="downloads"]')).toHaveText('22,701') await expect(stats(page).locator('[data-namespace-stat="stars"]')).toHaveText('2,001') @@ -32,7 +40,7 @@ test('namespace hero totals and catalogue results use the Composer prefix', asyn await expect(page.locator('[data-featured-plugins]')).toHaveCount(0) await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'http://127.0.0.1:8765/plugins/by/vendor') await expect(page.locator('meta[name="robots"]')).toHaveCount(0) - await page.screenshot({ path: 'tmp/namespace-desktop.png', fullPage: true, animations: 'disabled' }) + await page.screenshot({ path: 'tmp/namespace-desktop.png', animations: 'disabled' }) }) test('search, version filters, sorting and reset retain the namespace and hero', async ({ page }) => { diff --git a/webroot/img/packagist-logo.png b/webroot/img/packagist-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..76c0e62a20d7ca8f50ce24b1e5acb8b4af2b932f GIT binary patch literal 4573 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 literal 0 HcmV?d00001 From c5f1c8b05615d16d5508eacae0513399513e80ce Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 14:03:42 +0100 Subject: [PATCH 4/9] Simplify namespace header copy --- templates/Packages/by_namespace.php | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/Packages/by_namespace.php b/templates/Packages/by_namespace.php index 1ba045d5..0852ae97 100644 --- a/templates/Packages/by_namespace.php +++ b/templates/Packages/by_namespace.php @@ -30,7 +30,6 @@

-

From 84a3f786d2b0cf368a9a152f773f7820d151913e Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 13 Nov 2026 14:54:04 +0100 Subject: [PATCH 5/9] Refine namespace profiles and add Composer package URLs --- config/app.php | 8 + config/routes.php | 20 ++- playwright.config.js | 1 + resources/css/style.css | 77 ++++++++ resources/js/app.js | 32 ++++ src/Application.php | 6 +- src/Controller/PackagesController.php | 9 +- src/Island/NamespaceProfileIsland.php | 37 ++++ src/Service/GithubProfileService.php | 71 ++++++++ src/View/AppView.php | 3 + src/View/Helper/NumberHelper.php | 36 ++++ src/View/IslandTemplateView.php | 4 + templates/Packages/by_namespace.php | 87 ++++----- templates/Packages/view.php | 6 + .../element/Loading/namespace_profile.php | 32 ++++ templates/element/Namespaces/profile.php | 55 ++++++ templates/element/Packages/package-row.php | 8 +- templates/island/featured_plugins.php | 6 +- templates/island/namespace_profile.php | 16 ++ templates/layout/default.php | 2 +- tests/TestCase/ApplicationTest.php | 7 +- .../Controller/PackagesControllerTest.php | 100 ++++++++++- tests/TestCase/Island/CatalogueIslandTest.php | 4 +- .../Island/NamespaceProfileIslandTest.php | 67 +++++++ .../Service/GithubProfileServiceTest.php | 113 ++++++++++++ .../TestCase/View/Helper/NumberHelperTest.php | 56 ++++++ tests/bootstrap.php | 2 + tests/browser/catalogue.spec.js | 4 +- tests/browser/namespaces.spec.js | 167 ++++++++++++++---- tests/browser/setup.php | 6 + 30 files changed, 926 insertions(+), 116 deletions(-) create mode 100644 src/Island/NamespaceProfileIsland.php create mode 100644 src/Service/GithubProfileService.php create mode 100644 src/View/Helper/NumberHelper.php create mode 100644 templates/element/Loading/namespace_profile.php create mode 100644 templates/element/Namespaces/profile.php create mode 100644 templates/island/namespace_profile.php create mode 100644 tests/TestCase/Island/NamespaceProfileIslandTest.php create mode 100644 tests/TestCase/Service/GithubProfileServiceTest.php create mode 100644 tests/TestCase/View/Helper/NumberHelperTest.php diff --git a/config/app.php b/config/app.php index 29a1d6f6..9bd83e7f 100644 --- a/config/app.php +++ b/config/app.php @@ -157,6 +157,14 @@ 'url' => env('CACHE_DEFAULT_URL'), ], + 'github_profiles' => [ + 'className' => FileEngine::class, + 'path' => CACHE . 'github_profiles' . DS, + 'prefix' => 'plugins_github_profiles_', + 'duration' => '+1 day', + 'url' => env('CACHE_GITHUB_PROFILES_URL'), + ], + 'rate_limit' => [ 'className' => FileEngine::class, 'path' => CACHE . 'rate_limit' . DS, diff --git a/config/routes.php b/config/routes.php index e88d10d8..f6815f14 100644 --- a/config/routes.php +++ b/config/routes.php @@ -59,16 +59,26 @@ * to use (in this case, templates/Pages/home.php)... */ $builder->connect('/', ['controller' => 'Packages', 'action' => 'index']); - $builder->get('/plugins/by/{namespace}', [ + $packageSegment = '[A-Za-z0-9][A-Za-z0-9_.-]*'; + $builder->get('/plugins/{namespace}/{name}', [ + 'controller' => 'Packages', + 'action' => 'view', + ], 'plugin:view') + ->setPatterns([ + 'namespace' => $packageSegment, + 'name' => $packageSegment, + ]) + ->setPass(['namespace', 'name']) + ->setExtensions([]); + $builder->get('/plugins/{namespace}', [ 'controller' => 'Packages', 'action' => 'byNamespace', ], 'plugin:namespace') ->setPatterns([ - 'namespace' => '[A-Za-z0-9][A-Za-z0-9_.-]*', + 'namespace' => $packageSegment, ]) - ->setPass(['namespace']); - $builder->get('/plugins/{id}', ['controller' => 'Packages', 'action' => 'view'], 'plugin:view') - ->setPatterns(['id' => '[0-9]+'])->setPass(['id']); + ->setPass(['namespace']) + ->setExtensions([]); $builder->connect('/requirements', ['controller' => 'Pages', 'action' => 'display', 'requirements']); /* diff --git a/playwright.config.js b/playwright.config.js index ab7fdb2e..d77fce92 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -17,6 +17,7 @@ export default defineConfig({ DEBUG: 'false', RATE_LIMIT_ENABLED: 'false', CACHE_DEFAULT_URL: 'array://', + CACHE_GITHUB_PROFILES_URL: `file://${fileURLToPath(new URL('./tmp/browser-github-profiles/', import.meta.url))}`, CACHE_MODEL_URL: 'array://', CACHE_PERSISTENT_URL: 'array://', }, diff --git a/resources/css/style.css b/resources/css/style.css index 582d73e6..4709dad2 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -174,6 +174,81 @@ body::before { & h1 { @apply mb-2 text-[clamp(1.875rem,3.5vw,2.5rem)] leading-tight font-bold tracking-[-0.025em]; } & p { @apply text-muted; } } +.namespace-hero { + @apply relative isolate overflow-hidden border-b border-base-300 bg-base-100; + --namespace-rose: color-mix(in srgb, var(--color-primary) 28%, transparent); + &::before, &::after { + content: ""; + position: absolute; + z-index: -1; + pointer-events: none; + } + &::before { + inset: -45%; + background: + radial-gradient(ellipse at 15% 45%, var(--namespace-rose), transparent 50%), + radial-gradient(ellipse at 75% 35%, var(--glow-violet), transparent 50%), + radial-gradient(ellipse at 60% 80%, var(--glow-amber), transparent 45%); + animation: namespace-gradient 22s ease-in-out infinite alternate; + } + &::after { + inset: -60%; + background: radial-gradient(ellipse at 60% 50%, var(--glow-violet), transparent 58%); + animation: namespace-light 30s ease-in-out infinite alternate; + } +} +[data-theme="cakephp"] .namespace-hero { + --namespace-rose: color-mix(in srgb, var(--color-cake-red) 46%, transparent); + --glow-violet: #7d85dd66; + --glow-amber: #ffb98166; + background-color: var(--color-base-200); +} +.namespace-profile { + @apply grid min-w-0 items-center gap-8 pt-24 pb-12 md:gap-10 lg:min-h-72 lg:grid-cols-[minmax(0,1fr)_auto] + lg:gap-16 lg:py-20; +} +.namespace-toolbar { + @apply absolute inset-x-0 top-3 flex items-center justify-between gap-3 md:top-4; +} +.namespace-title { + @apply text-[clamp(1.75rem,5vw,4rem)] leading-none font-bold tracking-[-0.055em] wrap-anywhere; +} +.namespace-avatar { + @apply relative size-14 overflow-hidden rounded-xl bg-primary/10 text-lg font-semibold text-primary + md:size-24 md:rounded-2xl md:text-2xl; + & img { @apply absolute inset-0 size-full object-cover; } +} +.namespace-identity { + @apply flex min-w-0 items-center gap-4 md:gap-5; + min-height: max(6rem, calc(clamp(1.75rem, 5vw, 4rem) + 3.25rem)); +} +.namespace-description { + @apply mt-3 line-clamp-2 max-w-[42ch] text-sm leading-5 text-muted; +} +.namespace-profile-links { + @apply mt-2 ml-18 flex gap-1 md:ml-29; +} +.namespace-profile-link { + @apply size-8 min-h-8 border-0 bg-transparent p-0 text-muted shadow-none hover:bg-base-100/40 hover:text-primary; + & svg { @apply size-4.5; } +} +@media (pointer: coarse) { + .namespace-profile-link { @apply size-11 min-h-11; } +} +.namespace-statistics { + @apply w-full min-w-0 md:max-w-md lg:w-96; +} +.namespace-stat-value { + @apply text-[clamp(1.5rem,2.8vw,2.5rem)] leading-tight font-semibold tracking-tight tabular-nums; +} +@keyframes namespace-gradient { + from { transform: translate3d(-4%, -3%, 0) rotate(-6deg) scale(1); } + to { transform: translate3d(5%, 4%, 0) rotate(7deg) scale(1.12); } +} +@keyframes namespace-light { + from { transform: translate3d(4%, 0, 0) rotate(-15deg); } + to { transform: translate3d(-4%, 3%, 0) rotate(20deg); } +} .catalogue-discovery { @apply mb-6 rounded-2xl border border-base-300 bg-base-100 p-4 md:p-5; } @@ -466,7 +541,9 @@ body::before { @keyframes page-loading { from { transform: translateX(-100%); } to { transform: translateX(300%); } } @media (prefers-reduced-motion: reduce) { .page-loading-bar, .loading { animation: none; } + .namespace-hero::before, .namespace-hero::after { animation: none; } } @media (forced-colors: active) { body::before { display: none; } + .namespace-hero::before, .namespace-hero::after { display: none; } } diff --git a/resources/js/app.js b/resources/js/app.js index a13096fa..68112598 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -18,6 +18,38 @@ const register = (Alpine) => { }, }) + Alpine.data('namespaceCounters', () => ({ + frame: null, + init() { + const motion = window.matchMedia('(prefers-reduced-motion: reduce)') + if (motion.matches) return + const counters = Array.from(this.$el.querySelectorAll('[data-count-up-value]'), (element, index) => ({ + element, + total: Number(element.dataset.countUpValue), + final: element.textContent.trim(), + delay: index * 200, + })) + const formatter = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }) + const started = performance.now() + const tick = now => { + let complete = true + for (const counter of counters) { + const progress = motion.matches ? 1 : Math.max(0, Math.min((now - started - counter.delay) / 1100, 1)) + const eased = 1 - (1 - progress) ** 3 + counter.element.textContent = progress === 1 + ? counter.final + : formatter.format(Math.round(counter.total * eased)).replace('K', 'k') + complete &&= progress === 1 + } + this.frame = complete ? null : requestAnimationFrame(tick) + } + this.frame = requestAnimationFrame(tick) + }, + destroy() { + if (this.frame !== null) cancelAnimationFrame(this.frame) + }, + })) + Alpine.data('catalogue', () => ({ categorySearch: '', categoriesToggled(event) { diff --git a/src/Application.php b/src/Application.php index c9bd51e9..87f56570 100644 --- a/src/Application.php +++ b/src/Application.php @@ -20,12 +20,14 @@ use App\Command\SyncPackagesCommand; use App\Event\AfterGithubIdentify; use App\Http\RateLimit\AtomicFixedWindowRateLimiter; +use App\Island\NamespaceProfileIsland; use App\Middleware\ThemeCookieMiddleware; use App\Model\Table\PackagesTable; use App\Model\Table\UsersTable; use App\Policy\RequestPolicy; use App\Service\AwesomeListImporter; use App\Service\AwesomeListParser; +use App\Service\GithubProfileService; use App\Service\PackagistImporter; use Authentication\AuthenticationService; use Authentication\AuthenticationServiceInterface; @@ -203,7 +205,7 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue } return !in_array($request->getMethod(), ['GET', 'HEAD'], true) - || !($path === '' || $path === '/packages' || preg_match('#^/plugins/(?:[0-9]+|by/[A-Za-z0-9][A-Za-z0-9_.-]*)$#', $path)); + || !($path === '' || $path === '/packages' || preg_match('#^/plugins/[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$#', $path)); }, ]), ); @@ -223,6 +225,8 @@ public function services(ContainerInterface $container): void { $container->addShared(PackagesTable::class, static fn(): RepositoryInterface => FactoryLocator::get('Table')->get(PackagesTable::class)); $container->addShared(Client::class); + $container->addShared(GithubProfileService::class)->addArgument(Client::class); + $container->add(NamespaceProfileIsland::class)->addArgument(GithubProfileService::class); $container->addShared(AwesomeListParser::class); $container->addShared('packagist.http', static function (): Client { $client = new Client(); diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index fd4e3a23..6a57b01e 100644 --- a/src/Controller/PackagesController.php +++ b/src/Controller/PackagesController.php @@ -55,9 +55,14 @@ public function byNamespace(string $namespace): ?Response /** * Provide a normal detail URL for direct links and browsers without JavaScript. */ - public function view(string $id): void + public function view(string $namespace, string $name): void { - $package = $this->fetchTable(PackagesTable::class)->get($id, contain: ['Tags', 'Categories']); + $package = $this->fetchTable(PackagesTable::class)->find() + ->where([ + 'Packages.package' => strtolower($namespace . '/' . $name), + ]) + ->contain(['Tags', 'Categories']) + ->firstOrFail(); $this->set(compact('package')); } } diff --git a/src/Island/NamespaceProfileIsland.php b/src/Island/NamespaceProfileIsland.php new file mode 100644 index 00000000..efa7fff5 --- /dev/null +++ b/src/Island/NamespaceProfileIsland.php @@ -0,0 +1,37 @@ + + */ + public function viewData(): array + { + return [ + 'profile' => $this->profiles->profile(basename($this->githubUrl)), + ]; + } +} diff --git a/src/Service/GithubProfileService.php b/src/Service/GithubProfileService.php new file mode 100644 index 00000000..f7043b5a --- /dev/null +++ b/src/Service/GithubProfileService.php @@ -0,0 +1,71 @@ +get($key); + if (is_array($cached)) { + return $cached !== [] ? $cached : null; + } + + $profile = null; + try { + $response = $this->client->get('https://api.github.com/users/' . rawurlencode($owner), [], [ + 'timeout' => 3, + 'headers' => [ + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'plugins.cakephp.org', + 'X-GitHub-Api-Version' => '2022-11-28', + ], + ]); + $data = $response->isOk() ? $response->getJson() : null; + if (is_array($data) && is_string($data['login'] ?? null) && strcasecmp($data['login'], $owner) === 0) { + $avatarUrl = $data['avatar_url'] ?? null; + if ( + !is_string($avatarUrl) || + parse_url($avatarUrl, PHP_URL_SCHEME) !== 'https' || + !in_array(parse_url($avatarUrl, PHP_URL_HOST), ['avatars.githubusercontent.com', 'github.com'], true) + ) { + $avatarUrl = null; + } + $bio = is_string($data['bio'] ?? null) ? trim($data['bio']) : ''; + $profile = [ + 'avatarUrl' => $avatarUrl, + 'bio' => $bio !== '' ? $bio : null, + ]; + } + } catch (ClientExceptionInterface) { + // Optional enrichment must not prevent the catalogue from rendering. + } + $cache->set($key, $profile ?? [], $profile !== null ? 86400 : 900); + + return $profile; + } +} diff --git a/src/View/AppView.php b/src/View/AppView.php index d2bbe6c4..7a214a6b 100644 --- a/src/View/AppView.php +++ b/src/View/AppView.php @@ -15,6 +15,7 @@ */ namespace App\View; +use App\View\Helper\NumberHelper; use Cake\View\View; /** @@ -25,6 +26,7 @@ * @link https://book.cakephp.org/5/en/views.html#the-app-view * @property \Authentication\View\Helper\IdentityHelper $Identity * @property \App\View\Helper\UserHelper $User + * @property \App\View\Helper\NumberHelper $Number * @property \Interact\View\Helper\InteractHelper $Interact * @property \CakeVite\View\Helper\ViteHelper $Vite */ @@ -44,6 +46,7 @@ public function initialize(): void $this->loadHelper('Paginator', ['templates' => 'paginator-templates']); $this->loadHelper('Form', ['templates' => 'form-templates']); $this->loadHelper('Html', ['templates' => 'html-templates']); + $this->loadHelper('Number', ['className' => NumberHelper::class]); $this->loadHelper('Authentication.Identity'); $this->loadHelper('User'); $this->loadHelper('Interact.Interact'); diff --git a/src/View/Helper/NumberHelper.php b/src/View/Helper/NumberHelper.php new file mode 100644 index 00000000..f00dfe26 --- /dev/null +++ b/src/View/Helper/NumberHelper.php @@ -0,0 +1,36 @@ += 1_000_000_000 => 1_000_000_000, + $value >= 1_000_000 => 1_000_000, + $value >= 1_000 => 1_000, + default => 1, + }; + $suffix = match ($divisor) { + 1_000_000_000 => 'B', + 1_000_000 => 'M', + 1_000 => 'k', + default => '', + }; + + return $this->format($value / $divisor, [ + 'precision' => $divisor === 1 ? 0 : 1, + 'after' => $suffix, + ]); + } +} diff --git a/src/View/IslandTemplateView.php b/src/View/IslandTemplateView.php index 1f5d2428..2f49a561 100644 --- a/src/View/IslandTemplateView.php +++ b/src/View/IslandTemplateView.php @@ -3,11 +3,14 @@ namespace App\View; +use App\View\Helper\NumberHelper; use Interact\View\Helper\PaginatorHelper; use Interact\View\TemplateView; /** * Shares application presentation templates with isolated island renders. + * + * @property \App\View\Helper\NumberHelper $Number */ class IslandTemplateView extends TemplateView { @@ -19,6 +22,7 @@ public function initialize(): void parent::initialize(); $this->addHelper('Form', ['templates' => 'form-templates', 'autoSetCustomValidity' => false]); $this->addHelper('Html', ['templates' => 'html-templates']); + $this->addHelper('Number', ['className' => NumberHelper::class]); $this->addHelper('Paginator', ['className' => PaginatorHelper::class, 'templates' => 'paginator-templates']); } } diff --git a/templates/Packages/by_namespace.php b/templates/Packages/by_namespace.php index 0852ae97..58656abb 100644 --- a/templates/Packages/by_namespace.php +++ b/templates/Packages/by_namespace.php @@ -6,62 +6,51 @@ * @var string|null $githubUrl */ $this->assign('title', __('Plugins by {0}', $namespace)); -$packagistUrl = 'https://packagist.org/packages/' . rawurlencode($namespace) . '/'; $statLabels = [ 'plugins' => __('Plugins'), 'downloads' => __('Downloads'), 'stars' => __('Stars'), ]; ?> -
- Html->link('← ' . __('Browse all plugins'), '/', [ - 'class' => 'mb-5 inline-flex min-h-8 items-center text-sm text-muted', - 'x-interact:navigate' => '', - ]) ?> -
-
-
-
-
-
-
- $label) : ?> -
-
-
Number->format($stats[$key]) ?>
-
- -
-
+ + + +
-
+ Interact->render('Catalogue', props: compact('namespace'), key: 'namespace-catalogue-' . $namespace) ?> element('Packages/plugin-modal') ?> diff --git a/templates/Packages/view.php b/templates/Packages/view.php index 19cad8ba..c418a3e3 100644 --- a/templates/Packages/view.php +++ b/templates/Packages/view.php @@ -4,6 +4,12 @@ * @var \App\Model\Entity\Package $package */ $this->assign('title', $package->package); +[$namespace, $name] = explode('/', $package->package, 2); +$this->assign('canonicalUrl', $this->Url->build([ + '_name' => 'plugin:view', + 'namespace' => $namespace, + 'name' => $name, +], ['fullBase' => true])); ?>

package) ?>

diff --git a/templates/element/Loading/namespace_profile.php b/templates/element/Loading/namespace_profile.php new file mode 100644 index 00000000..4c7174c0 --- /dev/null +++ b/templates/element/Loading/namespace_profile.php @@ -0,0 +1,32 @@ + +
+ element('Namespaces/profile', [ + 'namespace' => $namespace, + 'githubUrl' => $githubUrl, + 'profile' => null, + 'loading' => true, + ]) ?> + +
+

+ Form->button(__('Try again'), [ + 'type' => 'button', + 'class' => 'btn btn-ghost btn-sm mt-2', + '@click' => '$server.sync()', + 'x-show' => '$server.error && $server.error.recovery === "retry"', + ':disabled' => '$server.busy', + ]) ?> + Form->button(__('Reload page'), [ + 'type' => 'button', + 'class' => 'btn btn-ghost btn-sm mt-2', + '@click' => 'window.location.reload()', + 'x-show' => '$server.error && $server.error.recovery === "reload"', + ]) ?> +
+
diff --git a/templates/element/Namespaces/profile.php b/templates/element/Namespaces/profile.php new file mode 100644 index 00000000..01665f19 --- /dev/null +++ b/templates/element/Namespaces/profile.php @@ -0,0 +1,55 @@ + +
+
+ +
+

+

+
+
+ +
diff --git a/templates/element/Packages/package-row.php b/templates/element/Packages/package-row.php index 9736bd73..6505c265 100644 --- a/templates/element/Packages/package-row.php +++ b/templates/element/Packages/package-row.php @@ -11,8 +11,12 @@ $summaries[] = $label . ' ' . implode(', ', $majors); } } -$detailsUrl = $this->Url->build(['_name' => 'plugin:view', 'id' => $package->id]); -$packageNamespace = explode('/', $package->package, 2)[0]; +[$packageNamespace, $packageName] = explode('/', $package->package, 2); +$detailsUrl = $this->Url->build([ + '_name' => 'plugin:view', + 'namespace' => $packageNamespace, + 'name' => $packageName, +]); ?>
diff --git a/templates/island/featured_plugins.php b/templates/island/featured_plugins.php index cb11bbf4..7e4d67ca 100644 --- a/templates/island/featured_plugins.php +++ b/templates/island/featured_plugins.php @@ -21,7 +21,11 @@ $tone = ['rose', 'violet', 'amber'][hexdec(substr(hash('sha256', $package->package), 0, 4)) % 3]; ?>