diff --git a/config/Migrations/20261113120000_AddPackageNamespace.php b/config/Migrations/20261113120000_AddPackageNamespace.php new file mode 100644 index 00000000..a527f19b --- /dev/null +++ b/config/Migrations/20261113120000_AddPackageNamespace.php @@ -0,0 +1,41 @@ +table('packages') + ->addColumn('namespace', 'string', [ + 'limit' => 255, + 'null' => true, + ]) + ->update(); + + $this->execute("UPDATE packages SET namespace = LOWER(SUBSTR(package, 1, INSTR(package, '/') - 1))"); + + $this->table('packages') + ->changeColumn('namespace', 'string', [ + 'limit' => 255, + 'null' => false, + ]) + ->addIndex(['namespace']) + ->update(); + } + + /** + * The full Composer name remains available after rollback. + */ + public function down(): void + { + $this->table('packages') + ->removeIndex(['namespace']) + ->removeColumn('namespace') + ->update(); + } +} diff --git a/config/Migrations/20261113160000_AddMonthlyPackageDownloads.php b/config/Migrations/20261113160000_AddMonthlyPackageDownloads.php new file mode 100644 index 00000000..293442da --- /dev/null +++ b/config/Migrations/20261113160000_AddMonthlyPackageDownloads.php @@ -0,0 +1,20 @@ +table('packages') + ->addColumn('monthly_downloads', 'integer', [ + 'null' => true, + 'default' => null, + ]) + ->update(); + } +} diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock index 16238958..320af07a 100644 Binary files a/config/Migrations/schema-dump-default.lock and b/config/Migrations/schema-dump-default.lock differ diff --git a/config/app.php b/config/app.php index 29a1d6f6..dd9b52ee 100644 --- a/config/app.php +++ b/config/app.php @@ -108,6 +108,7 @@ 'GitHub' => [ 'applicationId' => env('AUTH_ID_GITHUB'), 'applicationSecret' => env('AUTH_SECRET_GITHUB'), + 'apiToken' => env('GITHUB_API_TOKEN'), ], 'Packages' => [ @@ -157,6 +158,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 996e4414..48b40ca8 100644 --- a/config/routes.php +++ b/config/routes.php @@ -58,9 +58,34 @@ * its action called 'display', and we pass a param to select the view file * to use (in this case, templates/Pages/home.php)... */ - $builder->connect('/', ['controller' => 'Packages', 'action' => 'index']); - $builder->get('/plugins/{id}', ['controller' => 'Packages', 'action' => 'view'], 'plugin:view') - ->setPatterns(['id' => '[0-9]+'])->setPass(['id']); + $builder->connect('/', [ + 'controller' => 'Packages', + 'action' => 'index', + ], ['_name' => 'plugin:index']); + $builder->get('/publishers', [ + 'controller' => 'Publishers', + 'action' => 'index', + ], 'publisher:index'); + $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' => $packageSegment, + ]) + ->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..1eaf2064 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -174,9 +174,106 @@ 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-lg lg:w-[30rem]; +} +.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; } +.publisher-controls { + @apply mb-5 flex flex-col gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 md:flex-row md:items-center md:p-5; +} +.publisher-row { + @apply grid grid-cols-1 items-center gap-4 border-b border-base-300 p-4 + hover:bg-row-hover/40 md:grid-cols-[minmax(0,1fr)_19rem] md:p-5; +} +.publisher-rank { + @apply absolute -right-2 -bottom-1 z-10 min-h-6 min-w-7 rounded-full border-2 border-base-100 + bg-base-content px-1.5 text-xs leading-none font-semibold text-base-100 tabular-nums; +} +.publisher-avatar { + @apply relative size-14 overflow-hidden rounded-xl bg-primary/10 text-lg font-semibold text-primary md:size-16; + & img { @apply absolute inset-0 size-full object-cover; } +} +.publisher-name { + @apply text-lg leading-tight font-semibold wrap-anywhere; + & a { @apply text-base-content hover:text-primary; } +} +.publisher-plugin { @apply h-auto max-w-full px-2 py-1 text-xs whitespace-normal text-muted wrap-anywhere hover:text-primary; } +.publisher-statistics { @apply grid grid-cols-3 gap-x-4 md:gap-x-6; } +.publisher-statistics > div { @apply row-span-2 grid grid-rows-subgrid; } .reset-filters { @apply inline-flex min-h-10 shrink-0 items-center justify-center gap-1.5 text-[0.8125rem] font-medium whitespace-nowrap max-md:w-10; @@ -466,7 +563,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 103a7f8a..80250c6b 100644 --- a/src/Application.php +++ b/src/Application.php @@ -17,16 +17,13 @@ namespace App; use ADmad\SocialAuth\Middleware\SocialAuthMiddleware; -use App\Command\SyncPackagesCommand; use App\Event\AfterGithubIdentify; use App\Http\RateLimit\AtomicFixedWindowRateLimiter; 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\PackagistImporter; +use App\Service\GithubApiService; use Authentication\AuthenticationService; use Authentication\AuthenticationServiceInterface; use Authentication\AuthenticationServiceProviderInterface; @@ -43,7 +40,6 @@ use Cake\Datasource\FactoryLocator; use Cake\Datasource\RepositoryInterface; use Cake\Error\Middleware\ErrorHandlerMiddleware; -use Cake\Event\EventManager; use Cake\Http\BaseApplication; use Cake\Http\Client; use Cake\Http\Client\ClientEvent; @@ -55,6 +51,7 @@ use Cake\ORM\Locator\TableLocator; use Cake\Routing\Middleware\AssetMiddleware; use Cake\Routing\Middleware\RoutingMiddleware; +use League\Container\ReflectionContainer; use Packagist\Api\Client as PackagistClient; use Psr\Http\Message\ServerRequestInterface; use SocialConnect\OAuth2\Provider\GitHub; @@ -83,8 +80,16 @@ public function bootstrap(): void new TableLocator()->allowFallbackClass(false), ); } + } - EventManager::instance()->on(new AfterGithubIdentify()); + /** + * Resolve authentication event listeners through CakePHP's container. + * + * @return list> + */ + public function eventListeners(): array + { + return [AfterGithubIdentify::class]; } /** @@ -203,7 +208,7 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue } return !in_array($request->getMethod(), ['GET', 'HEAD'], true) - || !($path === '' || $path === '/packages' || preg_match('#^/plugins/[0-9]+$#', $path)); + || !in_array($path, ['', '/packages', '/publishers'], true) && !preg_match('#^/plugins/[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$#', $path); }, ]), ); @@ -221,9 +226,14 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue */ public function services(ContainerInterface $container): void { + // Interact constructs fresh island roots separately from cached service dependencies. + $container->delegate(new ReflectionContainer(cacheResolutions: !Configure::read('debug'))); $container->addShared(PackagesTable::class, static fn(): RepositoryInterface => FactoryLocator::get('Table')->get(PackagesTable::class)); $container->addShared(Client::class); - $container->addShared(AwesomeListParser::class); + $container->addShared(GithubApiService::class)->addArguments([ + Client::class, + (string)Configure::read('GitHub.apiToken', ''), + ]); $container->addShared('packagist.http', static function (): Client { $client = new Client(); $userAgent = (string)env('PACKAGIST_USER_AGENT', 'plugins.cakephp.org (https://plugins.cakephp.org; mailto=security@cakephp.org)'); @@ -235,9 +245,6 @@ public function services(ContainerInterface $container): void return $client; }); $container->addShared(PackagistClient::class)->addArgument('packagist.http'); - $container->addShared(PackagistImporter::class)->addArguments([PackagesTable::class, PackagistClient::class]); - $container->addShared(AwesomeListImporter::class)->addArguments([PackagesTable::class, Client::class, AwesomeListParser::class]); - $container->add(SyncPackagesCommand::class)->addArguments([PackagistImporter::class, AwesomeListImporter::class]); } /** diff --git a/src/Controller/PackagesController.php b/src/Controller/PackagesController.php index aa0cd245..3faffea3 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,12 +28,45 @@ 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); + $packages = $this->fetchTable(PackagesTable::class); + $stats = $packages->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); + } + $githubUrl = $packages->namespaceGithubUrl($namespace); + $backUrl = $this->getRequest()->referer(); + if ($backUrl === null || parse_url($backUrl, PHP_URL_PATH) === $this->getRequest()->getPath()) { + $backUrl = ['_name' => 'plugin:index']; + } + $this->set(compact('namespace', 'stats', 'githubUrl', 'backUrl')); + + return null; + } + /** * 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/Controller/PublishersController.php b/src/Controller/PublishersController.php new file mode 100644 index 00000000..fb3424cc --- /dev/null +++ b/src/Controller/PublishersController.php @@ -0,0 +1,27 @@ +Search->setConfig('actions', [], false); + $this->Authentication->allowUnauthenticated(['index']); + } + + /** + * Render the directory island in the shared application layout. + */ + public function index(): void + { + } +} diff --git a/src/Event/AfterGithubIdentify.php b/src/Event/AfterGithubIdentify.php index 6a45add5..0bfb9272 100644 --- a/src/Event/AfterGithubIdentify.php +++ b/src/Event/AfterGithubIdentify.php @@ -6,15 +6,23 @@ use ADmad\SocialAuth\Middleware\SocialAuthMiddleware; use App\Model\Entity\User; use App\Model\Enum\UserRole; +use App\Model\Table\UsersTable; +use App\Service\GithubApiService; use Cake\Event\EventInterface; use Cake\Event\EventListenerInterface; -use Cake\Http\Client; use Cake\ORM\Locator\LocatorAwareTrait; class AfterGithubIdentify implements EventListenerInterface { use LocatorAwareTrait; + /** + * Resolve the shared GitHub client through the application container. + */ + public function __construct(private readonly GithubApiService $github) + { + } + /** * Returns a list of events this object is interested in. * @@ -41,22 +49,14 @@ public function afterIdentify(EventInterface $event, User $user): void return; } - $http = new Client([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $token, - 'Accept' => 'application/vnd.github+json', - 'User-Agent' => 'plugins.cakephp.org', - ], - ]); - - $response = $http->get('https://api.github.com/user/memberships/orgs/cakephp'); + $response = $this->github->get('/user/memberships/orgs/cakephp', token: $token); if (!$response->isOk() && $response->getStatusCode() !== 404) { return; } $isCakePHPDev = $response->isOk() && ($response->getJson()['state'] ?? null) === 'active'; - $usersTable = $this->getTableLocator()->get('Users'); + $usersTable = $this->fetchTable(UsersTable::class); // Organization membership is authoritative, even for the last administrator. $user->set('is_cakephp_dev', $isCakePHPDev); $user->set('role', $isCakePHPDev ? UserRole::Admin : UserRole::User); diff --git a/src/Island/CatalogueIsland.php b/src/Island/CatalogueIsland.php index 77910955..56140268 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,12 @@ public function viewData(): array 'phpTags' => $options['php'], 'catalogueQuery' => $query, 'categoryOptions' => $categoryOptions, + 'catalogueRoute' => $this->namespace !== null ? [ + '_name' => 'plugin:namespace', + 'namespace' => $this->namespace, + ] : [ + '_name' => 'plugin:index', + ], ]; } } 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/Island/PublishersIsland.php b/src/Island/PublishersIsland.php new file mode 100644 index 00000000..0ec30abf --- /dev/null +++ b/src/Island/PublishersIsland.php @@ -0,0 +1,131 @@ + 20, + 'maxLimit' => 20, + 'sortableFields' => [], + ], + url: ['page' => 'page'], + history: true, + )] + public PaginationState $paging; + + /** + * Only the pure avatar URL builder is needed; profile lookups remain on profile pages. + */ + public function __construct(private readonly GithubProfileService $profiles) + { + } + + /** + * Preserve explicit history paging while resetting pages after filter changes. + */ + public function updated(UpdatedPaths $paths): void + { + if (($paths->has('search') || $paths->has('sort') || $paths->has('cakephp')) && !$paths->has('paging.page')) { + $this->paging->page = 1; + } + } + + /** + * Retain the selected popularity metric when clearing a search. + */ + #[Action] + public function clearSearch(): void + { + $this->search = ''; + $this->paging->page = 1; + } + + /** + * Aggregate once, then batch-load details for the visible page. + * + * @return array + */ + public function viewData(): array + { + if (mb_strlen($this->search) > 100) { + throw new BadRequestException('Search query is too long.'); + } + $sortOptions = [ + 'monthly_downloads' => __('Monthly downloads'), + 'downloads' => __('Lifetime downloads'), + 'stars' => __('Stars'), + 'plugins' => __('Plugin count'), + ]; + if (!isset($sortOptions[$this->sort])) { + $this->sort = 'monthly_downloads'; + } + $packages = $this->fetchTable(PackagesTable::class); + $majors = $packages->publisherMajors(); + $current = $majors[0] ?? null; + $versionOptions = []; + if ($current !== null) { + $versionOptions['current'] = __('CakePHP {0} (current)', $current); + foreach ($majors as $major) { + if ($major !== $current) { + $versionOptions[$major] = __('CakePHP {0}', $major); + } + } + } + $versionOptions['all'] = __('All versions'); + if (!isset($versionOptions[$this->cakephp])) { + $this->cakephp = $current !== null ? 'current' : 'all'; + } + $major = match ($this->cakephp) { + 'all' => null, + 'current' => $current, + default => $this->cakephp, + }; + $publishers = $this->paginate($packages->unhydratedFind('publishers', search: $this->search, sort: $this->sort, major: $major)); + $namespaces = []; + foreach ($publishers as $publisher) { + $namespaces[] = $publisher['namespace']; + } + $details = $packages->publisherDetails($namespaces, $this->sort, $major); + foreach ($details as &$detail) { + $detail['avatarUrl'] = $detail['githubUrl'] !== null + ? $this->profiles->avatarUrl(basename($detail['githubUrl']), 96) + : null; + } + unset($detail); + + return [ + 'publishers' => $publishers, + 'details' => $details, + 'sortOptions' => $sortOptions, + 'versionOptions' => $versionOptions, + 'selectedMajor' => $major, + 'showRanks' => trim($this->search) === '', + 'rankStart' => ($this->paging->page - 1) * $this->paging->limit + 1, + ]; + } +} diff --git a/src/Model/Entity/Package.php b/src/Model/Entity/Package.php index e2a4a8d8..9e69b234 100644 --- a/src/Model/Entity/Package.php +++ b/src/Model/Entity/Package.php @@ -12,9 +12,11 @@ * * @property int $id * @property string $package + * @property string $namespace * @property string $description * @property string $repo_url * @property int $downloads + * @property int|null $monthly_downloads * @property int $stars * @property string|null $latest_stable_version * @property \Cake\I18n\Date|null $latest_stable_release_date @@ -42,6 +44,7 @@ class Package extends Entity #[Override] protected array $_accessible = [ 'id' => false, + 'namespace' => false, '*' => true, ]; diff --git a/src/Model/Table/PackagesTable.php b/src/Model/Table/PackagesTable.php index 77924cdc..606fd1a6 100644 --- a/src/Model/Table/PackagesTable.php +++ b/src/Model/Table/PackagesTable.php @@ -4,8 +4,12 @@ namespace App\Model\Table; use App\Model\Filter\PackagesCollection; +use ArrayObject; use Cake\Cache\Cache; +use Cake\Database\Expression\FunctionExpression; use Cake\Database\Expression\QueryExpression; +use Cake\Datasource\EntityInterface; +use Cake\Event\EventInterface; use Cake\ORM\Query\SelectQuery; use Cake\ORM\Table; use Cake\Validation\Validator; @@ -87,6 +91,10 @@ public function validationDefault(Validator $validator): Validator ->requirePresence('downloads', 'create') ->notEmptyString('downloads'); + $validator + ->nonNegativeInteger('monthly_downloads') + ->allowEmptyString('monthly_downloads'); + $validator ->integer('stars') ->requirePresence('stars', 'create') @@ -105,6 +113,20 @@ public function validationDefault(Validator $validator): Validator return $validator; } + /** + * Derive the indexed vendor whenever an import creates or renames a package. + * + * @param \Cake\Event\EventInterface $event Save event. + * @param \Cake\Datasource\EntityInterface $entity Package being saved. + * @param \ArrayObject $options Save options. + */ + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + if ($entity->isNew() || $entity->isDirty('package')) { + $entity->set('namespace', strtolower(explode('/', (string)$entity->get('package'), 2)[0])); + } + } + /** * Build the public catalogue with its version associations. * @@ -112,8 +134,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 +156,214 @@ public function findCatalogue(SelectQuery $query, array $filters): SelectQuery return $query; } + /** + * Match the indexed Composer namespace exactly. + * + * @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 + { + return $query->where([ + $this->aliasField('namespace') => $namespace, + ]); + } + + /** + * Rank Composer namespaces from packages supporting the selected CakePHP major. + * + * @param \Cake\ORM\Query\SelectQuery $query Package query. + * @param string $search Case-insensitive namespace substring. + * @param string $sort Popularity metric. + * @param string|null $major CakePHP major; null includes all versions. + * @return \Cake\ORM\Query\SelectQuery + */ + public function findPublishers(SelectQuery $query, string $search = '', string $sort = 'monthly_downloads', ?string $major = null): SelectQuery + { + $query->select([ + 'namespace' => $this->aliasField('namespace'), + 'plugins' => $query->func()->count('*'), + 'downloads' => $query->func()->sum($this->aliasField('downloads')), + 'monthly_downloads' => $query->expr()->case() + ->when($query->expr()->eq($query->func()->count($this->aliasField('monthly_downloads')), $query->func()->count('*'))) + ->then($query->func()->sum($this->aliasField('monthly_downloads'))), + 'stars' => $query->func()->sum($this->aliasField('stars')), + 'latest_release' => $query->func()->max($this->aliasField('latest_stable_release_date'))->setReturnType('string'), + ])->where([ + $this->aliasField('namespace') . ' !=' => '', + ])->groupBy($this->aliasField('namespace')); + + if ($major !== null) { + $query->where($query->expr()->exists($this->publisherCompatibility($major))); + } + + $search = mb_strtolower(trim($search)); + if ($search !== '') { + $query->where($query->expr()->gt(new FunctionExpression('INSTR', [ + $this->aliasField('namespace') => 'identifier', + $search, + ]), 0, 'integer')); + } + $sort = match ($sort) { + 'downloads', 'stars', 'plugins' => $sort, + default => 'monthly_downloads', + }; + + return $query->orderBy([ + $sort => 'DESC', + $this->aliasField('namespace') => 'ASC', + ]); + } + + /** + * Batch profile owners and two popular plugins for the visible publishers. + * + * @param list $namespaces Visible namespace names. + * @param string $sort Popularity metric; plugin-count rankings preview monthly downloads. + * @param string|null $major CakePHP major; null includes all versions. + * @return array}> + */ + public function publisherDetails(array $namespaces, string $sort = 'monthly_downloads', ?string $major = null): array + { + if ($namespaces === []) { + return []; + } + $details = array_fill_keys($namespaces, ['githubUrl' => null, 'plugins' => []]); + $owners = []; + $metric = match ($sort) { + 'stars', 'downloads' => $sort, + default => 'monthly_downloads', + }; + $packages = $this->find()->select(['namespace', 'package', 'repo_url']) + ->where(['namespace IN' => $namespaces]) + ->orderBy([ + $this->aliasField($metric) => 'DESC', + $this->aliasField('package') => 'ASC', + ]); + if ($major !== null) { + $packages->select(['publisher_compatible' => $packages->expr()->exists($this->publisherCompatibility($major))]); + } + foreach ($packages->all() as $package) { + $namespace = $package->namespace; + $owner = $this->githubOwner($package->repo_url); + if ($owner !== null) { + $owners[$namespace][$owner] = true; + } + if (($major === null || $package->get('publisher_compatible')) && count($details[$namespace]['plugins']) < 2) { + $details[$namespace]['plugins'][] = [ + 'package' => $package->package, + 'name' => explode('/', $package->package, 2)[1], + ]; + } + } + foreach ($owners as $namespace => $namespaceOwners) { + if (count($namespaceOwners) === 1) { + $details[$namespace]['githubUrl'] = 'https://github.com/' . array_key_first($namespaceOwners); + } + } + + return $details; + } + + /** + * EXISTS counts a compatible package once even when several minor-version tags match. + * + * @return \Cake\ORM\Query\SelectQuery + */ + private function publisherCompatibility(string $major): SelectQuery + { + $junction = $this->Tagged->getTarget(); + $alias = $junction->getAlias(); + + return $junction->find()->select([$alias . '.fk_id']) + ->innerJoin(['PublisherVersion' => $this->Tags->getTarget()->getTable()], ['PublisherVersion.id = ' . $alias . '.tag_id']) + ->where([ + $alias . '.fk_model' => $this->getAlias(), + 'PublisherVersion.slug LIKE' => 'cakephp-' . $major . '-%', + ]) + ->where(static fn(QueryExpression $exp) => $exp->equalFields($alias . '.fk_id', 'Packages.id')); + } + + /** + * Derive supported major choices from the catalogue's existing compatibility metadata. + * + * @return list + */ + public function publisherMajors(): array + { + $majors = []; + foreach (array_keys($this->filterOptions()['cakephp']) as $slug) { + if (preg_match('/^cakephp-(\d+)-\d+$/D', $slug, $matches)) { + $major = $matches[1]; + if (!in_array($major, $majors, true)) { + $majors[] = $major; + } + } + } + + return $majors; + } + + /** + * 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), + ]; + } + + /** + * 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) { + $owner = $this->githubOwner($package->repo_url); + if ($owner !== null) { + $owners[$owner] = true; + } + if (count($owners) > 1) { + return null; + } + } + + return $owners !== [] ? 'https://github.com/' . array_key_first($owners) : null; + } + + /** + * Resolve repository owners consistently for profile pages and publisher rows. + */ + private function githubOwner(string $url): ?string + { + $url = preg_replace('#^git@github\.com:#i', 'https://github.com/', trim($url)) ?? $url; + $host = strtolower((string)parse_url($url, PHP_URL_HOST)); + $parts = explode('/', trim((string)parse_url($url, PHP_URL_PATH), '/')); + if ( + !in_array($host, ['github.com', 'www.github.com'], true) || + count($parts) < 2 || $parts[1] === '' || + !preg_match('/^[a-z0-9][a-z0-9-]*$/iD', $parts[0]) + ) { + return null; + } + + return strtolower($parts[0]); + } + /** * Load configured featured packages without changing their display order. * @@ -158,15 +391,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/src/Service/GithubApiService.php b/src/Service/GithubApiService.php new file mode 100644 index 00000000..62b15004 --- /dev/null +++ b/src/Service/GithubApiService.php @@ -0,0 +1,49 @@ + $query Query parameters. + * @param string|null $token OAuth token for this request only. + */ + public function get(string $path, array $query = [], ?string $token = null): Response + { + if (!str_starts_with($path, '/') || str_starts_with($path, '//')) { + throw new InvalidArgumentException('GitHub API requests require a relative path starting with one slash.'); + } + $headers = [ + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'plugins.cakephp.org', + 'X-GitHub-Api-Version' => '2022-11-28', + ]; + $token ??= $this->token; + if ($token !== '') { + $headers['Authorization'] = 'Bearer ' . $token; + } + + return $this->client->get('https://api.github.com' . $path, $query, [ + 'timeout' => 3, + 'headers' => $headers, + ]); + } +} diff --git a/src/Service/GithubProfileService.php b/src/Service/GithubProfileService.php new file mode 100644 index 00000000..41642360 --- /dev/null +++ b/src/Service/GithubProfileService.php @@ -0,0 +1,83 @@ +avatarUrl($owner); + if ($avatarUrl === null) { + return null; + } + $owner = strtolower($owner); + $fallback = [ + 'avatarUrl' => $avatarUrl, + 'bio' => null, + ]; + $key = 'github_profile_v2_' . $owner; + $cache = Cache::pool('github_profiles'); + $cached = $cache->get($key); + if (is_array($cached)) { + return $cached !== [] ? [ + 'avatarUrl' => $cached['avatarUrl'] ?? $fallback['avatarUrl'], + 'bio' => $cached['bio'] ?? null, + ] : $fallback; + } + + $profile = null; + try { + $response = $this->github->get('/users/' . rawurlencode($owner)); + $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 = $fallback['avatarUrl']; + } + $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 ?? $fallback; + } +} diff --git a/src/Service/PackagistImporter.php b/src/Service/PackagistImporter.php index d0c7cf8d..52045a37 100644 --- a/src/Service/PackagistImporter.php +++ b/src/Service/PackagistImporter.php @@ -106,7 +106,7 @@ public function sync(?callable $progress = null): array } /** - * @return array{package: string, description: string, repo_url: string, downloads: int, stars: int, tag_list: array, latest_stable_version: ?string, latest_stable_release_date: ?\Cake\I18n\Date, is_abandoned: bool} + * @return array{package: string, description: string, repo_url: string, downloads: int, monthly_downloads: int, stars: int, tag_list: array, latest_stable_version: ?string, latest_stable_release_date: ?\Cake\I18n\Date, is_abandoned: bool} */ private function getDataForPackage(string $packageName): array { @@ -119,6 +119,7 @@ private function getDataForPackage(string $packageName): array 'description' => $metaDetails->getDescription(), 'repo_url' => $metaDetails->getRepository(), 'downloads' => $metaDetails->getDownloads()->getTotal(), + 'monthly_downloads' => $metaDetails->getDownloads()->getMonthly(), 'stars' => $metaDetails->getGithubStars(), 'tag_list' => [], 'latest_stable_version' => null, @@ -166,6 +167,7 @@ private function getDataForPackage(string $packageName): array 'description' => $metaDetails->getDescription(), 'repo_url' => $metaDetails->getRepository(), 'downloads' => $metaDetails->getDownloads()->getTotal(), + 'monthly_downloads' => $metaDetails->getDownloads()->getMonthly(), 'stars' => $metaDetails->getGithubStars(), 'tag_list' => $meta, 'latest_stable_version' => $latestStable ? $latestStable->getVersion() : null, 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 new file mode 100644 index 00000000..f394b587 --- /dev/null +++ b/templates/Packages/by_namespace.php @@ -0,0 +1,57 @@ +assign('title', __('Plugins by {0}', $namespace)); +$statLabels = [ + 'plugins' => __('Plugins'), + 'downloads' => __('Downloads'), + 'stars' => __('Stars'), +]; +?> +
+
+
+ Html->link('← ' . __('Back'), $backUrl, [ + 'class' => 'inline-flex min-h-8 items-center text-sm text-muted', + 'x-interact:navigate' => '', + ]) ?> +
+
+ + Interact->render( + 'NamespaceProfile', + props: compact('namespace', 'githubUrl'), + key: 'namespace-profile-' . $namespace, + loading: 'lazy', + placeholder: 'Loading/namespace_profile', + ) ?> + + element('Namespaces/profile', [ + 'namespace' => $namespace, + 'githubUrl' => null, + 'profile' => null, + 'loading' => false, + ]) ?> + +
+
+ $label) : ?> +
+
+
Number->compact($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..315723f3 100644 --- a/templates/Packages/view.php +++ b/templates/Packages/view.php @@ -4,10 +4,16 @@ * @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/Publishers/index.php b/templates/Publishers/index.php new file mode 100644 index 00000000..60a6baf9 --- /dev/null +++ b/templates/Publishers/index.php @@ -0,0 +1,11 @@ +assign('title', __('Publishers')); +?> +
+
+

+

+
+
+Interact->render('Publishers', key: 'publishers-directory') ?> 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/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..6505c265 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) { @@ -10,7 +11,12 @@ $summaries[] = $label . ' ' . implode(', ', $majors); } } -$detailsUrl = $this->Url->build(['_name' => 'plugin:view', 'id' => $package->id]); +[$packageNamespace, $packageName] = explode('/', $package->package, 2); +$detailsUrl = $this->Url->build([ + '_name' => 'plugin:view', + 'namespace' => $packageNamespace, + 'name' => $packageName, +]); ?>
@@ -20,6 +26,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..9ee27689 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.')) ?>

