├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── docc.yml ├── .gitignore ├── .swiftlint.yml ├── LICENSE ├── Package.swift ├── README.md ├── RELEASE_NOTES.md ├── Resources ├── Demo-v2.gif └── Icon.png ├── Sources └── TagKit │ ├── Slugs.swift │ ├── TagKit.docc │ ├── Resources │ │ ├── Logo.png │ │ └── Page.png │ └── TagKit.md │ ├── Tags.swift │ ├── Views │ ├── TagEditList.swift │ ├── TagList.swift │ └── TagTextField.swift │ └── _Deprecated │ ├── Slugifiable.swift │ ├── TagCapsule.swift │ ├── TagCapsuleStyle.swift │ └── Views+Deprecated.swift ├── Tests └── TagKitTests │ ├── SlugsTests.swift │ └── TagsTests.swift ├── package_version.sh └── scripts ├── build.sh ├── chmod.sh ├── docc.sh ├── framework.sh ├── git_default_branch.sh ├── package_docc.sh ├── package_framework.sh ├── package_name.sh ├── package_version.sh ├── sync_from.sh ├── test.sh ├── version.sh ├── version_bump.sh ├── version_number.sh ├── version_validate_git.sh └── version_validate_target.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [danielsaidi] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds and tests the project. 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build Runner 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-15 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: maxim-lobanov/setup-xcode@v1 18 | with: 19 | xcode-version: latest-stable 20 | - name: Build all platforms 21 | run: bash scripts/build.sh ${{ github.event.repository.name }} 22 | - name: Test iOS 23 | run: bash scripts/test.sh ${{ github.event.repository.name }} 24 | -------------------------------------------------------------------------------- /.github/workflows/docc.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds publish DocC docs to GitHub Pages. 2 | # Source: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 3 | # Sample: https://github.com/AgoraIO-Community/VideoUIKit-iOS/blob/main/.github/workflows/deploy_docs.yml 4 | 5 | name: DocC Runner 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | deploy: 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: macos-15 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - id: pages 32 | name: Setup Pages 33 | uses: actions/configure-pages@v4 34 | - name: Select Xcode version 35 | uses: maxim-lobanov/setup-xcode@v1 36 | with: 37 | xcode-version: latest-stable 38 | - name: Build DocC 39 | run: bash scripts/docc.sh ${{ github.event.repository.name }} 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: '.build/docs-iOS' 44 | - id: deployment 45 | name: Deploy to GitHub Pages 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | .swiftpm/ 5 | xcuserdata/ 6 | DerivedData/ -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - function_body_length 3 | - identifier_name 4 | - line_length 5 | - multiple_closures_with_trailing_closure 6 | - todo 7 | - trailing_whitespace 8 | - type_name 9 | - vertical_whitespace 10 | 11 | included: 12 | - Sources 13 | - Tests 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Daniel Saidi 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "TagKit", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | .library( 16 | name: "TagKit", 17 | targets: ["TagKit"] 18 | ) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "TagKit" 23 | ), 24 | .testTarget( 25 | name: "TagKitTests", 26 | dependencies: ["TagKit"] 27 | ) 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Project Icon 3 |

4 | 5 |

6 | Version 7 | Swift 5.9 8 | Swift UI 9 | Documentation 10 | MIT License 11 |

12 | 13 | 14 | 15 | # TagKit 16 | 17 | TagKit is a Swift SDK that makes it easy to work with tags and slugify strings in `Swift` and `SwiftUI`. 18 | 19 |

20 | 21 |

