├── .github └── workflows │ ├── ci.yml │ └── documentation.yml ├── .gitignore ├── Changelog.md ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── HypertextLiteral │ ├── Extensions │ └── Swift+HyperTextConvertible.swift │ ├── HTML.swift │ └── Protocols │ ├── HypertextAttributeValueInterpolatable.swift │ ├── HypertextAttributesInterpolatable.swift │ └── HypertextLiteralConvertible.swift └── Tests ├── HypertextLiteralTests └── HypertextLiteralTests.swift └── LinuxMain.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | macos: 7 | runs-on: macOS-latest 8 | 9 | strategy: 10 | matrix: 11 | xcode: 12 | - "11.3.1" # Swift 5.1 13 | - "11.7" # Swift 5.2 14 | - "12" # Swift 5.3 15 | destination: 16 | - platform=macOS 17 | - platform=iOS Simulator,name=iPhone 11 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v1 22 | - name: Generate Xcode Project 23 | run: swift package generate-xcodeproj 24 | - name: Run Test Target 25 | run: xcodebuild test -destination '${{ matrix.destination }}' -scheme HypertextLiteral-Package 26 | env: 27 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 28 | 29 | linux: 30 | runs-on: ubuntu-latest 31 | 32 | strategy: 33 | matrix: 34 | swift: ["5.1", "5.2", "5.3"] 35 | 36 | container: 37 | image: swift:${{ matrix.swift }} 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v1 42 | - name: Build and Test 43 | run: swift test --enable-test-discovery 44 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - .github/workflows/documentation.yml 9 | - Sources/**.swift 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v1 18 | - name: Generate Documentation 19 | uses: SwiftDocOrg/swift-doc@master 20 | with: 21 | inputs: "Sources" 22 | output: "Documentation" 23 | - name: Upload Documentation to Wiki 24 | uses: SwiftDocOrg/github-wiki-publish-action@master 25 | with: 26 | path: "Documentation" 27 | env: 28 | GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm/ 7 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.0.3] - 2020-09-24 11 | 12 | ### Fixed 13 | 14 | - Fixed handling of boolean attributes. 15 | #3 by @siemensikkema. 16 | 17 | ### Changed 18 | 19 | - Changed CI to test for Swift 5.1, 5.2, and 5.3 20 | #4 by @mattt and @MaxDesiatov. 21 | 22 | ## [0.0.2] - 2020-03-12 23 | 24 | ### Fixed 25 | 26 | - Fixed building on platforms other than macOS. 27 | #1 by @MaxDesiatov. 28 | 29 | ### Changed 30 | 31 | - Changed labeled string interpolation methods to have public access level. 32 | 52fca8f by @mattt. 33 | 34 | ## [0.0.1] - 2020-02-19 35 | 36 | Initial release. 37 | 38 | [unreleased]: https://github.com/NSHipster/HypertextLiteral/compare/0.0.3...master 39 | [0.0.3]: https://github.com/NSHipster/HypertextLiteral/releases/tag/0.0.3 40 | [0.0.2]: https://github.com/NSHipster/HypertextLiteral/releases/tag/0.0.2 41 | [0.0.1]: https://github.com/NSHipster/HypertextLiteral/releases/tag/0.0.1 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Read Evaluate Press, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "HypertextLiteral", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "HypertextLiteral", 12 | targets: ["HypertextLiteral"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "HypertextLiteral", 23 | dependencies: []), 24 | .testTarget( 25 | name: "HypertextLiteralTests", 26 | dependencies: ["HypertextLiteral"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HypertextLiteral 2 | 3 | ![CI][ci badge] 4 | [![Documentation][documentation badge]][documentation] 5 | 6 | **HypertextLiteral** is a Swift package for 7 | generating HTML, XML, and [other SGML dialects](#support-for-other-formats). 8 | 9 | It uses [custom string interpolation][expressiblebystringinterpolation] 10 | to append and escape values _based on context_, 11 | with built-in affordances for common patterns 12 | and an extensible architecture for defining your own behavior. 13 | 14 | ```swift 15 | import HypertextLiteral 16 | 17 | let attributes = [ 18 | "style": [ 19 | "background": "yellow", 20 | "font-weight": "bold" 21 | ] 22 | ] 23 | 24 | let html: HTML = "whoa" 25 | // whoa 26 | ``` 27 | 28 | HypertextLiteral is small and self-contained with no external dependencies. 29 | You can get up to speed in just a few minutes, 30 | without needing to learn any new APIs or domain-specific languages (DSLs). 31 | Less time fighting your tools means more time spent generating web content. 32 | 33 | > This project is inspired by and borrows implementation details from 34 | > [Hypertext Literal][htl] by Mike Bostock ([@mbostock][@mbostock]). 35 | > You can read more about it [here][observablehq]. 36 | 37 | ## Requirements 38 | 39 | - Swift 5.1+ 40 | 41 | ## Installation 42 | 43 | ### Swift Package Manager 44 | 45 | Add the HypertextLiteral package to your target dependencies in `Package.swift`: 46 | 47 | ```swift 48 | import PackageDescription 49 | 50 | let package = Package( 51 | name: "YourProject", 52 | dependencies: [ 53 | .package( 54 | url: "https://github.com/NSHipster/HypertextLiteral", 55 | from: "0.0.3" 56 | ), 57 | ] 58 | ) 59 | ``` 60 | 61 | Then run the `swift build` command to build your project. 62 | 63 | ## Usage 64 | 65 | Hypertext literals automatically escape interpolated values 66 | based on the context in which they appear. 67 | 68 | * By default, 69 | interpolated content escapes the [XML entities][xml entities] 70 | `<`, `>`, `&`, `"`, and `'` 71 | as named character references 72 | (for example, `<` becomes `<`) 73 | * In the context of an attribute value, 74 | quotation marks are escaped with a backslash (`\"`) 75 | * In a context of comment, 76 | any start and end delimiters (``) are removed 77 | 78 | ### Interpolating Content 79 | 80 | To get a better sense of how this works in practice, 81 | consider the following examples: 82 | 83 | ```swift 84 | let level: Int = 1 85 | "Hello, world!" as HTML 86 | //

