├── .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!\(elementName)>" 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 | /* Swift.org
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 |
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(#"