22 | 23 | You can slug and tag any type, customize the slug format, and use the built-in views to list and edit tags with ease. 24 | 25 | 26 | 27 | 28 | ## Installation 29 | 30 | TagKit can be installed with the Swift Package Manager: 31 | 32 | ``` 33 | https://github.com/danielsaidi/TagKit.git 34 | ``` 35 | 36 | 37 | 38 | ## Getting started 39 | 40 | TagKit lets you slugify strings and manage tags for any taggable type. 41 | 42 | 43 | ### Slugs 44 | 45 | Slugifying a string means to remove unwanted characters and replacing whitespaces with a separator. This is often used in urls, where a page slug creates a unique, valid url that also describes the content. 46 | 47 | TagKit has a ``Swift/String/slugified(with:)`` string extension that lets you slugify strings with a standard or custom ``SlugConfiguration``: 48 | 49 | ``` 50 | let custom = SlugConfiguration( 51 | separator: "+", 52 | allowedCharacters: .init(charactersIn: "hewo") 53 | ) 54 | 55 | "Hello, world!".slugified() // "hello-world" 56 | "Hello, world!".slugified(with: custom) // "he+wo" 57 | ``` 58 | 59 | Slugified strings are automatically lowercased, since a slug should be case-insensitively unique. 60 | 61 | 62 | ### Tags 63 | 64 | Tagging is the process of adding tags to an item, with the intent to categorize, group, filter and search among tags. 65 | 66 | TagKit has a ``Taggable`` protocol that can be implemented by any type that has mutable ``Taggable/tags``: 67 | 68 | ```swift 69 | public protocol Taggable { 70 | 71 | var tags: [String] { get set } 72 | } 73 | ``` 74 | 75 | Once a type implements ``Taggable``, it can make use of a lot of automatically implemented functionality that the protocol provides, like ``Taggable/hasTags``, ``Taggable/slugifiedTags``, ``Taggable/addTag(_:)``, ``Taggable/removeTag(_:)``, ``Taggable/toggleTag(_:)``. All ``Taggable`` collections are extended as well. 76 | 77 | 78 | ### Views 79 | 80 | TagKit has a couple of tag related views, like ``TagList``, ``TagEditList`` and ``TagTextField``. 81 | 82 | 83 | 84 | ## Documentation 85 | 86 | The online [documentation][Documentation] has more information, articles, code examples, etc. 87 | 88 | 89 | 90 | ## Support this library 91 | 92 | You can [sponsor me][Sponsors] on GitHub Sponsors or [reach out][Email] for paid support, to help support my [open-source projects][OpenSource]. 93 | 94 | Your support makes it possible for me to put more work into these projects and make them the best they can be. 95 | 96 | 97 | 98 | ## Contact 99 | 100 | Feel free to reach out if you have questions or if you want to contribute in any way: 101 | 102 | * Website: [danielsaidi.com][Website] 103 | * E-mail: [daniel.saidi@gmail.com][Email] 104 | * Bluesky: [@danielsaidi@bsky.social][Bluesky] 105 | * Mastodon: [@danielsaidi@mastodon.social][Mastodon] 106 | 107 | 108 | 109 | ## License 110 | 111 | TagKit is available under the MIT license. See the [LICENSE][License] file for more info. 112 | 113 | 114 | 115 | [Email]: mailto:daniel.saidi@gmail.com 116 | [Website]: https://danielsaidi.com 117 | [GitHub]: https://github.com/danielsaidi 118 | [OpenSource]: https://danielsaidi.com/opensource 119 | [Sponsors]: https://github.com/sponsors/danielsaidi 120 | 121 | [Bluesky]: https://bsky.app/profile/danielsaidi.bsky.social 122 | [Mastodon]: https://mastodon.social/@danielsaidi 123 | [Twitter]: https://twitter.com/danielsaidi 124 | 125 | [Documentation]: https://danielsaidi.github.io/TagKit 126 | [License]: https://github.com/danielsaidi/TagKit/blob/master/LICENSE 127 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | TagKit will use semver after 1.0. 4 | 5 | 6 | 7 | ## 0.5 8 | 9 | This version makes TagKit use Swift 6, by removing the flow layout parts from the library. 10 | 11 | The `TagList` and `TagEditList` is therefore much simpler now before, and can be used in more ways. 12 | 13 | ### 💡 Behavior Changes 14 | 15 | * `TagList` and `TagEditList` now just lists the provided tags. 16 | * `TagList` and `TagEditList` can now be rendered in any layout container. 17 | 18 | ### 🗑️ Deprecated 19 | 20 | * `Slugifiable` has been deprecated. Just use the `slugified` string extension instead. 21 | * `TagCapsule` has been deprecated, since it's better to just customize a regular `Text`. 22 | 23 | ### 💥 Breaking Changes 24 | 25 | * `FlowLayout` could not be refactored to support strict concurrency, and has been removed. 26 | 27 | 28 | 29 | ## 0.4.1 30 | 31 | This version temporarily downgrades to Swift 5.9 since Xcode 16.1 made things stop working. 32 | 33 | 34 | 35 | ## 0.4 36 | 37 | This version makes TagKit use Swift 6. 38 | 39 | 40 | 41 | ## 0.3 42 | 43 | This version adds support for strict concurrency. 44 | 45 | This requires standard styles to be converted to read-only values. 46 | 47 | This version also adjusts the visual appearance of some standard styles. 48 | 49 | ### ✨ Features 50 | 51 | * `TagCapsuleStyle` now supports specifying a shadow. 52 | 53 | ### 💡 Behavior Changes 54 | 55 | * `TagCapsule.standard` and `.standardSelected` now use material backgrounds. 56 | 57 | 58 | 59 | ## 0.2 60 | 61 | This version bumps the package to Swift 5.9 and adds support for visionOS. 62 | 63 | This version also bumps the deployment targets to make it possible to add more features. 64 | 65 | ### ✨ Features 66 | 67 | * `FlowLayout` is now public. 68 | * `TagCapsuleStyle` has new, material-based default styles. 69 | * `TagCapsuleStyle` now supports specifying a background material as well. 70 | 71 | ### 💡 Behavior Changes 72 | 73 | * `TagCapsule` now applies a `TagCapsuleStyle`. 74 | 75 | 76 | 77 | ## 0.1.1 78 | 79 | This version adjusts the tag capsule border to be rendered as an overlay. 80 | 81 | 82 | 83 | ## 0.1 84 | 85 | This version is the first public beta release of TagKit. 86 | 87 | ### ✨ Features 88 | 89 | * `Slugifiable` is a protocol that describes slugifiable types and adds additional functionality to any types that implement it. 90 | * `SlugConfiguration` can be used to customized the slugified result. 91 | * `Taggable` is a protocol that describes taggable types and adds additional functionality to any types that implement it and collections that contain them. 92 | * `TagList` renders a collection of tags using a custom view builder. 93 | * `TagEditList` renders a collection of toggleable tags using a custom view builder. 94 | * `TagCapsule` renders a tag with a customizable style. 95 | * `TagTextField` automatically slugifies any text that is entered into it. 96 | -------------------------------------------------------------------------------- /Resources/Demo-v2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/TagKit/b559c4af835cb3a74a154fec104c417f57066a9e/Resources/Demo-v2.gif -------------------------------------------------------------------------------- /Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/TagKit/b559c4af835cb3a74a154fec104c417f57066a9e/Resources/Icon.png -------------------------------------------------------------------------------- /Sources/TagKit/Slugs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Slugs.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-18. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Create a slugified version of the string. 14 | /// 15 | /// - Parameters: 16 | /// - configuration: The configuration to use, by default ``SlugConfiguration/standard``. 17 | func slugified( 18 | with configuration: SlugConfiguration = .standard 19 | ) -> String { 20 | lowercased() 21 | .components(separatedBy: configuration.notAllowedCharacterSet) 22 | .filter { !$0.isEmpty } 23 | .joined(separator: configuration.separator) 24 | } 25 | } 26 | 27 | /// This configuration defines how ``Slugifiable`` types are 28 | /// slugified. 29 | /// 30 | /// The standard configuration allows `a-z0-9`, and will for 31 | /// instance slugify `Hello, world!` into `hello-world`. 32 | public struct SlugConfiguration { 33 | 34 | /// Create a new slug configurator. 35 | /// 36 | /// - Parameters: 37 | /// - separator: The separator to use in the slugified string, by default `-`. 38 | /// - allowedCharacters: The characters to allow in the slugified string, by default `a-z0-9`. 39 | public init( 40 | separator: String = "-", 41 | allowedCharacters: String = "abcdefghijklmnopqrstuvwxyz0123456789" 42 | ) { 43 | let chars = allowedCharacters + separator 44 | self.separator = separator 45 | self.allowedCharacters = chars 46 | let allowedSet = NSCharacterSet(charactersIn: chars) 47 | self.allowedCharacterSet = allowedSet 48 | self.notAllowedCharacterSet = allowedSet.inverted 49 | } 50 | 51 | /// The separator to use in the slugified string. 52 | public let separator: String 53 | 54 | /// The characters to allow in the slugified string. 55 | public let allowedCharacters: String 56 | 57 | /// The characters to allow in the slugified string. 58 | public let allowedCharacterSet: NSCharacterSet 59 | 60 | /// The characters to not allow in the slugified string. 61 | public let notAllowedCharacterSet: CharacterSet 62 | } 63 | 64 | public extension SlugConfiguration { 65 | 66 | /// A standard slug configuration, with `-` as component 67 | /// separator and `a-zA-Z0-9` as allowed characters. 68 | static var standard: SlugConfiguration { .init() } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/TagKit/TagKit.docc/Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/TagKit/b559c4af835cb3a74a154fec104c417f57066a9e/Sources/TagKit/TagKit.docc/Resources/Logo.png -------------------------------------------------------------------------------- /Sources/TagKit/TagKit.docc/Resources/Page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/TagKit/b559c4af835cb3a74a154fec104c417f57066a9e/Sources/TagKit/TagKit.docc/Resources/Page.png -------------------------------------------------------------------------------- /Sources/TagKit/TagKit.docc/TagKit.md: -------------------------------------------------------------------------------- 1 | # ``TagKit`` 2 | 3 | TagKit is a Swift SDK that makes it easy to work with tags and slugification in `Swift` and `SwiftUI`. 4 | 5 | 6 | 7 | ## Overview 8 | 9 | ![TagKit logo](Logo.png) 10 | 11 | TagKit is a Swift SDK that makes it easy to work with tags and slugified strings in `Swift` and `SwiftUI`. 12 | 13 | You can slug and tag any type, customize the slug format, and use the built-in views to list and edit tags with ease. 14 | 15 | 16 | 17 | ## Installation 18 | 19 | TagKit can be installed with the Swift Package Manager: 20 | 21 | ``` 22 | https://github.com/danielsaidi/TagKit.git 23 | ``` 24 | 25 | 26 | 27 | ## Getting started 28 | 29 | TagKit lets you slugify strings and manage tags for any taggable type. 30 | 31 | 32 | ### Slugs 33 | 34 | Slugifying a string means to remove unwanted characters and replacing whitespaces with a separator. This is often used in urls, where a page slug creates a unique, valid url that also describes the content. 35 | 36 | TagKit has a ``Swift/String/slugified(with:)`` string extension that lets you slugify strings with a standard or custom ``SlugConfiguration``: 37 | 38 | ``` 39 | let custom = SlugConfiguration( 40 | separator: "+", 41 | allowedCharacters: .init(charactersIn: "hewo") 42 | ) 43 | 44 | "Hello, world!".slugified() // "hello-world" 45 | "Hello, world!".slugified(with: custom) // "he+wo" 46 | ``` 47 | 48 | Slugified strings are automatically lowercased, since a slug should be case-insensitively unique. 49 | 50 | 51 | ### Tags 52 | 53 | Tagging is the process of adding tags to an item, with the intent to categorize, group, filter and search among tags. 54 | 55 | TagKit has a ``Taggable`` protocol that can be implemented by any type that has mutable ``Taggable/tags``: 56 | 57 | ```swift 58 | public protocol Taggable { 59 | 60 | var tags: [String] { get set } 61 | } 62 | ``` 63 | 64 | Once a type implements ``Taggable``, it can make use of a lot of automatically implemented functionality that the protocol provides, like ``Taggable/hasTags``, ``Taggable/slugifiedTags``, ``Taggable/addTag(_:)``, ``Taggable/removeTag(_:)``, ``Taggable/toggleTag(_:)``. All ``Taggable`` collections are extended as well. 65 | 66 | 67 | ### Views 68 | 69 | TagKit has a couple of tag related views, like ``TagList``, ``TagEditList`` and ``TagTextField``. 70 | 71 | 72 | 73 | 74 | ## Repository 75 | 76 | For more information, source code, and to report issues, sponsor the project etc., visit the [project repository](https://github.com/danielsaidi/TagKit). 77 | 78 | 79 | 80 | ## License 81 | 82 | TagKit is available under the MIT license. 83 | 84 | 85 | 86 | ## Topics 87 | 88 | ### Slugs 89 | 90 | - ``SlugConfiguration`` 91 | 92 | ### Tags 93 | 94 | - ``Taggable`` 95 | 96 | ### Views 97 | 98 | - ``TagList`` 99 | - ``TagEditList`` 100 | - ``TagTextField`` 101 | -------------------------------------------------------------------------------- /Sources/TagKit/Tags.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tags.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-06. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol describe types that can be tagged with one 12 | /// or many tags, which can be used to group, search etc. 13 | /// 14 | /// Types that implement this protocol get access to a bunch 15 | /// of additional features, e.g. ``hasTags``, ``addTag(_:)``, 16 | /// ``toggleTag(_:)``, etc. 17 | /// 18 | /// Note that the ``tags`` property is a raw string list. If 19 | /// ypu want a list of slugified tags, use ``slugifiedTags``. 20 | public protocol Taggable { 21 | 22 | /// All tags that have been applied to the item. 23 | var tags: [String] { get set } 24 | } 25 | 26 | public extension Taggable { 27 | 28 | /// Whether or not the item has any tags. 29 | var hasTags: Bool { 30 | !tags.isEmpty 31 | } 32 | 33 | /// Get a list of slugified ``tags``. 34 | var slugifiedTags: [String] { 35 | slugifiedTags() 36 | } 37 | 38 | /// Add a tag to the taggable type. 39 | mutating func addTag(_ tag: String) { 40 | if hasTag(tag) { return } 41 | tags.append(tag) 42 | } 43 | 44 | /// Whether or not the item has a certain tag. 45 | func hasTag(_ tag: String) -> Bool { 46 | if tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } 47 | return slugifiedTags.contains(tag.slugified()) 48 | } 49 | 50 | /// Remove a tag from the taggable type. 51 | mutating func removeTag(_ tag: String) { 52 | guard hasTag(tag) else { return } 53 | tags = tags.filter { $0 != tag } 54 | } 55 | 56 | /// Get a list of slugified ``tags``. 57 | /// 58 | /// - Parameters: 59 | /// - config: The slug configuration to use, by default ``SlugConfiguration/standard``. 60 | func slugifiedTags( 61 | configuration: SlugConfiguration = .standard 62 | ) -> [String] { 63 | tags.map { $0.slugified(with: configuration) } 64 | } 65 | 66 | /// Toggle a tag on the taggable type. 67 | mutating func toggleTag(_ tag: String) { 68 | if hasTag(tag) { 69 | removeTag(tag) 70 | } else { 71 | addTag(tag) 72 | } 73 | } 74 | } 75 | 76 | public extension Collection where Element: Taggable { 77 | 78 | /// Get all the slugified tags in the collection. 79 | /// 80 | /// Read more about slugified strings in ``Slugifiable``. 81 | var allTags: [String] { 82 | let slugs = flatMap { $0.slugifiedTags } 83 | return Array(Set(slugs)).sorted() 84 | } 85 | 86 | /// Get all items in the collection with a certain tag. 87 | func withTag(_ tag: String) -> [Element] { 88 | filter { $0.hasTag(tag) } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/TagKit/Views/TagEditList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagEditList.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-19. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view lists a collection of tags, that can be tapped 12 | /// to toggle them in the provided tags binding. 13 | /// 14 | /// This view will list all tags in the provided binding, as 15 | /// well as a list of additional tags which should be listed 16 | /// even when they are not set in the binding. 17 | /// 18 | /// Note that this list only renders the tag views. You must 19 | /// specify the container in which they will be rendered. 20 | public struct TagEditList: View { 21 | 22 | /// Create a tag edit list. 23 | /// 24 | /// - Parameters: 25 | /// - tags: The items to render in the layout. 26 | /// - additionalTags: Additional tags to pick from. 27 | /// - tagView: The tag view builder. 28 | public init( 29 | tags: Binding<[String]>, 30 | additionalTags: [String], 31 | @ViewBuilder tagView: @escaping TagViewBuilder 32 | ) { 33 | self.tags = tags 34 | self.additionalTags = additionalTags 35 | self.tagView = tagView 36 | } 37 | 38 | private let tags: Binding<[String]> 39 | private let additionalTags: [String] 40 | 41 | @ViewBuilder 42 | private let tagView: TagViewBuilder 43 | 44 | /// This type defines the tag view builder for the list. 45 | public typealias TagViewBuilder = (_ tag: String, _ hasTag: Bool) -> TagView 46 | 47 | public var body: some View { 48 | TagList( 49 | tags: allTags 50 | ) { tag in 51 | Button(action: { toggleTag(tag) }) { 52 | tagView(tag, hasTag(tag)) 53 | } 54 | .withButtonStyle() 55 | } 56 | } 57 | } 58 | 59 | private extension View { 60 | 61 | func withButtonStyle() -> some View { 62 | self.buttonStyle(.plain) 63 | } 64 | } 65 | 66 | private extension TagEditList { 67 | 68 | var allTags: [String] { 69 | Array(Set(tags.wrappedValue + additionalTags)).sorted() 70 | } 71 | 72 | func addTag(_ tag: String) { 73 | if hasTag(tag) { return } 74 | tags.wrappedValue.append(tag) 75 | } 76 | 77 | func hasTag(_ tag: String) -> Bool { 78 | tags.wrappedValue.contains(tag) 79 | } 80 | 81 | func removeTag(_ tag: String) { 82 | guard hasTag(tag) else { return } 83 | tags.wrappedValue = tags.wrappedValue.filter { $0 != tag } 84 | } 85 | 86 | func toggleTag(_ tag: String) { 87 | if hasTag(tag) { 88 | removeTag(tag) 89 | } else { 90 | addTag(tag) 91 | } 92 | } 93 | } 94 | 95 | #Preview { 96 | 97 | struct Preview: View { 98 | 99 | @State var newTag = "" 100 | @State var tags = ["tag-1"] 101 | 102 | let slugConfiguration = SlugConfiguration.standard 103 | 104 | var body: some View { 105 | NavigationView { 106 | ScrollView { 107 | TagEditList( 108 | tags: $tags, 109 | additionalTags: ["always-visible"] 110 | ) { tag, isAdded in 111 | Text(tag.slugified()) 112 | .font(.system(size: 12)) 113 | .foregroundColor(.black) 114 | .padding(.horizontal, 8) 115 | .padding(.vertical, 4) 116 | .background(isAdded ? Color.green : Color.primary.opacity(0.1), in: .capsule) 117 | } 118 | .padding() 119 | } 120 | .toolbar { 121 | ToolbarItem { 122 | HStack { 123 | TagTextField( 124 | text: $newTag, 125 | placeholder: "Add new tag", 126 | configuration: slugConfiguration 127 | ) 128 | #if os(iOS) 129 | .autocorrectionDisabled() 130 | .textFieldStyle(.roundedBorder) 131 | #endif 132 | Button("Add") { 133 | addNewTag(tag: newTag) 134 | } 135 | .disabled(newTag.isEmpty) 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | private func addNewTag( 143 | tag: String, 144 | selected: Bool = true 145 | ) { 146 | let slug = tag.slugified(with: slugConfiguration) 147 | if selected { 148 | tags.append(slug) 149 | } 150 | newTag = "" 151 | } 152 | } 153 | 154 | return Preview() 155 | } 156 | -------------------------------------------------------------------------------- /Sources/TagKit/Views/TagList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagList.swift 3 | // TagKit 4 | // 5 | // Based on https://github.com/globulus/swiftui-flow-layout 6 | // 7 | // Created by Daniel Saidi on 2022-08-18. 8 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 9 | // 10 | 11 | import SwiftUI 12 | 13 | /// This enum specifies supported tag list container types. 14 | public enum TagListContainer { 15 | 16 | case scrollView, vstack 17 | } 18 | 19 | /// This view can be used to list a collection of tags. 20 | /// 21 | /// The view takes a list of tags and renders them using the 22 | /// provided `tagView` builder. It will not slugify the tag 23 | /// elements, so either provide already slugified strings or 24 | /// slugify them in the view builder. 25 | /// 26 | /// Note that this list only renders the tag views. You must 27 | /// specify the container in which they will be rendered. 28 | public struct TagList: View { 29 | 30 | /// Create a tag list. 31 | /// 32 | /// - Parameters: 33 | /// - tags: The items to render in the layout. 34 | /// - container: The container type, by default `.scrollView`. 35 | /// - horizontalSpacing: The horizontal spacing between items. 36 | /// - verticalSpacing: The vertical spacing between items. 37 | /// - tagView: The item view builder. 38 | public init( 39 | tags: [String], 40 | @ViewBuilder tagView: @escaping TagViewBuilder 41 | ) { 42 | self.tags = tags 43 | self.tagView = tagView 44 | } 45 | 46 | private let tags: [String] 47 | 48 | @ViewBuilder 49 | private let tagView: TagViewBuilder 50 | 51 | /// This type defines the tag view builder for the list. 52 | public typealias TagViewBuilder = (_ tag: String) -> TagView 53 | 54 | public var body: some View { 55 | ForEach(Array(tags.enumerated()), id: \.offset) { 56 | tagView($0.element) 57 | } 58 | } 59 | } 60 | 61 | #Preview { 62 | 63 | ScrollView { 64 | TagList(tags: [ 65 | "A long tag here", 66 | "Another long tag here", 67 | "A", "bunch", "of", "short", "tags" 68 | ]) { tag in 69 | Text(tag.slugified()) 70 | .font(.system(size: 12)) 71 | .foregroundColor(.black) 72 | .padding(.horizontal, 8) 73 | .padding(.vertical, 4) 74 | .background(Color.green, in: .rect(cornerRadius: 5)) 75 | } 76 | .padding() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/TagKit/Views/TagTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagTextField.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-04-22. 6 | // Copyright © 2021 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /** 12 | This text field will automatically slugify any text that is 13 | entered into it. 14 | 15 | This text field will also make it harder to type characters 16 | that are not in the configuration's allowed character set. 17 | */ 18 | public struct TagTextField: View { 19 | 20 | /// Create a new tag text field. 21 | /// 22 | /// - Parameters: 23 | /// - text: The text binding. 24 | /// - placeholder: The text field placeholder, by default empty. 25 | /// - configuration: The slug configuration to use, by default ``SlugConfiguration/standard``. 26 | public init( 27 | text: Binding, 28 | placeholder: String = "", 29 | configuration: SlugConfiguration = .standard 30 | ) { 31 | self.text = Binding( 32 | get: { text.wrappedValue.slugified() }, 33 | set: { text.wrappedValue = $0.slugified() } 34 | ) 35 | self.placeholder = placeholder 36 | self.configuration = configuration 37 | } 38 | 39 | private let text: Binding 40 | private let placeholder: String 41 | private let configuration: SlugConfiguration 42 | 43 | public var body: some View { 44 | TextField(placeholder, text: text) 45 | .textCase(.lowercase) 46 | .withoutCapitalization() 47 | } 48 | } 49 | 50 | private extension View { 51 | 52 | @ViewBuilder 53 | func withoutCapitalization() -> some View { 54 | #if os(iOS) 55 | self.autocapitalization(.none) 56 | #else 57 | self 58 | #endif 59 | } 60 | } 61 | 62 | #Preview { 63 | 64 | struct Preview: View { 65 | 66 | @State var text = "" 67 | 68 | var body: some View { 69 | TagTextField(text: $text, placeholder: "Enter tag") 70 | #if os(iOS) 71 | .textFieldStyle(.roundedBorder) 72 | #endif 73 | .padding() 74 | } 75 | } 76 | 77 | return Preview() 78 | } 79 | -------------------------------------------------------------------------------- /Sources/TagKit/_Deprecated/Slugifiable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Slugifiable.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-18. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @available(*, deprecated, renamed: "Sluggable") 12 | public protocol Slugifiable { 13 | 14 | /// The value used to create a slugified representation. 15 | var slugifiableValue: String { get } 16 | } 17 | 18 | @available(*, deprecated, renamed: "Sluggable") 19 | public extension Slugifiable { 20 | 21 | /// Convert the slugifiable value to a slugified string. 22 | /// 23 | /// With the default configuration, `Hello, world!` will 24 | /// be slugified to `hello-world`. 25 | /// 26 | /// - Parameters: 27 | /// - configuration: The configuration to use, by default ``SlugConfiguration/standard``. 28 | func slugified( 29 | configuration: SlugConfiguration = .standard 30 | ) -> String { 31 | slugifiableValue.lowercased() 32 | .components(separatedBy: configuration.notAllowedCharacterSet) 33 | .filter { !$0.isEmpty } 34 | .joined(separator: configuration.separator) 35 | } 36 | } 37 | 38 | public extension String { 39 | 40 | @available(*, deprecated, renamed: "slugified(with:)") 41 | func slugified( 42 | configuration: SlugConfiguration 43 | ) -> String { 44 | slugified(with: configuration) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/TagKit/_Deprecated/TagCapsule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagCapsule.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-19. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import SwiftUI 11 | 12 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 13 | public struct TagCapsule: View { 14 | 15 | /// Create a tag capsule. 16 | /// 17 | /// - Parameters: 18 | /// - tag: The tag to render. 19 | public init( 20 | _ tag: String 21 | ) { 22 | self.tag = tag 23 | } 24 | 25 | private let tag: String 26 | 27 | @Environment(\.tagCapsuleStyle) 28 | private var style: TagCapsuleStyle 29 | 30 | public var body: some View { 31 | Text(tag) 32 | .padding(style.padding) 33 | .foregroundColor(style.foregroundColor) 34 | .materialCapsuleBackground(with: style.backgroundMaterial) 35 | .background(Capsule().fill(style.backgroundColor)) 36 | .padding(style.border.width) 37 | .background(Capsule() 38 | .strokeBorder( 39 | style.border.color, 40 | lineWidth: style.border.width 41 | ) 42 | ) 43 | .compositingGroup() 44 | .shadow( 45 | color: style.shadow.color, 46 | radius: style.shadow.radius, 47 | x: style.shadow.offsetX, 48 | y: style.shadow.offsetY 49 | ) 50 | 51 | } 52 | } 53 | 54 | private extension View { 55 | 56 | @ViewBuilder 57 | func materialCapsuleBackground( 58 | with material: Material? 59 | ) -> some View { 60 | if let material { 61 | self.background(Capsule().fill(material)) 62 | } else { 63 | self 64 | } 65 | } 66 | } 67 | 68 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 69 | private extension TagCapsuleStyle { 70 | 71 | static var spiderman: Self { 72 | .init( 73 | foregroundColor: .black, 74 | backgroundColor: .red, 75 | backgroundMaterial: .thin, 76 | border: .init( 77 | color: .blue, 78 | width: 4 79 | ), 80 | padding: .init( 81 | top: 10, 82 | leading: 20, 83 | bottom: 12, 84 | trailing: 20 85 | ) 86 | ) 87 | } 88 | 89 | static var spidermanSelected: Self { 90 | var style = Self.spiderman 91 | style.backgroundMaterial = .none 92 | style.shadow = .standardSelected 93 | return style 94 | } 95 | } 96 | #endif 97 | -------------------------------------------------------------------------------- /Sources/TagKit/_Deprecated/TagCapsuleStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagCapsuleStyle.swift 3 | // TagKit 4 | // 5 | // Created by Daniel Saidi on 2022-09-07. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import SwiftUI 11 | 12 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 13 | public struct TagCapsuleStyle { 14 | 15 | /// Create a new tag capsule style. 16 | /// 17 | /// - Parameters: 18 | /// - foregroundColor: The foreground color, by default `.primary`. 19 | /// - backgroundColor: The background color, by default `.clear`. 20 | /// - backgroundMaterial: The background material, by default `.ultraThin`. 21 | /// - border: The border style, by default ``Border/standard``. 22 | /// - shadow: The shadow style, by default ``Shadow/standard``. 23 | /// - padding: The intrinsic padding to apply, by default a small padding. 24 | public init( 25 | foregroundColor: Color = .primary, 26 | backgroundColor: Color = .clear, 27 | backgroundMaterial: Material? = .ultraThin, 28 | border: Border = .standard, 29 | shadow: Shadow = .standard, 30 | padding: EdgeInsets? = nil 31 | ) { 32 | var defaultPadding: EdgeInsets 33 | #if os(tvOS) 34 | defaultPadding = .init(top: 12, leading: 20, bottom: 14, trailing: 20) 35 | #else 36 | defaultPadding = .init(top: 5, leading: 10, bottom: 7, trailing: 10) 37 | #endif 38 | 39 | self.foregroundColor = foregroundColor 40 | self.backgroundColor = backgroundColor 41 | self.backgroundMaterial = backgroundMaterial 42 | self.border = border 43 | self.shadow = shadow 44 | self.padding = padding ?? defaultPadding 45 | } 46 | 47 | @available(*, deprecated, message: "Use the new borderStyle initializer instead.") 48 | public init( 49 | foregroundColor: Color = .primary, 50 | backgroundColor: Color = .clear, 51 | backgroundMaterial: Material? = .ultraThin, 52 | borderColor: Color = .clear, 53 | borderWidth: Double = 1, 54 | shadow: Shadow = .standard, 55 | padding: EdgeInsets? = nil 56 | ) { 57 | self.init( 58 | foregroundColor: foregroundColor, 59 | backgroundColor: backgroundColor, 60 | backgroundMaterial: backgroundMaterial, 61 | border: .init( 62 | color: borderColor, 63 | width: borderWidth 64 | ), 65 | shadow: shadow, 66 | padding: padding 67 | ) 68 | } 69 | 70 | /// The foreground color. 71 | public var foregroundColor: Color 72 | 73 | /// The background color. 74 | public var backgroundColor: Color 75 | 76 | /// The background material. 77 | public var backgroundMaterial: Material? 78 | 79 | /// The border style. 80 | public var border: Border 81 | 82 | /// The shadow style. 83 | public var shadow: Shadow 84 | 85 | /// The padding to apply to the text. 86 | public var padding: EdgeInsets 87 | } 88 | 89 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 90 | public extension TagCapsuleStyle { 91 | 92 | struct Border { 93 | 94 | /// Create a new tag capsule border style. 95 | /// 96 | /// - Parameters: 97 | /// - color: The border color, by default `.clear`. 98 | /// - width: The border width, by default `1`. 99 | public init( 100 | color: Color = .clear, 101 | width: Double = 1 102 | ) { 103 | self.color = color 104 | self.width = width 105 | } 106 | 107 | /// The border color. 108 | public var color: Color 109 | 110 | /// The border width. 111 | public var width: Double 112 | } 113 | 114 | struct Shadow { 115 | 116 | /// Create a new tag capsule shadow style. 117 | /// 118 | /// - Parameters: 119 | /// - color: The shadow color, by default `.clear`. 120 | /// - radius: The shadow radius, by default `0`. 121 | /// - offsetX: The x offset, by default `0`. 122 | /// - offsetY: The y offset, by default `1`. 123 | public init( 124 | color: Color = .clear, 125 | radius: Double = 0, 126 | offsetX: Double = 0, 127 | offsetY: Double = 1 128 | ) { 129 | self.color = color 130 | self.radius = radius 131 | self.offsetX = offsetX 132 | self.offsetY = offsetY 133 | } 134 | 135 | /// The shadow color. 136 | public var color: Color 137 | 138 | /// The shadow radius. 139 | public var radius: Double 140 | 141 | /// The x offset. 142 | public var offsetX: Double 143 | 144 | /// The y offset. 145 | public var offsetY: Double 146 | } 147 | 148 | /// The standard style. 149 | static var standard: Self { 150 | .init() 151 | } 152 | 153 | /// The standard, selected style. 154 | static var standardSelected: Self { 155 | .init( 156 | backgroundMaterial: .regular, 157 | border: .standardSelected, 158 | shadow: .standardSelected 159 | ) 160 | } 161 | } 162 | 163 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 164 | public extension TagCapsuleStyle.Shadow { 165 | 166 | /// The standard shadow style. 167 | static var standard: Self { 168 | .init() 169 | } 170 | 171 | /// The standard, selected shadow style. 172 | static var standardSelected: Self { 173 | .init( 174 | color: .primary.opacity(0.5), 175 | radius: 0 176 | ) 177 | } 178 | } 179 | 180 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 181 | public extension TagCapsuleStyle.Border { 182 | 183 | /// The standard border style. 184 | static var standard: Self { 185 | .init() 186 | } 187 | 188 | /// The standard, selected border style. 189 | static var standardSelected: Self { 190 | .init() 191 | } 192 | } 193 | 194 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 195 | public extension View { 196 | 197 | /// Apply a ``TagCapsule`` style to the view hierarchy. 198 | func tagCapsuleStyle( 199 | _ style: TagCapsuleStyle 200 | ) -> some View { 201 | self.environment(\.tagCapsuleStyle, style) 202 | } 203 | } 204 | 205 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 206 | private extension TagCapsuleStyle { 207 | 208 | struct Key: EnvironmentKey { 209 | 210 | public static var defaultValue: TagCapsuleStyle { .standard } 211 | } 212 | } 213 | 214 | @available(*, deprecated, message: "Just use a regular Text element and style it as you wish.") 215 | public extension EnvironmentValues { 216 | 217 | /// This value can bind to a line spacing picker config. 218 | var tagCapsuleStyle: TagCapsuleStyle { 219 | get { self [TagCapsuleStyle.Key.self] } 220 | set { self [TagCapsuleStyle.Key.self] = newValue } 221 | } 222 | } 223 | #endif 224 | -------------------------------------------------------------------------------- /Sources/TagKit/_Deprecated/Views+Deprecated.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension TagList { 4 | 5 | @available(*, deprecated, message: "The container parameter is no longer used") 6 | public init( 7 | tags: [String], 8 | container: TagListContainer, 9 | @ViewBuilder tagView: @escaping TagViewBuilder 10 | ) { 11 | self.init( 12 | tags: tags, 13 | tagView: tagView 14 | ) 15 | } 16 | 17 | @available(*, deprecated, message: "The container and spacing parameters are no longer used") 18 | public init( 19 | tags: [String], 20 | container: TagListContainer, 21 | horizontalSpacing: CGFloat, 22 | @ViewBuilder tagView: @escaping TagViewBuilder 23 | ) { 24 | self.init( 25 | tags: tags, 26 | tagView: tagView 27 | ) 28 | } 29 | 30 | @available(*, deprecated, message: "The container and spacing parameters are no longer used") 31 | public init( 32 | tags: [String], 33 | container: TagListContainer, 34 | verticalSpacing: CGFloat, 35 | @ViewBuilder tagView: @escaping TagViewBuilder 36 | ) { 37 | self.init( 38 | tags: tags, 39 | tagView: tagView 40 | ) 41 | } 42 | 43 | @available(*, deprecated, message: "The spacing parameter is no longer used") 44 | public init( 45 | tags: [String], 46 | horizontalSpacing: CGFloat, 47 | @ViewBuilder tagView: @escaping TagViewBuilder 48 | ) { 49 | self.init( 50 | tags: tags, 51 | tagView: tagView 52 | ) 53 | } 54 | 55 | @available(*, deprecated, message: "The spacing parameter is no longer used") 56 | public init( 57 | tags: [String], 58 | verticalSpacing: CGFloat, 59 | @ViewBuilder tagView: @escaping TagViewBuilder 60 | ) { 61 | self.init( 62 | tags: tags, 63 | tagView: tagView 64 | ) 65 | } 66 | } 67 | 68 | extension TagEditList { 69 | 70 | @available(*, deprecated, message: "The container parameter is no longer used") 71 | public init( 72 | tags: Binding<[String]>, 73 | additionalTags: [String], 74 | container: TagListContainer, 75 | @ViewBuilder tagView: @escaping TagViewBuilder 76 | ) { 77 | self.init( 78 | tags: tags, 79 | additionalTags: additionalTags, 80 | tagView: tagView 81 | ) 82 | } 83 | 84 | @available(*, deprecated, message: "The container and spacing parameters are no longer used") 85 | public init( 86 | tags: Binding<[String]>, 87 | additionalTags: [String], 88 | container: TagListContainer, 89 | horizontalSpacing: CGFloat, 90 | @ViewBuilder tagView: @escaping TagViewBuilder 91 | ) { 92 | self.init( 93 | tags: tags, 94 | additionalTags: additionalTags, 95 | tagView: tagView 96 | ) 97 | } 98 | 99 | @available(*, deprecated, message: "The container and spacing parameters are no longer used") 100 | public init( 101 | tags: Binding<[String]>, 102 | additionalTags: [String], 103 | container: TagListContainer, 104 | verticalSpacing: CGFloat, 105 | @ViewBuilder tagView: @escaping TagViewBuilder 106 | ) { 107 | self.init( 108 | tags: tags, 109 | additionalTags: additionalTags, 110 | tagView: tagView 111 | ) 112 | } 113 | 114 | @available(*, deprecated, message: "The spacing parameter is no longer used") 115 | public init( 116 | tags: Binding<[String]>, 117 | additionalTags: [String], 118 | horizontalSpacing: CGFloat, 119 | @ViewBuilder tagView: @escaping TagViewBuilder 120 | ) { 121 | self.init( 122 | tags: tags, 123 | additionalTags: additionalTags, 124 | tagView: tagView 125 | ) 126 | } 127 | 128 | @available(*, deprecated, message: "The spacing parameter is no longer used") 129 | public init( 130 | tags: Binding<[String]>, 131 | additionalTags: [String], 132 | verticalSpacing: CGFloat, 133 | @ViewBuilder tagView: @escaping TagViewBuilder 134 | ) { 135 | self.init( 136 | tags: tags, 137 | additionalTags: additionalTags, 138 | tagView: tagView 139 | ) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Tests/TagKitTests/SlugsTests.swift: -------------------------------------------------------------------------------- 1 | import TagKit 2 | import XCTest 3 | 4 | final class SlugConfigurationTests: XCTestCase { 5 | 6 | func testStringUsesStandardSlugConfigurationByDefault() { 7 | let string = "I'd love a super-great AppleCar!" 8 | let result = string.slugified() 9 | let expected = "i-d-love-a-super-great-applecar" 10 | 11 | XCTAssertEqual(result, expected) 12 | } 13 | 14 | func testStringCanUseCustomSlugConfiguration() { 15 | let string = "I'd love a super-great AppleCar!" 16 | let config = SlugConfiguration( 17 | separator: "+", 18 | allowedCharacters: "abc") 19 | let result = string.slugified(with: config) 20 | let expected = "a+a+a+ca" 21 | 22 | XCTAssertEqual(result, expected) 23 | } 24 | 25 | func testHasValidStandardSlugConfiguration() { 26 | let config = SlugConfiguration.standard 27 | let expected = "abcdefghijklmnopqrstuvwxyz0123456789-" 28 | 29 | XCTAssertEqual(config.separator, "-") 30 | XCTAssertEqual(config.allowedCharacters, expected) 31 | XCTAssertEqual(config.allowedCharacterSet, NSCharacterSet(charactersIn: expected)) 32 | } 33 | 34 | func testCanCreateCustomSlugConfiguration() { 35 | let config = SlugConfiguration( 36 | separator: "+", 37 | allowedCharacters: "abc123") 38 | let expected = "abc123+" 39 | 40 | XCTAssertEqual(config.separator, "+") 41 | XCTAssertEqual(config.allowedCharacters, expected) 42 | XCTAssertEqual(config.allowedCharacterSet, NSCharacterSet(charactersIn: expected)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/TagKitTests/TagsTests.swift: -------------------------------------------------------------------------------- 1 | import TagKit 2 | import XCTest 3 | 4 | private struct TestItem: Taggable { 5 | 6 | var tags: [String] 7 | } 8 | 9 | final class TaggableItemTests: XCTestCase { 10 | 11 | func test_hasTags_returnsTrueIfItemHasTags() { 12 | let item1 = TestItem(tags: []) 13 | let item2 = TestItem(tags: ["foo"]) 14 | 15 | XCTAssertFalse(item1.hasTags) 16 | XCTAssertTrue(item2.hasTags) 17 | } 18 | 19 | func test_slugifiedTags_canReturnSlugifiedTagsUsingStandardConfiguration() { 20 | let item = TestItem(tags: ["first tag", "Second tag"]) 21 | let result = item.slugifiedTags 22 | 23 | XCTAssertEqual(result, ["first-tag", "second-tag"]) 24 | } 25 | 26 | func test_slugifiedTags_canReturnSlugifiedTagsUsingCustomConfiguration() { 27 | let item = TestItem(tags: ["first tag", "Second tag"]) 28 | let config = SlugConfiguration(separator: "+") 29 | let result = item.slugifiedTags(configuration: config) 30 | 31 | XCTAssertEqual(result, ["first+tag", "second+tag"]) 32 | } 33 | 34 | func test_addTag_addsNonExistingTags() { 35 | var item = TestItem(tags: ["tag1", "tag2"]) 36 | item.addTag("tag2") 37 | item.addTag("tag3") 38 | 39 | XCTAssertEqual(item.tags, ["tag1", "tag2", "tag3"]) 40 | } 41 | 42 | func test_hasTag_returnsSlugifiedMatch() { 43 | let item = TestItem(tags: ["first tag ", " Second tag"]) 44 | 45 | XCTAssertTrue(item.hasTag("First Tag")) 46 | XCTAssertTrue(item.hasTag("first-tag")) 47 | XCTAssertTrue(item.hasTag("Second Tag")) 48 | XCTAssertTrue(item.hasTag("second-tag")) 49 | XCTAssertFalse(item.hasTag("Third Tag")) 50 | XCTAssertFalse(item.hasTag("third-tag")) 51 | } 52 | 53 | func test_removeTag_removesExistingTags() { 54 | var item = TestItem(tags: ["tag1", "tag2"]) 55 | item.removeTag("tag2") 56 | item.removeTag("tag3") 57 | 58 | XCTAssertEqual(item.tags, ["tag1"]) 59 | } 60 | 61 | func test_toggleTag_addsOrRemovesTags() { 62 | var item = TestItem(tags: ["tag1", "tag2"]) 63 | item.toggleTag("tag2") 64 | XCTAssertEqual(item.tags, ["tag1"]) 65 | item.toggleTag("tag2") 66 | XCTAssertEqual(item.tags, ["tag1", "tag2"]) 67 | } 68 | 69 | private let items: [TestItem] = { 70 | var item1 = TestItem(tags: ["item", "Item 1"]) 71 | var item2 = TestItem(tags: ["iTem", "item 2"]) 72 | var item3 = TestItem(tags: ["iteM", "item 3"]) 73 | return [item1, item2, item3] 74 | }() 75 | 76 | func test_allTagsRetursSlugifiedItems() { 77 | let result = items.allTags 78 | 79 | XCTAssertEqual(result, ["item", "item-1", "item-2", "item-3"]) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new project version for the current project. 5 | # You can customize this to fit your project when you copy these scripts. 6 | # You can pass in a custom branch if you don't want to use the default one. 7 | 8 | SCRIPT="scripts/package_version.sh" 9 | chmod +x $SCRIPT 10 | bash $SCRIPT 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds a for all provided . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # build.sh [ default:iOS macOS tvOS watchOS xrOS] 10 | # e.g. `bash scripts/build.sh MyTarget iOS macOS` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Verify that all required arguments are provided 16 | if [ $# -eq 0 ]; then 17 | echo "Error: This script requires at least one argument" 18 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 19 | echo "For instance: $0 MyTarget iOS macOS" 20 | exit 1 21 | fi 22 | 23 | # Define argument variables 24 | TARGET=$1 25 | 26 | # Remove TARGET from arguments list 27 | shift 28 | 29 | # Define platforms variable 30 | if [ $# -eq 0 ]; then 31 | set -- iOS macOS tvOS watchOS xrOS 32 | fi 33 | PLATFORMS=$@ 34 | 35 | # A function that builds $TARGET for a specific platform 36 | build_platform() { 37 | 38 | # Define a local $PLATFORM variable 39 | local PLATFORM=$1 40 | 41 | # Build $TARGET for the $PLATFORM 42 | echo "Building $TARGET for $PLATFORM..." 43 | if ! xcodebuild -scheme $TARGET -derivedDataPath .build -destination generic/platform=$PLATFORM; then 44 | echo "Failed to build $TARGET for $PLATFORM" 45 | return 1 46 | fi 47 | 48 | # Complete successfully 49 | echo "Successfully built $TARGET for $PLATFORM" 50 | } 51 | 52 | # Start script 53 | echo "" 54 | echo "Building $TARGET for [$PLATFORMS]..." 55 | echo "" 56 | 57 | # Loop through all platforms and call the build function 58 | for PLATFORM in $PLATFORMS; do 59 | if ! build_platform "$PLATFORM"; then 60 | exit 1 61 | fi 62 | done 63 | 64 | # Complete successfully 65 | echo "" 66 | echo "Building $TARGET completed successfully!" 67 | echo "" 68 | -------------------------------------------------------------------------------- /scripts/chmod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script makes all scripts in this folder executable. 5 | 6 | # Usage: 7 | # scripts_chmod.sh 8 | # e.g. `bash scripts/chmod.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Use the script folder to refer to other scripts. 14 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 15 | 16 | # Find all .sh files in the FOLDER except chmod.sh 17 | find "$FOLDER" -name "*.sh" ! -name "chmod.sh" -type f | while read -r script; do 18 | chmod +x "$script" 19 | done 20 | -------------------------------------------------------------------------------- /scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | # The documentation ends up in to .build/docs-. 8 | 9 | # Usage: 10 | # docc.sh [ default:iOS macOS tvOS watchOS xrOS] 11 | # e.g. `bash scripts/docc.sh MyTarget iOS macOS` 12 | 13 | # Exit immediately if a command exits with a non-zero status 14 | set -e 15 | 16 | # Fail if any command in a pipeline fails 17 | set -o pipefail 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | echo "For instance: $0 MyTarget iOS macOS" 24 | exit 1 25 | fi 26 | 27 | # Define argument variables 28 | TARGET=$1 29 | TARGET_LOWERCASED=$(echo "$1" | tr '[:upper:]' '[:lower:]') 30 | 31 | # Remove TARGET from arguments list 32 | shift 33 | 34 | # Define platforms variable 35 | if [ $# -eq 0 ]; then 36 | set -- iOS macOS tvOS watchOS xrOS 37 | fi 38 | PLATFORMS=$@ 39 | 40 | # Prepare the package for DocC 41 | swift package resolve; 42 | 43 | # A function that builds $TARGET for a specific platform 44 | build_platform() { 45 | 46 | # Define a local $PLATFORM variable and set an exit code 47 | local PLATFORM=$1 48 | local EXIT_CODE=0 49 | 50 | # Define the build folder name, based on the $PLATFORM 51 | case $PLATFORM in 52 | "iOS") 53 | DEBUG_PATH="Debug-iphoneos" 54 | ;; 55 | "macOS") 56 | DEBUG_PATH="Debug" 57 | ;; 58 | "tvOS") 59 | DEBUG_PATH="Debug-appletvos" 60 | ;; 61 | "watchOS") 62 | DEBUG_PATH="Debug-watchos" 63 | ;; 64 | "xrOS") 65 | DEBUG_PATH="Debug-xros" 66 | ;; 67 | *) 68 | echo "Error: Unsupported platform '$PLATFORM'" 69 | exit 1 70 | ;; 71 | esac 72 | 73 | # Build $TARGET docs for the $PLATFORM 74 | echo "Building $TARGET docs for $PLATFORM..." 75 | if ! xcodebuild docbuild -scheme $TARGET -derivedDataPath .build/docbuild -destination "generic/platform=$PLATFORM"; then 76 | echo "Error: Failed to build documentation for $PLATFORM" >&2 77 | return 1 78 | fi 79 | 80 | # Transform docs for static hosting 81 | if ! $(xcrun --find docc) process-archive \ 82 | transform-for-static-hosting .build/docbuild/Build/Products/$DEBUG_PATH/$TARGET.doccarchive \ 83 | --output-path .build/docs-$PLATFORM \ 84 | --hosting-base-path "$TARGET"; then 85 | echo "Error: Failed to transform documentation for $PLATFORM" >&2 86 | return 1 87 | fi 88 | 89 | # Inject a root redirect script on the root page 90 | echo "" > .build/docs-$PLATFORM/index.html; 91 | 92 | # Complete successfully 93 | echo "Successfully built $TARGET docs for $PLATFORM" 94 | return 0 95 | } 96 | 97 | # Start script 98 | echo "" 99 | echo "Building $TARGET docs for [$PLATFORMS]..." 100 | echo "" 101 | 102 | # Loop through all platforms and call the build function 103 | for PLATFORM in $PLATFORMS; do 104 | if ! build_platform "$PLATFORM"; then 105 | exit 1 106 | fi 107 | done 108 | 109 | # Complete successfully 110 | echo "" 111 | echo "Building $TARGET docs completed successfully!" 112 | echo "" 113 | -------------------------------------------------------------------------------- /scripts/framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Important: 9 | # This script doesn't work on packages, only on .xcproj projects that generate a framework. 10 | 11 | # Usage: 12 | # framework.sh [ default:iOS macOS tvOS watchOS xrOS] 13 | # e.g. `bash scripts/framework.sh MyTarget iOS macOS` 14 | 15 | # Exit immediately if a command exits with a non-zero status 16 | set -e 17 | 18 | # Verify that all required arguments are provided 19 | if [ $# -eq 0 ]; then 20 | echo "Error: This script requires exactly one argument" 21 | echo "Usage: $0 " 22 | exit 1 23 | fi 24 | 25 | # Define argument variables 26 | TARGET=$1 27 | 28 | # Remove TARGET from arguments list 29 | shift 30 | 31 | # Define platforms variable 32 | if [ $# -eq 0 ]; then 33 | set -- iOS macOS tvOS watchOS xrOS 34 | fi 35 | PLATFORMS=$@ 36 | 37 | # Define local variables 38 | BUILD_FOLDER=.build 39 | BUILD_FOLDER_ARCHIVES=.build/framework_archives 40 | BUILD_FILE=$BUILD_FOLDER/$TARGET.xcframework 41 | BUILD_ZIP=$BUILD_FOLDER/$TARGET.zip 42 | 43 | # Start script 44 | echo "" 45 | echo "Building $TARGET XCFramework for [$PLATFORMS]..." 46 | echo "" 47 | 48 | # Delete old builds 49 | echo "Cleaning old builds..." 50 | rm -rf $BUILD_ZIP 51 | rm -rf $BUILD_FILE 52 | rm -rf $BUILD_FOLDER_ARCHIVES 53 | 54 | 55 | # Generate XCArchive files for all platforms 56 | echo "Generating XCArchives..." 57 | 58 | # Initialize the xcframework command 59 | XCFRAMEWORK_CMD="xcodebuild -create-xcframework" 60 | 61 | # Build iOS archives and append to the xcframework command 62 | if [[ " ${PLATFORMS[@]} " =~ " iOS " ]]; then 63 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 64 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 65 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 66 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 67 | fi 68 | 69 | # Build iOS archive and append to the xcframework command 70 | if [[ " ${PLATFORMS[@]} " =~ " macOS " ]]; then 71 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=macOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-macOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 72 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-macOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 73 | fi 74 | 75 | # Build tvOS archives and append to the xcframework command 76 | if [[ " ${PLATFORMS[@]} " =~ " tvOS " ]]; then 77 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 78 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 79 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 80 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 81 | fi 82 | 83 | # Build watchOS archives and append to the xcframework command 84 | if [[ " ${PLATFORMS[@]} " =~ " watchOS " ]]; then 85 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 86 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 87 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 88 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 89 | fi 90 | 91 | # Build xrOS archives and append to the xcframework command 92 | if [[ " ${PLATFORMS[@]} " =~ " xrOS " ]]; then 93 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 94 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 95 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 96 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 97 | fi 98 | 99 | # Genererate XCFramework 100 | echo "Generating XCFramework..." 101 | XCFRAMEWORK_CMD+=" -output $BUILD_FILE" 102 | eval "$XCFRAMEWORK_CMD" 103 | 104 | # Genererate iOS XCFramework zip 105 | echo "Generating XCFramework zip..." 106 | zip -r $BUILD_ZIP $BUILD_FILE 107 | echo "" 108 | echo "***** CHECKSUM *****" 109 | swift package compute-checksum $BUILD_ZIP 110 | echo "********************" 111 | echo "" 112 | 113 | # Complete successfully 114 | echo "" 115 | echo "$TARGET XCFramework created successfully!" 116 | echo "" 117 | -------------------------------------------------------------------------------- /scripts/git_default_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script echos the default git branch name. 5 | 6 | # Usage: 7 | # git_default_branch.sh 8 | # e.g. `bash scripts/git_default_branch.sh` 9 | 10 | BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') 11 | echo $BRANCH 12 | -------------------------------------------------------------------------------- /scripts/package_docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC documentation for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_docc.sh [ default:iOS] 9 | # e.g. `bash scripts/package_docc.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_DOCC="$FOLDER/docc.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package documentation 29 | bash $SCRIPT_DOCC $PACKAGE_NAME $PLATFORMS || { echo "DocC script failed"; exit 1; } 30 | -------------------------------------------------------------------------------- /scripts/package_framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script generates an XCFramework for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_framework.sh [ default:iOS] 9 | # e.g. `bash scripts/package_framework.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_FRAMEWORK="$FOLDER/framework.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package framework 29 | bash $SCRIPT_FRAMEWORK $PACKAGE_NAME $PLATFORMS 30 | -------------------------------------------------------------------------------- /scripts/package_name.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script finds the main target name in `Package.swift`. 5 | 6 | # Usage: 7 | # package_name.sh 8 | # e.g. `bash scripts/package_name.sh` 9 | 10 | # Exit immediately if a command exits with non-zero status 11 | set -e 12 | 13 | # Check that a Package.swift file exists 14 | if [ ! -f "Package.swift" ]; then 15 | echo "Error: Package.swift not found in current directory" 16 | exit 1 17 | fi 18 | 19 | # Using grep and sed to extract the package name 20 | # 1. grep finds the line containing "name:" 21 | # 2. sed extracts the text between quotes 22 | package_name=$(grep -m 1 'name:.*"' Package.swift | sed -n 's/.*name:[[:space:]]*"\([^"]*\)".*/\1/p') 23 | 24 | if [ -z "$package_name" ]; then 25 | echo "Error: Could not find package name in Package.swift" 26 | exit 1 27 | else 28 | echo "$package_name" 29 | fi 30 | -------------------------------------------------------------------------------- /scripts/package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for `Package.swift`. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # package_version.sh 9 | # e.g. `bash scripts/package_version.sh master` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_BRANCH_NAME="$FOLDER/git_default_branch.sh" 17 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 18 | SCRIPT_VERSION="$FOLDER/version.sh" 19 | 20 | # Get branch name 21 | DEFAULT_BRANCH=$("$SCRIPT_BRANCH_NAME") || { echo "Failed to get branch name"; exit 1; } 22 | BRANCH_NAME=${1:-$DEFAULT_BRANCH} 23 | 24 | # Get package name 25 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 26 | 27 | # Build package version 28 | bash $SCRIPT_VERSION $PACKAGE_NAME $BRANCH_NAME 29 | -------------------------------------------------------------------------------- /scripts/sync_from.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script syncs Swift Package Scripts from a . 5 | # This script will overwrite the existing "scripts" folder. 6 | # Only pass in the full path to a Swift Package Scripts root. 7 | 8 | # Usage: 9 | # package_name.sh 10 | # e.g. `bash sync_from.sh ../SwiftPackageScripts` 11 | 12 | # Define argument variables 13 | SOURCE=$1 14 | 15 | # Define variables 16 | FOLDER="scripts/" 17 | SOURCE_FOLDER="$SOURCE/$FOLDER" 18 | 19 | # Start script 20 | echo "" 21 | echo "Syncing scripts from $SOURCE_FOLDER..." 22 | echo "" 23 | 24 | # Remove existing folder 25 | rm -rf $FOLDER 26 | 27 | # Copy folder 28 | cp -r "$SOURCE_FOLDER/" "$FOLDER/" 29 | 30 | # Complete successfully 31 | echo "" 32 | echo "Script syncing from $SOURCE_FOLDER completed successfully!" 33 | echo "" 34 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script tests a for all provided . 5 | 6 | # Usage: 7 | # test.sh [ default:iOS macOS tvOS watchOS xrOS] 8 | # e.g. `bash scripts/test.sh MyTarget iOS macOS` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Verify that all required arguments are provided 14 | if [ $# -eq 0 ]; then 15 | echo "Error: This script requires at least one argument" 16 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 17 | echo "For instance: $0 MyTarget iOS macOS" 18 | exit 1 19 | fi 20 | 21 | # Define argument variables 22 | TARGET=$1 23 | 24 | # Remove TARGET from arguments list 25 | shift 26 | 27 | # Define platforms variable 28 | if [ $# -eq 0 ]; then 29 | set -- iOS macOS tvOS watchOS xrOS 30 | fi 31 | PLATFORMS=$@ 32 | 33 | # Start script 34 | echo "" 35 | echo "Testing $TARGET for [$PLATFORMS]..." 36 | echo "" 37 | 38 | # A function that gets the latest simulator for a certain OS. 39 | get_latest_simulator() { 40 | local PLATFORM=$1 41 | local SIMULATOR_TYPE 42 | 43 | case $PLATFORM in 44 | "iOS") 45 | SIMULATOR_TYPE="iPhone" 46 | ;; 47 | "tvOS") 48 | SIMULATOR_TYPE="Apple TV" 49 | ;; 50 | "watchOS") 51 | SIMULATOR_TYPE="Apple Watch" 52 | ;; 53 | "xrOS") 54 | SIMULATOR_TYPE="Apple Vision" 55 | ;; 56 | *) 57 | echo "Error: Unsupported platform for simulator '$PLATFORM'" 58 | return 1 59 | ;; 60 | esac 61 | 62 | # Get the latest simulator for the platform 63 | xcrun simctl list devices available | grep "$SIMULATOR_TYPE" | tail -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' 64 | } 65 | 66 | # A function that tests $TARGET for a specific platform 67 | test_platform() { 68 | 69 | # Define a local $PLATFORM variable 70 | local PLATFORM="${1//_/ }" 71 | 72 | # Define the destination, based on the $PLATFORM 73 | case $PLATFORM in 74 | "iOS"|"tvOS"|"watchOS"|"xrOS") 75 | local SIMULATOR_UDID=$(get_latest_simulator "$PLATFORM") 76 | if [ -z "$SIMULATOR_UDID" ]; then 77 | echo "Error: No simulator found for $PLATFORM" 78 | return 1 79 | fi 80 | DESTINATION="id=$SIMULATOR_UDID" 81 | ;; 82 | "macOS") 83 | DESTINATION="platform=macOS" 84 | ;; 85 | *) 86 | echo "Error: Unsupported platform '$PLATFORM'" 87 | return 1 88 | ;; 89 | esac 90 | 91 | # Test $TARGET for the $DESTINATION 92 | echo "Testing $TARGET for $PLATFORM..." 93 | xcodebuild test -scheme $TARGET -derivedDataPath .build -destination "$DESTINATION" -enableCodeCoverage YES 94 | local TEST_RESULT=$? 95 | 96 | if [[ $TEST_RESULT -ne 0 ]]; then 97 | return $TEST_RESULT 98 | fi 99 | 100 | # Complete successfully 101 | echo "Successfully tested $TARGET for $PLATFORM" 102 | return 0 103 | } 104 | 105 | # Loop through all platforms and call the test function 106 | for PLATFORM in $PLATFORMS; do 107 | if ! test_platform "$PLATFORM"; then 108 | exit 1 109 | fi 110 | done 111 | 112 | # Complete successfully 113 | echo "" 114 | echo "Testing $TARGET completed successfully!" 115 | echo "" 116 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for the provided and . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `scripts/version.sh MyTarget master iOS macOS` 11 | 12 | # This script will: 13 | # * Call version_validate_git.sh to validate the git repo. 14 | # * Call version_validate_target to run tests, swiftlint, etc. 15 | # * Call version_bump.sh if all validation steps above passed. 16 | 17 | # Exit immediately if a command exits with a non-zero status 18 | set -e 19 | 20 | # Verify that all required arguments are provided 21 | if [ $# -lt 2 ]; then 22 | echo "Error: This script requires at least two arguments" 23 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 24 | echo "For instance: $0 MyTarget master iOS macOS" 25 | exit 1 26 | fi 27 | 28 | # Define argument variables 29 | TARGET=$1 30 | BRANCH=${2:-main} 31 | 32 | # Remove TARGET and BRANCH from arguments list 33 | shift 34 | shift 35 | 36 | # Read platform arguments or use default value 37 | if [ $# -eq 0 ]; then 38 | set -- iOS macOS tvOS watchOS xrOS 39 | fi 40 | 41 | # Use the script folder to refer to other scripts. 42 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 43 | SCRIPT_VALIDATE_GIT="$FOLDER/version_validate_git.sh" 44 | SCRIPT_VALIDATE_TARGET="$FOLDER/version_validate_target.sh" 45 | SCRIPT_VERSION_BUMP="$FOLDER/version_bump.sh" 46 | 47 | # A function that run a certain script and checks for errors 48 | run_script() { 49 | local script="$1" 50 | shift # Remove the first argument (the script path) 51 | 52 | if [ ! -f "$script" ]; then 53 | echo "Error: Script not found: $script" 54 | exit 1 55 | fi 56 | 57 | chmod +x "$script" 58 | if ! "$script" "$@"; then 59 | echo "Error: Script $script failed" 60 | exit 1 61 | fi 62 | } 63 | 64 | # Start script 65 | echo "" 66 | echo "Creating a new version for $TARGET on the $BRANCH branch..." 67 | echo "" 68 | 69 | # Validate git and project 70 | echo "Validating..." 71 | run_script "$SCRIPT_VALIDATE_GIT" "$BRANCH" 72 | run_script "$SCRIPT_VALIDATE_TARGET" "$TARGET" 73 | 74 | # Bump version 75 | echo "Bumping version..." 76 | run_script "$SCRIPT_VERSION_BUMP" 77 | 78 | # Complete successfully 79 | echo "" 80 | echo "Version created successfully!" 81 | echo "" 82 | -------------------------------------------------------------------------------- /scripts/version_bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script bumps the project version number. 5 | # You can append --no-semver to disable semantic version validation. 6 | 7 | # Usage: 8 | # version_bump.sh [--no-semver] 9 | # e.g. `bash scripts/version_bump.sh` 10 | # e.g. `bash scripts/version_bump.sh --no-semver` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Use the script folder to refer to other scripts. 16 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 17 | SCRIPT_VERSION_NUMBER="$FOLDER/version_number.sh" 18 | 19 | 20 | # Parse --no-semver argument 21 | VALIDATE_SEMVER=true 22 | for arg in "$@"; do 23 | case $arg in 24 | --no-semver) 25 | VALIDATE_SEMVER=false 26 | shift # Remove --no-semver from processing 27 | ;; 28 | esac 29 | done 30 | 31 | # Start script 32 | echo "" 33 | echo "Bumping version number..." 34 | echo "" 35 | 36 | # Get the latest version 37 | VERSION=$($SCRIPT_VERSION_NUMBER) 38 | if [ $? -ne 0 ]; then 39 | echo "Failed to get the latest version" 40 | exit 1 41 | fi 42 | 43 | # Print the current version 44 | echo "The current version is: $VERSION" 45 | 46 | # Function to validate semver format, including optional -rc. suffix 47 | validate_semver() { 48 | if [ "$VALIDATE_SEMVER" = false ]; then 49 | return 0 50 | fi 51 | 52 | if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then 53 | return 0 54 | else 55 | return 1 56 | fi 57 | } 58 | 59 | # Prompt user for new version 60 | while true; do 61 | read -p "Enter the new version number: " NEW_VERSION 62 | 63 | # Validate the version number to ensure that it's a semver version 64 | if validate_semver "$NEW_VERSION"; then 65 | break 66 | else 67 | echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)." 68 | exit 1 69 | fi 70 | done 71 | 72 | # Push the new tag 73 | git push -u origin HEAD 74 | git tag $NEW_VERSION 75 | git push --tags 76 | 77 | # Complete successfully 78 | echo "" 79 | echo "Version tag pushed successfully!" 80 | echo "" 81 | -------------------------------------------------------------------------------- /scripts/version_number.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script returns the latest project version. 5 | 6 | # Usage: 7 | # version_number.sh 8 | # e.g. `bash scripts/version_number.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Check if the current directory is a Git repository 14 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 15 | echo "Error: Not a Git repository" 16 | exit 1 17 | fi 18 | 19 | # Fetch all tags 20 | git fetch --tags > /dev/null 2>&1 21 | 22 | # Get the latest semver tag 23 | latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) 24 | 25 | # Check if we found a version tag 26 | if [ -z "$latest_version" ]; then 27 | echo "Error: No semver tags found in this repository" >&2 28 | exit 1 29 | fi 30 | 31 | # Print the latest version 32 | echo "$latest_version" 33 | -------------------------------------------------------------------------------- /scripts/version_validate_git.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates the Git repository for release. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # version_validate_git.sh " 9 | # e.g. `bash scripts/version_validate_git.sh master` 10 | 11 | # This script will: 12 | # * Validate that the script is run within a git repository. 13 | # * Validate that the git repository doesn't have any uncommitted changes. 14 | # * Validate that the current git branch matches the provided one. 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires exactly one argument" 22 | echo "Usage: $0 " 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | BRANCH=$1 28 | 29 | # Start script 30 | echo "" 31 | echo "Validating git repository..." 32 | echo "" 33 | 34 | # Check if the current directory is a Git repository 35 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 36 | echo "Error: Not a Git repository" 37 | exit 1 38 | fi 39 | 40 | # Check for uncommitted changes 41 | if [ -n "$(git status --porcelain)" ]; then 42 | echo "Error: Git repository is dirty. There are uncommitted changes." 43 | exit 1 44 | fi 45 | 46 | # Verify that we're on the correct branch 47 | current_branch=$(git rev-parse --abbrev-ref HEAD) 48 | if [ "$current_branch" != "$BRANCH" ]; then 49 | echo "Error: Not on the specified branch. Current branch is $current_branch, expected $1." 50 | exit 1 51 | fi 52 | 53 | # The Git repository validation succeeded. 54 | echo "" 55 | echo "Git repository validated successfully!" 56 | echo "" 57 | -------------------------------------------------------------------------------- /scripts/version_validate_target.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates a for release. 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version_validate_target.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `bash scripts/version_validate_target.sh iOS macOS` 11 | 12 | # This script will: 13 | # * Validate that swiftlint passes. 14 | # * Validate that all unit tests passes for all . 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all requires at least one argument" 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | TARGET=$1 28 | 29 | # Remove TARGET from arguments list 30 | shift 31 | 32 | # Define platforms variable 33 | if [ $# -eq 0 ]; then 34 | set -- iOS macOS tvOS watchOS xrOS 35 | fi 36 | PLATFORMS=$@ 37 | 38 | # Use the script folder to refer to other scripts. 39 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 40 | SCRIPT_TEST="$FOLDER/test.sh" 41 | 42 | # A function that run a certain script and checks for errors 43 | run_script() { 44 | local script="$1" 45 | shift # Remove the first argument (script path) from the argument list 46 | 47 | if [ ! -f "$script" ]; then 48 | echo "Error: Script not found: $script" 49 | exit 1 50 | fi 51 | 52 | chmod +x "$script" 53 | if ! "$script" "$@"; then 54 | echo "Error: Script $script failed" 55 | exit 1 56 | fi 57 | } 58 | 59 | # Start script 60 | echo "" 61 | echo "Validating project..." 62 | echo "" 63 | 64 | # Run SwiftLint 65 | echo "Running SwiftLint" 66 | if ! swiftlint --strict; then 67 | echo "Error: SwiftLint failed" 68 | exit 1 69 | fi 70 | 71 | # Run unit tests 72 | echo "Testing..." 73 | run_script "$SCRIPT_TEST" "$TARGET" "$PLATFORMS" 74 | 75 | # Complete successfully 76 | echo "" 77 | echo "Project successfully validated!" 78 | echo "" 79 | --------------------------------------------------------------------------------