├── .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 | 
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: "", 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** "
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: "", 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 | 
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 |
--------------------------------------------------------------------------------