@@ -60,7 +68,12 @@ @@ -101,7 +114,12 @@ label); ?> diff --git a/templates/element/navbar.php b/templates/element/navbar.php index 164e632a..b2b7dc2a 100644 --- a/templates/element/navbar.php +++ b/templates/element/navbar.php @@ -7,10 +7,24 @@ $minimal = $minimal ?? false; $requirements = $this->getRequest()->getPath() === '/requirements'; +$publishers = $this->getRequest()->getPath() === '/publishers'; $loggedIn = !$minimal && $this->Identity->isLoggedIn(); $links = [ - ['label' => __('Browse plugins'), 'url' => '/', 'active' => !$requirements], - ['label' => __('Listing requirements'), 'url' => '/requirements', 'active' => $requirements], + [ + 'label' => __('Browse plugins'), + 'url' => '/', + 'active' => !$requirements && !$publishers, + ], + [ + 'label' => __('Publishers'), + 'url' => ['_name' => 'publisher:index'], + 'active' => $publishers, + ], + [ + 'label' => __('Listing requirements'), + 'url' => '/requirements', + 'active' => $requirements, + ], ]; $loginUrl = [ 'prefix' => false, 'plugin' => 'ADmad/SocialAuth', 'controller' => 'Auth', 'action' => 'login', 'provider' => 'github', 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/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]; ?>