├── README.md ├── Demo ├── MastoParseDemoApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── MastoParseDemoAppApp.swift │ ├── ExamplePostHTML.swift │ └── ContentView.swift └── MastoParseDemoApp.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved │ └── project.pbxproj ├── Tests └── MastoParseTests │ └── MastoParseTests.swift ├── Package.resolved ├── LICENSE ├── Package.swift ├── .gitignore └── Sources └── MastoParse ├── MastoParseContentBlock.swift ├── MastoParseHtmlTree.swift └── MastoParseAccumulator.swift /README.md: -------------------------------------------------------------------------------- 1 | # MastoParse 2 | Parses Mastodon post contents for use in SwiftUI 3 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/MastoParseTests/MastoParseTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import MastoParse 3 | 4 | @Test func example() async throws { 5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 | } 7 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/MastoParseDemoAppApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastoParseDemoAppApp.swift 3 | // MastoParseDemoApp 4 | // 5 | // Created by Shannon Hughes on 7/2/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MastoParseDemoAppApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | DemoView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ca7e1563569a1bcf8fffdcba63f6e62ea61476227fb2265c52e8e6ff05c2f7fd", 3 | "pins" : [ 4 | { 5 | "identity" : "swiftsoup", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/scinfu/SwiftSoup.git", 8 | "state" : { 9 | "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", 10 | "version" : "2.8.8" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "7164d7bfd1cb7736e82985d8b1abdf090c043005faa4e402636e59c18332cdfd", 3 | "pins" : [ 4 | { 5 | "identity" : "swiftsoup", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/scinfu/SwiftSoup.git", 8 | "state" : { 9 | "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", 10 | "version" : "2.8.8" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mastodon 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 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MastoParse", 8 | platforms: [ 9 | .macOS(.v14), 10 | .iOS(.v16) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "MastoParse", 16 | targets: ["MastoParse"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMajor(from: "2.8.8")), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package, defining a module or a test suite. 23 | // Targets can depend on other targets in this package and products from dependencies. 24 | .target( 25 | name: "MastoParse", 26 | dependencies: ["SwiftSoup"] 27 | ), 28 | .testTarget( 29 | name: "MastoParseTests", 30 | dependencies: ["MastoParse"] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | .DS_Store 8 | 9 | ## Obj-C/Swift specific 10 | *.hmap 11 | 12 | ## App packaging 13 | *.ipa 14 | *.dSYM.zip 15 | *.dSYM 16 | 17 | ## Playgrounds 18 | timeline.xctimeline 19 | playground.xcworkspace 20 | 21 | # Swift Package Manager 22 | # 23 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 24 | # Packages/ 25 | # Package.pins 26 | # Package.resolved 27 | # *.xcodeproj 28 | # 29 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 30 | # hence it is not needed unless you have added a package configuration file to your project 31 | # .swiftpm 32 | 33 | .build/ 34 | 35 | # CocoaPods 36 | # 37 | # We recommend against adding the Pods directory to your .gitignore. However 38 | # you should judge for yourself, the pros and cons are mentioned at: 39 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 40 | # 41 | # Pods/ 42 | # 43 | # Add this line if you want to avoid checking in source code from the Xcode workspace 44 | # *.xcworkspace 45 | 46 | # Carthage 47 | # 48 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 49 | # Carthage/Checkouts 50 | 51 | Carthage/Build/ 52 | 53 | # fastlane 54 | # 55 | # It is recommended to not store the screenshots in the git repo. 56 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 57 | # For more information about the recommended setup visit: 58 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 59 | 60 | fastlane/report.xml 61 | fastlane/Preview.html 62 | fastlane/screenshots/**/*.png 63 | fastlane/test_output 64 | -------------------------------------------------------------------------------- /Sources/MastoParse/MastoParseContentBlock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastoParseContentBlock.swift 3 | // MastoParse 4 | // 5 | // Created by Shannon Hughes on 7/1/25. 6 | // 7 | 8 | public func getParseBlocks(from html: String) throws -> [MastoParseContentBlock] { 9 | let nodes = try buildContentTree(from: html) 10 | let blocks = getParseBlocks(from: nodes) 11 | return blocks 12 | } 13 | 14 | func getParseBlocks(from nodes: [MastoParseNode]) -> [MastoParseContentBlock] { 15 | let blocks = toMastoParseAccumulators(nodes, addingTo: nil).flatMap { accumulator in 16 | if let block = accumulator.contentBlock() { 17 | return [block] 18 | } else { 19 | return accumulator.contentRows(inheritingNestedFormatting: [], withPrefix: nil) 20 | } 21 | } 22 | 23 | return blocks 24 | } 25 | 26 | public class MastoParseContentBlock: Identifiable { 27 | } 28 | 29 | public enum MastoParseNestedFormat { 30 | case topLevelBlockquote 31 | case subordinateBlockquote 32 | case listLevel 33 | } 34 | 35 | public class MastoParseContentRow: MastoParseContentBlock { 36 | public enum Style { 37 | case paragraph 38 | case code 39 | } 40 | 41 | public let style: Style 42 | public let listItemPrefix: String? 43 | public let nestedFormatting: [MastoParseNestedFormat] 44 | public let contents: [MastoParseInlineElement] 45 | 46 | public init(contents: [MastoParseInlineElement], style: Style, listItemPrefix: String?, nestedFormatting: [MastoParseNestedFormat]) { 47 | self.style = style 48 | self.listItemPrefix = listItemPrefix 49 | self.contents = contents 50 | self.nestedFormatting = nestedFormatting 51 | } 52 | } 53 | 54 | public class MastoParseBlockquote: MastoParseContentBlock { 55 | public let contents: [MastoParseContentRow] 56 | 57 | init(contents: [MastoParseContentRow]) { 58 | self.contents = contents 59 | } 60 | } 61 | 62 | public struct MastoParseInlineElement: Sendable { 63 | public enum ElementType: Sendable { 64 | case text 65 | case code 66 | } 67 | 68 | public let type: ElementType 69 | public let contents: String 70 | 71 | public init(type: ElementType, contents: String) { 72 | self.type = type 73 | self.contents = contents 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/ExamplePostHTML.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExamplePostHTML.swift 3 | // TestingStrings 4 | // 5 | // Created by Shannon Hughes on 6/25/25. 6 | // 7 | 8 | let examples : [String] = [ 9 | 10 | "

This is a post with a blockquote...

\n
\n

This thing, here, is a block quote
with some bold as well

\n
I can even nest another blockquote!
And then nest yet another one! But we'll keep this one short.
\n \n

Here is some plaintext after the list. This is still inside the first blockquote.

Here's another nested blockquote! This could go on and on and on, but just long enough to wrap would be ok.
", 11 | 12 | "

This is a post with a list...

\n \n

Some plaintext after the list.

And another blockquote! Just another silly blockquote...
", 13 | 14 | "

This is a post with a variety of HTML in it

\n

For instance, this text is bold and this one as well, while this text is stricken through and this one as well.