Hello, world!

87 | 88 | let elementName: String = "h1" 89 | "<\(elementName)>Hello, world!" as HTML 90 | //

Hello, world!

91 | 92 | let startTag: String = "

", endTag: String = "

" 93 | "\(startTag)Hello, world!\(endTag)" as HTML 94 | // <h1>Hello, world!</h1> 95 | ``` 96 | 97 | Interpolation for an element's name in part or whole work as intended, 98 | but interpolation of the tag itself causes the string to have its 99 | angle bracket (`<` and `>`) escaped. 100 | 101 | When you don't want this to happen, 102 | such as when you're embedding HTML content in a template, 103 | you can either pass that content as an `HTML` value 104 | or interpolate using the `unsafeUnescaped` argument label. 105 | 106 | ```swift 107 | let startTag: HTML = "

", endTag: HTML = "

" 108 | "\(startTag)Hello, world!\(endTag)" as HTML 109 | //

Hello, world!

110 | 111 | "\(unsafeUnescaped: "

")Hello, world!\(unsafeUnescaped: "

")" as HTML 112 | //

Hello, world!

113 | ``` 114 | 115 | > **Note**: 116 | > Interpolation with the `unsafeUnescaped` argument label 117 | > appends the provided literal directly, 118 | > which may lead to invalid results. 119 | > For this reason, 120 | > use of `HTML` values for composition is preferred. 121 | 122 | ### Interpolating Attribute Values 123 | 124 | Attributes in hypertext literals may be interchangeably specified 125 | with or without quotation marks, either single (`'`) or double (`"`). 126 | 127 | ```swift 128 | let id: String = "logo 129 | let title: String = #"Swift.org | "Welcome to Swift.org""# 130 | let url = URL(string: "https://swift.org/")! 131 | 132 | #"Swift.org"# as HTML 133 | /* 136 | */ 137 | ``` 138 | 139 | Some attributes have special, built-in rules for value interpolation. 140 | 141 | When you interpolate an array of strings for an element's `class` attribute, 142 | the resulting value is a space-delimited list. 143 | 144 | ```swift 145 | let classNames: [String] = ["alpha", "bravo", "charlie"] 146 | 147 | "
" as HTML 148 | //
149 | ``` 150 | 151 | If you interpolate a dictionary for the value of an element's `style` attribute, 152 | it's automatically converted to CSS. 153 | 154 | ```swift 155 | let style: [String: Any] = [ 156 | "background": "orangered", 157 | "font-weight": 700 158 | ] 159 | 160 | "Swift" as HTML 161 | // Swift 162 | ``` 163 | 164 | The Boolean value `true` interpolates to different values depending the attribute. 165 | 166 | ```swift 167 | """ 168 | 172 | """ as HTML 173 | /* */ 177 | ``` 178 | 179 | ### Interpolating Attributes with Dictionaries 180 | 181 | You can specify multiple attributes at once 182 | by interpolating dictionaries keyed by strings. 183 | 184 | ```swift 185 | let attributes: [String: Any] = [ 186 | "id": "primary", 187 | "class": ["alpha", "bravo", "charlie"], 188 | "style": [ 189 | "font-size": "larger" 190 | ] 191 | ] 192 | 193 | "
" as HTML 194 | /*
*/ 197 | ``` 198 | 199 | Attributes with a common `aria-` or `data-` prefix 200 | can be specified with a nested dictionary. 201 | 202 | ```swift 203 | let attributes: [String: Any] = [ 204 | "id": "article", 205 | "aria": [ 206 | "role": "article", 207 | ], 208 | "data": [ 209 | "index": 1, 210 | "count": 3, 211 | ] 212 | ] 213 | 214 | "
" as HTML 215 | /*
*/ 219 | ``` 220 | 221 | ### Support for Other Formats 222 | 223 | In addition to HTML, 224 | you can use hypertext literals for [XML][xml] and other [SGML][sgml] formats. 225 | Below is an example of how `HypertextLiteral` can be used 226 | to generate an SVG document. 227 | 228 | ```swift 229 | import HypertextLiteral 230 | 231 | typealias SVG = HTML 232 | 233 | let groupAttributes: [String: Any] = [ 234 | "stroke-width": 3, 235 | "stroke": "#FFFFEE" 236 | ] 237 | 238 | func box(_ rect: CGRect, radius: CGVector = CGVector(dx: 10, dy: 10), attributes: [String: Any] = [:]) -> SVG { 239 | #""" 240 | 244 | """# 245 | } 246 | 247 | let svg: SVG = #""" 248 | 249 | 250 | 251 | \#(box(CGRect(x: 12, y: 28, width: 60, height: 60), attributes: ["fill": "#F06507"])) 252 | \#(box(CGRect(x: 27, y: 18, width: 55, height: 55), attributes: ["fill": "#F2A02D"])) 253 | \#(box(CGRect(x: 47, y: 11, width: 40, height: 40), attributes: ["fill": "#FEC352"])) 254 | 255 | 256 | """# 257 | ``` 258 | 259 | ## License 260 | 261 | MIT 262 | 263 | ## Contact 264 | 265 | Mattt ([@mattt](https://twitter.com/mattt)) 266 | 267 | [expressiblebystringinterpolation]: https://nshipster.com/expressiblebystringinterpolation/ 268 | [htl]: https://github.com/observablehq/htl 269 | [@mbostock]: https://github.com/mbostock 270 | [observablehq]: https://observablehq.com/@observablehq/htl 271 | [xml entities]: https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references 272 | [named character references]: https://html.spec.whatwg.org/multipage/named-characters.html#named-character-references 273 | [xml]: https://en.wikipedia.org/wiki/XML 274 | [sgml]: https://en.wikipedia.org/wiki/Standard_Generalized_Markup_Language 275 | [svg]: https://en.wikipedia.org/wiki/Scalable_Vector_Graphics 276 | 277 | [ci badge]: https://github.com/NSHipster/HypertextLiteral/workflows/CI/badge.svg 278 | [documentation badge]: https://github.com/NSHipster/HypertextLiteral/workflows/Documentation/badge.svg 279 | [documentation]: https://github.com/NSHipster/HypertextLiteral/wiki 280 | -------------------------------------------------------------------------------- /Sources/HypertextLiteral/Extensions/Swift+HyperTextConvertible.swift: -------------------------------------------------------------------------------- 1 | #if !SKIP_DEFAULT_HYPER_TEXT_CONVERTIBLE_CONFORMANCES 2 | 3 | extension Array: HypertextLiteralConvertible where Element: HypertextLiteralConvertible { 4 | public var html: HTML { 5 | return HTML(map { $0.html.description }.joined(separator: "\n")) 6 | } 7 | } 8 | 9 | extension Array: HypertextAttributesInterpolatable where Element: HypertextAttributesInterpolatable { 10 | public func html(in element: String) -> HTML { 11 | return HTML(map { $0.html(in: element).description }.joined(separator: " ")) 12 | } 13 | } 14 | 15 | extension Array: HypertextAttributeValueInterpolatable where Element: StringProtocol { 16 | public func html(for attribute: String, in element: String) -> HTML? { 17 | switch attribute { 18 | case "class": 19 | return HTML(map { String($0) }.joined(separator: " ")) 20 | default: 21 | return nil 22 | } 23 | } 24 | } 25 | 26 | extension Bool: HypertextAttributeValueInterpolatable { 27 | public func html(for attribute: String, in element: String) -> HTML? { 28 | switch attribute { 29 | case // Global Attributes 30 | "contenteditable", 31 | "hidden", 32 | "spellcheck", 33 | // Media Attributes 34 | "autoplay", 35 | "controls", 36 | "loop", 37 | "muted", 38 | "preload", 39 | // Input Attributes 40 | "autofocus", 41 | "disabled", 42 | "multiple", 43 | "readonly", 44 | "required", 45 | "selected", 46 | "wrap", 47 | // Script Attributes 48 | "async", 49 | "defer": 50 | return self ? HTML(attribute) : nil 51 | case "translate": 52 | return HTML(self ? "yes" : "no") 53 | case "autocomplete": 54 | return HTML(self ? "on" : "off") 55 | default: 56 | return HTML(self ? "true" : "false") 57 | } 58 | } 59 | } 60 | 61 | extension Dictionary: HypertextAttributesInterpolatable where Key: StringProtocol { 62 | public func html(in element: String) -> HTML { 63 | func attribute(for key: String, value: Any) -> (name: String, value: String)? { 64 | guard let interpolatableValue = value as? HypertextAttributeValueInterpolatable else { 65 | return (key, "\(value)") 66 | } 67 | 68 | return interpolatableValue.html(for: key, in: element).map { (key, $0.description) } 69 | } 70 | 71 | let attributes = flatMap { (key, value) -> [(name: String, value: String)] in 72 | switch key { 73 | case "aria", "data": 74 | guard let value = value as? [String: Any] else { fallthrough } 75 | return value.compactMap { (nestedKey, nestedValue) in 76 | attribute(for: "\(key)-\(nestedKey)", value: nestedValue) 77 | } 78 | default: 79 | return [attribute(for: "\(key)", value: value)].compactMap { $0 } 80 | } 81 | } 82 | 83 | return HTML(attributes.sorted(by: { $0.0 < $1.0 }).map { #"\#($0.0)="\#($0.1)""# }.joined(separator: " ")) 84 | } 85 | } 86 | 87 | extension Dictionary: HypertextAttributeValueInterpolatable where Key: StringProtocol { 88 | public func html(for attribute: String, in element: String) -> HTML? { 89 | switch attribute { 90 | case "style": 91 | return HTML(map { "\($0.key): \($0.value);" }.sorted().joined(separator: " ")) 92 | default: 93 | return nil 94 | } 95 | } 96 | } 97 | 98 | #endif 99 | -------------------------------------------------------------------------------- /Sources/HypertextLiteral/HTML.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | An object whose content can be created using string interpolation 5 | in a way that interprets values according to the context 6 | at which the interpolation occurs. 7 | 8 | For more information, 9 | see [this project's README](https://github.com/NSHipster/HypertextLiteral). 10 | */ 11 | public struct HTML: LosslessStringConvertible, Equatable, Hashable { 12 | /// The HTML content. 13 | public var description: String 14 | 15 | /** 16 | Creates an HTML object with the specified content. 17 | - Parameter description: The HTML content. 18 | */ 19 | public init(_ description: String) { 20 | self.description = description 21 | } 22 | } 23 | 24 | // MARK: - HypertextConvertible 25 | 26 | extension HTML: HypertextLiteralConvertible { 27 | /// A representation of this instance in a hypertext literal. 28 | public var html: HTML { 29 | return self 30 | } 31 | } 32 | 33 | // MARK: - 34 | 35 | extension HTML { 36 | class Parser { 37 | private enum State { 38 | case text 39 | case tagOpen 40 | case elementName 41 | case afterElementName 42 | case attributeName 43 | case afterAttributeName 44 | case beforeAttributeValue 45 | case attributeValue(QuotationMark?) 46 | case afterAttributeValue 47 | case selfClosingTag 48 | case beforeComment 49 | case comment 50 | case unsupportedEntity 51 | } 52 | 53 | enum QuotationMark: Character { 54 | case single = "'" 55 | case double = "\"" 56 | } 57 | 58 | enum Disposition { 59 | case text 60 | case element(elementName: String) 61 | case attribute(elementName: String, attributeName: String, quotationMarks: QuotationMark?) 62 | case comment 63 | } 64 | 65 | private var state: State = .text 66 | 67 | func parse(_ string: String) -> Disposition { 68 | var skipNext: Int = 0 69 | 70 | var elementName: String? 71 | var attributeName: String? 72 | 73 | for (index, character) in zip(string.indices, string) { 74 | guard skipNext <= 0 else { 75 | skipNext -= 1 76 | continue 77 | } 78 | 79 | func willNextScan(_ value: Target) -> Bool where Target: StringProtocol { 80 | guard !value.isEmpty else { return false } 81 | guard let startIndex = string.index(index, offsetBy: 1, limitedBy: string.endIndex), 82 | let endIndex = string.index(index, offsetBy: value.count + 1, limitedBy: string.endIndex), 83 | string[startIndex.."): 107 | state = .text 108 | case "a"..."z", "A"..."Z", "-": 109 | state = .elementName 110 | continue redo 111 | case "?": 112 | state = .unsupportedEntity 113 | continue redo 114 | default: 115 | state = .text 116 | continue redo 117 | } 118 | case .elementName: 119 | switch character { 120 | case _ where character.isWhitespace: 121 | state = .afterElementName 122 | case "/": 123 | state = .selfClosingTag 124 | case ">": 125 | state = .text 126 | default: 127 | elementName += character 128 | } 129 | case .afterElementName: 130 | switch character { 131 | case _ where character.isWhitespace: 132 | break 133 | case "/", ">": 134 | state = .afterAttributeName 135 | continue redo 136 | case "=": 137 | state = .beforeAttributeValue 138 | default: 139 | state = .attributeName 140 | continue redo 141 | } 142 | case .attributeName: 143 | switch character { 144 | case "/", ">": 145 | fallthrough 146 | case _ where character.isWhitespace: 147 | state = .afterAttributeName 148 | continue redo 149 | case "=": 150 | state = .beforeAttributeValue 151 | default: 152 | attributeName += character 153 | } 154 | case .afterAttributeName: 155 | switch character { 156 | case _ where character.isWhitespace: 157 | break 158 | case "/": 159 | state = .selfClosingTag 160 | case "=": 161 | state = .beforeAttributeValue 162 | case ">": 163 | state = .text 164 | default: 165 | attributeName = nil 166 | state = .attributeName 167 | continue redo 168 | } 169 | case .beforeAttributeValue: 170 | switch character { 171 | case _ where character.isWhitespace: 172 | break 173 | case ">": 174 | state = .text 175 | default: 176 | let quotationMark = QuotationMark(rawValue: character) 177 | state = .attributeValue(quotationMark) 178 | if quotationMark == nil { 179 | continue redo 180 | } 181 | } 182 | case .attributeValue(let quotationMark?): 183 | if character == quotationMark.rawValue { 184 | attributeName = nil 185 | state = .afterAttributeValue 186 | } 187 | case .attributeValue: 188 | switch character { 189 | case _ where character.isWhitespace: 190 | attributeName = nil 191 | state = .afterAttributeValue 192 | case ">": 193 | state = .text 194 | default: 195 | break 196 | } 197 | case .afterAttributeValue: 198 | switch character { 199 | case _ where character.isWhitespace: 200 | state = .afterElementName 201 | case "/": 202 | state = .selfClosingTag 203 | case ">": 204 | state = .text 205 | default: 206 | state = .afterElementName 207 | continue redo 208 | } 209 | case .selfClosingTag: 210 | switch character { 211 | case ">": 212 | elementName = nil 213 | attributeName = nil 214 | state = .text 215 | default: 216 | state = .afterElementName 217 | continue redo 218 | } 219 | case .unsupportedEntity: 220 | if character == ">" { 221 | state = .text 222 | } 223 | case .beforeComment: 224 | switch character { 225 | case "-": 226 | state = .beforeComment 227 | case ">": 228 | state = .text 229 | default: 230 | state = .comment 231 | continue redo 232 | } 233 | case .comment: 234 | if willNextScan("-->") { 235 | state = .text 236 | } else { 237 | state = .comment 238 | } 239 | } 240 | 241 | break 242 | } while true 243 | } 244 | 245 | switch state { 246 | case .afterElementName: 247 | return .element(elementName: elementName ?? "") 248 | case .attributeValue(let quotationMarks): 249 | return .attribute(elementName: elementName ?? "", attributeName: attributeName ?? "", quotationMarks: quotationMarks) 250 | case .beforeAttributeValue, .afterAttributeValue: 251 | return .attribute(elementName: elementName ?? "", attributeName: attributeName ?? "", quotationMarks: .none) 252 | case .comment, .beforeComment: 253 | return .comment 254 | default: 255 | return .text 256 | } 257 | } 258 | } 259 | } 260 | 261 | // MARK: - Comparable 262 | 263 | extension HTML: Comparable { 264 | public static func < (lhs: HTML, rhs: HTML) -> Bool { 265 | return lhs.description < rhs.description 266 | } 267 | } 268 | 269 | // MARK: - Codable 270 | 271 | extension HTML: Codable { 272 | public init(decoder: Decoder) throws { 273 | let container = try decoder.singleValueContainer() 274 | self.description = try container.decode(String.self) 275 | } 276 | 277 | public func encode(to encoder: Encoder) throws { 278 | var container = encoder.singleValueContainer() 279 | try container.encode(description) 280 | } 281 | } 282 | 283 | // MARK: - ExpressibleByStringLiteral 284 | 285 | extension HTML: ExpressibleByStringLiteral { 286 | public init(stringLiteral value: String) { 287 | self.init(value) 288 | } 289 | } 290 | 291 | // MARK: - ExpressibleByStringInterpolation 292 | 293 | extension HTML: ExpressibleByStringInterpolation { 294 | public init(stringInterpolation: StringInterpolation) { 295 | self.init(stringInterpolation.value) 296 | } 297 | 298 | /// The type each segment of a string literal containing interpolations should be appended to. 299 | public struct StringInterpolation: StringInterpolationProtocol { 300 | fileprivate var value: String = "" 301 | 302 | private var parser = Parser() 303 | private var disposition: Parser.Disposition = .text 304 | 305 | public init(literalCapacity: Int, interpolationCount: Int) { 306 | self.value.reserveCapacity(literalCapacity) 307 | } 308 | 309 | public mutating func appendLiteral(_ literal: String) { 310 | disposition = parser.parse(literal) 311 | self.value.append(literal) 312 | } 313 | 314 | public mutating func appendInterpolation(_ value: T) where T: CustomStringConvertible { 315 | switch disposition { 316 | case .text: 317 | switch value { 318 | case let value as HypertextLiteralConvertible: 319 | appendLiteral(value.html.description) 320 | default: 321 | appendLiteral(value.description.escaped) 322 | } 323 | case let .element(elementName): 324 | switch value { 325 | case let value as HypertextAttributesInterpolatable: 326 | appendLiteral(value.html(in: elementName).description) 327 | default: 328 | appendLiteral(value.description.escaped) 329 | } 330 | case let .attribute(elementName, attributeName, quotationMarks): 331 | var literal: String 332 | if let html = (value as? HypertextAttributeValueInterpolatable)?.html(for: attributeName, in: elementName) { 333 | literal = html.description 334 | } else { 335 | literal = value.description 336 | } 337 | 338 | if quotationMarks != .single { 339 | literal = literal.replacingOccurrences(of: "\"", with: "\\\"") 340 | } 341 | 342 | if quotationMarks == .none { 343 | literal = #""\#(literal)""# 344 | } 345 | 346 | appendLiteral(literal) 347 | case .comment: 348 | appendInterpolation(comment: value.description) 349 | } 350 | } 351 | 352 | public mutating func appendInterpolation(unsafeUnescaped string: String) { 353 | appendLiteral(string) 354 | } 355 | 356 | public mutating func appendInterpolation(comment string: String) { 357 | let string = string.replacingOccurrences(of: "", with: "") 359 | .trimmingCharacters(in: .whitespacesAndNewlines) 360 | switch disposition { 361 | case .comment: 362 | appendLiteral(string) 363 | default: 364 | appendLiteral("") 365 | } 366 | } 367 | } 368 | } 369 | 370 | // MARK: - 371 | 372 | fileprivate extension StringProtocol { 373 | var escaped: String { 374 | #if os(macOS) 375 | return (CFXMLCreateStringByEscapingEntities(nil, String(self) as NSString, nil)! as NSString) as String 376 | #else 377 | return [ 378 | ("&", "&"), 379 | ("<", "<"), 380 | (">", ">"), 381 | ("'", "'"), 382 | ("\"", """), 383 | ].reduce(String(self)) { (string, element) in 384 | string.replacingOccurrences(of: element.0, with: element.1) 385 | } 386 | #endif 387 | } 388 | 389 | func escapingOccurrences(of target: Target, options: String.CompareOptions = [], range searchRange: Range? = nil) -> String where Target : StringProtocol { 390 | return replacingOccurrences(of: target, with: target.escaped, options: options, range: searchRange) 391 | } 392 | 393 | func escapingOccurrences(of targets: [Target], options: String.CompareOptions = []) -> String where Target : StringProtocol { 394 | return targets.reduce(into: String(self)) { (result, target) in 395 | result = result.escapingOccurrences(of: target, options: options) 396 | } 397 | } 398 | } 399 | 400 | fileprivate func += (lhs: inout String?, rhs: Character) { 401 | lhs = lhs ?? "" 402 | lhs?.append(rhs) 403 | } 404 | -------------------------------------------------------------------------------- /Sources/HypertextLiteral/Protocols/HypertextAttributeValueInterpolatable.swift: -------------------------------------------------------------------------------- 1 | /** 2 | A type that customizes its representation in 3 | a hypertext literal attribute value. 4 | */ 5 | public protocol HypertextAttributeValueInterpolatable { 6 | 7 | /** 8 | Returns a representative value of this instance 9 | for an attribute value in a hypertext literal. 10 | 11 | - Parameters: 12 | - attribute: The name of the attribute. 13 | - element: The name of the element. 14 | - Returns: An HTML representation, or `nil`. 15 | */ 16 | func html(for attribute: String, in element: String) -> HTML? 17 | } 18 | -------------------------------------------------------------------------------- /Sources/HypertextLiteral/Protocols/HypertextAttributesInterpolatable.swift: -------------------------------------------------------------------------------- 1 | /** 2 | A type that provides attributes for a hypertext literal element. 3 | */ 4 | public protocol HypertextAttributesInterpolatable { 5 | /** 6 | Returns the attributes that correspond to this instance 7 | for an element in a hypertext literal. 8 | 9 | - Parameters: 10 | - element: The name of the element. 11 | - Returns: An HTML representation, or `nil`. 12 | */ 13 | func html(in element: String) -> HTML 14 | } 15 | -------------------------------------------------------------------------------- /Sources/HypertextLiteral/Protocols/HypertextLiteralConvertible.swift: -------------------------------------------------------------------------------- 1 | /** 2 | A type that customizes its representation in a hypertext literal. 3 | */ 4 | public protocol HypertextLiteralConvertible { 5 | /// A representation of this instance in a hypertext literal. 6 | var html: HTML { get } 7 | } 8 | -------------------------------------------------------------------------------- /Tests/HypertextLiteralTests/HypertextLiteralTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import HypertextLiteral 3 | 4 | final class HypertextLiteralTests: XCTestCase { 5 | func testOriginalHyperTextLiteralEquivalence() throws { 6 | let attributes = [ 7 | "style": [ 8 | "background": "yellow", 9 | "font-weight": "bold" 10 | ] 11 | ] 12 | 13 | let html: HTML = "whoa" 14 | 15 | let expected: String = #"whoa"# 16 | 17 | XCTAssertEqual(html.description, expected) 18 | } 19 | 20 | func testInitializerWithDescription() throws { 21 | let html: HTML = HTML(#"

Hello, world!

"#) 22 | 23 | let expected: String = #"

Hello, world!

"# 24 | 25 | XCTAssertEqual(html.description, expected) 26 | } 27 | 28 | func testStringLiteralWithoutInterpolation() throws { 29 | let html: HTML = #"

Hello, world!

"# 30 | 31 | let expected: String = #"

Hello, world!

"# 32 | 33 | XCTAssertEqual(html.description, expected) 34 | } 35 | 36 | func testStringLiteralWithTextInterpolation() throws { 37 | let html: HTML = #"

Hello, \#("world")!

"# 38 | 39 | let expected: String = #"

Hello, world!

"# 40 | 41 | XCTAssertEqual(html.description, expected) 42 | } 43 | 44 | func testStringLiteralWithEscapedTextInterpolation() throws { 45 | let html: HTML = #"

Hello, \#("")!

"# 46 | 47 | let expected: String = #"

Hello, <world>!

"# 48 | 49 | XCTAssertEqual(html.description, expected) 50 | } 51 | 52 | func testStringLiteralWithElementNameInterpolation() throws { 53 | let tag: String = "h1" 54 | let html: HTML = #"<\#(tag)>Hello, world!"# 55 | 56 | let expected: String = #"

Hello, world!

"# 57 | 58 | XCTAssertEqual(html.description, expected) 59 | } 60 | 61 | func testStringLiteralWithPartialElementNameInterpolation() throws { 62 | let level: Int = 1 63 | let html: HTML = #"Hello, world!"# 64 | 65 | let expected: String = #"

Hello, world!

"# 66 | 67 | XCTAssertEqual(html.description, expected) 68 | } 69 | 70 | func testStringLiteralWithStringTagInterpolation() throws { 71 | let startTag: String = "

" 72 | let endTag: String = "

" 73 | 74 | let html: HTML = #"\#(startTag)Hello, world!\#(endTag)"# 75 | 76 | let expected: String = #"<h1>Hello, world!</h1>"# 77 | 78 | XCTAssertEqual(html.description, expected) 79 | } 80 | 81 | func testStringLiteralWithHTMLTagInterpolation() throws { 82 | let startTag: HTML = "

" 83 | let endTag: HTML = "

" 84 | 85 | let html: HTML = #"\#(startTag)Hello, world!\#(endTag)"# 86 | 87 | let expected: String = #"

Hello, world!

"# 88 | 89 | XCTAssertEqual(html.description, expected) 90 | } 91 | 92 | func testStringLiteralWithStringAttributeInterpolation() throws { 93 | let id: String = "logo" 94 | let url = URL(string: "https://swift.org/")! 95 | let title: String = #"Swift.org | "Welcome to Swift.org""# 96 | let html: HTML = #"Swift.org"# 97 | 98 | let expected: String = #""# 99 | 100 | XCTAssertEqual(html.description, expected) 101 | } 102 | 103 | func testStringLiteralWithClassAttributeInterpolation() throws { 104 | let attributes: [String: [String]] = [ 105 | "class": ["alpha", "bravo", "charlie"] 106 | ] 107 | 108 | let html: HTML = #""" 109 |
110 | """# 111 | 112 | let expected = #""" 113 |
114 | """# 115 | 116 | XCTAssertEqual(html.description, expected) 117 | } 118 | 119 | func testStringLiteralWithStyleAttributeInterpolation() throws { 120 | let style: [String: Any] = [ 121 | "background": "orangered", 122 | "font-weight": 700 123 | ] 124 | 125 | let html: HTML = #""" 126 | Swift 127 | """# 128 | 129 | let expected = #""" 130 | Swift 131 | """# 132 | 133 | XCTAssertEqual(html.description, expected) 134 | } 135 | 136 | func testStringLiteralWithNestedAttributesInterpolation() throws { 137 | let attributes: [String: [String: Any]] = [ 138 | "aria": [ 139 | "role": "article", 140 | ], 141 | "data": [ 142 | "index": 1, 143 | "count": 3, 144 | ], 145 | "style": [ 146 | "background": "orangered", 147 | "font-weight": 700 148 | ] 149 | ] 150 | 151 | let html: HTML = #""" 152 |
153 | """# 154 | 155 | let expected = #""" 156 |
157 | """# 158 | 159 | XCTAssertEqual(html.description, expected) 160 | } 161 | 162 | func testStringLiteralWithBooleanAttributeInterpolationOfTrueValues() throws { 163 | let attributes: [String: Any] = [ 164 | "aria": [ 165 | "label": true 166 | ], 167 | "autocomplete": true, 168 | "spellcheck": true, 169 | "translate": true, 170 | "type": "text" 171 | ] 172 | 173 | let html: HTML = #""" 174 | 175 | """# 176 | 177 | let expected = #""" 178 | 179 | """# 180 | 181 | XCTAssertEqual(html.description, expected) 182 | } 183 | 184 | func testStringLiteralWithBooleanAttributeInterpolationOfFalseValues() throws { 185 | let attributes: [String: Any] = [ 186 | "aria": [ 187 | "label": false 188 | ], 189 | "autocomplete": false, 190 | "spellcheck": false, 191 | "translate": false, 192 | "type": "text" 193 | ] 194 | 195 | let html: HTML = #""" 196 | 197 | """# 198 | 199 | let expected = #""" 200 | 201 | """# 202 | 203 | XCTAssertEqual(html.description, expected) 204 | } 205 | 206 | func testStringLiteralWithUnsafeUnescapedInterpolation() throws { 207 | let inlineHTML: String = "&" 208 | let html: HTML = #"\#(unsafeUnescaped: inlineHTML)"# 209 | 210 | let expected = #"&"# 211 | 212 | XCTAssertEqual(html.description, expected) 213 | } 214 | 215 | func testStringLiteralWithoutUnsafeUnescapedInterpolation() throws { 216 | let inlineHTML: String = "&" 217 | let html: HTML = #"\#(inlineHTML)"# 218 | 219 | let expected = #"<strong>&amp;</strong>"# 220 | 221 | XCTAssertEqual(html.description, expected) 222 | } 223 | 224 | func testStringLiteralWithCommentInterpolationInText() throws { 225 | let html: HTML = #"\#(comment: "( ゚Д゚)"# 228 | 229 | XCTAssertEqual(html.description, expected) 230 | } 231 | 232 | func testStringLiteralWithCommentInterpolationInComment() throws { 233 | let html: HTML = #"") -->"# 234 | 235 | let expected = #""# 236 | 237 | XCTAssertEqual(html.description, expected) 238 | } 239 | 240 | func testStringLiteralWithEmbeddedHTMLInterpolation() throws { 241 | func results(for string: String) -> HTML { 242 | func entry(for character: Character) -> HTML { 243 | func definition(for scalar: Unicode.Scalar) -> HTML { 244 | return #"
U+\#(String(format: "%04X", scalar.value)) \#(scalar.properties.name ?? "")
"# 245 | } 246 | 247 | return #""" 248 |
\#(character)
249 | \#(character.unicodeScalars.map { definition(for: $0) }) 250 | """# 251 | } 252 | 253 | return #""" 254 |
255 | \#(string.map { entry(for: $0) }) 256 |
257 | """# 258 | } 259 | 260 | let string: String = "🕵️❗️" 261 | 262 | let title: String = "Unicode String Inspector - \(string)" 263 | 264 | let content: HTML = #""" 265 |

Results for \#(string):

266 | \#(results(for: string)) 267 | """# 268 | 269 | let html: HTML = #""" 270 | 271 | 272 | 273 | \#(title) 274 | 275 | 276 |
277 | \#(content) 278 |
279 | 280 | 281 | """# 282 | 283 | let expected: String = #""" 284 | 285 | 286 | 287 | Unicode String Inspector - 🕵️❗️ 288 | 289 | 290 |
291 |

Results for 🕵️❗️:

292 |
293 |
🕵️
294 |
U+1F575 SLEUTH OR SPY
295 |
U+FE0F VARIATION SELECTOR-16
296 |
❗️
297 |
U+2757 HEAVY EXCLAMATION MARK SYMBOL
298 |
U+FE0F VARIATION SELECTOR-16
299 |
300 |
301 | 302 | 303 | """# 304 | 305 | XCTAssertEqual(html.description, expected) 306 | } 307 | 308 | func testStringLiteralWithSVGInterpolation() throws { 309 | typealias SVG = HTML 310 | 311 | let groupAttributes: [String: Any] = [ 312 | "stroke-width": 3, 313 | "stroke": "#FFFFEE" 314 | ] 315 | 316 | func box(_ rect: CGRect, radius: CGFloat = 10, attributes: [String: Any] = [:]) -> SVG { 317 | #""" 318 | 322 | """# 323 | } 324 | 325 | let svg: SVG = #""" 326 | 327 | 328 | 329 | \#(box(CGRect(x: 12, y: 28, width: 60, height: 60), attributes: ["fill": "#F06507"])) 330 | \#(box(CGRect(x: 27, y: 18, width: 55, height: 55), attributes: ["fill": "#F2A02D"])) 331 | \#(box(CGRect(x: 47, y: 11, width: 40, height: 40), attributes: ["fill": "#FEC352"])) 332 | 333 | 334 | """# 335 | 336 | let expected = #""" 337 | 338 | 339 | 340 | 344 | 348 | 352 | 353 | 354 | """# 355 | 356 | XCTAssertEqual(svg.description, expected) 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | fatalError("Run with `swift test --enable-test-discovery`") 2 | --------------------------------------------------------------------------------