Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions config/Migrations/20261113120000_AddPackageNamespace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class AddPackageNamespace extends BaseMigration
{
/**
* Backfill the Composer vendor before requiring it on future inserts.
*/
public function up(): void
{
$this->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();
}
}
20 changes: 20 additions & 0 deletions config/Migrations/20261113160000_AddMonthlyPackageDownloads.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class AddMonthlyPackageDownloads extends BaseMigration
{
/**
* Unknown counts remain distinct from packages with zero monthly downloads.
*/
public function change(): void
{
$this->table('packages')
->addColumn('monthly_downloads', 'integer', [
'null' => true,
'default' => null,
])
->update();
}
}
Binary file modified config/Migrations/schema-dump-default.lock
Binary file not shown.
9 changes: 9 additions & 0 deletions config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
'GitHub' => [
'applicationId' => env('AUTH_ID_GITHUB'),
'applicationSecret' => env('AUTH_SECRET_GITHUB'),
'apiToken' => env('GITHUB_API_TOKEN'),
],

'Packages' => [
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

/*
Expand Down
1 change: 1 addition & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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://',
},
Expand Down
99 changes: 99 additions & 0 deletions resources/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
32 changes: 32 additions & 0 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 18 additions & 11 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<class-string<\Cake\Event\EventListenerInterface>>
*/
public function eventListeners(): array
{
return [AfterGithubIdentify::class];
}

/**
Expand Down Expand Up @@ -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);
},
]),
);
Expand All @@ -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)');
Expand All @@ -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]);
}

/**
Expand Down
Loading
Loading