diff --git a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php index a41e1af..5dbbc34 100644 --- a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php +++ b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php @@ -14,6 +14,8 @@ class BuildPackageDownloadStatsAction public function handle(Package $package): PackageDownloadStatsData { $startDate = Carbon::now()->subDays(29)->startOfDay(); + $previousPeriodStart = Carbon::now()->subDays(59)->startOfDay(); + $previousPeriodEnd = Carbon::now()->subDays(30)->endOfDay(); $dailyCounts = $package->downloads() ->where('downloaded_at', '>=', $startDate) @@ -23,18 +25,27 @@ public function handle(Package $package): PackageDownloadStatsData ->pluck('downloads', 'date'); $dailyDownloads = []; + $currentPeriodDownloads = 0; for ($i = 29; $i >= 0; $i--) { $date = Carbon::now()->subDays($i)->format('Y-m-d'); + $count = (int) ($dailyCounts[$date] ?? 0); + $currentPeriodDownloads += $count; $dailyDownloads[] = new DailyDownloadData( date: $date, - downloads: (int) ($dailyCounts[$date] ?? 0), + downloads: $count, ); } + $previousPeriodDownloads = (int) $package->downloads() + ->whereBetween('downloaded_at', [$previousPeriodStart, $previousPeriodEnd]) + ->count(); + $versionDailyDownloads = $this->buildVersionDailyDownloads($package, $startDate); return new PackageDownloadStatsData( totalDownloads: $package->downloads()->count(), + currentPeriodDownloads: $currentPeriodDownloads, + previousPeriodDownloads: $previousPeriodDownloads, dailyDownloads: $dailyDownloads, versionDailyDownloads: $versionDailyDownloads, ); diff --git a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php index 945a10e..d53338a 100644 --- a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php +++ b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php @@ -16,6 +16,8 @@ class PackageDownloadStatsData extends Data */ public function __construct( public int $totalDownloads, + public int $currentPeriodDownloads, + public int $previousPeriodDownloads, #[TypeScriptType('array<'.DailyDownloadData::class.'>')] public array $dailyDownloads, #[TypeScriptType('array<'.VersionDailyDownloadData::class.'>')] diff --git a/app/Domains/Package/Contracts/Data/PackageVersionDetailData.php b/app/Domains/Package/Contracts/Data/PackageVersionDetailData.php index b134903..f425eae 100644 --- a/app/Domains/Package/Contracts/Data/PackageVersionDetailData.php +++ b/app/Domains/Package/Contracts/Data/PackageVersionDetailData.php @@ -5,7 +5,9 @@ use App\Domains\Repository\Contracts\Enums\GitProvider; use App\Domains\Security\Contracts\Data\SecurityAdvisoryMatchData; use App\Models\PackageVersion; +use App\Services\Markdown\MarkdownRenderer; use Carbon\CarbonInterface; +use Illuminate\Support\Facades\App; use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -39,6 +41,7 @@ public function __construct( public ?array $keywords, public bool $isStable, public bool $isDev, + public ?string $readmeHtml = null, /** @var SecurityAdvisoryMatchData[]|null */ public ?array $advisoryMatches = null, ) {} @@ -46,7 +49,8 @@ public function __construct( public static function fromModel( PackageVersion $version, ?GitProvider $provider = null, - ?string $repoIdentifier = null + ?string $repoIdentifier = null, + ?string $customBaseUrl = null, ): self { $base = PackageVersionData::fromModel($version, $provider, $repoIdentifier); $composerJson = $version->composer_json ?? []; @@ -65,6 +69,8 @@ public static function fromModel( ->all(); } + $readmeHtml = static::renderReadme($version, $provider, $repoIdentifier, $customBaseUrl); + return new self( uuid: $base->uuid, version: $base->version, @@ -85,10 +91,40 @@ public static function fromModel( keywords: ! empty($composerJson['keywords']) ? $composerJson['keywords'] : null, isStable: $base->isStable(), isDev: $base->isDev(), + readmeHtml: $readmeHtml, advisoryMatches: $advisoryMatchesData, ); } + protected static function renderReadme( + PackageVersion $version, + ?GitProvider $provider, + ?string $repoIdentifier, + ?string $customBaseUrl, + ): ?string { + if (empty($version->readme)) { + return null; + } + + $blobBaseUrl = null; + $rawFileBaseUrl = null; + + // Prefer the immutable commit SHA over the moving tag/branch name so README + // links keep pointing at the same content even if the tag is later moved. + $ref = $version->source_reference ?: $version->source_tag; + + if ($provider !== null && $repoIdentifier !== null && $ref !== null && $ref !== '') { + $blobBaseUrl = $provider->blobBaseUrl($repoIdentifier, $ref, $customBaseUrl); + $rawFileBaseUrl = $provider->rawFileBaseUrl($repoIdentifier, $ref, $customBaseUrl); + } + + return App::make(MarkdownRenderer::class)->render( + $version->readme, + $blobBaseUrl, + $rawFileBaseUrl, + ); + } + /** * Merge psr-4 and psr-0 autoload entries into a flat namespace → path map. * diff --git a/app/Domains/Package/Http/Controllers/PackageController.php b/app/Domains/Package/Http/Controllers/PackageController.php index e935edb..9e51db6 100644 --- a/app/Domains/Package/Http/Controllers/PackageController.php +++ b/app/Domains/Package/Http/Controllers/PackageController.php @@ -118,10 +118,23 @@ public function show(Request $request, Organization $organization, Package $pack $versionModel, $package->repository?->provider, $package->repository?->repo_identifier, + $package->repository?->custom_base_url, ); } } + $primaryVersionModel = $package->versions()->stable()->orderBySemanticVersion('desc')->first() + ?? $package->versions()->orderBySemanticVersion('desc')->first(); + + $primaryVersion = $primaryVersionModel + ? PackageVersionDetailData::fromModel( + $primaryVersionModel, + $package->repository?->provider, + $package->repository?->repo_identifier, + $package->repository?->custom_base_url, + ) + : null; + return Inertia::render('organizations/packages/show', [ 'organization' => OrganizationData::fromModel($organization), 'package' => PackageData::fromModel($package), @@ -134,6 +147,7 @@ public function show(Request $request, Organization $organization, Package $pack 'canManageVersions' => request()->user()?->can('deleteRepository', $organization) ?? false, 'canDeletePackage' => request()->user()?->can('deleteRepository', $organization) ?? false, 'activeVersion' => $activeVersion, + 'primaryVersion' => $primaryVersion, ]); } } diff --git a/app/Domains/Repository/Actions/FetchReadmeAction.php b/app/Domains/Repository/Actions/FetchReadmeAction.php new file mode 100644 index 0000000..65c5fca --- /dev/null +++ b/app/Domains/Repository/Actions/FetchReadmeAction.php @@ -0,0 +1,47 @@ +getFileContent($ref, $filename); + + if ($contents === null) { + continue; + } + + if (strlen($contents) > self::MAX_BYTES) { + Log::info('Skipped README that exceeds the size cap', [ + 'repository' => $provider->getRepositoryIdentifier(), + 'ref' => $ref, + 'filename' => $filename, + 'size_bytes' => strlen($contents), + ]); + + return null; + } + + return $contents; + } + + return null; + } +} diff --git a/app/Domains/Repository/Actions/SyncRefAction.php b/app/Domains/Repository/Actions/SyncRefAction.php index 218143a..2089cce 100644 --- a/app/Domains/Repository/Actions/SyncRefAction.php +++ b/app/Domains/Repository/Actions/SyncRefAction.php @@ -16,6 +16,7 @@ class SyncRefAction public function __construct( protected FindOrCreatePackageAction $findOrCreatePackage, protected CreateDistArchiveAction $createDistArchive, + protected FetchReadmeAction $fetchReadmeAction, ) {} /** @@ -57,7 +58,9 @@ public function handle( } } - $result = DB::transaction(function () use ($metadata, $ref, $package, $repository, $provider): array { + $readme = $this->fetchReadmeAction->handle($provider, $ref->name); + + $result = DB::transaction(function () use ($metadata, $ref, $package, $repository, $provider, $readme): array { if (! $package) { $package = $this->findOrCreatePackage->handle($repository, $metadata->name); } @@ -74,6 +77,7 @@ public function handle( $version->update([ 'normalized_version' => $metadata->normalizedVersion, 'composer_json' => $metadata->composerJson, + 'readme' => $readme, 'source_url' => $sourceUrl, 'source_reference' => $ref->commit, 'source_tag' => $ref->name, @@ -87,6 +91,7 @@ public function handle( 'version' => $metadata->version, 'normalized_version' => $metadata->normalizedVersion, 'composer_json' => $metadata->composerJson, + 'readme' => $readme, 'source_url' => $sourceUrl, 'source_reference' => $ref->commit, 'source_tag' => $ref->name, diff --git a/app/Domains/Repository/Commands/BackfillReadmesCommand.php b/app/Domains/Repository/Commands/BackfillReadmesCommand.php new file mode 100644 index 0000000..f1a72af --- /dev/null +++ b/app/Domains/Repository/Commands/BackfillReadmesCommand.php @@ -0,0 +1,83 @@ +option('repository'); + + $query = PackageVersion::query() + ->whereNull('readme') + ->whereHas('package.repository', function ($query) use ($repositoryFilter) { + if ($repositoryFilter) { + $query->where('uuid', $repositoryFilter); + } + }) + ->with(['package.repository']); + + $total = (clone $query)->count(); + + if ($total === 0) { + $this->info('No package versions need a README backfill.'); + + return self::SUCCESS; + } + + $this->info("Backfilling README for {$total} version(s)..."); + + $providers = []; + $fetched = 0; + $missing = 0; + + $query->chunkById((int) $this->option('chunk'), function ($versions) use ( + $fetchReadmeAction, + &$providers, + &$fetched, + &$missing, + ) { + foreach ($versions as $version) { + /** @var Repository $repository */ + $repository = $version->package->repository; + + $providers[$repository->uuid] ??= GitProviderFactory::make($repository); + + $ref = $version->source_tag ?: $version->source_reference; + + if (! $ref) { + $missing++; + + continue; + } + + $readme = $fetchReadmeAction->handle($providers[$repository->uuid], $ref); + + if ($readme === null) { + $missing++; + + continue; + } + + $version->update(['readme' => $readme]); + $fetched++; + } + }); + + $this->info("Done. Fetched: {$fetched}. Skipped (no README found): {$missing}."); + + return self::SUCCESS; + } +} diff --git a/app/Domains/Repository/Contracts/Enums/GitProvider.php b/app/Domains/Repository/Contracts/Enums/GitProvider.php index 0657a28..39c127f 100644 --- a/app/Domains/Repository/Contracts/Enums/GitProvider.php +++ b/app/Domains/Repository/Contracts/Enums/GitProvider.php @@ -62,6 +62,36 @@ public function webhookRouteName(): string }; } + /** + * Base URL (with trailing slash) for resolving relative in a repository file. + * + * Returns null for providers where the layout is unknown (generic Git). + */ + public function rawFileBaseUrl(string $repoIdentifier, string $ref, ?string $baseUrl = null): ?string + { + return match ($this) { + self::GitHub => "https://raw.githubusercontent.com/{$repoIdentifier}/{$ref}/", + self::GitLab => rtrim($baseUrl ?? 'https://gitlab.com', '/')."/{$repoIdentifier}/-/raw/{$ref}/", + self::Bitbucket => "https://bitbucket.org/{$repoIdentifier}/raw/{$ref}/", + self::Git => null, + }; + } + + /** + * Base URL (with trailing slash) for resolving relative in a repository file. + * + * Returns null for providers where the layout is unknown (generic Git). + */ + public function blobBaseUrl(string $repoIdentifier, string $ref, ?string $baseUrl = null): ?string + { + return match ($this) { + self::GitHub => "https://github.com/{$repoIdentifier}/blob/{$ref}/", + self::GitLab => rtrim($baseUrl ?? 'https://gitlab.com', '/')."/{$repoIdentifier}/-/blob/{$ref}/", + self::Bitbucket => "https://bitbucket.org/{$repoIdentifier}/src/{$ref}/", + self::Git => null, + }; + } + /** * @return array */ diff --git a/app/Models/PackageVersion.php b/app/Models/PackageVersion.php index 794b8af..d350de8 100644 --- a/app/Models/PackageVersion.php +++ b/app/Models/PackageVersion.php @@ -21,6 +21,7 @@ * @property string $version * @property string $normalized_version * @property array $composer_json + * @property string|null $readme * @property string|null $source_url * @property string|null $source_reference * @property string|null $source_tag @@ -46,6 +47,7 @@ * @method static Builder|PackageVersion whereDistUrl($value) * @method static Builder|PackageVersion whereNormalizedVersion($value) * @method static Builder|PackageVersion wherePackageUuid($value) + * @method static Builder|PackageVersion whereReadme($value) * @method static Builder|PackageVersion whereReleasedAt($value) * @method static Builder|PackageVersion whereSourceReference($value) * @method static Builder|PackageVersion whereSourceUrl($value) diff --git a/app/Services/Markdown/MarkdownRenderer.php b/app/Services/Markdown/MarkdownRenderer.php new file mode 100644 index 0000000..6f46430 --- /dev/null +++ b/app/Services/Markdown/MarkdownRenderer.php @@ -0,0 +1,82 @@ + and URLs + * to absolute Git-provider URLs so README links and images resolve correctly. + * + * Pass null base URLs (generic Git provider) to leave relative URLs untouched. + */ + public function render( + string $markdown, + ?string $blobBaseUrl = null, + ?string $rawFileBaseUrl = null, + ): string { + $environment = new Environment([ + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + 'renderer' => [ + 'soft_break' => "\n", + ], + ]); + + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new GithubFlavoredMarkdownExtension); + $environment->addExtension(new AutolinkExtension); + + if ($blobBaseUrl !== null || $rawFileBaseUrl !== null) { + $environment->addEventListener( + DocumentParsedEvent::class, + fn (DocumentParsedEvent $event) => $this->rewriteUrls( + $event->getDocument(), + $blobBaseUrl, + $rawFileBaseUrl, + ), + ); + } + + return (string) (new MarkdownConverter($environment))->convert($markdown); + } + + protected function rewriteUrls(Node $document, ?string $blobBaseUrl, ?string $rawFileBaseUrl): void + { + foreach ($document->iterator() as $node) { + if ($node instanceof Image && $rawFileBaseUrl !== null) { + $this->rewriteIfRelative($node, $rawFileBaseUrl); + } elseif ($node instanceof Link && $blobBaseUrl !== null) { + $this->rewriteIfRelative($node, $blobBaseUrl); + } + } + } + + protected function rewriteIfRelative(AbstractWebResource $node, string $baseUrl): void + { + $url = $node->getUrl(); + + if ($url === '' || $this->isAbsolute($url)) { + return; + } + + $node->setUrl(rtrim($baseUrl, '/').'/'.ltrim($url, '/')); + } + + protected function isAbsolute(string $url): bool + { + // Absolute URL (any scheme), protocol-relative URL, or in-page anchor. + return preg_match('~^(?:[a-z][a-z0-9+.\-]*:|//|#)~i', $url) === 1; + } +} diff --git a/composer.json b/composer.json index 286aaf6..302ec8e 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "laravel/socialite": "^5.24", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.9", + "league/commonmark": "^2.8", "league/flysystem-aws-s3-v3": "^3.0", "sentry/sentry-laravel": "^4.20", "socialiteproviders/gitlab": "^4.1", diff --git a/composer.lock b/composer.lock index 06c68b6..2d82b15 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "11690528d666f7634bf5973c88a9d8ba", + "content-hash": "6e6d4b3a092b3e6e320bd210acfe9a45", "packages": [ { "name": "aws/aws-crt-php", @@ -15217,5 +15217,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2026_05_17_000001_add_readme_to_package_versions.php b/database/migrations/2026_05_17_000001_add_readme_to_package_versions.php new file mode 100644 index 0000000..e47bd4f --- /dev/null +++ b/database/migrations/2026_05_17_000001_add_readme_to_package_versions.php @@ -0,0 +1,22 @@ +longText('readme')->nullable()->after('composer_json'); + }); + } + + public function down(): void + { + Schema::table('package_versions', function (Blueprint $table) { + $table->dropColumn('readme'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 05b3544..192af02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", "@rolldown/plugin-babel": "^0.2.2", + "@tailwindcss/typography": "^0.5.19", "@types/luxon": "^3.7.1", "@types/node": "^25.5.0", "babel-plugin-react-compiler": "^1.0.0", @@ -370,6 +371,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -386,6 +388,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -402,6 +405,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -418,6 +422,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -434,6 +439,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -450,6 +456,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -466,6 +473,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,6 +490,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,6 +507,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -514,6 +524,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -530,6 +541,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -546,6 +558,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -562,6 +575,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,6 +592,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -594,6 +609,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -610,6 +626,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,6 +643,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,6 +660,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,6 +677,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,6 +694,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -690,6 +711,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,6 +728,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -722,6 +745,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -738,6 +762,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -754,6 +779,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -770,6 +796,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5762,6 +5789,19 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -7195,6 +7235,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -10052,6 +10105,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11730,6 +11797,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 4a80ed3..2472fac 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "types": "tsc --noEmit" }, "devDependencies": { + "@babel/core": "^7.26.0", "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@rolldown/plugin-babel": "^0.2.2", + "@tailwindcss/typography": "^0.5.19", "@types/luxon": "^3.7.1", "@types/node": "^25.5.0", "babel-plugin-react-compiler": "^1.0.0", @@ -28,8 +31,6 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.7.2", "typescript-eslint": "^8.57.2", - "@babel/core": "^7.26.0", - "@rolldown/plugin-babel": "^0.2.2", "vitepress": "^2.0.0-alpha.17" }, "dependencies": { diff --git a/resources/css/app.css b/resources/css/app.css index d2b1c28..b44904d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,6 +2,8 @@ @import 'tw-animate-css'; +@plugin '@tailwindcss/typography'; + @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; diff --git a/resources/js/pages/organizations/packages/show.tsx b/resources/js/pages/organizations/packages/show.tsx index ed101c6..313d862 100644 --- a/resources/js/pages/organizations/packages/show.tsx +++ b/resources/js/pages/organizations/packages/show.tsx @@ -1,6 +1,7 @@ import { show } from '@/actions/App/Domains/Repository/Http/Controllers/RepositoryController'; import { CopyButton } from '@/components/copy-button'; import HeadingSmall from '@/components/heading-small'; +import { StatCard } from '@/components/stats/stat-card'; import { VersionDownloadChart } from '@/components/stats/version-download-chart'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -43,9 +44,12 @@ import { import { useDebounce } from '@/hooks/use-debounce'; import AppLayout from '@/layouts/app-layout'; import { createOrganizationBreadcrumb } from '@/lib/breadcrumbs'; -import { formatBytes } from '@/lib/utils'; +import { cn, formatBytes } from '@/lib/utils'; import { Head, Link, router, usePage } from '@inertiajs/react'; import { + Activity, + BarChart3, + BookOpen, Calendar, Check, ChevronRight, @@ -57,20 +61,47 @@ import { GitCommit, Globe, HardDrive, + Info, + Layers, Link2, Lock, Package as PackageIcon, + Scale, Search, ShieldAlert, Tag, Terminal, Trash2, + TrendingDown, + TrendingUp, Users, X, } from 'lucide-react'; import { DateTime } from 'luxon'; import { useEffect, useRef, useState } from 'react'; +type PackageTab = 'overview' | 'stats' | 'versions'; + +const PACKAGE_TABS: ReadonlyArray<{ + value: PackageTab; + label: string; + icon: typeof Info; +}> = [ + { value: 'overview', label: 'Overview', icon: Info }, + { value: 'stats', label: 'Stats', icon: BarChart3 }, + { value: 'versions', label: 'Versions', icon: Layers }, +]; + +function parseTabFromUrl(search: string): PackageTab { + const value = new URLSearchParams(search).get('tab'); + + if (value === 'versions' || value === 'stats') { + return value; + } + + return 'overview'; +} + type OrganizationData = App.Domains.Organization.Contracts.Data.OrganizationData; type PackageData = App.Domains.Package.Contracts.Data.PackageData; @@ -105,6 +136,7 @@ interface PackageShowProps { canManageVersions: boolean; canDeletePackage: boolean; activeVersion: PackageVersionDetailData | null; + primaryVersion: PackageVersionDetailData | null; } function CopyInstallButton({ text }: { text: string }) { @@ -156,6 +188,469 @@ function CopyInstallButton({ text }: { text: string }) { ); } +function PackageDetailsCard({ + version, +}: { + version: PackageVersionDetailData; +}) { + const phpRequirement = ( + version.require as unknown as Record | null + )?.php; + + const authors = + (version.authors as unknown as Array<{ + name?: string; + email?: string; + homepage?: string; + }> | null) ?? []; + const keywords = (version.keywords as unknown as string[] | null) ?? []; + + const facts: Array<{ icon: typeof Tag; label: string; value: string }> = []; + + facts.push({ + icon: Tag, + label: 'Latest version', + value: version.version, + }); + + if (version.type) { + facts.push({ icon: PackageIcon, label: 'Type', value: version.type }); + } + + if (version.license) { + facts.push({ icon: Scale, label: 'License', value: version.license }); + } + + if (phpRequirement) { + facts.push({ + icon: Terminal, + label: 'PHP requirement', + value: phpRequirement, + }); + } + + return ( + + + Package details + + + {version.description && ( +

{version.description}

+ )} + +
+ {facts.map((fact) => ( +
+ +
+
+ {fact.label} +
+
+ {fact.value} +
+
+
+ ))} +
+ + {authors.length > 0 && ( +
+
+ + Author{authors.length === 1 ? '' : 's'} +
+
+ {authors.map((author, index) => ( + + {author.name ?? author.email ?? 'Unknown'} + + ))} +
+
+ )} + + {keywords.length > 0 && ( +
+
+ Keywords +
+
+ {keywords.map((keyword) => ( + + {keyword} + + ))} +
+
+ )} +
+
+ ); +} + +function ReadmeSection({ version }: { version: PackageVersionDetailData }) { + if (!version.readmeHtml) { + return null; + } + + return ( + + + + + Readme + + from {version.version} + + + + +
+ + + ); +} + +function formatCompact(value: number): string { + if (!Number.isFinite(value)) { + return '0'; + } + + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); +} + +function StatsTabContent({ + downloadStats, +}: { + downloadStats: PackageDownloadStatsData; +}) { + const currentPeriod = downloadStats.currentPeriodDownloads; + const previousPeriod = downloadStats.previousPeriodDownloads; + + let trendLabel: string; + if (previousPeriod === 0) { + trendLabel = currentPeriod === 0 ? 'No prior data' : 'New activity'; + } else { + const change = + ((currentPeriod - previousPeriod) / previousPeriod) * 100; + const sign = change > 0 ? '+' : ''; + trendLabel = `${sign}${change.toFixed(1)}% vs previous 30 days`; + } + + const TrendIcon = + previousPeriod > 0 && currentPeriod < previousPeriod + ? TrendingDown + : TrendingUp; + + const dailyAverage = currentPeriod / 30; + + return ( +
+
+ + + +
+ + +
+ ); +} + +interface VersionsTabContentProps { + versions: PackageShowProps['versions']; + queryFilter: string; + typeFilter: string; + hasActiveFilters: boolean; + onQueryChange: (value: string) => void; + onTypeChange: (value: string) => void; + onClear: () => void; + onOpenVersion: (uuid: string) => void; + onPageChange: (page: number) => void; + packageName: string; +} + +function VersionsTabContent({ + versions, + queryFilter, + typeFilter, + hasActiveFilters, + onQueryChange, + onTypeChange, + onClear, + onOpenVersion, + onPageChange, + packageName, +}: VersionsTabContentProps) { + return ( +
+ + +
+
+ + onQueryChange(e.target.value)} + className="pl-9" + /> +
+ + {hasActiveFilters && ( + + )} +
+ + {versions.data.length === 0 ? ( + + + {hasActiveFilters + ? 'No versions match the current filters.' + : 'No versions available yet.'} + + + ) : ( + <> + + {versions.data.map((version, index) => ( +
onOpenVersion(version.uuid)} + className={`group/version flex w-full cursor-pointer items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-muted/50 ${index < versions.data.length - 1 ? 'border-b' : ''}`} + > +
+
+ + {version.version} + + {version.vulnerabilityCount > 0 && ( + + + {version.vulnerabilityCount}{' '} + vulnerabilit + {version.vulnerabilityCount === + 1 + ? 'y' + : 'ies'} + + )} + {version.distSize !== null && ( + + + + + {formatBytes( + version.distSize, + )} + + + + Mirror stored and served by + Pricore + + + )} +
+
+ {version.releasedAt && ( + + + {DateTime.fromISO( + version.releasedAt, + ).toRelative()} + · + {DateTime.fromISO( + version.releasedAt, + ).toLocaleString( + DateTime.DATETIME_MED, + )} + + )} + {version.sourceReference && + (version.commitUrl ? ( + + e.stopPropagation() + } + className="flex items-center gap-1 font-mono hover:text-foreground hover:underline" + > + + {version.sourceReference.substring( + 0, + 7, + )} + + ) : ( + + + {version.sourceReference.substring( + 0, + 7, + )} + + ))} + {version.tagUrl && ( + + e.stopPropagation() + } + className="flex items-center gap-1 hover:text-foreground hover:underline" + > + + {version.sourceTag} + + )} +
+
+ + +
+ ))} + + + {versions.last_page > 1 && ( +
+
+ Showing{' '} + {(versions.current_page - 1) * + versions.per_page + + 1}{' '} + to{' '} + {Math.min( + versions.current_page * versions.per_page, + versions.total, + )}{' '} + of {versions.total} versions +
+
+ {versions.links.map((link, index) => { + if ( + link.url === null || + link.label === '...' + ) { + return ( + + + + ); + } + + const pageNumber = new URL( + link.url, + ).searchParams.get('page'); + + return ( + + ); + })} +
+
+ )} + + )} +
+ ); +} + export default function PackageShow({ organization, package: pkg, @@ -165,6 +660,7 @@ export default function PackageShow({ canManageVersions, canDeletePackage, activeVersion, + primaryVersion, }: PackageShowProps) { const { auth } = usePage<{ auth: { organizations: OrganizationData[] }; @@ -173,11 +669,37 @@ export default function PackageShow({ const [queryFilter, setQueryFilter] = useState(filters.query); const [typeFilter, setTypeFilter] = useState(filters.type || 'all'); const [page, setPage] = useState(versions.current_page); + const [activeTab, setActiveTab] = useState(() => + typeof window === 'undefined' + ? 'overview' + : parseTabFromUrl(window.location.search), + ); const debouncedQuery = useDebounce(queryFilter, 300); const isInitialMount = useRef(true); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const params = new URLSearchParams(window.location.search); + + if (activeTab === 'overview') { + params.delete('tab'); + } else { + params.set('tab', activeTab); + } + + const query = params.toString(); + const next = `${window.location.pathname}${query ? `?${query}` : ''}`; + + if (next !== window.location.pathname + window.location.search) { + window.history.replaceState(null, '', next); + } + }, [activeTab]); + useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; @@ -195,6 +717,9 @@ export default function PackageShow({ if (page > 1) { params.page = String(page); } + if (activeTab !== 'overview') { + params.tab = activeTab; + } router.get( `/organizations/${organization.slug}/packages/${pkg.uuid}`, @@ -205,6 +730,11 @@ export default function PackageShow({ replace: true, }, ); + // activeTab is intentionally omitted: switching tabs is a client-only + // action and should not trigger an Inertia request. We include it in + // the params above so the URL stays in sync when a filter changes + // while the user is on a non-default tab. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedQuery, typeFilter, page, organization.slug, pkg.uuid]); const hasActiveFilters = queryFilter !== '' || typeFilter !== 'all'; @@ -262,8 +792,6 @@ export default function PackageShow({ }, ]; - const composerRepoCommand = `composer config repositories.${organization.slug} composer ${organization.composerRepositoryUrl}`; - return ( @@ -405,280 +933,67 @@ export default function PackageShow({
- - - Composer Configuration - - -

- Add this repository to your project to install - packages from this organization: -

-
- - {composerRepoCommand} - - -
-
-
+ - +
+ -
-
- - - handleQueryChange(e.target.value) - } - className="pl-9" - /> -
- - {hasActiveFilters && ( - - )} -
+
+ {activeTab === 'overview' && ( + <> + {primaryVersion && ( + + )} - {versions.data.length === 0 ? ( - - - {hasActiveFilters - ? 'No versions match the current filters.' - : 'No versions available yet.'} - - - ) : ( - <> - - {versions.data.map((version, index) => ( -
- openVersion(version.uuid) - } - className={`group/version flex w-full cursor-pointer items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-muted/50 ${index < versions.data.length - 1 ? 'border-b' : ''}`} - > -
-
- - {version.version} - - {version.vulnerabilityCount > - 0 && ( - - - { - version.vulnerabilityCount - }{' '} - vulnerabilit - {version.vulnerabilityCount === - 1 - ? 'y' - : 'ies'} - - )} - {version.distSize !== null && ( - - - - - {formatBytes( - version.distSize, - )} - - - - Mirror stored and - served by Pricore - - - )} -
-
- {version.releasedAt && ( - - - {DateTime.fromISO( - version.releasedAt, - ).toRelative()} - · - {DateTime.fromISO( - version.releasedAt, - ).toLocaleString( - DateTime.DATETIME_MED, - )} - - )} - {version.sourceReference && - (version.commitUrl ? ( - - e.stopPropagation() - } - className="flex items-center gap-1 font-mono hover:text-foreground hover:underline" - > - - {version.sourceReference.substring( - 0, - 7, - )} - - ) : ( - - - {version.sourceReference.substring( - 0, - 7, - )} - - ))} - {version.tagUrl && ( - - e.stopPropagation() - } - className="flex items-center gap-1 hover:text-foreground hover:underline" - > - - {version.sourceTag} - - )} -
-
- - -
- ))} -
- - {versions.last_page > 1 && ( -
-
- Showing{' '} - {(versions.current_page - 1) * - versions.per_page + - 1}{' '} - to{' '} - {Math.min( - versions.current_page * - versions.per_page, - versions.total, - )}{' '} - of {versions.total} versions -
-
- {versions.links.map((link, index) => { - if ( - link.url === null || - link.label === '...' - ) { - return ( - - - - ); - } + {primaryVersion?.readmeHtml && ( + + )} + + )} - const pageNumber = new URL( - link.url, - ).searchParams.get('page'); + {activeTab === 'stats' && ( + + )} - return ( - - ); - })} -
-
- )} - - )} + {activeTab === 'versions' && ( + + )} +
diff --git a/resources/js/pages/organizations/settings/tokens.tsx b/resources/js/pages/organizations/settings/tokens.tsx index c790e6c..7cd1494 100644 --- a/resources/js/pages/organizations/settings/tokens.tsx +++ b/resources/js/pages/organizations/settings/tokens.tsx @@ -2,6 +2,7 @@ import { destroy, store, } from '@/actions/App/Domains/Token/Http/Controllers/TokenController'; +import { CopyButton } from '@/components/copy-button'; import CreateTokenDialog from '@/components/create-token-dialog'; import InfoBox from '@/components/info-box'; import RevokeTokenDialog from '@/components/revoke-token-dialog'; @@ -67,6 +68,8 @@ export default function Tokens({ setRevokeDialogOpen(true); }; + const composerRepoCommand = `composer config repositories.${organization.slug} composer ${organization.composerRepositoryUrl}`; + return (
@@ -82,6 +85,22 @@ export default function Tokens({
+
+
+

Registry URL

+

+ Run this once per project to register{' '} + {organization.name} as a Composer repository. +

+
+
+ + {composerRepoCommand} + + +
+
+
diff --git a/resources/types/generated.d.ts b/resources/types/generated.d.ts index aa9a305..0018afb 100644 --- a/resources/types/generated.d.ts +++ b/resources/types/generated.d.ts @@ -152,6 +152,8 @@ mirrorUuid: string | null; }; export type PackageDownloadStatsData = { totalDownloads: number; +currentPeriodDownloads: number; +previousPeriodDownloads: number; dailyDownloads: Array; versionDailyDownloads: Array; }; @@ -189,6 +191,7 @@ authors: Array | null; keywords: Array | null; isStable: boolean; isDev: boolean; +readmeHtml: string | null; advisoryMatches: Array | null; }; export type VersionDailyDownloadData = { diff --git a/tests/Feature/Domains/Repository/FetchReadmeActionTest.php b/tests/Feature/Domains/Repository/FetchReadmeActionTest.php new file mode 100644 index 0000000..35ac2cd --- /dev/null +++ b/tests/Feature/Domains/Repository/FetchReadmeActionTest.php @@ -0,0 +1,48 @@ +shouldReceive('getFileContent') + ->with('main', 'README.md') + ->andReturn('# Hello'); + $provider->shouldNotReceive('getFileContent')->with('main', 'readme.md'); + + $result = (new FetchReadmeAction)->handle($provider, 'main'); + + expect($result)->toBe('# Hello'); +}); + +it('falls back to alternate filenames', function () { + $provider = Mockery::mock(GitProviderInterface::class); + $provider->shouldReceive('getFileContent')->with('main', 'README.md')->andReturn(null); + $provider->shouldReceive('getFileContent')->with('main', 'readme.md')->andReturn('lowercase'); + $provider->shouldReceive('getFileContent')->byDefault()->andReturn(null); + + $result = (new FetchReadmeAction)->handle($provider, 'main'); + + expect($result)->toBe('lowercase'); +}); + +it('returns null when no candidate filename exists', function () { + $provider = Mockery::mock(GitProviderInterface::class); + $provider->shouldReceive('getFileContent')->andReturn(null); + + $result = (new FetchReadmeAction)->handle($provider, 'main'); + + expect($result)->toBeNull(); +}); + +it('rejects READMEs above the size cap', function () { + $oversized = str_repeat('a', 513 * 1024); + + $provider = Mockery::mock(GitProviderInterface::class); + $provider->shouldReceive('getRepositoryIdentifier')->andReturn('vendor/pkg'); + $provider->shouldReceive('getFileContent')->with('main', 'README.md')->andReturn($oversized); + + $result = (new FetchReadmeAction)->handle($provider, 'main'); + + expect($result)->toBeNull(); +}); diff --git a/tests/Unit/MarkdownRendererTest.php b/tests/Unit/MarkdownRendererTest.php new file mode 100644 index 0000000..d5f31c7 --- /dev/null +++ b/tests/Unit/MarkdownRendererTest.php @@ -0,0 +1,88 @@ +renderer = new MarkdownRenderer; +}); + +it('renders headings, lists, and code blocks', function () { + $html = $this->renderer->render(<<<'MD' +# Heading + +- one +- two + +```php +echo 'hi'; +``` +MD); + + expect($html) + ->toContain('

Heading

') + ->toContain('
  • one
  • ') + ->toContain('
    ');
    +});
    +
    +it('renders GFM tables', function () {
    +    $html = $this->renderer->render(<<<'MD'
    +| A | B |
    +|---|---|
    +| 1 | 2 |
    +MD);
    +
    +    expect($html)
    +        ->toContain('')
    +        ->toContain('')
    +        ->toContain('');
    +});
    +
    +it('escapes inline HTML to prevent XSS', function () {
    +    $html = $this->renderer->render('Hello ');
    +
    +    expect($html)
    +        ->not->toContain('
    A1