import Foundation extension String { /// A namespace for the paths of the site's screens. /// /// Single-sourced, so the navigation and the controllers never spell a path differently. enum Route { /// The account screen. static let account = "/account" /// The side-by-side comparison of copies. static let compare = "/compare" /// The public grading guide. static let gradingGuide = "/grading-guide" /// The browse / home screen. static let home = "/" /// The message threads. static let messages = "/messages" /// The open-a-shop onboarding. static let openShop = "/sell/open-a-shop" /// The search results screen. static let search = "/search" /// The seller dashboard. static let sellerDashboard = "/sell" /// The series index. static let series = "/series" /// The passkey sign-in screen. static let signIn = "/sign-in" /// The path a sign-out is posted to. static let signOut = "/sign-out" /// The want list. static let wants = "/wants" /// The first-sign-in screen. static let welcome = "/welcome" /// The path an offer is answered at. /// - Parameter id: the offer's identifier. static func answer( offer id: UUID ) -> String { "\(sellerDashboard)/offers/\(id.uuidString.lowercased())" } /// The path buying a listing outright starts at. /// - Parameter slug: the listing's URL slug. static func buy( _ slug: String ) -> String { "\(listing(slug))/buy" } /// The path of a conversation. /// - Parameter id: the conversation's identifier. static func conversation( _ id: UUID ) -> String { "\(messages)/\(id.uuidString.lowercased())" } /// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken. /// - Parameters: /// - path: the action's own path. /// - origin: the path of the screen the action is taken on. /// - viewer: the signed-in viewer, or `nil` when signed out. static func gated( _ path: String, from origin: String, viewer: Viewer? ) -> String { viewer == nil ? signIn(returningTo: origin) : path } /// The path of a listing's detail screen. /// - Parameter slug: the listing's URL slug. static func listing( _ slug: String ) -> String { "/listing/\(slug)" } /// The path of a new message thread about a listing. /// - Parameter slug: the listing's URL slug. static func message( listing slug: String ) -> String { "\(messages)/new?listing=\(slug)" } /// The path of a new message thread with a shop. /// - Parameter slug: the shop's URL slug. static func message( shop slug: String ) -> String { "\(messages)/new?shop=\(slug)" } /// The path of the offer sheet for a listing. /// - Parameter slug: the listing's URL slug. static func offer( _ slug: String ) -> String { "\(listing(slug))/offer" } /// The path of a sale record. /// - Parameter number: the record's number. static func sale( _ number: Int ) -> String { "/sales/\(number)" } /// The path of a series' screen, optionally on the copies of one issue. /// - Parameters: /// - slug: the series' URL slug. /// - issue: the issue whose copies are listed, or `nil` (the default) for none. static func series( _ slug: String, issue: Int? = nil ) -> String { "\(series)/\(slug)\(issue.map { "?issue=\($0)" } ?? "")" } /// The path of a shop's public screen. /// - Parameter slug: the shop's URL slug. static func shop( _ slug: String ) -> String { "/shop/\(slug)" } /// The path to return to after signing in: the one asked for when it stays on this site, else the home screen. /// /// Only a rooted path is followed. Anything else — another origin, a scheme-relative `//host`, a backslash a browser reads as a slash — /// would turn the sign-in into an open redirect. /// - Parameter path: the path asked for, as the `return` parameter carried it. static func safeReturn( _ path: String? ) -> String { guard let path, path.hasPrefix("/"), !path.hasPrefix("//"), !path.contains("\\"), !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { return home } return path } /// The path of the first-sign-in screen, continuing to the given path afterwards. static func welcome( returningTo path: String ) -> String { "\(welcome)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" } /// The path of the form adding a want, filled from a listing or with a title. static func newWant( listing slug: String? = nil, title: String? = nil ) -> String { let query = [slug.map { "listing=\($0)" }, title.map { "title=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" }] .compactMap(\.self) .joined(separator: "&") return "\(wants)/new\(query.isEmpty ? "" : "?\(query)")" } /// The path of the sign-in screen, returning to the given path afterwards. /// - Parameter path: the path the visitor's gated action started from. static func signIn( returningTo path: String ) -> String { "\(signIn)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" } } } // MARK: - Constants private extension CharacterSet { /// The characters a query value carries unescaped. static let urlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: "&=+?#")) }