diff --git a/FrontEnd/styles/package.scss b/FrontEnd/styles/package.scss index 28ee28c6b..3d3069a56 100644 --- a/FrontEnd/styles/package.scss +++ b/FrontEnd/styles/package.scss @@ -156,6 +156,17 @@ align-items: center; } } + + li.custom-collections { + grid-column-start: span 2; + background-image: var(--image-tags); + + a { + display: flex; + gap: 5px; + align-items: center; + } + } } section.sidebar-links { diff --git a/Sources/App/Commands/Reconcile.swift b/Sources/App/Commands/Reconcile.swift index 072bfc15a..80b2c8690 100644 --- a/Sources/App/Commands/Reconcile.swift +++ b/Sources/App/Commands/Reconcile.swift @@ -138,8 +138,8 @@ func processPackageDenyList(packageList: [URL], denyList: [URL]) -> [URL] { } -func reconcileCustomCollection(client: Client, database: Database, fullPackageList: [URL], _ dto: CustomCollection.DTO) async throws { - let collection = try await CustomCollection.findOrCreate(on: database, dto) +func reconcileCustomCollection(client: Client, database: Database, fullPackageList: [URL], _ details: CustomCollection.Details) async throws { + let collection = try await CustomCollection.findOrCreate(on: database, details) // Limit incoming URLs to 50 since this is input outside of our control @Dependency(\.packageListRepository) var packageListRepository diff --git a/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift b/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift index f24c59228..e0f5d4c53 100644 --- a/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift +++ b/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift @@ -51,6 +51,7 @@ extension API.PackageController.GetRoute { var fundingLinks: [FundingLink] var swift6Readiness: Swift6Readiness? var forkedFromInfo: ForkedFromInfo? + var customCollections: [CustomCollection.Details] internal init(packageId: Package.Id, repositoryOwner: String, @@ -83,7 +84,8 @@ extension API.PackageController.GetRoute { preReleaseReference: App.Reference?, fundingLinks: [FundingLink] = [], swift6Readiness: Swift6Readiness?, - forkedFromInfo: ForkedFromInfo? + forkedFromInfo: ForkedFromInfo?, + customCollections: [CustomCollection.Details] ) { self.packageId = packageId self.repositoryOwner = repositoryOwner @@ -126,6 +128,7 @@ extension API.PackageController.GetRoute { self.fundingLinks = fundingLinks self.swift6Readiness = swift6Readiness self.forkedFromInfo = forkedFromInfo + self.customCollections = customCollections } init?(result: API.PackageController.PackageResult, @@ -136,7 +139,8 @@ extension API.PackageController.GetRoute { platformBuildInfo: BuildInfo?, weightedKeywords: [WeightedKeyword] = [], swift6Readiness: Swift6Readiness?, - forkedFromInfo: ForkedFromInfo?) { + forkedFromInfo: ForkedFromInfo?, + customCollections: [CustomCollection.Details]) { // we consider certain attributes as essential and return nil (raising .notFound) let repository = result.repository guard @@ -182,7 +186,8 @@ extension API.PackageController.GetRoute { preReleaseReference: result.preReleaseVersion?.reference, fundingLinks: result.repository.fundingLinks, swift6Readiness: swift6Readiness, - forkedFromInfo: forkedFromInfo + forkedFromInfo: forkedFromInfo, + customCollections: customCollections ) } diff --git a/Sources/App/Controllers/API/API+PackageController+GetRoute.swift b/Sources/App/Controllers/API/API+PackageController+GetRoute.swift index 782967b6c..d64d83ba4 100644 --- a/Sources/App/Controllers/API/API+PackageController+GetRoute.swift +++ b/Sources/App/Controllers/API/API+PackageController+GetRoute.swift @@ -47,6 +47,8 @@ extension API.PackageController { repository: repository) async let forkedFromInfo = forkedFromInfo(on: database, fork: packageResult.repository.forkedFrom) + async let customCollections = customCollections(on: database, package: packageResult.package) + guard let model = try await Self.Model( result: packageResult, @@ -57,7 +59,8 @@ extension API.PackageController { platformBuildInfo: buildInfo.platform, weightedKeywords: weightedKeywords, swift6Readiness: buildInfo.swift6Readiness, - forkedFromInfo: forkedFromInfo + forkedFromInfo: forkedFromInfo, + customCollections: customCollections ), let schema = API.PackageSchema(result: packageResult) else { @@ -96,6 +99,16 @@ extension API.PackageController.GetRoute { return .fromGitHub(url: url) } } + + static func customCollections(on database: Database, package: Package) async -> [CustomCollection.Details] { + guard Current.environment() == .development else { return [] } + do { + try await package.$customCollections.load(on: database) + return package.customCollections.map(\.details) + } catch { + return [] + } + } } diff --git a/Sources/App/Controllers/API/Types+WithExample.swift b/Sources/App/Controllers/API/Types+WithExample.swift index a15170f81..5106812f7 100644 --- a/Sources/App/Controllers/API/Types+WithExample.swift +++ b/Sources/App/Controllers/API/Types+WithExample.swift @@ -248,7 +248,8 @@ extension API.PackageController.GetRoute.Model: WithExample { releaseReference: .tag(1, 2, 3, "1.2.3"), preReleaseReference: nil, swift6Readiness: nil, - forkedFromInfo: nil) + forkedFromInfo: nil, + customCollections: []) } } diff --git a/Sources/App/Core/Dependencies/PackageListRepositoryClient.swift b/Sources/App/Core/Dependencies/PackageListRepositoryClient.swift index c92548545..5b639008f 100644 --- a/Sources/App/Core/Dependencies/PackageListRepositoryClient.swift +++ b/Sources/App/Core/Dependencies/PackageListRepositoryClient.swift @@ -22,7 +22,7 @@ struct PackageListRepositoryClient { var fetchPackageList: @Sendable (_ client: Client) async throws -> [URL] var fetchPackageDenyList: @Sendable (_ client: Client) async throws -> [URL] var fetchCustomCollection: @Sendable (_ client: Client, _ url: URL) async throws -> [URL] - var fetchCustomCollections: @Sendable (_ client: Client) async throws -> [CustomCollection.DTO] + var fetchCustomCollections: @Sendable (_ client: Client) async throws -> [CustomCollection.Details] } @@ -62,7 +62,7 @@ extension PackageListRepositoryClient: DependencyKey { try await client .get(Constants.customCollectionsUri) .content - .decode([CustomCollection.DTO].self, using: JSONDecoder()) + .decode([CustomCollection.Details].self, using: JSONDecoder()) } ) } diff --git a/Sources/App/Models/CustomCollection.swift b/Sources/App/Models/CustomCollection.swift index 650acede0..1f9b289c1 100644 --- a/Sources/App/Models/CustomCollection.swift +++ b/Sources/App/Models/CustomCollection.swift @@ -47,39 +47,40 @@ final class CustomCollection: @unchecked Sendable, Model, Content { @Field(key: "url") var url: URL - // reference fields + // relationships + @Siblings(through: CustomCollectionPackage.self, from: \.$customCollection, to: \.$package) var packages: [Package] init() { } - init(id: Id? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, _ dto: DTO) { + init(id: Id? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, _ details: Details) { self.id = id self.createdAt = createdAt self.updatedAt = updatedAt - self.name = dto.name - self.description = dto.description - self.badge = dto.badge - self.url = dto.url + self.name = details.name + self.description = details.description + self.badge = details.badge + self.url = details.url } } extension CustomCollection { - struct DTO: Codable { + struct Details: Codable, Equatable { var name: String var description: String? var badge: String? var url: URL } - static func findOrCreate(on database: Database, _ dto: DTO) async throws -> CustomCollection { + static func findOrCreate(on database: Database, _ details: Details) async throws -> CustomCollection { if let collection = try await CustomCollection.query(on: database) - .filter(\.$url == dto.url) + .filter(\.$url == details.url) .first() { return collection } else { - let collection = CustomCollection(dto) + let collection = CustomCollection(details) try await collection.save(on: database) return collection } @@ -98,6 +99,10 @@ extension CustomCollection { let removedIDs = Set(existing.keys).subtracting(Set(incoming.keys)) try await $packages.detach(existing[removedIDs], on: database) } + + var details: Details { + .init(name: name, description: description, badge: badge, url: url) + } } diff --git a/Sources/App/Models/Package.swift b/Sources/App/Models/Package.swift index 7b61e863f..76f87c22e 100644 --- a/Sources/App/Models/Package.swift +++ b/Sources/App/Models/Package.swift @@ -57,6 +57,9 @@ final class Package: @unchecked Sendable, Model, Content { // relationships + @Siblings(through: CustomCollectionPackage.self, from: \.$package, to: \.$customCollection) + var customCollections: [CustomCollection] + @Children(for: \.$package) var repositories: [Repository] diff --git a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift index b794cc6f0..4fcc5145b 100644 --- a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift +++ b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift @@ -398,6 +398,19 @@ extension API.PackageController.GetRoute.Model { } } + func customCollectionsItem() -> Node { + guard !customCollections.isEmpty else { return .empty } + return .li( + .class("custom-collections"), + .forEach(customCollections, { collection in + .a( + .href(collection.url), // FIXME: link to custom collection page + .text("\(collection.name)") + ) + }) + ) + } + func latestReleaseMetadata() -> Node { guard let dateLink = releases.stable else { return .empty } return releaseMetadata(dateLink, title: "Latest Release", cssClass: "stable") diff --git a/Sources/App/Views/PackageController/PackageShow+View.swift b/Sources/App/Views/PackageController/PackageShow+View.swift index 271e68543..364ee7fae 100644 --- a/Sources/App/Views/PackageController/PackageShow+View.swift +++ b/Sources/App/Views/PackageController/PackageShow+View.swift @@ -180,7 +180,8 @@ extension PackageShow { model.productTypeListItem(.plugin), model.targetTypeListItem(.macro), model.dataRaceSafeListItem(), - model.keywordsListItem() + model.keywordsListItem(), + model.customCollectionsItem() ) ) } diff --git a/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift b/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift index bd8737c10..545606c30 100644 --- a/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift +++ b/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift @@ -43,7 +43,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase { platformBuildInfo: nil, weightedKeywords: [], swift6Readiness: nil, - forkedFromInfo: nil) + forkedFromInfo: nil, + customCollections: []) // validate XCTAssertNotNil(m) @@ -66,7 +67,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase { platformBuildInfo: nil, weightedKeywords: [], swift6Readiness: nil, - forkedFromInfo: nil)) + forkedFromInfo: nil, + customCollections: [])) // validate XCTAssertEqual(model.packageIdentity, "swift-bar") @@ -89,7 +91,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase { platformBuildInfo: nil, weightedKeywords: [], swift6Readiness: nil, - forkedFromInfo: nil)) + forkedFromInfo: nil, + customCollections: [])) // validate XCTAssertEqual(model.documentationTarget, .internal(docVersion: .reference("main"), archive: "archive1")) @@ -116,7 +119,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase { platformBuildInfo: nil, weightedKeywords: [], swift6Readiness: nil, - forkedFromInfo: nil)) + forkedFromInfo: nil, + customCollections: [])) // validate XCTAssertEqual(model.documentationTarget, .external(url: "https://example.com/package/documentation")) diff --git a/Tests/AppTests/CustomCollectionTests.swift b/Tests/AppTests/CustomCollectionTests.swift index 373a2b94b..efd0d1ed9 100644 --- a/Tests/AppTests/CustomCollectionTests.swift +++ b/Tests/AppTests/CustomCollectionTests.swift @@ -137,6 +137,7 @@ class CustomCollectionTests: AppTestCase { } func test_CustomCollection_packages() async throws { + // Test CustomCollection.packages relation // setup let p1 = Package(id: .id0, url: "1".asGithubUrl.url) try await p1.save(on: app.db) @@ -154,6 +155,29 @@ class CustomCollectionTests: AppTestCase { } } + func test_Package_customCollections() async throws { + // Test Package.customCollections relation + // setup + let p1 = Package(id: .id0, url: "1".asGithubUrl.url) + try await p1.save(on: app.db) + do { + let collection = CustomCollection(id: .id1, .init(name: "List 1", url: "https://github.com/foo/bar/list-1.json")) + try await collection.save(on: app.db) + try await collection.$packages.attach(p1, on: app.db) + } + do { + let collection = CustomCollection(id: .id2, .init(name: "List 2", url: "https://github.com/foo/bar/list-2.json")) + try await collection.save(on: app.db) + try await collection.$packages.attach(p1, on: app.db) + } + + do { // MUT + let pkg = try await Package.find(.id0, on: app.db).unwrap() + try await pkg.$customCollections.load(on: app.db) + XCTAssertEqual(Set(pkg.customCollections.map(\.id)) , Set([.id1, .id2])) + } + } + func test_CustomCollection_cascade() async throws { // setup let pkg = Package(id: .id0, url: "1".asGithubUrl.url) diff --git a/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift b/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift index c23ef67df..69d36b573 100644 --- a/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift +++ b/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift @@ -129,7 +129,8 @@ extension API.PackageController.GetRoute.Model { releaseReference: .tag(5, 2, 0), preReleaseReference: .tag(5, 3, 0, "beta.1"), swift6Readiness: nil, - forkedFromInfo: nil + forkedFromInfo: nil, + customCollections: [] ) } } diff --git a/Tests/AppTests/WebpageSnapshotTests.swift b/Tests/AppTests/WebpageSnapshotTests.swift index 55292228f..56355c2f6 100644 --- a/Tests/AppTests/WebpageSnapshotTests.swift +++ b/Tests/AppTests/WebpageSnapshotTests.swift @@ -282,6 +282,15 @@ class WebpageSnapshotTests: SnapshotTestCase { assertSnapshot(of: page, as: .html) } + func test_PackageShowView_customCollection() throws { + var model = API.PackageController.GetRoute.Model.mock + model.homepageUrl = "https://swiftpackageindex.com/" + model.customCollections = [.init(name: "Custom Collection", url: "https://github.com/foo/bar/list.json")] + let page = { PackageShow.View(path: "", model: model, packageSchema: .mock).document() } + + assertSnapshot(of: page, as: .html) + } + func test_PackageReleasesView() throws { let model = PackageReleases.Model.mock let page = { PackageReleases.View(model: model).document() } diff --git a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html new file mode 100644 index 000000000..f0e0834b6 --- /dev/null +++ b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html @@ -0,0 +1,448 @@ + + + + + + + + + + Alamofire – Swift Package Index + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

+ The Swift Package Index logo.Swift Package Index +

+
+ +
+
+

Track the adoption of Swift 6 strict concurrency checks for data race safety. How many packages are + Ready for Swift 6? +

+ +
+
+
+
+

Alamofire

+ + Alamo + / + Alamofire + +
+
+ +
+ +

When working with an Xcode project:

+
+ +
+

When working with a Swift Package Manager manifest:

+

Select a package version:

+
+

+ 5.2.0 +

+
+ +
+
+
+

+ 5.3.0-beta.1 +

+
+ +
+
+
+

+ main +

+
+ +
+
+
+

+ + +

+
+ +
+
+
+
+
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque quis porttitor erat. Vivamus porttitor mi odio, quis imperdiet velit blandit id. Vivamus vehicula urna eget ipsum laoreet, sed porttitor sapien malesuada. Mauris faucibus tellus at augue vehicula, vitae aliquet felis ullamcorper. Praesent vitae leo rhoncus, egestas elit id, porttitor lacus. Cras ac bibendum mauris. Praesent luctus quis nulla sit amet tempus. Ut pharetra non augue sed pellentesque.

+
+
+
+ +
+
+
+
+

Compatibility

+ Full Build Results +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+ + + + + \ No newline at end of file