diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9ad7aaaf..2511316a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -345,6 +345,24 @@ jobs: - name: Build types package run: npm run build --workspace=@pr-pm/types + - name: Configure AWS credentials for S3 data + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Download S3 data for static build + run: | + echo "Downloading SEO data from S3..." + mkdir -p packages/webapp/public/seo-data + aws s3 sync s3://prpm-prod-packages/seo-data/ packages/webapp/public/seo-data/ \ + --exclude "*" \ + --include "packages.json" \ + --include "collections.json" + echo "✓ S3 data downloaded" + ls -lh packages/webapp/public/seo-data/ + - name: Build webapp with SSG run: | cd packages/webapp @@ -354,6 +372,7 @@ jobs: CI: false NEXT_PUBLIC_REGISTRY_URL: https://registry.prpm.dev REGISTRY_URL: https://registry.prpm.dev + NEXT_PUBLIC_S3_SEO_DATA_URL: https://prpm-prod-packages.s3.amazonaws.com/seo-data - name: Verify build output run: | diff --git a/packages/webapp/src/app/collections/[slug]/page.tsx b/packages/webapp/src/app/collections/[slug]/page.tsx index 866d121c..8d412313 100644 --- a/packages/webapp/src/app/collections/[slug]/page.tsx +++ b/packages/webapp/src/app/collections/[slug]/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import type { Collection } from '@pr-pm/types' const REGISTRY_URL = process.env.NEXT_PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL || 'https://registry.prpm.dev' +const S3_SEO_DATA_URL = process.env.NEXT_PUBLIC_S3_SEO_DATA_URL || 'https://prpm-prod-packages.s3.amazonaws.com/seo-data' // Allow dynamic rendering for params not in generateStaticParams export const dynamicParams = true @@ -20,68 +21,36 @@ export async function generateStaticParams() { } try { - const allCollections: string[] = [] - let offset = 0 - const limit = 100 - let hasMore = true - - console.log(`[SSG Collections] Starting - REGISTRY_URL: ${REGISTRY_URL}`) - console.log(`[SSG Collections] Environment check:`, { - NEXT_PUBLIC_REGISTRY_URL: process.env.NEXT_PUBLIC_REGISTRY_URL, - REGISTRY_URL: process.env.REGISTRY_URL, - NODE_ENV: process.env.NODE_ENV, - CI: process.env.CI + console.log(`[SSG Collections] Starting - S3_SEO_DATA_URL: ${S3_SEO_DATA_URL}`) + + // Fetch collection data from S3 (uploaded by Lambda) + const url = `${S3_SEO_DATA_URL}/collections.json` + console.log(`[SSG Collections] Fetching from S3: ${url}`) + + const res = await fetch(url, { + next: { revalidate: 3600 } // Revalidate every hour }) - // Paginate through all collections - while (hasMore) { - const url = `${REGISTRY_URL}/api/v1/search/seo/collections?limit=${limit}&offset=${offset}` - console.log(`[SSG Collections] Attempting fetch: ${url}`) - - try { - const res = await fetch(url, { - next: { revalidate: 3600 } // Revalidate every hour - }) - - console.log(`[SSG Collections] Response status: ${res.status} ${res.statusText}`) - - if (!res.ok) { - console.error(`[SSG Collections] HTTP ${res.status}: Failed to fetch collections`) - console.error(`[SSG Collections] Response headers:`, Object.fromEntries(res.headers.entries())) - break - } - - const data = await res.json() - console.log(`[SSG Collections] Received data with ${data.collections?.length || 0} collections`) - - if (!data.collections || !Array.isArray(data.collections)) { - console.error('[SSG Collections] Invalid response format:', data) - break - } - - allCollections.push(...data.collections) - hasMore = data.hasMore - offset += limit - - console.log(`[SSG Collections] Progress: ${allCollections.length} collections fetched`) - } catch (fetchError) { - console.error('[SSG Collections] Fetch error:', fetchError) - console.error('[SSG Collections] Error details:', { - message: fetchError instanceof Error ? fetchError.message : String(fetchError), - stack: fetchError instanceof Error ? fetchError.stack : undefined - }) - break - } + if (!res.ok) { + console.error(`[SSG Collections] HTTP ${res.status}: Failed to fetch collections from S3`) + console.error(`[SSG Collections] Response headers:`, Object.fromEntries(res.headers.entries())) + return [] } - console.log(`[SSG Collections] ✅ Complete: ${allCollections.length} collections for static generation`) + const collections = await res.json() + console.log(`[SSG Collections] Received ${collections.length} collections from S3`) - // ALWAYS return an array, even if empty - const params = allCollections.map((slug) => ({ - slug: encodeURIComponent(slug), + if (!Array.isArray(collections)) { + console.error('[SSG Collections] Invalid response format - expected array') + return [] + } + + // Map to slug params + const params = collections.map((collection: any) => ({ + slug: encodeURIComponent(collection.name_slug), })) - console.log(`[SSG Collections] Returning ${params.length} params`) + console.log(`[SSG Collections] ✅ Complete: ${params.length} collections for static generation`) return params } catch (outerError) { @@ -98,62 +67,55 @@ export async function generateStaticParams() { // Generate metadata for SEO export async function generateMetadata({ params }: { params: { slug: string } }): Promise { const decodedSlug = decodeURIComponent(params.slug) + const collection = await getCollection(decodedSlug) - try { - // Collections typically use 'collection' as the default scope - const scope = 'collection' - const name = decodedSlug - - const res = await fetch(`${REGISTRY_URL}/api/v1/collections/${scope}/${name}`, { - next: { revalidate: 3600 } - }) - - if (!res.ok) { - return { - title: 'Collection Not Found', - description: 'The requested collection could not be found.', - } - } - - const collection: Collection = await res.json() - - return { - title: `${collection.name_slug} - PRPM Collection`, - description: collection.description || `Install ${collection.name_slug} collection with PRPM - curated package collection`, - keywords: [...(collection.tags || []), collection.category, collection.framework, 'prpm', 'collection', 'ai', 'coding'].filter((k): k is string => Boolean(k)), - openGraph: { - title: collection.name_slug, - description: collection.description || 'Curated package collection', - type: 'website', - }, - twitter: { - card: 'summary', - title: collection.name_slug, - description: collection.description || 'Curated package collection', - }, - } - } catch (error) { + if (!collection) { return { - title: 'Collection Error', - description: 'Error loading collection details.', + title: 'Collection Not Found', + description: 'The requested collection could not be found.', } } + + return { + title: `${collection.name_slug} - PRPM Collection`, + description: collection.description || `Install ${collection.name_slug} collection with PRPM - curated package collection`, + keywords: [...(collection.tags || []), collection.category, collection.framework, 'prpm', 'collection', 'ai', 'coding'].filter((k): k is string => Boolean(k)), + openGraph: { + title: collection.name_slug, + description: collection.description || 'Curated package collection', + type: 'website', + }, + twitter: { + card: 'summary', + title: collection.name_slug, + description: collection.description || 'Curated package collection', + }, + } } async function getCollection(slug: string): Promise { try { - // Collections typically use 'collection' as the default scope - // URL format: /collections/name-slug -> API: /api/v1/collections/collection/name-slug - const scope = 'collection' - const name = slug - - const res = await fetch(`${REGISTRY_URL}/api/v1/collections/${scope}/${name}`, { + // Fetch collections data from S3 + const url = `${S3_SEO_DATA_URL}/collections.json` + const res = await fetch(url, { next: { revalidate: 3600 } // Revalidate every hour }) - if (!res.ok) return null + if (!res.ok) { + console.error(`Error fetching collections from S3: ${res.status}`) + return null + } + + const collections = await res.json() + + if (!Array.isArray(collections)) { + console.error('Invalid collections data format from S3') + return null + } - return res.json() + // Find the collection by slug + const collection = collections.find((c: any) => c.name_slug === slug) + return collection || null } catch (error) { console.error('Error fetching collection:', error) return null @@ -242,20 +204,20 @@ export default async function CollectionPage({ params }: { params: { slug: strin {collection.packages && collection.packages.length > 0 && (

