diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index cade1d7..c7fdf23 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -8,13 +8,14 @@ The package provides, grouped by role: | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | | Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | +| Link previews | `SocialCard`, its `Image`, and the `SocialCardTag` meta tags it derives | | Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | | Contexts | `LocalizedRequestContext` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules The package holds only what every service can reuse; anything a service owns is injected, never referenced: -- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. +- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. - **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. @@ -29,7 +30,8 @@ Sources/ │ ├── Methods/ FingerprintAssets │ ├── Middlewares/ the five HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController -│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse +│ └── Types/ SocialCard, with its image and tag types in SocialCard/ └── Internal/ └── Types/ implementation details (FNV1aHash) Tests/ @@ -43,4 +45,4 @@ Every suite carries a tag naming the kind of API it exercises — `.asset`, `.ex ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). -- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- macOS 15, matching the sibling `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms). diff --git a/Packages/Utility/Package.swift b/Packages/Utility/Package.swift new file mode 100644 index 0000000..574189c --- /dev/null +++ b/Packages/Utility/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Utility", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Utility", + targets: [ + "Utility" + ] + ) + ], + targets: [ + .target( + name: "Utility", + path: "Sources", + ), + .testTarget( + name: "UtilityTests", + dependencies: [ + .byName(name: "Utility") + ], + path: "Tests" + ), + ] +) diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md new file mode 100644 index 0000000..168ee32 --- /dev/null +++ b/Packages/Utility/README.md @@ -0,0 +1,31 @@ +# Utility +The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework. + +## Overview +The package provides, grouped by role: +| Role | Types | +| --- | --- | +| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form | + +## Design rules +- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird). +- **Method structs.** Helpers hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +## Layout +Sources are split by visibility, then by kind, one type per file: +``` +Sources/ +└── Public/ + └── Methods/ NormalizeEmail +Tests/ +├── Cases/ the test suites, mirroring the Sources/ layout +└── Utils/ the suite Tag constants +``` + +## Testing +Every suite carries a tag naming the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits). + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Infrastructure`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). +- No package dependencies — Foundation only. diff --git a/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift new file mode 100644 index 0000000..6429a6c --- /dev/null +++ b/Packages/Utility/Sources/Public/Methods/NormalizeEmail.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A validator reducing a submitted email address to its canonical form. +/// +/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can +/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes +/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address. +public struct NormalizeEmail: Sendable { + + // MARK: Initializers + + /// Creates an email normalization method. + public init() {} + + // MARK: Functions + + /// Validates and normalizes a submitted email address. + /// - Parameter email: the submitted email address, if any. + /// - Returns: the normalized address, or `nil` when the submission is missing or invalid. + public func callAsFunction( + _ email: String? + ) -> String? { + guard + let email = email? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + email.utf8.count <= Constant.Length.maxEmail, + !email.unicodeScalars.contains(where: { + $0.properties.generalCategory == .control + }), + email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil + else { + return nil + } + + return email + } + +} + +// MARK: - Constants + +private enum Constant { + /// A namespace for the length constants. + enum Length { + /// The longest an email address can be, in bytes, per RFC 5321's path limit. + static let maxEmail = 254 + } +} diff --git a/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift new file mode 100644 index 0000000..a26372e --- /dev/null +++ b/Packages/Utility/Tests/Cases/Public/Methods/NormalizeEmailTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import Utility + +@Suite( + "NormalizeEmail method", + .tags(.method) +) +struct NormalizeEmailTests { + + // MARK: Properties + + private let normalize = NormalizeEmail() + + // MARK: Functional tests + + @Test(arguments: [ + ("fan@loudmail.nl", "fan@loudmail.nl"), + ("Fan@LoudMail.NL", "fan@loudmail.nl"), + (" fan@loudmail.nl\n", "fan@loudmail.nl"), + ("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"), + // Exactly 254 bytes: the longest address the validator accepts. + (String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"), + ]) + func `normalizes a valid address`( + submitted: String, + expected: String + ) { + #expect(normalize(submitted) == expected) + } + + @Test(arguments: [ + nil, + "", + " ", + "not-an-email", + "missing@dot", + "@loudmail.nl", + "fan@", + "fan@.nl", + "spaced out@loudmail.nl", + "fan@loud mail.nl", + // One byte over the 254-byte limit. + String(repeating: "a", count: 243) + "@loudmail.nl", + // Few enough characters, but multibyte ones put it over the byte limit. + String(repeating: "é", count: 130) + "@loudmail.nl", + // Control characters are not whitespace, so only the dedicated check catches them. + "fan\u{00}@loudmail.nl", + "fan@loudmail.nl\u{7F}", + ] as [String?]) + func `rejects a missing or invalid address`( + submitted: String? + ) { + #expect(normalize(submitted) == nil) + } + +} diff --git a/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..877f053 --- /dev/null +++ b/Packages/Utility/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + /// Tests exercising a method of the Utility package. + @Tag static var method: Tag +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 71e1f67..f3539f9 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -20,6 +20,9 @@ let package = Package( ) ], dependencies: [ + .package( + path: "../../Packages/Infrastructure" + ), .package( path: "../../Packages/Localization" ), @@ -27,7 +30,7 @@ let package = Package( path: "../../Packages/Persistence" ), .package( - path: "../../Packages/Infrastructure" + path: "../../Packages/Utility" ), .package( url: "https://github.com/elementary-swift/elementary.git", @@ -79,9 +82,10 @@ let package = Package( .target( name: "WebsiteLibrary", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Localization"), .byName(name: "Persistence"), - .byName(name: "Infrastructure"), + .byName(name: "Utility"), .product( name: "Configuration", package: "swift-configuration" @@ -121,8 +125,8 @@ let package = Package( .testTarget( name: "WebsiteLibraryTests", dependencies: [ - .byName(name: "Persistence"), .byName(name: "Infrastructure"), + .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index b82c471..a16d3f0 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -52,6 +52,13 @@ "identifier" : "InfrastructureTests", "name" : "InfrastructureTests" } + }, + { + "target" : { + "containerPath" : "container:..\/..\/Packages\/Utility", + "identifier" : "UtilityTests", + "name" : "UtilityTests" + } } ], "version" : 1