From 78b43f4fa1833d5f8df7d3b6da49f51b76a77afe Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 7 Jul 2021 20:53:00 -0400 Subject: [PATCH 01/11] Add support for custom fonts --- Sources/TokamakCore/Tokens/Font.swift | 65 ++++++++++++++++++- Sources/TokamakDemo/TextDemo.swift | 2 + .../TokamakStaticHTML/Views/Text/Text.swift | 8 ++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/Sources/TokamakCore/Tokens/Font.swift b/Sources/TokamakCore/Tokens/Font.swift index c0378b9be..8839c62ba 100644 --- a/Sources/TokamakCore/Tokens/Font.swift +++ b/Sources/TokamakCore/Tokens/Font.swift @@ -36,7 +36,7 @@ public protocol AnyFontBoxDeferredToRenderer: AnyFontBox { public class AnyFontBox: AnyTokenBox, Hashable, Equatable { public struct _Font: Hashable, Equatable { - public var _name: String + public var _name: _FontNames public var _size: CGFloat public var _design: Font.Design public var _weight: Font.Weight @@ -57,7 +57,7 @@ public class AnyFontBox: AnyTokenBox, Hashable, Equatable { monospaceDigit: Bool = false, leading: Font.Leading = .standard ) { - _name = name.rawValue + _name = name _size = size _design = design _weight = weight @@ -169,6 +169,50 @@ public class _SystemFontBox: AnyFontBox { } } +public class _CustomFontBox: AnyFontBox { + public let name: String + public let size: Size + public enum Size: Hashable { + // FIXME: Update size with dynamic type. + case dynamic(CGFloat) + case fixed(CGFloat) + } + + // FIXME: Update size with dynamic type using `textStyle`. + public let textStyle: Font.TextStyle? + + public static func == (lhs: _CustomFontBox, rhs: _CustomFontBox) -> Bool { + lhs.name == rhs.name + && lhs.size == rhs.size + && lhs.textStyle == rhs.textStyle + } + + override public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(size) + hasher.combine(textStyle) + } + + init(_ name: String, size: Size, relativeTo textStyle: Font.TextStyle? = nil) { + (self.name, self.size, self.textStyle) = (name, size, textStyle) + } + + override public func resolve(in environment: EnvironmentValues) -> ResolvedValue { + switch size { + case let .dynamic(size): + return .init( + name: .custom(name), + size: size + ) + case let .fixed(size): + return .init( + name: .custom(name), + size: size + ) + } + } +} + public struct Font: Hashable { let provider: AnyFontBox @@ -245,8 +289,9 @@ public extension Font { } } -public enum _FontNames: String, CaseIterable { +public enum _FontNames: Hashable { case system + case custom(String) } public extension Font { @@ -328,6 +373,20 @@ public extension Font { } } +public extension Font { + static func custom(_ name: String, size: CGFloat) -> Self { + .init(_CustomFontBox(name, size: .dynamic(size))) + } + + static func custom(_ name: String, size: CGFloat, relativeTo textStyle: TextStyle) -> Self { + .init(_CustomFontBox(name, size: .dynamic(size), relativeTo: textStyle)) + } + + static func custom(_ name: String, fixedSize: CGFloat) -> Self { + .init(_CustomFontBox(name, size: .fixed(fixedSize))) + } +} + public struct _FontProxy { let subject: Font public init(_ subject: Font) { self.subject = subject } diff --git a/Sources/TokamakDemo/TextDemo.swift b/Sources/TokamakDemo/TextDemo.swift index cbbc513be..299a66db8 100644 --- a/Sources/TokamakDemo/TextDemo.swift +++ b/Sources/TokamakDemo/TextDemo.swift @@ -64,6 +64,8 @@ struct TextDemo: View { ) .multilineTextAlignment(alignment) } + Text("Custom Font") + .font(.custom("Marker Felt", size: 17)) } } } diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index b7cd919bc..3fb9efd6b 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -80,9 +80,13 @@ extension Font.Leading: CustomStringConvertible { public extension Font { func styles(in environment: EnvironmentValues) -> [String: String] { let proxy = _FontProxy(self).resolve(in: environment) + let family: String + switch proxy._name { + case .system: family = proxy._design.description + case let .custom(custom): family = custom + } return [ - "font-family": proxy._name == _FontNames.system.rawValue ? proxy._design.description : proxy - ._name, + "font-family": family, "font-weight": "\(proxy._bold ? Font.Weight.bold.value : proxy._weight.value)", "font-style": proxy._italic ? "italic" : "normal", "font-size": "\(proxy._size)", From 0443ec0175e3477983beac8fb50c9372093f017f Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 8 Jul 2021 11:22:55 -0400 Subject: [PATCH 02/11] Add CSS sanitizer --- Sources/TokamakDemo/TextDemo.swift | 2 +- Sources/TokamakStaticHTML/Sanitizer.swift | 48 ++++++++++ .../TokamakStaticHTML/Views/Text/Text.swift | 89 ++++++++++--------- 3 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 Sources/TokamakStaticHTML/Sanitizer.swift diff --git a/Sources/TokamakDemo/TextDemo.swift b/Sources/TokamakDemo/TextDemo.swift index 299a66db8..4b4cdbb08 100644 --- a/Sources/TokamakDemo/TextDemo.swift +++ b/Sources/TokamakDemo/TextDemo.swift @@ -65,7 +65,7 @@ struct TextDemo: View { .multilineTextAlignment(alignment) } Text("Custom Font") - .font(.custom("Marker Felt", size: 17)) + .font(.custom("\"Marker Felt\"", size: 17)) } } } diff --git a/Sources/TokamakStaticHTML/Sanitizer.swift b/Sources/TokamakStaticHTML/Sanitizer.swift new file mode 100644 index 000000000..4e19d2d8e --- /dev/null +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -0,0 +1,48 @@ +// Copyright 2020-2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/8/21. +// + +import Foundation + +protocol Sanitizer { + associatedtype Input + associatedtype Output + static func sanitize(_ input: Input) -> Output +} + +enum Sanitizers { + enum CSS { + static func sanitize(string inputs: [String]) -> String { + inputs.map(StringValue.sanitize).joined(separator: ", ") + } + + static func sanitize(string inputs: String...) -> String { + sanitize(string: inputs) + } + + enum StringValue: Sanitizer { + static func sanitize(_ input: String) -> String { + """ + '\(input.filter { + $0.isLetter || $0.isNumber + || $0 == "-" || $0 == "_" || $0 == " " + || $0.unicodeScalars.allSatisfy { $0 > "\u{00A0}" } + })' + """ + } + } + } +} diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index 3fb9efd6b..1c1c4345b 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -15,51 +15,51 @@ import Foundation import TokamakCore -extension Font.Design: CustomStringConvertible { +public extension Font.Design { /// Some default font stacks for the various designs - public var description: String { + var families: [String] { switch self { case .default: - return #""" - system, - -apple-system, - '.SFNSText-Regular', - 'San Francisco', - 'Roboto', - 'Segoe UI', - 'Helvetica Neue', - 'Lucida Grande', - sans-serif - """# + return [ + "system", + "-apple-system", + "'.SFNSText-Regular'", + "'San Francisco'", + "'Roboto'", + "'Segoe UI'", + "'Helvetica Neue'", + "'Lucida Grande'", + "sans-serif", + ] case .monospaced: - return #""" - Consolas, - 'Andale Mono WT', - 'Andale Mono', - 'Lucida Console', - 'Lucida Sans Typewriter', - 'DejaVu Sans Mono', - 'Bitstream Vera Sans Mono', - 'Liberation Mono', - 'Nimbus Mono L', - Monaco, - 'Courier New', - Courier, - monospace - """# + return [ + "Consolas", + "'Andale Mono WT'", + "'Andale Mono'", + "'Lucida Console'", + "'Lucida Sans Typewriter'", + "'DejaVu Sans Mono'", + "'Bitstream Vera Sans Mono'", + "'Liberation Mono'", + "'Nimbus Mono L'", + "Monaco", + "'Courier New'", + "Courier", + "monospace", + ] case .rounded: // Not supported due to browsers not having a rounded font builtin - return Self.default.description + return Self.default.families case .serif: - return #""" - Cambria, - 'Hoefler Text', - Utopia, - 'Liberation Serif', - 'Nimbus Roman No9 L Regular', - Times, - 'Times New Roman', - serif - """# + return [ + "Cambria", + "'Hoefler Text'", + "Utopia", + "'Liberation Serif'", + "'Nimbus Roman No9 L Regular'", + "Times", + "'Times New Roman'", + "serif", + ] } } } @@ -80,13 +80,13 @@ extension Font.Leading: CustomStringConvertible { public extension Font { func styles(in environment: EnvironmentValues) -> [String: String] { let proxy = _FontProxy(self).resolve(in: environment) - let family: String + let family: [String] switch proxy._name { - case .system: family = proxy._design.description - case let .custom(custom): family = custom + case .system: family = proxy._design.families + case let .custom(custom): family = [custom] } return [ - "font-family": family, + "font-family": Sanitizers.CSS.sanitize(string: family), "font-weight": "\(proxy._bold ? Font.Weight.bold.value : proxy._weight.value)", "font-style": proxy._italic ? "italic" : "normal", "font-size": "\(proxy._size)", @@ -199,7 +199,8 @@ extension Text { "style": """ \(font?.styles(in: environment).filter { weight != nil ? $0.key != "font-weight" : true } .inlineStyles ?? "") - \(font == nil ? "font-family: \(Font.Design.default.description);" : "") + \(font == nil ? + "font-family: \(Sanitizers.CSS.sanitize(string: Font.Design.default.families));" : "") color: \((color ?? .primary).cssValue(environment)); font-style: \(italic ? "italic" : "normal"); font-weight: \(weight?.value ?? resolvedFont?._weight.value ?? 400); From 9644bc00450e43939c81e348e1d4f43bc21e2bc8 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 8 Jul 2021 11:28:43 -0400 Subject: [PATCH 03/11] Add sanitizer tests --- .../SanitizerTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Tests/TokamakStaticHTMLTests/SanitizerTests.swift diff --git a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift new file mode 100644 index 000000000..1e962b940 --- /dev/null +++ b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift @@ -0,0 +1,27 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import TokamakStaticHTML +import XCTest + +final class SanitizerTests: XCTestCase { + func testCSSString() { + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello"), "'hello'") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "'hello world'"), "'hello world'") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), "'hello world'") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") + + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello", "world"), "'hello', 'world'") + } +} From 60a80d998b1eb59c01b18486809898ddb0f0510a Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 8 Jul 2021 11:34:34 -0400 Subject: [PATCH 04/11] Add fallback font families --- Sources/TokamakStaticHTML/Views/Text/Text.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index 1c1c4345b..6ac90ade5 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -83,7 +83,8 @@ public extension Font { let family: [String] switch proxy._name { case .system: family = proxy._design.families - case let .custom(custom): family = [custom] + case let .custom(custom): + family = [custom] + Font.Design.default.families // Fallback } return [ "font-family": Sanitizers.CSS.sanitize(string: family), From c2dff01ba2c0ff873ca050aac48274610eeecbfe Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 8 Jul 2021 18:41:34 -0400 Subject: [PATCH 05/11] Add fallback font support by chaining modifiers --- Sources/TokamakCore/Tokens/Font.swift | 54 ++++++++++++++-- Sources/TokamakDemo/TextDemo.swift | 3 + Sources/TokamakStaticHTML/Sanitizer.swift | 62 ++++++++++++++++--- .../TokamakStaticHTML/Views/Text/Text.swift | 46 +++++++++----- .../SanitizerTests.swift | 7 ++- 5 files changed, 143 insertions(+), 29 deletions(-) diff --git a/Sources/TokamakCore/Tokens/Font.swift b/Sources/TokamakCore/Tokens/Font.swift index 8839c62ba..bb073cd9d 100644 --- a/Sources/TokamakCore/Tokens/Font.swift +++ b/Sources/TokamakCore/Tokens/Font.swift @@ -69,7 +69,14 @@ public class AnyFontBox: AnyTokenBox, Hashable, Equatable { } } - public static func == (lhs: AnyFontBox, rhs: AnyFontBox) -> Bool { false } + public static func == (lhs: AnyFontBox, rhs: AnyFontBox) -> Bool { + lhs.equals(rhs) + } + + public func equals(_ other: AnyFontBox) -> Bool { + fatalError("implement \(#function) in subclass") + } + public func hash(into hasher: inout Hasher) { fatalError("implement \(#function) in subclass") } @@ -97,6 +104,11 @@ public class _ConcreteFontBox: AnyFontBox { override public func resolve(in environment: EnvironmentValues) -> ResolvedValue { font } + + override public func equals(_ other: AnyFontBox) -> Bool { + guard let other = other as? _ConcreteFontBox else { return false } + return other.font == font + } } public class _ModifiedFontBox: AnyFontBox { @@ -121,6 +133,15 @@ public class _ModifiedFontBox: AnyFontBox { modifier(&font) return font } + + override public func equals(_ other: AnyFontBox) -> Bool { + guard let other = other as? _ModifiedFontBox else { return false } + var resolved = provider.resolve(in: .init()) + modifier(&resolved) + var otherResolved = other.provider.resolve(in: .init()) + other.modifier(&otherResolved) + return other.provider.equals(provider) && resolved == otherResolved + } } public class _SystemFontBox: AnyFontBox { @@ -167,6 +188,11 @@ public class _SystemFontBox: AnyFontBox { case .caption2: return .init(name: .system, size: 11) } } + + override public func equals(_ other: AnyFontBox) -> Bool { + guard let other = other as? _SystemFontBox else { return false } + return other.value == value + } } public class _CustomFontBox: AnyFontBox { @@ -211,6 +237,11 @@ public class _CustomFontBox: AnyFontBox { ) } } + + override public func equals(_ other: AnyFontBox) -> Bool { + guard let other = other as? _CustomFontBox else { return false } + return other.name == name && other.size == size && other.textStyle == textStyle + } } public struct Font: Hashable { @@ -399,17 +430,30 @@ public struct _FontProxy { } } -struct FontKey: EnvironmentKey { - static let defaultValue: Font? = nil +enum FontPathKey: EnvironmentKey { + static let defaultValue: [Font] = [] } public extension EnvironmentValues { + var _fontPath: [Font] { + get { + self[FontPathKey.self] + } + set { + self[FontPathKey.self] = newValue + } + } + var font: Font? { get { - self[FontKey.self] + _fontPath.first } set { - self[FontKey.self] = newValue + if let newFont = newValue { + _fontPath = [newFont] + _fontPath.filter { $0 != newFont } + } else { + _fontPath = [] + } } } } diff --git a/Sources/TokamakDemo/TextDemo.swift b/Sources/TokamakDemo/TextDemo.swift index 4b4cdbb08..a8dc6a2b6 100644 --- a/Sources/TokamakDemo/TextDemo.swift +++ b/Sources/TokamakDemo/TextDemo.swift @@ -66,6 +66,9 @@ struct TextDemo: View { } Text("Custom Font") .font(.custom("\"Marker Felt\"", size: 17)) + Text("Fallback Font") + .font(.custom("\"Marker-Felt\"", size: 17)) + .font(.system(.body, design: .serif)) } } } diff --git a/Sources/TokamakStaticHTML/Sanitizer.swift b/Sources/TokamakStaticHTML/Sanitizer.swift index 4e19d2d8e..e4b92bafd 100644 --- a/Sources/TokamakStaticHTML/Sanitizer.swift +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -20,27 +20,71 @@ import Foundation protocol Sanitizer { associatedtype Input associatedtype Output + static func validate(_ input: Input) -> Bool static func sanitize(_ input: Input) -> Output } enum Sanitizers { enum CSS { - static func sanitize(string inputs: [String]) -> String { - inputs.map(StringValue.sanitize).joined(separator: ", ") + /// Automatically sanitizes a value. + static func sanitize(_ value: String) -> String { + if value.starts(with: "'") || value.starts(with: "\"") { + return sanitize(string: value) + } else { + return validate(identifier: value) + ? sanitize(identifier: value) + : sanitize(string: value) + } + } + + static func sanitize(identifier: String) -> String { + Identifier.sanitize(identifier) + } + + static func sanitize(string: String) -> String { + StringValue.sanitize(string) } - static func sanitize(string inputs: String...) -> String { - sanitize(string: inputs) + static func validate(identifier: String) -> Bool { + Identifier.validate(identifier) } + static func validate(string: String) -> Bool { + StringValue.validate(string) + } + + /// Sanitizes an identifier. + enum Identifier: Sanitizer { + static func isIdentifierChar(_ char: Character) -> Bool { + char.isLetter || char.isNumber + || char == "-" || char == "_" + || char.unicodeScalars.allSatisfy { $0 > "\u{00A0}" } + } + + static func validate(_ input: String) -> Bool { + input.allSatisfy(isIdentifierChar) + } + + static func sanitize(_ input: String) -> String { + input.filter(isIdentifierChar) + } + } + + /// Sanitizes a quoted string. enum StringValue: Sanitizer { + static func isStringContent(_ char: Character) -> Bool { + char != "'" && char != "\"" + } + + static func validate(_ input: String) -> Bool { + input.starts(with: "'") || input.starts(with: "\"") + && !input.dropFirst().dropLast().allSatisfy(isStringContent) + && input.last == input.first + } + static func sanitize(_ input: String) -> String { """ - '\(input.filter { - $0.isLetter || $0.isNumber - || $0 == "-" || $0 == "_" || $0 == " " - || $0.unicodeScalars.allSatisfy { $0 > "\u{00A0}" } - })' + '\(input.filter(isStringContent))' """ } } diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index 6ac90ade5..221599fc2 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -80,14 +80,8 @@ extension Font.Leading: CustomStringConvertible { public extension Font { func styles(in environment: EnvironmentValues) -> [String: String] { let proxy = _FontProxy(self).resolve(in: environment) - let family: [String] - switch proxy._name { - case .system: family = proxy._design.families - case let .custom(custom): - family = [custom] + Font.Design.default.families // Fallback - } return [ - "font-family": Sanitizers.CSS.sanitize(string: family), + "font-family": families(in: environment).joined(separator: ", "), "font-weight": "\(proxy._bold ? Font.Weight.bold.value : proxy._weight.value)", "font-style": proxy._italic ? "italic" : "normal", "font-size": "\(proxy._size)", @@ -95,6 +89,21 @@ public extension Font { "font-variant": proxy._smallCaps ? "small-caps" : "normal", ] } + + func families(in environment: EnvironmentValues) -> [String] { + let proxy = _FontProxy(self).resolve(in: environment) + switch proxy._name { + case .system: + return proxy._design.families + case let .custom(custom): + return [Sanitizers.CSS.sanitize(custom)] + + environment._fontPath.dropFirst().flatMap { font -> [String] in + var env = environment + env._fontPath = [] + return font.families(in: env) + } // Fallback + } + } } extension TextAlignment: CustomStringConvertible { @@ -157,7 +166,7 @@ extension Text { ) -> [HTMLAttribute: String] { let isRedacted = environment.redactionReasons.contains(.placeholder) - var font: Font? + var fontStack: [Font] = [] var color: Color? var italic: Bool = false var weight: Font.Weight? @@ -169,8 +178,12 @@ extension Text { switch modifier { case let .color(_color): color = _color - case let .font(_font): - font = _font + case let .font(font): + if let font = font { + fontStack.append(font) + } else { + fontStack = [] + } case .italic: italic = true case let .weight(_weight): @@ -194,14 +207,19 @@ extension Text { let decorationColor = strikethrough?.1?.cssValue(environment) ?? underline?.1?.cssValue(environment) ?? "inherit" - let resolvedFont = font == nil ? nil : _FontProxy(font!).resolve(in: environment) + + var fontPathEnv = environment + fontPathEnv._fontPath = fontStack + fontPathEnv._fontPath.filter { !fontStack.contains($0) } + let resolvedFont = fontPathEnv._fontPath + .isEmpty ? nil : _FontProxy(fontPathEnv._fontPath.first!).resolve(in: environment) return [ "style": """ - \(font?.styles(in: environment).filter { weight != nil ? $0.key != "font-weight" : true } + \(fontPathEnv._fontPath.first?.styles(in: fontPathEnv) + .filter { weight != nil ? $0.key != "font-weight" : true } .inlineStyles ?? "") - \(font == nil ? - "font-family: \(Sanitizers.CSS.sanitize(string: Font.Design.default.families));" : "") + \(fontPathEnv._fontPath + .isEmpty ? "font-family: \(Font.Design.default.families.joined(separator: ", "));" : "") color: \((color ?? .primary).cssValue(environment)); font-style: \(italic ? "italic" : "normal"); font-weight: \(weight?.value ?? resolvedFont?._weight.value ?? 400); diff --git a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift index 1e962b940..903316d4b 100644 --- a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift +++ b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift @@ -21,7 +21,12 @@ final class SanitizerTests: XCTestCase { XCTAssertEqual(Sanitizers.CSS.sanitize(string: "'hello world'"), "'hello world'") XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), "'hello world'") XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") + } - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello", "world"), "'hello', 'world'") + func testCSSIdentifier() { + XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "hello"), "hello") + XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "hello-world"), "hello-world") + XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "-hello-world_1"), "-hello-world_1") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") } } From 4932798022eed344864e780781a4de3bd4596d09 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 9 Jul 2021 08:34:16 -0400 Subject: [PATCH 06/11] Fix fallback ordering and update tests to not depend on Text --- Sources/TokamakDemo/TextDemo.swift | 8 +- Sources/TokamakDemo/main.swift | 7 +- Sources/TokamakStaticHTML/Views/HTML.swift | 16 +- .../TokamakStaticHTML/Views/Text/Text.swift | 5 +- Tests/TokamakStaticHTMLTests/HTMLTests.swift | 31 +++- .../HTMLTests/testFontStacks.1.txt | 136 +++++++++++++++++ .../HTMLTests/testFontStacks.2.txt | 139 ++++++++++++++++++ .../HTMLTests/testOptional.1.txt | 21 +-- .../HTMLTests/testPaddingFusion.1.txt | 21 +-- .../HTMLTests/testPaddingFusion.2.txt | 21 +-- 10 files changed, 335 insertions(+), 70 deletions(-) create mode 100644 Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt create mode 100644 Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt diff --git a/Sources/TokamakDemo/TextDemo.swift b/Sources/TokamakDemo/TextDemo.swift index a8dc6a2b6..e68022fca 100644 --- a/Sources/TokamakDemo/TextDemo.swift +++ b/Sources/TokamakDemo/TextDemo.swift @@ -66,9 +66,11 @@ struct TextDemo: View { } Text("Custom Font") .font(.custom("\"Marker Felt\"", size: 17)) - Text("Fallback Font") - .font(.custom("\"Marker-Felt\"", size: 17)) - .font(.system(.body, design: .serif)) + VStack { + Text("Fallback Font") + .font(.custom("\"Marker-Felt\"", size: 17)) + } + .font(.system(.body, design: .serif)) } } } diff --git a/Sources/TokamakDemo/main.swift b/Sources/TokamakDemo/main.swift index c29cbe20e..3d3075db6 100644 --- a/Sources/TokamakDemo/main.swift +++ b/Sources/TokamakDemo/main.swift @@ -21,7 +21,12 @@ struct CustomScene: Scene { var body: some Scene { print("In CustomScene.body scenePhase is \(scenePhase)") return WindowGroup("Tokamak Demo") { - TokamakDemoView() +// TokamakDemoView() + VStack { + Text("Fallback Font") + .font(.custom("\"Marker-Felt\"", size: 17)) + } + .font(.system(.body, design: .serif)) } } } diff --git a/Sources/TokamakStaticHTML/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift index a972272bf..9e2f691a8 100644 --- a/Sources/TokamakStaticHTML/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -141,9 +141,17 @@ public protocol StylesConvertible { var styles: [String: String] { get } } -public extension Dictionary { - var inlineStyles: String { - map { "\($0.0): \($0.1);" } - .joined(separator: " ") +public extension Dictionary + where Key: Comparable & CustomStringConvertible, Value: CustomStringConvertible +{ + func inlineStyles(shouldSortDeclarations: Bool = false) -> String { + let declarations = map { "\($0.key): \($0.value);" } + if shouldSortDeclarations { + return declarations + .sorted() + .joined(separator: " ") + } else { + return declarations.joined(separator: " ") + } } } diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index 221599fc2..a96df887d 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -209,7 +209,8 @@ extension Text { ?? "inherit" var fontPathEnv = environment - fontPathEnv._fontPath = fontStack + fontPathEnv._fontPath.filter { !fontStack.contains($0) } + fontPathEnv._fontPath = fontStack.reversed() + fontPathEnv._fontPath + .filter { !fontStack.contains($0) } let resolvedFont = fontPathEnv._fontPath .isEmpty ? nil : _FontProxy(fontPathEnv._fontPath.first!).resolve(in: environment) @@ -217,7 +218,7 @@ extension Text { "style": """ \(fontPathEnv._fontPath.first?.styles(in: fontPathEnv) .filter { weight != nil ? $0.key != "font-weight" : true } - .inlineStyles ?? "") + .inlineStyles(shouldSortDeclarations: true) ?? "") \(fontPathEnv._fontPath .isEmpty ? "font-family: \(Font.Design.default.families.joined(separator: ", "));" : "") color: \((color ?? .primary).cssValue(environment)); diff --git a/Tests/TokamakStaticHTMLTests/HTMLTests.swift b/Tests/TokamakStaticHTMLTests/HTMLTests.swift index eb1c3f7e6..cc2bf271c 100644 --- a/Tests/TokamakStaticHTMLTests/HTMLTests.swift +++ b/Tests/TokamakStaticHTMLTests/HTMLTests.swift @@ -23,16 +23,16 @@ import XCTest final class HTMLTests: XCTestCase { struct Model { - let text: Text + let color: Color } private struct OptionalBody: View { var model: Model? var body: some View { - if let text = model?.text { + if let color = model?.color { VStack { - text + color Spacer() } @@ -41,7 +41,7 @@ final class HTMLTests: XCTestCase { } func testOptional() { - let resultingHTML = StaticHTMLRenderer(OptionalBody(model: Model(text: Text("text")))) + let resultingHTML = StaticHTMLRenderer(OptionalBody(model: Model(color: Color.red))) .render(shouldSortAttributes: true) assertSnapshot(matching: resultingHTML, as: .lines) @@ -49,17 +49,36 @@ final class HTMLTests: XCTestCase { func testPaddingFusion() { let nestedTwice = StaticHTMLRenderer( - Text("text").padding(10).padding(20) + Color.red.padding(10).padding(20) ).render(shouldSortAttributes: true) assertSnapshot(matching: nestedTwice, as: .lines) let nestedThrice = StaticHTMLRenderer( - Text("text").padding(20).padding(20).padding(20) + Color.red.padding(20).padding(20).padding(20) ).render(shouldSortAttributes: true) assertSnapshot(matching: nestedThrice, as: .lines) } + + func testFontStacks() { + let customFont = StaticHTMLRenderer( + Text("Hello, world!") + .font(.custom("Marker Felt", size: 17)) + ).render(shouldSortAttributes: true) + + assertSnapshot(matching: customFont, as: .lines) + + let fallbackFont = StaticHTMLRenderer( + VStack { + Text("Hello, world!") + .font(.custom("Marker Felt", size: 17)) + } + .font(.system(.body, design: .serif)) + ).render(shouldSortAttributes: true) + + assertSnapshot(matching: fallbackFont, as: .lines) + } } #endif diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt new file mode 100644 index 000000000..f6b68a116 --- /dev/null +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt @@ -0,0 +1,136 @@ + + + + + +Hello, world! + \ No newline at end of file diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt new file mode 100644 index 000000000..460574bf9 --- /dev/null +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt @@ -0,0 +1,139 @@ + + + + + +
Hello, world!
+ \ No newline at end of file diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt index 14bd3069a..394cde2a5 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt @@ -126,23 +126,8 @@ align-items: center; overflow: hidden;">
text +">
\ No newline at end of file diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt index 9ca80e95d..54e36f2aa 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt @@ -123,22 +123,7 @@ width: 100%; height: 100%; justify-content: center; align-items: center; -overflow: hidden;">
text
+overflow: hidden;">
\ No newline at end of file diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt index 8fed506cd..c17e1a487 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt @@ -123,22 +123,7 @@ width: 100%; height: 100%; justify-content: center; align-items: center; -overflow: hidden;">
text
+overflow: hidden;">
\ No newline at end of file From a7f78102673f49d15439681ff7cf4bf9c6570be1 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 9 Jul 2021 08:55:07 -0400 Subject: [PATCH 07/11] Cleanup --- Sources/TokamakDemo/main.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/TokamakDemo/main.swift b/Sources/TokamakDemo/main.swift index 3d3075db6..c29cbe20e 100644 --- a/Sources/TokamakDemo/main.swift +++ b/Sources/TokamakDemo/main.swift @@ -21,12 +21,7 @@ struct CustomScene: Scene { var body: some Scene { print("In CustomScene.body scenePhase is \(scenePhase)") return WindowGroup("Tokamak Demo") { -// TokamakDemoView() - VStack { - Text("Fallback Font") - .font(.custom("\"Marker-Felt\"", size: 17)) - } - .font(.system(.body, design: .serif)) + TokamakDemoView() } } } From d0439bbd43aeb3b20d376bdf6dc9d59253949a8d Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 9 Jul 2021 08:59:15 -0400 Subject: [PATCH 08/11] Split up Font.swift --- Sources/TokamakCore/Tokens/Font/Font.swift | 191 +++++++++++++++ .../{Font.swift => Font/FontBoxes.swift} | 220 ------------------ .../Tokens/Font/FontModifiers.swift | 61 +++++ 3 files changed, 252 insertions(+), 220 deletions(-) create mode 100644 Sources/TokamakCore/Tokens/Font/Font.swift rename Sources/TokamakCore/Tokens/{Font.swift => Font/FontBoxes.swift} (57%) create mode 100644 Sources/TokamakCore/Tokens/Font/FontModifiers.swift diff --git a/Sources/TokamakCore/Tokens/Font/Font.swift b/Sources/TokamakCore/Tokens/Font/Font.swift new file mode 100644 index 000000000..9c6db2c68 --- /dev/null +++ b/Sources/TokamakCore/Tokens/Font/Font.swift @@ -0,0 +1,191 @@ +// Copyright 2018-2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public struct Font: Hashable { + let provider: AnyFontBox + + init(_ provider: AnyFontBox) { + self.provider = provider + } +} + +public extension Font { + struct Weight: Hashable { + public let value: Int + + public static let ultraLight: Self = .init(value: 100) + public static let thin: Self = .init(value: 200) + public static let light: Self = .init(value: 300) + public static let regular: Self = .init(value: 400) + public static let medium: Self = .init(value: 500) + public static let semibold: Self = .init(value: 600) + public static let bold: Self = .init(value: 700) + public static let heavy: Self = .init(value: 800) + public static let black: Self = .init(value: 900) + } +} + +public extension Font { + enum Leading { + case standard + case tight + case loose + } +} + +public enum _FontNames: Hashable { + case system + case custom(String) +} + +public extension Font { + static func system(size: CGFloat, weight: Weight = .regular, + design: Design = .default) -> Self + { + .init( + _ConcreteFontBox( + .init( + name: .system, + size: size, + design: design, + weight: weight, + smallCaps: false, + italic: false, + bold: false, + monospaceDigit: false, + leading: .standard + ) + ) + ) + } + + enum Design: Hashable { + case `default` + case serif + case rounded + case monospaced + } +} + +public extension Font { + static let largeTitle: Self = .init(_SystemFontBox(.largeTitle)) + static let title: Self = .init(_SystemFontBox(.title)) + static let title2: Self = .init(_SystemFontBox(.title2)) + static let title3: Self = .init(_SystemFontBox(.title3)) + static let headline: Font = .init(_SystemFontBox(.headline)) + static let subheadline: Self = .init(_SystemFontBox(.subheadline)) + static let body: Self = .init(_SystemFontBox(.body)) + static let callout: Self = .init(_SystemFontBox(.callout)) + static let footnote: Self = .init(_SystemFontBox(.footnote)) + static let caption: Self = .init(_SystemFontBox(.caption)) + static let caption2: Self = .init(_SystemFontBox(.caption2)) + + static func system(_ style: TextStyle, design: Design = .default) -> Self { + .init(_ModifiedFontBox(previously: style.font.provider) { + $0._design = design + }) + } + + enum TextStyle: Hashable, CaseIterable { + case largeTitle + case title + case title2 + case title3 + case headline + case subheadline + case body + case callout + case footnote + case caption + case caption2 + + var font: Font { + switch self { + case .largeTitle: return .largeTitle + case .title: return .title + case .title2: return .title2 + case .title3: return .title3 + case .headline: return .headline + case .subheadline: return .subheadline + case .body: return .body + case .callout: return .callout + case .footnote: return .footnote + case .caption: return .caption + case .caption2: return .caption2 + } + } + } +} + +public extension Font { + static func custom(_ name: String, size: CGFloat) -> Self { + .init(_CustomFontBox(name, size: .dynamic(size))) + } + + static func custom(_ name: String, size: CGFloat, relativeTo textStyle: TextStyle) -> Self { + .init(_CustomFontBox(name, size: .dynamic(size), relativeTo: textStyle)) + } + + static func custom(_ name: String, fixedSize: CGFloat) -> Self { + .init(_CustomFontBox(name, size: .fixed(fixedSize))) + } +} + +public struct _FontProxy { + let subject: Font + public init(_ subject: Font) { self.subject = subject } + public func resolve(in environment: EnvironmentValues) -> AnyFontBox.ResolvedValue { + if let deferred = subject.provider as? AnyFontBoxDeferredToRenderer { + return deferred.deferredResolve(in: environment) + } else { + return subject.provider.resolve(in: environment) + } + } +} + +enum FontPathKey: EnvironmentKey { + static let defaultValue: [Font] = [] +} + +public extension EnvironmentValues { + var _fontPath: [Font] { + get { + self[FontPathKey.self] + } + set { + self[FontPathKey.self] = newValue + } + } + + var font: Font? { + get { + _fontPath.first + } + set { + if let newFont = newValue { + _fontPath = [newFont] + _fontPath.filter { $0 != newFont } + } else { + _fontPath = [] + } + } + } +} + +public extension View { + func font(_ font: Font?) -> some View { + environment(\.font, font) + } +} diff --git a/Sources/TokamakCore/Tokens/Font.swift b/Sources/TokamakCore/Tokens/Font/FontBoxes.swift similarity index 57% rename from Sources/TokamakCore/Tokens/Font.swift rename to Sources/TokamakCore/Tokens/Font/FontBoxes.swift index bb073cd9d..6cdf2e3d8 100644 --- a/Sources/TokamakCore/Tokens/Font.swift +++ b/Sources/TokamakCore/Tokens/Font/FontBoxes.swift @@ -243,223 +243,3 @@ public class _CustomFontBox: AnyFontBox { return other.name == name && other.size == size && other.textStyle == textStyle } } - -public struct Font: Hashable { - let provider: AnyFontBox - - fileprivate init(_ provider: AnyFontBox) { - self.provider = provider - } - - public func italic() -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._italic = true - }) - } - - public func smallCaps() -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._smallCaps = true - }) - } - - public func lowercaseSmallCaps() -> Self { - smallCaps() - } - - public func uppercaseSmallCaps() -> Self { - smallCaps() - } - - public func monospacedDigit() -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._monospaceDigit = true - }) - } - - public func weight(_ weight: Weight) -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._weight = weight - }) - } - - public func bold() -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._bold = true - }) - } - - public func leading(_ leading: Leading) -> Self { - .init(_ModifiedFontBox(previously: provider) { - $0._leading = leading - }) - } -} - -public extension Font { - struct Weight: Hashable { - public let value: Int - - public static let ultraLight: Self = .init(value: 100) - public static let thin: Self = .init(value: 200) - public static let light: Self = .init(value: 300) - public static let regular: Self = .init(value: 400) - public static let medium: Self = .init(value: 500) - public static let semibold: Self = .init(value: 600) - public static let bold: Self = .init(value: 700) - public static let heavy: Self = .init(value: 800) - public static let black: Self = .init(value: 900) - } -} - -public extension Font { - enum Leading { - case standard - case tight - case loose - } -} - -public enum _FontNames: Hashable { - case system - case custom(String) -} - -public extension Font { - static func system(size: CGFloat, weight: Weight = .regular, - design: Design = .default) -> Self - { - .init( - _ConcreteFontBox( - .init( - name: .system, - size: size, - design: design, - weight: weight, - smallCaps: false, - italic: false, - bold: false, - monospaceDigit: false, - leading: .standard - ) - ) - ) - } - - enum Design: Hashable { - case `default` - case serif - case rounded - case monospaced - } -} - -public extension Font { - static let largeTitle: Self = .init(_SystemFontBox(.largeTitle)) - static let title: Self = .init(_SystemFontBox(.title)) - static let title2: Self = .init(_SystemFontBox(.title2)) - static let title3: Self = .init(_SystemFontBox(.title3)) - static let headline: Font = .init(_SystemFontBox(.headline)) - static let subheadline: Self = .init(_SystemFontBox(.subheadline)) - static let body: Self = .init(_SystemFontBox(.body)) - static let callout: Self = .init(_SystemFontBox(.callout)) - static let footnote: Self = .init(_SystemFontBox(.footnote)) - static let caption: Self = .init(_SystemFontBox(.caption)) - static let caption2: Self = .init(_SystemFontBox(.caption2)) - - static func system(_ style: TextStyle, design: Design = .default) -> Self { - .init(_ModifiedFontBox(previously: style.font.provider) { - $0._design = design - }) - } - - enum TextStyle: Hashable, CaseIterable { - case largeTitle - case title - case title2 - case title3 - case headline - case subheadline - case body - case callout - case footnote - case caption - case caption2 - - var font: Font { - switch self { - case .largeTitle: return .largeTitle - case .title: return .title - case .title2: return .title2 - case .title3: return .title3 - case .headline: return .headline - case .subheadline: return .subheadline - case .body: return .body - case .callout: return .callout - case .footnote: return .footnote - case .caption: return .caption - case .caption2: return .caption2 - } - } - } -} - -public extension Font { - static func custom(_ name: String, size: CGFloat) -> Self { - .init(_CustomFontBox(name, size: .dynamic(size))) - } - - static func custom(_ name: String, size: CGFloat, relativeTo textStyle: TextStyle) -> Self { - .init(_CustomFontBox(name, size: .dynamic(size), relativeTo: textStyle)) - } - - static func custom(_ name: String, fixedSize: CGFloat) -> Self { - .init(_CustomFontBox(name, size: .fixed(fixedSize))) - } -} - -public struct _FontProxy { - let subject: Font - public init(_ subject: Font) { self.subject = subject } - public func resolve(in environment: EnvironmentValues) -> AnyFontBox.ResolvedValue { - if let deferred = subject.provider as? AnyFontBoxDeferredToRenderer { - return deferred.deferredResolve(in: environment) - } else { - return subject.provider.resolve(in: environment) - } - } -} - -enum FontPathKey: EnvironmentKey { - static let defaultValue: [Font] = [] -} - -public extension EnvironmentValues { - var _fontPath: [Font] { - get { - self[FontPathKey.self] - } - set { - self[FontPathKey.self] = newValue - } - } - - var font: Font? { - get { - _fontPath.first - } - set { - if let newFont = newValue { - _fontPath = [newFont] + _fontPath.filter { $0 != newFont } - } else { - _fontPath = [] - } - } - } -} - -public extension View { - func font(_ font: Font?) -> some View { - environment(\.font, font) - } -} diff --git a/Sources/TokamakCore/Tokens/Font/FontModifiers.swift b/Sources/TokamakCore/Tokens/Font/FontModifiers.swift new file mode 100644 index 000000000..513cd1990 --- /dev/null +++ b/Sources/TokamakCore/Tokens/Font/FontModifiers.swift @@ -0,0 +1,61 @@ +// Copyright 2018-2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public extension Font { + func italic() -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._italic = true + }) + } + + func smallCaps() -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._smallCaps = true + }) + } + + func lowercaseSmallCaps() -> Self { + smallCaps() + } + + func uppercaseSmallCaps() -> Self { + smallCaps() + } + + func monospacedDigit() -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._monospaceDigit = true + }) + } + + func weight(_ weight: Weight) -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._weight = weight + }) + } + + func bold() -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._bold = true + }) + } + + func leading(_ leading: Leading) -> Self { + .init(_ModifiedFontBox(previously: provider) { + $0._leading = leading + }) + } +} From 2a08948cfba6f59ce8190c79544c3495b9adce11 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 9 Jul 2021 11:37:09 -0400 Subject: [PATCH 09/11] Update sanitizers to use grammar from w3.org, provide default fallback --- Sources/TokamakCore/Tokens/Font/Font.swift | 3 + Sources/TokamakStaticHTML/Sanitizer.swift | 119 +++++++++++++++--- .../TokamakStaticHTML/Views/Text/Text.swift | 4 + .../SanitizerTests.swift | 18 ++- .../HTMLTests/testFontStacks.1.txt | 2 +- 5 files changed, 122 insertions(+), 24 deletions(-) diff --git a/Sources/TokamakCore/Tokens/Font/Font.swift b/Sources/TokamakCore/Tokens/Font/Font.swift index 9c6db2c68..e5e9bdee1 100644 --- a/Sources/TokamakCore/Tokens/Font/Font.swift +++ b/Sources/TokamakCore/Tokens/Font/Font.swift @@ -147,6 +147,9 @@ public extension Font { public struct _FontProxy { let subject: Font public init(_ subject: Font) { self.subject = subject } + + public var provider: AnyFontBox { subject.provider } + public func resolve(in environment: EnvironmentValues) -> AnyFontBox.ResolvedValue { if let deferred = subject.provider as? AnyFontBoxDeferredToRenderer { return deferred.deferredResolve(in: environment) diff --git a/Sources/TokamakStaticHTML/Sanitizer.swift b/Sources/TokamakStaticHTML/Sanitizer.swift index e4b92bafd..fecf06670 100644 --- a/Sources/TokamakStaticHTML/Sanitizer.swift +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -33,7 +33,7 @@ enum Sanitizers { } else { return validate(identifier: value) ? sanitize(identifier: value) - : sanitize(string: value) + : sanitize(string: "'\(value)'") } } @@ -53,40 +53,121 @@ enum Sanitizers { StringValue.validate(string) } + /// Parsers for CSS grammar. + /// + /// Specified on [w3.org](https://www.w3.org/TR/CSS21/grammar.html) + private enum Parsers { + /// `[0-9a-f]` + static let h: RegularExpression = #"[0-9a-f]"# + + /// `[\240-\377]` + static let nonAscii: RegularExpression = #"[\240-\377]"# + + /// `\\{h}{1,6}(\r\n|[ \t\r\n\f])?` + static let unicode: RegularExpression = #"\\\#(h){1,6}(\r\n|[ \t\r\n\f])?"# + + /// `{unicode}|\\[^\r\n\f0-9a-f]` + static let escape: RegularExpression = #"\#(unicode)|\\[^\r\n\f0-9a-f]"# + + /// `[_a-z]|{nonascii}|{escape}` + static let nmStart: RegularExpression = #"[_a-z]|\#(nonAscii)|\#(escape)"# + /// `[_a-z0-9-]|{nonascii}|{escape}` + static let nmChar: RegularExpression = #"[_a-z0-9-]|\#(nonAscii)|\#(escape)"# + + /// `\"([^\n\r\f\\"]|\\{nl}|{escape})*\"` + static let string1: RegularExpression = #"\"([^\n\r\f\\"]|\\\#(nl)|\#(escape))*\""# + /// `\'([^\n\r\f\\']|\\{nl}|{escape})*\'` + static let string2: RegularExpression = #"\'([^\n\r\f\\']|\\\#(nl)|\#(escape))*\'"# + + /// `-?{nmstart}{nmchar}*` + static let ident: RegularExpression = #"-?\#(nmStart)\#(nmChar)*"# + + /// `\n|\r\n|\r|\f` + static let nl: RegularExpression = #"\n|\r\n|\r|\f"# + } + /// Sanitizes an identifier. enum Identifier: Sanitizer { - static func isIdentifierChar(_ char: Character) -> Bool { - char.isLetter || char.isNumber - || char == "-" || char == "_" - || char.unicodeScalars.allSatisfy { $0 > "\u{00A0}" } - } - static func validate(_ input: String) -> Bool { - input.allSatisfy(isIdentifierChar) + Parsers.ident.matches(input) } static func sanitize(_ input: String) -> String { - input.filter(isIdentifierChar) + Parsers.ident.filter(input) } } /// Sanitizes a quoted string. enum StringValue: Sanitizer { - static func isStringContent(_ char: Character) -> Bool { - char != "'" && char != "\"" - } - static func validate(_ input: String) -> Bool { - input.starts(with: "'") || input.starts(with: "\"") - && !input.dropFirst().dropLast().allSatisfy(isStringContent) - && input.last == input.first + Parsers.string1.matches(input) + || Parsers.string2.matches(input) } static func sanitize(_ input: String) -> String { - """ - '\(input.filter(isStringContent))' - """ + ( + Parsers.string1.matches(input) + ? Parsers.string1.filter(input) + : Parsers.string2.filter(input) + ) + .replacingOccurrences(of: "\"", with: """) + } + } + } +} + +struct RegularExpression: ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + let pattern: String + private let nsRegularExpression: NSRegularExpression? + + init(_ pattern: String) { + self.pattern = pattern + nsRegularExpression = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + } + + init(stringLiteral value: String) { + self.init(value) + } + + init(stringInterpolation: StringInterpolation) { + self.init(stringInterpolation.pattern) + } + + func matches(_ input: String) -> Bool { + guard let range = input.range( + of: pattern, + options: [.regularExpression, .caseInsensitive, .anchored] + ) else { return false } + return range.lowerBound == input.startIndex && range.upperBound == input.endIndex + } + + func filter(_ input: String) -> String { + nsRegularExpression? + .matches( + in: input, + options: [], + range: NSRange(location: 0, length: input.utf16.count) + ) + .compactMap { + guard let range = Range($0.range, in: input) else { return nil } + return String(input[range]) } + .joined() ?? "" + } + + struct StringInterpolation: StringInterpolationProtocol { + var pattern: String = "" + + init(literalCapacity: Int, interpolationCount: Int) { + pattern.reserveCapacity(literalCapacity + interpolationCount) + } + + mutating func appendLiteral(_ literal: String) { + pattern.append(literal) + } + + mutating func appendInterpolation(_ regex: RegularExpression) { + pattern.append("(\(regex.pattern))") } } } diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index a96df887d..a9983495f 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -211,6 +211,10 @@ extension Text { var fontPathEnv = environment fontPathEnv._fontPath = fontStack.reversed() + fontPathEnv._fontPath .filter { !fontStack.contains($0) } + if fontPathEnv._fontPath.allSatisfy({ _FontProxy($0).provider is _CustomFontBox }) { + // Add a fallback + fontPathEnv._fontPath.append(.body) + } let resolvedFont = fontPathEnv._fontPath .isEmpty ? nil : _FontProxy(fontPathEnv._fontPath.first!).resolve(in: environment) diff --git a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift index 903316d4b..c212ba3b2 100644 --- a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift +++ b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift @@ -17,16 +17,26 @@ import XCTest final class SanitizerTests: XCTestCase { func testCSSString() { - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello"), "'hello'") + XCTAssertFalse(Sanitizers.CSS.validate(string: "hello")) + XCTAssertTrue(Sanitizers.CSS.validate(string: "\"hello\"")) + XCTAssertTrue(Sanitizers.CSS.validate(string: "\'hello\'")) + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "'hello world'"), "'hello world'") - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), "'hello world'") - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), ""hello world"") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "''") } func testCSSIdentifier() { + XCTAssertFalse(Sanitizers.CSS.validate(identifier: "\"hey there\"")) + XCTAssertTrue(Sanitizers.CSS.validate(identifier: "hey-there")) + XCTAssertTrue(Sanitizers.CSS.validate(identifier: "-hey-there2")) + XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "hello"), "hello") XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "hello-world"), "hello-world") XCTAssertEqual(Sanitizers.CSS.sanitize(identifier: "-hello-world_1"), "-hello-world_1") - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") + } + + func testCSSSanitizer() { + XCTAssertEqual(Sanitizers.CSS.sanitize("hello world"), "'hello world'") } } diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt index f6b68a116..27f8ab554 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt @@ -123,7 +123,7 @@ width: 100%; height: 100%; justify-content: center; align-items: center; -overflow: hidden;"> Date: Sat, 10 Jul 2021 14:01:14 -0400 Subject: [PATCH 11/11] Fix sanitizier behavior --- Sources/TokamakStaticHTML/Sanitizer.swift | 19 +++++++++++-------- .../SanitizerTests.swift | 6 ++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Sources/TokamakStaticHTML/Sanitizer.swift b/Sources/TokamakStaticHTML/Sanitizer.swift index 33a7bbf02..c1bfdede1 100644 --- a/Sources/TokamakStaticHTML/Sanitizer.swift +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -61,7 +61,7 @@ enum Sanitizers { static let h: RegularExpression = #"[0-9a-f]"# /// `[\240-\377]` - static let nonAscii: RegularExpression = #"[\240-\377]"# + static let nonAscii: RegularExpression = #"[\0240-\0377]"# /// `\\{h}{1,6}(\r\n|[ \t\r\n\f])?` static let unicode: RegularExpression = #"\\\#(h){1,6}(\r\n|[ \t\r\n\f])?"# @@ -75,9 +75,11 @@ enum Sanitizers { static let nmChar: RegularExpression = #"[_a-z0-9-]|\#(nonAscii)|\#(escape)"# /// `\"([^\n\r\f\\"]|\\{nl}|{escape})*\"` - static let string1: RegularExpression = #"\"([^\n\r\f\\"]|\\\#(nl)|\#(escape))*\""# + static let string1Content: RegularExpression = #"([^\n\r\f\\"]|\\\#(nl)|\#(escape))*"# + static let string1: RegularExpression = #""\#(string1Content)""# /// `\'([^\n\r\f\\']|\\{nl}|{escape})*\'` - static let string2: RegularExpression = #"\'([^\n\r\f\\']|\\\#(nl)|\#(escape))*\'"# + static let string2Content: RegularExpression = #"([^\n\r\f\\']|\\\#(nl)|\#(escape))*"# + static let string2: RegularExpression = #"'\#(string2Content)'"# /// `-?{nmstart}{nmchar}*` static let ident: RegularExpression = #"-?\#(nmStart)\#(nmChar)*"# @@ -105,12 +107,13 @@ enum Sanitizers { } static func sanitize(_ input: String) -> String { - ( + """ + '\( Parsers.string1.matches(input) - ? Parsers.string1.filter(input) - : Parsers.string2.filter(input) - ) - .replacingOccurrences(of: "\"", with: """) + ? Parsers.string1Content.filter(input) + : Parsers.string2Content.filter(input) + .replacingOccurrences(of: "\"", with: """))' + """ } } } diff --git a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift index c212ba3b2..b47df4aeb 100644 --- a/Tests/TokamakStaticHTMLTests/SanitizerTests.swift +++ b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift @@ -22,12 +22,13 @@ final class SanitizerTests: XCTestCase { XCTAssertTrue(Sanitizers.CSS.validate(string: "\'hello\'")) XCTAssertEqual(Sanitizers.CSS.sanitize(string: "'hello world'"), "'hello world'") - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), ""hello world"") - XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "''") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "\"hello world\""), "'hello world'") + XCTAssertEqual(Sanitizers.CSS.sanitize(string: "hello'''world"), "'helloworld'") } func testCSSIdentifier() { XCTAssertFalse(Sanitizers.CSS.validate(identifier: "\"hey there\"")) + XCTAssertFalse(Sanitizers.CSS.validate(identifier: "1hey-there")) XCTAssertTrue(Sanitizers.CSS.validate(identifier: "hey-there")) XCTAssertTrue(Sanitizers.CSS.validate(identifier: "-hey-there2")) @@ -38,5 +39,6 @@ final class SanitizerTests: XCTestCase { func testCSSSanitizer() { XCTAssertEqual(Sanitizers.CSS.sanitize("hello world"), "'hello world'") + XCTAssertEqual(Sanitizers.CSS.sanitize("hello-world"), "hello-world") } }