📦 Packages ({collection.packages.length})

-
+
{collection.packages .sort((a, b) => (a.installOrder || 999) - (b.installOrder || 999)) .map((pkg, index) => (
-
+
#{index + 1}

- {(pkg as any).package?.name || pkg.packageId} + {(pkg as any).packageName || pkg.packageId}

{pkg.required && ( @@ -272,11 +234,11 @@ export default async function CollectionPage({ params }: { params: { slug: strin

{(pkg as any).package.description}

)} {pkg.reason && ( -

+

Why included: {pkg.reason}

)} -
+
Version: {pkg.version || 'latest'} {pkg.formatOverride && ( @@ -285,7 +247,29 @@ export default async function CollectionPage({ params }: { params: { slug: strin )}
+
+ {(pkg as any).packageName && ( + + View Details → + + )} +
+ + {/* Full prompt content */} + {(pkg as any).fullContent && ( +
+

📄 Prompt Content

+
+
+                          {(pkg as any).fullContent}
+                        
+
+
+ )}
))}
diff --git a/packages/webapp/src/app/packages/[author]/[...package]/page.tsx b/packages/webapp/src/app/packages/[author]/[...package]/page.tsx index a108aefc..32834f35 100644 --- a/packages/webapp/src/app/packages/[author]/[...package]/page.tsx +++ b/packages/webapp/src/app/packages/[author]/[...package]/page.tsx @@ -5,14 +5,15 @@ import type { PackageInfo } from '@pr-pm/types' import CopyInstallCommand from '@/components/CopyInstallCommand' const REGISTRY_URL = process.env.NEXT_PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL || 'https://registry.prpm.dev' +const S3_SEO_DATA_URL = process.env.NEXT_PUBLIC_S3_SEO_DATA_URL || 'https://prpm-prod-packages.s3.amazonaws.com/seo-data' // Allow dynamic rendering for params not in generateStaticParams export const dynamicParams = true -// Helper to get package content/snippet +// Helper to get package content - prefer full content, fall back to snippet function getPackageContent(pkg: any): string | null { - // Use snippet field from the package if available - return pkg.snippet || null + // Use fullContent if available (from S3), otherwise fall back to snippet + return pkg.fullContent || pkg.snippet || null } // Generate static params for all packages @@ -27,66 +28,33 @@ export async function generateStaticParams() { } try { - const allPackages: string[] = [] - let offset = 0 - const limit = 100 - let hasMore = true - - console.log(`[SSG Packages] Starting - REGISTRY_URL: ${REGISTRY_URL}`) - console.log(`[SSG Packages] Environment check:`, { - NEXT_PUBLIC_REGISTRY_URL: process.env.NEXT_PUBLIC_REGISTRY_URL, - REGISTRY_URL: process.env.REGISTRY_URL, - NODE_ENV: process.env.NODE_ENV, - CI: process.env.CI - }) - - // Paginate through all packages - while (hasMore) { - const url = `${REGISTRY_URL}/api/v1/search/seo/packages?limit=${limit}&offset=${offset}` - console.log(`[SSG Packages] Attempting fetch: ${url}`) + console.log(`[SSG Packages] Starting - S3_SEO_DATA_URL: ${S3_SEO_DATA_URL}`) - try { - const res = await fetch(url, { - next: { revalidate: 3600 } // Revalidate every hour - }) + // Fetch package data from S3 (uploaded by Lambda) + const url = `${S3_SEO_DATA_URL}/packages.json` + console.log(`[SSG Packages] Fetching from S3: ${url}`) - console.log(`[SSG Packages] Response status: ${res.status} ${res.statusText}`) - - if (!res.ok) { - console.error(`[SSG Packages] HTTP ${res.status}: Failed to fetch packages`) - console.error(`[SSG Packages] Response headers:`, Object.fromEntries(res.headers.entries())) - break - } + const res = await fetch(url, { + next: { revalidate: 3600 } // Revalidate every hour + }) - const data = await res.json() - console.log(`[SSG Packages] Received data with ${data.packages?.length || 0} packages`) + if (!res.ok) { + console.error(`[SSG Packages] HTTP ${res.status}: Failed to fetch packages from S3`) + console.error(`[SSG Packages] Response headers:`, Object.fromEntries(res.headers.entries())) + return [] + } - if (!data.packages || !Array.isArray(data.packages)) { - console.error('[SSG Packages] Invalid response format:', data) - break - } + const packages = await res.json() + console.log(`[SSG Packages] Received ${packages.length} packages from S3`) - allPackages.push(...data.packages) - hasMore = data.hasMore - offset += limit - - console.log(`[SSG Packages] Progress: ${allPackages.length} packages fetched`) - } catch (fetchError) { - console.error('[SSG Packages] Fetch error:', fetchError) - console.error('[SSG Packages] Error details:', { - message: fetchError instanceof Error ? fetchError.message : String(fetchError), - stack: fetchError instanceof Error ? fetchError.stack : undefined - }) - break - } + if (!Array.isArray(packages)) { + console.error('[SSG Packages] Invalid response format - expected array') + return [] } - console.log(`[SSG Packages] ✅ Complete: ${allPackages.length} packages for static generation`) - - // Transform package names to author/package format - // @scope/package -> scope/package - // unscoped-package -> prpm/unscoped-package (default author) - const params = allPackages.map((name) => { + // Transform package data to author/package format + const params = packages.map((pkg: any) => { + const name = pkg.name if (name.startsWith('@')) { // Scoped package: @author/package/sub/path -> author + [package, sub, path] const withoutAt = name.substring(1) // Remove @ @@ -104,7 +72,7 @@ export async function generateStaticParams() { } }) - console.log(`[SSG Packages] Returning ${params.length} params`) + console.log(`[SSG Packages] ✅ Complete: ${params.length} packages for static generation`) return params } catch (outerError) { @@ -124,58 +92,55 @@ export async function generateMetadata({ params }: { params: { author: string; p const packagePath = Array.isArray(params.package) ? params.package.join('/') : params.package const fullName = `@${params.author}/${packagePath}` - try { - // URL-encode the package name to handle @ and / characters - const encodedName = encodeURIComponent(fullName) - const res = await fetch(`${REGISTRY_URL}/api/v1/packages/${encodedName}`, { - next: { revalidate: 3600 } - }) - - if (!res.ok) { - return { - title: 'Package Not Found', - description: 'The requested package could not be found.', - } - } - - const pkg: PackageInfo = await res.json() + const pkg = await getPackage(fullName) + if (!pkg) { return { - title: `${pkg.name} - PRPM Package`, - description: pkg.description || `Install ${pkg.name} with PRPM - ${pkg.format} ${pkg.subtype} for your AI coding workflow`, - keywords: [...(pkg.tags || []), pkg.format, pkg.subtype, pkg.category, 'prpm', 'ai', 'coding'].filter((k): k is string => Boolean(k)), - openGraph: { - title: pkg.name, - description: pkg.description || `${pkg.format} ${pkg.subtype} package`, - type: 'website', - }, - twitter: { - card: 'summary', - title: pkg.name, - description: pkg.description || `${pkg.format} ${pkg.subtype} package`, - }, - } - } catch (error) { - return { - title: 'Package Error', - description: 'Error loading package details.', + title: 'Package Not Found', + description: 'The requested package could not be found.', } } + + return { + title: `${pkg.name} - PRPM Package`, + description: pkg.description || `Install ${pkg.name} with PRPM - ${pkg.format} ${pkg.subtype} for your AI coding workflow`, + keywords: [...(pkg.tags || []), pkg.format, pkg.subtype, pkg.category, 'prpm', 'ai', 'coding'].filter((k): k is string => Boolean(k)), + openGraph: { + title: pkg.name, + description: pkg.description || `${pkg.format} ${pkg.subtype} package`, + type: 'website', + }, + twitter: { + card: 'summary', + title: pkg.name, + description: pkg.description || `${pkg.format} ${pkg.subtype} package`, + }, + } } async function getPackage(name: string): Promise { try { - // URL-encode the package name to handle @ and / characters - const encodedName = encodeURIComponent(name) - const url = `${REGISTRY_URL}/api/v1/packages/${encodedName}` - + // Fetch packages data from S3 + const url = `${S3_SEO_DATA_URL}/packages.json` const res = await fetch(url, { next: { revalidate: 3600 } // Revalidate every hour }) - if (!res.ok) return null + if (!res.ok) { + console.error(`Error fetching packages from S3: ${res.status}`) + return null + } + + const packages = await res.json() + + if (!Array.isArray(packages)) { + console.error('Invalid packages data format from S3') + return null + } - return res.json() + // Find the package by name + const pkg = packages.find((p: any) => p.name === name) + return pkg || null } catch (error) { console.error('Error fetching package:', error) return null @@ -272,7 +237,7 @@ export default async function PackagePage({ params }: { params: { author: string {/* Full Package Content - Prominently displayed at the top */} {content && (
-

📄 Prompt Content Snippet

+

📄 Full Prompt Content

                 {content}
diff --git a/packages/webapp/src/components/CollectionModal.tsx b/packages/webapp/src/components/CollectionModal.tsx
index c144f92d..46f87856 100644
--- a/packages/webapp/src/components/CollectionModal.tsx
+++ b/packages/webapp/src/components/CollectionModal.tsx
@@ -1,6 +1,7 @@
 'use client'
 
 import { useState, useEffect } from 'react'
+import Link from 'next/link'
 
 interface CollectionPackage {
   packageId: string
@@ -219,6 +220,15 @@ export default function CollectionModal({ collection: initialCollection, isOpen,
           
         
+ {/* View Details Link */} + e.stopPropagation()} + > + View Full Details → + +
) diff --git a/packages/webapp/src/components/PackageModal.tsx b/packages/webapp/src/components/PackageModal.tsx index da7cfce6..15da23e2 100644 --- a/packages/webapp/src/components/PackageModal.tsx +++ b/packages/webapp/src/components/PackageModal.tsx @@ -184,12 +184,21 @@ export default function PackageModal({ package: pkg, isOpen, onClose }: PackageM
)} - +
+ + e.stopPropagation()} + > + View Details → + +
)