├── .gitignore ├── Example ├── SwiftyMarkdownExample macOS │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── Main.storyboard │ ├── Info.plist │ ├── SwiftyMarkdownExample_macOS.entitlements │ └── ViewController.swift ├── SwiftyMarkdownExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── SwiftyMarkdownExample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── bubble.imageset │ │ │ ├── Contents.json │ │ │ └── bubble.png │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ ├── ViewController.swift │ └── example.md ├── SwiftyMarkdownExampleTests │ ├── Info.plist │ └── SwiftyMarkdownExampleTests.swift └── SwiftyMarkdownExampleUITests │ ├── Info.plist │ └── SwiftyMarkdownExampleUITests.swift ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Package.swift ├── Playground └── SwiftyMarkdown.playground │ ├── Pages │ ├── Attributed String.xcplaygroundpage │ │ ├── Contents.swift │ │ └── Resources │ │ │ └── bubble.png │ ├── Groups.xcplaygroundpage │ │ ├── Contents.swift │ │ └── Sources │ │ │ └── Tokens.swift │ ├── Line Processing.xcplaygroundpage │ │ └── Contents.swift │ ├── SKLabelNode.xcplaygroundpage │ │ └── Contents.swift │ ├── Scanner Tests.xcplaygroundpage │ │ └── Contents.swift │ └── Tokenising.xcplaygroundpage │ │ └── Contents.swift │ ├── Resources │ └── test.md │ ├── Sources │ ├── String+SwiftyMarkdown.swift │ ├── Swifty Line Processor.swift │ ├── Swifty Tokeniser.swift │ └── SwiftyMarkdown.swift │ └── contents.xcplayground ├── README.md ├── Resources ├── metadataTest.md └── test.md ├── Sources └── SwiftyMarkdown │ ├── CharacterRule.swift │ ├── PerfomanceLog.swift │ ├── String+SwiftyMarkdown.swift │ ├── SwiftyLineProcessor.swift │ ├── SwiftyMarkdown+iOS.swift │ ├── SwiftyMarkdown+macOS.swift │ ├── SwiftyMarkdown.swift │ ├── SwiftyScanner.swift │ ├── SwiftyTokeniser.swift │ └── Token.swift ├── SwiftyMarkdown.podspec ├── SwiftyMarkdown.xcodeproj ├── SwiftyMarkdownTests_Info.plist ├── SwiftyMarkdown_Info.plist ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ ├── xcbaselines │ └── SwiftyMarkdown::SwiftyMarkdownTests.xcbaseline │ │ ├── 88991ED5-B954-422F-B610-BDC9A4AEC008.plist │ │ ├── AD1DF83E-20BC-4E7E-8C14-683818ED0A26.plist │ │ └── Info.plist │ └── xcschemes │ └── SwiftyMarkdown-Package.xcscheme ├── Tests ├── LinuxMain.swift └── SwiftyMarkdownTests │ ├── SwiftyMarkdownAttributedStringTests.swift │ ├── SwiftyMarkdownCharacterTests.swift │ ├── SwiftyMarkdownLineTests.swift │ ├── SwiftyMarkdownLinkTests.swift │ ├── SwiftyMarkdownPerformanceTests.swift │ └── XCTest+SwiftyMarkdown.swift └── fastlane ├── Appfile ├── Fastfile └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | 20 | ## Other 21 | *.xccheckout 22 | *.moved-aside 23 | *.xcuserstate 24 | *.xcscmblueprint 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | 30 | ## Playgrounds 31 | timeline.xctimeline 32 | playground.xcworkspace 33 | 34 | # Swift Package Manager 35 | # 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | .build/ 39 | .swiftpm/ 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | # Pods/ 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | # Carthage/Checkouts 53 | 54 | Carthage/Build 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 59 | # screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 62 | 63 | fastlane/report.xml 64 | fastlane/screenshots 65 | 66 | # OS X 67 | .DS_Store 68 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftyMarkdownExample macOS 4 | // 5 | // Created by Simon Fairbairn on 01/02/2020. 6 | // Copyright © 2020 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @NSApplicationMain 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | 14 | 15 | 16 | func applicationDidFinishLaunching(_ aNotification: Notification) { 17 | // Insert code here to initialize your application 18 | } 19 | 20 | func applicationWillTerminate(_ aNotification: Notification) { 21 | // Insert code here to tear down your application 22 | } 23 | 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "size" : "16x16", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "size" : "16x16", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "size" : "32x32", 16 | "scale" : "1x" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "size" : "32x32", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "size" : "128x128", 26 | "scale" : "1x" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "size" : "128x128", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "size" : "256x256", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "size" : "256x256", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "size" : "512x512", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "size" : "512x512", 51 | "scale" : "2x" 52 | } 53 | ], 54 | "info" : { 55 | "version" : 1, 56 | "author" : "xcode" 57 | } 58 | } -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2020 Voyage Travel Apps. All rights reserved. 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | NSSupportsAutomaticTermination 32 | 33 | NSSupportsSuddenTermination 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/SwiftyMarkdownExample_macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample macOS/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // SwiftyMarkdownExample macOS 4 | // 5 | // Created by Simon Fairbairn on 01/02/2020. 6 | // Copyright © 2020 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftyMarkdown 11 | 12 | class ViewController: NSViewController { 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | 17 | // Do any additional setup after loading the view. 18 | } 19 | 20 | override var representedObject: Any? { 21 | didSet { 22 | // Update the view, if already loaded. 23 | } 24 | } 25 | 26 | 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SwiftyMarkdown", 6 | "repositoryURL": "https://github.com/SimonFairbairn/SwiftyMarkdown.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "36f0c4d9c772a57f72941e7b51f0f04fb57fd79b", 10 | "version": "1.0.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftyMarkdownExample 4 | // 5 | // Created by Simon Fairbairn on 05/03/2016. 6 | // Copyright © 2016 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Assets.xcassets/bubble.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "bubble.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Assets.xcassets/bubble.imageset/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonFairbairn/SwiftyMarkdown/dde451ab4ed9b77b328e21baa471fdfa0cf61369/Example/SwiftyMarkdownExample/Assets.xcassets/bubble.imageset/bubble.png -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 47 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | UISupportedInterfaceOrientations~ipad 40 | 41 | UIInterfaceOrientationPortrait 42 | UIInterfaceOrientationPortraitUpsideDown 43 | UIInterfaceOrientationLandscapeLeft 44 | UIInterfaceOrientationLandscapeRight 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // SwiftyMarkdownExample 4 | // 5 | // Created by Simon Fairbairn on 05/03/2016. 6 | // Copyright © 2016 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftyMarkdown 11 | 12 | class ViewController: UIViewController { 13 | 14 | 15 | @IBOutlet weak var textField : UITextField! 16 | @IBOutlet weak var textView : UITextView! 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | 21 | // This is to help debugging. 22 | reloadText(nil) 23 | 24 | self.textField.text = "Yo I'm a *single* line **string**. How do I look?" 25 | } 26 | 27 | @IBAction func processText( _ sender : UIButton? ) { 28 | guard let existentText = self.textField.text else { 29 | return 30 | } 31 | self.textView.attributedText = SwiftyMarkdown(string: existentText).attributedString() 32 | } 33 | 34 | @IBAction func reloadText( _ sender : UIButton? ) { 35 | 36 | self.textView.dataDetectorTypes = UIDataDetectorTypes.all 37 | 38 | if let url = Bundle.main.url(forResource: "example", withExtension: "md"), let md = SwiftyMarkdown(url: url) { 39 | md.h2.fontName = "AvenirNextCondensed-Bold" 40 | md.h2.color = UIColor.blue 41 | md.h2.alignment = .center 42 | 43 | md.code.fontName = "CourierNewPSMT" 44 | 45 | 46 | if #available(iOS 13.0, *) { 47 | md.strikethrough.color = .tertiaryLabel 48 | } else { 49 | md.strikethrough.color = .lightGray 50 | } 51 | 52 | md.blockquotes.fontStyle = .italic 53 | 54 | md.underlineLinks = true 55 | 56 | self.textView.attributedText = md.attributedString() 57 | 58 | } else { 59 | fatalError("Error loading file") 60 | } 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExample/example.md: -------------------------------------------------------------------------------- 1 | # Swifty Markdown 2 | 3 | SwiftyMarkdown is a Swift-based *Markdown* parser that converts *Markdown* files or strings into **NSAttributedStrings**. It uses sensible defaults and supports dynamic type, even with custom fonts. 4 | 5 | Show Images From Your App Bundle! 6 | --- 7 | ![Image](bubble) 8 | 9 | Customise fonts and colors easily in a Swift-like way: 10 | 11 | md.code.fontName = "CourierNewPSMT" 12 | 13 | md.h2.fontName = "AvenirNextCondensed-Medium" 14 | md.h2.color = UIColor.redColor() 15 | md.h2.alignment = .center 16 | 17 | It supports the standard Markdown syntax, like *italics*, _underline italics_, **bold**, `backticks for code`, ~~strikethrough~~, and headings. 18 | 19 | It ignores random * and correctly handles escaped \*asterisks\* and \_underlines\_ and \`backticks\`. It also supports inline Markdown [Links](http://voyagetravelapps.com/). 20 | 21 | > It also now supports blockquotes 22 | > and it supports whole-line italic and bold styles so you can go completely wild with styling! Wow! Such styles! Much fun! 23 | 24 | **Lists** 25 | 26 | - It Supports 27 | - Unordered 28 | - Lists 29 | - Indented item with a longer string to make sure indentation is consistent 30 | - Second level indent with a longer string to make sure indentation is consistent 31 | - List item with a longer string to make sure indentation is consistent 32 | 33 | 1. And 34 | 1. Ordered 35 | 1. Lists 36 | 1. Indented item 37 | 1. Second level indent 38 | 1. (Use `1.` as the list item identifier) 39 | 1. List item 40 | 1. List item 41 | - Mix 42 | - List styles 43 | 1. List item with a longer string to make sure indentation is consistent 44 | 1. List item 45 | 1. List item 46 | 1. List item 47 | 1. List item 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExampleTests/SwiftyMarkdownExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdownExampleTests.swift 3 | // SwiftyMarkdownExampleTests 4 | // 5 | // Created by Simon Fairbairn on 05/03/2016. 6 | // Copyright © 2016 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyMarkdownExample 11 | 12 | class SwiftyMarkdownExampleTests: XCTestCase { 13 | 14 | override func setUp() { 15 | super.setUp() 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | } 18 | 19 | override func tearDown() { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | super.tearDown() 22 | } 23 | 24 | func testExample() { 25 | // This is an example of a functional test case. 26 | // Use XCTAssert and related functions to verify your tests produce the correct results. 27 | } 28 | 29 | func testPerformanceExample() { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExampleUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/SwiftyMarkdownExampleUITests/SwiftyMarkdownExampleUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdownExampleUITests.swift 3 | // SwiftyMarkdownExampleUITests 4 | // 5 | // Created by Simon Fairbairn on 05/03/2016. 6 | // Copyright © 2016 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class SwiftyMarkdownExampleUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | super.setUp() 15 | 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | 18 | // In UI tests it is usually best to stop immediately when a failure occurs. 19 | continueAfterFailure = false 20 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 21 | XCUIApplication().launch() 22 | 23 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 24 | } 25 | 26 | override func tearDown() { 27 | // Put teardown code here. This method is called after the invocation of each test method in the class. 28 | super.tearDown() 29 | } 30 | 31 | func testExample() { 32 | // Use recording to get started writing UI tests. 33 | // Use XCTAssert and related functions to verify your tests produce the correct results. 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # A sample Gemfile 3 | source "https://rubygems.org" 4 | 5 | # gem "rails" 6 | gem "cocoapods" 7 | gem 'fastlane' 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.5) 5 | rexml 6 | activesupport (6.1.6) 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | i18n (>= 1.6, < 2) 9 | minitest (>= 5.1) 10 | tzinfo (~> 2.0) 11 | zeitwerk (~> 2.3) 12 | addressable (2.8.0) 13 | public_suffix (>= 2.0.2, < 5.0) 14 | algoliasearch (1.27.5) 15 | httpclient (~> 2.8, >= 2.8.3) 16 | json (>= 1.5.1) 17 | artifactory (3.0.15) 18 | atomos (0.1.3) 19 | aws-eventstream (1.2.0) 20 | aws-partitions (1.588.0) 21 | aws-sdk-core (3.131.0) 22 | aws-eventstream (~> 1, >= 1.0.2) 23 | aws-partitions (~> 1, >= 1.525.0) 24 | aws-sigv4 (~> 1.1) 25 | jmespath (~> 1.0) 26 | aws-sdk-kms (1.57.0) 27 | aws-sdk-core (~> 3, >= 3.127.0) 28 | aws-sigv4 (~> 1.1) 29 | aws-sdk-s3 (1.114.0) 30 | aws-sdk-core (~> 3, >= 3.127.0) 31 | aws-sdk-kms (~> 1) 32 | aws-sigv4 (~> 1.4) 33 | aws-sigv4 (1.5.0) 34 | aws-eventstream (~> 1, >= 1.0.2) 35 | babosa (1.0.4) 36 | claide (1.1.0) 37 | cocoapods (1.11.3) 38 | addressable (~> 2.8) 39 | claide (>= 1.0.2, < 2.0) 40 | cocoapods-core (= 1.11.3) 41 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 42 | cocoapods-downloader (>= 1.4.0, < 2.0) 43 | cocoapods-plugins (>= 1.0.0, < 2.0) 44 | cocoapods-search (>= 1.0.0, < 2.0) 45 | cocoapods-trunk (>= 1.4.0, < 2.0) 46 | cocoapods-try (>= 1.1.0, < 2.0) 47 | colored2 (~> 3.1) 48 | escape (~> 0.0.4) 49 | fourflusher (>= 2.3.0, < 3.0) 50 | gh_inspector (~> 1.0) 51 | molinillo (~> 0.8.0) 52 | nap (~> 1.0) 53 | ruby-macho (>= 1.0, < 3.0) 54 | xcodeproj (>= 1.21.0, < 2.0) 55 | cocoapods-core (1.11.3) 56 | activesupport (>= 5.0, < 7) 57 | addressable (~> 2.8) 58 | algoliasearch (~> 1.0) 59 | concurrent-ruby (~> 1.1) 60 | fuzzy_match (~> 2.0.4) 61 | nap (~> 1.0) 62 | netrc (~> 0.11) 63 | public_suffix (~> 4.0) 64 | typhoeus (~> 1.0) 65 | cocoapods-deintegrate (1.0.5) 66 | cocoapods-downloader (1.6.3) 67 | cocoapods-plugins (1.0.0) 68 | nap 69 | cocoapods-search (1.0.1) 70 | cocoapods-trunk (1.6.0) 71 | nap (>= 0.8, < 2.0) 72 | netrc (~> 0.11) 73 | cocoapods-try (1.2.0) 74 | colored (1.2) 75 | colored2 (3.1.2) 76 | commander (4.6.0) 77 | highline (~> 2.0.0) 78 | concurrent-ruby (1.1.10) 79 | declarative (0.0.20) 80 | digest-crc (0.6.4) 81 | rake (>= 12.0.0, < 14.0.0) 82 | domain_name (0.5.20190701) 83 | unf (>= 0.0.5, < 1.0.0) 84 | dotenv (2.7.6) 85 | emoji_regex (3.2.3) 86 | escape (0.0.4) 87 | ethon (0.15.0) 88 | ffi (>= 1.15.0) 89 | excon (0.92.3) 90 | faraday (1.10.0) 91 | faraday-em_http (~> 1.0) 92 | faraday-em_synchrony (~> 1.0) 93 | faraday-excon (~> 1.1) 94 | faraday-httpclient (~> 1.0) 95 | faraday-multipart (~> 1.0) 96 | faraday-net_http (~> 1.0) 97 | faraday-net_http_persistent (~> 1.0) 98 | faraday-patron (~> 1.0) 99 | faraday-rack (~> 1.0) 100 | faraday-retry (~> 1.0) 101 | ruby2_keywords (>= 0.0.4) 102 | faraday-cookie_jar (0.0.7) 103 | faraday (>= 0.8.0) 104 | http-cookie (~> 1.0.0) 105 | faraday-em_http (1.0.0) 106 | faraday-em_synchrony (1.0.0) 107 | faraday-excon (1.1.0) 108 | faraday-httpclient (1.0.1) 109 | faraday-multipart (1.0.3) 110 | multipart-post (>= 1.2, < 3) 111 | faraday-net_http (1.0.1) 112 | faraday-net_http_persistent (1.2.0) 113 | faraday-patron (1.0.0) 114 | faraday-rack (1.0.0) 115 | faraday-retry (1.0.3) 116 | faraday_middleware (1.2.0) 117 | faraday (~> 1.0) 118 | fastimage (2.2.6) 119 | fastlane (2.206.0) 120 | CFPropertyList (>= 2.3, < 4.0.0) 121 | addressable (>= 2.8, < 3.0.0) 122 | artifactory (~> 3.0) 123 | aws-sdk-s3 (~> 1.0) 124 | babosa (>= 1.0.3, < 2.0.0) 125 | bundler (>= 1.12.0, < 3.0.0) 126 | colored 127 | commander (~> 4.6) 128 | dotenv (>= 2.1.1, < 3.0.0) 129 | emoji_regex (>= 0.1, < 4.0) 130 | excon (>= 0.71.0, < 1.0.0) 131 | faraday (~> 1.0) 132 | faraday-cookie_jar (~> 0.0.6) 133 | faraday_middleware (~> 1.0) 134 | fastimage (>= 2.1.0, < 3.0.0) 135 | gh_inspector (>= 1.1.2, < 2.0.0) 136 | google-apis-androidpublisher_v3 (~> 0.3) 137 | google-apis-playcustomapp_v1 (~> 0.1) 138 | google-cloud-storage (~> 1.31) 139 | highline (~> 2.0) 140 | json (< 3.0.0) 141 | jwt (>= 2.1.0, < 3) 142 | mini_magick (>= 4.9.4, < 5.0.0) 143 | multipart-post (~> 2.0.0) 144 | naturally (~> 2.2) 145 | optparse (~> 0.1.1) 146 | plist (>= 3.1.0, < 4.0.0) 147 | rubyzip (>= 2.0.0, < 3.0.0) 148 | security (= 0.1.3) 149 | simctl (~> 1.6.3) 150 | terminal-notifier (>= 2.0.0, < 3.0.0) 151 | terminal-table (>= 1.4.5, < 2.0.0) 152 | tty-screen (>= 0.6.3, < 1.0.0) 153 | tty-spinner (>= 0.8.0, < 1.0.0) 154 | word_wrap (~> 1.0.0) 155 | xcodeproj (>= 1.13.0, < 2.0.0) 156 | xcpretty (~> 0.3.0) 157 | xcpretty-travis-formatter (>= 0.0.3) 158 | ffi (1.15.5) 159 | fourflusher (2.3.1) 160 | fuzzy_match (2.0.4) 161 | gh_inspector (1.1.3) 162 | google-apis-androidpublisher_v3 (0.21.0) 163 | google-apis-core (>= 0.4, < 2.a) 164 | google-apis-core (0.5.0) 165 | addressable (~> 2.5, >= 2.5.1) 166 | googleauth (>= 0.16.2, < 2.a) 167 | httpclient (>= 2.8.1, < 3.a) 168 | mini_mime (~> 1.0) 169 | representable (~> 3.0) 170 | retriable (>= 2.0, < 4.a) 171 | rexml 172 | webrick 173 | google-apis-iamcredentials_v1 (0.10.0) 174 | google-apis-core (>= 0.4, < 2.a) 175 | google-apis-playcustomapp_v1 (0.7.0) 176 | google-apis-core (>= 0.4, < 2.a) 177 | google-apis-storage_v1 (0.14.0) 178 | google-apis-core (>= 0.4, < 2.a) 179 | google-cloud-core (1.6.0) 180 | google-cloud-env (~> 1.0) 181 | google-cloud-errors (~> 1.0) 182 | google-cloud-env (1.6.0) 183 | faraday (>= 0.17.3, < 3.0) 184 | google-cloud-errors (1.2.0) 185 | google-cloud-storage (1.36.2) 186 | addressable (~> 2.8) 187 | digest-crc (~> 0.4) 188 | google-apis-iamcredentials_v1 (~> 0.1) 189 | google-apis-storage_v1 (~> 0.1) 190 | google-cloud-core (~> 1.6) 191 | googleauth (>= 0.16.2, < 2.a) 192 | mini_mime (~> 1.0) 193 | googleauth (1.1.3) 194 | faraday (>= 0.17.3, < 3.a) 195 | jwt (>= 1.4, < 3.0) 196 | memoist (~> 0.16) 197 | multi_json (~> 1.11) 198 | os (>= 0.9, < 2.0) 199 | signet (>= 0.16, < 2.a) 200 | highline (2.0.3) 201 | http-cookie (1.0.4) 202 | domain_name (~> 0.5) 203 | httpclient (2.8.3) 204 | i18n (1.10.0) 205 | concurrent-ruby (~> 1.0) 206 | jmespath (1.6.1) 207 | json (2.6.2) 208 | jwt (2.3.0) 209 | memoist (0.16.2) 210 | mini_magick (4.11.0) 211 | mini_mime (1.1.2) 212 | minitest (5.15.0) 213 | molinillo (0.8.0) 214 | multi_json (1.15.0) 215 | multipart-post (2.0.0) 216 | nanaimo (0.3.0) 217 | nap (1.1.0) 218 | naturally (2.2.1) 219 | netrc (0.11.0) 220 | optparse (0.1.1) 221 | os (1.1.4) 222 | plist (3.6.0) 223 | public_suffix (4.0.7) 224 | rake (13.0.6) 225 | representable (3.2.0) 226 | declarative (< 0.1.0) 227 | trailblazer-option (>= 0.1.1, < 0.2.0) 228 | uber (< 0.2.0) 229 | retriable (3.1.2) 230 | rexml (3.2.5) 231 | rouge (2.0.7) 232 | ruby-macho (2.5.1) 233 | ruby2_keywords (0.0.5) 234 | rubyzip (2.3.2) 235 | security (0.1.3) 236 | signet (0.16.1) 237 | addressable (~> 2.8) 238 | faraday (>= 0.17.5, < 3.0) 239 | jwt (>= 1.5, < 3.0) 240 | multi_json (~> 1.10) 241 | simctl (1.6.8) 242 | CFPropertyList 243 | naturally 244 | terminal-notifier (2.0.0) 245 | terminal-table (1.8.0) 246 | unicode-display_width (~> 1.1, >= 1.1.1) 247 | trailblazer-option (0.1.2) 248 | tty-cursor (0.7.1) 249 | tty-screen (0.8.1) 250 | tty-spinner (0.9.3) 251 | tty-cursor (~> 0.7) 252 | typhoeus (1.4.0) 253 | ethon (>= 0.9.0) 254 | tzinfo (2.0.4) 255 | concurrent-ruby (~> 1.0) 256 | uber (0.1.0) 257 | unf (0.1.4) 258 | unf_ext 259 | unf_ext (0.0.8.1) 260 | unicode-display_width (1.8.0) 261 | webrick (1.7.0) 262 | word_wrap (1.0.0) 263 | xcodeproj (1.21.0) 264 | CFPropertyList (>= 2.3.3, < 4.0) 265 | atomos (~> 0.1.3) 266 | claide (>= 1.0.2, < 2.0) 267 | colored2 (~> 3.1) 268 | nanaimo (~> 0.3.0) 269 | rexml (~> 3.2.4) 270 | xcpretty (0.3.0) 271 | rouge (~> 2.0.7) 272 | xcpretty-travis-formatter (1.0.1) 273 | xcpretty (~> 0.2, >= 0.0.7) 274 | zeitwerk (2.5.4) 275 | 276 | PLATFORMS 277 | ruby 278 | 279 | DEPENDENCIES 280 | cocoapods 281 | fastlane 282 | 283 | BUNDLED WITH 284 | 2.3.8 285 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Simon Fairbairn 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:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SwiftyMarkdown", 6 | platforms: [ 7 | .iOS(SupportedPlatform.IOSVersion.v11), 8 | .tvOS(SupportedPlatform.TVOSVersion.v11), 9 | .macOS(.v10_12), 10 | .watchOS(.v4) 11 | ], 12 | products: [ 13 | .library(name: "SwiftyMarkdown", targets: ["SwiftyMarkdown"]), 14 | ], 15 | targets: [ 16 | .target(name: "SwiftyMarkdown"), 17 | .testTarget(name: "SwiftyMarkdownTests", dependencies: ["SwiftyMarkdown"]) 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Attributed String.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: [Previous](@previous) 2 | 3 | import UIKit 4 | 5 | 6 | enum CharacterStyle : CharacterStyling { 7 | case none 8 | case bold 9 | case italic 10 | case code 11 | case link 12 | case image 13 | } 14 | 15 | enum MarkdownLineStyle : LineStyling { 16 | var shouldTokeniseLine: Bool { 17 | switch self { 18 | case .codeblock: 19 | return false 20 | default: 21 | return true 22 | } 23 | 24 | } 25 | 26 | case h1 27 | case h2 28 | case h3 29 | case h4 30 | case h5 31 | case h6 32 | case previousH1 33 | case previousH2 34 | case body 35 | case blockquote 36 | case codeblock 37 | case unorderedList 38 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? { 39 | switch self { 40 | case .previousH1: 41 | return MarkdownLineStyle.h1 42 | case .previousH2: 43 | return MarkdownLineStyle.h2 44 | default : 45 | return nil 46 | } 47 | } 48 | } 49 | 50 | 51 | @objc public protocol FontProperties { 52 | var fontName : String? { get set } 53 | var color : UIColor { get set } 54 | var fontSize : CGFloat { get set } 55 | } 56 | 57 | 58 | /** 59 | A struct defining the styles that can be applied to the parsed Markdown. The `fontName` property is optional, and if it's not set then the `fontName` property of the Body style will be applied. 60 | 61 | If that is not set, then the system default will be used. 62 | */ 63 | @objc open class BasicStyles : NSObject, FontProperties { 64 | public var fontName : String? 65 | public var color = UIColor.black 66 | public var fontSize : CGFloat = 0.0 67 | } 68 | 69 | /// A class that takes a [Markdown](https://daringfireball.net/projects/markdown/) string or file and returns an NSAttributedString with the applied styles. Supports Dynamic Type. 70 | @objc open class SwiftyMarkdown: NSObject { 71 | static let lineRules = [ 72 | LineRule(token: "=", type: MarkdownLineStyle.previousH1, removeFrom: .entireLine, changeAppliesTo: .previous), 73 | LineRule(token: "-", type: MarkdownLineStyle.previousH2, removeFrom: .entireLine, changeAppliesTo: .previous), 74 | LineRule(token: " ", type: MarkdownLineStyle.codeblock, removeFrom: .leading, shouldTrim: false), 75 | LineRule(token: "\t", type: MarkdownLineStyle.codeblock, removeFrom: .leading, shouldTrim: false), 76 | LineRule(token: ">",type : MarkdownLineStyle.blockquote, removeFrom: .leading), 77 | LineRule(token: "- ",type : MarkdownLineStyle.unorderedList, removeFrom: .leading), 78 | LineRule(token: "###### ",type : MarkdownLineStyle.h6, removeFrom: .both), 79 | LineRule(token: "##### ",type : MarkdownLineStyle.h5, removeFrom: .both), 80 | LineRule(token: "#### ",type : MarkdownLineStyle.h4, removeFrom: .both), 81 | LineRule(token: "### ",type : MarkdownLineStyle.h3, removeFrom: .both), 82 | LineRule(token: "## ",type : MarkdownLineStyle.h2, removeFrom: .both), 83 | LineRule(token: "# ",type : MarkdownLineStyle.h1, removeFrom: .both) 84 | ] 85 | 86 | static let characterRules = [ 87 | CharacterRule(openTag: "![", intermediateTag: "](", closingTag: ")", escapeCharacter: "\\", styles: [1 : [CharacterStyle.image]], maxTags: 1), 88 | CharacterRule(openTag: "[", intermediateTag: "](", closingTag: ")", escapeCharacter: "\\", styles: [1 : [CharacterStyle.link]], maxTags: 1), 89 | CharacterRule(openTag: "`", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.code]], maxTags: 1, cancels: .allRemaining), 90 | CharacterRule(openTag: "*", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.italic], 2 : [CharacterStyle.bold], 3 : [CharacterStyle.bold, CharacterStyle.italic]], maxTags: 3), 91 | CharacterRule(openTag: "_", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.italic], 2 : [CharacterStyle.bold], 3 : [CharacterStyle.bold, CharacterStyle.italic]], maxTags: 3) 92 | ] 93 | 94 | let lineProcessor = SwiftyLineProcessor(rules: SwiftyMarkdown.lineRules, defaultRule: MarkdownLineStyle.body) 95 | let tokeniser = SwiftyTokeniser(with: SwiftyMarkdown.characterRules) 96 | 97 | /// The styles to apply to any H1 headers found in the Markdown 98 | open var h1 = BasicStyles() 99 | 100 | /// The styles to apply to any H2 headers found in the Markdown 101 | open var h2 = BasicStyles() 102 | 103 | /// The styles to apply to any H3 headers found in the Markdown 104 | open var h3 = BasicStyles() 105 | 106 | /// The styles to apply to any H4 headers found in the Markdown 107 | open var h4 = BasicStyles() 108 | 109 | /// The styles to apply to any H5 headers found in the Markdown 110 | open var h5 = BasicStyles() 111 | 112 | /// The styles to apply to any H6 headers found in the Markdown 113 | open var h6 = BasicStyles() 114 | 115 | /// The default body styles. These are the base styles and will be used for e.g. headers if no other styles override them. 116 | open var body = BasicStyles() 117 | 118 | /// The styles to apply to any links found in the Markdown 119 | open var link = BasicStyles() 120 | 121 | /// The styles to apply to any bold text found in the Markdown 122 | open var bold = BasicStyles() 123 | 124 | /// The styles to apply to any italic text found in the Markdown 125 | open var italic = BasicStyles() 126 | 127 | /// The styles to apply to any code blocks or inline code text found in the Markdown 128 | open var code = BasicStyles() 129 | 130 | 131 | var currentType : MarkdownLineStyle = .body 132 | 133 | 134 | let string : String 135 | 136 | let tagList = "!\\_*`[]()" 137 | let validMarkdownTags = CharacterSet(charactersIn: "!\\_*`[]()") 138 | 139 | 140 | /** 141 | 142 | - parameter string: A string containing [Markdown](https://daringfireball.net/projects/markdown/) syntax to be converted to an NSAttributedString 143 | 144 | - returns: An initialized SwiftyMarkdown object 145 | */ 146 | public init(string : String ) { 147 | self.string = string 148 | } 149 | 150 | /** 151 | A failable initializer that takes a URL and attempts to read it as a UTF-8 string 152 | 153 | - parameter url: The location of the file to read 154 | 155 | - returns: An initialized SwiftyMarkdown object, or nil if the string couldn't be read 156 | */ 157 | public init?(url : URL ) { 158 | 159 | do { 160 | self.string = try NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String 161 | 162 | } catch { 163 | self.string = "" 164 | return nil 165 | } 166 | } 167 | 168 | /** 169 | Set font size for all styles 170 | 171 | - parameter size: size of font 172 | */ 173 | open func setFontSizeForAllStyles(with size: CGFloat) { 174 | h1.fontSize = size 175 | h2.fontSize = size 176 | h3.fontSize = size 177 | h4.fontSize = size 178 | h5.fontSize = size 179 | h6.fontSize = size 180 | body.fontSize = size 181 | italic.fontSize = size 182 | code.fontSize = size 183 | link.fontSize = size 184 | } 185 | 186 | open func setFontColorForAllStyles(with color: UIColor) { 187 | h1.color = color 188 | h2.color = color 189 | h3.color = color 190 | h4.color = color 191 | h5.color = color 192 | h6.color = color 193 | body.color = color 194 | italic.color = color 195 | code.color = color 196 | link.color = color 197 | } 198 | 199 | open func setFontNameForAllStyles(with name: String) { 200 | h1.fontName = name 201 | h2.fontName = name 202 | h3.fontName = name 203 | h4.fontName = name 204 | h5.fontName = name 205 | h6.fontName = name 206 | body.fontName = name 207 | italic.fontName = name 208 | code.fontName = name 209 | link.fontName = name 210 | } 211 | 212 | 213 | 214 | /** 215 | Generates an NSAttributedString from the string or URL passed at initialisation. Custom fonts or styles are applied to the appropriate elements when this method is called. 216 | 217 | - returns: An NSAttributedString with the styles applied 218 | */ 219 | open func attributedString() -> NSAttributedString { 220 | let attributedString = NSMutableAttributedString(string: "") 221 | let foundAttributes : [SwiftyLine] = lineProcessor.process(self.string) 222 | 223 | var strings : [String] = [] 224 | for line in foundAttributes { 225 | let finalTokens = self.tokeniser.process(line.line) 226 | attributedString.append(attributedStringFor(tokens: finalTokens, in: line)) 227 | } 228 | 229 | return attributedString 230 | } 231 | 232 | 233 | } 234 | 235 | extension SwiftyMarkdown { 236 | 237 | func font( for line : SwiftyLine, characterOverride : CharacterStyle? = nil ) -> UIFont { 238 | let textStyle : UIFont.TextStyle 239 | var fontName : String? 240 | var fontSize : CGFloat? 241 | 242 | // What type are we and is there a font name set? 243 | switch line.lineStyle as! MarkdownLineStyle { 244 | case .h1: 245 | fontName = h1.fontName 246 | fontSize = h1.fontSize 247 | if #available(iOS 9, *) { 248 | textStyle = UIFont.TextStyle.title1 249 | } else { 250 | textStyle = UIFont.TextStyle.headline 251 | } 252 | case .h2: 253 | fontName = h2.fontName 254 | fontSize = h2.fontSize 255 | if #available(iOS 9, *) { 256 | textStyle = UIFont.TextStyle.title2 257 | } else { 258 | textStyle = UIFont.TextStyle.headline 259 | } 260 | case .h3: 261 | fontName = h3.fontName 262 | fontSize = h3.fontSize 263 | if #available(iOS 9, *) { 264 | textStyle = UIFont.TextStyle.title2 265 | } else { 266 | textStyle = UIFont.TextStyle.subheadline 267 | } 268 | case .h4: 269 | fontName = h4.fontName 270 | fontSize = h4.fontSize 271 | textStyle = UIFont.TextStyle.headline 272 | case .h5: 273 | fontName = h5.fontName 274 | fontSize = h5.fontSize 275 | textStyle = UIFont.TextStyle.subheadline 276 | case .h6: 277 | fontName = h6.fontName 278 | fontSize = h6.fontSize 279 | textStyle = UIFont.TextStyle.footnote 280 | default: 281 | fontName = body.fontName 282 | fontSize = body.fontSize 283 | textStyle = UIFont.TextStyle.body 284 | } 285 | 286 | if fontName == nil { 287 | fontName = body.fontName 288 | } 289 | 290 | if let characterOverride = characterOverride { 291 | switch characterOverride { 292 | case .code: 293 | fontName = code.fontName ?? fontName 294 | case .link: 295 | fontName = link.fontName ?? fontName 296 | default: 297 | break 298 | } 299 | } 300 | 301 | fontSize = fontSize == 0.0 ? nil : fontSize 302 | var font : UIFont 303 | if let existentFontName = fontName { 304 | font = UIFont.preferredFont(forTextStyle: textStyle) 305 | let finalSize : CGFloat 306 | if let existentFontSize = fontSize { 307 | finalSize = existentFontSize 308 | } else { 309 | let styleDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) 310 | finalSize = styleDescriptor.fontAttributes[.size] as? CGFloat ?? CGFloat(14) 311 | } 312 | 313 | if let customFont = UIFont(name: existentFontName, size: finalSize) { 314 | let fontMetrics = UIFontMetrics(forTextStyle: textStyle) 315 | font = fontMetrics.scaledFont(for: customFont) 316 | } else { 317 | font = UIFont.preferredFont(forTextStyle: textStyle) 318 | } 319 | } else { 320 | font = UIFont.preferredFont(forTextStyle: textStyle) 321 | } 322 | 323 | return font 324 | 325 | } 326 | 327 | func color( for line : SwiftyLine ) -> UIColor { 328 | // What type are we and is there a font name set? 329 | switch line.lineStyle as! MarkdownLineStyle { 330 | case .h1, .previousH1: 331 | return h1.color 332 | case .h2, .previousH2: 333 | return h2.color 334 | case .h3: 335 | return h3.color 336 | case .h4: 337 | return h4.color 338 | case .h5: 339 | return h5.color 340 | case .h6: 341 | return h6.color 342 | case .body: 343 | return body.color 344 | case .codeblock: 345 | return code.color 346 | case .blockquote: 347 | return body.color 348 | case .unorderedList: 349 | return body.color 350 | } 351 | } 352 | 353 | func attributedStringFor( tokens : [Token], in line : SwiftyLine ) -> NSAttributedString { 354 | var outputLine = line.line 355 | if let style = line.lineStyle as? MarkdownLineStyle, style == .codeblock { 356 | outputLine = "\t\(outputLine)" 357 | } 358 | 359 | var attributes : [NSAttributedString.Key : AnyObject] = [:] 360 | let finalAttributedString = NSMutableAttributedString() 361 | for token in tokens { 362 | var font = self.font(for: line) 363 | attributes[.foregroundColor] = self.color(for: line) 364 | guard let styles = token.characterStyles as? [CharacterStyle] else { 365 | continue 366 | } 367 | if styles.contains(.italic) { 368 | if let italicDescriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) { 369 | font = UIFont(descriptor: italicDescriptor, size: 0) 370 | } 371 | } 372 | if styles.contains(.bold) { 373 | if let boldDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) { 374 | font = UIFont(descriptor: boldDescriptor, size: 0) 375 | } 376 | } 377 | attributes[.font] = font 378 | if styles.contains(.link), let url = token.metadataString { 379 | attributes[.foregroundColor] = self.link.color 380 | attributes[.font] = self.font(for: line, characterOverride: .link) 381 | attributes[.link] = url as AnyObject 382 | } 383 | 384 | if styles.contains(.image), let imageName = token.metadataString { 385 | let image1Attachment = NSTextAttachment() 386 | image1Attachment.image = UIImage(named: imageName) 387 | let str = NSAttributedString(attachment: image1Attachment) 388 | finalAttributedString.append(str) 389 | continue 390 | } 391 | 392 | if styles.contains(.code) { 393 | attributes[.foregroundColor] = self.code.color 394 | attributes[.font] = self.font(for: line, characterOverride: .code) 395 | } else { 396 | // Switch back to previous font 397 | } 398 | let str = NSAttributedString(string: token.outputString, attributes: attributes) 399 | finalAttributedString.append(str) 400 | } 401 | 402 | 403 | return finalAttributedString 404 | } 405 | } 406 | 407 | 408 | let image = UIImage(named: "bubble") 409 | let image1Attachment = NSTextAttachment() 410 | image1Attachment.image = image 411 | let att = NSAttributedString(attachment: image1Attachment) 412 | 413 | 414 | 415 | var str = "# Hello, *playground* `code` **bold** ![Image](bubble)" 416 | 417 | let md = SwiftyMarkdown(string: str) 418 | md.body.color = .red 419 | md.h1.color = .white 420 | md.h1.fontName = "Noteworthy-Light" 421 | 422 | md.link.color = .red 423 | 424 | md.code.fontName = "CourierNewPSMT" 425 | 426 | md.attributedString() 427 | 428 | //: [Next](@next) 429 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Attributed String.xcplaygroundpage/Resources/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonFairbairn/SwiftyMarkdown/dde451ab4ed9b77b328e21baa471fdfa0cf61369/Playground/SwiftyMarkdown.playground/Pages/Attributed String.xcplaygroundpage/Resources/bubble.png -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Groups.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: [Previous](@previous) 2 | 3 | import Foundation 4 | 5 | extension String { 6 | func repeating( _ max : Int ) -> String { 7 | var output = self 8 | for _ in 1.. [Token] { 84 | print(self) 85 | var tokens : [Token] = [] 86 | 87 | if !self.preOpenString.isEmpty { 88 | tokens.append(Token(type: .string, inputString: self.preOpenString)) 89 | } 90 | if !self.openTagString.isEmpty { 91 | tokens.append(Token(type: .openTag, inputString: self.openTagString)) 92 | } 93 | if !self.intermediateString.isEmpty { 94 | var token = Token(type: .string, inputString: self.intermediateString) 95 | token.metadataString = self.metadataString 96 | tokens.append(token) 97 | } 98 | if !self.intermediateTagString.isEmpty { 99 | tokens.append(Token(type: .intermediateTag, inputString: self.intermediateTagString)) 100 | } 101 | if !self.metadataString.isEmpty { 102 | tokens.append(Token(type: .metadata, inputString: self.metadataString)) 103 | } 104 | if !self.closedTagString.isEmpty { 105 | tokens.append(Token(type: .closeTag, inputString: self.closedTagString)) 106 | } 107 | 108 | self.preOpenString = "" 109 | self.openTagString = "" 110 | self.intermediateString = "" 111 | self.intermediateTagString = "" 112 | self.metadataString = "" 113 | self.closedTagString = "" 114 | self.postClosedString = "" 115 | 116 | self.state = .none 117 | 118 | return tokens 119 | } 120 | } 121 | 122 | struct TokenGroup { 123 | enum TokenGroupType { 124 | case string 125 | case tag 126 | case escape 127 | } 128 | 129 | let string : String 130 | let isEscaped : Bool 131 | let type : TokenGroupType 132 | var state : TagState = .none 133 | } 134 | 135 | 136 | 137 | func getTokenGroups( for string : inout String, with rule : Rule, shouldEmpty : Bool = false ) -> [TokenGroup] { 138 | if string.isEmpty { 139 | return [] 140 | } 141 | let maxCount = rule.openTag.count * rule.maxTags 142 | var groups : [TokenGroup] = [] 143 | 144 | let maxTag = rule.openTag.repeating(rule.maxTags) 145 | 146 | if maxTag.contains(string) { 147 | if string.count == maxCount || shouldEmpty { 148 | var token = TokenGroup(string: string, isEscaped: false, type: .tag) 149 | token.state = .open 150 | groups.append(token) 151 | string.removeAll() 152 | } 153 | 154 | } else if string == rule.intermediateTag { 155 | var token = TokenGroup(string: string, isEscaped: false, type: .tag) 156 | token.state = .intermediate 157 | groups.append(token) 158 | string.removeAll() 159 | } else if string == rule.closingTag { 160 | var token = TokenGroup(string: string, isEscaped: false, type: .tag) 161 | token.state = .closed 162 | groups.append(token) 163 | string.removeAll() 164 | } 165 | 166 | if shouldEmpty && !string.isEmpty { 167 | let token = TokenGroup(string: string, isEscaped: false, type: .tag) 168 | groups.append(token) 169 | string.removeAll() 170 | } 171 | return groups 172 | } 173 | 174 | func scan( _ string : String, with rule : Rule) -> [Token] { 175 | let scanner = Scanner(string: string) 176 | scanner.charactersToBeSkipped = nil 177 | var tokens : [Token] = [] 178 | var set = CharacterSet(charactersIn: "\(rule.openTag)\(rule.intermediateTag ?? "")\(rule.closingTag ?? "")") 179 | if let existentEscape = rule.escapeCharacter { 180 | set.insert(charactersIn: String(existentEscape)) 181 | } 182 | 183 | var openTag = rule.openTag.repeating(rule.maxTags) 184 | 185 | var tagString = TagString(with: rule) 186 | 187 | var openTagFound : TagState = .none 188 | var regularCharacters = "" 189 | var tagGroupCount = 0 190 | while !scanner.isAtEnd { 191 | tagGroupCount += 1 192 | 193 | if #available(iOS 13.0, OSX 10.15, watchOS 6.0, tvOS 13.0, *) { 194 | if let start = scanner.scanUpToCharacters(from: set) { 195 | tagString.append(start) 196 | } 197 | } else { 198 | var string : NSString? 199 | scanner.scanUpToCharacters(from: set, into: &string) 200 | if let existentString = string as String? { 201 | tagString.append(existentString) 202 | } 203 | } 204 | 205 | // The end of the string 206 | let maybeFoundChars = scanner.scanCharacters(from: set ) 207 | guard let foundTag = maybeFoundChars else { 208 | continue 209 | } 210 | 211 | if foundTag == rule.openTag && foundTag.count < rule.minTags { 212 | tagString.append(foundTag) 213 | continue 214 | } 215 | 216 | //:-- 217 | print(foundTag) 218 | var tokenGroups : [TokenGroup] = [] 219 | var escapeCharacter : Character? = nil 220 | var cumulatedString = "" 221 | for char in foundTag { 222 | if let existentEscapeCharacter = escapeCharacter { 223 | 224 | // If any of the tags feature the current character 225 | let escape = String(existentEscapeCharacter) 226 | let nextTagCharacter = String(char) 227 | if rule.openTag.contains(nextTagCharacter) || rule.intermediateTag?.contains(nextTagCharacter) ?? false || rule.closingTag?.contains(nextTagCharacter) ?? false { 228 | tokenGroups.append(TokenGroup(string: nextTagCharacter, isEscaped: true, type: .tag)) 229 | escapeCharacter = nil 230 | } else if nextTagCharacter == escape { 231 | // Doesn't apply to this rule 232 | tokenGroups.append(TokenGroup(string: nextTagCharacter, isEscaped: false, type: .escape)) 233 | } 234 | 235 | continue 236 | } 237 | if let existentEscape = rule.escapeCharacter { 238 | if char == existentEscape { 239 | tokenGroups.append(contentsOf: getTokenGroups(for: &cumulatedString, with: rule, shouldEmpty: true)) 240 | escapeCharacter = char 241 | continue 242 | } 243 | } 244 | cumulatedString.append(char) 245 | tokenGroups.append(contentsOf: getTokenGroups(for: &cumulatedString, with: rule)) 246 | 247 | } 248 | if let remainingEscape = escapeCharacter { 249 | tokenGroups.append(TokenGroup(string: String(remainingEscape), isEscaped: false, type: .escape)) 250 | } 251 | 252 | tokenGroups.append(contentsOf: getTokenGroups(for: &cumulatedString, with: rule, shouldEmpty: true)) 253 | tagString.append(contentsOf: tokenGroups) 254 | 255 | if tagString.state == .closed { 256 | tokens.append(contentsOf: tagString.tokens()) 257 | } 258 | 259 | 260 | } 261 | 262 | tokens.append(contentsOf: tagString.tokens()) 263 | 264 | 265 | return tokens 266 | } 267 | 268 | //: [Next](@next) 269 | 270 | 271 | 272 | var string = "[]([[\\[Some Link]\\]](\\(\\(\\url) [Regular link](url)" 273 | //string = "Text before [Regular link](url) Text after" 274 | var output = "[]([[Some Link]] Regular link" 275 | 276 | var tokens = scan(string, with: LinkRule()) 277 | print( tokens.filter( { $0.type == .string }).map({ $0.outputString }).joined()) 278 | //print( tokens ) 279 | 280 | //string = "**\\*\\Bold\\*\\***" 281 | //output = "*\\Bold**" 282 | 283 | //tokens = scan(string, with: AsteriskRule()) 284 | //print( tokens ) 285 | 286 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Groups.xcplaygroundpage/Sources/Tokens.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | 5 | public protocol Rule { 6 | var escapeCharacter : Character? { get } 7 | var openTag : String { get } 8 | var intermediateTag : String? { get } 9 | var closingTag : String? { get } 10 | var maxTags : Int { get } 11 | var minTags : Int { get } 12 | } 13 | 14 | public struct LinkRule : Rule { 15 | public let escapeCharacter : Character? = "\\" 16 | public let openTag : String = "[" 17 | public let intermediateTag : String? = "](" 18 | public let closingTag : String? = ")" 19 | public let maxTags : Int = 1 20 | public let minTags : Int = 1 21 | public init() { } 22 | } 23 | 24 | public struct AsteriskRule : Rule { 25 | public let escapeCharacter : Character? = "\\" 26 | public let openTag : String = "*" 27 | public let intermediateTag : String? = nil 28 | public let closingTag : String? = nil 29 | public let maxTags : Int = 3 30 | public let minTags : Int = 1 31 | public init() { } 32 | } 33 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Line Processing.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum MarkdownLineStyle : LineStyling { 4 | var shouldTokeniseLine: Bool { 5 | switch self { 6 | case .codeblock: 7 | return false 8 | default: 9 | return true 10 | } 11 | 12 | } 13 | 14 | case h1 15 | case h2 16 | case h3 17 | case h4 18 | case h5 19 | case h6 20 | case previousH1 21 | case previousH2 22 | case body 23 | case blockquote 24 | case codeblock 25 | case unorderedList 26 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? { 27 | switch self { 28 | case .previousH1: 29 | return MarkdownLineStyle.h1 30 | case .previousH2: 31 | return MarkdownLineStyle.h2 32 | default : 33 | return nil 34 | } 35 | } 36 | } 37 | 38 | let rules = [ 39 | LineRule(token: "=", type: MarkdownLineStyle.previousH1, removeFrom: .entireLine, changeAppliesTo: .previous), 40 | LineRule(token: "-", type: MarkdownLineStyle.previousH2, removeFrom: .entireLine, changeAppliesTo: .previous), 41 | LineRule(token: " ", type: MarkdownLineStyle.codeblock, removeFrom: .leading), 42 | LineRule(token: "\t", type: MarkdownLineStyle.codeblock, removeFrom: .leading), 43 | LineRule(token: ">",type : MarkdownLineStyle.blockquote, removeFrom: .leading), 44 | LineRule(token: "- ",type : MarkdownLineStyle.unorderedList, removeFrom: .leading), 45 | LineRule(token: "###### ",type : MarkdownLineStyle.h6, removeFrom: .both), 46 | LineRule(token: "##### ",type : MarkdownLineStyle.h5, removeFrom: .both), 47 | LineRule(token: "#### ",type : MarkdownLineStyle.h4, removeFrom: .both), 48 | LineRule(token: "### ",type : MarkdownLineStyle.h3, removeFrom: .both), 49 | LineRule(token: "## ",type : MarkdownLineStyle.h2, removeFrom: .both), 50 | LineRule(token: "# ",type : MarkdownLineStyle.h1, removeFrom: .both) 51 | ] 52 | 53 | let lineProcesser = SwiftyLineProcessor(rules: rules, defaultRule: MarkdownLineStyle.body) 54 | print(lineProcesser.process("#### Heading 4 ###").first?.line ?? "") 55 | 56 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/SKLabelNode.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: [Previous](@previous) 2 | 3 | import Foundation 4 | import SpriteKit 5 | import PlaygroundSupport 6 | 7 | 8 | 9 | class GameScene : SKScene { 10 | var str = "# Text\n## Speaker 1\nHello, **playground**. *I* don't want to be here, you know. *I* want to be somewhere else." 11 | override func didMove(to view: SKView) { 12 | 13 | let md = SwiftyMarkdown(string: str) 14 | md.h2.alignment = .center 15 | md.body.alignment = .center 16 | 17 | let label = SKLabelNode(attributedText: md.attributedString()) 18 | label.position = CGPoint(x: 100, y: 100) 19 | label.numberOfLines = 0 20 | label.preferredMaxLayoutWidth = 400 21 | label.horizontalAlignmentMode = .left 22 | self.addChild(label) 23 | } 24 | } 25 | 26 | 27 | let view = SKView(frame: CGRect(x: 0, y: 0, width: 600, height: 500)) 28 | let scene = GameScene(size: view.frame.size) 29 | scene.scaleMode = .aspectFit 30 | view.presentScene(scene) 31 | PlaygroundPage.current.liveView = view 32 | 33 | //: [Next](@next) 34 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Pages/Scanner Tests.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: [Previous](@previous) 2 | 3 | import Foundation 4 | 5 | 6 | 7 | //: [Next](@next) 8 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Resources/test.md: -------------------------------------------------------------------------------- 1 | A Markdown Example 2 | ======== 3 | 4 | Headings 5 | ----- 6 | 7 | # Heading 1 8 | ## Heading 2 9 | ### Heading 3 ### 10 | #### Heading 4 #### 11 | ##### Heading 5 ##### 12 | ###### Heading 6 ###### 13 | 14 | A simple paragraph with a random * in *the* middle. Now with ** **Added Bold** 15 | 16 | A standard paragraph with an *italic*, * spaced asterisk, \*escaped asterisks\*, _underscored italics_, \_escaped underscores\_, **bold** \*\*escaped double asterisks\*\*, __underscored bold__, _ spaced underscore \_\_escaped double underscores\_\_. 17 | 18 | This is a very basic implementation of markdown. 19 | 20 | *This whole line is italic* 21 | 22 | **This whole line is bold** 23 | 24 | The End -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Sources/String+SwiftyMarkdown.swift: -------------------------------------------------------------------------------- 1 | // Code inside modules can be shared between pages and other source files. 2 | import Foundation 3 | 4 | /// https://stackoverflow.com/questions/32305891/index-of-a-substring-in-a-string-with-swift/32306142#32306142 5 | public extension StringProtocol { 6 | func index(of string: S, options: String.CompareOptions = []) -> Index? { 7 | range(of: string, options: options)?.lowerBound 8 | } 9 | func endIndex(of string: S, options: String.CompareOptions = []) -> Index? { 10 | range(of: string, options: options)?.upperBound 11 | } 12 | func indices(of string: S, options: String.CompareOptions = []) -> [Index] { 13 | var indices: [Index] = [] 14 | var startIndex = self.startIndex 15 | while startIndex < endIndex, 16 | let range = self[startIndex...] 17 | .range(of: string, options: options) { 18 | indices.append(range.lowerBound) 19 | startIndex = range.lowerBound < range.upperBound ? range.upperBound : 20 | index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex 21 | } 22 | return indices 23 | } 24 | func ranges(of string: S, options: String.CompareOptions = []) -> [Range] { 25 | var result: [Range] = [] 26 | var startIndex = self.startIndex 27 | while startIndex < endIndex, 28 | let range = self[startIndex...] 29 | .range(of: string, options: options) { 30 | result.append(range) 31 | startIndex = range.lowerBound < range.upperBound ? range.upperBound : 32 | index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex 33 | } 34 | return result 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Sources/Swifty Line Processor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyLineProcessor.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 16/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol LineStyling { 12 | var shouldTokeniseLine : Bool { get } 13 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? 14 | } 15 | 16 | public struct SwiftyLine : CustomStringConvertible { 17 | public let line : String 18 | public let lineStyle : LineStyling 19 | public var description: String { 20 | return self.line 21 | } 22 | } 23 | 24 | extension SwiftyLine : Equatable { 25 | public static func == ( _ lhs : SwiftyLine, _ rhs : SwiftyLine ) -> Bool { 26 | return lhs.line == rhs.line 27 | } 28 | } 29 | 30 | public enum Remove { 31 | case leading 32 | case trailing 33 | case both 34 | case entireLine 35 | case none 36 | } 37 | 38 | public enum ChangeApplication { 39 | case current 40 | case previous 41 | } 42 | 43 | public struct LineRule { 44 | let token : String 45 | let removeFrom : Remove 46 | let type : LineStyling 47 | let shouldTrim : Bool 48 | let changeAppliesTo : ChangeApplication 49 | 50 | public init(token : String, type : LineStyling, removeFrom : Remove = .leading, shouldTrim : Bool = true, changeAppliesTo : ChangeApplication = .current ) { 51 | self.token = token 52 | self.type = type 53 | self.removeFrom = removeFrom 54 | self.shouldTrim = shouldTrim 55 | self.changeAppliesTo = changeAppliesTo 56 | } 57 | } 58 | 59 | public class SwiftyLineProcessor { 60 | 61 | let defaultType : LineStyling 62 | public var processEmptyStrings : LineStyling? 63 | let lineRules : [LineRule] 64 | 65 | public init( rules : [LineRule], defaultRule: LineStyling) { 66 | self.lineRules = rules 67 | self.defaultType = defaultRule 68 | } 69 | 70 | func findLeadingLineElement( _ element : LineRule, in string : String ) -> String { 71 | var output = string 72 | if let range = output.index(output.startIndex, offsetBy: element.token.count, limitedBy: output.endIndex), output[output.startIndex.. String { 80 | var output = string 81 | let token = element.token.trimmingCharacters(in: .whitespaces) 82 | if let range = output.index(output.endIndex, offsetBy: -(token.count), limitedBy: output.startIndex), output[range.. SwiftyLine { 91 | if text.isEmpty, let style = processEmptyStrings { 92 | return SwiftyLine(line: "", lineStyle: style) 93 | } 94 | let previousLines = lineRules.filter({ $0.changeAppliesTo == .previous }) 95 | for element in previousLines { 96 | let output = (element.shouldTrim) ? text.trimmingCharacters(in: .whitespaces) : text 97 | let charSet = CharacterSet(charactersIn: element.token ) 98 | if output.unicodeScalars.allSatisfy({ charSet.contains($0) }) { 99 | return SwiftyLine(line: "", lineStyle: element.type) 100 | } 101 | } 102 | for element in lineRules { 103 | guard element.token.count > 0 else { 104 | continue 105 | } 106 | var output : String = (element.shouldTrim) ? text.trimmingCharacters(in: .whitespaces) : text 107 | let unprocessed = output 108 | 109 | switch element.removeFrom { 110 | case .leading: 111 | output = findLeadingLineElement(element, in: output) 112 | case .trailing: 113 | output = findTrailingLineElement(element, in: output) 114 | case .both: 115 | output = findLeadingLineElement(element, in: output) 116 | output = findTrailingLineElement(element, in: output) 117 | default: 118 | break 119 | } 120 | // Only if the output has changed in some way 121 | guard unprocessed != output else { 122 | continue 123 | } 124 | output = (element.shouldTrim) ? output.trimmingCharacters(in: .whitespaces) : output 125 | return SwiftyLine(line: output, lineStyle: element.type) 126 | 127 | } 128 | 129 | return SwiftyLine(line: text.trimmingCharacters(in: .whitespaces), lineStyle: defaultType) 130 | } 131 | 132 | public func process( _ string : String ) -> [SwiftyLine] { 133 | var foundAttributes : [SwiftyLine] = [] 134 | for heading in string.split(separator: "\n") { 135 | 136 | if processEmptyStrings == nil, heading.isEmpty { 137 | continue 138 | } 139 | 140 | let input : SwiftyLine 141 | input = processLineLevelAttributes(String(heading)) 142 | 143 | if let existentPrevious = input.lineStyle.styleIfFoundStyleAffectsPreviousLine(), foundAttributes.count > 0 { 144 | if let idx = foundAttributes.firstIndex(of: foundAttributes.last!) { 145 | let updatedPrevious = foundAttributes.last! 146 | foundAttributes[idx] = SwiftyLine(line: updatedPrevious.line, lineStyle: existentPrevious) 147 | } 148 | continue 149 | } 150 | foundAttributes.append(input) 151 | } 152 | return foundAttributes 153 | } 154 | 155 | } 156 | 157 | 158 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Sources/Swifty Tokeniser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyTokeniser.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 16/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | import Foundation 9 | import os.log 10 | 11 | extension OSLog { 12 | private static var subsystem = "SwiftyTokeniser" 13 | static let tokenising = OSLog(subsystem: subsystem, category: "Tokenising") 14 | static let styling = OSLog(subsystem: subsystem, category: "Styling") 15 | } 16 | 17 | // Tag definition 18 | public protocol CharacterStyling { 19 | 20 | } 21 | 22 | public enum SpaceAllowed { 23 | case no 24 | case bothSides 25 | case oneSide 26 | case leadingSide 27 | case trailingSide 28 | } 29 | 30 | public enum Cancel { 31 | case none 32 | case allRemaining 33 | case currentSet 34 | } 35 | 36 | public struct CharacterRule { 37 | public let openTag : String 38 | public let intermediateTag : String? 39 | public let closingTag : String? 40 | public let escapeCharacter : Character? 41 | public let styles : [Int : [CharacterStyling]] 42 | public var maxTags : Int = 1 43 | public var spacesAllowed : SpaceAllowed = .oneSide 44 | public var cancels : Cancel = .none 45 | 46 | public init(openTag: String, intermediateTag: String? = nil, closingTag: String? = nil, escapeCharacter: Character? = nil, styles: [Int : [CharacterStyling]] = [:], maxTags : Int = 1, cancels : Cancel = .none) { 47 | self.openTag = openTag 48 | self.intermediateTag = intermediateTag 49 | self.closingTag = closingTag 50 | self.escapeCharacter = escapeCharacter 51 | self.styles = styles 52 | self.maxTags = maxTags 53 | self.cancels = cancels 54 | } 55 | } 56 | 57 | // Token definition 58 | public enum TokenType { 59 | case repeatingTag 60 | case openTag 61 | case intermediateTag 62 | case closeTag 63 | case processed 64 | case string 65 | case escape 66 | case metadata 67 | } 68 | 69 | 70 | 71 | public struct Token { 72 | public let id = UUID().uuidString 73 | public var type : TokenType 74 | public let inputString : String 75 | public var metadataString : String? = nil 76 | public var characterStyles : [CharacterStyling] = [] 77 | public var group : Int = 0 78 | public var count : Int = 0 79 | public var shouldSkip : Bool = false 80 | public var outputString : String { 81 | get { 82 | switch self.type { 83 | case .repeatingTag: 84 | if count == 0 { 85 | return "" 86 | } else { 87 | let range = inputString.startIndex.. [Token] { 114 | guard rules.count > 0 else { 115 | return [Token(type: .string, inputString: inputString)] 116 | } 117 | 118 | var currentTokens : [Token] = [] 119 | var mutableRules = self.rules 120 | while !mutableRules.isEmpty { 121 | let nextRule = mutableRules.removeFirst() 122 | if currentTokens.isEmpty { 123 | // This means it's the first time through 124 | currentTokens = self.applyStyles(to: self.scan(inputString, with: nextRule), usingRule: nextRule) 125 | continue 126 | } 127 | // Each string could have additional tokens within it, so they have to be scanned as well with the current rule. 128 | // The one string token might then be exploded into multiple more tokens 129 | var replacements : [Int : [Token]] = [:] 130 | for (idx,token) in currentTokens.enumerated() { 131 | switch token.type { 132 | case .string: 133 | 134 | if !token.shouldSkip { 135 | let nextTokens = self.scan(token.outputString, with: nextRule) 136 | replacements[idx] = self.applyStyles(to: nextTokens, usingRule: nextRule) 137 | } 138 | 139 | default: 140 | break 141 | } 142 | } 143 | // This replaces the individual string tokens with the new token arrays 144 | // making sure to apply any previously found styles to the new tokens. 145 | for key in replacements.keys.sorted(by: { $0 > $1 }) { 146 | let existingToken = currentTokens[key] 147 | var newTokens : [Token] = [] 148 | for token in replacements[key]! { 149 | var newToken = token 150 | if existingToken.metadataString != nil { 151 | newToken.metadataString = existingToken.metadataString 152 | } 153 | 154 | newToken.characterStyles.append(contentsOf: existingToken.characterStyles) 155 | newTokens.append(newToken) 156 | } 157 | currentTokens.replaceSubrange(key...key, with: newTokens) 158 | } 159 | } 160 | return currentTokens 161 | } 162 | 163 | func handleClosingTagFromOpenTag(withIndex index : Int, in tokens: inout [Token], following rule : CharacterRule ) { 164 | 165 | guard rule.closingTag != nil else { 166 | return 167 | } 168 | guard let closeTokenIdx = tokens.firstIndex(where: { $0.type == .closeTag }) else { 169 | return 170 | } 171 | 172 | var metadataIndex = index 173 | // If there's an intermediate tag, get the index of that 174 | if rule.intermediateTag != nil { 175 | guard let nextTokenIdx = tokens.firstIndex(where: { $0.type == .intermediateTag }) else { 176 | return 177 | } 178 | metadataIndex = nextTokenIdx 179 | let styles : [CharacterStyling] = rule.styles[1] ?? [] 180 | for i in index.. [Token] { 208 | var mutableTokens : [Token] = tokens 209 | print( tokens.map( { ( $0.outputString, $0.count )})) 210 | for idx in 0.. 0 else { 220 | continue 221 | } 222 | 223 | let startIdx = idx 224 | var endIdx : Int? = nil 225 | 226 | if let nextTokenIdx = mutableTokens.firstIndex(where: { $0.inputString == theToken.inputString && $0.type == theToken.type && $0.count == theToken.count && $0.id != theToken.id }) { 227 | endIdx = nextTokenIdx 228 | } 229 | guard let existentEnd = endIdx else { 230 | continue 231 | } 232 | 233 | let styles : [CharacterStyling] = rule.styles[theToken.count] ?? [] 234 | for i in startIdx.. [Token] { 288 | let scanner = Scanner(string: string) 289 | scanner.charactersToBeSkipped = nil 290 | var tokens : [Token] = [] 291 | var set = CharacterSet(charactersIn: "\(rule.openTag)\(rule.intermediateTag ?? "")\(rule.closingTag ?? "")") 292 | if let existentEscape = rule.escapeCharacter { 293 | set.insert(charactersIn: String(existentEscape)) 294 | } 295 | 296 | var openTagFound = false 297 | var openingString = "" 298 | while !scanner.isAtEnd { 299 | 300 | if #available(iOS 13.0, *) { 301 | if let start = scanner.scanUpToCharacters(from: set) { 302 | openingString.append(start) 303 | } 304 | } else { 305 | var string : NSString? 306 | scanner.scanUpToCharacters(from: set, into: &string) 307 | if let existentString = string as String? { 308 | openingString.append(existentString) 309 | } 310 | // Fallback on earlier versions 311 | } 312 | 313 | let lastChar : String? 314 | if #available(iOS 13.0, *) { 315 | lastChar = ( scanner.currentIndex > string.startIndex ) ? String(string[string.index(before: scanner.currentIndex).. string.startIndex ) ? String(string[string.index(before: scanLocation).. Bool { 461 | switch rule.spacesAllowed { 462 | case .leadingSide: 463 | guard nextCharacter != nil else { 464 | return true 465 | } 466 | if nextCharacter == " " { 467 | return false 468 | } 469 | case .trailingSide: 470 | guard previousCharacter != nil else { 471 | return true 472 | } 473 | if previousCharacter == " " { 474 | return false 475 | } 476 | case .no: 477 | switch (previousCharacter, nextCharacter) { 478 | case (nil, nil), ( " ", _ ), ( _, " " ): 479 | return false 480 | default: 481 | return true 482 | } 483 | 484 | case .oneSide: 485 | switch (previousCharacter, nextCharacter) { 486 | case (nil, " " ), (" ", nil), (" ", " " ): 487 | return false 488 | default: 489 | return true 490 | } 491 | default: 492 | break 493 | } 494 | return true 495 | } 496 | 497 | } 498 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/Sources/SwiftyMarkdown.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | enum CharacterStyle : CharacterStyling { 5 | case none 6 | case bold 7 | case italic 8 | case code 9 | case link 10 | case image 11 | } 12 | 13 | enum MarkdownLineStyle : LineStyling { 14 | var shouldTokeniseLine: Bool { 15 | switch self { 16 | case .codeblock: 17 | return false 18 | default: 19 | return true 20 | } 21 | 22 | } 23 | 24 | case h1 25 | case h2 26 | case h3 27 | case h4 28 | case h5 29 | case h6 30 | case previousH1 31 | case previousH2 32 | case body 33 | case blockquote 34 | case codeblock 35 | case unorderedList 36 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? { 37 | switch self { 38 | case .previousH1: 39 | return MarkdownLineStyle.h1 40 | case .previousH2: 41 | return MarkdownLineStyle.h2 42 | default : 43 | return nil 44 | } 45 | } 46 | } 47 | 48 | @objc public enum FontStyle : Int { 49 | case normal 50 | case bold 51 | case italic 52 | case boldItalic 53 | } 54 | 55 | @objc public protocol FontProperties { 56 | var fontName : String? { get set } 57 | var color : UIColor { get set } 58 | var fontSize : CGFloat { get set } 59 | var fontStyle : FontStyle { get set } 60 | } 61 | 62 | @objc public protocol LineProperties { 63 | var alignment : NSTextAlignment { get set } 64 | } 65 | 66 | 67 | /** 68 | A class defining the styles that can be applied to the parsed Markdown. The `fontName` property is optional, and if it's not set then the `fontName` property of the Body style will be applied. 69 | 70 | If that is not set, then the system default will be used. 71 | */ 72 | @objc open class BasicStyles : NSObject, FontProperties { 73 | public var fontName : String? 74 | public var color = UIColor.black 75 | public var fontSize : CGFloat = 0.0 76 | public var fontStyle : FontStyle = .normal 77 | } 78 | 79 | @objc open class LineStyles : NSObject, FontProperties, LineProperties { 80 | public var fontName : String? 81 | public var color = UIColor.black 82 | public var fontSize : CGFloat = 0.0 83 | public var fontStyle : FontStyle = .normal 84 | public var alignment: NSTextAlignment = .left 85 | } 86 | 87 | 88 | 89 | /// A class that takes a [Markdown](https://daringfireball.net/projects/markdown/) string or file and returns an NSAttributedString with the applied styles. Supports Dynamic Type. 90 | @objc open class SwiftyMarkdown: NSObject { 91 | static let lineRules = [ 92 | LineRule(token: "=", type: MarkdownLineStyle.previousH1, removeFrom: .entireLine, changeAppliesTo: .previous), 93 | LineRule(token: "-", type: MarkdownLineStyle.previousH2, removeFrom: .entireLine, changeAppliesTo: .previous), 94 | LineRule(token: " ", type: MarkdownLineStyle.codeblock, removeFrom: .leading, shouldTrim: false), 95 | LineRule(token: "\t", type: MarkdownLineStyle.codeblock, removeFrom: .leading, shouldTrim: false), 96 | LineRule(token: ">",type : MarkdownLineStyle.blockquote, removeFrom: .leading), 97 | LineRule(token: "- ",type : MarkdownLineStyle.unorderedList, removeFrom: .leading), 98 | LineRule(token: "###### ",type : MarkdownLineStyle.h6, removeFrom: .both), 99 | LineRule(token: "##### ",type : MarkdownLineStyle.h5, removeFrom: .both), 100 | LineRule(token: "#### ",type : MarkdownLineStyle.h4, removeFrom: .both), 101 | LineRule(token: "### ",type : MarkdownLineStyle.h3, removeFrom: .both), 102 | LineRule(token: "## ",type : MarkdownLineStyle.h2, removeFrom: .both), 103 | LineRule(token: "# ",type : MarkdownLineStyle.h1, removeFrom: .both) 104 | ] 105 | 106 | static let characterRules = [ 107 | CharacterRule(openTag: "![", intermediateTag: "](", closingTag: ")", escapeCharacter: "\\", styles: [1 : [CharacterStyle.image]], maxTags: 1), 108 | CharacterRule(openTag: "[", intermediateTag: "](", closingTag: ")", escapeCharacter: "\\", styles: [1 : [CharacterStyle.link]], maxTags: 1), 109 | CharacterRule(openTag: "`", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.code]], maxTags: 1, cancels: .allRemaining), 110 | CharacterRule(openTag: "*", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.italic], 2 : [CharacterStyle.bold], 3 : [CharacterStyle.bold, CharacterStyle.italic]], maxTags: 3), 111 | CharacterRule(openTag: "_", intermediateTag: nil, closingTag: nil, escapeCharacter: "\\", styles: [1 : [CharacterStyle.italic], 2 : [CharacterStyle.bold], 3 : [CharacterStyle.bold, CharacterStyle.italic]], maxTags: 3) 112 | ] 113 | 114 | let lineProcessor = SwiftyLineProcessor(rules: SwiftyMarkdown.lineRules, defaultRule: MarkdownLineStyle.body) 115 | let tokeniser = SwiftyTokeniser(with: SwiftyMarkdown.characterRules) 116 | 117 | /// The styles to apply to any H1 headers found in the Markdown 118 | open var h1 = LineStyles() 119 | 120 | /// The styles to apply to any H2 headers found in the Markdown 121 | open var h2 = LineStyles() 122 | 123 | /// The styles to apply to any H3 headers found in the Markdown 124 | open var h3 = LineStyles() 125 | 126 | /// The styles to apply to any H4 headers found in the Markdown 127 | open var h4 = LineStyles() 128 | 129 | /// The styles to apply to any H5 headers found in the Markdown 130 | open var h5 = LineStyles() 131 | 132 | /// The styles to apply to any H6 headers found in the Markdown 133 | open var h6 = LineStyles() 134 | 135 | /// The default body styles. These are the base styles and will be used for e.g. headers if no other styles override them. 136 | open var body = LineStyles() 137 | 138 | /// The styles to apply to any blockquotes found in the Markdown 139 | open var blockquotes = LineStyles() 140 | 141 | /// The styles to apply to any links found in the Markdown 142 | open var link = BasicStyles() 143 | 144 | /// The styles to apply to any bold text found in the Markdown 145 | open var bold = BasicStyles() 146 | 147 | /// The styles to apply to any italic text found in the Markdown 148 | open var italic = BasicStyles() 149 | 150 | /// The styles to apply to any code blocks or inline code text found in the Markdown 151 | open var code = BasicStyles() 152 | 153 | 154 | 155 | public var underlineLinks : Bool = false 156 | 157 | var currentType : MarkdownLineStyle = .body 158 | 159 | 160 | let string : String 161 | 162 | let tagList = "!\\_*`[]()" 163 | let validMarkdownTags = CharacterSet(charactersIn: "!\\_*`[]()") 164 | 165 | 166 | /** 167 | 168 | - parameter string: A string containing [Markdown](https://daringfireball.net/projects/markdown/) syntax to be converted to an NSAttributedString 169 | 170 | - returns: An initialized SwiftyMarkdown object 171 | */ 172 | public init(string : String ) { 173 | self.string = string 174 | super.init() 175 | if #available(iOS 13.0, *) { 176 | self.setFontColorForAllStyles(with: .label) 177 | } 178 | } 179 | 180 | /** 181 | A failable initializer that takes a URL and attempts to read it as a UTF-8 string 182 | 183 | - parameter url: The location of the file to read 184 | 185 | - returns: An initialized SwiftyMarkdown object, or nil if the string couldn't be read 186 | */ 187 | public init?(url : URL ) { 188 | 189 | do { 190 | self.string = try NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String 191 | 192 | } catch { 193 | self.string = "" 194 | return nil 195 | } 196 | super.init() 197 | if #available(iOS 13.0, *) { 198 | self.setFontColorForAllStyles(with: .label) 199 | } 200 | } 201 | 202 | /** 203 | Set font size for all styles 204 | 205 | - parameter size: size of font 206 | */ 207 | open func setFontSizeForAllStyles(with size: CGFloat) { 208 | h1.fontSize = size 209 | h2.fontSize = size 210 | h3.fontSize = size 211 | h4.fontSize = size 212 | h5.fontSize = size 213 | h6.fontSize = size 214 | body.fontSize = size 215 | italic.fontSize = size 216 | bold.fontSize = size 217 | code.fontSize = size 218 | link.fontSize = size 219 | link.fontSize = size 220 | } 221 | 222 | open func setFontColorForAllStyles(with color: UIColor) { 223 | h1.color = color 224 | h2.color = color 225 | h3.color = color 226 | h4.color = color 227 | h5.color = color 228 | h6.color = color 229 | body.color = color 230 | italic.color = color 231 | bold.color = color 232 | code.color = color 233 | link.color = color 234 | blockquotes.color = color 235 | } 236 | 237 | open func setFontNameForAllStyles(with name: String) { 238 | h1.fontName = name 239 | h2.fontName = name 240 | h3.fontName = name 241 | h4.fontName = name 242 | h5.fontName = name 243 | h6.fontName = name 244 | body.fontName = name 245 | italic.fontName = name 246 | bold.fontName = name 247 | code.fontName = name 248 | link.fontName = name 249 | blockquotes.fontName = name 250 | } 251 | 252 | 253 | 254 | /** 255 | Generates an NSAttributedString from the string or URL passed at initialisation. Custom fonts or styles are applied to the appropriate elements when this method is called. 256 | 257 | - returns: An NSAttributedString with the styles applied 258 | */ 259 | open func attributedString() -> NSAttributedString { 260 | let attributedString = NSMutableAttributedString(string: "") 261 | self.lineProcessor.processEmptyStrings = MarkdownLineStyle.body 262 | let foundAttributes : [SwiftyLine] = lineProcessor.process(self.string) 263 | 264 | for line in foundAttributes { 265 | let finalTokens = self.tokeniser.process(line.line) 266 | attributedString.append(attributedStringFor(tokens: finalTokens, in: line)) 267 | attributedString.append(NSAttributedString(string: "\n")) 268 | } 269 | return attributedString 270 | } 271 | 272 | 273 | } 274 | 275 | extension SwiftyMarkdown { 276 | 277 | func font( for line : SwiftyLine, characterOverride : CharacterStyle? = nil ) -> UIFont { 278 | let textStyle : UIFont.TextStyle 279 | var fontName : String? 280 | var fontSize : CGFloat? 281 | 282 | var globalBold = false 283 | var globalItalic = false 284 | 285 | let style : FontProperties 286 | // What type are we and is there a font name set? 287 | switch line.lineStyle as! MarkdownLineStyle { 288 | case .h1: 289 | style = self.h1 290 | if #available(iOS 9, *) { 291 | textStyle = UIFont.TextStyle.title1 292 | } else { 293 | textStyle = UIFont.TextStyle.headline 294 | } 295 | case .h2: 296 | style = self.h2 297 | if #available(iOS 9, *) { 298 | textStyle = UIFont.TextStyle.title2 299 | } else { 300 | textStyle = UIFont.TextStyle.headline 301 | } 302 | case .h3: 303 | style = self.h3 304 | if #available(iOS 9, *) { 305 | textStyle = UIFont.TextStyle.title2 306 | } else { 307 | textStyle = UIFont.TextStyle.subheadline 308 | } 309 | case .h4: 310 | style = self.h4 311 | textStyle = UIFont.TextStyle.headline 312 | case .h5: 313 | style = self.h5 314 | textStyle = UIFont.TextStyle.subheadline 315 | case .h6: 316 | style = self.h6 317 | textStyle = UIFont.TextStyle.footnote 318 | case .codeblock: 319 | style = self.code 320 | textStyle = UIFont.TextStyle.body 321 | case .blockquote: 322 | style = self.blockquotes 323 | textStyle = UIFont.TextStyle.body 324 | default: 325 | style = self.body 326 | textStyle = UIFont.TextStyle.body 327 | } 328 | 329 | fontName = style.fontName 330 | fontSize = style.fontSize 331 | switch style.fontStyle { 332 | case .bold: 333 | globalBold = true 334 | case .italic: 335 | globalItalic = true 336 | case .boldItalic: 337 | globalItalic = true 338 | globalBold = true 339 | case .normal: 340 | break 341 | } 342 | 343 | if fontName == nil { 344 | fontName = body.fontName 345 | } 346 | 347 | if let characterOverride = characterOverride { 348 | switch characterOverride { 349 | case .code: 350 | fontName = code.fontName ?? fontName 351 | fontSize = code.fontSize 352 | case .link: 353 | fontName = link.fontName ?? fontName 354 | fontSize = link.fontSize 355 | case .bold: 356 | fontName = bold.fontName ?? fontName 357 | fontSize = bold.fontSize 358 | globalBold = true 359 | case .italic: 360 | fontName = italic.fontName ?? fontName 361 | fontSize = italic.fontSize 362 | globalItalic = true 363 | default: 364 | break 365 | } 366 | } 367 | 368 | fontSize = fontSize == 0.0 ? nil : fontSize 369 | var font : UIFont 370 | if let existentFontName = fontName { 371 | font = UIFont.preferredFont(forTextStyle: textStyle) 372 | let finalSize : CGFloat 373 | if let existentFontSize = fontSize { 374 | finalSize = existentFontSize 375 | } else { 376 | let styleDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) 377 | finalSize = styleDescriptor.fontAttributes[.size] as? CGFloat ?? CGFloat(14) 378 | } 379 | 380 | if let customFont = UIFont(name: existentFontName, size: finalSize) { 381 | let fontMetrics = UIFontMetrics(forTextStyle: textStyle) 382 | font = fontMetrics.scaledFont(for: customFont) 383 | } else { 384 | font = UIFont.preferredFont(forTextStyle: textStyle) 385 | } 386 | } else { 387 | font = UIFont.preferredFont(forTextStyle: textStyle) 388 | } 389 | 390 | if globalItalic, let italicDescriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) { 391 | font = UIFont(descriptor: italicDescriptor, size: 0) 392 | } 393 | if globalBold, let boldDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) { 394 | font = UIFont(descriptor: boldDescriptor, size: 0) 395 | } 396 | 397 | return font 398 | 399 | } 400 | 401 | func color( for line : SwiftyLine ) -> UIColor { 402 | // What type are we and is there a font name set? 403 | switch line.lineStyle as! MarkdownLineStyle { 404 | case .h1, .previousH1: 405 | return h1.color 406 | case .h2, .previousH2: 407 | return h2.color 408 | case .h3: 409 | return h3.color 410 | case .h4: 411 | return h4.color 412 | case .h5: 413 | return h5.color 414 | case .h6: 415 | return h6.color 416 | case .body: 417 | return body.color 418 | case .codeblock: 419 | return code.color 420 | case .blockquote: 421 | return blockquotes.color 422 | case .unorderedList: 423 | return body.color 424 | } 425 | } 426 | 427 | func attributedStringFor( tokens : [Token], in line : SwiftyLine ) -> NSAttributedString { 428 | 429 | var finalTokens = tokens 430 | let finalAttributedString = NSMutableAttributedString() 431 | var attributes : [NSAttributedString.Key : AnyObject] = [:] 432 | 433 | 434 | let lineProperties : LineProperties 435 | switch line.lineStyle as! MarkdownLineStyle { 436 | case .h1: 437 | lineProperties = self.h1 438 | case .h2: 439 | lineProperties = self.h2 440 | case .h3: 441 | lineProperties = self.h3 442 | case .h4: 443 | lineProperties = self.h4 444 | case .h5: 445 | lineProperties = self.h5 446 | case .h6: 447 | lineProperties = self.h6 448 | 449 | case .codeblock: 450 | lineProperties = body 451 | let paragraphStyle = NSMutableParagraphStyle() 452 | paragraphStyle.firstLineHeadIndent = 20.0 453 | attributes[.paragraphStyle] = paragraphStyle 454 | case .blockquote: 455 | lineProperties = self.blockquotes 456 | let paragraphStyle = NSMutableParagraphStyle() 457 | paragraphStyle.firstLineHeadIndent = 20.0 458 | attributes[.paragraphStyle] = paragraphStyle 459 | case .unorderedList: 460 | lineProperties = body 461 | finalTokens.insert(Token(type: .string, inputString: "・ "), at: 0) 462 | default: 463 | lineProperties = body 464 | break 465 | } 466 | 467 | if lineProperties.alignment != .left { 468 | let paragraphStyle = NSMutableParagraphStyle() 469 | paragraphStyle.alignment = lineProperties.alignment 470 | attributes[.paragraphStyle] = paragraphStyle 471 | } 472 | 473 | 474 | for token in finalTokens { 475 | attributes[.font] = self.font(for: line) 476 | attributes[.foregroundColor] = self.color(for: line) 477 | guard let styles = token.characterStyles as? [CharacterStyle] else { 478 | continue 479 | } 480 | if styles.contains(.italic) { 481 | attributes[.font] = self.font(for: line, characterOverride: .italic) 482 | attributes[.foregroundColor] = self.italic.color 483 | } 484 | if styles.contains(.bold) { 485 | attributes[.font] = self.font(for: line, characterOverride: .bold) 486 | attributes[.foregroundColor] = self.bold.color 487 | } 488 | 489 | if styles.contains(.link), let url = token.metadataString { 490 | attributes[.foregroundColor] = self.link.color 491 | attributes[.font] = self.font(for: line, characterOverride: .link) 492 | attributes[.link] = url as AnyObject 493 | 494 | if underlineLinks { 495 | attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue as AnyObject 496 | } 497 | } 498 | 499 | if styles.contains(.image), let imageName = token.metadataString { 500 | let image1Attachment = NSTextAttachment() 501 | image1Attachment.image = UIImage(named: imageName) 502 | let str = NSAttributedString(attachment: image1Attachment) 503 | finalAttributedString.append(str) 504 | continue 505 | } 506 | 507 | if styles.contains(.code) { 508 | attributes[.foregroundColor] = self.code.color 509 | attributes[.font] = self.font(for: line, characterOverride: .code) 510 | } else { 511 | // Switch back to previous font 512 | } 513 | let str = NSAttributedString(string: token.outputString, attributes: attributes) 514 | finalAttributedString.append(str) 515 | } 516 | 517 | return finalAttributedString 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /Playground/SwiftyMarkdown.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftyMarkdown 1.0 2 | 3 | SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a Swift-style syntax. It uses dynamic type to set the font size correctly with whatever font you'd like to use. 4 | 5 | - [What's New](#fully-rebuilt-for-2020) 6 | - [Installation](#installation) 7 | - [How to Use](#how-to-use-swiftymarkdown) 8 | - [Screenshot](#screenshot) 9 | - [Front Matter](#front-matter) 10 | - [Appendix](#appendix) 11 | 12 | ## Fully Rebuilt For 2020! 13 | 14 | SwiftyMarkdown now features a more robust and reliable rules-based line processing and character tokenisation engine. It has added support for images stored in the bundle (`![Image]()`), codeblocks, blockquotes, and unordered lists! 15 | 16 | Line-level attributes can now have a paragraph alignment applied to them (e.g. `h2.aligment = .center`), and links can be optionally underlined by setting `underlineLinks` to `true`. 17 | 18 | It also uses the system color `.label` as the default font color on iOS 13 and above for Dark Mode support out of the box. 19 | 20 | Support for all of Apple's platforms has been enabled. 21 | 22 | ## Installation 23 | 24 | ### CocoaPods: 25 | 26 | `pod 'SwiftyMarkdown'` 27 | 28 | ### SPM: 29 | 30 | In Xcode, `File -> Swift Packages -> Add Package Dependency` and add the GitHub URL. 31 | 32 | ## How To Use SwiftyMarkdown 33 | 34 | Read Markdown from a text string... 35 | 36 | ```swift 37 | let md = SwiftyMarkdown(string: "# Heading\nMy *Markdown* string") 38 | md.attributedString() 39 | ``` 40 | 41 | ...or from a URL. 42 | 43 | ```swift 44 | if let url = Bundle.main.url(forResource: "file", withExtension: "md"), md = SwiftyMarkdown(url: url ) { 45 | md.attributedString() 46 | } 47 | ``` 48 | 49 | If you want to use a different string once SwiftyMarkdown has been initialised, you can now do so like this: 50 | 51 | ```swift 52 | let md = SwiftyMarkdown(string: "# Heading\nMy *Markdown* string") 53 | md.attributedString(from: "A **SECOND** Markdown string. *Fancy!*") 54 | ``` 55 | 56 | The attributed string can then be assigned to any label or text control that has support for attributed text. 57 | 58 | ```swift 59 | let md = SwiftyMarkdown(string: "# Heading\nMy *Markdown* string") 60 | let label = UILabel() 61 | label.attributedText = md.attributedString() 62 | ``` 63 | 64 | ## Supported Markdown Features 65 | 66 | *italics* or _italics_ 67 | **bold** or __bold__ 68 | ~~Linethrough~~Strikethroughs. 69 | `code` 70 | 71 | # Header 1 72 | 73 | or 74 | 75 | Header 1 76 | ==== 77 | 78 | ## Header 2 79 | 80 | or 81 | 82 | Header 2 83 | --- 84 | 85 | ### Header 3 86 | #### Header 4 87 | ##### Header 5 ##### 88 | ###### Header 6 ###### 89 | 90 | Indented code blocks (spaces or tabs) 91 | 92 | [Links](http://voyagetravelapps.com/) 93 | ![Images]() 94 | 95 | [Referenced Links][1] 96 | ![Referenced Images][2] 97 | 98 | [1]: http://voyagetravelapps.com/ 99 | [2]: 100 | 101 | > Blockquotes 102 | 103 | - Bulleted 104 | - Lists 105 | - Including indented lists 106 | - Up to three levels 107 | - Neat! 108 | 109 | 1. Ordered 110 | 1. Lists 111 | 1. Including indented lists 112 | - Up to three levels 113 | 114 | 115 | 116 | Compound rules also work, for example: 117 | 118 | It recognises **[Bold Links](http://voyagetravelapps.com/)** 119 | 120 | Or [**Bold Links**](http://voyagetravelapps.com/) 121 | 122 | Images will be inserted into the returned `NSAttributedString` as an `NSTextAttachment` (sadly, this will not work on watchOS as `NSTextAttachment` is not available). 123 | 124 | ## Customisation 125 | 126 | Set the attributes of every paragraph and character style type using straightforward dot syntax: 127 | 128 | ```swift 129 | md.body.fontName = "AvenirNextCondensed-Medium" 130 | 131 | md.h1.color = UIColor.redColor() 132 | md.h1.fontName = "AvenirNextCondensed-Bold" 133 | md.h1.fontSize = 16 134 | md.h1.alignmnent = .center 135 | 136 | md.italic.color = UIColor.blueColor() 137 | 138 | md.underlineLinks = true 139 | 140 | md.bullet = "🍏" 141 | ``` 142 | 143 | On iOS, Specified font sizes will be adjusted relative to the the user's dynamic type settings. 144 | 145 | ## Screenshot 146 | 147 | ![Screenshot](https://cl.ly/779e6964257a/swiftymarkdown-2020.png) 148 | 149 | There's an example project included in the repository. Open the `Example/SwiftyMarkdown.xcodeproj` file to get started. 150 | 151 | ## Front Matter 152 | 153 | SwiftyMarkdown recognises YAML front matter and will populate the `frontMatterAttributes` property with the key-value pairs that it fines. 154 | 155 | ## Appendix 156 | 157 | ### A) All Customisable Properties 158 | 159 | ```swift 160 | h1.fontName : String 161 | h1.fontSize : CGFloat 162 | h1.color : UI/NSColor 163 | h1.fontStyle : FontStyle 164 | h1.alignment : NSTextAlignment 165 | 166 | h2.fontName : String 167 | h2.fontSize : CGFloat 168 | h2.color : UI/NSColor 169 | h2.fontStyle : FontStyle 170 | h2.alignment : NSTextAlignment 171 | 172 | h3.fontName : String 173 | h3.fontSize : CGFloat 174 | h3.color : UI/NSColor 175 | h3.fontStyle : FontStyle 176 | h3.alignment : NSTextAlignment 177 | 178 | h4.fontName : String 179 | h4.fontSize : CGFloat 180 | h4.color : UI/NSColor 181 | h4.fontStyle : FontStyle 182 | h4.alignment : NSTextAlignment 183 | 184 | h5.fontName : String 185 | h5.fontSize : CGFloat 186 | h5.color : UI/NSColor 187 | h5.fontStyle : FontStyle 188 | h5.alignment : NSTextAlignment 189 | 190 | h6.fontName : String 191 | h6.fontSize : CGFloat 192 | h6.color : UI/NSColor 193 | h6.fontStyle : FontStyle 194 | h6.alignment : NSTextAlignment 195 | 196 | body.fontName : String 197 | body.fontSize : CGFloat 198 | body.color : UI/NSColor 199 | body.fontStyle : FontStyle 200 | body.alignment : NSTextAlignment 201 | 202 | blockquotes.fontName : String 203 | blockquotes.fontSize : CGFloat 204 | blockquotes.color : UI/NSColor 205 | blockquotes.fontStyle : FontStyle 206 | blockquotes.alignment : NSTextAlignment 207 | 208 | link.fontName : String 209 | link.fontSize : CGFloat 210 | link.color : UI/NSColor 211 | link.fontStyle : FontStyle 212 | 213 | bold.fontName : String 214 | bold.fontSize : CGFloat 215 | bold.color : UI/NSColor 216 | bold.fontStyle : FontStyle 217 | 218 | italic.fontName : String 219 | italic.fontSize : CGFloat 220 | italic.color : UI/NSColor 221 | italic.fontStyle : FontStyle 222 | 223 | code.fontName : String 224 | code.fontSize : CGFloat 225 | code.color : UI/NSColor 226 | code.fontStyle : FontStyle 227 | 228 | strikethrough.fontName : String 229 | strikethrough.fontSize : CGFloat 230 | strikethrough.color : UI/NSColor 231 | strikethrough.fontStyle : FontStyle 232 | 233 | underlineLinks : Bool 234 | 235 | bullet : String 236 | ``` 237 | 238 | `FontStyle` is an enum with these cases: `normal`, `bold`, `italic`, and `bolditalic` to give you more precise control over how lines and character styles should look. For example, perhaps you want blockquotes to default to having the italic style: 239 | 240 | ```swift 241 | md.blockquotes.fontStyle = .italic 242 | ``` 243 | Or, if you like a bit of chaos: 244 | 245 | ```swift 246 | md.bold.fontStyle = .italic 247 | md.italic.fontStyle = .bold 248 | ``` 249 | 250 | ### B) Advanced Customisation 251 | 252 | SwiftyMarkdown uses a rules-based line processing and customisation engine that is no longer limited to Markdown. Rules are processed in order, from top to bottom. Line processing happens first, then character styles are applied based on the character rules. 253 | 254 | For example, here's how a small subset of Markdown line tags are set up within SwiftyMarkdown: 255 | 256 | ```swift 257 | enum MarkdownLineStyle : LineStyling { 258 | case h1 259 | case h2 260 | case previousH1 261 | case codeblock 262 | case body 263 | 264 | var shouldTokeniseLine: Bool { 265 | switch self { 266 | case .codeblock: 267 | return false 268 | default: 269 | return true 270 | } 271 | } 272 | 273 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? { 274 | switch self { 275 | case .previousH1: 276 | return MarkdownLineStyle.h1 277 | default : 278 | return nil 279 | } 280 | } 281 | } 282 | 283 | static public var lineRules = [ 284 | LineRule(token: " ",type : MarkdownLineStyle.codeblock, removeFrom: .leading), 285 | LineRule(token: "=",type : MarkdownLineStyle.previousH1, removeFrom: .entireLine, changeAppliesTo: .previous), 286 | LineRule(token: "## ",type : MarkdownLineStyle.h2, removeFrom: .both), 287 | LineRule(token: "# ",type : MarkdownLineStyle.h1, removeFrom: .both) 288 | ] 289 | 290 | let lineProcessor = SwiftyLineProcessor(rules: SwiftyMarkdown.lineRules, default: MarkdownLineStyle.body) 291 | ``` 292 | 293 | Similarly, the character styles all follow rules: 294 | 295 | ```swift 296 | enum CharacterStyle : CharacterStyling { 297 | case link, bold, italic, code 298 | } 299 | 300 | static public var characterRules = [ 301 | CharacterRule(primaryTag: CharacterRuleTag(tag: "[", type: .open), otherTags: [ 302 | CharacterRuleTag(tag: "]", type: .close), 303 | CharacterRuleTag(tag: "[", type: .metadataOpen), 304 | CharacterRuleTag(tag: "]", type: .metadataClose) 305 | ], styles: [1 : CharacterStyle.link], metadataLookup: true, definesBoundary: true), 306 | CharacterRule(primaryTag: CharacterRuleTag(tag: "`", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.code], shouldCancelRemainingTags: true, balancedTags: true), 307 | CharacterRule(primaryTag: CharacterRuleTag(tag: "*", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.italic, 2 : CharacterStyle.bold], minTags:1 , maxTags:2), 308 | CharacterRule(primaryTag: CharacterRuleTag(tag: "_", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.italic, 2 : CharacterStyle.bold], minTags:1 , maxTags:2) 309 | ] 310 | ``` 311 | 312 | These Character Rules are defined by SwiftyMarkdown: 313 | 314 | public struct CharacterRule : CustomStringConvertible { 315 | 316 | public let primaryTag : CharacterRuleTag 317 | public let tags : [CharacterRuleTag] 318 | public let escapeCharacters : [Character] 319 | public let styles : [Int : CharacterStyling] 320 | public let minTags : Int 321 | public let maxTags : Int 322 | public var metadataLookup : Bool = false 323 | public var definesBoundary = false 324 | public var shouldCancelRemainingRules = false 325 | public var balancedTags = false 326 | } 327 | 328 | 1. `primaryTag`: Each rule must have at least one tag and it can be one of `repeating`, `open`, `close`, `metadataOpen`, or `metadataClose`. `repeating` tags are tags that have identical open and close characters (and often have more than 1 style depending on how many are in a group). For example, the `*` tag used in Markdown. 329 | 2. `tags`: An array of other tags that the rule can look for. This is where you would put the `close` tag for a custom rule, for example. 330 | 3. `escapeCharacters`: The characters that appear prior to any of the tag characters that tell the scanner to ignore the tag. 331 | 4. `styles`: The styles that should be applied to every character between the opening and closing tags. 332 | 5. `minTags`: The minimum number of repeating characters to be considered a successful match. For example, setting the `primaryTag` to `*` and the `minTag` to 2 would mean that `**foo**` would be a successful match wheras `*bar*` would not. 333 | 6. `maxTags`: The maximum number of repeating characters to be considered a successful match. 334 | 7. `metadataLookup`: Used for Markdown reference links. Tells the scanner to try to look up the metadata from this dictionary, rather than from the inline result. 335 | 8. `definesBoundary`: In order for open and close tags to be successful, the `boundaryCount` for a given location in the string needs to be the same. Setting this property to `true` means that this rule will increase the `boundaryCount` for every character between its opening and closing tags. For example, the `[` rule defines a boundary. After it is applied, the string `*foo[bar*]` becomes `*foobar*` with a boundaryCount `00001111`. Applying the `*` rule results in the output `*foobar*` because the opening `*` tag and the closing `*` tag now have different `boundaryCount` values. It's basically a way to fix the `**[should not be bold**](url)` problem in Markdown. 336 | 9. `shouldCancelRemainingTags`: A successful match will mark every character between the opening and closing tags as complete, thereby preventing any further rules from being applied to those characters. 337 | 10. `balancedTags`: This flag requires that the opening and closing tags be of exactly equal length. E.g. If this is set to true, `**foo*` would result in `**foo*`. If it was false, the output would be `*foo`. 338 | 339 | 340 | 341 | #### Rule Subsets 342 | 343 | If you want to only support a small subset of Markdown, it's now easy to do. 344 | 345 | This example would only process strings with `*` and `_` characters, ignoring links, images, code, and all line-level attributes (headings, blockquotes, etc.) 346 | ```swift 347 | SwiftyMarkdown.lineRules = [] 348 | 349 | SwiftyMarkdown.characterRules = [ 350 | CharacterRule(primaryTag: CharacterRuleTag(tag: "*", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.italic, 2 : CharacterStyle.bold], minTags:1 , maxTags:2), 351 | CharacterRule(primaryTag: CharacterRuleTag(tag: "_", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.italic, 2 : CharacterStyle.bold], minTags:1 , maxTags:2) 352 | ] 353 | ``` 354 | 355 | #### Custom Rules 356 | 357 | If you wanted to create a rule that applied a style of `Elf` to a range of characters between "The elf will speak now: %Here is my elf speaking%", you could set things up like this: 358 | 359 | ```swift 360 | enum Characters : CharacterStyling { 361 | case elf 362 | 363 | func isEqualTo( _ other : CharacterStyling) -> Bool { 364 | if let other = other as? Characters else { 365 | return false 366 | } 367 | return other == self 368 | } 369 | } 370 | 371 | let characterRules = [ 372 | CharacterRule(primaryTag: CharacterRuleTag(tag: "%", type: .repeating), otherTags: [], styles: [1 : CharacterStyle.elf]) 373 | ] 374 | 375 | let processor = SwiftyTokeniser( with : characterRules ) 376 | let string = "The elf will speak now: %Here is my elf speaking%" 377 | let tokens = processor.process(string) 378 | ``` 379 | 380 | The output is an array of tokens would be equivalent to: 381 | 382 | ```swift 383 | [ 384 | Token(type: .string, inputString: "The elf will speak now: ", characterStyles: []), 385 | Token(type: .repeatingTag, inputString: "%", characterStyles: []), 386 | Token(type: .string, inputString: "Here is my elf speaking", characterStyles: [.elf]), 387 | Token(type: .repeatingTag, inputString: "%", characterStyles: []) 388 | ] 389 | ``` 390 | 391 | ### C) SpriteKit Support 392 | 393 | Did you know that `SKLabelNode` supports attributed text? I didn't. 394 | 395 | ```swift 396 | let smd = SwiftyMarkdown(string: "My Character's **Dialogue**") 397 | 398 | let label = SKLabelNode() 399 | label.preferredMaxLayoutWidth = 500 400 | label.numberOfLines = 0 401 | label.attributedText = smd.attributedString() 402 | ``` 403 | 404 | -------------------------------------------------------------------------------- /Resources/metadataTest.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: "Trail Wallet FAQ" 4 | date: 2015-04-22 10:59 5 | comments: true 6 | sharing: true 7 | liking: false 8 | footer: true 9 | sidebar: false 10 | --- 11 | 12 | # Good Day To You, Walleteer! 13 | 14 | We are Erin and Simon from [Never Ending Voyage][1] and we want to thank you for trying out our app. We have been travelling non-stop for seven years and part of how we support ourselves is through Trail Wallet. 15 | -------------------------------------------------------------------------------- /Resources/test.md: -------------------------------------------------------------------------------- 1 | # SwiftyMarkdown 1.0 2 | 3 | SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a Swift-style syntax. It uses dynamic type to set the font size correctly with whatever font you'd like to use. 4 | 5 | ## Fully Rebuilt For 2020! 6 | 7 | SwiftyMarkdown now features a more robust and reliable rules-based line processing and tokenisation engine. It has added support for images stored in the bundle (`![Image]()`), codeblocks, blockquotes, and unordered lists! 8 | 9 | Line-level attributes can now have a paragraph alignment applied to them (e.g. `h2.aligment = .center`), and links can be underlined by setting underlineLinks to `true`. 10 | 11 | It also uses the system color `.label` as the default font color on iOS 13 and above for Dark Mode support out of the box. 12 | 13 | ## Installation 14 | 15 | ### CocoaPods: 16 | 17 | `pod 'SwiftyMarkdown'` 18 | 19 | ### SPM: 20 | 21 | In Xcode, `File -> Swift Packages -> Add Package Dependency` and add the GitHub URL. 22 | 23 | *italics* or _italics_ 24 | **bold** or __bold__ 25 | ~~Linethrough~~Strikethroughs. 26 | `code` 27 | 28 | # Header 1 29 | 30 | or 31 | 32 | Header 1 33 | ==== 34 | 35 | ## Header 2 36 | 37 | or 38 | 39 | Header 2 40 | --- 41 | 42 | ### Header 3 43 | #### Header 4 44 | ##### Header 5 ##### 45 | ###### Header 6 ###### 46 | 47 | Indented code blocks (spaces or tabs) 48 | 49 | [Links](http://voyagetravelapps.com/) 50 | ![Images]() 51 | 52 | > Blockquotes 53 | 54 | - Bulleted 55 | - Lists 56 | - Including indented lists 57 | - Up to three levels 58 | - Neat! 59 | 60 | 1. Ordered 61 | 1. Lists 62 | 1. Including indented lists 63 | - Up to three levels 64 | 1. Neat! 65 | 66 | # SwiftyMarkdown 1.0 67 | 68 | SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a Swift-style syntax. It uses dynamic type to set the font size correctly with whatever font you'd like to use. 69 | 70 | ## Fully Rebuilt For 2020! 71 | 72 | SwiftyMarkdown now features a more robust and reliable rules-based line processing and tokenisation engine. It has added support for images stored in the bundle (`![Image]()`), codeblocks, blockquotes, and unordered lists! 73 | 74 | Line-level attributes can now have a paragraph alignment applied to them (e.g. `h2.aligment = .center`), and links can be underlined by setting underlineLinks to `true`. 75 | 76 | It also uses the system color `.label` as the default font color on iOS 13 and above for Dark Mode support out of the box. 77 | 78 | ## Installation 79 | 80 | ### CocoaPods: 81 | 82 | `pod 'SwiftyMarkdown'` 83 | 84 | ### SPM: 85 | 86 | In Xcode, `File -> Swift Packages -> Add Package Dependency` and add the GitHub URL. 87 | 88 | *italics* or _italics_ 89 | **bold** or __bold__ 90 | ~~Linethrough~~Strikethroughs. 91 | `code` 92 | 93 | # Header 1 94 | 95 | or 96 | 97 | Header 1 98 | ==== 99 | 100 | ## Header 2 101 | 102 | or 103 | 104 | Header 2 105 | --- 106 | 107 | ### Header 3 108 | #### Header 4 109 | ##### Header 5 ##### 110 | ###### Header 6 ###### 111 | 112 | Indented code blocks (spaces or tabs) 113 | 114 | [Links](http://voyagetravelapps.com/) 115 | ![Images]() 116 | 117 | > Blockquotes 118 | 119 | - Bulleted 120 | - Lists 121 | - Including indented lists 122 | - Up to three levels 123 | - Neat! 124 | 125 | 1. Ordered 126 | 1. Lists 127 | 1. Including indented lists 128 | - Up to three levels 129 | 1. Neat! 130 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/CharacterRule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CharacterRule.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 04/02/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SpaceAllowed { 11 | case no 12 | case bothSides 13 | case oneSide 14 | case leadingSide 15 | case trailingSide 16 | } 17 | 18 | public enum Cancel { 19 | case none 20 | case allRemaining 21 | case currentSet 22 | } 23 | 24 | public enum CharacterRuleTagType { 25 | case open 26 | case close 27 | case metadataOpen 28 | case metadataClose 29 | case repeating 30 | } 31 | 32 | 33 | public struct CharacterRuleTag { 34 | let tag : String 35 | let type : CharacterRuleTagType 36 | 37 | public init( tag : String, type : CharacterRuleTagType ) { 38 | self.tag = tag 39 | self.type = type 40 | } 41 | } 42 | 43 | public struct CharacterRule : CustomStringConvertible { 44 | 45 | public let primaryTag : CharacterRuleTag 46 | public let tags : [CharacterRuleTag] 47 | public let escapeCharacters : [Character] 48 | public let styles : [Int : CharacterStyling] 49 | public let minTags : Int 50 | public let maxTags : Int 51 | public var metadataLookup : Bool = false 52 | public var isRepeatingTag : Bool { 53 | return self.primaryTag.type == .repeating 54 | } 55 | public var definesBoundary = false 56 | public var shouldCancelRemainingRules = false 57 | public var balancedTags = false 58 | 59 | public var description: String { 60 | return "Character Rule with Open tag: \(self.primaryTag.tag) and current styles : \(self.styles) " 61 | } 62 | 63 | public func tag( for type : CharacterRuleTagType ) -> CharacterRuleTag? { 64 | return self.tags.filter({ $0.type == type }).first ?? nil 65 | } 66 | 67 | public init(primaryTag: CharacterRuleTag, otherTags: [CharacterRuleTag], escapeCharacters : [Character] = ["\\"], styles: [Int : CharacterStyling] = [:], minTags : Int = 1, maxTags : Int = 1, metadataLookup : Bool = false, definesBoundary : Bool = false, shouldCancelRemainingRules : Bool = false, balancedTags : Bool = false) { 68 | self.primaryTag = primaryTag 69 | self.tags = otherTags 70 | self.escapeCharacters = escapeCharacters 71 | self.styles = styles 72 | self.metadataLookup = metadataLookup 73 | self.definesBoundary = definesBoundary 74 | self.shouldCancelRemainingRules = shouldCancelRemainingRules 75 | self.minTags = maxTags < minTags ? maxTags : minTags 76 | self.maxTags = minTags > maxTags ? minTags : maxTags 77 | self.balancedTags = balancedTags 78 | } 79 | } 80 | 81 | 82 | 83 | enum ElementType { 84 | case tag 85 | case escape 86 | case string 87 | case space 88 | case newline 89 | case metadata 90 | } 91 | 92 | struct Element { 93 | let character : Character 94 | var type : ElementType 95 | var boundaryCount : Int = 0 96 | var isComplete : Bool = false 97 | var styles : [CharacterStyling] = [] 98 | var metadata : [String] = [] 99 | } 100 | 101 | extension CharacterSet { 102 | func containsUnicodeScalars(of character: Character) -> Bool { 103 | return character.unicodeScalars.allSatisfy(contains(_:)) 104 | } 105 | } 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/PerfomanceLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PerfomanceLog.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 04/02/2020. 6 | // 7 | 8 | import Foundation 9 | import os.log 10 | 11 | class PerformanceLog { 12 | var timer : TimeInterval = 0 13 | let enablePerfomanceLog : Bool 14 | let log : OSLog 15 | let identifier : String 16 | 17 | init( with environmentVariableName : String, identifier : String, log : OSLog ) { 18 | self.log = log 19 | self.enablePerfomanceLog = (ProcessInfo.processInfo.environment[environmentVariableName] != nil) 20 | self.identifier = identifier 21 | } 22 | 23 | func start() { 24 | guard enablePerfomanceLog else { return } 25 | self.timer = Date().timeIntervalSinceReferenceDate 26 | os_log("--- TIMER %{public}@ began", log: self.log, type: .info, self.identifier) 27 | } 28 | 29 | func tag( with string : String) { 30 | guard enablePerfomanceLog else { return } 31 | if timer == 0 { 32 | self.start() 33 | } 34 | os_log("TIMER %{public}@: %f %@", log: self.log, type: .info, self.identifier, Date().timeIntervalSinceReferenceDate - self.timer, string) 35 | } 36 | 37 | func end() { 38 | guard enablePerfomanceLog else { return } 39 | self.timer = Date().timeIntervalSinceReferenceDate 40 | os_log("--- TIMER %{public}@ finished. Total time: %f", log: self.log, type: .info, self.identifier, Date().timeIntervalSinceReferenceDate - self.timer) 41 | self.timer = 0 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/String+SwiftyMarkdown.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+SwiftyMarkdown.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 08/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Some helper functions based on this: 12 | /// https://stackoverflow.com/questions/32305891/index-of-a-substring-in-a-string-with-swift/32306142#32306142 13 | extension StringProtocol { 14 | func index(of string: S, options: String.CompareOptions = []) -> Index? { 15 | range(of: string, options: options)?.lowerBound 16 | } 17 | func endIndex(of string: S, options: String.CompareOptions = []) -> Index? { 18 | range(of: string, options: options)?.upperBound 19 | } 20 | func indices(of string: S, options: String.CompareOptions = []) -> [Index] { 21 | var indices: [Index] = [] 22 | var startIndex = self.startIndex 23 | while startIndex < endIndex, 24 | let range = self[startIndex...] 25 | .range(of: string, options: options) { 26 | indices.append(range.lowerBound) 27 | startIndex = range.lowerBound < range.upperBound ? range.upperBound : 28 | index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex 29 | } 30 | return indices 31 | } 32 | func ranges(of string: S, options: String.CompareOptions = []) -> [Range] { 33 | var result: [Range] = [] 34 | var startIndex = self.startIndex 35 | while startIndex < endIndex, 36 | let range = self[startIndex...] 37 | .range(of: string, options: options) { 38 | result.append(range) 39 | startIndex = range.lowerBound < range.upperBound ? range.upperBound : 40 | index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex 41 | } 42 | return result 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/SwiftyLineProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyLineProcessor.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 16/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | 12 | extension OSLog { 13 | private static var subsystem = "SwiftyLineProcessor" 14 | static let swiftyLineProcessorPerformance = OSLog(subsystem: subsystem, category: "Swifty Line Processor Performance") 15 | } 16 | 17 | public protocol LineStyling { 18 | var shouldTokeniseLine : Bool { get } 19 | func styleIfFoundStyleAffectsPreviousLine() -> LineStyling? 20 | } 21 | 22 | public struct SwiftyLine : CustomStringConvertible { 23 | public let line : String 24 | public let lineStyle : LineStyling 25 | public var description: String { 26 | return self.line 27 | } 28 | } 29 | 30 | extension SwiftyLine : Equatable { 31 | public static func == ( _ lhs : SwiftyLine, _ rhs : SwiftyLine ) -> Bool { 32 | return lhs.line == rhs.line 33 | } 34 | } 35 | 36 | public enum Remove { 37 | case leading 38 | case trailing 39 | case both 40 | case entireLine 41 | case none 42 | } 43 | 44 | public enum ChangeApplication { 45 | case current 46 | case previous 47 | case untilClose 48 | } 49 | 50 | public struct FrontMatterRule { 51 | let openTag : String 52 | let closeTag : String 53 | let keyValueSeparator : Character 54 | } 55 | 56 | public struct LineRule { 57 | let token : String 58 | let removeFrom : Remove 59 | let type : LineStyling 60 | let shouldTrim : Bool 61 | let changeAppliesTo : ChangeApplication 62 | 63 | public init(token : String, type : LineStyling, removeFrom : Remove = .leading, shouldTrim : Bool = true, changeAppliesTo : ChangeApplication = .current ) { 64 | self.token = token 65 | self.type = type 66 | self.removeFrom = removeFrom 67 | self.shouldTrim = shouldTrim 68 | self.changeAppliesTo = changeAppliesTo 69 | } 70 | } 71 | 72 | public class SwiftyLineProcessor { 73 | 74 | public var processEmptyStrings : LineStyling? 75 | public internal(set) var frontMatterAttributes : [String : String] = [:] 76 | 77 | var closeToken : String? = nil 78 | let defaultType : LineStyling 79 | 80 | let lineRules : [LineRule] 81 | let frontMatterRules : [FrontMatterRule] 82 | 83 | let perfomanceLog = PerformanceLog(with: "SwiftyLineProcessorPerformanceLogging", identifier: "Line Processor", log: OSLog.swiftyLineProcessorPerformance) 84 | 85 | public init( rules : [LineRule], defaultRule: LineStyling, frontMatterRules : [FrontMatterRule] = []) { 86 | self.lineRules = rules 87 | self.defaultType = defaultRule 88 | self.frontMatterRules = frontMatterRules 89 | } 90 | 91 | func findLeadingLineElement( _ element : LineRule, in string : String ) -> String { 92 | var output = string 93 | if let range = output.index(output.startIndex, offsetBy: element.token.count, limitedBy: output.endIndex), output[output.startIndex.. String { 101 | var output = string 102 | let token = element.token.trimmingCharacters(in: .whitespaces) 103 | if let range = output.index(output.endIndex, offsetBy: -(token.count), limitedBy: output.startIndex), output[range.. SwiftyLine? { 112 | if text.isEmpty, let style = processEmptyStrings { 113 | return SwiftyLine(line: "", lineStyle: style) 114 | } 115 | let previousLines = lineRules.filter({ $0.changeAppliesTo == .previous }) 116 | 117 | for element in lineRules { 118 | guard element.token.count > 0 else { 119 | continue 120 | } 121 | var output : String = (element.shouldTrim) ? text.trimmingCharacters(in: .whitespaces) : text 122 | let unprocessed = output 123 | 124 | if let hasToken = self.closeToken, unprocessed != hasToken { 125 | return nil 126 | } 127 | 128 | if !text.contains(element.token) { 129 | continue 130 | } 131 | 132 | switch element.removeFrom { 133 | case .leading: 134 | output = findLeadingLineElement(element, in: output) 135 | case .trailing: 136 | output = findTrailingLineElement(element, in: output) 137 | case .both: 138 | output = findLeadingLineElement(element, in: output) 139 | output = findTrailingLineElement(element, in: output) 140 | case .entireLine: 141 | let maybeOutput = output.replacingOccurrences(of: element.token, with: "") 142 | output = ( maybeOutput.isEmpty ) ? maybeOutput : output 143 | default: 144 | break 145 | } 146 | // Only if the output has changed in some way 147 | guard unprocessed != output else { 148 | continue 149 | } 150 | if element.changeAppliesTo == .untilClose { 151 | self.closeToken = (self.closeToken == nil) ? element.token : nil 152 | return nil 153 | } 154 | 155 | 156 | 157 | output = (element.shouldTrim) ? output.trimmingCharacters(in: .whitespaces) : output 158 | return SwiftyLine(line: output, lineStyle: element.type) 159 | 160 | } 161 | 162 | for element in previousLines { 163 | let output = (element.shouldTrim) ? text.trimmingCharacters(in: .whitespaces) : text 164 | let charSet = CharacterSet(charactersIn: element.token ) 165 | if output.unicodeScalars.allSatisfy({ charSet.contains($0) }) { 166 | return SwiftyLine(line: "", lineStyle: element.type) 167 | } 168 | } 169 | 170 | return SwiftyLine(line: text.trimmingCharacters(in: .whitespaces), lineStyle: defaultType) 171 | } 172 | 173 | func processFrontMatter( _ strings : [String] ) -> [String] { 174 | guard let firstString = strings.first?.trimmingCharacters(in: .whitespacesAndNewlines) else { 175 | return strings 176 | } 177 | var rulesToApply : FrontMatterRule? = nil 178 | for matter in self.frontMatterRules { 179 | if firstString == matter.openTag { 180 | rulesToApply = matter 181 | break 182 | } 183 | } 184 | guard let existentRules = rulesToApply else { 185 | return strings 186 | } 187 | var outputString = strings 188 | // Remove the first line, which is the front matter opening tag 189 | let _ = outputString.removeFirst() 190 | var closeFound = false 191 | while !closeFound { 192 | let nextString = outputString.removeFirst() 193 | if nextString == existentRules.closeTag { 194 | closeFound = true 195 | continue 196 | } 197 | var keyValue = nextString.components(separatedBy: "\(existentRules.keyValueSeparator)") 198 | if keyValue.count < 2 { 199 | continue 200 | } 201 | let key = keyValue.removeFirst() 202 | let value = keyValue.joined() 203 | self.frontMatterAttributes[key] = value 204 | } 205 | while outputString.first?.isEmpty ?? false { 206 | outputString.removeFirst() 207 | } 208 | return outputString 209 | } 210 | 211 | public func process( _ string : String ) -> [SwiftyLine] { 212 | var foundAttributes : [SwiftyLine] = [] 213 | 214 | 215 | self.perfomanceLog.start() 216 | 217 | var lines = string.components(separatedBy: CharacterSet.newlines) 218 | lines = self.processFrontMatter(lines) 219 | 220 | self.perfomanceLog.tag(with: "(Front matter completed)") 221 | 222 | 223 | for heading in lines { 224 | 225 | if processEmptyStrings == nil && heading.isEmpty { 226 | continue 227 | } 228 | 229 | guard let input = processLineLevelAttributes(String(heading)) else { 230 | continue 231 | } 232 | 233 | if let existentPrevious = input.lineStyle.styleIfFoundStyleAffectsPreviousLine(), foundAttributes.count > 0 { 234 | if let idx = foundAttributes.firstIndex(of: foundAttributes.last!) { 235 | let updatedPrevious = foundAttributes.last! 236 | foundAttributes[idx] = SwiftyLine(line: updatedPrevious.line, lineStyle: existentPrevious) 237 | } 238 | continue 239 | } 240 | foundAttributes.append(input) 241 | 242 | self.perfomanceLog.tag(with: "(line completed: \(heading)") 243 | } 244 | return foundAttributes 245 | } 246 | 247 | } 248 | 249 | 250 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/SwiftyMarkdown+iOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdown+macOS.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 17/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if !os(macOS) 12 | import UIKit 13 | 14 | extension SwiftyMarkdown { 15 | 16 | func font( for line : SwiftyLine, characterOverride : CharacterStyle? = nil ) -> UIFont { 17 | let textStyle : UIFont.TextStyle 18 | var fontName : String? 19 | var fontSize : CGFloat? 20 | 21 | var globalBold = false 22 | var globalItalic = false 23 | 24 | let style : FontProperties 25 | // What type are we and is there a font name set? 26 | switch line.lineStyle as! MarkdownLineStyle { 27 | case .h1: 28 | style = self.h1 29 | if #available(iOS 9, *) { 30 | textStyle = UIFont.TextStyle.title1 31 | } else { 32 | textStyle = UIFont.TextStyle.headline 33 | } 34 | case .h2: 35 | style = self.h2 36 | if #available(iOS 9, *) { 37 | textStyle = UIFont.TextStyle.title2 38 | } else { 39 | textStyle = UIFont.TextStyle.headline 40 | } 41 | case .h3: 42 | style = self.h3 43 | if #available(iOS 9, *) { 44 | textStyle = UIFont.TextStyle.title2 45 | } else { 46 | textStyle = UIFont.TextStyle.subheadline 47 | } 48 | case .h4: 49 | style = self.h4 50 | textStyle = UIFont.TextStyle.headline 51 | case .h5: 52 | style = self.h5 53 | textStyle = UIFont.TextStyle.subheadline 54 | case .h6: 55 | style = self.h6 56 | textStyle = UIFont.TextStyle.footnote 57 | case .codeblock: 58 | style = self.code 59 | textStyle = UIFont.TextStyle.body 60 | case .blockquote: 61 | style = self.blockquotes 62 | textStyle = UIFont.TextStyle.body 63 | default: 64 | style = self.body 65 | textStyle = UIFont.TextStyle.body 66 | } 67 | 68 | fontName = style.fontName 69 | fontSize = style.fontSize 70 | switch style.fontStyle { 71 | case .bold: 72 | globalBold = true 73 | case .italic: 74 | globalItalic = true 75 | case .boldItalic: 76 | globalItalic = true 77 | globalBold = true 78 | case .normal: 79 | break 80 | } 81 | 82 | if fontName == nil { 83 | fontName = body.fontName 84 | } 85 | 86 | if let characterOverride = characterOverride { 87 | switch characterOverride { 88 | case .code: 89 | fontName = code.fontName ?? fontName 90 | fontSize = code.fontSize 91 | case .link: 92 | fontName = link.fontName ?? fontName 93 | fontSize = link.fontSize 94 | case .bold: 95 | fontName = bold.fontName ?? fontName 96 | fontSize = bold.fontSize 97 | globalBold = true 98 | case .italic: 99 | fontName = italic.fontName ?? fontName 100 | fontSize = italic.fontSize 101 | globalItalic = true 102 | case .strikethrough: 103 | fontName = strikethrough.fontName ?? fontName 104 | fontSize = strikethrough.fontSize 105 | default: 106 | break 107 | } 108 | } 109 | 110 | fontSize = fontSize == 0.0 ? nil : fontSize 111 | var font : UIFont 112 | if let existentFontName = fontName { 113 | font = UIFont.preferredFont(forTextStyle: textStyle) 114 | let finalSize : CGFloat 115 | if let existentFontSize = fontSize { 116 | finalSize = existentFontSize 117 | } else { 118 | let styleDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) 119 | finalSize = styleDescriptor.fontAttributes[.size] as? CGFloat ?? CGFloat(14) 120 | } 121 | 122 | if let customFont = UIFont(name: existentFontName, size: finalSize) { 123 | let fontMetrics = UIFontMetrics(forTextStyle: textStyle) 124 | font = fontMetrics.scaledFont(for: customFont) 125 | } else { 126 | font = UIFont.preferredFont(forTextStyle: textStyle) 127 | } 128 | } else { 129 | font = UIFont.preferredFont(forTextStyle: textStyle) 130 | } 131 | 132 | if globalItalic, let italicDescriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) { 133 | font = UIFont(descriptor: italicDescriptor, size: fontSize ?? 0) 134 | } 135 | if globalBold, let boldDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) { 136 | font = UIFont(descriptor: boldDescriptor, size: fontSize ?? 0) 137 | } 138 | 139 | return font 140 | 141 | } 142 | 143 | func color( for line : SwiftyLine ) -> UIColor { 144 | // What type are we and is there a font name set? 145 | switch line.lineStyle as! MarkdownLineStyle { 146 | case .yaml: 147 | return body.color 148 | case .h1, .previousH1: 149 | return h1.color 150 | case .h2, .previousH2: 151 | return h2.color 152 | case .h3: 153 | return h3.color 154 | case .h4: 155 | return h4.color 156 | case .h5: 157 | return h5.color 158 | case .h6: 159 | return h6.color 160 | case .body: 161 | return body.color 162 | case .codeblock: 163 | return code.color 164 | case .blockquote: 165 | return blockquotes.color 166 | case .unorderedList, .unorderedListIndentFirstOrder, .unorderedListIndentSecondOrder, .orderedList, .orderedListIndentFirstOrder, .orderedListIndentSecondOrder: 167 | return body.color 168 | case .referencedLink: 169 | return link.color 170 | } 171 | } 172 | 173 | } 174 | #endif 175 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/SwiftyMarkdown+macOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdown+macOS.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 17/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if os(macOS) 11 | import AppKit 12 | 13 | extension SwiftyMarkdown { 14 | 15 | func font( for line : SwiftyLine, characterOverride : CharacterStyle? = nil ) -> NSFont { 16 | var fontName : String? 17 | var fontSize : CGFloat? 18 | 19 | var globalBold = false 20 | var globalItalic = false 21 | 22 | let style : FontProperties 23 | // What type are we and is there a font name set? 24 | switch line.lineStyle as! MarkdownLineStyle { 25 | case .h1: 26 | style = self.h1 27 | case .h2: 28 | style = self.h2 29 | case .h3: 30 | style = self.h3 31 | case .h4: 32 | style = self.h4 33 | case .h5: 34 | style = self.h5 35 | case .h6: 36 | style = self.h6 37 | case .codeblock: 38 | style = self.code 39 | case .blockquote: 40 | style = self.blockquotes 41 | default: 42 | style = self.body 43 | } 44 | 45 | fontName = style.fontName 46 | fontSize = style.fontSize 47 | switch style.fontStyle { 48 | case .bold: 49 | globalBold = true 50 | case .italic: 51 | globalItalic = true 52 | case .boldItalic: 53 | globalItalic = true 54 | globalBold = true 55 | case .normal: 56 | break 57 | } 58 | 59 | if fontName == nil { 60 | fontName = body.fontName 61 | } 62 | 63 | if let characterOverride = characterOverride { 64 | switch characterOverride { 65 | case .code: 66 | fontName = code.fontName ?? fontName 67 | fontSize = code.fontSize 68 | case .link: 69 | fontName = link.fontName ?? fontName 70 | fontSize = link.fontSize 71 | case .bold: 72 | fontName = bold.fontName ?? fontName 73 | fontSize = bold.fontSize 74 | globalBold = true 75 | case .italic: 76 | fontName = italic.fontName ?? fontName 77 | fontSize = italic.fontSize 78 | globalItalic = true 79 | default: 80 | break 81 | } 82 | } 83 | 84 | fontSize = fontSize == 0.0 ? nil : fontSize 85 | let finalSize : CGFloat 86 | if let existentFontSize = fontSize { 87 | finalSize = existentFontSize 88 | } else { 89 | finalSize = NSFont.systemFontSize 90 | } 91 | var font : NSFont 92 | if let existentFontName = fontName { 93 | if let customFont = NSFont(name: existentFontName, size: finalSize) { 94 | font = customFont 95 | } else { 96 | font = NSFont.systemFont(ofSize: finalSize) 97 | } 98 | } else { 99 | font = NSFont.systemFont(ofSize: finalSize) 100 | } 101 | 102 | if globalItalic { 103 | let italicDescriptor = font.fontDescriptor.withSymbolicTraits(.italic) 104 | font = NSFont(descriptor: italicDescriptor, size: 0) ?? font 105 | } 106 | if globalBold { 107 | let boldDescriptor = font.fontDescriptor.withSymbolicTraits(.bold) 108 | font = NSFont(descriptor: boldDescriptor, size: 0) ?? font 109 | } 110 | 111 | return font 112 | 113 | } 114 | 115 | func color( for line : SwiftyLine ) -> NSColor { 116 | // What type are we and is there a font name set? 117 | switch line.lineStyle as! MarkdownLineStyle { 118 | case .h1, .previousH1: 119 | return h1.color 120 | case .h2, .previousH2: 121 | return h2.color 122 | case .h3: 123 | return h3.color 124 | case .h4: 125 | return h4.color 126 | case .h5: 127 | return h5.color 128 | case .h6: 129 | return h6.color 130 | case .body: 131 | return body.color 132 | case .codeblock: 133 | return code.color 134 | case .blockquote: 135 | return blockquotes.color 136 | case .unorderedList, .unorderedListIndentFirstOrder, .unorderedListIndentSecondOrder, .orderedList, .orderedListIndentFirstOrder, .orderedListIndentSecondOrder: 137 | return body.color 138 | case .yaml: 139 | return body.color 140 | case .referencedLink: 141 | return body.color 142 | } 143 | } 144 | 145 | } 146 | #endif 147 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/SwiftyScanner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyScanner.swift 3 | // 4 | // 5 | // Created by Simon Fairbairn on 04/04/2020. 6 | // 7 | 8 | // 9 | // SwiftyScanner.swift 10 | // SwiftyMarkdown 11 | // 12 | // Created by Simon Fairbairn on 04/02/2020. 13 | // 14 | 15 | import Foundation 16 | import os.log 17 | 18 | extension OSLog { 19 | private static var subsystem = "SwiftyScanner" 20 | static let swiftyScanner = OSLog(subsystem: subsystem, category: "Swifty Scanner Scanner") 21 | static let swiftyScannerPerformance = OSLog(subsystem: subsystem, category: "Swifty Scanner Scanner Peformance") 22 | } 23 | 24 | enum RepeatingTagType { 25 | case open 26 | case either 27 | case close 28 | case neither 29 | } 30 | 31 | struct TagGroup { 32 | let groupID = UUID().uuidString 33 | var tagRanges : [ClosedRange] 34 | var tagType : RepeatingTagType = .open 35 | var count = 1 36 | } 37 | 38 | class SwiftyScanner { 39 | var elements : [Element] 40 | let rule : CharacterRule 41 | let metadata : [String : String] 42 | var pointer : Int = 0 43 | 44 | var spaceAndNewLine = CharacterSet.whitespacesAndNewlines 45 | var tagGroups : [TagGroup] = [] 46 | 47 | var isMetadataOpen = false 48 | 49 | 50 | var enableLog = (ProcessInfo.processInfo.environment["SwiftyScannerScanner"] != nil) 51 | 52 | let currentPerfomanceLog = PerformanceLog(with: "SwiftyScannerScannerPerformanceLogging", identifier: "Scanner", log: OSLog.swiftyScannerPerformance) 53 | let log = PerformanceLog(with: "SwiftyScannerScanner", identifier: "Scanner", log: OSLog.swiftyScanner) 54 | 55 | 56 | 57 | enum Position { 58 | case forward(Int) 59 | case backward(Int) 60 | } 61 | 62 | init( withElements elements : [Element], rule : CharacterRule, metadata : [String : String]) { 63 | self.elements = elements 64 | self.rule = rule 65 | self.currentPerfomanceLog.start() 66 | self.metadata = metadata 67 | } 68 | 69 | func elementsBetweenCurrentPosition( and newPosition : Position ) -> [Element]? { 70 | 71 | let newIdx : Int 72 | var isForward = true 73 | switch newPosition { 74 | case .backward(let positions): 75 | isForward = false 76 | newIdx = pointer - positions 77 | if newIdx < 0 { 78 | return nil 79 | } 80 | case .forward(let positions): 81 | newIdx = pointer + positions 82 | if newIdx >= self.elements.count { 83 | return nil 84 | } 85 | } 86 | 87 | 88 | let range : ClosedRange = ( isForward ) ? self.pointer...newIdx : newIdx...self.pointer 89 | return Array(self.elements[range]) 90 | } 91 | 92 | 93 | func element( for position : Position ) -> Element? { 94 | let newIdx : Int 95 | switch position { 96 | case .backward(let positions): 97 | newIdx = pointer - positions 98 | if newIdx < 0 { 99 | return nil 100 | } 101 | case .forward(let positions): 102 | newIdx = pointer + positions 103 | if newIdx >= self.elements.count { 104 | return nil 105 | } 106 | } 107 | return self.elements[newIdx] 108 | } 109 | 110 | 111 | func positionIsEqualTo( character : Character, direction : Position ) -> Bool { 112 | guard let validElement = self.element(for: direction) else { 113 | return false 114 | } 115 | return validElement.character == character 116 | } 117 | 118 | func positionContains( characters : [Character], direction : Position ) -> Bool { 119 | guard let validElement = self.element(for: direction) else { 120 | return false 121 | } 122 | return characters.contains(validElement.character) 123 | } 124 | 125 | func isEscaped() -> Bool { 126 | let isEscaped = self.positionContains(characters: self.rule.escapeCharacters, direction: .backward(1)) 127 | if isEscaped { 128 | self.elements[self.pointer - 1].type = .escape 129 | } 130 | return isEscaped 131 | } 132 | 133 | func range( for tag : String? ) -> ClosedRange? { 134 | 135 | guard let tag = tag else { 136 | return nil 137 | } 138 | 139 | guard let openChar = tag.first else { 140 | return nil 141 | } 142 | 143 | if self.pointer == self.elements.count { 144 | return nil 145 | } 146 | 147 | if self.elements[self.pointer].character != openChar { 148 | return nil 149 | } 150 | 151 | if isEscaped() { 152 | return nil 153 | } 154 | 155 | let range : ClosedRange 156 | if tag.count > 1 { 157 | guard let elements = self.elementsBetweenCurrentPosition(and: .forward(tag.count - 1) ) else { 158 | return nil 159 | } 160 | // If it's already a tag, then it should be ignored 161 | if elements.filter({ $0.type != .string }).count > 0 { 162 | return nil 163 | } 164 | if elements.map( { String($0.character) }).joined() != tag { 165 | return nil 166 | } 167 | let endIdx = (self.pointer + tag.count - 1) 168 | for i in self.pointer...endIdx { 169 | self.elements[i].type = .tag 170 | } 171 | range = self.pointer...endIdx 172 | self.pointer += tag.count 173 | } else { 174 | // If it's already a tag, then it should be ignored 175 | if self.elements[self.pointer].type != .string { 176 | return nil 177 | } 178 | self.elements[self.pointer].type = .tag 179 | range = self.pointer...self.pointer 180 | self.pointer += 1 181 | } 182 | return range 183 | } 184 | 185 | 186 | func resetTagGroup( withID id : String ) { 187 | if let idx = self.tagGroups.firstIndex(where: { $0.groupID == id }) { 188 | for range in self.tagGroups[idx].tagRanges { 189 | self.resetTag(in: range) 190 | } 191 | self.tagGroups.remove(at: idx) 192 | } 193 | self.isMetadataOpen = false 194 | } 195 | 196 | func resetTag( in range : ClosedRange) { 197 | for idx in range { 198 | self.elements[idx].type = .string 199 | } 200 | } 201 | 202 | func resetLastTag( for range : inout [ClosedRange]) { 203 | guard let last = range.last else { 204 | return 205 | } 206 | for idx in last { 207 | self.elements[idx].type = .string 208 | } 209 | } 210 | 211 | func closeTag( _ tag : String, withGroupID id : String ) { 212 | 213 | guard let tagIdx = self.tagGroups.firstIndex(where: { $0.groupID == id }) else { 214 | return 215 | } 216 | 217 | var metadataString = "" 218 | if self.isMetadataOpen { 219 | let metadataCloseRange = self.tagGroups[tagIdx].tagRanges.removeLast() 220 | let metadataOpenRange = self.tagGroups[tagIdx].tagRanges.removeLast() 221 | 222 | if metadataOpenRange.upperBound + 1 == (metadataCloseRange.lowerBound) { 223 | if self.enableLog { 224 | os_log("Nothing between the tags", log: OSLog.swiftyScanner, type:.info , self.rule.description) 225 | } 226 | } else { 227 | for idx in (metadataOpenRange.upperBound)...(metadataCloseRange.lowerBound) { 228 | self.elements[idx].type = .metadata 229 | if self.rule.definesBoundary { 230 | self.elements[idx].boundaryCount += 1 231 | } 232 | } 233 | 234 | 235 | let key = self.elements[metadataOpenRange.upperBound + 1.. 0 { 262 | if remainingTags >= self.rule.maxTags { 263 | remainingTags -= self.rule.maxTags 264 | if let style = self.rule.styles[ self.rule.maxTags ] { 265 | if !styles.contains(where: { $0.isEqualTo(style)}) { 266 | styles.append(style) 267 | } 268 | } 269 | } 270 | if let style = self.rule.styles[remainingTags] { 271 | remainingTags -= remainingTags 272 | if !styles.contains(where: { $0.isEqualTo(style)}) { 273 | styles.append(style) 274 | } 275 | } 276 | } 277 | 278 | for idx in (openRange.upperBound)...(closeRange.lowerBound) { 279 | self.elements[idx].styles.append(contentsOf: styles) 280 | self.elements[idx].metadata.append(metadataString) 281 | if self.rule.definesBoundary { 282 | self.elements[idx].boundaryCount += 1 283 | } 284 | if self.rule.shouldCancelRemainingRules { 285 | self.elements[idx].boundaryCount = 1000 286 | } 287 | } 288 | 289 | if self.rule.isRepeatingTag { 290 | let difference = ( openRange.upperBound - openRange.lowerBound ) - (closeRange.upperBound - closeRange.lowerBound) 291 | switch difference { 292 | case 1...: 293 | shouldRemove = false 294 | self.tagGroups[tagIdx].count = difference 295 | self.tagGroups[tagIdx].tagRanges.append( openRange.upperBound - (abs(difference) - 1)...openRange.upperBound ) 296 | case ...(-1): 297 | for idx in closeRange.upperBound - (abs(difference) - 1)...closeRange.upperBound { 298 | self.elements[idx].type = .string 299 | } 300 | default: 301 | break 302 | } 303 | } 304 | 305 | } 306 | if shouldRemove { 307 | self.tagGroups.removeAll(where: { $0.groupID == id }) 308 | } 309 | self.isMetadataOpen = false 310 | } 311 | 312 | func emptyRanges( _ ranges : inout [ClosedRange] ) { 313 | while !ranges.isEmpty { 314 | self.resetLastTag(for: &ranges) 315 | ranges.removeLast() 316 | } 317 | } 318 | 319 | func scanNonRepeatingTags() { 320 | var groupID = "" 321 | let closeTag = self.rule.tag(for: .close)?.tag 322 | let metadataOpen = self.rule.tag(for: .metadataOpen)?.tag 323 | let metadataClose = self.rule.tag(for: .metadataClose)?.tag 324 | 325 | while self.pointer < self.elements.count { 326 | if self.enableLog { 327 | os_log("CHARACTER: %@", log: OSLog.swiftyScanner, type:.info , String(self.elements[self.pointer].character)) 328 | } 329 | 330 | if let range = self.range(for: metadataClose) { 331 | if self.isMetadataOpen { 332 | guard let groupIdx = self.tagGroups.firstIndex(where: { $0.groupID == groupID }) else { 333 | self.pointer += 1 334 | continue 335 | } 336 | 337 | guard !self.tagGroups.isEmpty else { 338 | self.resetTagGroup(withID: groupID) 339 | continue 340 | } 341 | 342 | guard self.isMetadataOpen else { 343 | 344 | self.resetTagGroup(withID: groupID) 345 | continue 346 | } 347 | if self.enableLog { 348 | os_log("Closing metadata tag found. Closing tag with ID %@", log: OSLog.swiftyScanner, type:.info , groupID) 349 | } 350 | self.tagGroups[groupIdx].tagRanges.append(range) 351 | self.closeTag(closeTag!, withGroupID: groupID) 352 | self.isMetadataOpen = false 353 | continue 354 | } else { 355 | self.resetTag(in: range) 356 | self.pointer -= metadataClose!.count 357 | } 358 | 359 | } 360 | 361 | if let openRange = self.range(for: self.rule.primaryTag.tag) { 362 | if self.isMetadataOpen { 363 | self.resetTagGroup(withID: groupID) 364 | } 365 | 366 | let tagGroup = TagGroup(tagRanges: [openRange]) 367 | groupID = tagGroup.groupID 368 | if self.enableLog { 369 | os_log("New open tag found. Starting new Group with ID %@", log: OSLog.swiftyScanner, type:.info , groupID) 370 | } 371 | if self.rule.isRepeatingTag { 372 | 373 | } 374 | 375 | self.tagGroups.append(tagGroup) 376 | continue 377 | } 378 | 379 | if let range = self.range(for: closeTag) { 380 | guard !self.tagGroups.isEmpty else { 381 | if self.enableLog { 382 | os_log("No open tags exist, resetting this close tag", log: OSLog.swiftyScanner, type:.info) 383 | } 384 | self.resetTag(in: range) 385 | continue 386 | } 387 | self.tagGroups[self.tagGroups.count - 1].tagRanges.append(range) 388 | groupID = self.tagGroups[self.tagGroups.count - 1].groupID 389 | if self.enableLog { 390 | os_log("New close tag found. Appending to group with ID %@", log: OSLog.swiftyScanner, type:.info , groupID) 391 | } 392 | guard metadataOpen != nil else { 393 | if self.enableLog { 394 | os_log("No metadata tags exist, closing valid tag with ID %@", log: OSLog.swiftyScanner, type:.info , groupID) 395 | } 396 | self.closeTag(closeTag!, withGroupID: groupID) 397 | continue 398 | } 399 | 400 | guard self.pointer != self.elements.count else { 401 | continue 402 | } 403 | 404 | guard let range = self.range(for: metadataOpen) else { 405 | if self.enableLog { 406 | os_log("No metadata tag found, resetting group with ID %@", log: OSLog.swiftyScanner, type:.info , groupID) 407 | } 408 | self.resetTagGroup(withID: groupID) 409 | continue 410 | } 411 | self.tagGroups[self.tagGroups.count - 1].tagRanges.append(range) 412 | self.isMetadataOpen = true 413 | continue 414 | } 415 | 416 | 417 | if let range = self.range(for: metadataOpen) { 418 | if self.enableLog { 419 | os_log("Multiple open metadata tags found!", log: OSLog.swiftyScanner, type:.info , groupID) 420 | } 421 | self.resetTag(in: range) 422 | self.resetTagGroup(withID: groupID) 423 | self.isMetadataOpen = false 424 | continue 425 | } 426 | self.pointer += 1 427 | } 428 | } 429 | 430 | func scanRepeatingTags() { 431 | 432 | var groupID = "" 433 | let escapeCharacters = "" //self.rule.escapeCharacters.map( { String( $0 ) }).joined() 434 | let unionSet = spaceAndNewLine.union(CharacterSet(charactersIn: escapeCharacters)) 435 | while self.pointer < self.elements.count { 436 | if self.enableLog { 437 | os_log("CHARACTER: %@", log: OSLog.swiftyScanner, type:.info , String(self.elements[self.pointer].character)) 438 | } 439 | 440 | if var openRange = self.range(for: self.rule.primaryTag.tag) { 441 | 442 | if self.elements[openRange].first?.boundaryCount == 1000 { 443 | self.resetTag(in: openRange) 444 | continue 445 | } 446 | 447 | var count = 1 448 | var tagType : RepeatingTagType = .open 449 | if let prevElement = self.element(for: .backward(self.rule.primaryTag.tag.count + 1)) { 450 | if !unionSet.containsUnicodeScalars(of: prevElement.character) { 451 | tagType = .either 452 | } 453 | } else { 454 | tagType = .open 455 | } 456 | 457 | while let nextRange = self.range(for: self.rule.primaryTag.tag) { 458 | count += 1 459 | openRange = openRange.lowerBound...nextRange.upperBound 460 | } 461 | 462 | if self.rule.minTags > 1 { 463 | if (openRange.upperBound - openRange.lowerBound) + 1 < self.rule.minTags { 464 | self.resetTag(in: openRange) 465 | os_log("Tag does not meet minimum length", log: .swiftyScanner, type: .info) 466 | continue 467 | } 468 | } 469 | 470 | var validTagGroup = true 471 | if let nextElement = self.element(for: .forward(0)) { 472 | if unionSet.containsUnicodeScalars(of: nextElement.character) { 473 | if tagType == .either { 474 | tagType = .close 475 | } else { 476 | validTagGroup = tagType != .open 477 | } 478 | } 479 | } else { 480 | if tagType == .either { 481 | tagType = .close 482 | } else { 483 | validTagGroup = tagType != .open 484 | } 485 | } 486 | 487 | if !validTagGroup { 488 | if self.enableLog { 489 | os_log("Tag has whitespace on both sides", log: .swiftyScanner, type: .info) 490 | } 491 | self.resetTag(in: openRange) 492 | continue 493 | } 494 | 495 | if let idx = tagGroups.firstIndex(where: { $0.groupID == groupID }) { 496 | if tagType == .either { 497 | if tagGroups[idx].count == count { 498 | self.tagGroups[idx].tagRanges.append(openRange) 499 | self.closeTag(self.rule.primaryTag.tag, withGroupID: groupID) 500 | 501 | if let last = self.tagGroups.last { 502 | groupID = last.groupID 503 | } 504 | 505 | continue 506 | } 507 | } else { 508 | if let prevRange = tagGroups[idx].tagRanges.first { 509 | if self.elements[prevRange].first?.boundaryCount == self.elements[openRange].first?.boundaryCount { 510 | self.tagGroups[idx].tagRanges.append(openRange) 511 | self.closeTag(self.rule.primaryTag.tag, withGroupID: groupID) 512 | } 513 | } 514 | continue 515 | } 516 | } 517 | var tagGroup = TagGroup(tagRanges: [openRange]) 518 | groupID = tagGroup.groupID 519 | tagGroup.tagType = tagType 520 | tagGroup.count = count 521 | 522 | if self.enableLog { 523 | os_log("New open tag found with characters %@. Starting new Group with ID %@", log: OSLog.swiftyScanner, type:.info, self.elements[openRange].map( { String($0.character) }).joined(), groupID) 524 | } 525 | 526 | self.tagGroups.append(tagGroup) 527 | continue 528 | } 529 | 530 | self.pointer += 1 531 | } 532 | } 533 | 534 | 535 | func scan() -> [Element] { 536 | 537 | guard self.elements.filter({ $0.type == .string }).map({ String($0.character) }).joined().contains(self.rule.primaryTag.tag) else { 538 | return self.elements 539 | } 540 | 541 | self.currentPerfomanceLog.tag(with: "Beginning \(self.rule.primaryTag.tag)") 542 | 543 | if self.enableLog { 544 | os_log("RULE: %@", log: OSLog.swiftyScanner, type:.info , self.rule.description) 545 | } 546 | 547 | if self.rule.isRepeatingTag { 548 | self.scanRepeatingTags() 549 | } else { 550 | self.scanNonRepeatingTags() 551 | } 552 | 553 | for tagGroup in self.tagGroups { 554 | self.resetTagGroup(withID: tagGroup.groupID) 555 | } 556 | 557 | if self.enableLog { 558 | for element in self.elements { 559 | print(element) 560 | } 561 | } 562 | return self.elements 563 | } 564 | } 565 | -------------------------------------------------------------------------------- /Sources/SwiftyMarkdown/SwiftyTokeniser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyTokeniser.swift 3 | // SwiftyMarkdown 4 | // 5 | // Created by Simon Fairbairn on 16/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | import Foundation 9 | import os.log 10 | 11 | extension OSLog { 12 | private static var subsystem = "SwiftyTokeniser" 13 | static let tokenising = OSLog(subsystem: subsystem, category: "Tokenising") 14 | static let styling = OSLog(subsystem: subsystem, category: "Styling") 15 | static let performance = OSLog(subsystem: subsystem, category: "Peformance") 16 | } 17 | 18 | public class SwiftyTokeniser { 19 | let rules : [CharacterRule] 20 | var replacements : [String : [Token]] = [:] 21 | 22 | var enableLog = (ProcessInfo.processInfo.environment["SwiftyTokeniserLogging"] != nil) 23 | let totalPerfomanceLog = PerformanceLog(with: "SwiftyTokeniserPerformanceLogging", identifier: "Tokeniser Total Run Time", log: OSLog.performance) 24 | let currentPerfomanceLog = PerformanceLog(with: "SwiftyTokeniserPerformanceLogging", identifier: "Tokeniser Current", log: OSLog.performance) 25 | 26 | public var metadataLookup : [String : String] = [:] 27 | 28 | let newlines = CharacterSet.newlines 29 | let spaces = CharacterSet.whitespaces 30 | 31 | 32 | public init( with rules : [CharacterRule] ) { 33 | self.rules = rules 34 | 35 | self.totalPerfomanceLog.start() 36 | } 37 | 38 | deinit { 39 | self.totalPerfomanceLog.end() 40 | } 41 | 42 | 43 | /// This goes through every CharacterRule in order and applies it to the input string, tokenising the string 44 | /// if there are any matches. 45 | /// 46 | /// The for loop in the while loop (yeah, I know) is there to separate strings from within tags to 47 | /// those outside them. 48 | /// 49 | /// e.g. "A string with a \[link\]\(url\) tag" would have the "link" text tokenised separately. 50 | /// 51 | /// This is to prevent situations like **\[link**\](url) from returing a bold string. 52 | /// 53 | /// - Parameter inputString: A string to have the CharacterRules in `self.rules` applied to 54 | public func process( _ inputString : String ) -> [Token] { 55 | let currentTokens = [Token(type: .string, inputString: inputString)] 56 | guard rules.count > 0 else { 57 | return currentTokens 58 | } 59 | var mutableRules = self.rules 60 | 61 | if inputString.isEmpty { 62 | return [Token(type: .string, inputString: "", characterStyles: [])] 63 | } 64 | 65 | self.currentPerfomanceLog.start() 66 | 67 | var elementArray : [Element] = [] 68 | for char in inputString { 69 | if newlines.containsUnicodeScalars(of: char) { 70 | let element = Element(character: char, type: .newline) 71 | elementArray.append(element) 72 | continue 73 | } 74 | if spaces.containsUnicodeScalars(of: char) { 75 | let element = Element(character: char, type: .space) 76 | elementArray.append(element) 77 | continue 78 | } 79 | let element = Element(character: char, type: .string) 80 | elementArray.append(element) 81 | } 82 | 83 | while !mutableRules.isEmpty { 84 | let nextRule = mutableRules.removeFirst() 85 | if enableLog { 86 | os_log("------------------------------", log: .tokenising, type: .info) 87 | os_log("RULE: %@", log: OSLog.tokenising, type:.info , nextRule.description) 88 | } 89 | self.currentPerfomanceLog.tag(with: "(start rule %@)") 90 | 91 | let scanner = SwiftyScanner(withElements: elementArray, rule: nextRule, metadata: self.metadataLookup) 92 | elementArray = scanner.scan() 93 | } 94 | 95 | var output : [Token] = [] 96 | var lastElement = elementArray.first! 97 | 98 | func empty( _ string : inout String, into tokens : inout [Token] ) { 99 | guard !string.isEmpty else { 100 | return 101 | } 102 | var token = Token(type: .string, inputString: string) 103 | token.metadataStrings.append(contentsOf: lastElement.metadata) 104 | token.characterStyles = lastElement.styles 105 | string.removeAll() 106 | tokens.append(token) 107 | } 108 | 109 | var accumulatedString = "" 110 | for element in elementArray { 111 | guard element.type != .escape else { 112 | continue 113 | } 114 | 115 | guard element.type == .string || element.type == .space || element.type == .newline else { 116 | empty(&accumulatedString, into: &output) 117 | continue 118 | } 119 | if lastElement.styles as? [CharacterStyle] != element.styles as? [CharacterStyle] { 120 | empty(&accumulatedString, into: &output) 121 | } 122 | accumulatedString.append(element.character) 123 | lastElement = element 124 | } 125 | empty(&accumulatedString, into: &output) 126 | 127 | self.currentPerfomanceLog.tag(with: "(finished all rules)") 128 | 129 | if enableLog { 130 | os_log("=====RULE PROCESSING COMPLETE=====", log: .tokenising, type: .info) 131 | os_log("==================================", log: .tokenising, type: .info) 132 | } 133 | return output 134 | } 135 | } 136 | 137 | 138 | extension String { 139 | func repeating( _ max : Int ) -> String { 140 | var output = self 141 | for _ in 1.. Bool 13 | } 14 | 15 | // Token definition 16 | public enum TokenType { 17 | case repeatingTag 18 | case openTag 19 | case intermediateTag 20 | case closeTag 21 | case string 22 | case escape 23 | case replacement 24 | } 25 | 26 | public struct Token { 27 | public let id = UUID().uuidString 28 | public let type : TokenType 29 | public let inputString : String 30 | public var metadataStrings : [String] = [] 31 | public internal(set) var group : Int = 0 32 | public internal(set) var characterStyles : [CharacterStyling] = [] 33 | public internal(set) var count : Int = 0 34 | public internal(set) var shouldSkip : Bool = false 35 | public internal(set) var tokenIndex : Int = -1 36 | public internal(set) var isProcessed : Bool = false 37 | public internal(set) var isMetadata : Bool = false 38 | public var children : [Token] = [] 39 | 40 | public var outputString : String { 41 | get { 42 | switch self.type { 43 | case .repeatingTag: 44 | if count <= 0 { 45 | return "" 46 | } else { 47 | let range = inputString.startIndex.. Token { 69 | var newToken = Token(type: (isReplacement) ? .replacement : .string , inputString: string, characterStyles: self.characterStyles) 70 | newToken.metadataStrings = self.metadataStrings 71 | newToken.isMetadata = self.isMetadata 72 | newToken.isProcessed = self.isProcessed 73 | return newToken 74 | } 75 | } 76 | 77 | extension Sequence where Iterator.Element == Token { 78 | var oslogDisplay: String { 79 | return "[\"\(self.map( { ($0.outputString.isEmpty) ? "\($0.type): \($0.inputString)" : $0.outputString }).joined(separator: "\", \""))\"]" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SwiftyMarkdown.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "SwiftyMarkdown" 3 | s.version = "1.2.4" 4 | s.summary = "Converts Markdown to NSAttributed String" 5 | s.homepage = "https://github.com/SimonFairbairn/SwiftyMarkdown" 6 | s.license = 'MIT' 7 | s.author = { "Simon Fairbairn" => "simon@voyagetravelapps.com" } 8 | s.source = { :git => "https://github.com/SimonFairbairn/SwiftyMarkdown.git", :tag => s.version } 9 | s.social_media_url = 'https://twitter.com/SimonFairbairn' 10 | 11 | s.ios.deployment_target = "11.0" 12 | s.tvos.deployment_target = "11.0" 13 | s.osx.deployment_target = "10.12" 14 | s.watchos.deployment_target = "4.0" 15 | s.requires_arc = true 16 | 17 | s.source_files = 'Sources/SwiftyMarkdown/**/*' 18 | 19 | s.swift_version = "5.0" 20 | 21 | end 22 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/SwiftyMarkdownTests_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | BNDL 16 | CFBundleShortVersionString 17 | 1.2.4 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/SwiftyMarkdown_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.2.4 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/xcshareddata/xcbaselines/SwiftyMarkdown::SwiftyMarkdownTests.xcbaseline/88991ED5-B954-422F-B610-BDC9A4AEC008.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | SwiftyMarkdownPerformanceTests 8 | 9 | testThatFilesAreProcessedQuickly() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.1 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | testThatStringsAreProcessedQuickly() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.1 25 | baselineIntegrationDisplayName 26 | Local Baseline 27 | 28 | 29 | testThatVeryLongStringsAreProcessedQuickly() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.1 35 | baselineIntegrationDisplayName 36 | Local Baseline 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/xcshareddata/xcbaselines/SwiftyMarkdown::SwiftyMarkdownTests.xcbaseline/AD1DF83E-20BC-4E7E-8C14-683818ED0A26.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | SwiftyMarkdownPerformanceTests 8 | 9 | testThatFilesAreProcessedQuickly() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.212 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | maxPercentRelativeStandardDeviation 18 | 10 19 | 20 | 21 | testThatStringsAreProcessedQuickly() 22 | 23 | com.apple.XCTPerformanceMetric_WallClockTime 24 | 25 | baselineAverage 26 | 0.016 27 | baselineIntegrationDisplayName 28 | Local Baseline 29 | 30 | 31 | testThatVeryLongStringsAreProcessedQuickly() 32 | 33 | com.apple.XCTPerformanceMetric_WallClockTime 34 | 35 | baselineAverage 36 | 0.016 37 | baselineIntegrationDisplayName 38 | Local Baseline 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/xcshareddata/xcbaselines/SwiftyMarkdown::SwiftyMarkdownTests.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | 88991ED5-B954-422F-B610-BDC9A4AEC008 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 400 13 | cpuCount 14 | 1 15 | cpuKind 16 | 8-Core Intel Core i9 17 | cpuSpeedInMHz 18 | 2400 19 | logicalCPUCoresPerPackage 20 | 16 21 | modelCode 22 | MacBookPro16,1 23 | physicalCPUCoresPerPackage 24 | 8 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | x86_64h 30 | 31 | AD1DF83E-20BC-4E7E-8C14-683818ED0A26 32 | 33 | localComputer 34 | 35 | busSpeedInMHz 36 | 400 37 | cpuCount 38 | 1 39 | cpuKind 40 | 8-Core Intel Core i9 41 | cpuSpeedInMHz 42 | 2400 43 | logicalCPUCoresPerPackage 44 | 16 45 | modelCode 46 | MacBookPro16,1 47 | physicalCPUCoresPerPackage 48 | 8 49 | platformIdentifier 50 | com.apple.platform.macosx 51 | 52 | targetArchitecture 53 | x86_64 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /SwiftyMarkdown.xcodeproj/xcshareddata/xcschemes/SwiftyMarkdown-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 58 | 59 | 63 | 64 | 68 | 69 | 73 | 74 | 78 | 79 | 83 | 84 | 88 | 89 | 90 | 91 | 97 | 98 | 100 | 101 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import AppLibrarianTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += AppLibrarianTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftyMarkdownTests/SwiftyMarkdownAttributedStringTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdownAttributedStringTests.swift 3 | // SwiftyMarkdownTests 4 | // 5 | // Created by Simon Fairbairn on 17/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyMarkdown 11 | 12 | class SwiftyMarkdownAttributedStringTests: XCTestCase { 13 | 14 | func testThatAttributesAreAppliedCorrectly() { 15 | 16 | let string = """ 17 | # Heading 1 18 | 19 | A more *complicated* example. This one has **it all**. Here is a [link](http://voyagetravelapps.com/). 20 | 21 | ## Heading 2 22 | 23 | ## Heading 3 24 | 25 | > This one is a blockquote 26 | """ 27 | let md = SwiftyMarkdown(string: string) 28 | let attributedString = md.attributedString() 29 | 30 | XCTAssertNotNil(attributedString) 31 | 32 | XCTAssertEqual(attributedString.string, "Heading 1\n\nA more complicated example. This one has it all. Here is a link.\n\nHeading 2\n\nHeading 3\n\nThis one is a blockquote") 33 | 34 | 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Tests/SwiftyMarkdownTests/SwiftyMarkdownLineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdownTests.swift 3 | // SwiftyMarkdownTests 4 | // 5 | // Created by Simon Fairbairn on 05/03/2016. 6 | // Copyright © 2016 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyMarkdown 11 | 12 | struct StringTest { 13 | let input : String 14 | let expectedOutput : String 15 | var acutalOutput : String = "" 16 | } 17 | 18 | struct TokenTest { 19 | let input : String 20 | let output : String 21 | let tokens : [Token] 22 | } 23 | 24 | class SwiftyMarkdownTests: XCTestCase { 25 | 26 | 27 | override func setUp() { 28 | super.setUp() 29 | // Put setup code here. This method is called before the invocation of each test method in the class. 30 | } 31 | 32 | override func tearDown() { 33 | // Put teardown code here. This method is called after the invocation of each test method in the class. 34 | super.tearDown() 35 | } 36 | 37 | func testThatOctothorpeHeadersAreHandledCorrectly() { 38 | 39 | let heading1 = StringTest(input: "# Heading 1", expectedOutput: "Heading 1") 40 | var smd = SwiftyMarkdown(string:heading1.input ) 41 | XCTAssertEqual(smd.attributedString().string, heading1.expectedOutput) 42 | 43 | let heading2 = StringTest(input: "## Heading 2", expectedOutput: "Heading 2") 44 | smd = SwiftyMarkdown(string:heading2.input ) 45 | XCTAssertEqual(smd.attributedString().string, heading2.expectedOutput) 46 | 47 | let heading3 = StringTest(input: "### #Heading #3", expectedOutput: "#Heading #3") 48 | smd = SwiftyMarkdown(string:heading3.input ) 49 | XCTAssertEqual(smd.attributedString().string, heading3.expectedOutput) 50 | 51 | let heading4 = StringTest(input: " #### #Heading 4 ####", expectedOutput: "#Heading 4") 52 | smd = SwiftyMarkdown(string:heading4.input ) 53 | XCTAssertEqual(smd.attributedString().string, heading4.expectedOutput) 54 | 55 | let heading5 = StringTest(input: " ##### Heading 5 #### ", expectedOutput: "Heading 5 ####") 56 | smd = SwiftyMarkdown(string:heading5.input ) 57 | XCTAssertEqual(smd.attributedString().string, heading5.expectedOutput) 58 | 59 | let heading6 = StringTest(input: " ##### Heading 5 #### More ", expectedOutput: "Heading 5 #### More") 60 | smd = SwiftyMarkdown(string:heading6.input ) 61 | XCTAssertEqual(smd.attributedString().string, heading6.expectedOutput) 62 | 63 | let heading7 = StringTest(input: "# **Bold Header 1** ", expectedOutput: "Bold Header 1") 64 | smd = SwiftyMarkdown(string:heading7.input ) 65 | XCTAssertEqual(smd.attributedString().string, heading7.expectedOutput) 66 | 67 | let heading8 = StringTest(input: "## Header 2 _With Italics_", expectedOutput: "Header 2 With Italics") 68 | smd = SwiftyMarkdown(string:heading8.input ) 69 | XCTAssertEqual(smd.attributedString().string, heading8.expectedOutput) 70 | 71 | let heading9 = StringTest(input: " # Heading 1", expectedOutput: "# Heading 1") 72 | smd = SwiftyMarkdown(string:heading9.input ) 73 | XCTAssertEqual(smd.attributedString().string, heading9.expectedOutput) 74 | 75 | let allHeaders = [heading1, heading2, heading3, heading4, heading5, heading6, heading7, heading8, heading9] 76 | smd = SwiftyMarkdown(string: allHeaders.map({ $0.input }).joined(separator: "\n")) 77 | XCTAssertEqual(smd.attributedString().string, allHeaders.map({ $0.expectedOutput}).joined(separator: "\n")) 78 | 79 | let headerString = StringTest(input: "# Header 1\n## Header 2 ##\n### Header 3 ### \n#### Header 4#### \n##### Header 5\n###### Header 6", expectedOutput: "Header 1\nHeader 2\nHeader 3\nHeader 4\nHeader 5\nHeader 6") 80 | smd = SwiftyMarkdown(string: headerString.input) 81 | XCTAssertEqual(smd.attributedString().string, headerString.expectedOutput) 82 | 83 | let headerStringWithBold = StringTest(input: "# **Bold Header 1**", expectedOutput: "Bold Header 1") 84 | smd = SwiftyMarkdown(string: headerStringWithBold.input) 85 | XCTAssertEqual(smd.attributedString().string, headerStringWithBold.expectedOutput ) 86 | 87 | let headerStringWithItalic = StringTest(input: "## Header 2 _With Italics_", expectedOutput: "Header 2 With Italics") 88 | smd = SwiftyMarkdown(string: headerStringWithItalic.input) 89 | XCTAssertEqual(smd.attributedString().string, headerStringWithItalic.expectedOutput) 90 | 91 | } 92 | 93 | 94 | func testThatUndelinedHeadersAreHandledCorrectly() { 95 | 96 | let h1String = StringTest(input: "Header 1\n===\nSome following text", expectedOutput: "Header 1\nSome following text") 97 | var md = SwiftyMarkdown(string: h1String.input) 98 | XCTAssertEqual(md.attributedString().string, h1String.expectedOutput) 99 | 100 | let h2String = StringTest(input: "Header 2\n---\nSome following text", expectedOutput: "Header 2\nSome following text") 101 | md = SwiftyMarkdown(string: h2String.input) 102 | XCTAssertEqual(md.attributedString().string, h2String.expectedOutput) 103 | 104 | let h1StringWithBold = StringTest(input: "Header 1 **With Bold**\n===\nSome following text", expectedOutput: "Header 1 With Bold\nSome following text") 105 | md = SwiftyMarkdown(string: h1StringWithBold.input) 106 | XCTAssertEqual(md.attributedString().string, h1StringWithBold.expectedOutput) 107 | 108 | let h2StringWithItalic = StringTest(input: "Header 2 _With Italic_\n---\nSome following text", expectedOutput: "Header 2 With Italic\nSome following text") 109 | md = SwiftyMarkdown(string: h2StringWithItalic.input) 110 | XCTAssertEqual(md.attributedString().string, h2StringWithItalic.expectedOutput) 111 | 112 | let h2StringWithCode = StringTest(input: "Header 2 `With Code`\n---\nSome following text", expectedOutput: "Header 2 With Code\nSome following text") 113 | md = SwiftyMarkdown(string: h2StringWithCode.input) 114 | XCTAssertEqual(md.attributedString().string, h2StringWithCode.expectedOutput) 115 | } 116 | 117 | func testThatUnorderedListsAreHandledCorrectly() { 118 | let dashBullets = StringTest(input: "An Unordered List\n- Item 1\n\t- Indented\n- Item 2", expectedOutput: "An Unordered List\n-\tItem 1\n\t-\tIndented\n-\tItem 2") 119 | var md = SwiftyMarkdown(string: dashBullets.input) 120 | md.bullet = "-" 121 | XCTAssertEqual(md.attributedString().string, dashBullets.expectedOutput) 122 | 123 | let starBullets = StringTest(input: "An Unordered List\n* Item 1\n\t* Indented\n* Item 2", expectedOutput: "An Unordered List\n-\tItem 1\n\t-\tIndented\n-\tItem 2") 124 | md = SwiftyMarkdown(string: starBullets.input) 125 | md.bullet = "-" 126 | XCTAssertEqual(md.attributedString().string, starBullets.expectedOutput) 127 | 128 | } 129 | 130 | func testThatOrderedListsAreHandled() { 131 | let dashBullets = StringTest(input: "An Ordered List\n1. Item 1\n\t1. Indented\n1. Item 2", expectedOutput: "An Ordered List\n1.\tItem 1\n\t1.\tIndented\n2.\tItem 2") 132 | var md = SwiftyMarkdown(string: dashBullets.input) 133 | XCTAssertEqual(md.attributedString().string, dashBullets.expectedOutput) 134 | 135 | let moreComplicatedList = StringTest(input: """ 136 | A long ordered list: 137 | 138 | 1. Item 1 139 | 1. Item 2 140 | 1. First Indent 1 141 | 1. First Indent 2 142 | 1. Second Indent 1 143 | 1. First Indent 3 144 | 1. Second Indent 2 145 | 1. Item 3 146 | 147 | A break 148 | 149 | 1. Item 1 150 | 1. Item 2 151 | """, expectedOutput: """ 152 | A long ordered list: 153 | 154 | 1. Item 1 155 | 2. Item 2 156 | 1. First Indent 1 157 | 2. First Indent 2 158 | 1. Second Indent 1 159 | 3. First Indent 3 160 | 1. Second Indent 2 161 | 3. Item 3 162 | 163 | A break 164 | 165 | 1. Item 1 166 | 2. Item 2 167 | """) 168 | md = SwiftyMarkdown(string: moreComplicatedList.input) 169 | XCTAssertEqual(md.attributedString().string, moreComplicatedList.expectedOutput) 170 | 171 | } 172 | 173 | 174 | 175 | 176 | /* 177 | The reason for this test is because the list of items dropped every other item in bullet lists marked with "-" 178 | The faulty result was: "A cool title\n \n- Här har vi svenska ÅÄÖåäö tecken\n \nA Link" 179 | As you can see, "- Point number one" and "- Point number two" are mysteriously missing. 180 | It incorrectly identified rows as `Alt-H2` 181 | */ 182 | func offtestInternationalCharactersInList() { 183 | 184 | let expected = "A cool title\n\n- Point number one\n- Här har vi svenska ÅÄÖåäö tecken\n- Point number two\n \nA Link" 185 | let input = "# A cool title\n\n- Point number one\n- Här har vi svenska ÅÄÖåäö tecken\n- Point number two\n\n[A Link](http://dooer.com)" 186 | let output = SwiftyMarkdown(string: input).attributedString().string 187 | 188 | XCTAssertEqual(output, expected) 189 | 190 | } 191 | 192 | 193 | 194 | func testThatYAMLMetadataIsRemoved() { 195 | let yaml = StringTest(input: "---\nlayout: page\ntitle: \"Trail Wallet FAQ\"\ndate: 2015-04-22 10:59\ncomments: true\nsharing: true\nliking: false\nfooter: true\nsidebar: false\n---\n# Finally some Markdown!\n\nWith A Heading\n---", expectedOutput: "Finally some Markdown!\n\nWith A Heading") 196 | let md = SwiftyMarkdown(string: yaml.input) 197 | XCTAssertEqual(md.attributedString().string, yaml.expectedOutput) 198 | XCTAssertEqual(md.frontMatterAttributes.count, 8) 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /Tests/SwiftyMarkdownTests/SwiftyMarkdownPerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyMarkdownAttributedStringTests.swift 3 | // SwiftyMarkdownTests 4 | // 5 | // Created by Simon Fairbairn on 17/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyMarkdown 11 | 12 | class SwiftyMarkdownPerformanceTests: XCTestCase { 13 | 14 | func testThatFilesAreProcessedQuickly() { 15 | 16 | let url = self.resourceURL(for: "test.md") 17 | 18 | measure { 19 | guard let md = SwiftyMarkdown(url: url) else { 20 | XCTFail("Failed to load file") 21 | return 22 | } 23 | _ = md.attributedString() 24 | } 25 | 26 | } 27 | 28 | func testThatStringsAreProcessedQuickly() { 29 | let string = "SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use." 30 | let md = SwiftyMarkdown(string: string) 31 | measure { 32 | _ = md.attributedString(from: string) 33 | } 34 | } 35 | 36 | func testThatVeryLongStringsAreProcessedQuickly() { 37 | let string = "SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use. SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use. SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use. SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use. SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use. SwiftyMarkdown converts Markdown files and strings into `NSAttributedString`s using sensible defaults and a *Swift*-style syntax. It uses **dynamic type** to set the font size correctly with [whatever](https://www.neverendingvoyage.com/) font you'd like to use." 38 | let md = SwiftyMarkdown(string: string) 39 | measure { 40 | _ = md.attributedString(from: string) 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Tests/SwiftyMarkdownTests/XCTest+SwiftyMarkdown.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTest+SwiftyMarkdown.swift 3 | // SwiftyMarkdownTests 4 | // 5 | // Created by Simon Fairbairn on 17/12/2019. 6 | // Copyright © 2019 Voyage Travel Apps. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyMarkdown 11 | 12 | 13 | struct ChallengeReturn { 14 | let tokens : [Token] 15 | let stringTokens : [Token] 16 | let links : [Token] 17 | let images : [Token] 18 | let attributedString : NSAttributedString 19 | let foundStyles : [[CharacterStyle]] 20 | let expectedStyles : [[CharacterStyle]] 21 | } 22 | 23 | enum Rule { 24 | case asterisks 25 | case backticks 26 | case underscores 27 | case images 28 | case links 29 | case referencedLinks 30 | case referencedImages 31 | case tildes 32 | 33 | func asCharacterRule() -> CharacterRule { 34 | switch self { 35 | case .images: 36 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "![" && !$0.metadataLookup }).first! 37 | case .links: 38 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "[" && !$0.metadataLookup }).first! 39 | case .backticks: 40 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "`" }).first! 41 | case .tildes: 42 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "~" }).first! 43 | case .asterisks: 44 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "*" }).first! 45 | case .underscores: 46 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "_" }).first! 47 | case .referencedLinks: 48 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "[" && $0.metadataLookup }).first! 49 | case .referencedImages: 50 | return SwiftyMarkdown.characterRules.filter({ $0.primaryTag.tag == "![" && $0.metadataLookup }).first! 51 | } 52 | } 53 | } 54 | 55 | class SwiftyMarkdownCharacterTests : XCTestCase { 56 | let defaultRules = SwiftyMarkdown.characterRules 57 | 58 | var challenge : TokenTest! 59 | var results : ChallengeReturn! 60 | 61 | func attempt( _ challenge : TokenTest, rules : [Rule]? = nil ) -> ChallengeReturn { 62 | if let validRules = rules { 63 | SwiftyMarkdown.characterRules = validRules.map({ $0.asCharacterRule() }) 64 | } else { 65 | SwiftyMarkdown.characterRules = self.defaultRules 66 | } 67 | 68 | let md = SwiftyMarkdown(string: challenge.input) 69 | md.applyAttachments = false 70 | let attributedString = md.attributedString() 71 | let tokens : [Token] = md.previouslyFoundTokens 72 | let stringTokens = tokens.filter({ $0.type == .string && !$0.isMetadata }) 73 | 74 | let existentTokenStyles = stringTokens.compactMap({ $0.characterStyles as? [CharacterStyle] }) 75 | let expectedStyles = challenge.tokens.compactMap({ $0.characterStyles as? [CharacterStyle] }) 76 | 77 | let linkTokens = tokens.filter({ $0.type == .string && (($0.characterStyles as? [CharacterStyle])?.contains(.link) ?? false) }) 78 | let imageTokens = tokens.filter({ $0.type == .string && (($0.characterStyles as? [CharacterStyle])?.contains(.image) ?? false) }) 79 | 80 | return ChallengeReturn(tokens: tokens, stringTokens: stringTokens, links : linkTokens, images: imageTokens, attributedString: attributedString, foundStyles: existentTokenStyles, expectedStyles : expectedStyles) 81 | } 82 | } 83 | 84 | 85 | extension XCTestCase { 86 | 87 | func resourceURL(for filename : String ) -> URL { 88 | let thisSourceFile = URL(fileURLWithPath: #file) 89 | let thisDirectory = thisSourceFile.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() 90 | return thisDirectory.appendingPathComponent("Resources", isDirectory: true).appendingPathComponent(filename) 91 | } 92 | 93 | 94 | } 95 | 96 | 97 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier "com.voyagetravelapps.Stormcloud" # The bundle identifier of your app 2 | apple_id "simon@voyagetravelapps.com" # Your Apple email address 3 | 4 | # You can uncomment any of the lines below and add your own 5 | # team selection in case you're in multiple teams 6 | # team_name "Felix Krause" 7 | # team_id "Q2CBPJ58CA" 8 | 9 | 10 | # you can even provide different app identifiers, Apple IDs and team names per lane: 11 | # https://github.com/KrauseFx/fastlane/blob/master/docs/Appfile.md 12 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # Customise this file, documentation can be found here: 2 | # https://github.com/KrauseFx/fastlane/tree/master/docs 3 | # All available actions: https://github.com/KrauseFx/fastlane/blob/master/docs/Actions.md 4 | # can also be listed using the `fastlane actions` command 5 | 6 | # Change the syntax highlighting to Ruby 7 | # All lines starting with a # are ignored when running `fastlane` 8 | 9 | # By default, fastlane will send which actions are used 10 | # No personal data is shared, more information on https://github.com/fastlane/enhancer 11 | # Uncomment the following line to opt out 12 | # opt_out_usage 13 | 14 | # If you want to automatically update fastlane if a new version is available: 15 | # update_fastlane 16 | 17 | # This is the minimum version number required. 18 | # Update this, if you use features of a newer version 19 | $scheme = "SwiftyMarkdown" 20 | $spec = "SwiftyMarkdown.podspec" 21 | $project = './SwiftyMarkdown.xcodeproj' 22 | 23 | import "/Users/simon/Developer/Fastlane/FastfilePods" -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios patch 19 | 20 | ```sh 21 | [bundle exec] fastlane ios patch 22 | ``` 23 | 24 | This does the following: 25 | 26 | 27 | 28 | - Runs the unit tests 29 | 30 | - Ensures Cocoapods compatibility 31 | 32 | - Bumps the patch version 33 | 34 | ### ios minor 35 | 36 | ```sh 37 | [bundle exec] fastlane ios minor 38 | ``` 39 | 40 | This does the following: 41 | 42 | 43 | 44 | - Runs the unit tests 45 | 46 | - Ensures Cocoapods compatibility 47 | 48 | - Bumps the minor version 49 | 50 | ### ios major 51 | 52 | ```sh 53 | [bundle exec] fastlane ios major 54 | ``` 55 | 56 | This does the following: 57 | 58 | 59 | 60 | - Runs the unit tests 61 | 62 | - Ensures Cocoapods compatibility 63 | 64 | - Bumps the major version 65 | 66 | ### ios test 67 | 68 | ```sh 69 | [bundle exec] fastlane ios test 70 | ``` 71 | 72 | 73 | 74 | ### ios submit_pod 75 | 76 | ```sh 77 | [bundle exec] fastlane ios submit_pod 78 | ``` 79 | 80 | Push the repo to remote and submits the Pod to the given spec repository. Do this after running update to run tests, bump versions, and commit changes. 81 | 82 | ---- 83 | 84 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 85 | 86 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 87 | 88 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 89 | --------------------------------------------------------------------------------