\n
\n

This thing, here, is a block quote
with some bold as well

\n \n
\n
// And this is some code\n// with some comments\nlet x = 5
\n

And this is inline code

\n

Finally, please observe this Ruby element: 明日 (Ashita)

\n", 15 | 16 | "

A blog… https://blog.joinmastodon.org/2025/06/mastodon-dpga/

Can be a great thing to read!

", 17 | 18 | "

This is a post with an unordered list:

\n ", 19 | 20 | "

This is a post with an ordered list:

\n
    \n
  1. a list item
  2. \n
  3. \n and another with\n
      \n
    1. nested
    2. \n
    3. items!
    4. \n
    \n
  4. \n
  5. a final, unnested list item
", 21 | 22 | "

This is a self-quote of a remote formatted post

\n

RE: https://example.org/foo/bar/baz

\n" 23 | ] 24 | -------------------------------------------------------------------------------- /Sources/MastoParse/MastoParseHtmlTree.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastoParseTree.swift 3 | // Created by Shannon Hughes on 7/1/25. 4 | // 5 | 6 | import Foundation 7 | import SwiftSoup 8 | 9 | // MARK: MastoParseTree 10 | 11 | /// Convert Mastodon post contents html into a tree of MastoParseNodes 12 | func buildContentTree(from html: String) throws -> [MastoParseNode] { 13 | let document = try SwiftSoup.parseBodyFragment(html) 14 | guard let body = document.body() else { return [] } 15 | 16 | return try body.getChildNodes().compactMap { try transform(node: $0) } 17 | } 18 | 19 | /// One node in the tree 20 | indirect enum MastoParseNode: CustomDebugStringConvertible { 21 | case text(String) // leaf 22 | case element(ElementNode) // internal 23 | 24 | var debugDescription: String { 25 | switch self { 26 | case .text(let t): return "TEXT: " + #""\#(t)""# 27 | case .element(let e): return "<\(e.name) \(e.attributes)> (\(e.children.count) children)" 28 | } 29 | } 30 | } 31 | 32 | struct ElementNode { 33 | let name: String 34 | let attributes: [String:String] 35 | var children: [MastoParseNode] 36 | } 37 | 38 | 39 | private func transform(node: SwiftSoup.Node) throws -> MastoParseNode? { 40 | switch node { 41 | case let textNode as SwiftSoup.TextNode: 42 | let text = textNode.getWholeText() 43 | return text.isEmpty ? nil : .text(text) 44 | 45 | case let element as SwiftSoup.Element: 46 | guard Allowed.elements.contains(element.tagName()) else { 47 | // Skip unsupported elements but keep their children 48 | return try wrapChildren(of: element) 49 | } 50 | 51 | let attrs = filteredAttributes(from: element) 52 | 53 | let children = try element.getChildNodes().compactMap { try transform(node: $0) } 54 | return .element(ElementNode(name: element.tagName(), 55 | attributes: attrs, 56 | children: children)) 57 | 58 | default: 59 | return nil 60 | } 61 | } 62 | 63 | private func wrapChildren(of element: SwiftSoup.Element) throws -> MastoParseNode? { 64 | // Flatten an unsupported tag by lifting children one level up 65 | let converted = try element.getChildNodes().compactMap { try transform(node: $0) } 66 | return converted.count == 1 ? converted.first : .element( 67 | ElementNode(name: "span", attributes: [:], children: converted) 68 | ) 69 | } 70 | 71 | private func filteredAttributes(from element: SwiftSoup.Element) -> [String:String] { 72 | guard let attributes = element.getAttributes(), let allowed = Allowed.attributes[element.tagName()] else { return [:] } 73 | 74 | return attributes.reduce(into: [String:String]()) { dict, attr in 75 | if allowed.contains(attr.getKey()) { 76 | dict[attr.getKey()] = attr.getValue() 77 | } 78 | } 79 | } 80 | 81 | private enum Allowed { 82 | 83 | static let elements: Set = [ 84 | "p","br","span","a","del","s","pre","blockquote","code","b","strong","u","i","em", 85 | "ul","ol","li","ruby","rt","rp" 86 | ] 87 | 88 | /// attributes per element 89 | static let attributes: [String: Set] = [ 90 | "a" : ["href","rel","class","translate"], 91 | "span": ["class","translate"], 92 | "ol" : ["start","reversed"], 93 | "li" : ["value"], 94 | "p" : ["class"], 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // MastoParseDemoApp 4 | // 5 | // Created by Shannon Hughes on 7/2/25. 6 | // 7 | 8 | import SwiftUI 9 | import MastoParse 10 | 11 | struct DisplayExample: Identifiable { 12 | let id: Int 13 | let displayBlocks: [MastoParseContentBlock] 14 | } 15 | 16 | @MainActor 17 | let displayExamples: [ DisplayExample ] = { 18 | examples.enumerated().map { (idx, html) in 19 | do { 20 | let blocks = try getParseBlocks(from: html) 21 | return DisplayExample(id: idx, displayBlocks: blocks) 22 | } catch { 23 | let errorItem = MastoParseInlineElement(type: .text, contents: "ERROR: \(error)") 24 | let block = MastoParseContentRow(contents: [errorItem], style: .paragraph, nestedFormatting: []) 25 | return DisplayExample(id: idx, displayBlocks: [block]) 26 | } 27 | } 28 | }() 29 | 30 | struct DemoView: View { 31 | 32 | @State var emoji: Image? 33 | static let font: Font.TextStyle = .body // customize this to the font style you wish to use 34 | @ScaledMetric(relativeTo: font) private var emojiSize: CGFloat = 25 35 | 36 | var body: some View { 37 | ScrollView { 38 | VStack(alignment: .leading) { 39 | ForEach(displayExamples) { html in 40 | VStack(alignment: .leading) { 41 | ForEach(html.displayBlocks) { block in 42 | if let blockquote = block as? MastoParseBlockquote { 43 | BlockquoteView(block: blockquote) 44 | } else if let row = block as? MastoParseContentRow { 45 | RowView(row: row) 46 | } else { 47 | Text("CASE NOT HANDLED") 48 | } 49 | } 50 | } 51 | .onTapGesture { 52 | print("tapped!!!") 53 | } 54 | Rectangle() 55 | .fill(.gray) 56 | .frame(width: 300, height: 1) 57 | } 58 | } 59 | } 60 | .padding() 61 | } 62 | } 63 | 64 | let indent: CGFloat = 16 65 | let nestedBlockQuoteIndicatorWidth: CGFloat = 2 66 | let indicatorToBlockQuoteSpacing: CGFloat = 4 67 | 68 | let blockquoteColor = Color.purple.opacity(0.5) 69 | struct BlockquoteView: View { 70 | let block: MastoParseBlockquote 71 | 72 | var body: some View { 73 | HStack { 74 | VStack { 75 | Image(systemName: "quote.opening") 76 | .font(.title) 77 | .fontWeight(.bold) 78 | .foregroundStyle(blockquoteColor) 79 | 80 | Spacer() 81 | } 82 | VStack(alignment: .leading, spacing: 0) { 83 | ForEach(Array(block.contents.enumerated()), id: \.offset) { idx, element in 84 | RowView(row: element) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | enum TextElement { 92 | case image(Image) 93 | case text(LocalizedStringKey) 94 | case code(String) 95 | } 96 | 97 | struct RowView: View { 98 | static let font: Font.TextStyle = .body 99 | @ScaledMetric(relativeTo: font) private var imgBaseline: CGFloat = -5 // without this, the custom emoji sit too high amidst the surrounding text 100 | 101 | let row: MastoParseContentRow 102 | 103 | var body: some View { 104 | let totalFormattingSpaceRequired = row.nestedFormatting.reduce(into: CGFloat.zero) { partialResult, format in 105 | switch format { 106 | case .listLevel: 107 | partialResult += indent 108 | case .subordinateBlockquote: 109 | partialResult += nestedBlockQuoteIndicatorWidth + indicatorToBlockQuoteSpacing 110 | case .topLevelBlockquote: 111 | break 112 | } 113 | } 114 | 115 | combineElements(row.contents.map({ element in 116 | switch element.type { 117 | case .text: 118 | return .text(LocalizedStringKey(element.contents)) 119 | case .code: 120 | return .code(element.contents) 121 | } 122 | 123 | })) 124 | .padding(EdgeInsets(top: 0, leading: totalFormattingSpaceRequired, bottom: 0, trailing: 0)) 125 | .background() { 126 | // Putting the nested blockquote bar in a background correctly expands its height to match the contents of the row. Trying to include it in the same HStack as the content leaves the bar too short. 127 | HStack(spacing: 0) { 128 | ForEach(Array(row.nestedFormatting.enumerated()), id: \.offset) { idx, indicator in 129 | switch indicator { 130 | case .topLevelBlockquote: 131 | EmptyView() 132 | case .subordinateBlockquote: 133 | blockquoteColor 134 | .frame(width: nestedBlockQuoteIndicatorWidth) 135 | Spacer() 136 | .frame(maxWidth: indicatorToBlockQuoteSpacing) 137 | case .listLevel: 138 | Spacer() 139 | .frame(width: indent) 140 | } 141 | } 142 | Spacer() 143 | .frame(maxWidth: .infinity) 144 | } 145 | } 146 | } 147 | 148 | @ViewBuilder func combineElements(_ elements: [TextElement]) -> some View { 149 | let pieces = elements.map { element in 150 | switch element { 151 | case .image(let image): 152 | return Text("\(image)").baselineOffset(imgBaseline) 153 | case .text(let text): 154 | return Text(text) 155 | case .code(let text): 156 | var attributed = AttributedString(text) 157 | attributed.backgroundColor = blockquoteColor 158 | attributed.font = .system(.body, design: .monospaced) 159 | return Text(attributed) 160 | } 161 | } 162 | pieces.reduce(Text(""), +) 163 | .fixedSize(horizontal: false, vertical: true) 164 | } 165 | } 166 | 167 | #Preview { 168 | DemoView() 169 | } 170 | 171 | -------------------------------------------------------------------------------- /Sources/MastoParse/MastoParseAccumulator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastoParseAccumulator.swift 3 | // MastoParse 4 | // 5 | // Created by Shannon Hughes on 7/1/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public class MarkdownAccumulator { 11 | var charactersToTrim: CharacterSet? { 12 | return nil 13 | } 14 | 15 | func appendInlineElement(_ element: MastoParseInlineElement) { 16 | fatalError("subclasses must implement") 17 | } 18 | 19 | var canAppendBlocks: Bool { 20 | return false 21 | } 22 | 23 | func appendBlock(_ block: MarkdownAccumulator) -> Bool { 24 | fatalError("subclasses must implement") 25 | } 26 | 27 | func contentRows(inheritingNestedFormatting inherited: [MastoParseNestedFormat], withPrefix prefix: String?) -> [MastoParseContentRow] { 28 | fatalError("subclasses must implement") 29 | } 30 | 31 | func contentBlock() -> MastoParseContentBlock? { 32 | fatalError("subclasses must implement") 33 | } 34 | } 35 | 36 | private class MarkdownFlowAccumulator: MarkdownAccumulator { 37 | enum FlowAccumulatorType { 38 | case paragraph 39 | case code 40 | } 41 | 42 | let type: FlowAccumulatorType 43 | var inlineElements = [MastoParseInlineElement]() 44 | 45 | init(_ type: FlowAccumulatorType) { 46 | self.type = type 47 | } 48 | 49 | override var charactersToTrim: CharacterSet? { 50 | switch type { 51 | case .code: 52 | return nil 53 | case .paragraph: 54 | return .newlines 55 | } 56 | } 57 | 58 | override func appendInlineElement(_ element: MastoParseInlineElement) { 59 | self.inlineElements.append(element) 60 | } 61 | 62 | override func appendBlock(_ block: MarkdownAccumulator) -> Bool { 63 | return false 64 | } 65 | 66 | override func contentRows(inheritingNestedFormatting inherited: [MastoParseNestedFormat], withPrefix prefix: String?) -> [MastoParseContentRow] { 67 | switch type { 68 | case .paragraph: 69 | if !inlineElements.isEmpty { 70 | return [MastoParseContentRow(contents: inlineElements, style: .paragraph, listItemPrefix: prefix, nestedFormatting: inherited)] 71 | } 72 | case .code: 73 | if !inlineElements.isEmpty { 74 | return [MastoParseContentRow(contents: inlineElements, style: .code, listItemPrefix: prefix, nestedFormatting: inherited)] 75 | } 76 | } 77 | return [] 78 | } 79 | 80 | override func contentBlock() -> MastoParseContentBlock? { 81 | return nil 82 | } 83 | } 84 | 85 | class MarkdownBlockAccumulator: MarkdownAccumulator { 86 | enum BlockType: Equatable { 87 | case blockquote 88 | case list(prefix: String?) 89 | 90 | var prefix: String? { 91 | switch self { 92 | case .blockquote: 93 | return nil 94 | case .list(let prefix): 95 | return prefix 96 | } 97 | } 98 | } 99 | 100 | let type: BlockType 101 | var contents = [MarkdownAccumulator]() 102 | 103 | init(_ type: BlockType) { 104 | self.type = type 105 | } 106 | 107 | override var charactersToTrim: CharacterSet? { 108 | switch type { 109 | case .blockquote: 110 | return .newlines 111 | case .list: 112 | return .whitespacesAndNewlines 113 | } 114 | } 115 | 116 | override func appendInlineElement(_ element: MastoParseInlineElement) { 117 | if let currentParagraph = contents.last as? MarkdownFlowAccumulator { 118 | currentParagraph.appendInlineElement(element) 119 | } else { 120 | let newParagraph = MarkdownFlowAccumulator(.paragraph) 121 | newParagraph.appendInlineElement(element) 122 | contents.append(newParagraph) 123 | } 124 | } 125 | 126 | override var canAppendBlocks: Bool { 127 | return true 128 | } 129 | 130 | override func appendBlock(_ block: MarkdownAccumulator) -> Bool { 131 | contents.append(block) 132 | return true 133 | } 134 | 135 | override func contentRows(inheritingNestedFormatting inherited: [MastoParseNestedFormat], withPrefix prefix: String?) -> [MastoParseContentRow] { 136 | let immutableContents = contents.flatMap { accumulator in 137 | let childFormatting: [MastoParseNestedFormat] = { 138 | switch type { 139 | case .blockquote: 140 | if inherited.isEmpty { 141 | return [.topLevelBlockquote] 142 | } else { 143 | return inherited + [.subordinateBlockquote] 144 | } 145 | case .list: 146 | if let childBlockquote = accumulator as? MarkdownBlockAccumulator, childBlockquote.type == .blockquote { 147 | return inherited + [.listLevel, .listLevel] 148 | } else { 149 | return inherited + [.listLevel] 150 | } 151 | } 152 | }() 153 | return accumulator.contentRows(inheritingNestedFormatting: childFormatting, withPrefix: type.prefix) 154 | } 155 | return immutableContents 156 | } 157 | 158 | override func contentBlock() -> MastoParseContentBlock? { 159 | let contents = contentRows(inheritingNestedFormatting: [], withPrefix: nil) 160 | guard !contents.isEmpty else { 161 | return nil 162 | } 163 | switch type { 164 | case .blockquote: 165 | return MastoParseBlockquote(contents: contents) 166 | case .list: 167 | return nil 168 | } 169 | } 170 | } 171 | 172 | private func listItems(_ nodes: [MastoParseNode], ordered: Bool, startingIndex: Int?) -> [MarkdownAccumulator] { 173 | let listItemContents: [[MastoParseNode]] = nodes.compactMap { node -> [MastoParseNode]? in 174 | guard case .element(let li) = node, li.name == "li" else { return nil } 175 | return li.children 176 | } 177 | 178 | var index = startingIndex ?? 1 179 | 180 | let accumulatedItems: [MarkdownAccumulator] = listItemContents.map { contents in 181 | defer { index += 1 } 182 | let listPrefix = ordered ? "\(index). " : "• " 183 | let listItem = MarkdownBlockAccumulator(.list(prefix: listPrefix)) 184 | 185 | _ = toMastoParseAccumulators(contents, addingTo: listItem) 186 | 187 | return listItem 188 | } 189 | 190 | return accumulatedItems 191 | } 192 | 193 | 194 | func toMastoParseAccumulators(_ nodes: [MastoParseNode], addingTo containingAccumulator: MarkdownAccumulator?) -> [MarkdownAccumulator] { 195 | var accumulatedBlocks = [MarkdownAccumulator]() 196 | 197 | var currentAccumulator: MarkdownAccumulator = containingAccumulator ?? MarkdownFlowAccumulator(.paragraph) 198 | 199 | for node in nodes { 200 | func append(_ inlineElement: MastoParseInlineElement.ElementType, contents: String) { 201 | let trimmed = { 202 | if let charactersToTrim = currentAccumulator.charactersToTrim { 203 | contents.trimmingCharacters(in: charactersToTrim) 204 | } else { 205 | contents 206 | } 207 | }() 208 | currentAccumulator.appendInlineElement(MastoParseInlineElement(type: inlineElement, contents: trimmed)) 209 | } 210 | 211 | switch node { 212 | case .text(let t): 213 | append(.text, contents: t) 214 | case .element(let element): 215 | var skipChildren = false 216 | 217 | if isInlineElement(element.name) { 218 | switch element.name { 219 | case "br": 220 | currentAccumulator.appendInlineElement(MastoParseInlineElement(type: .text, contents: "\n")) 221 | default: 222 | let markdownString = toString([node]) 223 | append(element.name == "code" ? .code : .text, contents: markdownString) 224 | } 225 | skipChildren = true 226 | } else { 227 | let newAccumulator: MarkdownAccumulator? = { () -> MarkdownAccumulator? in 228 | switch element.name { 229 | case "p": 230 | return MarkdownFlowAccumulator(.paragraph) 231 | case "pre": 232 | return MarkdownFlowAccumulator(.code) 233 | case "blockquote": 234 | let blockquote = MarkdownBlockAccumulator(.blockquote) 235 | let contents = toMastoParseAccumulators(element.children, addingTo: nil) 236 | blockquote.contents = contents 237 | if !currentAccumulator.appendBlock(blockquote) { 238 | accumulatedBlocks.append(currentAccumulator) 239 | accumulatedBlocks.append(blockquote) 240 | } 241 | skipChildren = true 242 | currentAccumulator = MarkdownFlowAccumulator(.paragraph) // we've taken care of the whole blockquote already, now we wipe the slate clean for a fresh start 243 | return nil 244 | case "ul", "ol": // unordered or ordered list 245 | var startIndex: Int 246 | if let start = element.attributes["start"], let intStart = Int(start) { 247 | startIndex = intStart 248 | } else { 249 | startIndex = 1 250 | } 251 | 252 | let itemAccumulators = listItems(element.children, ordered: element.name == "ol", startingIndex: element.name == "ol" ? startIndex : nil) 253 | 254 | let appendToCurrent = currentAccumulator.canAppendBlocks 255 | if !appendToCurrent { 256 | accumulatedBlocks.append(currentAccumulator) 257 | } 258 | for itemAccumulator in itemAccumulators { 259 | if appendToCurrent { 260 | _ = currentAccumulator.appendBlock(itemAccumulator) 261 | } else { 262 | accumulatedBlocks.append(itemAccumulator) 263 | } 264 | } 265 | skipChildren = true 266 | currentAccumulator = MarkdownFlowAccumulator(.paragraph) // we've taken care of the whole list already, now we wipe the slate clean for a fresh start 267 | return nil 268 | case "li": // list item 269 | assertionFailure("list items should be handled by the listItems() function") 270 | return nil 271 | default: 272 | // treat as text 273 | let markdownString = toString([node]) 274 | append(element.name == "code" ? .code : .text, contents: markdownString) 275 | skipChildren = true 276 | return nil 277 | } 278 | }() 279 | if let newAccumulator, let currentAccumulator = currentAccumulator as? MarkdownBlockAccumulator, currentAccumulator.appendBlock(newAccumulator) { 280 | if !skipChildren { 281 | _ = toMastoParseAccumulators(element.children, addingTo: newAccumulator) 282 | } 283 | continue 284 | } else if let newAccumulator { 285 | accumulatedBlocks.append(currentAccumulator) 286 | if !skipChildren { 287 | _ = toMastoParseAccumulators(element.children, addingTo: newAccumulator) 288 | } 289 | currentAccumulator = newAccumulator 290 | } else { 291 | continue 292 | } 293 | } 294 | } 295 | } 296 | accumulatedBlocks.append(currentAccumulator) 297 | 298 | return accumulatedBlocks 299 | } 300 | 301 | private func isInlineElement(_ elementName: String) -> Bool { 302 | switch elementName { 303 | case "strong", "b", "em", "i", "u", "del", "s", "code", "a", "br": return true 304 | default: 305 | return false 306 | } 307 | } 308 | 309 | private func escapeMarkdown(_ text: String) -> String { 310 | // Escape Markdown characters unless inside code blocks 311 | let specialChars = ["\\", "`", "*", "_", "{", "}", "[", "]", "(", ")", "#", "+", "-", ".", "!"] 312 | var escaped = text 313 | for char in specialChars { 314 | escaped = escaped.replacingOccurrences(of: char, with: "\\" + char) 315 | } 316 | return escaped 317 | } 318 | 319 | func toString(_ nodes: [MastoParseNode]) -> String { 320 | nodes.map { node in 321 | switch node { 322 | case .text(let t): 323 | #if DEBUG && false 324 | print("toString of text is: \(t)") 325 | #endif 326 | return escapeMarkdown(t) 327 | 328 | case .element(let element): 329 | if !isInlineElement(element.name) { 330 | return toString(element.children) 331 | } 332 | 333 | let childrenMarkdown = toString(element.children) 334 | 335 | switch element.name { 336 | case "strong", "b": return "**\(childrenMarkdown)**" 337 | case "em", "i": return "_\(childrenMarkdown)_" 338 | case "u": return childrenMarkdown // Markdown doesn't support underline 339 | case "del", "s": return "~~\(childrenMarkdown)~~" 340 | case "code": 341 | if element.attributes["class"]?.contains("language-") == true { 342 | return "\n\(childrenMarkdown)\n" 343 | } else { 344 | return "\(childrenMarkdown)" 345 | } 346 | case "pre", "blockquote": assertionFailure(); return "" 347 | case "a": 348 | let href = element.attributes["href"] ?? "#" 349 | return "[\(trimUrlStringForDisplay(childrenMarkdown))](\(href))" 350 | case "br": return " \n" 351 | default: 352 | return childrenMarkdown 353 | } 354 | } 355 | }.joined() 356 | } 357 | 358 | func trimUrlStringForDisplay(_ urlString: String) -> String { 359 | let maxLength: Int = 30 360 | var trimmed = urlString 361 | let https = "https://" 362 | let http = "http://" 363 | let escapedWww = "www\\." 364 | let www = "www." 365 | 366 | trimmed = trimmed.replacingOccurrences(of: https, with: "", options: .anchored) 367 | trimmed = trimmed.replacingOccurrences(of: http, with: "", options: .anchored) 368 | trimmed = trimmed.replacingOccurrences(of: escapedWww, with: "", options: .anchored) 369 | trimmed = trimmed.replacingOccurrences(of: www, with: "", options: .anchored) 370 | if trimmed.count > maxLength { 371 | return String(trimmed.prefix(maxLength)) + "…" 372 | } else { 373 | return trimmed 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /Demo/MastoParseDemoApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | FBA2EA8D2E1C0833005ADEF0 /* MastoParse in Frameworks */ = {isa = PBXBuildFile; productRef = FBA2EA8C2E1C0833005ADEF0 /* MastoParse */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXContainerItemProxy section */ 14 | FBB76EAA2E1562BE00480EBA /* PBXContainerItemProxy */ = { 15 | isa = PBXContainerItemProxy; 16 | containerPortal = FBB76E912E1562BC00480EBA /* Project object */; 17 | proxyType = 1; 18 | remoteGlobalIDString = FBB76E982E1562BC00480EBA; 19 | remoteInfo = MastoParseDemoApp; 20 | }; 21 | FBB76EB42E1562BE00480EBA /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = FBB76E912E1562BC00480EBA /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = FBB76E982E1562BC00480EBA; 26 | remoteInfo = MastoParseDemoApp; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | FBB76E992E1562BC00480EBA /* MastoParseDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MastoParseDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | FBB76EA92E1562BE00480EBA /* MastoParseDemoAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastoParseDemoAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | FBB76EB32E1562BE00480EBA /* MastoParseDemoAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastoParseDemoAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 34 | /* End PBXFileReference section */ 35 | 36 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 37 | FBB76E9B2E1562BC00480EBA /* MastoParseDemoApp */ = { 38 | isa = PBXFileSystemSynchronizedRootGroup; 39 | path = MastoParseDemoApp; 40 | sourceTree = ""; 41 | }; 42 | /* End PBXFileSystemSynchronizedRootGroup section */ 43 | 44 | /* Begin PBXFrameworksBuildPhase section */ 45 | FBB76E962E1562BC00480EBA /* Frameworks */ = { 46 | isa = PBXFrameworksBuildPhase; 47 | buildActionMask = 2147483647; 48 | files = ( 49 | FBA2EA8D2E1C0833005ADEF0 /* MastoParse in Frameworks */, 50 | ); 51 | runOnlyForDeploymentPostprocessing = 0; 52 | }; 53 | FBB76EA62E1562BE00480EBA /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | ); 58 | runOnlyForDeploymentPostprocessing = 0; 59 | }; 60 | FBB76EB02E1562BE00480EBA /* Frameworks */ = { 61 | isa = PBXFrameworksBuildPhase; 62 | buildActionMask = 2147483647; 63 | files = ( 64 | ); 65 | runOnlyForDeploymentPostprocessing = 0; 66 | }; 67 | /* End PBXFrameworksBuildPhase section */ 68 | 69 | /* Begin PBXGroup section */ 70 | FBB76E902E1562BC00480EBA = { 71 | isa = PBXGroup; 72 | children = ( 73 | FBB76E9B2E1562BC00480EBA /* MastoParseDemoApp */, 74 | FBB76E9A2E1562BC00480EBA /* Products */, 75 | ); 76 | sourceTree = ""; 77 | }; 78 | FBB76E9A2E1562BC00480EBA /* Products */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | FBB76E992E1562BC00480EBA /* MastoParseDemoApp.app */, 82 | FBB76EA92E1562BE00480EBA /* MastoParseDemoAppTests.xctest */, 83 | FBB76EB32E1562BE00480EBA /* MastoParseDemoAppUITests.xctest */, 84 | ); 85 | name = Products; 86 | sourceTree = ""; 87 | }; 88 | /* End PBXGroup section */ 89 | 90 | /* Begin PBXNativeTarget section */ 91 | FBB76E982E1562BC00480EBA /* MastoParseDemoApp */ = { 92 | isa = PBXNativeTarget; 93 | buildConfigurationList = FBB76EBD2E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoApp" */; 94 | buildPhases = ( 95 | FBB76E952E1562BC00480EBA /* Sources */, 96 | FBB76E962E1562BC00480EBA /* Frameworks */, 97 | FBB76E972E1562BC00480EBA /* Resources */, 98 | ); 99 | buildRules = ( 100 | ); 101 | dependencies = ( 102 | ); 103 | fileSystemSynchronizedGroups = ( 104 | FBB76E9B2E1562BC00480EBA /* MastoParseDemoApp */, 105 | ); 106 | name = MastoParseDemoApp; 107 | packageProductDependencies = ( 108 | FBA2EA8C2E1C0833005ADEF0 /* MastoParse */, 109 | ); 110 | productName = MastoParseDemoApp; 111 | productReference = FBB76E992E1562BC00480EBA /* MastoParseDemoApp.app */; 112 | productType = "com.apple.product-type.application"; 113 | }; 114 | FBB76EA82E1562BE00480EBA /* MastoParseDemoAppTests */ = { 115 | isa = PBXNativeTarget; 116 | buildConfigurationList = FBB76EC02E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoAppTests" */; 117 | buildPhases = ( 118 | FBB76EA52E1562BE00480EBA /* Sources */, 119 | FBB76EA62E1562BE00480EBA /* Frameworks */, 120 | FBB76EA72E1562BE00480EBA /* Resources */, 121 | ); 122 | buildRules = ( 123 | ); 124 | dependencies = ( 125 | FBB76EAB2E1562BE00480EBA /* PBXTargetDependency */, 126 | ); 127 | name = MastoParseDemoAppTests; 128 | packageProductDependencies = ( 129 | ); 130 | productName = MastoParseDemoAppTests; 131 | productReference = FBB76EA92E1562BE00480EBA /* MastoParseDemoAppTests.xctest */; 132 | productType = "com.apple.product-type.bundle.unit-test"; 133 | }; 134 | FBB76EB22E1562BE00480EBA /* MastoParseDemoAppUITests */ = { 135 | isa = PBXNativeTarget; 136 | buildConfigurationList = FBB76EC32E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoAppUITests" */; 137 | buildPhases = ( 138 | FBB76EAF2E1562BE00480EBA /* Sources */, 139 | FBB76EB02E1562BE00480EBA /* Frameworks */, 140 | FBB76EB12E1562BE00480EBA /* Resources */, 141 | ); 142 | buildRules = ( 143 | ); 144 | dependencies = ( 145 | FBB76EB52E1562BE00480EBA /* PBXTargetDependency */, 146 | ); 147 | name = MastoParseDemoAppUITests; 148 | packageProductDependencies = ( 149 | ); 150 | productName = MastoParseDemoAppUITests; 151 | productReference = FBB76EB32E1562BE00480EBA /* MastoParseDemoAppUITests.xctest */; 152 | productType = "com.apple.product-type.bundle.ui-testing"; 153 | }; 154 | /* End PBXNativeTarget section */ 155 | 156 | /* Begin PBXProject section */ 157 | FBB76E912E1562BC00480EBA /* Project object */ = { 158 | isa = PBXProject; 159 | attributes = { 160 | BuildIndependentTargetsInParallel = 1; 161 | LastSwiftUpdateCheck = 1620; 162 | LastUpgradeCheck = 1620; 163 | TargetAttributes = { 164 | FBB76E982E1562BC00480EBA = { 165 | CreatedOnToolsVersion = 16.2; 166 | }; 167 | FBB76EA82E1562BE00480EBA = { 168 | CreatedOnToolsVersion = 16.2; 169 | TestTargetID = FBB76E982E1562BC00480EBA; 170 | }; 171 | FBB76EB22E1562BE00480EBA = { 172 | CreatedOnToolsVersion = 16.2; 173 | TestTargetID = FBB76E982E1562BC00480EBA; 174 | }; 175 | }; 176 | }; 177 | buildConfigurationList = FBB76E942E1562BC00480EBA /* Build configuration list for PBXProject "MastoParseDemoApp" */; 178 | developmentRegion = en; 179 | hasScannedForEncodings = 0; 180 | knownRegions = ( 181 | en, 182 | Base, 183 | ); 184 | mainGroup = FBB76E902E1562BC00480EBA; 185 | minimizedProjectReferenceProxies = 1; 186 | packageReferences = ( 187 | FBA2EA8B2E1C0833005ADEF0 /* XCLocalSwiftPackageReference "../../MastoParse" */, 188 | ); 189 | preferredProjectObjectVersion = 77; 190 | productRefGroup = FBB76E9A2E1562BC00480EBA /* Products */; 191 | projectDirPath = ""; 192 | projectRoot = ""; 193 | targets = ( 194 | FBB76E982E1562BC00480EBA /* MastoParseDemoApp */, 195 | FBB76EA82E1562BE00480EBA /* MastoParseDemoAppTests */, 196 | FBB76EB22E1562BE00480EBA /* MastoParseDemoAppUITests */, 197 | ); 198 | }; 199 | /* End PBXProject section */ 200 | 201 | /* Begin PBXResourcesBuildPhase section */ 202 | FBB76E972E1562BC00480EBA /* Resources */ = { 203 | isa = PBXResourcesBuildPhase; 204 | buildActionMask = 2147483647; 205 | files = ( 206 | ); 207 | runOnlyForDeploymentPostprocessing = 0; 208 | }; 209 | FBB76EA72E1562BE00480EBA /* Resources */ = { 210 | isa = PBXResourcesBuildPhase; 211 | buildActionMask = 2147483647; 212 | files = ( 213 | ); 214 | runOnlyForDeploymentPostprocessing = 0; 215 | }; 216 | FBB76EB12E1562BE00480EBA /* Resources */ = { 217 | isa = PBXResourcesBuildPhase; 218 | buildActionMask = 2147483647; 219 | files = ( 220 | ); 221 | runOnlyForDeploymentPostprocessing = 0; 222 | }; 223 | /* End PBXResourcesBuildPhase section */ 224 | 225 | /* Begin PBXSourcesBuildPhase section */ 226 | FBB76E952E1562BC00480EBA /* Sources */ = { 227 | isa = PBXSourcesBuildPhase; 228 | buildActionMask = 2147483647; 229 | files = ( 230 | ); 231 | runOnlyForDeploymentPostprocessing = 0; 232 | }; 233 | FBB76EA52E1562BE00480EBA /* Sources */ = { 234 | isa = PBXSourcesBuildPhase; 235 | buildActionMask = 2147483647; 236 | files = ( 237 | ); 238 | runOnlyForDeploymentPostprocessing = 0; 239 | }; 240 | FBB76EAF2E1562BE00480EBA /* Sources */ = { 241 | isa = PBXSourcesBuildPhase; 242 | buildActionMask = 2147483647; 243 | files = ( 244 | ); 245 | runOnlyForDeploymentPostprocessing = 0; 246 | }; 247 | /* End PBXSourcesBuildPhase section */ 248 | 249 | /* Begin PBXTargetDependency section */ 250 | FBB76EAB2E1562BE00480EBA /* PBXTargetDependency */ = { 251 | isa = PBXTargetDependency; 252 | target = FBB76E982E1562BC00480EBA /* MastoParseDemoApp */; 253 | targetProxy = FBB76EAA2E1562BE00480EBA /* PBXContainerItemProxy */; 254 | }; 255 | FBB76EB52E1562BE00480EBA /* PBXTargetDependency */ = { 256 | isa = PBXTargetDependency; 257 | target = FBB76E982E1562BC00480EBA /* MastoParseDemoApp */; 258 | targetProxy = FBB76EB42E1562BE00480EBA /* PBXContainerItemProxy */; 259 | }; 260 | /* End PBXTargetDependency section */ 261 | 262 | /* Begin XCBuildConfiguration section */ 263 | FBB76EBB2E1562BE00480EBA /* Debug */ = { 264 | isa = XCBuildConfiguration; 265 | buildSettings = { 266 | ALWAYS_SEARCH_USER_PATHS = NO; 267 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 268 | CLANG_ANALYZER_NONNULL = YES; 269 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 270 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 271 | CLANG_ENABLE_MODULES = YES; 272 | CLANG_ENABLE_OBJC_ARC = YES; 273 | CLANG_ENABLE_OBJC_WEAK = YES; 274 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 275 | CLANG_WARN_BOOL_CONVERSION = YES; 276 | CLANG_WARN_COMMA = YES; 277 | CLANG_WARN_CONSTANT_CONVERSION = YES; 278 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 279 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 280 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 281 | CLANG_WARN_EMPTY_BODY = YES; 282 | CLANG_WARN_ENUM_CONVERSION = YES; 283 | CLANG_WARN_INFINITE_RECURSION = YES; 284 | CLANG_WARN_INT_CONVERSION = YES; 285 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 286 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 287 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 288 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 289 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 290 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 291 | CLANG_WARN_STRICT_PROTOTYPES = YES; 292 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 293 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 294 | CLANG_WARN_UNREACHABLE_CODE = YES; 295 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 296 | COPY_PHASE_STRIP = NO; 297 | DEBUG_INFORMATION_FORMAT = dwarf; 298 | ENABLE_STRICT_OBJC_MSGSEND = YES; 299 | ENABLE_TESTABILITY = YES; 300 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 301 | GCC_C_LANGUAGE_STANDARD = gnu17; 302 | GCC_DYNAMIC_NO_PIC = NO; 303 | GCC_NO_COMMON_BLOCKS = YES; 304 | GCC_OPTIMIZATION_LEVEL = 0; 305 | GCC_PREPROCESSOR_DEFINITIONS = ( 306 | "DEBUG=1", 307 | "$(inherited)", 308 | ); 309 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 310 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 311 | GCC_WARN_UNDECLARED_SELECTOR = YES; 312 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 313 | GCC_WARN_UNUSED_FUNCTION = YES; 314 | GCC_WARN_UNUSED_VARIABLE = YES; 315 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 316 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 317 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 318 | MTL_FAST_MATH = YES; 319 | ONLY_ACTIVE_ARCH = YES; 320 | SDKROOT = iphoneos; 321 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 322 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 323 | }; 324 | name = Debug; 325 | }; 326 | FBB76EBC2E1562BE00480EBA /* Release */ = { 327 | isa = XCBuildConfiguration; 328 | buildSettings = { 329 | ALWAYS_SEARCH_USER_PATHS = NO; 330 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 331 | CLANG_ANALYZER_NONNULL = YES; 332 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 333 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 334 | CLANG_ENABLE_MODULES = YES; 335 | CLANG_ENABLE_OBJC_ARC = YES; 336 | CLANG_ENABLE_OBJC_WEAK = YES; 337 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 338 | CLANG_WARN_BOOL_CONVERSION = YES; 339 | CLANG_WARN_COMMA = YES; 340 | CLANG_WARN_CONSTANT_CONVERSION = YES; 341 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 342 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 343 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 344 | CLANG_WARN_EMPTY_BODY = YES; 345 | CLANG_WARN_ENUM_CONVERSION = YES; 346 | CLANG_WARN_INFINITE_RECURSION = YES; 347 | CLANG_WARN_INT_CONVERSION = YES; 348 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 349 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 350 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 351 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 352 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 353 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 354 | CLANG_WARN_STRICT_PROTOTYPES = YES; 355 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 356 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 357 | CLANG_WARN_UNREACHABLE_CODE = YES; 358 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 359 | COPY_PHASE_STRIP = NO; 360 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 361 | ENABLE_NS_ASSERTIONS = NO; 362 | ENABLE_STRICT_OBJC_MSGSEND = YES; 363 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 364 | GCC_C_LANGUAGE_STANDARD = gnu17; 365 | GCC_NO_COMMON_BLOCKS = YES; 366 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 367 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 368 | GCC_WARN_UNDECLARED_SELECTOR = YES; 369 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 370 | GCC_WARN_UNUSED_FUNCTION = YES; 371 | GCC_WARN_UNUSED_VARIABLE = YES; 372 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 373 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 374 | MTL_ENABLE_DEBUG_INFO = NO; 375 | MTL_FAST_MATH = YES; 376 | SDKROOT = iphoneos; 377 | SWIFT_COMPILATION_MODE = wholemodule; 378 | VALIDATE_PRODUCT = YES; 379 | }; 380 | name = Release; 381 | }; 382 | FBB76EBE2E1562BE00480EBA /* Debug */ = { 383 | isa = XCBuildConfiguration; 384 | buildSettings = { 385 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 386 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 387 | CODE_SIGN_STYLE = Automatic; 388 | CURRENT_PROJECT_VERSION = 1; 389 | DEVELOPMENT_ASSET_PATHS = "\"MastoParseDemoApp/Preview Content\""; 390 | DEVELOPMENT_TEAM = 8795L94465; 391 | ENABLE_PREVIEWS = YES; 392 | GENERATE_INFOPLIST_FILE = YES; 393 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 394 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 395 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 396 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 397 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 398 | LD_RUNPATH_SEARCH_PATHS = ( 399 | "$(inherited)", 400 | "@executable_path/Frameworks", 401 | ); 402 | MARKETING_VERSION = 1.0; 403 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoApp; 404 | PRODUCT_NAME = "$(TARGET_NAME)"; 405 | SWIFT_EMIT_LOC_STRINGS = YES; 406 | SWIFT_VERSION = 5.0; 407 | TARGETED_DEVICE_FAMILY = "1,2"; 408 | }; 409 | name = Debug; 410 | }; 411 | FBB76EBF2E1562BE00480EBA /* Release */ = { 412 | isa = XCBuildConfiguration; 413 | buildSettings = { 414 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 416 | CODE_SIGN_STYLE = Automatic; 417 | CURRENT_PROJECT_VERSION = 1; 418 | DEVELOPMENT_ASSET_PATHS = "\"MastoParseDemoApp/Preview Content\""; 419 | DEVELOPMENT_TEAM = 8795L94465; 420 | ENABLE_PREVIEWS = YES; 421 | GENERATE_INFOPLIST_FILE = YES; 422 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 423 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 424 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 425 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 426 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 427 | LD_RUNPATH_SEARCH_PATHS = ( 428 | "$(inherited)", 429 | "@executable_path/Frameworks", 430 | ); 431 | MARKETING_VERSION = 1.0; 432 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoApp; 433 | PRODUCT_NAME = "$(TARGET_NAME)"; 434 | SWIFT_EMIT_LOC_STRINGS = YES; 435 | SWIFT_VERSION = 5.0; 436 | TARGETED_DEVICE_FAMILY = "1,2"; 437 | }; 438 | name = Release; 439 | }; 440 | FBB76EC12E1562BE00480EBA /* Debug */ = { 441 | isa = XCBuildConfiguration; 442 | buildSettings = { 443 | BUNDLE_LOADER = "$(TEST_HOST)"; 444 | CODE_SIGN_STYLE = Automatic; 445 | CURRENT_PROJECT_VERSION = 1; 446 | DEVELOPMENT_TEAM = 8795L94465; 447 | GENERATE_INFOPLIST_FILE = YES; 448 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 449 | MARKETING_VERSION = 1.0; 450 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoAppTests; 451 | PRODUCT_NAME = "$(TARGET_NAME)"; 452 | SWIFT_EMIT_LOC_STRINGS = NO; 453 | SWIFT_VERSION = 5.0; 454 | TARGETED_DEVICE_FAMILY = "1,2"; 455 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MastoParseDemoApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MastoParseDemoApp"; 456 | }; 457 | name = Debug; 458 | }; 459 | FBB76EC22E1562BE00480EBA /* Release */ = { 460 | isa = XCBuildConfiguration; 461 | buildSettings = { 462 | BUNDLE_LOADER = "$(TEST_HOST)"; 463 | CODE_SIGN_STYLE = Automatic; 464 | CURRENT_PROJECT_VERSION = 1; 465 | DEVELOPMENT_TEAM = 8795L94465; 466 | GENERATE_INFOPLIST_FILE = YES; 467 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 468 | MARKETING_VERSION = 1.0; 469 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoAppTests; 470 | PRODUCT_NAME = "$(TARGET_NAME)"; 471 | SWIFT_EMIT_LOC_STRINGS = NO; 472 | SWIFT_VERSION = 5.0; 473 | TARGETED_DEVICE_FAMILY = "1,2"; 474 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MastoParseDemoApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MastoParseDemoApp"; 475 | }; 476 | name = Release; 477 | }; 478 | FBB76EC42E1562BE00480EBA /* Debug */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | CODE_SIGN_STYLE = Automatic; 482 | CURRENT_PROJECT_VERSION = 1; 483 | DEVELOPMENT_TEAM = 8795L94465; 484 | GENERATE_INFOPLIST_FILE = YES; 485 | MARKETING_VERSION = 1.0; 486 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoAppUITests; 487 | PRODUCT_NAME = "$(TARGET_NAME)"; 488 | SWIFT_EMIT_LOC_STRINGS = NO; 489 | SWIFT_VERSION = 5.0; 490 | TARGETED_DEVICE_FAMILY = "1,2"; 491 | TEST_TARGET_NAME = MastoParseDemoApp; 492 | }; 493 | name = Debug; 494 | }; 495 | FBB76EC52E1562BE00480EBA /* Release */ = { 496 | isa = XCBuildConfiguration; 497 | buildSettings = { 498 | CODE_SIGN_STYLE = Automatic; 499 | CURRENT_PROJECT_VERSION = 1; 500 | DEVELOPMENT_TEAM = 8795L94465; 501 | GENERATE_INFOPLIST_FILE = YES; 502 | MARKETING_VERSION = 1.0; 503 | PRODUCT_BUNDLE_IDENTIFIER = com.whattherestimefor.MastoParseDemoAppUITests; 504 | PRODUCT_NAME = "$(TARGET_NAME)"; 505 | SWIFT_EMIT_LOC_STRINGS = NO; 506 | SWIFT_VERSION = 5.0; 507 | TARGETED_DEVICE_FAMILY = "1,2"; 508 | TEST_TARGET_NAME = MastoParseDemoApp; 509 | }; 510 | name = Release; 511 | }; 512 | /* End XCBuildConfiguration section */ 513 | 514 | /* Begin XCConfigurationList section */ 515 | FBB76E942E1562BC00480EBA /* Build configuration list for PBXProject "MastoParseDemoApp" */ = { 516 | isa = XCConfigurationList; 517 | buildConfigurations = ( 518 | FBB76EBB2E1562BE00480EBA /* Debug */, 519 | FBB76EBC2E1562BE00480EBA /* Release */, 520 | ); 521 | defaultConfigurationIsVisible = 0; 522 | defaultConfigurationName = Release; 523 | }; 524 | FBB76EBD2E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoApp" */ = { 525 | isa = XCConfigurationList; 526 | buildConfigurations = ( 527 | FBB76EBE2E1562BE00480EBA /* Debug */, 528 | FBB76EBF2E1562BE00480EBA /* Release */, 529 | ); 530 | defaultConfigurationIsVisible = 0; 531 | defaultConfigurationName = Release; 532 | }; 533 | FBB76EC02E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoAppTests" */ = { 534 | isa = XCConfigurationList; 535 | buildConfigurations = ( 536 | FBB76EC12E1562BE00480EBA /* Debug */, 537 | FBB76EC22E1562BE00480EBA /* Release */, 538 | ); 539 | defaultConfigurationIsVisible = 0; 540 | defaultConfigurationName = Release; 541 | }; 542 | FBB76EC32E1562BE00480EBA /* Build configuration list for PBXNativeTarget "MastoParseDemoAppUITests" */ = { 543 | isa = XCConfigurationList; 544 | buildConfigurations = ( 545 | FBB76EC42E1562BE00480EBA /* Debug */, 546 | FBB76EC52E1562BE00480EBA /* Release */, 547 | ); 548 | defaultConfigurationIsVisible = 0; 549 | defaultConfigurationName = Release; 550 | }; 551 | /* End XCConfigurationList section */ 552 | 553 | /* Begin XCLocalSwiftPackageReference section */ 554 | FBA2EA8B2E1C0833005ADEF0 /* XCLocalSwiftPackageReference "../../MastoParse" */ = { 555 | isa = XCLocalSwiftPackageReference; 556 | relativePath = ../../MastoParse; 557 | }; 558 | /* End XCLocalSwiftPackageReference section */ 559 | 560 | /* Begin XCSwiftPackageProductDependency section */ 561 | FBA2EA8C2E1C0833005ADEF0 /* MastoParse */ = { 562 | isa = XCSwiftPackageProductDependency; 563 | productName = MastoParse; 564 | }; 565 | /* End XCSwiftPackageProductDependency section */ 566 | }; 567 | rootObject = FBB76E912E1562BC00480EBA /* Project object */; 568 | } 569 | --------------------------------------------------------------------------------