├── .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 |
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 | [](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 | 
49 | 
50 | 
51 | 
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 |
--------------------------------------------------------------------------------