├── .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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 | 
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 |
--------------------------------------------------------------------------------