├── .ruby-version ├── App ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 16x16.png │ │ │ ├── 32x32.png │ │ │ ├── 128x128.png │ │ │ ├── 16x16@2x.png │ │ │ ├── 256x256.png │ │ │ ├── 32x32@2x.png │ │ │ ├── 512x512.png │ │ │ ├── 128x128@2x.png │ │ │ ├── 256x256@2x.png │ │ │ ├── 512x512@2x.png │ │ │ ├── AppIcon 1.png │ │ │ ├── AppIcon-iOS-Default-1024x1024@1x.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Settings.bundle │ │ ├── en.lproj │ │ │ └── Root.strings │ │ ├── Root.plist │ │ ├── Acknowledgements.plist │ │ ├── LICENSE_github-markdown-css.plist │ │ ├── LICENSE_highlight-js.plist │ │ └── LICENSE_marked.plist │ ├── SwiftEvolution.entitlements │ ├── Info.plist │ └── AppIcon.icon │ │ ├── icon.json │ │ └── Assets │ │ └── Logo.svg ├── App.swift └── AppShortcutsProvider.swift ├── EvolutionUI ├── .gitignore ├── Tests │ └── EvolutionUITests │ │ └── EvolutionUITests.swift ├── Sources │ ├── Extensions │ │ ├── SceneStorage.swift │ │ ├── ToolbarItemPlacement.swift │ │ ├── Color.swift │ │ ├── AppStorage.swift │ │ ├── Binding.swift │ │ ├── View.swift │ │ ├── BlockStyle.swift │ │ ├── ProposalStatusState.swift │ │ ├── UIViewRepresentable.swift │ │ └── UIColor.swift │ ├── Views │ │ ├── BookmarkButton.swift │ │ ├── FallbackGlassEffect.swift │ │ ├── TranslateButton.swift │ │ ├── BookmarkMenu.swift │ │ ├── ErrorView.swift │ │ ├── FilterCommands.swift │ │ ├── CopiedHUD.swift │ │ ├── MarkdownStyleModifier.swift │ │ ├── SplashCodeSyntaxHighlighter.swift │ │ ├── ProposalStatusPicker.swift │ │ ├── StatusFilter.swift │ │ ├── SettingsView.swift │ │ ├── ProposalDetailRow.swift │ │ ├── DownloadProgress.swift │ │ ├── MyCodeBlock.swift │ │ └── FlowLayout.swift │ └── Models │ │ └── MarkdownTranslator.swift └── Package.swift ├── EvolutionModel ├── .gitignore ├── Tests │ └── EvolutionModelTests │ │ └── EvolutionModelTests.swift ├── Sources │ ├── Proposal │ │ ├── Proposal+SortDescriptor.swift │ │ ├── GithubURL.swift │ │ ├── Proposal+Snapshot.swift │ │ ├── Proposal+FetchDescriptor.swift │ │ ├── ReviewState.swift │ │ ├── ProposalRepository.swift │ │ └── Proposal.swift │ ├── Bookmark │ │ ├── Bookmark.swift │ │ └── BookmarkRepository.swift │ └── Markdown │ │ ├── Markdown.swift │ │ ├── MarkdownURL.swift │ │ └── MarkdownRepository.swift └── Package.swift ├── EvolutionModule ├── .gitignore ├── Tests │ └── EvolutionModuleTests │ │ └── EvolutionModuleTests.swift ├── Sources │ ├── Environment │ │ ├── PreviewTrait.swift │ │ ├── EvolutionPreviewModifier.swift │ │ └── EnvironmentResolver.swift │ ├── ProposalList │ │ ├── ProposalSortKey.swift │ │ ├── ProposalListMode.swift │ │ ├── ProposalQuery.swift │ │ ├── ProposalListCell.swift │ │ └── ProposalListView.swift │ ├── ProposalDetail │ │ ├── ProposalDetailToolBar.swift │ │ ├── ProposalDetailView.swift │ │ └── ProposalDetailViewModel.swift │ ├── AppScene.swift │ └── Content │ │ ├── ContentRootView.swift │ │ ├── ContentViewModel.swift │ │ └── ContentView.swift └── Package.swift ├── SwiftEvolution.xcodeproj ├── project.xcworkspace │ ├── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── contents.xcworkspacedata ├── xcshareddata │ └── xcschemes │ │ └── App.xcscheme └── project.pbxproj ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .cursor └── commands │ ├── commit.md │ └── pr.md ├── LICENSE.txt ├── .gitignore └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.6 2 | -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/en.lproj/Root.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Settings.bundle/en.lproj/Root.strings -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/16x16.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/32x32.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/128x128.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/16x16@2x.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/256x256.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/32x32@2x.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/512x512.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/128x128@2x.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/256x256@2x.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/512x512@2x.png -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon 1.png -------------------------------------------------------------------------------- /EvolutionUI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /EvolutionModel/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /EvolutionModule/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /App/Resources/SwiftEvolution.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iOS-Default-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Koshimizu-Takehito/SwiftEvolution-Viewer/HEAD/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iOS-Default-1024x1024@1x.png -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /EvolutionUI/Tests/EvolutionUITests/EvolutionUITests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import EvolutionUI 3 | 4 | /// Basic smoke test for the ``EvolutionUI`` package. 5 | @Test func example() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EvolutionModel/Tests/EvolutionModelTests/EvolutionModelTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import EvolutionModel 3 | 4 | /// Basic smoke test for the ``EvolutionModel`` package. 5 | @Test func example() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | -------------------------------------------------------------------------------- /EvolutionModule/Tests/EvolutionModuleTests/EvolutionModuleTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import EvolutionModule 3 | 4 | /// Basic smoke test for the ``EvolutionModule`` package. 5 | @Test func example() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/Proposal+SortDescriptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | public extension SortDescriptor { 5 | /// Sorts proposals by identifier in descending order. 6 | static var proposalID: Self { 7 | SortDescriptor(\Proposal.proposalID, order: .reverse) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Environment/PreviewTrait.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | /// Convenience trait for previews that require proposal data. 7 | extension PreviewTrait where T == Preview.ViewTraits { 8 | @MainActor public static var evolution: Self = .modifier(EvolutionPreviewModifier()) 9 | } 10 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/SceneStorage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension SceneStorage where Value: RawRepresentable, Value.RawValue == String { 4 | /// Convenience initializer that uses the type name as the storage key. 5 | public init(wrappedValue: Value) { 6 | self.init(wrappedValue: wrappedValue, String(describing: Value.self)) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/ToolbarItemPlacement.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ToolbarItemPlacement { 4 | /// Toolbar placement used on the primary side of a split view. 5 | public static var content: Self { 6 | #if os(macOS) 7 | .automatic 8 | #elseif os(iOS) 9 | .topBarTrailing 10 | #endif 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | /// A platform-appropriate color that mirrors the system's dark text color. 5 | public static var darkText: Color { 6 | #if os(macOS) 7 | Color(NSColor.labelColor) 8 | #elseif os(iOS) 9 | Color(UIColor.label) 10 | #endif 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/AppStorage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension AppStorage where Value: RawRepresentable, Value.RawValue == String { 4 | /// Convenience initializer that uses the type name as the storage key. 5 | public init(wrappedValue: Value, store: UserDefaults? = nil) { 6 | self.init( 7 | wrappedValue: wrappedValue, 8 | String(describing: Value.self), 9 | store: store 10 | ) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/App.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModule 2 | import SwiftUI 3 | import AppIntents 4 | 5 | // MARK: - App 6 | 7 | /// Entry point for the Swift Evolution sample application. 8 | @main 9 | struct App: SwiftUI.App { 10 | 11 | init() { 12 | AppShortcutsProvider.updateAppShortcutParameters() 13 | } 14 | 15 | var body: some Scene { 16 | AppScene() 17 | } 18 | } 19 | 20 | // MARK: - Preview 21 | 22 | #Preview(traits: .evolution) { 23 | ContentRootView() 24 | .environment(\.colorScheme, .dark) 25 | } 26 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Environment/EvolutionPreviewModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | import SwiftUI 3 | 4 | /// Injects in-memory proposal data into SwiftUI previews. 5 | struct EvolutionPreviewModifier: PreviewModifier { 6 | public static func makeSharedContext() throws -> ModelContainer { 7 | EnvironmentResolver.modelContainer(isStoredInMemoryOnly: true) 8 | } 9 | 10 | public func body(content: Content, context modelContainer: ModelContainer) -> some View { 11 | content 12 | .modifier(EnvironmentResolver(modelContainer)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/GithubURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct GithubURL: RawRepresentable, Codable, Hashable, Sendable { 4 | public let rawValue: URL 5 | 6 | public init(rawValue: URL) { 7 | self.rawValue = rawValue 8 | } 9 | 10 | public init(link: String) { 11 | var components = URLComponents() 12 | components.scheme = "https" 13 | components.host = "github.com" 14 | components.path = "/swiftlang/swift-evolution/blob/main/proposals" 15 | rawValue = components.url!.appending(path: link) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLSchemes 11 | 12 | swift-evolution-app 13 | 14 | 15 | 16 | NSAppTransportSecurity 17 | 18 | NSAllowsArbitraryLoadsInWebContent 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /App/Resources/AppIcon.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "fill" : { 3 | "automatic-gradient" : "srgb:0.94118,0.31765,0.21569,1.00000" 4 | }, 5 | "groups" : [ 6 | { 7 | "layers" : [ 8 | { 9 | "image-name" : "Logo.svg", 10 | "name" : "Logo" 11 | } 12 | ], 13 | "shadow" : { 14 | "kind" : "neutral", 15 | "opacity" : 0.5 16 | }, 17 | "translucency" : { 18 | "enabled" : true, 19 | "value" : 0.5 20 | } 21 | } 22 | ], 23 | "supported-platforms" : { 24 | "circles" : [ 25 | "watchOS" 26 | ], 27 | "squares" : "shared" 28 | } 29 | } -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/Binding.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftUI 3 | 4 | extension Binding> { 5 | /// Creates a binding that reflects whether the specified state is contained in the set. 6 | /// - Parameter state: The proposal status to monitor. 7 | public func isOn(_ state: ReviewState) -> Binding { 8 | Binding { 9 | wrappedValue.contains(state) 10 | } set: { isOn in 11 | if isOn { 12 | wrappedValue.insert(state) 13 | } else { 14 | wrappedValue.remove(state) 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | ## Changes 4 | - 5 | 6 | ## Motivation & Context 7 | - 8 | 9 | ## Screenshots 10 | - 11 | 12 | ## How to Test 13 | 1. 14 | 15 | ## Breaking Changes 16 | - [ ] なし 17 | - 影響がある場合は詳細に記述: 18 | 19 | ## Documentation 20 | - [ ] 必要なし 21 | - [ ] 更新した(README / API リファレンス / サンプル など) 22 | 23 | ## Checklist 24 | - [ ] PR タイトルは Conventional Commits に準拠(`feat:`, `fix:`, `docs:` 等) 25 | - [ ] 自己レビュー済み(lint/format 実行済み) 26 | - [ ] テスト追加/更新(必要に応じて) 27 | - [ ] CI グリーン 28 | - [ ] 関連 Issue をリンク(`Closes #` / `Fixes #` / `Refs #`) 29 | 30 | ## Related Issues 31 | - 32 | 33 | ## Notes for Reviewers 34 | - 35 | 36 | ## Changelog 37 | - 38 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalList/ProposalSortKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ProposalSortKey: String, Hashable, Sendable, CaseIterable, CustomStringConvertible, Identifiable { 4 | case proposalID 5 | case reviewStatus 6 | 7 | var id: String { 8 | rawValue 9 | } 10 | 11 | var description: String { 12 | switch self { 13 | case .proposalID: 14 | "Proposal ID" 15 | case .reviewStatus: 16 | "Review status" 17 | } 18 | } 19 | 20 | mutating func toggle() { 21 | switch self { 22 | case .proposalID: 23 | self = .reviewStatus 24 | case .reviewStatus: 25 | self = .proposalID 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/View.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(macOS) || os(tvOS) 4 | /// Minimal shim for `NavigationBarItem` APIs on platforms that lack them. 5 | public struct NavigationBarItem { 6 | public enum TitleDisplayMode { 7 | case automatic 8 | case inline 9 | case large 10 | } 11 | } 12 | #endif 13 | 14 | extension View { 15 | @inline(__always) 16 | /// Cross-platform wrapper around `navigationBarTitleDisplayMode`. 17 | /// - Parameter displayMode: Desired title display mode on iOS. 18 | public func iOSNavigationBarTitleDisplayMode(_ displayMode: NavigationBarItem.TitleDisplayMode) 19 | -> some View 20 | { 21 | #if os(macOS) || os(tvOS) 22 | self 23 | #else 24 | navigationBarTitleDisplayMode(displayMode) 25 | #endif 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /EvolutionModel/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | /// Package manifest for persistence models built on top of ``EvolutionCore``. 7 | let package = Package( 8 | name: "EvolutionModel", 9 | platforms: [ 10 | .iOS(.v26), .macOS(.v15), .tvOS(.v26), .watchOS(.v26), .visionOS(.v26) 11 | ], 12 | products: [ 13 | .library( 14 | name: "EvolutionModel", 15 | targets: ["EvolutionModel"] 16 | ), 17 | ], 18 | dependencies: [], 19 | targets: [ 20 | .target( 21 | name: "EvolutionModel", 22 | dependencies: [] 23 | ), 24 | .testTarget( 25 | name: "EvolutionModelTests", 26 | dependencies: ["EvolutionModel"] 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalDetail/ProposalDetailToolBar.swift: -------------------------------------------------------------------------------- 1 | import EvolutionUI 2 | import SwiftUI 3 | 4 | // MARK: - ProposalDetailToolBar 5 | 6 | /// Toolbar content used within ``ProposalDetailView``. 7 | struct ProposalDetailToolBar: ToolbarContent { 8 | /// Backing view model for the proposal detail screen. 9 | @Bindable var viewModel: ProposalDetailViewModel 10 | 11 | var body: some ToolbarContent { 12 | ToolbarItem { 13 | Menu("Menu", systemImage: "ellipsis") { 14 | OpenSafariButton(proposal: viewModel.proposal) 15 | BookmarkButton(isBookmarked: $viewModel.isBookmarked) 16 | if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { 17 | TranslateButton(isTranslating: viewModel.translating, action: viewModel.translate) 18 | } 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/BlockStyle.swift: -------------------------------------------------------------------------------- 1 | import Markdown 2 | import MarkdownUI 3 | import Splash 4 | import SwiftData 5 | import SwiftUI 6 | 7 | @MainActor 8 | extension BlockStyle where Configuration == ListMarkerConfiguration { 9 | /// A list marker that displays a small filled circle. 10 | public static var customCircle: Self { 11 | BlockStyle { _ in 12 | Circle() 13 | .frame(width: 6, height: 6) 14 | .relativeFrame(minWidth: .zero, alignment: .trailing) 15 | } 16 | } 17 | 18 | /// A list marker that renders ordered list numbers with monospaced digits. 19 | public static var customDecimal: Self { 20 | BlockStyle { configuration in 21 | Text("\(configuration.itemNumber).") 22 | .monospacedDigit() 23 | .relativeFrame(minWidth: .zero, alignment: .trailing) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/ProposalStatusState.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftUI 3 | 4 | extension ReviewState { 5 | /// Display color associated with each proposal status. 6 | public var color: Color { 7 | switch self { 8 | case .accepted: 9 | .green 10 | case .activeReview: 11 | .orange 12 | case .implemented: 13 | .blue 14 | case .previewing: 15 | .mint 16 | case .rejected: 17 | .red 18 | case .returnedForRevision: 19 | .purple 20 | case .withdrawn: 21 | .gray 22 | case .unknown: 23 | .gray 24 | } 25 | } 26 | } 27 | 28 | extension EnvironmentValues { 29 | /// Environment value storing the set of currently selected proposal states. 30 | @Entry public var selectedStatus: Set = .init(ReviewState.allCases) 31 | } 32 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | StringsTable 6 | Root 7 | PreferenceSpecifiers 8 | 9 | 10 | Type 11 | PSGroupSpecifier 12 | 13 | 14 | Type 15 | PSChildPaneSpecifier 16 | Title 17 | 謝辞 18 | File 19 | Acknowledgements 20 | 21 | 22 | Type 23 | PSTitleValueSpecifier 24 | DefaultValue 25 | 1.0.0(1) 26 | Title 27 | バージョン 28 | Key 29 | version 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /.cursor/commands/commit.md: -------------------------------------------------------------------------------- 1 | ## Instructions 2 | 以下の3つを直列に実行してください。**それ以外の行動は一切禁止**です。 3 | 4 | > 💡 このコマンドはデフォルトシェルが **zsh** であることを前提としています(Bash 互換構文)。 5 | 6 | 1. 現在の Git の staged な変更を `git diff --cached` で確認する 7 | 2. Git の staged な変更をもとに、**英語のコミットメッセージを作成する** 8 | - メッセージは **Conventional Commits** のフォーマットに**必ず**従うこと 9 | - 単一行の要約に加え、必要に応じて**複数行の詳細説明**を含めてもよい 10 | - 複数行の詳細説明は、**行頭をハイフン(`-`)で始める箇条書き**とする 11 | - 詳細説明の**行数は最大3行**とする。コミットは 3. の方法で改行すること。 12 | - 記述内容の指針: 13 | - **変更の背景・理由(Why)** と **影響範囲** を中心に。**実装詳細(How)** は必要な範囲で。 14 | - **1行あたり 72 文字前後**を目安(CLI やメール連携での可読性向上のため)。 15 | - 必要に応じて **関連 Issue/PR**、**ブレイキングチェンジ**、**移行手順** などを記載。 16 | 3. 作成したコミットメッセージを用いて `git commit` を実行する 17 | 例(zsh): 18 | ```zsh 19 | git commit \ 20 | -m 'feat: add new animation easing function' \ 21 | -m $'- Introduces a custom elastic ease-in-out curve.\n- Focused on smoother transition and natural rebound effect.' 22 | ``` 23 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/UIViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - NSViewRepresentable 4 | #if os(macOS) 5 | /// Cross-platform alias that mirrors `UIViewRepresentable` on macOS by wrapping `NSViewRepresentable`. 6 | public protocol UIViewRepresentable: NSViewRepresentable where NSViewType == ViewType { 7 | associatedtype ViewType: NSView 8 | @MainActor 9 | func makeUIView(context: Context) -> ViewType 10 | @MainActor 11 | func updateUIView(_ uiView: ViewType, context: Context) 12 | } 13 | 14 | extension UIViewRepresentable { 15 | @MainActor 16 | public func makeNSView(context: Context) -> ViewType { 17 | makeUIView(context: context) 18 | } 19 | 20 | @MainActor 21 | public func updateNSView(_ uiView: ViewType, context: Context) { 22 | updateUIView(uiView, context: context) 23 | } 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/BookmarkButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A button that toggles the bookmark state for a proposal. 4 | public struct BookmarkButton: View { 5 | /// Binding that reflects whether the proposal is bookmarked. 6 | @Binding var isBookmarked: Bool 7 | 8 | /// Creates a bookmark toggle bound to the given state. 9 | public init(isBookmarked: Binding) { 10 | _isBookmarked = isBookmarked 11 | } 12 | 13 | public var body: some View { 14 | Button("Bookmark", systemImage: isBookmarked ? "bookmark.fill" : "bookmark") { 15 | withAnimation { 16 | isBookmarked.toggle() 17 | } 18 | } 19 | .animation(.default, value: isBookmarked) 20 | } 21 | 22 | /// The SF Symbol name for the current state. 23 | var symbol: String { 24 | isBookmarked ? "bookmark.fill" : "bookmark" 25 | } 26 | } 27 | 28 | #Preview { 29 | BookmarkButton(isBookmarked: .constant(false)) 30 | } 31 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/Acknowledgements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | Type 9 | PSChildPaneSpecifier 10 | Title 11 | github-markdown-css 12 | File 13 | LICENSE_github-markdown-css 14 | 15 | 16 | Type 17 | PSChildPaneSpecifier 18 | Title 19 | highlight.js 20 | File 21 | LICENSE_highlight-js 22 | 23 | 24 | Type 25 | PSChildPaneSpecifier 26 | Title 27 | marked 28 | File 29 | LICENSE_marked 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalList/ProposalListMode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | public enum ProposalListMode: Hashable, Sendable, RawRepresentable { 5 | case all 6 | case bookmark 7 | case search(String) 8 | 9 | public init(rawValue: String) { 10 | switch rawValue { 11 | case "all": 12 | self = .all 13 | case "bookmark": 14 | self = .bookmark 15 | case let text where text.hasPrefix("search:"): 16 | let query = String(text.dropFirst("search:".count)) 17 | self = .search(query) 18 | default: 19 | fatalError("Unknown proposal list mode: \(rawValue)") 20 | } 21 | } 22 | 23 | public var rawValue: String { 24 | switch self { 25 | case .all: 26 | return "all" 27 | case .bookmark: 28 | return "bookmark" 29 | case .search(let query): 30 | return "search:\(query)" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Environment/EnvironmentResolver.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftData 3 | import SwiftUI 4 | 5 | public struct EnvironmentResolver: ViewModifier { 6 | public nonisolated static func modelContainer(isStoredInMemoryOnly: Bool = false) -> ModelContainer { 7 | try! ModelContainer( 8 | for: Proposal.self, Markdown.self, 9 | configurations: ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly) 10 | ) 11 | } 12 | 13 | var modelContainer: ModelContainer 14 | 15 | init(_ modelContainer: ModelContainer) { 16 | self.modelContainer = modelContainer 17 | } 18 | 19 | public func body(content: Content) -> some View { 20 | content 21 | .modelContainer(modelContainer) 22 | .environment(ContentViewModel(modelContainer: modelContainer)) 23 | .environment(BookmarkRepository(modelContainer: modelContainer)) 24 | .environment(ProposalRepository(modelContainer: modelContainer)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Bookmark/Bookmark.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - Bookmark 5 | 6 | /// Stores a user's bookmarked proposal for quick access. 7 | /// 8 | /// The model keeps track of the associated `Proposal` and when the 9 | /// bookmark was last updated. Each bookmark is uniquely identified by the 10 | /// proposal's identifier. 11 | @Model 12 | public final class Bookmark { 13 | #Unique([\.proposalID]) 14 | 15 | /// Unique identifier for the bookmarked proposal. 16 | @Attribute(.unique) public private(set) var proposalID: String 17 | 18 | /// The proposal associated with this bookmark. 19 | @Attribute(.unique) public private(set) var proposal: Proposal 20 | 21 | /// Timestamp indicating when the bookmark was last modified. 22 | public var updatedAt: Date 23 | 24 | /// Creates a new bookmark for the given proposal. 25 | init(proposal: Proposal) { 26 | self.proposal = proposal 27 | self.proposalID = proposal.proposalID 28 | self.updatedAt = .now 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/FallbackGlassEffect.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Applies a glass-like material, falling back to ``.ultraThinMaterial`` on 4 | /// platforms where the native ``View.glassEffect(in:)`` modifier is 5 | /// unavailable. 6 | public struct FallbackGlassEffect: ViewModifier { 7 | /// Shape that defines the region of the glass effect. 8 | var shape: S 9 | 10 | public func body(content: Content) -> some View { 11 | if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { 12 | content.glassEffect(in: shape) 13 | } else { 14 | content.background(.ultraThinMaterial.opacity(0.7), in: shape) 15 | } 16 | } 17 | } 18 | 19 | public extension View { 20 | /// Wraps the view in a platform-appropriate glass effect using the 21 | /// provided shape. 22 | /// - Parameter shape: The shape that bounds the glass effect. 23 | func fallbackGlassEffect(shape: S) -> some View { 24 | modifier(FallbackGlassEffect(shape: shape)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/AppScene.swift: -------------------------------------------------------------------------------- 1 | import EvolutionUI 2 | import SwiftUI 3 | 4 | // MARK: - AppScene 5 | 6 | /// Root scene for the application that wires up model containers and commands. 7 | @MainActor 8 | public struct AppScene { 9 | @State private var modelContainer = EnvironmentResolver.modelContainer() 10 | 11 | /// Creates the scene with the provided root content view. 12 | public init() { } 13 | } 14 | 15 | // MARK: - Scene 16 | 17 | extension AppScene: Scene { 18 | /// Body of the SwiftUI scene that hosts the app's content and commands. 19 | public var body: some Scene { 20 | WindowGroup { 21 | ContentRootView() 22 | .modifier(EnvironmentResolver(modelContainer)) 23 | } 24 | .commands { 25 | FilterCommands() 26 | } 27 | #if os(macOS) 28 | Settings { 29 | SettingsView() 30 | } 31 | #endif 32 | } 33 | } 34 | 35 | // MARK: - Preview 36 | 37 | #Preview(traits: .evolution) { 38 | ContentRootView() 39 | .environment(\.colorScheme, .dark) 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 takehito 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/Proposal+Snapshot.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - Snapshot 5 | 6 | extension Proposal { 7 | /// Immutable view of a ``Proposal`` used for value semantics. 8 | struct Snapshot: Hashable, Codable, Sendable { 9 | /// Unique proposal identifier such as "SE-0001". 10 | var id: String 11 | /// Path to the proposal's markdown on GitHub. 12 | var link: String 13 | /// Current review status. 14 | var status: Status 15 | /// Proposal title. 16 | var title: String 17 | } 18 | } 19 | 20 | extension Proposal.Snapshot { 21 | /// Metadata describing the review lifecycle of a proposal. 22 | struct Status: Codable, Hashable, Sendable { 23 | /// Raw state string such as "activeReview" or "accepted". 24 | var state: String 25 | /// Version of Swift in which the change shipped, if any. 26 | var version: String? 27 | /// The end date for the proposal's review period. 28 | var end: String? 29 | /// The start date for the proposal's review period. 30 | var start: String? 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/TranslateButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Button that toggles translation of the proposal's markdown content. 4 | /// Displays a progress indicator while translation is in progress. 5 | public struct TranslateButton: View { 6 | private var isTranslating: Bool 7 | private var action: () async throws -> Void 8 | 9 | public init(isTranslating: Bool, action: @escaping () async throws -> Void) { 10 | self.isTranslating = isTranslating 11 | self.action = action 12 | } 13 | 14 | public var body: some View { 15 | if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { 16 | if !isTranslating { 17 | Button("Translate", systemImage: "character.bubble") { 18 | Task { 19 | try await action() 20 | } 21 | } 22 | } else { 23 | ZStack { 24 | Button("Translate", systemImage: "character.bubble") {} 25 | .hidden() 26 | ProgressView() 27 | } 28 | } 29 | } else { 30 | EmptyView() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Markdown/Markdown.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - Markdown 5 | 6 | /// Represents the markdown content for a specific Swift Evolution proposal. 7 | /// 8 | /// Each instance tracks the remote source URL, the proposal identifier, and 9 | /// optionally the fetched markdown text. 10 | @Model 11 | public final class Markdown { 12 | #Unique([\.url, \.proposalID]) 13 | 14 | /// The remote URL pointing to the markdown file. 15 | @Attribute(.unique) public private(set) var url: URL 16 | 17 | /// The proposal identifier, such as "SE-0001". 18 | @Attribute(.unique) public private(set) var proposalID: String 19 | 20 | /// Raw markdown text, populated after the file is fetched. 21 | public var text: String? 22 | 23 | /// Creates a new markdown record. 24 | /// - Parameters: 25 | /// - url: Location of the markdown file. 26 | /// - proposalID: Identifier for the proposal this markdown belongs to. 27 | /// - text: Optional markdown string if already loaded. 28 | init(url: URL, proposalID: String, text: String? = nil) { 29 | self.url = url 30 | self.proposalID = proposalID 31 | self.text = text 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.cursor/commands/pr.md: -------------------------------------------------------------------------------- 1 | ## Instructions 2 | 以下の4つを直列に実行してください。**それ以外の行動は一切禁止**です。 3 | 4 | > 💡 このコマンドはデフォルトシェルが **zsh** であることを前提としています(Bash 互換構文)。 5 | 6 | 1. 現在のブランチと派生元ブランチを確認する 7 | - `git branch --show-current` で現在のブランチを確認 8 | - `git remote -v` でリモートリポジトリを確認 9 | - `BASE=$(git merge-base --fork-point origin/main HEAD 2>/dev/null || echo origin/main)` で基点を設定 10 | - `git log --oneline "$BASE"..HEAD --graph --decorate` でコミット履歴を確認 11 | - `git diff "$BASE"..HEAD` で差分を確認 12 | 13 | 2. プルリクエストのタイトルと説明を作成する 14 | - タイトル、説明文は**必ず英文**で作成 15 | - 説明は `.github/pull_request_template.md` のフォーマットに**必ず**従うこと 16 | - **Summary**、**Changes**、**Motivation & Context** を中心に記載 17 | 18 | 3. GitHub CLI を使用してプルリクエストを作成する 19 | ```zsh 20 | gh pr create \ 21 | --title "feat: add new feature" \ 22 | --body "## Summary 23 | Brief description of the changes 24 | 25 | ## Changes 26 | - Added new feature X 27 | - Improved performance of Y 28 | - Updated documentation for Z 29 | 30 | ## Motivation & Context 31 | - Why this change was necessary 32 | - What problem it solves 33 | - How it improves the codebase" \ 34 | --base main 35 | ``` 36 | 37 | 4. 作成されたプルリクエストのURLを表示する 38 | - `gh pr list --state open --limit 1 --json url --jq '.[0].url'` でプルリクエストのURLを出力する 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | types: [ opened, synchronize, reopened ] 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-26 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Xcode 26 15 | uses: maxim-lobanov/setup-xcode@v1 16 | with: 17 | xcode-version: '26.0.1' 18 | 19 | - name: Install xcpretty 20 | run: gem install xcpretty 21 | 22 | - name: List available simulators 23 | run: xcrun simctl list devices 24 | 25 | - name: Build SwiftEvolution app 26 | run: | 27 | xcodebuild -project SwiftEvolution.xcodeproj -scheme App \ 28 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \ 29 | CODE_SIGNING_ALLOWED=NO clean build | xcpretty 30 | 31 | - name: Build Swift Packages 32 | run: | 33 | swift build --package-path EvolutionModel 34 | swift build --package-path EvolutionUI 35 | swift build --package-path EvolutionModule 36 | 37 | - name: Run Swift Package Tests 38 | run: | 39 | swift test --package-path EvolutionModel 40 | swift test --package-path EvolutionUI 41 | swift test --package-path EvolutionModule 42 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/Proposal+FetchDescriptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | extension FetchDescriptor { 5 | /// Convenience helper for building a descriptor that looks up a proposal by ID. 6 | public static func id(_ proposalID: String) -> Self { 7 | FetchDescriptor(predicate: #Predicate { 8 | $0.proposalID == proposalID 9 | }) 10 | } 11 | 12 | public static func ids(_ proposalIDs: some Sequence) -> Self { 13 | FetchDescriptor(predicate: .ids(proposalIDs)) 14 | } 15 | } 16 | 17 | extension Predicate { 18 | public static func ids(_ ids: some Sequence) -> Predicate { 19 | let ids = Set(ids) 20 | return #Predicate { 21 | ids.contains($0.proposalID) 22 | } 23 | } 24 | 25 | public static func ids(_ ids: String...) -> Predicate { 26 | self.ids(ids) 27 | } 28 | 29 | public static func states(_ states: some Sequence = ReviewState.allCases) -> Predicate { 30 | let states = Set(states.map(\.rawValue)) 31 | return #Predicate { 32 | states.contains($0.status.state.rawValue) 33 | } 34 | } 35 | 36 | public static func states(_ states: ReviewState...) -> Predicate { 37 | self.states(states) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Extensions/UIColor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Color 4 | #if os(macOS) 5 | /// Alias to `NSColor` for cross-platform code sharing. 6 | public typealias UIColor = NSColor 7 | extension UIColor { 8 | /// Default tint color equivalent for macOS. 9 | static var tintColor: UIColor { 10 | controlTextColor.usingColorSpace(.extendedSRGB)! 11 | } 12 | 13 | /// Background color matching the system window background. 14 | public static var systemBackground: UIColor { 15 | windowBackgroundColor.usingColorSpace(.extendedSRGB)! 16 | } 17 | 18 | /// Secondary background color used for grouped content areas. 19 | public static var secondarySystemBackground: UIColor { 20 | windowBackgroundColor.usingColorSpace(.extendedSRGB)! 21 | } 22 | 23 | /// Label color in the extended sRGB color space. 24 | public static var label: UIColor { 25 | labelColor.usingColorSpace(.extendedSRGB)! 26 | } 27 | } 28 | 29 | extension NSView { 30 | /// Convenience property to bridge AppKit and UIKit background colors. 31 | public var backgroundColor: UIColor? { 32 | get { 33 | (layer?.backgroundColor).flatMap(UIColor.init(cgColor:)) 34 | } 35 | set { 36 | layer?.backgroundColor = newValue?.cgColor 37 | } 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/BookmarkMenu.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftUI 3 | 4 | public struct BookmarkMenu: View { 5 | @Environment(BookmarkRepository.self) private var repository 6 | /// Proposal to be presented. 7 | private let proposal: Proposal 8 | 9 | private var isBookmarked: Bool { 10 | proposal.bookmark != nil 11 | } 12 | 13 | public init(proposal: Proposal) { 14 | self.proposal = proposal 15 | } 16 | 17 | public var body: some View { 18 | let isBookmarked = isBookmarked 19 | let text = isBookmarked ? "Remove Bookmark" : "Add Bookmark" 20 | Button(action: toggle) { 21 | Label(text, systemImage: "bookmark") 22 | } 23 | .symbolVariant(isBookmarked ? .none : .fill) 24 | .tint(ReviewState(proposal: proposal).color) 25 | } 26 | 27 | func toggle() { 28 | // Toggle Bookmark 29 | try? repository.update(id: proposal.proposalID, isBookmarked: !isBookmarked) 30 | } 31 | } 32 | 33 | public struct OpenSafariButton: View { 34 | @Environment(\.openURL) private var openURL 35 | 36 | private let proposal: Proposal 37 | 38 | public init(proposal: Proposal) { 39 | self.proposal = proposal 40 | } 41 | 42 | public var body: some View { 43 | Button("Open Safari", systemImage: "safari") { 44 | openURL(GithubURL(link: proposal.link).rawValue) 45 | } 46 | .tint(ReviewState(proposal: proposal).color) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Markdown/MarkdownURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Converts proposal links into URLs that point directly to raw markdown files. 4 | struct MarkdownURL: RawRepresentable, Codable, Hashable, Sendable { 5 | /// The fully qualified URL of the markdown document. 6 | let rawValue: URL 7 | 8 | /// Directly wraps an existing markdown URL. 9 | /// - Parameter rawValue: Fully qualified URL to a markdown document. 10 | init(rawValue: URL) { 11 | self.rawValue = rawValue 12 | } 13 | 14 | /// Creates a ``MarkdownURL`` from an existing GitHub page URL by converting 15 | /// it to the corresponding `raw.githubusercontent.com` location. 16 | /// - Parameter url: Standard GitHub URL to a markdown file. 17 | init(url: URL) { 18 | let host = "raw.githubusercontent.com" 19 | var component = URLComponents(url: url, resolvingAgainstBaseURL: false)! 20 | component.host = host 21 | component.path = component.path.replacingOccurrences(of: "/blob", with: "") 22 | self.rawValue = component.url! 23 | } 24 | 25 | /// Creates a ``MarkdownURL`` for a proposal using its link value from the 26 | /// proposal feed. 27 | /// - Parameter link: The path portion of the proposal URL. 28 | init(link: String) { 29 | var component = URLComponents() 30 | component.scheme = "https" 31 | component.host = "raw.githubusercontent.com" 32 | component.path = "/swiftlang/swift-evolution/main/proposals/\(link)" 33 | self.rawValue = component.url! 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/LICENSE_github-markdown-css.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | Type 9 | PSGroupSpecifier 10 | FooterText 11 | MIT License 12 | 13 | Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /EvolutionUI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | /// UI components and presentation logic for the Swift Evolution client. 7 | let package = Package( 8 | name: "EvolutionUI", 9 | platforms: [ 10 | .iOS(.v26), .macOS(.v15), .tvOS(.v26), .watchOS(.v26), .visionOS(.v26), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "EvolutionUI", 16 | targets: ["EvolutionUI"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(path: "../EvolutionModel"), 21 | .package(url: "https://github.com/swiftlang/swift-markdown.git", .upToNextMinor(from: "0.6.0")), 22 | .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", .upToNextMinor(from: "2.4.1")), 23 | .package(url: "https://github.com/JohnSundell/Splash.git", .upToNextMinor(from: "0.16.0")), 24 | ], 25 | targets: [ 26 | .target( 27 | name: "EvolutionUI", 28 | dependencies: [ 29 | "EvolutionModel", 30 | .product(name: "Markdown", package: "swift-markdown"), 31 | .product(name: "MarkdownUI", package: "swift-markdown-ui"), 32 | .product(name: "Splash", package: "Splash") 33 | ] 34 | ), 35 | .testTarget( 36 | name: "EvolutionUITests", 37 | dependencies: ["EvolutionUI"] 38 | ), 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A reusable view that displays an error message with an optional retry button. 4 | public struct ErrorView: View { 5 | /// The error to present to the user. 6 | public var error: Error? 7 | /// Action executed when the user chooses to retry. 8 | public var retry: (() -> Void)? 9 | 10 | public init(error: Error? = nil, retry: (() -> Void)? = nil) { 11 | self.error = error 12 | self.retry = retry 13 | } 14 | 15 | public var body: some View { 16 | Group { 17 | if let error { 18 | ContentUnavailableView { 19 | Label("Error", systemImage: "exclamationmark.triangle") 20 | } description: { 21 | Text(error.localizedDescription) 22 | } actions: { 23 | if let retry { 24 | Button("Retry", action: retry) 25 | } 26 | } 27 | } 28 | } 29 | .animation(.default, value: error as? NSError) 30 | } 31 | } 32 | 33 | extension ErrorView { 34 | /// Convenience initializer that triggers a `UUID`-based refresh when retrying. 35 | public init(error: (any Error)? = nil, _ retry: Binding) { 36 | self.init(error: error) { 37 | retry.wrappedValue = UUID() 38 | } 39 | } 40 | } 41 | 42 | #Preview { 43 | let error = URLError( 44 | .notConnectedToInternet, 45 | userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] 46 | ) 47 | return ErrorView(error: error, retry: { }) 48 | } 49 | -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c42551310cce7e9375bed75a1b5daf32a11602ccd63bd41ce7c402e41182cf0c", 3 | "pins" : [ 4 | { 5 | "identity" : "networkimage", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/gonzalezreal/NetworkImage", 8 | "state" : { 9 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 10 | "version" : "6.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "splash", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/JohnSundell/Splash.git", 17 | "state" : { 18 | "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8", 19 | "version" : "0.16.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-cmark", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/swiftlang/swift-cmark.git", 26 | "state" : { 27 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", 28 | "version" : "0.6.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-markdown", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-markdown", 35 | "state" : { 36 | "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", 37 | "version" : "0.6.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-markdown-ui", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 44 | "state" : { 45 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 46 | "version" : "2.4.1" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/FilterCommands.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftUI 3 | 4 | @MainActor 5 | /// Menu commands for filtering proposals and toggling bookmarks. 6 | public struct FilterCommands { 7 | @StatusFilter private var filter 8 | @AppStorage("isBookmarked") private var isBookmarked: Bool = false 9 | 10 | public init() {} 11 | } 12 | 13 | extension FilterCommands: Commands { 14 | public var body: some Commands { 15 | CommandMenu("Filter") { 16 | Divider() 17 | Menu("Review Status") { 18 | ForEach(0..<3, id: \.self) { index in 19 | let option = ReviewState.allCases[index] 20 | Toggle(option.description, isOn: $filter(option)) 21 | Toggle(option.description, isOn: .constant(false)) 22 | .keyboardShortcut(.init(Character("\(index + 1)")), modifiers: [.command]) 23 | } 24 | 25 | Divider() 26 | 27 | Button("Select All") { 28 | let allCases = ReviewState.allCases 29 | filter = .init(uniqueKeysWithValues: allCases.map { ($0, true) }) 30 | } 31 | .disabled(filter.values.allSatisfy(\.self)) 32 | .keyboardShortcut("A", modifiers: [.command, .shift]) 33 | 34 | Button("Deselect All") { 35 | filter = [:] 36 | } 37 | .disabled(filter.values.allSatisfy { !$0 }) 38 | .keyboardShortcut("D", modifiers: [.command, .shift]) 39 | } 40 | Divider() 41 | 42 | Toggle("Show Bookmarks Only", isOn: $isBookmarked) 43 | .keyboardShortcut("B", modifiers: [.command, .shift]) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /EvolutionModule/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | /// High-level feature modules composed from core models and UI components. 7 | let package = Package( 8 | name: "EvolutionModule", 9 | platforms: [ 10 | .iOS(.v26), .macOS(.v15), .tvOS(.v26), .watchOS(.v26), .visionOS(.v26), 11 | ], 12 | products: [ 13 | .library( 14 | name: "EvolutionModule", 15 | targets: ["EvolutionModule"] 16 | ), 17 | ], 18 | dependencies: [ 19 | .package(path: "../EvolutionModel"), 20 | .package(path: "../EvolutionUI"), 21 | .package(url: "https://github.com/swiftlang/swift-markdown.git", .upToNextMinor(from: "0.6.0")), 22 | .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", .upToNextMinor(from: "2.4.1")), 23 | .package(url: "https://github.com/JohnSundell/Splash.git", .upToNextMinor(from: "0.16.0")), 24 | ], 25 | targets: [ 26 | // Targets are the basic building blocks of a package, defining a module or a test suite. 27 | // Targets can depend on other targets in this package and products from dependencies. 28 | .target( 29 | name: "EvolutionModule", 30 | dependencies: [ 31 | "EvolutionModel", 32 | "EvolutionUI", 33 | .product(name: "Markdown", package: "swift-markdown"), 34 | .product(name: "MarkdownUI", package: "swift-markdown-ui"), 35 | .product(name: "Splash", package: "Splash") 36 | ] 37 | ), 38 | .testTarget( 39 | name: "EvolutionModuleTests", 40 | dependencies: ["EvolutionModule"] 41 | ), 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-iOS-Default-1024x1024@1x.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "16x16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "16x16@2x.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "32x32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "32x32@2x.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "128x128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "128x128@2x.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "256x256.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "256x256@2x.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "512x512.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "512x512@2x.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | }, 69 | { 70 | "filename" : "AppIcon 1.png", 71 | "idiom" : "universal", 72 | "platform" : "watchos", 73 | "size" : "1024x1024" 74 | } 75 | ], 76 | "info" : { 77 | "author" : "xcode", 78 | "version" : 1 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/CopiedHUD.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Displays a transient heads-up display after code is copied to the clipboard. 4 | public struct CopiedHUD: View { 5 | /// The most recently copied code snippet, if any. 6 | var copied: CopiedCode? 7 | /// Tracks the size of the foreground content. 8 | @State var size = CGSize(width: Double.infinity, height: .infinity) 9 | /// Tracks the size of the background container. 10 | @State var backgroundSize = CGSize(width: Double.infinity, height: .infinity) 11 | 12 | /// Creates a HUD for the given copied code snippet. 13 | public init(copied: CopiedCode? = nil) { 14 | self.copied = copied 15 | } 16 | 17 | public var body: some View { 18 | VStack(spacing: 0) { 19 | let imageEdge: CGFloat = min(backgroundSize.width / 3, backgroundSize.height / 3) 20 | Image(systemName: "checkmark.circle") 21 | .resizable() 22 | .scaledToFit() 23 | .font(.title3) 24 | .frame(maxWidth: imageEdge, maxHeight: imageEdge) 25 | .padding() 26 | Text(copied != nil ? "Copied!" : "") 27 | .font(.title) 28 | .fontWeight(.bold) 29 | .contentTransition(.opacity) 30 | .animation(.default, value: copied) 31 | } 32 | .onGeometryChange(for: CGSize.self, of: \.size) { size = $1 } 33 | .frame(maxWidth: size.width, maxHeight: size.height) 34 | .padding() 35 | .fallbackGlassEffect(shape: .rect(cornerRadius: 18)) 36 | .opacity(copied != nil ? 1 : 0) 37 | .symbolRenderingMode(.hierarchical) 38 | .foregroundStyle(.tint) 39 | .symbolEffect(.bounce, value: copied) 40 | .frame(maxWidth: .infinity, maxHeight: .infinity) 41 | .onGeometryChange(for: CGSize.self, of: \.size) { 42 | backgroundSize = $1 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/MarkdownStyleModifier.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import Splash 3 | import SwiftUI 4 | 5 | /// Configures markdown styling for proposal content. 6 | public struct MarkdownStyleModifier: ViewModifier { 7 | /// Current color scheme of the environment. 8 | @Environment(\.colorScheme) private var colorScheme 9 | 10 | public init() {} 11 | 12 | public func body(content: Content) -> some View { 13 | content 14 | .markdownBulletedListMarker(.customCircle) 15 | .markdownNumberedListMarker(.customDecimal) 16 | .markdownTextStyle(\.code) { 17 | FontFamilyVariant(.monospaced) 18 | FontSize(.em(0.85)) 19 | ForegroundColor(Color(UIColor.label)) 20 | BackgroundColor(Color(UIColor.label).opacity(0.2)) 21 | } 22 | .markdownBlockStyle(\.blockquote) { configuration in 23 | configuration.label 24 | .padding() 25 | .markdownTextStyle { 26 | FontCapsVariant(.lowercaseSmallCaps) 27 | FontWeight(.semibold) 28 | BackgroundColor(nil) 29 | } 30 | .overlay(alignment: .leading) { 31 | Rectangle() 32 | .fill(Color(UIColor.tintColor)) 33 | .frame(width: 4) 34 | } 35 | .background(Color(UIColor.tintColor).opacity(0.5)) 36 | } 37 | .markdownBlockStyle(\.codeBlock) { 38 | MyCodeBlock(configuration: $0) 39 | } 40 | .markdownCodeSyntaxHighlighter(.splash(theme: theme)) 41 | } 42 | 43 | private var theme: Splash.Theme { 44 | // NOTE: We are ignoring the Splash theme font 45 | switch colorScheme { 46 | case .dark: 47 | return .wwdc18(withFont: .init(size: 16)) 48 | default: 49 | return .sunset(withFont: .init(size: 16)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/LICENSE_highlight-js.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | Type 9 | PSGroupSpecifier 10 | FooterText 11 | BSD 3-Clause License 12 | 13 | Copyright (c) 2006, Ivan Sagalaev. 14 | All rights reserved. 15 | 16 | Redistribution and use in source and binary forms, with or without 17 | modification, are permitted provided that the following conditions are met: 18 | 19 | * Redistributions of source code must retain the above copyright notice, this 20 | list of conditions and the following disclaimer. 21 | 22 | * Redistributions in binary form must reproduce the above copyright notice, 23 | this list of conditions and the following disclaimer in the documentation 24 | and/or other materials provided with the distribution. 25 | 26 | * Neither the name of the copyright holder nor the names of its 27 | contributors may be used to endorse or promote products derived from 28 | this software without specific prior written permission. 29 | 30 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 31 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 32 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 33 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 34 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 35 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 36 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 37 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 38 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 39 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalList/ProposalQuery.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | // MARK: - ProposalQuery 7 | 8 | @MainActor 9 | struct ProposalQuery: DynamicProperty { 10 | /// Currently selected status filter. 11 | @StatusFilter var reviewStates: [ReviewState : Bool] 12 | 13 | @SceneStorage var sortKey: ProposalSortKey = .proposalID 14 | 15 | var mode: ProposalListMode = .all 16 | } 17 | 18 | private extension ProposalQuery { 19 | var filter: Predicate { 20 | let states = Set(reviewStates.lazy.filter(\.value).map(\.key.rawValue)) 21 | /// Predicate limiting results to the selected states and optional bookmark filter. 22 | switch mode { 23 | case .all: 24 | return #Predicate { proposal in 25 | states.contains(proposal.status.state.rawValue) 26 | } 27 | case .bookmark: 28 | return #Predicate { proposal in 29 | states.contains(proposal.status.state.rawValue) && (proposal.bookmark != nil) 30 | } 31 | case .search(let text): 32 | return text.isEmpty ? Predicate.true : #Predicate { proposal in 33 | proposal.title.contains(text) || 34 | proposal.proposalID.contains(text) || 35 | proposal.status.state.title.contains(text) 36 | } 37 | } 38 | } 39 | 40 | var sort: [SortDescriptor] { 41 | switch sortKey { 42 | case .proposalID: 43 | return [SortDescriptor(\.proposalID, order: .reverse)] 44 | case .reviewStatus: 45 | return [ 46 | SortDescriptor(\.status.state.order), 47 | SortDescriptor(\.status.version.code, order: .reverse), 48 | SortDescriptor(\.proposalID, order: .reverse) 49 | ] 50 | } 51 | } 52 | } 53 | 54 | extension Query { 55 | init(_ query: ProposalQuery) { 56 | self = Query(filter: query.filter, sort: query.sort) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/ReviewState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - State 4 | 5 | /// Enumerates the standardized set of review states a proposal may be in. 6 | public enum ReviewState: String, Codable, Hashable, CaseIterable, Sendable, Identifiable, CustomStringConvertible, Comparable { 7 | case activeReview 8 | case accepted 9 | case implemented 10 | case previewing 11 | case rejected 12 | case returnedForRevision 13 | case withdrawn 14 | case unknown 15 | 16 | /// Conformance to `Identifiable`. 17 | public var id: String { rawValue } 18 | 19 | /// Human-friendly name displayed to users. 20 | public var description: String { 21 | switch self { 22 | case .activeReview: 23 | "Active Review" 24 | case .accepted: 25 | "Accepted" 26 | case .implemented: 27 | "Implemented" 28 | case .previewing: 29 | "Previewing" 30 | case .rejected: 31 | "Rejected" 32 | case .returnedForRevision: 33 | "Returned" 34 | case .withdrawn: 35 | "Withdrawn" 36 | case .unknown: 37 | "Unknown" 38 | } 39 | } 40 | 41 | public var order: Int { 42 | switch self { 43 | case .activeReview: 44 | 0 45 | case .accepted: 46 | 1 47 | case .implemented: 48 | 2 49 | case .previewing: 50 | 3 51 | case .rejected: 52 | 4 53 | case .returnedForRevision: 54 | 5 55 | case .withdrawn: 56 | 6 57 | case .unknown: 58 | Int.max 59 | } 60 | } 61 | 62 | /// Initializes from a ``Proposal`` model if the state string matches a known case. 63 | public init(proposal: Proposal) { 64 | self = ReviewState.init(rawValue: proposal.status.state.rawValue) ?? .unknown 65 | } 66 | 67 | /// Provides ordering for states based on their declaration order. 68 | public static func < (lhs: Self, rhs: Self) -> Bool { 69 | lhs.order < rhs.order 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/SplashCodeSyntaxHighlighter.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import Splash 3 | import SwiftUI 4 | 5 | public extension CodeSyntaxHighlighter where Self == SplashCodeSyntaxHighlighter { 6 | /// Creates a Splash-based syntax highlighter with the given theme. 7 | static func splash(theme: Splash.Theme) -> Self { 8 | Self.init(theme: theme) 9 | } 10 | } 11 | 12 | /// Syntax highlighter leveraging the Splash library. 13 | public struct SplashCodeSyntaxHighlighter: CodeSyntaxHighlighter { 14 | private let highlighter: SyntaxHighlighter 15 | 16 | public init(theme: Splash.Theme) { 17 | highlighter = SyntaxHighlighter(format: Format(theme: theme)) 18 | } 19 | 20 | public func highlightCode(_ content: String, language: String?) -> Text { 21 | highlighter.highlight(content) 22 | } 23 | } 24 | 25 | extension SplashCodeSyntaxHighlighter { 26 | private struct Format: Splash.OutputFormat { 27 | private let theme: Splash.Theme 28 | 29 | init(theme: Splash.Theme) { 30 | self.theme = theme 31 | } 32 | 33 | func makeBuilder() -> Builder { 34 | Builder(theme: theme) 35 | } 36 | } 37 | 38 | private struct Builder: OutputBuilder { 39 | private let theme: Splash.Theme 40 | private var string: AttributedString 41 | 42 | fileprivate init(theme: Splash.Theme) { 43 | self.theme = theme 44 | self.string = .init() 45 | } 46 | 47 | mutating func addToken(_ token: String, ofType type: TokenType) { 48 | var part = AttributedString(token) 49 | part.foregroundColor = theme.tokenColors[type] ?? theme.plainTextColor 50 | string += part 51 | } 52 | 53 | mutating func addPlainText(_ text: String) { 54 | var part = AttributedString(text) 55 | part.foregroundColor = theme.plainTextColor 56 | string += part 57 | } 58 | 59 | mutating func addWhitespace(_ whitespace: String) { 60 | string += AttributedString(whitespace) 61 | } 62 | 63 | func build() -> Text { 64 | Text(string) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/ProposalStatusPicker.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import Observation 3 | import SwiftUI 4 | 5 | /// Toolbar button that allows users to filter proposals by status. 6 | public struct ProposalStatusPicker: View { 7 | @State private var showPopover = false 8 | @StatusFilter private var filter 9 | 10 | public init() {} 11 | 12 | public var body: some View { 13 | Button( 14 | action: { 15 | showPopover.toggle() 16 | }, 17 | label: { 18 | Image(systemName: iconName) 19 | .imageScale(.large) 20 | } 21 | ) 22 | .popover(isPresented: $showPopover) { 23 | VStack { 24 | FlowLayout(alignment: .leading, spacing: 8) { 25 | ForEach(filter.keys.sorted(by: <)) { option in 26 | Toggle(option.description, isOn: $filter(option)) 27 | .toggleStyle(.button) 28 | .tint(option.color) 29 | } 30 | } 31 | Divider() 32 | .padding(.vertical) 33 | HStack { 34 | Spacer() 35 | Button("Select All") { 36 | let allCases = ReviewState.allCases 37 | filter = .init(uniqueKeysWithValues: allCases.map { ($0, true) }) 38 | } 39 | .disabled(filter.values.allSatisfy(\.self)) 40 | Spacer() 41 | Button("Deselect All") { 42 | filter = [:] 43 | } 44 | .disabled(filter.values.allSatisfy { !$0 }) 45 | Spacer() 46 | } 47 | } 48 | .animation(.default, value: filter) 49 | .frame(idealWidth: 240) 50 | .padding() 51 | .presentationCompactAdaptation(.popover) 52 | .tint(Color.blue) 53 | } 54 | } 55 | 56 | /// Icon name reflecting whether filters are active. 57 | var iconName: String { 58 | filter.values.allSatisfy(\.self) 59 | ? "line.3.horizontal.decrease.circle" 60 | : "line.3.horizontal.decrease.circle.fill" 61 | } 62 | } 63 | 64 | #Preview { 65 | ProposalStatusPicker() 66 | } 67 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Bookmark/BookmarkRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - BookmarkRepository 5 | 6 | /// Provides data access for ``Bookmark`` objects stored in `SwiftData`. 7 | /// 8 | /// All methods run on the actor's isolated model container to ensure safe 9 | /// access from concurrent contexts. 10 | @ModelActor 11 | public actor BookmarkRepository: Observable {} 12 | 13 | @MainActor 14 | extension BookmarkRepository { 15 | /// Adds or removes a bookmark for the given proposal. 16 | /// - Parameters: 17 | /// - id: proposal id. 18 | /// - isBookmarked: Pass `true` to add a bookmark, or `false` to remove it. 19 | public func update(id: String, isBookmarked: Bool) throws { 20 | if isBookmarked { 21 | try add(id: id) 22 | } else { 23 | try delete(id: id) 24 | } 25 | } 26 | 27 | /// Inserts a new bookmark if one does not already exist. 28 | private func add(id: String) throws { 29 | let context = modelContainer.mainContext 30 | let predicate = #Predicate { $0.proposalID == id } 31 | let descriptor = FetchDescriptor(predicate: predicate) 32 | let proposal = try context.fetch(descriptor).first 33 | try context.transaction { 34 | if let proposal, proposal.bookmark == nil { 35 | context.insert(Bookmark(proposal: proposal)) 36 | } 37 | } 38 | } 39 | 40 | /// Deletes an existing bookmark for the given proposal. 41 | private func delete(id: String) throws { 42 | let descriptor = FetchDescriptor( 43 | predicate: #Predicate { 44 | $0.proposalID == id 45 | } 46 | ) 47 | let context = modelContainer.mainContext 48 | try context.transaction { 49 | try context.fetch(descriptor).forEach { object in 50 | context.delete(object) 51 | } 52 | } 53 | } 54 | 55 | /// Loads a bookmark for the specified proposal identifier. 56 | /// - Parameter proposalID: The identifier of the proposal to look up. 57 | /// - Returns: A ``Bookmark`` if one exists, otherwise `nil`. 58 | public func load(proposalID: String) -> Bookmark? { 59 | return try? modelContainer.mainContext 60 | .fetch(.init(predicate: #Predicate { $0.proposalID == proposalID })) 61 | .first 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Markdown/MarkdownRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - MarkdownRepository 5 | 6 | /// Handles network retrieval and persistence of proposal markdown files. 7 | @ModelActor 8 | public actor MarkdownRepository { 9 | /// Returns the number of markdown files currently stored. 10 | public func loadCount() -> Int { 11 | let predicate = Predicate.true 12 | let count = try? modelContext.fetchCount(FetchDescriptor(predicate: predicate)) 13 | return count ?? 0 14 | } 15 | } 16 | 17 | @MainActor 18 | extension MarkdownRepository { 19 | /// Downloads and stores the markdown for the given proposal. 20 | @discardableResult 21 | public func fetch(with proposalID: String, link: String) async throws -> Markdown { 22 | let url = MarkdownURL(link: link).rawValue 23 | let (data, _) = try await URLSession.shared.data(from: url) 24 | let text = (String(data: data, encoding: .utf8) ?? "") 25 | .replacingOccurrences(of: "'", with: #"\'"#) 26 | let context = modelContainer.mainContext 27 | try context.transaction { 28 | if let markdown = try? self.load(proposalID: proposalID, url: url) { 29 | if markdown.text != text { 30 | markdown.text = text 31 | } 32 | } else { 33 | context.insert(Markdown(url: url, proposalID: proposalID, text: text)) 34 | } 35 | } 36 | return try load(proposalID: proposalID, url: url)! 37 | } 38 | 39 | /// Loads the stored markdown for the specified proposal, if available. 40 | /// - Parameter proposal: The proposal whose markdown should be looked up. 41 | /// - Returns: A ``Markdown`` when the markdown exists in storage. 42 | public func load(with proposal: Proposal) throws -> Markdown? { 43 | let proposalID = proposal.proposalID 44 | let predicate = #Predicate { $0.proposalID == proposalID } 45 | return try modelContainer.mainContext.fetch(FetchDescriptor(predicate: predicate)) 46 | .first 47 | } 48 | 49 | private func load(proposalID: String, url: URL) throws -> Markdown? { 50 | let predicate = #Predicate { $0.proposalID == proposalID && $0.url == url } 51 | return try modelContainer.mainContext.fetch(FetchDescriptor(predicate: predicate)) 52 | .first 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Content/ContentRootView.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | public enum ContentRootTab: String, CaseIterable, Sendable { 7 | case proposal, bookmark, search 8 | 9 | public static var `default`: Self { .proposal } 10 | 11 | var role: TabRole? { 12 | switch self { 13 | case .proposal, .bookmark: 14 | nil 15 | case .search: 16 | .search 17 | } 18 | } 19 | } 20 | 21 | public struct ContentRootView: View { 22 | @Environment(ContentViewModel.self) private var viewModel 23 | @AppStorage("ContentRootView.selection") private var selection: ContentRootTab = .default 24 | 25 | /// Trigger used to re-fetch proposal data. 26 | @State private var refresh: UUID? 27 | 28 | @State private var searchText = "" 29 | 30 | public init() {} 31 | 32 | public var body: some View { 33 | TabView(selection: $selection) { 34 | ForEach(ContentRootTab.allCases, id: \.self) { tab in 35 | Tab(value: tab, role: tab.role) { 36 | content(for: tab) 37 | } label: { 38 | label(for: tab) 39 | } 40 | } 41 | } 42 | .overlay { 43 | ErrorView(error: viewModel.fetchError, $refresh) 44 | } 45 | .task(id: refresh) { 46 | await viewModel.fetchProposals() 47 | } 48 | .tint(.orange) 49 | } 50 | } 51 | 52 | private extension ContentRootView { 53 | @ViewBuilder 54 | func content(for tab: ContentRootTab) -> some View { 55 | switch tab { 56 | case .proposal: 57 | ContentView() 58 | case .bookmark: 59 | ContentView(mode: .bookmark) 60 | case .search: 61 | ContentView(mode: .search(searchText)) 62 | .searchable(text: $searchText) 63 | } 64 | } 65 | 66 | @ViewBuilder 67 | func label(for tab: ContentRootTab) -> some View { 68 | switch tab { 69 | case .proposal: 70 | Label("Proposal", systemImage: "swift") 71 | case .bookmark: 72 | Label("Bookmark", systemImage: "bookmark") 73 | case .search: 74 | Label("Search", systemImage: "magnifyingglass") 75 | } 76 | } 77 | } 78 | 79 | // MARK: - Preview 80 | 81 | #Preview(traits: .evolution) { 82 | @Previewable @Environment(\.modelContext) var context 83 | ContentRootView() 84 | .environment(\.colorScheme, .dark) 85 | } 86 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/StatusFilter.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import SwiftUI 3 | 4 | @propertyWrapper 5 | /// Stores a mapping of proposal statuses to inclusion flags using `AppStorage`. 6 | public struct StatusFilter: DynamicProperty { 7 | @AppStorage("accepted") 8 | private var accepted = true 9 | @AppStorage("activeReview") 10 | private var activeReview = true 11 | @AppStorage("implemented") 12 | private var implemented = true 13 | @AppStorage("previewing") 14 | private var previewing = true 15 | @AppStorage("rejected") 16 | private var rejected = true 17 | @AppStorage("returnedForRevision") 18 | private var returnedForRevision = true 19 | @AppStorage("withdrawn") 20 | private var withdrawn = true 21 | 22 | public init() {} 23 | 24 | public var wrappedValue: [ReviewState: Bool] { 25 | get { 26 | var values = [ReviewState: Bool]() 27 | values[.accepted] = accepted 28 | values[.activeReview] = activeReview 29 | values[.implemented] = implemented 30 | values[.previewing] = previewing 31 | values[.rejected] = rejected 32 | values[.returnedForRevision] = returnedForRevision 33 | values[.withdrawn] = withdrawn 34 | return values 35 | } 36 | nonmutating set { 37 | accepted = newValue[.accepted, default: false] 38 | activeReview = newValue[.activeReview, default: false] 39 | implemented = newValue[.implemented, default: false] 40 | previewing = newValue[.previewing, default: false] 41 | rejected = newValue[.rejected, default: false] 42 | returnedForRevision = newValue[.returnedForRevision, default: false] 43 | withdrawn = newValue[.withdrawn, default: false] 44 | } 45 | } 46 | 47 | /// Provides bindings to individual status flags. 48 | public var projectedValue: (_ status: ReviewState) -> (Binding) { 49 | return { status in 50 | switch status { 51 | case .accepted: 52 | $accepted 53 | case .activeReview: 54 | $activeReview 55 | case .implemented: 56 | $implemented 57 | case .previewing: 58 | $previewing 59 | case .rejected: 60 | $rejected 61 | case .returnedForRevision: 62 | $returnedForRevision 63 | case .withdrawn: 64 | $withdrawn 65 | case .unknown: 66 | .constant(false) 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalList/ProposalListCell.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | // MARK: - Cell 7 | 8 | /// Displays summary information for a proposal in the list view. 9 | struct ProposalListCell: View { 10 | /// Proposal to be presented. 11 | let proposal: Proposal 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 8) { 15 | let label = label 16 | HStack { 17 | // Status label 18 | HStack { 19 | Text(label.text) 20 | if let version = proposal.status.version.rawValue, !version.isEmpty { 21 | Text(version) 22 | } 23 | } 24 | .padding(.horizontal, 8) 25 | .padding(.vertical, 4) 26 | .overlay { 27 | if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { 28 | ConcentricRectangle(corners: .fixed(8)) 29 | .stroke() 30 | } else { 31 | RoundedRectangle(cornerRadius: 6) 32 | .stroke() 33 | } 34 | } 35 | .foregroundStyle(label.color) 36 | 37 | // Bookmark indicator 38 | Image(systemName: "bookmark.fill") 39 | .foregroundStyle(label.color) 40 | .opacity(proposal.bookmark != nil ? 1 : 0) 41 | .animation(.default, value: proposal.bookmark) 42 | } 43 | // Title 44 | Text(title) 45 | .lineLimit(nil) // Required on macOS to allow multiline titles 46 | } 47 | #if os(macOS) 48 | .padding(.vertical, 8) 49 | .padding(.horizontal, 4) 50 | #endif 51 | } 52 | 53 | /// Text and color pair representing the proposal's status. 54 | private var label: (text: String, color: Color) { 55 | let state = ReviewState(proposal: proposal) 56 | return (String(describing: state), state.color) 57 | } 58 | 59 | /// Combined identifier and title string. 60 | private var title: AttributedString { 61 | let id = AttributedString( 62 | proposal.proposalID, 63 | attributes: .init().foregroundColor(.secondary) 64 | ) 65 | let markdownTitle = try? AttributedString(markdown: proposal.title) 66 | let title = 67 | markdownTitle 68 | ?? AttributedString(proposal.title, attributes: .init().foregroundColor(.primary)) 69 | return id + " " + title 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /App/Resources/AppIcon.icon/Assets/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Content/ContentViewModel.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | /// Coordinates fetching proposal metadata and markdown content for the 7 | /// ``ContentView``. 8 | @Observable 9 | @MainActor 10 | final class ContentViewModel { 11 | private let proposalRepository: ProposalRepository 12 | private let markdownRepository: MarkdownRepository 13 | 14 | /// All loaded proposals from storage. 15 | private var proposals: [Proposal] = [] { 16 | didSet { 17 | Task { [self] in 18 | await fetchMarkdowns() 19 | } 20 | } 21 | } 22 | /// Error encountered when attempting to fetch proposals. 23 | private(set) var fetchError: Error? 24 | /// Progress information for background markdown downloads. 25 | private(set) var downloadProgress: DownloadProgress? 26 | 27 | /// Creates a model bound to the shared `ModelContainer`. 28 | init(modelContainer: ModelContainer) { 29 | self.proposalRepository = ProposalRepository(modelContainer: modelContainer) 30 | self.markdownRepository = MarkdownRepository(modelContainer: modelContainer) 31 | proposals = proposalRepository.load() 32 | } 33 | 34 | /// Retrieves the list of proposals from the remote feed and stores them. 35 | func fetchProposals() async { 36 | fetchError = nil 37 | do { 38 | // Load proposal list data. 39 | proposals = try await proposalRepository.fetch() 40 | } catch { 41 | if proposals.isEmpty { 42 | fetchError = error 43 | } 44 | } 45 | } 46 | 47 | /// Loads and caches markdown documents for each proposal, updating the 48 | /// ``downloadProgress`` as items are fetched. 49 | func fetchMarkdowns() async { 50 | let total = proposals.count 51 | let currentCount = await markdownRepository.loadCount() 52 | downloadProgress = DownloadProgress(total: total, current: currentCount) 53 | await withThrowingTaskGroup { group in 54 | for proposal in proposals { 55 | if (try? markdownRepository.load(with: proposal)) == nil { 56 | let proposalID = proposal.proposalID 57 | let link = proposal.link 58 | group.addTask { 59 | try await self.fetch(with: proposalID, link: link) 60 | } 61 | } 62 | } 63 | } 64 | downloadProgress?.current = total 65 | } 66 | 67 | /// Downloads markdown for a single proposal and advances the progress 68 | /// counter. 69 | private func fetch(with proposalID: String, link: String) async throws { 70 | try await markdownRepository.fetch(with: proposalID, link: link) 71 | downloadProgress?.current += 1 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/ProposalRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - ProposalRepository 5 | 6 | /// Retrieves and persists proposal metadata from the Swift Evolution feed. 7 | @ModelActor 8 | public actor ProposalRepository: Observable { 9 | /// Top-level structure of the `evolution.json` feed. 10 | private struct V1: Decodable { 11 | /// All proposals listed in the feed. 12 | let proposals: [Proposal.Snapshot] 13 | } 14 | 15 | /// Location of the JSON feed describing all proposals. 16 | private var url: URL { 17 | URL(string: "https://download.swift.org/swift-evolution/v1/evolution.json")! 18 | } 19 | 20 | private func download() async throws { 21 | let (data, _) = try await URLSession.shared.data(from: url) 22 | let snapshots = try JSONDecoder().decode(V1.self, from: data).proposals 23 | let context = modelContext 24 | try context.transaction { 25 | snapshots.forEach { proposal in 26 | if let object = try? context.fetch(.id(proposal.id)).first { 27 | object.update(with: proposal) 28 | } else { 29 | context.insert(Proposal(snapshot: proposal)) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | @MainActor 37 | extension ProposalRepository { 38 | /// Downloads the proposal feed and stores the results. 39 | /// - Returns: An array of stored proposal snapshots. 40 | public func fetch(sortBy sortDescriptor: [SortDescriptor] = [.proposalID]) async throws -> [Proposal] { 41 | try await download() 42 | return try modelContainer.mainContext.fetch(FetchDescriptor(predicate: .true, sortBy: sortDescriptor)) 43 | } 44 | 45 | /// Finds a proposal by its identifier if it exists in storage. 46 | /// - Parameter proposalID: The proposal identifier to search for. 47 | public func find(by proposalID: String) -> Proposal? { 48 | try? modelContainer.mainContext.fetch(.id(proposalID)).first 49 | } 50 | 51 | public func find(by proposalIDs: some Sequence) -> [Proposal] { 52 | do { 53 | return try modelContainer.mainContext.fetch(.ids(proposalIDs)) 54 | } catch { 55 | return [] 56 | } 57 | } 58 | 59 | /// Loads any proposals already stored in the local database. 60 | /// - Parameter sortDescriptor: Ordering to apply to the returned results. 61 | /// - Returns: An array of proposal snapshots from persistent storage. 62 | public func load(predicate: Predicate = .true, sortBy sortDescriptor: [SortDescriptor] = [.proposalID]) -> [Proposal] { 63 | do { 64 | return try modelContainer.mainContext.fetch(.init(predicate: predicate, sortBy: sortDescriptor)) 65 | } catch { 66 | return [] 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import SwiftUI 3 | 4 | /// Top-level settings interface presented on macOS builds. 5 | public struct SettingsView: View { 6 | private enum Tabs: Hashable { 7 | case general, advanced 8 | } 9 | 10 | public init() {} 11 | 12 | public var body: some View { 13 | TabView { 14 | AcknowledgementsView() 15 | .tabItem { 16 | Label("Acknowledgements", systemImage: "hands.clap.fill") 17 | } 18 | .tag(Tabs.general) 19 | } 20 | } 21 | } 22 | 23 | /// Displays acknowledgements for third-party libraries. 24 | struct AcknowledgementsView: View { 25 | @State var selection = Acknowledgement.allItems.first 26 | @State var items = Acknowledgement.allItems 27 | 28 | var body: some View { 29 | HStack(alignment: .top, spacing: 16) { 30 | NavigationStack { 31 | List(selection: $selection) { 32 | ForEach(items) { item in 33 | NavigationLink(item.title, value: item) 34 | } 35 | } 36 | } 37 | .frame(width: 200) 38 | 39 | if let selection { 40 | ScrollView { 41 | Text(selection.text) 42 | .lineLimit(nil) 43 | .padding(.vertical) 44 | } 45 | } 46 | Spacer() 47 | } 48 | .padding(16) 49 | .frame(idealWidth: 800, idealHeight: 600) 50 | } 51 | } 52 | 53 | /// Model describing a single third-party acknowledgement entry. 54 | struct Acknowledgement: Hashable, Identifiable { 55 | var id: String { title } 56 | /// Title of the acknowledged library. 57 | var title: String = "" 58 | /// Full acknowledgement text. 59 | var text: String = "" 60 | } 61 | 62 | extension Acknowledgement { 63 | static let allItems: [Self] = { 64 | func plist(name: String) -> [[String: Any]] { 65 | guard 66 | let settings = Bundle.main.url(forResource: "Settings", withExtension: "bundle"), 67 | let acknowledgements = Bundle(url: settings)?.url(forResource: name, withExtension: "plist"), 68 | let plist = try? NSDictionary(contentsOf: acknowledgements, error: ()), 69 | let preferences = plist["PreferenceSpecifiers"] as? [[String: Any]] 70 | else { 71 | return [] 72 | } 73 | return preferences 74 | } 75 | var acknowledgements = [Acknowledgement]() 76 | for specification in plist(name: "Acknowledgements") { 77 | guard 78 | let title = specification["Title"] as? String, 79 | let file = specification["File"] as? String, 80 | let text = plist(name: file).first?["FooterText"] as? String 81 | else { 82 | break 83 | } 84 | acknowledgements.append(.init(title: title, text: text)) 85 | } 86 | return acknowledgements 87 | }() 88 | } 89 | 90 | #endif 91 | -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/xcshareddata/xcschemes/App.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /App/Resources/Settings.bundle/LICENSE_marked.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | Type 9 | PSGroupSpecifier 10 | FooterText 11 | # License information 12 | 13 | ## Contribution License Agreement 14 | 15 | If you contribute code to this project, you are implicitly allowing your code 16 | to be distributed under the MIT license. You are also implicitly verifying that 17 | all code is your original work. `</legalese>` 18 | 19 | ## Marked 20 | 21 | Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) 22 | Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) 23 | 24 | Permission is hereby granted, free of charge, to any person obtaining a copy 25 | of this software and associated documentation files (the "Software"), to deal 26 | in the Software without restriction, including without limitation the rights 27 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 28 | copies of the Software, and to permit persons to whom the Software is 29 | furnished to do so, subject to the following conditions: 30 | 31 | The above copyright notice and this permission notice shall be included in 32 | all copies or substantial portions of the Software. 33 | 34 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 38 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 39 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 40 | THE SOFTWARE. 41 | 42 | ## Markdown 43 | 44 | Copyright © 2004, John Gruber 45 | http://daringfireball.net/ 46 | All rights reserved. 47 | 48 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 49 | 50 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 51 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 52 | * Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 53 | 54 | This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Generated by gibo (https://github.com/simonwhitaker/gibo) 2 | ### https://raw.github.com/github/gitignore/218a941be92679ce67d0484547e3e142b2f5f6f0/Swift.gitignore 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 12 | *.xcscmblueprint 13 | *.xccheckout 14 | 15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 16 | build/ 17 | DerivedData/ 18 | *.moved-aside 19 | *.pbxuser 20 | !default.pbxuser 21 | *.mode1v3 22 | !default.mode1v3 23 | *.mode2v3 24 | !default.mode2v3 25 | *.perspectivev3 26 | !default.perspectivev3 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | 31 | ## App packaging 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # 56 | # We recommend against adding the Pods directory to your .gitignore. However 57 | # you should judge for yourself, the pros and cons are mentioned at: 58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 59 | # 60 | # Pods/ 61 | # 62 | # Add this line if you want to avoid checking in source code from the Xcode workspace 63 | # *.xcworkspace 64 | 65 | # Carthage 66 | # 67 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 68 | # Carthage/Checkouts 69 | 70 | Carthage/Build/ 71 | 72 | # Accio dependency management 73 | Dependencies/ 74 | .accio/ 75 | 76 | # fastlane 77 | # 78 | # It is recommended to not store the screenshots in the git repo. 79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 80 | # For more information about the recommended setup visit: 81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 82 | 83 | fastlane/report.xml 84 | fastlane/Preview.html 85 | fastlane/screenshots/**/*.png 86 | fastlane/test_output 87 | 88 | # Code Injection 89 | # 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | 95 | 96 | ### https://raw.github.com/github/gitignore/218a941be92679ce67d0484547e3e142b2f5f6f0/Global/Xcode.gitignore 97 | 98 | # Xcode 99 | # 100 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 101 | 102 | ## User settings 103 | xcuserdata/ 104 | 105 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 106 | *.xcscmblueprint 107 | *.xccheckout 108 | 109 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 110 | build/ 111 | DerivedData/ 112 | *.moved-aside 113 | *.pbxuser 114 | !default.pbxuser 115 | *.mode1v3 116 | !default.mode1v3 117 | *.mode2v3 118 | !default.mode2v3 119 | *.perspectivev3 120 | !default.perspectivev3 121 | 122 | ## Gcc Patch 123 | /*.gcno 124 | 125 | 126 | .DS_Store 127 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/ProposalDetailRow.swift: -------------------------------------------------------------------------------- 1 | import Markdown 2 | import EvolutionModel 3 | import Foundation 4 | 5 | /// A single row of formatted markdown in the proposal detail screen. 6 | public struct ProposalDetailRow: Hashable, Identifiable { 7 | /// Identifier used for navigation and hashing. 8 | public var id: String 9 | /// HTML markup representing the row's contents. 10 | public var markup: String 11 | 12 | public init(id: String, markup: String) { 13 | self.id = id 14 | self.markup = markup 15 | } 16 | } 17 | 18 | extension [ProposalDetailRow] { 19 | /// Creates an array of rows by parsing the proposal's markdown document. 20 | public init(markdown: Markdown) { 21 | let markdownString = markdown.text ?? "" 22 | let document = Document(parsing: markdownString) 23 | var idCount = [String: Int]() 24 | self = document.children.enumerated().map { offset, content -> ProposalDetailRow in 25 | if let heading = content as? Heading { 26 | let heading = heading.format() 27 | let id = Self.htmlID(fromMarkdownHeader: heading) 28 | let count = idCount[id] 29 | let _ = { 30 | idCount[id] = (count ?? 0) + 1 31 | }() 32 | return ProposalDetailRow(id: count.map { "\(id)-\($0)" } ?? id, markup: heading) 33 | } else { 34 | return ProposalDetailRow(id: "\(offset)", markup: content.format()) 35 | } 36 | } 37 | } 38 | 39 | /// Generates an HTML `id` slug from a Markdown heading line. 40 | /// - Parameters: 41 | /// - line: Example: "### `~Copyable` as logical negation" 42 | /// - includeHash: Whether to prefix the slug with `#` (default is `true`). 43 | /// - Returns: Example: "#copyable-as-logical-negation" 44 | private nonisolated static func htmlID(fromMarkdownHeader line: String, includeHash: Bool = true) -> String { 45 | // 1) Remove leading heading markers (0-3 spaces + 1-6 # characters + space) 46 | let headerPattern = #"^\s{0,3}#{1,6}\s+"# 47 | let textStart = line.replacingOccurrences( 48 | of: headerPattern, 49 | with: "", 50 | options: .regularExpression 51 | ) 52 | 53 | // 2) Remove backticks and parentheses but keep contents 54 | var s = textStart.replacingOccurrences(of: "`", with: "") 55 | .replacingOccurrences(of: "(", with: "") 56 | .replacingOccurrences(of: ")", with: "") 57 | 58 | // 3) Unicode normalization (Romanization followed by diacritic removal) 59 | // e.g., "Café" -> "Cafe"; Japanese may be romanized with `toLatin` 60 | if let latin = s.applyingTransform(.toLatin, reverse: false) { 61 | s = latin 62 | } 63 | s = s.folding( 64 | options: [.diacriticInsensitive, .caseInsensitive], 65 | locale: .current 66 | ) 67 | 68 | // 4) Lowercase all characters 69 | s = s.lowercased() 70 | 71 | // 5) Replace disallowed characters with a hyphen 72 | // Consecutive non-alphanumerics are collapsed into a single hyphen 73 | s = s.replacingOccurrences( 74 | of: #"[^a-z0-9]+"#, 75 | with: "-", 76 | options: .regularExpression 77 | ) 78 | 79 | // 6) Trim leading and trailing hyphens 80 | s = s.trimmingCharacters(in: CharacterSet(charactersIn: "-")) 81 | 82 | // 7) Fallback if the result is empty 83 | if s.isEmpty { s = "section" } 84 | 85 | return includeHash ? "#\(s)" : s 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /App/AppShortcutsProvider.swift: -------------------------------------------------------------------------------- 1 | import AppIntents 2 | import EvolutionModel 3 | import EvolutionModule 4 | import SwiftData 5 | import SwiftUI 6 | 7 | // MARK: - AppShortcutsProvider 8 | 9 | struct AppShortcutsProvider: AppIntents.AppShortcutsProvider { 10 | static var shortcutTileColor: ShortcutTileColor { .orange } 11 | 12 | static var appShortcuts: [AppShortcut] { 13 | AppShortcut( 14 | intent: ProposalIntent(), 15 | phrases: ["\(.applicationName)で検索する"], 16 | shortTitle: "Active Reviews", 17 | systemImageName: "swift" 18 | ) 19 | } 20 | } 21 | 22 | // MARK: - ProposalIntent 23 | 24 | struct ProposalIntent: AppIntent { 25 | static var title: LocalizedStringResource { "Open Proposal" } 26 | static var openAppWhenRun: Bool { true } 27 | 28 | @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) 29 | static var supportedModes: IntentModes { .foreground } 30 | 31 | @Parameter(title: "Active Reviews") var proposal: ProposalEntity 32 | 33 | @MainActor func perform() async throws -> some IntentResult { 34 | let defaults = UserDefaults.standard 35 | let tab = ContentRootTab.proposal.rawValue 36 | defaults.set(tab, forKey: "ContentRootView.selection") 37 | defaults.set(proposal.proposalId, forKey: "ContentView.\(ProposalListMode.all).selectedId") 38 | return .result() 39 | } 40 | } 41 | 42 | // MARK: - ProposalEntity 43 | 44 | struct ProposalEntity: AppEntity, Identifiable { 45 | static let typeDisplayRepresentation: TypeDisplayRepresentation = "Active Reviews" 46 | static let defaultQuery = ProposalQuery() 47 | 48 | var id: ProposalID 49 | @Property(title: "Id") var proposalId: String 50 | @Property(title: "Title") var title: String 51 | 52 | var displayRepresentation: DisplayRepresentation { 53 | DisplayRepresentation(title: "\(proposalId) \(title)") 54 | } 55 | 56 | nonisolated init(model: Proposal) { 57 | self.id = .init(rawValue: model.proposalID) 58 | self.proposalId = model.proposalID 59 | self.title = model.title 60 | } 61 | } 62 | 63 | // MARK: - ProposalQuery 64 | 65 | struct ProposalQuery: EntityQuery { 66 | private let repository = ProposalRepository( 67 | modelContainer: EnvironmentResolver.modelContainer() 68 | ) 69 | 70 | @MainActor 71 | func entities(for identifiers: [ProposalID]) async throws -> [ProposalEntity] { 72 | let ids = identifiers.map(\.id) 73 | return repository.find(by: ids).map(ProposalEntity.init(model:)) 74 | } 75 | 76 | @MainActor 77 | func suggestedEntities() async throws -> [ProposalEntity] { 78 | activeReviews() 79 | } 80 | 81 | @MainActor 82 | func defaultResult() async -> ProposalEntity? { 83 | activeReviews().last 84 | } 85 | 86 | @MainActor 87 | private func activeReviews() -> [ProposalEntity] { 88 | repository.load(predicate: .states(.activeReview)) 89 | .map(ProposalEntity.init(model:)) 90 | } 91 | } 92 | 93 | // MARK: - ProposalID 94 | 95 | nonisolated 96 | struct ProposalID: EntityIdentifierConvertible, RawRepresentable, Hashable, Identifiable, Sendable, CustomStringConvertible { 97 | var rawValue: String 98 | 99 | var id: String { rawValue } 100 | 101 | var description: String { rawValue } 102 | 103 | init(rawValue: String) { 104 | self.rawValue = rawValue 105 | } 106 | 107 | var entityIdentifierString: String { 108 | rawValue 109 | } 110 | 111 | static func entityIdentifier(for identifier: String) -> ProposalID? { 112 | self.init(rawValue: identifier) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/DownloadProgress.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - DownloadProgressView 4 | 5 | /// Represents the current state of a multi-item download operation. 6 | public struct DownloadProgress: Hashable, Sendable { 7 | /// Total number of items expected to be downloaded. 8 | public var total: Int 9 | /// Number of items that have finished downloading. 10 | public var current: Int 11 | 12 | /// Creates a new progress value. 13 | /// - Parameters: 14 | /// - total: The total number of items to be fetched. 15 | /// - current: The number of items already fetched. 16 | public init(total: Int, current: Int) { 17 | self.total = total 18 | self.current = current 19 | } 20 | } 21 | 22 | /// A heads-up display that shows the progress of downloading proposal markdown. 23 | public struct DownloadProgressView: View { 24 | /// Backing progress value that drives the view. 25 | private var progress: DownloadProgress 26 | 27 | /// Total number of items expected. 28 | private var total: Int { 29 | progress.total 30 | } 31 | /// Number of items retrieved so far. 32 | private var current: Int { 33 | progress.current 34 | } 35 | 36 | /// Creates a new progress view for the given state. 37 | public init(progress: DownloadProgress) { 38 | self.progress = progress 39 | } 40 | 41 | public var body: some View { 42 | VStack(spacing: 0) { 43 | Spacer(minLength: 0) 44 | .frame(height: 10) 45 | Text(description) 46 | .font(.footnote) 47 | .monospacedDigit() 48 | .contentTransition(.interpolate) 49 | .animation(.snappy, value: description) 50 | Spacer(minLength: 0) 51 | .frame(height: 10) 52 | ProgressView(value: ratio) 53 | .animation(.snappy, value: ratio) 54 | .progressViewStyle(.linear) 55 | .padding(.horizontal) 56 | Spacer(minLength: 0) 57 | .frame(height: 10) 58 | } 59 | .padding(.horizontal, 18) 60 | .fallbackGlassEffect(shape: .rect(cornerRadius: 18)) 61 | .opacity(opacity) 62 | .animation(opacity == 0 ? .snappy.delay(0.5) : .snappy, value: opacity) 63 | .padding(30) 64 | .tint(.blue) 65 | } 66 | 67 | /// Text describing the current progress in a localized format. 68 | var description: LocalizedStringResource { 69 | let digits = String(abs(total)).count 70 | return "Downloading... ( \(String(format: "%0\(digits)ld", current)) / \(total) )" 71 | } 72 | 73 | /// Ratio of completed items to total items. 74 | var ratio: Double { 75 | let ratio = Double(current) / Double(total) 76 | return min(max(ratio, 0), 1) 77 | } 78 | 79 | /// Opacity used to hide the view when no download is in progress. 80 | var opacity: Double { 81 | ratio > 0.0 && ratio < 1.0 ? 1.0 : 0.0 82 | } 83 | } 84 | 85 | #Preview { 86 | @Previewable @State 87 | var state: (viewId: UUID, progress: DownloadProgress) 88 | = (.init(), .init(total: 100, current: 0)) 89 | ZStack(alignment: .bottom) { 90 | Color.blue.mix(with: .mint, by: 0.5).opacity(0.5) 91 | DownloadProgressView(progress: state.progress) 92 | } 93 | .task { 94 | for i in 0...100 { 95 | state.progress.current = i 96 | try? await Task.sleep(for: .microseconds(100000)) 97 | } 98 | try? await Task.sleep(for: .microseconds(1000000)) 99 | state = (.init(), .init(total: 100, current: 0)) 100 | } 101 | .id(state.viewId) 102 | } 103 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/MyCodeBlock.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import Splash 3 | import SwiftUI 4 | 5 | /// Displays a stylized code block with copy-to-clipboard support. 6 | public struct MyCodeBlock: View { 7 | /// Current color scheme used to select the syntax highlighting theme. 8 | @Environment(\.colorScheme) private var colorScheme 9 | @State private var copied: CopiedCode? 10 | 11 | var configuration: CodeBlockConfiguration 12 | 13 | public init(configuration: CodeBlockConfiguration) { 14 | self.configuration = configuration 15 | } 16 | 17 | private var theme: Splash.Theme { 18 | // NOTE: We are ignoring the Splash theme font 19 | switch colorScheme { 20 | case .dark: 21 | return .wwdc18(withFont: .init(size: 16)) 22 | default: 23 | return .sunset(withFont: .init(size: 16)) 24 | } 25 | } 26 | 27 | public var body: some View { 28 | VStack(spacing: 0) { 29 | headerView() 30 | 31 | Divider() 32 | 33 | ScrollView(.horizontal) { 34 | configuration.label 35 | .relativeLineSpacing(.em(0.25)) 36 | .markdownTextStyle { 37 | FontFamilyVariant(.monospaced) 38 | FontSize(.em(0.85)) 39 | } 40 | .padding() 41 | } 42 | } 43 | .background { 44 | Color(.secondarySystemBackground) 45 | } 46 | .clipShape(.rect(cornerRadius: 8)) 47 | .markdownMargin(top: .zero, bottom: .em(0.8)) 48 | .preference(key: CopiedCode.self, value: copied) 49 | } 50 | 51 | @ViewBuilder 52 | func headerView() -> some View { 53 | HStack { 54 | Text(configuration.language ?? "plain text") 55 | .font(.system(.caption, design: .monospaced)) 56 | .fontWeight(.semibold) 57 | .foregroundStyle(Color(theme.plainTextColor)) 58 | Spacer() 59 | 60 | Button { 61 | copyToClipboard(configuration.content) 62 | } label: { 63 | Image(systemName: "clipboard") 64 | } 65 | .buttonStyle(.borderless) 66 | } 67 | .padding(.horizontal) 68 | .padding(.vertical, 8) 69 | .background { 70 | Color(theme.backgroundColor) 71 | } 72 | } 73 | 74 | private func copyToClipboard(_ string: String) { 75 | #if os(macOS) 76 | let pasteboard = NSPasteboard.general 77 | pasteboard.clearContents() 78 | pasteboard.setString(string, forType: .string) 79 | #elseif os(iOS) 80 | UIPasteboard.general.string = string 81 | #endif 82 | copied = CopiedCode(code: string) 83 | } 84 | } 85 | 86 | /// Represents a block of code that was copied to the clipboard. 87 | public struct CopiedCode: PreferenceKey, Hashable, Identifiable { 88 | public var id = UUID() 89 | public var code: String 90 | 91 | public init(id: UUID = UUID(), code: String) { 92 | self.id = id 93 | self.code = code 94 | } 95 | 96 | public static var defaultValue: CopiedCode? { nil } 97 | 98 | public static func reduce(value: inout CopiedCode?, nextValue: () -> CopiedCode?) { 99 | value = nextValue() 100 | } 101 | } 102 | 103 | extension View { 104 | public func onCopyToClipboard(perform: @escaping (_ code: CopiedCode) async -> Void) 105 | -> some View 106 | { 107 | onPreferenceChange(CopiedCode.self) { code in 108 | if let code { 109 | Task { 110 | await perform(code) 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/Content/ContentView.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | // MARK: - ContentView 7 | 8 | /// Main entry view that displays the list of proposals and their details. 9 | @MainActor 10 | public struct ContentView { 11 | /// Navigation path for presenting nested proposal details. 12 | @State private var navigationPath = NavigationPath() 13 | 14 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 15 | 16 | /// Model context used to load additional data. 17 | @Environment(\.modelContext) private var context 18 | 19 | /// ViewModel 20 | @Environment(ContentViewModel.self) private var viewModel 21 | 22 | /// Currently selected status filter. 23 | @StatusFilter private var filter 24 | 25 | @SceneStorage private var sortKey: ProposalSortKey = .proposalID 26 | 27 | var query: ProposalQuery 28 | 29 | @AppStorage private var selectedId: String? 30 | 31 | public init(mode: ProposalListMode = .all) { 32 | self._selectedId = AppStorage("ContentView.\(mode).selectedId") 33 | query = ProposalQuery(mode: mode) 34 | } 35 | } 36 | 37 | // MARK: - View 38 | 39 | extension ContentView: View { 40 | public var body: some View { 41 | ZStack(alignment: .bottom) { 42 | switch horizontalSizeClass { 43 | case .compact: 44 | NavigationStack(path: $navigationPath) { 45 | ProposalListView(query: query) 46 | .navigationDestination(for: Proposal.self) { proposal in 47 | // Destination 48 | detail(proposal: proposal) 49 | } 50 | .onChange(of: selectedId, initial: true) { _, newValue in 51 | if let newValue { 52 | navigationPath.append(newValue) 53 | selectedId = nil 54 | } 55 | } 56 | } 57 | 58 | default: 59 | NavigationSplitView { 60 | // List view 61 | ProposalListView($selectedId, query: query) 62 | .environment(\.horizontalSizeClass, horizontalSizeClass) 63 | } detail: { 64 | // Detail view 65 | if let selectedId { 66 | NavigationStack(path: $navigationPath) { 67 | detail(selectedId: selectedId) 68 | } 69 | .navigationDestination(for: Proposal.self) { proposal in 70 | // Destination 71 | detail(proposal: proposal) 72 | } 73 | .id(selectedId) 74 | } 75 | } 76 | } 77 | if let progress = viewModel.downloadProgress { 78 | DownloadProgressView(progress: progress) 79 | .frame(maxWidth: 375) 80 | } 81 | } 82 | .tint(.darkText) 83 | } 84 | 85 | /// Builds the actual detail view for a proposal. 86 | @ViewBuilder 87 | func detail(selectedId: String) -> some View { 88 | let proposal = try? context.fetch(.id(selectedId)).first 89 | if let proposal { 90 | ProposalDetailView($navigationPath, proposal: proposal) 91 | } 92 | } 93 | 94 | @ViewBuilder 95 | func detail(proposal: Proposal) -> some View { 96 | ProposalDetailView($navigationPath, proposal: proposal) 97 | } 98 | } 99 | 100 | #Preview(traits: .evolution) { 101 | @Previewable @Environment(ContentViewModel.self) var viewModel 102 | ContentView() 103 | .environment(\.colorScheme, .dark) 104 | .task { 105 | await viewModel.fetchProposals() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalList/ProposalListView.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import SwiftData 4 | import SwiftUI 5 | 6 | // MARK: - ListView 7 | 8 | /// Displays a list of proposals and manages selection state. 9 | @MainActor 10 | struct ProposalListView { 11 | @Environment(\.horizontalSizeClass) private var horizontal 12 | private var selectedId: Binding? 13 | @Query private var proposals: [Proposal] 14 | var query: ProposalQuery 15 | 16 | init(_ selectedId: Binding? = nil, query: ProposalQuery) { 17 | self.selectedId = selectedId 18 | _proposals = Query(query) 19 | self.query = query 20 | } 21 | } 22 | 23 | extension ProposalListView: View { 24 | var body: some View { 25 | Group { 26 | if let selectedId { 27 | List(proposals, selection: selectedId) { proposal in 28 | NavigationLink(value: proposal.proposalID) { 29 | ProposalListCell(proposal: proposal) 30 | } 31 | .contextMenu { 32 | OpenSafariButton(proposal: proposal) 33 | BookmarkMenu(proposal: proposal) 34 | } 35 | } 36 | } else { 37 | List(proposals) { proposal in 38 | NavigationLink(value: proposal) { 39 | ProposalListCell(proposal: proposal) 40 | } 41 | .contextMenu { 42 | OpenSafariButton(proposal: proposal) 43 | BookmarkMenu(proposal: proposal) 44 | } 45 | } 46 | } 47 | } 48 | .overlay { 49 | emptyView 50 | } 51 | .animation(.default, value: proposals) 52 | .tint(.darkText.opacity(0.2)) 53 | .navigationTitle(navigationTitle) 54 | .onAppear(perform: selectFirstItem) 55 | .toolbar { toolbar } 56 | } 57 | 58 | /// Selects the first proposal when running on larger displays. 59 | func selectFirstItem() { 60 | #if os(macOS) 61 | if selectedId?.wrappedValue == nil, let proposal = proposals.first { 62 | selectedId?.wrappedValue = proposal.proposalID 63 | } 64 | #elseif os(iOS) 65 | // Provide an initial selection when the split view is displayed side-by-side. 66 | if horizontal == .regular, selectedId?.wrappedValue == nil, let proposal = proposals.first { 67 | selectedId?.wrappedValue = proposal.proposalID 68 | } 69 | #endif 70 | } 71 | 72 | @ToolbarContentBuilder 73 | private var toolbar: some ToolbarContent { 74 | switch query.mode { 75 | case .all: 76 | ToolbarItem { 77 | Menu("Sort", systemImage: "arrow.trianglehead.swap") { 78 | Picker(selection: query.$sortKey) { 79 | ForEach(ProposalSortKey.allCases) { sortKey in 80 | Text(String(describing: sortKey)) 81 | .tag(sortKey) 82 | } 83 | } label: { 84 | Text(String(describing: query.sortKey)) 85 | } 86 | } 87 | } 88 | ToolbarItem { 89 | ProposalStatusPicker() 90 | .tint(.darkText) 91 | } 92 | case .bookmark, .search: 93 | ToolbarItem {} 94 | } 95 | } 96 | 97 | private var navigationTitle: LocalizedStringResource { 98 | switch query.mode { 99 | case .all: 100 | "Swift Evolution" 101 | case .bookmark: 102 | "Bookmark" 103 | case .search: 104 | "Search" 105 | } 106 | } 107 | 108 | @ViewBuilder 109 | private var emptyView: some View { 110 | switch query.mode { 111 | case .bookmark where proposals.isEmpty: 112 | ContentUnavailableView( 113 | "No bookmarked proposals", 114 | systemImage: "bookmark", 115 | description: Text("Bookmark proposals you care about to see them here.") 116 | ) 117 | default: 118 | EmptyView() 119 | } 120 | } 121 | } 122 | 123 | #Preview(traits: .evolution) { 124 | ContentRootView() 125 | .environment(\.colorScheme, .dark) 126 | } 127 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalDetail/ProposalDetailView.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import Markdown 4 | import MarkdownUI 5 | import Observation 6 | import SafariServices 7 | import Splash 8 | import SwiftData 9 | import SwiftUI 10 | 11 | // MARK: - ProposalDetailView 12 | 13 | /// Displays the full markdown contents of a single Swift Evolution proposal 14 | /// and manages navigation to related proposals or sections within the 15 | /// document. 16 | @MainActor 17 | struct ProposalDetailView { 18 | /// Navigation path for pushing additional proposal details. 19 | @Binding var path: NavigationPath 20 | 21 | /// Backing view model that loads markdown content. 22 | @State private var viewModel: ProposalDetailViewModel 23 | 24 | /// Trigger used to re-fetch markdown data. 25 | @State private var refresh: UUID? 26 | 27 | /// Recently copied code block to show in the HUD. 28 | @State private var copied: CopiedCode? 29 | 30 | /// Action used to open URLs from markdown links. 31 | @Environment(\.openURL) private var openURL 32 | } 33 | 34 | extension ProposalDetailView { 35 | init?(_ path: Binding, proposal: Proposal) { 36 | guard let viewModel = ProposalDetailViewModel(proposal: proposal) else { 37 | return nil 38 | } 39 | self.init(path: path, viewModel: viewModel) 40 | } 41 | } 42 | 43 | // MARK: - View 44 | 45 | extension ProposalDetailView: View { 46 | var body: some View { 47 | ScrollViewReader { proxy in 48 | List { 49 | let items = viewModel.items 50 | ForEach(items) { item in 51 | MarkdownUI.Markdown(item.markup) 52 | } 53 | .modifier(MarkdownStyleModifier()) 54 | .opacity(items.isEmpty ? 0 : 1) 55 | .animation(viewModel.translating ? nil : .default, value: items) 56 | .environment(\.openURL, openURLAction(with: proxy)) 57 | .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) 58 | .listRowSeparator(.hidden) 59 | .onCopyToClipboard { code in 60 | withAnimation { copied = code } 61 | try? await Task.sleep(for: .seconds(1)) 62 | withAnimation { copied = nil } 63 | } 64 | } 65 | .listStyle(.plain) 66 | .environment(\.defaultMinListRowHeight, 1) 67 | } 68 | .toolbar { 69 | ProposalDetailToolBar(viewModel: viewModel) 70 | } 71 | .overlay { 72 | ErrorView(error: viewModel.fetchError) { 73 | refresh = .init() 74 | } 75 | } 76 | .overlay { 77 | CopiedHUD(copied: copied) 78 | } 79 | .task(id: refresh) { 80 | guard refresh != nil else { return } 81 | await viewModel.loadMarkdown() 82 | } 83 | .navigationTitle(viewModel.title) 84 | .iOSNavigationBarTitleDisplayMode(.inline) 85 | .tint(viewModel.tint) 86 | } 87 | } 88 | 89 | extension ProposalDetailView { 90 | /// Creates an ``OpenURLAction`` that interprets links inside the markdown 91 | /// content and routes them to the appropriate destination. 92 | fileprivate func openURLAction(with proxy: ScrollViewProxy) -> OpenURLAction { 93 | OpenURLAction { url in 94 | switch viewModel.makeURLAction(url: url) { 95 | case .scrollTo(let id): 96 | withAnimation { proxy.scrollTo(id, anchor: .top) } 97 | case .showDetail(let proposal): 98 | path.append(proposal) 99 | case .open: 100 | showSafariView(url: url) 101 | } 102 | return .discarded 103 | } 104 | } 105 | 106 | /// Presents web content in `SFSafariViewController` when available. 107 | @MainActor 108 | fileprivate func showSafariView(url: URL) { 109 | guard url.scheme?.contains(/^https?$/) == true else { return } 110 | #if os(macOS) 111 | NSWorkspace.shared.open(url) 112 | #elseif os(iOS) 113 | let safari = SFSafariViewController(url: url) 114 | UIApplication.shared 115 | .connectedScenes 116 | .lazy 117 | .compactMap { $0 as? UIWindowScene } 118 | .first? 119 | .keyWindow? 120 | .rootViewController? 121 | .show(safari, sender: self) 122 | #endif 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /EvolutionModel/Sources/Proposal/Proposal.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | // MARK: - Proposal 5 | 6 | /// Represents a single Swift Evolution proposal. 7 | /// 8 | /// Each proposal has an identifier, a link to its markdown content, a status, 9 | /// and a human-readable title. Proposals may also be bookmarked by the user. 10 | @Model 11 | public final class Proposal: CustomStringConvertible { 12 | #Unique([\.proposalID]) 13 | 14 | /// The proposal identifier, such as "SE-0001". 15 | @Attribute(.unique, .spotlight) public var proposalID: String 16 | 17 | /// URL path to the proposal's markdown file on GitHub. 18 | public private(set) var link: String 19 | 20 | /// Human-readable proposal title. 21 | @Attribute(.spotlight) public private(set) var title: String 22 | 23 | // MARK: Relationship 24 | 25 | /// Reference to any bookmark associated with this proposal. 26 | @Relationship(deleteRule: .cascade, inverse: \Bookmark.proposal) 27 | public var bookmark: Bookmark? 28 | 29 | /// The current review status details. 30 | @Relationship(deleteRule: .cascade) 31 | public private(set) var status: Status 32 | 33 | // MARK: init 34 | 35 | /// Creates a managed proposal instance from a snapshot. 36 | required init(snapshot: Snapshot) { 37 | self.proposalID = snapshot.id 38 | self.link = snapshot.link 39 | self.status = Proposal.Status(snapshot.status) 40 | self.title = snapshot.title.trimmingCharacters(in: .whitespaces) 41 | } 42 | 43 | /// Updates the stored data using a more recent snapshot. 44 | @discardableResult 45 | func update(with snapshot: Snapshot) -> Self { 46 | guard proposalID == snapshot.id else { 47 | return self 48 | } 49 | self.link = snapshot.link 50 | self.status = .init(snapshot.status) 51 | self.title = snapshot.title.trimmingCharacters(in: .whitespaces) 52 | return self 53 | } 54 | 55 | public var description: String { 56 | """ 57 | proposalID: \(proposalID) 58 | title: \(title) 59 | link: \(link) 60 | status: \(status) 61 | """ 62 | } 63 | } 64 | 65 | // MARK: - Relationship 66 | 67 | extension Proposal { 68 | /// Metadata describing the review lifecycle of a proposal. 69 | @Model 70 | public final class Status: CustomStringConvertible { 71 | /// Raw state string such as "activeReview" or "accepted". 72 | @Relationship(deleteRule: .cascade) 73 | public private(set) var state: State 74 | /// Version of Swift in which the change shipped, if any. 75 | @Relationship(deleteRule: .cascade) 76 | public private(set) var version: Version 77 | /// The end date for the proposal's review period. 78 | public private(set) var end: String? 79 | /// The start date for the proposal's review period. 80 | public private(set) var start: String? 81 | 82 | init(_ status: Proposal.Snapshot.Status) { 83 | self.state = State(rawValue: status.state) 84 | self.version = Version(rawValue: status.version) 85 | self.end = status.end 86 | self.start = status.start 87 | } 88 | 89 | public var description: String { 90 | """ 91 | state: \(state) 92 | version: \(version) 93 | """ 94 | } 95 | } 96 | 97 | @Model 98 | public final class State: CustomStringConvertible { 99 | public private(set) var rawValue: String 100 | public private(set) var title: String 101 | public private(set) var order: Int 102 | 103 | public init(rawValue: String) { 104 | let state = ReviewState(rawValue: rawValue) ?? .unknown 105 | self.rawValue = rawValue 106 | self.order = state.order 107 | self.title = state.description 108 | } 109 | 110 | public var description: String { 111 | """ 112 | title: \(title) 113 | order: \(order) 114 | """ 115 | } 116 | } 117 | 118 | @Model 119 | public final class Version: CustomStringConvertible { 120 | public private(set) var rawValue: String? 121 | public private(set) var code: Int64 122 | 123 | public init(rawValue: String?) { 124 | self.rawValue = rawValue 125 | self.code = Version.makeCode(rawValue) 126 | } 127 | 128 | private static func makeCode(_ value: String?) -> Int64 { 129 | let value = value?.trimmingCharacters(in: .whitespaces) 130 | guard let value, !value.isEmpty else { 131 | return 0 132 | } 133 | var numbers = value.split(separator: ".").map(String.init).compactMap(Int.init).reversed().map(\.self) 134 | if numbers.isEmpty { 135 | return Int64.max 136 | } 137 | let major = numbers.popLast() ?? 0 138 | let minor = numbers.popLast() ?? 0 139 | let patch = numbers.popLast() ?? 0 140 | return (Int64(major) << 32) | (Int64(minor) << 16) | Int64(patch) 141 | } 142 | 143 | public var description: String { 144 | """ 145 | code: \(code) 146 | """ 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftEvolution-Viewer 2 | 3 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Koshimizu-Takehito/SwiftEvolution-Viewer) 4 | 5 | A native iOS and macOS application for browsing Swift Evolution proposals. This app provides an elegant and intuitive interface to explore, search, and bookmark Swift Evolution proposals with full markdown rendering support. 6 | 7 | ## Features 8 | 9 | - 📱 **Native iOS & macOS Support** - Optimized for both platforms with adaptive layouts 10 | - 📋 **Proposal Browsing** - Browse all Swift Evolution proposals with real-time data 11 | - 🔍 **Advanced Filtering** - Filter proposals by status (Accepted, Rejected, Active, etc.) 12 | - 🔖 **Bookmarking** - Save and organize your favorite proposals 13 | - 📖 **Full Markdown Support** - Rich markdown rendering with syntax highlighting 14 | - 🌙 **Dark Mode Support** - Beautiful dark and light themes 15 | - 📱 **Responsive Design** - Adaptive layouts for different screen sizes 16 | - 🔄 **Real-time Updates** - Automatic proposal data synchronization 17 | 18 | ## Screenshots 19 | 20 | *Screenshots will be added here* 21 | 22 | ## Architecture 23 | 24 | This project follows a modular architecture with clear separation of concerns: 25 | 26 | ### Core Modules 27 | 28 | - **EvolutionModel** - SwiftData models, persistence layer, and data management 29 | - **EvolutionModule** - Main application logic and views 30 | - **EvolutionUI** - Reusable UI components and styling 31 | 32 | ### Key Components 33 | 34 | - **Proposal Management** - SwiftData-based proposal models with snapshot pattern 35 | - **Bookmark System** - Persistent bookmark storage and management 36 | - **Markdown Rendering** - Custom markdown parser with syntax highlighting 37 | - **State Management** - SwiftData integration for local storage 38 | - **Navigation** - Split view navigation for iPad and macOS 39 | 40 | ### Data Models 41 | 42 | - **Proposal** - Core proposal model with SwiftData persistence 43 | - **Bookmark** - User bookmark management 44 | - **Markdown** - Markdown content caching and rendering 45 | 46 | ## Requirements 47 | 48 | ![Swift](https://img.shields.io/badge/swift-6.2-orange.svg) 49 | ![Platform](https://img.shields.io/badge/iOS-26.0+-blue.svg) 50 | ![Platform](https://img.shields.io/badge/macOS-15.0+-blue.svg) 51 | ![Xcode](https://img.shields.io/badge/xcode-26.0+-magenta.svg) 52 | 53 | ## Installation 54 | 55 | ### Prerequisites 56 | 57 | 1. Install Xcode 26.0 or later from the App Store 58 | 2. Ensure you have a valid Apple Developer account (for device deployment) 59 | 60 | ### Setup 61 | 62 | 1. Clone the repository: 63 | ```bash 64 | git clone git@github.com:Koshimizu-Takehito/SwiftEvolution-Viewer.git 65 | cd SwiftEvolution-Viewer 66 | ``` 67 | 68 | 2. Open the project in Xcode: 69 | ```bash 70 | open SwiftEvolution.xcodeproj 71 | ``` 72 | 73 | 3. Select your target device or simulator 74 | 75 | 4. Build and run the project (⌘+R) 76 | 77 | ## Usage 78 | 79 | ### Browsing Proposals 80 | 81 | 1. Launch the app to see the list of all Swift Evolution proposals 82 | 2. Use the search bar to find specific proposals by title or ID 83 | 3. Tap on any proposal to view its full details 84 | 85 | ### Filtering and Organization 86 | 87 | 1. Use the status filter to view proposals by their current state: 88 | - **Accepted** - Successfully implemented proposals 89 | - **Rejected** - Proposals that were not accepted 90 | - **Active** - Currently under review 91 | - **Implemented** - Proposals that have been implemented 92 | 93 | 2. Bookmark important proposals for quick access: 94 | - Tap the bookmark icon on any proposal 95 | - Use the bookmark filter to view only saved proposals 96 | 97 | ### Reading Proposals 98 | 99 | - Full markdown rendering with syntax highlighting 100 | - Responsive layout that adapts to your device 101 | - Support for code blocks, tables, and other markdown elements 102 | - Copy code snippets directly from the rendered content 103 | 104 | ## Development 105 | 106 | ### Project Structure 107 | 108 | ``` 109 | SwiftEvolution/ 110 | ├── App/ # Main app entry point 111 | ├── EvolutionModel/ # SwiftData models and data management 112 | │ └── Sources/ 113 | │ ├── Proposal/ # Proposal models and repository 114 | │ ├── Bookmark/ # Bookmark management 115 | │ └── Markdown/ # Markdown caching and rendering 116 | ├── EvolutionModule/ # Main application logic and views 117 | ├── EvolutionUI/ # Reusable UI components 118 | └── SwiftEvolution.xcodeproj 119 | ``` 120 | 121 | ### Building from Source 122 | 123 | 1. Ensure all dependencies are resolved: 124 | ```bash 125 | cd SwiftEvolution 126 | xcodebuild -resolvePackageDependencies 127 | ``` 128 | 129 | 2. Build the project: 130 | ```bash 131 | xcodebuild -project SwiftEvolution.xcodeproj -scheme App -configuration Debug 132 | ``` 133 | 134 | ### Running Tests 135 | 136 | ```bash 137 | xcodebuild test -project SwiftEvolution.xcodeproj -scheme App 138 | ``` 139 | 140 | ## License 141 | 142 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details. 143 | 144 | ## Acknowledgments 145 | 146 | - [Swift Evolution](https://github.com/apple/swift-evolution) - The official Swift Evolution repository 147 | - [SwiftUI](https://developer.apple.com/xcode/swiftui/) - Modern declarative UI framework 148 | - [SwiftData](https://developer.apple.com/documentation/swiftdata) - Persistent data framework 149 | -------------------------------------------------------------------------------- /EvolutionModule/Sources/ProposalDetail/ProposalDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | import EvolutionModel 2 | import EvolutionUI 3 | import Foundation 4 | import Markdown 5 | import Observation 6 | import SwiftData 7 | 8 | import struct SwiftUI.Color 9 | 10 | /// Manages loading, bookmarking, and translation for an individual proposal. 11 | @Observable 12 | @MainActor 13 | final class ProposalDetailViewModel: Observable { 14 | private let markdownRepository: MarkdownRepository 15 | private let bookmarkRepository: BookmarkRepository 16 | private let proposalRepository: ProposalRepository 17 | 18 | /// The proposal being displayed. 19 | private(set) var proposal: Proposal 20 | 21 | /// Parsed markdown content for presentation. 22 | private(set) var items: [ProposalDetailRow] = [] 23 | 24 | /// Error that occurred while fetching markdown text. 25 | private(set) var fetchError: Error? 26 | 27 | /// Indicates whether markdown is currently being translated. 28 | var translating: Bool = false 29 | 30 | /// Title for the navigation bar. 31 | var title: String { 32 | proposal.title 33 | } 34 | 35 | /// Tint color based on the proposal's status. 36 | var tint: Color { 37 | ReviewState(proposal: proposal).color 38 | } 39 | 40 | /// Bookmark state for the proposal. 41 | var isBookmarked: Bool = false { 42 | didSet { 43 | save(isBookmarked: isBookmarked) 44 | } 45 | } 46 | 47 | /// Creates a view model for the provided proposal using the supplied 48 | /// `ModelContainer` to access repositories. 49 | init?(proposal: Proposal) { 50 | guard let modelContainer = proposal.modelContext?.container else { 51 | return nil 52 | } 53 | self.proposal = proposal 54 | self.markdownRepository = MarkdownRepository(modelContainer: modelContainer) 55 | self.bookmarkRepository = BookmarkRepository(modelContainer: modelContainer) 56 | self.proposalRepository = ProposalRepository(modelContainer: modelContainer) 57 | Task { 58 | await loadMarkdown() 59 | await fetchMarkdown() 60 | } 61 | loadBookmark() 62 | } 63 | 64 | /// Loads cached markdown for the proposal if it has already been 65 | /// downloaded. 66 | func loadMarkdown() async { 67 | if let markdown = try? markdownRepository.load(with: proposal) { 68 | items = [ProposalDetailRow](markdown: markdown) 69 | } 70 | } 71 | 72 | /// Retrieves markdown text for the proposal. 73 | func fetchMarkdown() async { 74 | fetchError = nil 75 | do { 76 | let proposalID = proposal.proposalID 77 | let link = proposal.link 78 | let markdown = try await markdownRepository.fetch(with: proposalID, link: link) 79 | items = [ProposalDetailRow](markdown: markdown) 80 | } catch let error as URLError where error.code == URLError.cancelled { 81 | return 82 | } catch is CancellationError { 83 | return 84 | } catch { 85 | fetchError = error 86 | } 87 | } 88 | 89 | /// Loads the current bookmark state from persistent storage. 90 | func loadBookmark() { 91 | isBookmarked = (bookmarkRepository.load(proposalID: proposal.proposalID) != nil) 92 | } 93 | 94 | /// Persists the bookmark state for this proposal. 95 | private func save(isBookmarked: Bool) { 96 | let repository = bookmarkRepository 97 | try? repository.update(id: proposal.proposalID, isBookmarked: isBookmarked) 98 | } 99 | 100 | /// Translates the markdown contents in place. 101 | @available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) 102 | func translate() async throws { 103 | if items.isEmpty || translating { 104 | return 105 | } 106 | translating = true; defer { translating = false } 107 | 108 | let translator = MarkdownTranslator() 109 | for (offset, item) in items.enumerated() { 110 | for try await result in await translator.translate(markdown: item.markup) { 111 | items[offset].markup = result 112 | } 113 | } 114 | } 115 | } 116 | 117 | extension ProposalDetailViewModel { 118 | /// Possible actions triggered by tapping a link within the markdown content. 119 | enum URLAction { 120 | case scrollTo(id: String) 121 | case showDetail(Proposal) 122 | case open(URL) 123 | } 124 | 125 | /// Determines the appropriate action for a tapped URL. 126 | func makeURLAction(url: URL) -> URLAction { 127 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 128 | return .open(url) 129 | } 130 | switch (components.scheme, components.host, components.path) { 131 | case (_, "github.com", let path): 132 | guard let match = path.firstMatch(of: /^.+\/swift-evolution\/.*\/(\d+)-.*\.md/) else { 133 | break 134 | } 135 | return makeMarkdown(id: match.1).map(URLAction.showDetail) ?? .open(url) 136 | 137 | case (nil, nil, "") where components.fragment?.isEmpty == false: 138 | return .scrollTo(id: url.absoluteString) 139 | 140 | case (nil, nil, let path): 141 | guard let match = path.firstMatch(of: /(\d+)-.*\.md$/) else { 142 | break 143 | } 144 | return makeMarkdown(id: match.1).map(URLAction.showDetail) ?? .open(url) 145 | 146 | default: 147 | break 148 | } 149 | return .open(url) 150 | } 151 | 152 | private func makeMarkdown(id: some StringProtocol) -> Proposal? { 153 | proposalRepository.find(by: "SE-\(String(id))") 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Views/FlowLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // https://github.com/apple/sample-food-truck/blob/main/App/General/FlowLayout.swift 4 | // https://qiita.com/penguinsan_pg/items/aeab0dd86a9aa0ebdad9 5 | /// A flexible layout that arranges subviews in a flowing row-based grid. 6 | public struct FlowLayout: Layout { 7 | var alignment: Alignment = .center 8 | var spacing: CGFloat? 9 | 10 | public init(alignment: Alignment, spacing: CGFloat? = nil) { 11 | self.alignment = alignment 12 | self.spacing = spacing 13 | } 14 | 15 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { 16 | let result = FlowResult( 17 | in: proposal.replacingUnspecifiedDimensions().width, 18 | subviews: subviews, 19 | alignment: alignment, 20 | spacing: spacing 21 | ) 22 | return result.bounds 23 | } 24 | 25 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { 26 | let result = FlowResult( 27 | in: proposal.replacingUnspecifiedDimensions().width, 28 | subviews: subviews, 29 | alignment: alignment, 30 | spacing: spacing 31 | ) 32 | for row in result.rows { 33 | let rowXOffset = (bounds.width - row.frame.width) * alignment.horizontal.percent 34 | for index in row.range { 35 | let xPos = rowXOffset + row.frame.minX + row.xOffsets[index - row.range.lowerBound] + bounds.minX 36 | let rowYAlignment = (row.frame.height - subviews[index].sizeThatFits(.unspecified).height) * 37 | alignment.vertical.percent 38 | let yPos = row.frame.minY + rowYAlignment + bounds.minY 39 | subviews[index].place(at: CGPoint(x: xPos, y: yPos), anchor: .topLeading, proposal: .unspecified) 40 | } 41 | } 42 | } 43 | 44 | /// Pre-computed layout information for arranging subviews. 45 | struct FlowResult { 46 | var bounds = CGSize.zero 47 | var rows = [Row]() 48 | 49 | /// A single row within the flow layout. 50 | struct Row { 51 | var range: Range 52 | var xOffsets: [Double] 53 | var frame: CGRect 54 | } 55 | 56 | init(in maxPossibleWidth: Double, subviews: Subviews, alignment: Alignment, spacing: CGFloat?) { 57 | var itemsInRow = 0 58 | var remainingWidth = maxPossibleWidth.isFinite ? maxPossibleWidth : .greatestFiniteMagnitude 59 | var rowMinY = 0.0 60 | var rowHeight = 0.0 61 | var xOffsets: [Double] = [] 62 | for (index, subview) in zip(subviews.indices, subviews) { 63 | let idealSize = subview.sizeThatFits(.unspecified) 64 | if index != 0 && widthInRow(index: index, idealWidth: idealSize.width) > remainingWidth { 65 | // Finish the current row without this subview. 66 | finalizeRow(index: max(index - 1, 0), idealSize: idealSize) 67 | } 68 | addToRow(index: index, idealSize: idealSize) 69 | 70 | if index == subviews.count - 1 { 71 | // Finish this row; it's either full or we're on the last view anyway. 72 | finalizeRow(index: index, idealSize: idealSize) 73 | } 74 | } 75 | 76 | func spacingBefore(index: Int) -> Double { 77 | guard itemsInRow > 0 else { return 0 } 78 | return spacing ?? subviews[index - 1].spacing.distance(to: subviews[index].spacing, along: .horizontal) 79 | } 80 | 81 | func widthInRow(index: Int, idealWidth: Double) -> Double { 82 | idealWidth + spacingBefore(index: index) 83 | } 84 | 85 | func addToRow(index: Int, idealSize: CGSize) { 86 | let width = widthInRow(index: index, idealWidth: idealSize.width) 87 | 88 | xOffsets.append(maxPossibleWidth - remainingWidth + spacingBefore(index: index)) 89 | // Allocate width to this item (and spacing). 90 | remainingWidth -= width 91 | // Ensure the row height is as tall as the tallest item. 92 | rowHeight = max(rowHeight, idealSize.height) 93 | // Can fit in this row, add it. 94 | itemsInRow += 1 95 | } 96 | 97 | func finalizeRow(index: Int, idealSize: CGSize) { 98 | let rowWidth = maxPossibleWidth - remainingWidth 99 | rows.append( 100 | Row( 101 | range: index - max(itemsInRow - 1, 0) ..< index + 1, 102 | xOffsets: xOffsets, 103 | frame: CGRect(x: 0, y: rowMinY, width: rowWidth, height: rowHeight) 104 | ) 105 | ) 106 | bounds.width = max(bounds.width, rowWidth) 107 | let ySpacing = spacing ?? ViewSpacing().distance(to: ViewSpacing(), along: .vertical) 108 | bounds.height += rowHeight + (rows.count > 1 ? ySpacing : 0) 109 | rowMinY += rowHeight + ySpacing 110 | itemsInRow = 0 111 | rowHeight = 0 112 | xOffsets.removeAll() 113 | remainingWidth = maxPossibleWidth 114 | } 115 | } 116 | } 117 | } 118 | 119 | private extension HorizontalAlignment { 120 | var percent: Double { 121 | switch self { 122 | case .leading: return 0 123 | case .trailing: return 1 124 | default: return 0.5 125 | } 126 | } 127 | } 128 | 129 | private extension VerticalAlignment { 130 | var percent: Double { 131 | switch self { 132 | case .top: return 0 133 | case .bottom: return 1 134 | default: return 0.5 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /SwiftEvolution.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 70; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3A874FAB2E5196E700DEBD9B /* EvolutionModule in Frameworks */ = {isa = PBXBuildFile; productRef = 3A874FAA2E5196E700DEBD9B /* EvolutionModule */; }; 11 | 3ADA645B2E505A4900F03012 /* EvolutionModel in Resources */ = {isa = PBXBuildFile; fileRef = 3ADA645A2E505A4900F03012 /* EvolutionModel */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXFileReference section */ 15 | 3A2444182E5186CF0035346F /* EvolutionUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EvolutionUI; sourceTree = ""; }; 16 | 3A874FA82E5195ED00DEBD9B /* EvolutionModule */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EvolutionModule; sourceTree = ""; }; 17 | 3ADA645A2E505A4900F03012 /* EvolutionModel */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EvolutionModel; sourceTree = ""; }; 18 | FB4654F02B53FEFB00E494E6 /* SwiftData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftData.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.2.sdk/System/Library/Frameworks/SwiftData.framework; sourceTree = DEVELOPER_DIR; }; 19 | FBCE36922B3EDF2D000763B5 /* SwiftEvolution.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftEvolution.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20 | /* End PBXFileReference section */ 21 | 22 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 23 | 3A1FDDA72E5048D80076F14D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { 24 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 25 | membershipExceptions = ( 26 | Resources/Info.plist, 27 | ); 28 | target = FBCE36912B3EDF2D000763B5 /* SwiftEvolution */; 29 | }; 30 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 31 | 32 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 33 | 3A1FDD7C2E5048D80076F14D /* App */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3A1FDDA72E5048D80076F14D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = App; sourceTree = ""; }; 34 | /* End PBXFileSystemSynchronizedRootGroup section */ 35 | 36 | /* Begin PBXFrameworksBuildPhase section */ 37 | FBCE368F2B3EDF2D000763B5 /* Frameworks */ = { 38 | isa = PBXFrameworksBuildPhase; 39 | buildActionMask = 2147483647; 40 | files = ( 41 | 3A874FAB2E5196E700DEBD9B /* EvolutionModule in Frameworks */, 42 | ); 43 | runOnlyForDeploymentPostprocessing = 0; 44 | }; 45 | /* End PBXFrameworksBuildPhase section */ 46 | 47 | /* Begin PBXGroup section */ 48 | FB4654EF2B53FEFB00E494E6 /* Frameworks */ = { 49 | isa = PBXGroup; 50 | children = ( 51 | FB4654F02B53FEFB00E494E6 /* SwiftData.framework */, 52 | ); 53 | name = Frameworks; 54 | sourceTree = ""; 55 | }; 56 | FBCE36892B3EDF2D000763B5 = { 57 | isa = PBXGroup; 58 | children = ( 59 | 3ADA645A2E505A4900F03012 /* EvolutionModel */, 60 | 3A874FA82E5195ED00DEBD9B /* EvolutionModule */, 61 | 3A2444182E5186CF0035346F /* EvolutionUI */, 62 | 3A1FDD7C2E5048D80076F14D /* App */, 63 | FB4654EF2B53FEFB00E494E6 /* Frameworks */, 64 | FBCE36932B3EDF2D000763B5 /* Products */, 65 | ); 66 | sourceTree = ""; 67 | }; 68 | FBCE36932B3EDF2D000763B5 /* Products */ = { 69 | isa = PBXGroup; 70 | children = ( 71 | FBCE36922B3EDF2D000763B5 /* SwiftEvolution.app */, 72 | ); 73 | name = Products; 74 | sourceTree = ""; 75 | }; 76 | /* End PBXGroup section */ 77 | 78 | /* Begin PBXNativeTarget section */ 79 | FBCE36912B3EDF2D000763B5 /* SwiftEvolution */ = { 80 | isa = PBXNativeTarget; 81 | buildConfigurationList = FBCE36A32B3EDF2F000763B5 /* Build configuration list for PBXNativeTarget "SwiftEvolution" */; 82 | buildPhases = ( 83 | FBCE368E2B3EDF2D000763B5 /* Sources */, 84 | FBCE368F2B3EDF2D000763B5 /* Frameworks */, 85 | FBCE36902B3EDF2D000763B5 /* Resources */, 86 | ); 87 | buildRules = ( 88 | ); 89 | dependencies = ( 90 | ); 91 | fileSystemSynchronizedGroups = ( 92 | 3A1FDD7C2E5048D80076F14D /* App */, 93 | ); 94 | name = SwiftEvolution; 95 | productName = SwiftEvolutionApp; 96 | productReference = FBCE36922B3EDF2D000763B5 /* SwiftEvolution.app */; 97 | productType = "com.apple.product-type.application"; 98 | }; 99 | /* End PBXNativeTarget section */ 100 | 101 | /* Begin PBXProject section */ 102 | FBCE368A2B3EDF2D000763B5 /* Project object */ = { 103 | isa = PBXProject; 104 | attributes = { 105 | BuildIndependentTargetsInParallel = 1; 106 | LastSwiftUpdateCheck = 1510; 107 | LastUpgradeCheck = 2600; 108 | TargetAttributes = { 109 | FBCE36912B3EDF2D000763B5 = { 110 | CreatedOnToolsVersion = 15.1; 111 | }; 112 | }; 113 | }; 114 | buildConfigurationList = FBCE368D2B3EDF2D000763B5 /* Build configuration list for PBXProject "SwiftEvolution" */; 115 | compatibilityVersion = "Xcode 14.0"; 116 | developmentRegion = ja; 117 | hasScannedForEncodings = 0; 118 | knownRegions = ( 119 | Base, 120 | ja, 121 | ); 122 | mainGroup = FBCE36892B3EDF2D000763B5; 123 | packageReferences = ( 124 | 3ADA645C2E505A7900F03012 /* XCLocalSwiftPackageReference "EvolutionModel" */, 125 | 3A37B2E32E51875600F1812A /* XCLocalSwiftPackageReference "EvolutionUI" */, 126 | 3A874FA92E5196E700DEBD9B /* XCLocalSwiftPackageReference "EvolutionModule" */, 127 | ); 128 | productRefGroup = FBCE36932B3EDF2D000763B5 /* Products */; 129 | projectDirPath = ""; 130 | projectRoot = ""; 131 | targets = ( 132 | FBCE36912B3EDF2D000763B5 /* SwiftEvolution */, 133 | ); 134 | }; 135 | /* End PBXProject section */ 136 | 137 | /* Begin PBXResourcesBuildPhase section */ 138 | FBCE36902B3EDF2D000763B5 /* Resources */ = { 139 | isa = PBXResourcesBuildPhase; 140 | buildActionMask = 2147483647; 141 | files = ( 142 | 3ADA645B2E505A4900F03012 /* EvolutionModel in Resources */, 143 | ); 144 | runOnlyForDeploymentPostprocessing = 0; 145 | }; 146 | /* End PBXResourcesBuildPhase section */ 147 | 148 | /* Begin PBXSourcesBuildPhase section */ 149 | FBCE368E2B3EDF2D000763B5 /* Sources */ = { 150 | isa = PBXSourcesBuildPhase; 151 | buildActionMask = 2147483647; 152 | files = ( 153 | ); 154 | runOnlyForDeploymentPostprocessing = 0; 155 | }; 156 | /* End PBXSourcesBuildPhase section */ 157 | 158 | /* Begin XCBuildConfiguration section */ 159 | FBCE36A12B3EDF2F000763B5 /* Debug */ = { 160 | isa = XCBuildConfiguration; 161 | buildSettings = { 162 | ALWAYS_SEARCH_USER_PATHS = NO; 163 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 164 | CLANG_ANALYZER_NONNULL = YES; 165 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 166 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 167 | CLANG_ENABLE_MODULES = YES; 168 | CLANG_ENABLE_OBJC_ARC = YES; 169 | CLANG_ENABLE_OBJC_WEAK = YES; 170 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 171 | CLANG_WARN_BOOL_CONVERSION = YES; 172 | CLANG_WARN_COMMA = YES; 173 | CLANG_WARN_CONSTANT_CONVERSION = YES; 174 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 175 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 176 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 177 | CLANG_WARN_EMPTY_BODY = YES; 178 | CLANG_WARN_ENUM_CONVERSION = YES; 179 | CLANG_WARN_INFINITE_RECURSION = YES; 180 | CLANG_WARN_INT_CONVERSION = YES; 181 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 182 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 183 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 184 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 185 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 186 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 187 | CLANG_WARN_STRICT_PROTOTYPES = YES; 188 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 189 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 190 | CLANG_WARN_UNREACHABLE_CODE = YES; 191 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 192 | COPY_PHASE_STRIP = NO; 193 | DEAD_CODE_STRIPPING = YES; 194 | DEBUG_INFORMATION_FORMAT = dwarf; 195 | DEVELOPMENT_TEAM = L2S68XMK99; 196 | ENABLE_STRICT_OBJC_MSGSEND = YES; 197 | ENABLE_TESTABILITY = YES; 198 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 199 | GCC_C_LANGUAGE_STANDARD = gnu17; 200 | GCC_DYNAMIC_NO_PIC = NO; 201 | GCC_NO_COMMON_BLOCKS = YES; 202 | GCC_OPTIMIZATION_LEVEL = 0; 203 | GCC_PREPROCESSOR_DEFINITIONS = ( 204 | "DEBUG=1", 205 | "$(inherited)", 206 | ); 207 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 208 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 209 | GCC_WARN_UNDECLARED_SELECTOR = YES; 210 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 211 | GCC_WARN_UNUSED_FUNCTION = YES; 212 | GCC_WARN_UNUSED_VARIABLE = YES; 213 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 214 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 215 | MTL_FAST_MATH = YES; 216 | ONLY_ACTIVE_ARCH = YES; 217 | PRODUCT_NAME = SwiftEvolution; 218 | STRING_CATALOG_GENERATE_SYMBOLS = YES; 219 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 220 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; 221 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 222 | SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; 223 | SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; 224 | SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; 225 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 226 | SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; 227 | SWIFT_VERSION = 6.0; 228 | }; 229 | name = Debug; 230 | }; 231 | FBCE36A22B3EDF2F000763B5 /* Release */ = { 232 | isa = XCBuildConfiguration; 233 | buildSettings = { 234 | ALWAYS_SEARCH_USER_PATHS = NO; 235 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 236 | CLANG_ANALYZER_NONNULL = YES; 237 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 238 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 239 | CLANG_ENABLE_MODULES = YES; 240 | CLANG_ENABLE_OBJC_ARC = YES; 241 | CLANG_ENABLE_OBJC_WEAK = YES; 242 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 243 | CLANG_WARN_BOOL_CONVERSION = YES; 244 | CLANG_WARN_COMMA = YES; 245 | CLANG_WARN_CONSTANT_CONVERSION = YES; 246 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 247 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 248 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 249 | CLANG_WARN_EMPTY_BODY = YES; 250 | CLANG_WARN_ENUM_CONVERSION = YES; 251 | CLANG_WARN_INFINITE_RECURSION = YES; 252 | CLANG_WARN_INT_CONVERSION = YES; 253 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 254 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 255 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 256 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 257 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 258 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 259 | CLANG_WARN_STRICT_PROTOTYPES = YES; 260 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 261 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 262 | CLANG_WARN_UNREACHABLE_CODE = YES; 263 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 264 | COPY_PHASE_STRIP = NO; 265 | DEAD_CODE_STRIPPING = YES; 266 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 267 | DEVELOPMENT_TEAM = L2S68XMK99; 268 | ENABLE_NS_ASSERTIONS = NO; 269 | ENABLE_STRICT_OBJC_MSGSEND = YES; 270 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 271 | GCC_C_LANGUAGE_STANDARD = gnu17; 272 | GCC_NO_COMMON_BLOCKS = YES; 273 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 274 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 275 | GCC_WARN_UNDECLARED_SELECTOR = YES; 276 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 277 | GCC_WARN_UNUSED_FUNCTION = YES; 278 | GCC_WARN_UNUSED_VARIABLE = YES; 279 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 280 | MTL_ENABLE_DEBUG_INFO = NO; 281 | MTL_FAST_MATH = YES; 282 | PRODUCT_NAME = SwiftEvolution; 283 | STRING_CATALOG_GENERATE_SYMBOLS = YES; 284 | SWIFT_COMPILATION_MODE = wholemodule; 285 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; 286 | SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; 287 | SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; 288 | SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; 289 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 290 | SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; 291 | SWIFT_VERSION = 6.0; 292 | }; 293 | name = Release; 294 | }; 295 | FBCE36A42B3EDF2F000763B5 /* Debug */ = { 296 | isa = XCBuildConfiguration; 297 | buildSettings = { 298 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 299 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 300 | CODE_SIGN_ENTITLEMENTS = App/Resources/SwiftEvolution.entitlements; 301 | CODE_SIGN_STYLE = Automatic; 302 | CURRENT_PROJECT_VERSION = 1; 303 | DEAD_CODE_STRIPPING = YES; 304 | ENABLE_APP_SANDBOX = YES; 305 | ENABLE_HARDENED_RUNTIME = YES; 306 | ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 307 | ENABLE_PREVIEWS = YES; 308 | ENABLE_USER_SELECTED_FILES = readonly; 309 | GENERATE_INFOPLIST_FILE = YES; 310 | INFOPLIST_FILE = App/Resources/Info.plist; 311 | INFOPLIST_KEY_CFBundleDisplayName = Evolution; 312 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; 313 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 314 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 315 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 316 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 317 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 318 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 319 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 320 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 321 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 322 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 323 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; 324 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 325 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 326 | MACOSX_DEPLOYMENT_TARGET = 15.0; 327 | MARKETING_VERSION = 1.0; 328 | PRODUCT_BUNDLE_IDENTIFIER = com.takehito.koshimizu.SwiftEvolution; 329 | PRODUCT_NAME = "$(TARGET_NAME)"; 330 | SDKROOT = auto; 331 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 332 | SWIFT_EMIT_LOC_STRINGS = YES; 333 | SWIFT_STRICT_CONCURRENCY = complete; 334 | SWIFT_VERSION = 6.0; 335 | TARGETED_DEVICE_FAMILY = "1,2"; 336 | }; 337 | name = Debug; 338 | }; 339 | FBCE36A52B3EDF2F000763B5 /* Release */ = { 340 | isa = XCBuildConfiguration; 341 | buildSettings = { 342 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 343 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 344 | CODE_SIGN_ENTITLEMENTS = App/Resources/SwiftEvolution.entitlements; 345 | CODE_SIGN_STYLE = Automatic; 346 | CURRENT_PROJECT_VERSION = 1; 347 | DEAD_CODE_STRIPPING = YES; 348 | ENABLE_APP_SANDBOX = YES; 349 | ENABLE_HARDENED_RUNTIME = YES; 350 | ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 351 | ENABLE_PREVIEWS = YES; 352 | ENABLE_USER_SELECTED_FILES = readonly; 353 | GENERATE_INFOPLIST_FILE = YES; 354 | INFOPLIST_FILE = App/Resources/Info.plist; 355 | INFOPLIST_KEY_CFBundleDisplayName = Evolution; 356 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; 357 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 358 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 359 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 360 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 361 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 362 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 363 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 364 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 365 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 366 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 367 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; 368 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 369 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 370 | MACOSX_DEPLOYMENT_TARGET = 15.0; 371 | MARKETING_VERSION = 1.0; 372 | PRODUCT_BUNDLE_IDENTIFIER = com.takehito.koshimizu.SwiftEvolution; 373 | PRODUCT_NAME = "$(TARGET_NAME)"; 374 | SDKROOT = auto; 375 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 376 | SWIFT_EMIT_LOC_STRINGS = YES; 377 | SWIFT_STRICT_CONCURRENCY = complete; 378 | SWIFT_VERSION = 6.0; 379 | TARGETED_DEVICE_FAMILY = "1,2"; 380 | }; 381 | name = Release; 382 | }; 383 | /* End XCBuildConfiguration section */ 384 | 385 | /* Begin XCConfigurationList section */ 386 | FBCE368D2B3EDF2D000763B5 /* Build configuration list for PBXProject "SwiftEvolution" */ = { 387 | isa = XCConfigurationList; 388 | buildConfigurations = ( 389 | FBCE36A12B3EDF2F000763B5 /* Debug */, 390 | FBCE36A22B3EDF2F000763B5 /* Release */, 391 | ); 392 | defaultConfigurationIsVisible = 0; 393 | defaultConfigurationName = Release; 394 | }; 395 | FBCE36A32B3EDF2F000763B5 /* Build configuration list for PBXNativeTarget "SwiftEvolution" */ = { 396 | isa = XCConfigurationList; 397 | buildConfigurations = ( 398 | FBCE36A42B3EDF2F000763B5 /* Debug */, 399 | FBCE36A52B3EDF2F000763B5 /* Release */, 400 | ); 401 | defaultConfigurationIsVisible = 0; 402 | defaultConfigurationName = Release; 403 | }; 404 | /* End XCConfigurationList section */ 405 | 406 | /* Begin XCLocalSwiftPackageReference section */ 407 | 3A37B2E32E51875600F1812A /* XCLocalSwiftPackageReference "EvolutionUI" */ = { 408 | isa = XCLocalSwiftPackageReference; 409 | relativePath = EvolutionUI; 410 | }; 411 | 3A874FA92E5196E700DEBD9B /* XCLocalSwiftPackageReference "EvolutionModule" */ = { 412 | isa = XCLocalSwiftPackageReference; 413 | relativePath = EvolutionModule; 414 | }; 415 | 3ADA645C2E505A7900F03012 /* XCLocalSwiftPackageReference "EvolutionModel" */ = { 416 | isa = XCLocalSwiftPackageReference; 417 | relativePath = EvolutionModel; 418 | }; 419 | /* End XCLocalSwiftPackageReference section */ 420 | 421 | /* Begin XCSwiftPackageProductDependency section */ 422 | 3A874FAA2E5196E700DEBD9B /* EvolutionModule */ = { 423 | isa = XCSwiftPackageProductDependency; 424 | productName = EvolutionModule; 425 | }; 426 | /* End XCSwiftPackageProductDependency section */ 427 | }; 428 | rootObject = FBCE368A2B3EDF2D000763B5 /* Project object */; 429 | } 430 | -------------------------------------------------------------------------------- /EvolutionUI/Sources/Models/MarkdownTranslator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Markdown 3 | import Translation 4 | 5 | extension Locale.Language { 6 | /// English language code. 7 | public static var english: Self { .init(identifier: "en") } 8 | /// Japanese language code. 9 | public static var japanese: Self { .init(identifier: "ja") } 10 | } 11 | 12 | /// Utility that prints internal links found in markdown documents. 13 | public struct LinkReader: MarkupWalker { 14 | public mutating func visitLink(_ link: Link) { 15 | guard let components = URLComponents(string: link.destination!) else { 16 | return 17 | } 18 | 19 | switch (components.host, components.scheme, components.path) { 20 | case (nil, nil, "") where components.fragment?.isEmpty == false: 21 | if let text = link.children.lazy.compactMap({ $0 as? Text }).first.map(\.plainText) { 22 | print("🐰 \(text) 🦁 \(link.destination ?? "")") 23 | } 24 | default: 25 | break 26 | } 27 | defaultVisit(link) 28 | } 29 | 30 | public init() {} 31 | } 32 | 33 | /// Performs translation of markdown content between locales. 34 | @available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) 35 | public actor MarkdownTranslator { 36 | private typealias Rewriter = TranslatingMarkupRewriter 37 | private var source: Locale.Language 38 | private var target: Locale.Language 39 | 40 | /// Creates a translator specifying source and target languages. 41 | public init(source: Locale.Language = .english, target: Locale.Language = .japanese) { 42 | self.source = source 43 | self.target = target 44 | } 45 | 46 | /// Returns the translated markdown as a single string. 47 | public func translate(markdown: String) async throws -> String { 48 | let document = Document(parsing: markdown) 49 | var rewriter = MarkupTranslator(source: source, target: target) 50 | return try await rewriter.visit(document)?.format() ?? "" 51 | } 52 | 53 | /// Streams translated markdown as it is produced. 54 | public func translate(markdown: String) -> AsyncThrowingStream { 55 | AsyncThrowingStream { continuation in 56 | Task.detached(priority: .medium) { [self] in 57 | let document = Document(parsing: markdown) 58 | var rewriter = await TranslatingMarkupRewriter( 59 | root: document, 60 | source: source, 61 | target: target 62 | ) { markdown in 63 | continuation.yield(markdown) 64 | } 65 | do { 66 | try await rewriter.visit(document) 67 | continuation.finish() 68 | } catch { 69 | continuation.finish(throwing: error) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | /// Synchronously rewrites markup by translating each text fragment. 77 | @available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) 78 | public struct MarkupTranslator: AsyncMarkupRewriter { 79 | private let translator: TranslationSession 80 | 81 | public init(source: Locale.Language, target: Locale.Language) { 82 | self.translator = TranslationSession(installedSource: source, target: target) 83 | } 84 | 85 | public mutating func visitText(_ text: Text) async throws -> (any Markup)? { 86 | try await Text(translator.translate(text.string).targetText) 87 | } 88 | } 89 | 90 | /// Asynchronously rewrites markup and notifies a callback with each replacement. 91 | @available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) 92 | struct TranslatingMarkupRewriter: AsyncMarkupRewriter { 93 | private let translator: TranslationSession 94 | 95 | private var root: Markup { 96 | didSet { 97 | onReplace?(root.format()) 98 | } 99 | } 100 | 101 | private let onReplace: ((String) -> Void)? 102 | 103 | init( 104 | root: Markup, 105 | source: Locale.Language, 106 | target: Locale.Language, 107 | onReplace: ((String) -> Void)? = nil 108 | ) { 109 | self.translator = TranslationSession(installedSource: source, target: target) 110 | self.root = root 111 | self.onReplace = onReplace 112 | } 113 | 114 | mutating func visitText(_ text: Text) async throws -> (any Markup)? { 115 | let translated = try await Text(translator.translate(text.string).targetText) 116 | root = replace(in: root, original: text, translated: translated) 117 | return translated 118 | } 119 | 120 | private mutating func replace(in markup: Markup, original: Markup, translated: Markup) -> Markup 121 | { 122 | switch (markup, original) { 123 | case (let lhs as Text, let rhs as Text) where lhs.string == rhs.string: 124 | translated 125 | default: 126 | // Traverse child nodes and attempt replacement recursively 127 | markup.withUncheckedChildren( 128 | markup.children.map { 129 | replace(in: $0, original: original, translated: translated) 130 | } 131 | ) 132 | } 133 | } 134 | } 135 | 136 | // MARK: - AsyncMarkupVisitor 137 | 138 | protocol AsyncMarkupVisitor: MarkupVisitor { 139 | mutating func defaultVisit(_ markup: AsyncMarkup) async throws -> Result 140 | mutating func visit(_ markup: AsyncMarkup) async throws -> Result 141 | mutating func visitBlockQuote(_ blockQuote: BlockQuote) async throws -> Result 142 | mutating func visitCodeBlock(_ codeBlock: CodeBlock) async throws -> Result 143 | mutating func visitCustomBlock(_ customBlock: CustomBlock) async throws -> Result 144 | mutating func visitDocument(_ document: Document) async throws -> Result 145 | mutating func visitHeading(_ heading: Heading) async throws -> Result 146 | mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) async throws -> Result 147 | mutating func visitHTMLBlock(_ html: HTMLBlock) async throws -> Result 148 | mutating func visitListItem(_ listItem: ListItem) async throws -> Result 149 | mutating func visitOrderedList(_ orderedList: OrderedList) async throws -> Result 150 | mutating func visitUnorderedList(_ unorderedList: UnorderedList) async throws -> Result 151 | mutating func visitParagraph(_ paragraph: Paragraph) async throws -> Result 152 | mutating func visitBlockDirective(_ blockDirective: BlockDirective) async throws -> Result 153 | mutating func visitInlineCode(_ inlineCode: InlineCode) async throws -> Result 154 | mutating func visitCustomInline(_ customInline: CustomInline) async throws -> Result 155 | mutating func visitEmphasis(_ emphasis: Emphasis) async throws -> Result 156 | mutating func visitImage(_ image: Image) async throws -> Result 157 | mutating func visitInlineHTML(_ inlineHTML: InlineHTML) async throws -> Result 158 | mutating func visitLineBreak(_ lineBreak: LineBreak) async throws -> Result 159 | mutating func visitLink(_ link: Link) async throws -> Result 160 | mutating func visitSoftBreak(_ softBreak: SoftBreak) async throws -> Result 161 | mutating func visitStrong(_ strong: Strong) async throws -> Result 162 | mutating func visitText(_ text: Text) async throws -> Result 163 | mutating func visitStrikethrough(_ strikethrough: Strikethrough) async throws -> Result 164 | mutating func visitTable(_ table: Table) async throws -> Result 165 | mutating func visitTableHead(_ tableHead: Table.Head) async throws -> Result 166 | mutating func visitTableBody(_ tableBody: Table.Body) async throws -> Result 167 | mutating func visitTableRow(_ tableRow: Table.Row) async throws -> Result 168 | mutating func visitTableCell(_ tableCell: Table.Cell) async throws -> Result 169 | mutating func visitSymbolLink(_ symbolLink: SymbolLink) async throws -> Result 170 | mutating func visitInlineAttributes(_ attributes: InlineAttributes) async throws -> Result 171 | mutating func visitDoxygenDiscussion(_ doxygenDiscussion: DoxygenDiscussion) async throws 172 | -> Result 173 | mutating func visitDoxygenNote(_ doxygenNote: DoxygenNote) async throws -> Result 174 | mutating func visitDoxygenParameter(_ doxygenParam: DoxygenParameter) async throws -> Result 175 | mutating func visitDoxygenReturns(_ doxygenReturns: DoxygenReturns) async throws -> Result 176 | } 177 | 178 | extension AsyncMarkupVisitor { 179 | @discardableResult mutating func visit(_ markup: AsyncMarkup) async throws -> Result { 180 | try await markup.accept(&self) 181 | } 182 | mutating func visitBlockQuote(_ blockQuote: BlockQuote) async throws -> Result { 183 | try await defaultVisit(blockQuote) 184 | } 185 | mutating func visitCodeBlock(_ codeBlock: CodeBlock) async throws -> Result { 186 | try await defaultVisit(codeBlock) 187 | } 188 | mutating func visitCustomBlock(_ customBlock: CustomBlock) async throws -> Result { 189 | try await defaultVisit(customBlock) 190 | } 191 | mutating func visitDocument(_ document: Document) async throws -> Result { 192 | try await defaultVisit(document) 193 | } 194 | mutating func visitHeading(_ heading: Heading) async throws -> Result { 195 | try await defaultVisit(heading) 196 | } 197 | mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) async throws -> Result { 198 | try await defaultVisit(thematicBreak) 199 | } 200 | mutating func visitHTMLBlock(_ html: HTMLBlock) async throws -> Result { 201 | try await defaultVisit(html) 202 | } 203 | mutating func visitListItem(_ listItem: ListItem) async throws -> Result { 204 | try await defaultVisit(listItem) 205 | } 206 | mutating func visitOrderedList(_ orderedList: OrderedList) async throws -> Result { 207 | try await defaultVisit(orderedList) 208 | } 209 | mutating func visitUnorderedList(_ unorderedList: UnorderedList) async throws -> Result { 210 | try await defaultVisit(unorderedList) 211 | } 212 | mutating func visitParagraph(_ paragraph: Paragraph) async throws -> Result { 213 | try await defaultVisit(paragraph) 214 | } 215 | mutating func visitBlockDirective(_ blockDirective: BlockDirective) async throws -> Result { 216 | try await defaultVisit(blockDirective) 217 | } 218 | mutating func visitInlineCode(_ inlineCode: InlineCode) async throws -> Result { 219 | try await defaultVisit(inlineCode) 220 | } 221 | mutating func visitCustomInline(_ customInline: CustomInline) async throws -> Result { 222 | try await defaultVisit(customInline) 223 | } 224 | mutating func visitEmphasis(_ emphasis: Emphasis) async throws -> Result { 225 | try await defaultVisit(emphasis) 226 | } 227 | mutating func visitImage(_ image: Image) async throws -> Result { 228 | try await defaultVisit(image) 229 | } 230 | mutating func visitInlineHTML(_ inlineHTML: InlineHTML) async throws -> Result { 231 | try await defaultVisit(inlineHTML) 232 | } 233 | mutating func visitLineBreak(_ lineBreak: LineBreak) async throws -> Result { 234 | try await defaultVisit(lineBreak) 235 | } 236 | mutating func visitLink(_ link: Link) async throws -> Result { 237 | try await defaultVisit(link) 238 | } 239 | mutating func visitSoftBreak(_ softBreak: SoftBreak) async throws -> Result { 240 | try await defaultVisit(softBreak) 241 | } 242 | mutating func visitStrong(_ strong: Strong) async throws -> Result { 243 | try await defaultVisit(strong) 244 | } 245 | mutating func visitText(_ text: Text) async throws -> Result { 246 | try await defaultVisit(text) 247 | } 248 | mutating func visitStrikethrough(_ strikethrough: Strikethrough) async throws -> Result { 249 | try await defaultVisit(strikethrough) 250 | } 251 | mutating func visitTable(_ table: Table) async throws -> Result { 252 | try await defaultVisit(table) 253 | } 254 | mutating func visitTableHead(_ tableHead: Table.Head) async throws -> Result { 255 | try await defaultVisit(tableHead) 256 | } 257 | mutating func visitTableBody(_ tableBody: Table.Body) async throws -> Result { 258 | try await defaultVisit(tableBody) 259 | } 260 | mutating func visitTableRow(_ tableRow: Table.Row) async throws -> Result { 261 | try await defaultVisit(tableRow) 262 | } 263 | mutating func visitTableCell(_ tableCell: Table.Cell) async throws -> Result { 264 | try await defaultVisit(tableCell) 265 | } 266 | mutating func visitSymbolLink(_ symbolLink: SymbolLink) async throws -> Result { 267 | try await defaultVisit(symbolLink) 268 | } 269 | mutating func visitInlineAttributes(_ attributes: InlineAttributes) async throws -> Result { 270 | try await defaultVisit(attributes) 271 | } 272 | mutating func visitDoxygenDiscussion(_ doxygenDiscussion: DoxygenDiscussion) async throws 273 | -> Result 274 | { 275 | try await defaultVisit(doxygenDiscussion) 276 | } 277 | mutating func visitDoxygenNote(_ doxygenNote: DoxygenNote) async throws -> Result { 278 | try await defaultVisit(doxygenNote) 279 | } 280 | mutating func visitDoxygenParameter(_ doxygenParam: DoxygenParameter) async throws -> Result { 281 | try await defaultVisit(doxygenParam) 282 | } 283 | mutating func visitDoxygenReturns(_ doxygenReturns: DoxygenReturns) async throws -> Result { 284 | try await defaultVisit(doxygenReturns) 285 | } 286 | } 287 | 288 | // MARK: - AsyncMarkupWalker 289 | 290 | protocol AsyncMarkupWalker: AsyncMarkupVisitor, MarkupWalker {} 291 | 292 | extension AsyncMarkupWalker { 293 | mutating func descendInto(_ markup: AsyncMarkup) async throws { 294 | for child in markup.children { 295 | if let child = child as? AsyncMarkup { 296 | try await visit(child) 297 | } else { 298 | visit(child) 299 | } 300 | } 301 | } 302 | mutating func defaultVisit(_ markup: AsyncMarkup) async throws { 303 | try await descendInto(markup) 304 | } 305 | } 306 | 307 | extension HTMLFormatter: AsyncMarkupWalker {} 308 | extension MarkupFormatter: AsyncMarkupWalker {} 309 | 310 | // MARK: - AsyncMarkupRewriter 311 | 312 | protocol AsyncMarkupRewriter: AsyncMarkupVisitor, MarkupRewriter {} 313 | 314 | extension AsyncMarkupRewriter { 315 | public mutating func defaultVisit(_ markup: AsyncMarkup) async throws -> Markup? { 316 | var newChildren: [any Markup] = [] 317 | for child in markup.children { 318 | if let visited = try await transform(child) { 319 | newChildren.append(visited) 320 | } 321 | } 322 | return markup.withUncheckedChildren(newChildren) 323 | } 324 | 325 | private mutating func transform(_ child: any Markup) async throws -> (any Markup)? { 326 | if let child = child as? (any AsyncMarkup) { 327 | try await visit(child) 328 | } else { 329 | visit(child) 330 | } 331 | } 332 | } 333 | 334 | // MARK: - AsyncMarkup 335 | 336 | protocol AsyncMarkup: Markup { 337 | func accept(_ visitor: inout V) async throws -> V.Result 338 | } 339 | 340 | // MARK: - AsyncMarkup 341 | 342 | extension BlockDirective: AsyncMarkup { 343 | func accept(_ visitor: inout V) async throws -> V.Result { 344 | try await visitor.visitBlockDirective(self) 345 | } 346 | } 347 | extension BlockQuote: AsyncMarkup { 348 | func accept(_ visitor: inout V) async throws -> V.Result { 349 | try await visitor.visitBlockQuote(self) 350 | } 351 | } 352 | extension CodeBlock: AsyncMarkup { 353 | func accept(_ visitor: inout V) async throws -> V.Result { 354 | try await visitor.visitCodeBlock(self) 355 | } 356 | } 357 | extension CustomBlock: AsyncMarkup { 358 | func accept(_ visitor: inout V) async throws -> V.Result { 359 | try await visitor.visitCustomBlock(self) 360 | } 361 | } 362 | extension CustomInline: AsyncMarkup { 363 | func accept(_ visitor: inout V) async throws -> V.Result { 364 | try await visitor.visitCustomInline(self) 365 | } 366 | } 367 | extension Document: AsyncMarkup { 368 | func accept(_ visitor: inout V) async throws -> V.Result { 369 | try await visitor.visitDocument(self) 370 | } 371 | } 372 | extension DoxygenDiscussion: AsyncMarkup { 373 | func accept(_ visitor: inout V) async throws -> V.Result { 374 | try await visitor.visitDoxygenDiscussion(self) 375 | } 376 | } 377 | extension DoxygenNote: AsyncMarkup { 378 | func accept(_ visitor: inout V) async throws -> V.Result { 379 | try await visitor.visitDoxygenNote(self) 380 | } 381 | } 382 | extension DoxygenParameter: AsyncMarkup { 383 | func accept(_ visitor: inout V) async throws -> V.Result { 384 | try await visitor.visitDoxygenParameter(self) 385 | } 386 | } 387 | extension DoxygenReturns: AsyncMarkup { 388 | func accept(_ visitor: inout V) async throws -> V.Result { 389 | try await visitor.visitDoxygenReturns(self) 390 | } 391 | } 392 | extension Emphasis: AsyncMarkup { 393 | func accept(_ visitor: inout V) async throws -> V.Result { 394 | try await visitor.visitEmphasis(self) 395 | } 396 | } 397 | extension HTMLBlock: AsyncMarkup { 398 | func accept(_ visitor: inout V) async throws -> V.Result { 399 | try await visitor.visitHTMLBlock(self) 400 | } 401 | } 402 | extension Heading: AsyncMarkup { 403 | func accept(_ visitor: inout V) async throws -> V.Result { 404 | try await visitor.visitHeading(self) 405 | } 406 | } 407 | extension Image: AsyncMarkup { 408 | func accept(_ visitor: inout V) async throws -> V.Result { 409 | try await visitor.visitImage(self) 410 | } 411 | } 412 | extension InlineAttributes: AsyncMarkup { 413 | func accept(_ visitor: inout V) async throws -> V.Result { 414 | try await visitor.visitInlineAttributes(self) 415 | } 416 | } 417 | extension InlineCode: AsyncMarkup { 418 | func accept(_ visitor: inout V) async throws -> V.Result { 419 | try await visitor.visitInlineCode(self) 420 | } 421 | } 422 | extension InlineHTML: AsyncMarkup { 423 | func accept(_ visitor: inout V) async throws -> V.Result { 424 | try await visitor.visitInlineHTML(self) 425 | } 426 | } 427 | extension LineBreak: AsyncMarkup { 428 | func accept(_ visitor: inout V) async throws -> V.Result { 429 | try await visitor.visitLineBreak(self) 430 | } 431 | } 432 | extension Link: AsyncMarkup { 433 | func accept(_ visitor: inout V) async throws -> V.Result { 434 | try await visitor.visitLink(self) 435 | } 436 | } 437 | extension ListItem: AsyncMarkup { 438 | func accept(_ visitor: inout V) async throws -> V.Result { 439 | try await visitor.visitListItem(self) 440 | } 441 | } 442 | extension OrderedList: AsyncMarkup { 443 | func accept(_ visitor: inout V) async throws -> V.Result { 444 | try await visitor.visitOrderedList(self) 445 | } 446 | } 447 | extension Paragraph: AsyncMarkup { 448 | func accept(_ visitor: inout V) async throws -> V.Result { 449 | try await visitor.visitParagraph(self) 450 | } 451 | } 452 | extension SoftBreak: AsyncMarkup { 453 | func accept(_ visitor: inout V) async throws -> V.Result { 454 | try await visitor.visitSoftBreak(self) 455 | } 456 | } 457 | extension Strikethrough: AsyncMarkup { 458 | func accept(_ visitor: inout V) async throws -> V.Result { 459 | try await visitor.visitStrikethrough(self) 460 | } 461 | } 462 | extension Strong: AsyncMarkup { 463 | func accept(_ visitor: inout V) async throws -> V.Result { 464 | try await visitor.visitStrong(self) 465 | } 466 | } 467 | extension SymbolLink: AsyncMarkup { 468 | func accept(_ visitor: inout V) async throws -> V.Result { 469 | try await visitor.visitSymbolLink(self) 470 | } 471 | } 472 | extension Table: AsyncMarkup { 473 | func accept(_ visitor: inout V) async throws -> V.Result { 474 | try await visitor.visitTable(self) 475 | } 476 | } 477 | extension Table.Body: AsyncMarkup { 478 | func accept(_ visitor: inout V) async throws -> V.Result { 479 | try await visitor.visitTableBody(self) 480 | } 481 | } 482 | extension Table.Cell: AsyncMarkup { 483 | func accept(_ visitor: inout V) async throws -> V.Result { 484 | try await visitor.visitTableCell(self) 485 | } 486 | } 487 | extension Table.Head: AsyncMarkup { 488 | func accept(_ visitor: inout V) async throws -> V.Result { 489 | try await visitor.visitTableHead(self) 490 | } 491 | } 492 | extension Table.Row: AsyncMarkup { 493 | func accept(_ visitor: inout V) async throws -> V.Result { 494 | try await visitor.visitTableRow(self) 495 | } 496 | } 497 | extension Text: AsyncMarkup { 498 | func accept(_ visitor: inout V) async throws -> V.Result { 499 | try await visitor.visitText(self) 500 | } 501 | } 502 | extension ThematicBreak: AsyncMarkup { 503 | func accept(_ visitor: inout V) async throws -> V.Result { 504 | try await visitor.visitThematicBreak(self) 505 | } 506 | } 507 | extension UnorderedList: AsyncMarkup { 508 | func accept(_ visitor: inout V) async throws -> V.Result { 509 | try await visitor.visitUnorderedList(self) 510 | } 511 | } 512 | --------------------------------------------------------------------------------