diff --git a/Sources/TokamakCore/Tokens/Font/Font.swift b/Sources/TokamakCore/Tokens/Font/Font.swift new file mode 100644 index 000000000..e5e9bdee1 --- /dev/null +++ b/Sources/TokamakCore/Tokens/Font/Font.swift @@ -0,0 +1,194 @@ +// 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 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) + } 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 52% rename from Sources/TokamakCore/Tokens/Font.swift rename to Sources/TokamakCore/Tokens/Font/FontBoxes.swift index c0378b9be..6cdf2e3d8 100644 --- a/Sources/TokamakCore/Tokens/Font.swift +++ b/Sources/TokamakCore/Tokens/Font/FontBoxes.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 @@ -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,196 +188,58 @@ public class _SystemFontBox: AnyFontBox { case .caption2: return .init(name: .system, size: 11) } } -} - -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 - }) + override public func equals(_ other: AnyFontBox) -> Bool { + guard let other = other as? _SystemFontBox else { return false } + return other.value == value } } -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 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) } -} -public extension Font { - enum Leading { - case standard - case tight - case loose - } -} + // FIXME: Update size with dynamic type using `textStyle`. + public let textStyle: Font.TextStyle? -public enum _FontNames: String, CaseIterable { - case system -} - -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 - }) + public static func == (lhs: _CustomFontBox, rhs: _CustomFontBox) -> Bool { + lhs.name == rhs.name + && lhs.size == rhs.size + && lhs.textStyle == rhs.textStyle } - 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 - } - } + override public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(size) + hasher.combine(textStyle) } -} -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) - } + init(_ name: String, size: Size, relativeTo textStyle: Font.TextStyle? = nil) { + (self.name, self.size, self.textStyle) = (name, size, textStyle) } -} -struct FontKey: EnvironmentKey { - static let defaultValue: Font? = nil -} - -public extension EnvironmentValues { - var font: Font? { - get { - self[FontKey.self] - } - set { - self[FontKey.self] = newValue + 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 extension View { - func font(_ font: Font?) -> some View { - environment(\.font, font) + 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 } } 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 + }) + } +} diff --git a/Sources/TokamakDemo/TextDemo.swift b/Sources/TokamakDemo/TextDemo.swift index cbbc513be..e68022fca 100644 --- a/Sources/TokamakDemo/TextDemo.swift +++ b/Sources/TokamakDemo/TextDemo.swift @@ -64,6 +64,13 @@ struct TextDemo: View { ) .multilineTextAlignment(alignment) } + Text("Custom Font") + .font(.custom("\"Marker Felt\"", size: 17)) + VStack { + 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 new file mode 100644 index 000000000..c1bfdede1 --- /dev/null +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -0,0 +1,176 @@ +// 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 validate(_ input: Input) -> Bool + static func sanitize(_ input: Input) -> Output +} + +enum Sanitizers { + enum CSS { + /// 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) + ? value + : sanitize(string: "'\(value)'") + } + } + + static func sanitize(identifier: String) -> String { + Identifier.sanitize(identifier) + } + + static func sanitize(string: String) -> String { + StringValue.sanitize(string) + } + + static func validate(identifier: String) -> Bool { + Identifier.validate(identifier) + } + + static func validate(string: String) -> Bool { + 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 = #"[\0240-\0377]"# + + /// `\\{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 string1Content: RegularExpression = #"([^\n\r\f\\"]|\\\#(nl)|\#(escape))*"# + static let string1: RegularExpression = #""\#(string1Content)""# + /// `\'([^\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)*"# + + /// `\n|\r\n|\r|\f` + static let nl: RegularExpression = #"\n|\r\n|\r|\f"# + } + + /// Sanitizes an identifier. + enum Identifier: Sanitizer { + static func validate(_ input: String) -> Bool { + Parsers.ident.matches(input) + } + + static func sanitize(_ input: String) -> String { + Parsers.ident.filter(input) + } + } + + /// Sanitizes a quoted string. + enum StringValue: Sanitizer { + static func validate(_ input: String) -> Bool { + Parsers.string1.matches(input) + || Parsers.string2.matches(input) + } + + static func sanitize(_ input: String) -> String { + """ + '\( + Parsers.string1.matches(input) + ? Parsers.string1Content.filter(input) + : Parsers.string2Content.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/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 b7cd919bc..a9983495f 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", + ] } } } @@ -81,8 +81,7 @@ public extension Font { func styles(in environment: EnvironmentValues) -> [String: String] { let proxy = _FontProxy(self).resolve(in: environment) return [ - "font-family": proxy._name == _FontNames.system.rawValue ? proxy._design.description : proxy - ._name, + "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)", @@ -90,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 { @@ -152,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? @@ -164,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): @@ -189,13 +207,24 @@ 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.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) return [ "style": """ - \(font?.styles(in: environment).filter { weight != nil ? $0.key != "font-weight" : true } - .inlineStyles ?? "") - \(font == nil ? "font-family: \(Font.Design.default.description);" : "") + \(fontPathEnv._fontPath.first?.styles(in: fontPathEnv) + .filter { weight != nil ? $0.key != "font-weight" : true } + .inlineStyles(shouldSortDeclarations: true) ?? "") + \(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/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/SanitizerTests.swift b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift new file mode 100644 index 000000000..b47df4aeb --- /dev/null +++ b/Tests/TokamakStaticHTMLTests/SanitizerTests.swift @@ -0,0 +1,44 @@ +// 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() { + 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'") + } + + 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")) + + 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") + } + + func testCSSSanitizer() { + XCTAssertEqual(Sanitizers.CSS.sanitize("hello world"), "'hello world'") + 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 new file mode 100644 index 000000000..27f8ab554 --- /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