diff --git a/README.md b/README.md index adf8190..8abc3aa 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,9 @@ Hello world ``` Note the `{{$head}}` section in `base.mustache` is replaced with the `{{$head}}` section included inside the `{{", template `{{%CONTENT_TYPE: HTML}}{{.}}` will render as `<>` and `{{%CONTENT_TYPE: TEXT}}{{.}}` will render as `<>`. diff --git a/Sources/HummingbirdMustache/ContentType.swift b/Sources/HummingbirdMustache/ContentType.swift new file mode 100644 index 0000000..c378786 --- /dev/null +++ b/Sources/HummingbirdMustache/ContentType.swift @@ -0,0 +1,43 @@ +/// Protocol for content types +public protocol HBMustacheContentType { + /// escape text for this content type eg for HTML replace "<" with "<" + func escapeText(_ text: String) -> String +} + +/// Text content type where no character is escaped +struct HBTextContentType: HBMustacheContentType { + func escapeText(_ text: String) -> String { + return text + } +} + +/// HTML content where text is escaped for HTML output +struct HBHTMLContentType: HBMustacheContentType { + func escapeText(_ text: String) -> String { + return text.htmlEscape() + } +} + +/// Map of strings to content types. +/// +/// The string is read from the "CONTENT_TYPE" pragma `{{% CONTENT_TYPE: type}}`. Replace type with +/// the content type required. The default available types are `TEXT` and `HTML`. You can register your own +/// with `HBMustacheContentTypes.register`. +public enum HBMustacheContentTypes { + static func get(_ name: String) -> HBMustacheContentType? { + return self.types[name] + } + + /// Register new content type + /// - Parameters: + /// - contentType: Content type + /// - name: String to identify it + public static func register(_ contentType: HBMustacheContentType, named name: String) { + self.types[name] = contentType + } + + static var types: [String: HBMustacheContentType] = [ + "HTML": HBHTMLContentType(), + "TEXT": HBTextContentType(), + ] +} diff --git a/Sources/HummingbirdMustache/Context.swift b/Sources/HummingbirdMustache/Context.swift index 2b67a90..dbfe645 100644 --- a/Sources/HummingbirdMustache/Context.swift +++ b/Sources/HummingbirdMustache/Context.swift @@ -4,6 +4,7 @@ struct HBMustacheContext { let sequenceContext: HBMustacheSequenceContext? let indentation: String? let inherited: [String: HBMustacheTemplate]? + let contentType: HBMustacheContentType /// initialize context with a single objectt init(_ object: Any) { @@ -11,25 +12,34 @@ struct HBMustacheContext { self.sequenceContext = nil self.indentation = nil self.inherited = nil + self.contentType = HBHTMLContentType() } private init( stack: [Any], sequenceContext: HBMustacheSequenceContext?, indentation: String?, - inherited: [String: HBMustacheTemplate]? + inherited: [String: HBMustacheTemplate]?, + contentType: HBMustacheContentType ) { self.stack = stack self.sequenceContext = sequenceContext self.indentation = indentation self.inherited = inherited + self.contentType = contentType } /// return context with object add to stack func withObject(_ object: Any) -> HBMustacheContext { var stack = self.stack stack.append(object) - return .init(stack: stack, sequenceContext: nil, indentation: self.indentation, inherited: self.inherited) + return .init( + stack: stack, + sequenceContext: nil, + indentation: self.indentation, + inherited: self.inherited, + contentType: self.contentType + ) } /// return context with indent and parameter information for invoking a partial @@ -50,13 +60,36 @@ struct HBMustacheContext { } else { inherits = self.inherited } - return .init(stack: self.stack, sequenceContext: nil, indentation: indentation, inherited: inherits) + return .init( + stack: self.stack, + sequenceContext: nil, + indentation: indentation, + inherited: inherits, + contentType: HBHTMLContentType() + ) } - + /// return context with sequence info and sequence element added to stack func withSequence(_ object: Any, sequenceContext: HBMustacheSequenceContext) -> HBMustacheContext { var stack = self.stack stack.append(object) - return .init(stack: stack, sequenceContext: sequenceContext, indentation: self.indentation, inherited: self.inherited) + return .init( + stack: stack, + sequenceContext: sequenceContext, + indentation: self.indentation, + inherited: self.inherited, + contentType: self.contentType + ) + } + + /// return context with sequence info and sequence element added to stack + func withContentType(_ contentType: HBMustacheContentType) -> HBMustacheContext { + return .init( + stack: self.stack, + sequenceContext: self.sequenceContext, + indentation: self.indentation, + inherited: self.inherited, + contentType: contentType + ) } } diff --git a/Sources/HummingbirdMustache/Parser.swift b/Sources/HummingbirdMustache/Parser.swift index 5d49b67..1ca743d 100644 --- a/Sources/HummingbirdMustache/Parser.swift +++ b/Sources/HummingbirdMustache/Parser.swift @@ -234,6 +234,19 @@ extension HBParser { return self.buffer[startIndex.. Bool) -> Substring { + let startIndex = self.position + while !self.reachedEnd(), + cb(unsafeCurrent()) + { + unsafeAdvance() + } + return self.buffer[startIndex.. ParserState { - var newValue = self - newValue.startDelimiter = "{{" - newValue.endDelimiter = "}}" - return newValue - } } /// parse mustache text to generate a list of tokens @@ -236,6 +233,14 @@ extension HBMustacheTemplate { state = try self.parserSetDelimiter(&parser, state: state) setNewLine = self.isStandalone(&parser, state: state) + case "%": + // read config variable + parser.unsafeAdvance() + if let token = try self.readConfigVariable(&parser, state: state) { + tokens.append(token) + } + setNewLine = self.isStandalone(&parser, state: state) + default: // variable if whiteSpaceBefore.count > 0 { @@ -327,6 +332,34 @@ extension HBMustacheTemplate { return state.withDelimiters(start: String(startDelimiter), end: String(endDelimiter)) } + static func readConfigVariable(_ parser: inout HBParser, state: ParserState) throws -> Token? { + let variable: Substring + let value: Substring + + do { + parser.read(while: \.isWhitespace) + variable = parser.read(while: self.sectionNameCharsWithoutBrackets) + parser.read(while: \.isWhitespace) + guard try parser.read(":") else { throw Error.invalidConfigVariableSyntax } + parser.read(while: \.isWhitespace) + value = parser.read(while: self.sectionNameCharsWithoutBrackets) + guard try parser.read(string: state.endDelimiter) else { throw Error.invalidConfigVariableSyntax } + } catch { + throw Error.invalidConfigVariableSyntax + } + + // do both variable and value have content + guard variable.count > 0, value.count > 0 else { throw Error.invalidConfigVariableSyntax } + + switch variable { + case "CONTENT_TYPE": + guard let contentType = HBMustacheContentTypes.get(String(value)) else { throw Error.unrecognisedConfigVariable } + return .contentType(contentType) + default: + throw Error.unrecognisedConfigVariable + } + } + static func hasLineFinished(_ parser: inout HBParser) -> Bool { var parser2 = parser if parser.reachedEnd() { return true } diff --git a/Sources/HummingbirdMustache/Template+Render.swift b/Sources/HummingbirdMustache/Template+Render.swift index 92eba70..4220dbd 100644 --- a/Sources/HummingbirdMustache/Template+Render.swift +++ b/Sources/HummingbirdMustache/Template+Render.swift @@ -8,22 +8,24 @@ extension HBMustacheTemplate { /// - Returns: Rendered text func render(context: HBMustacheContext) -> String { var string = "" + var context = context + if let indentation = context.indentation, indentation != "" { for token in tokens { if string.last == "\n" { string += indentation } - string += self.renderToken(token, context: context) + string += self.renderToken(token, context: &context) } } else { for token in tokens { - string += self.renderToken(token, context: context) + string += self.renderToken(token, context: &context) } } return string } - func renderToken(_ token: Token, context: HBMustacheContext) -> String { + func renderToken(_ token: Token, context: inout HBMustacheContext) -> String { switch token { case .text(let text): return text @@ -32,7 +34,7 @@ extension HBMustacheTemplate { if let template = child as? HBMustacheTemplate { return template.render(context: context) } else { - return String(describing: child).htmlEscape() + return context.contentType.escapeText(String(describing: child)) } } case .unescapedVariable(let variable, let transform): @@ -58,6 +60,9 @@ extension HBMustacheTemplate { if let template = library?.getTemplate(named: name) { return template.render(context: context.withPartial(indented: indentation, inheriting: overrides)) } + + case .contentType(let contentType): + context = context.withContentType(contentType) } return "" } diff --git a/Sources/HummingbirdMustache/Template.swift b/Sources/HummingbirdMustache/Template.swift index c010429..54bcf9f 100644 --- a/Sources/HummingbirdMustache/Template.swift +++ b/Sources/HummingbirdMustache/Template.swift @@ -40,6 +40,7 @@ public final class HBMustacheTemplate { case invertedSection(name: String, transform: String? = nil, template: HBMustacheTemplate) case inheritedSection(name: String, template: HBMustacheTemplate) case partial(String, indentation: String?, inherits: [String: HBMustacheTemplate]?) + case contentType(HBMustacheContentType) } let tokens: [Token] diff --git a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift index 826a09c..83b9587 100644 --- a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift +++ b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift @@ -31,6 +31,11 @@ final class TemplateParserTests: XCTestCase { let template = try HBMustacheTemplate(string: "{{ section }}") XCTAssertEqual(template.tokens, [.variable(name: "section")]) } + + func testContentType() throws { + let template = try HBMustacheTemplate(string: "{{% CONTENT_TYPE:TEXT}}") + XCTAssertEqual(template.tokens, [.contentType(HBTextContentType())]) + } } extension HBMustacheTemplate: Equatable { @@ -52,6 +57,8 @@ extension HBMustacheTemplate.Token: Equatable { return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 case (.partial(let name1, let indent1, _), .partial(let name2, let indent2, _)): return name1 == name2 && indent1 == indent2 + case (.contentType(let contentType), .contentType(let contentType2)): + return type(of: contentType) == type(of: contentType2) default: return false } diff --git a/Tests/HummingbirdMustacheTests/TemplateRendererTests.swift b/Tests/HummingbirdMustacheTests/TemplateRendererTests.swift index 7926ab2..6cb1551 100644 --- a/Tests/HummingbirdMustacheTests/TemplateRendererTests.swift +++ b/Tests/HummingbirdMustacheTests/TemplateRendererTests.swift @@ -92,6 +92,13 @@ final class TemplateRendererTests: XCTestCase { XCTAssertEqual(template.render(Test(test: .init(string: "sub"))), "test sub") } + func testTextEscaping() throws { + let template1 = try HBMustacheTemplate(string: "{{% CONTENT_TYPE:TEXT}}{{.}}") + XCTAssertEqual(template1.render("<>"), "<>") + let template2 = try HBMustacheTemplate(string: "{{% CONTENT_TYPE:HTML}}{{.}}") + XCTAssertEqual(template2.render("<>"), "<>") + } + /// variables func testMustacheManualExample1() throws { let template = try HBMustacheTemplate(string: """