Add support for custom text escaping (#11)

* Add support for custom text escaping

* swift format

* Remove withDefaultDelimiters

* Update README.md

* Don't pass content type into partial
This commit is contained in:
Adam Fowler
2021-03-23 17:36:28 +00:00
committed by GitHub
parent ef4eb40eb7
commit d3edef1b8e
9 changed files with 164 additions and 16 deletions

View File

@@ -146,3 +146,9 @@ Hello world
</body> </body>
``` ```
Note the `{{$head}}` section in `base.mustache` is replaced with the `{{$head}}` section included inside the `{{<base}}` partial reference from `mypage.mustache`. The same occurs with the `{{$body}}` section. In that case though a default value is supplied for the situation where a `{{$body}}` section is not supplied. Note the `{{$head}}` section in `base.mustache` is replaced with the `{{$head}}` section included inside the `{{<base}}` partial reference from `mypage.mustache`. The same occurs with the `{{$body}}` section. In that case though a default value is supplied for the situation where a `{{$body}}` section is not supplied.
### Pragma
The syntax `{{% var: value}}` can be used to set template rendering configuration variables specific to Hummingbird Mustache. The only variable you can set at the moment is `CONTENT_TYPE`. This can be set to either to `HTML` or `TEXT` and defines how variables are escaped. A content type of `TEXT` means no variables are escaped and a content type of `HTML` will do HTML escaping of the rendered text. The content type defaults to `HTML`.
Given input object "<>", template `{{%CONTENT_TYPE: HTML}}{{.}}` will render as `&lt;&gt;` and `{{%CONTENT_TYPE: TEXT}}{{.}}` will render as `<>`.

View File

@@ -0,0 +1,43 @@
/// Protocol for content types
public protocol HBMustacheContentType {
/// escape text for this content type eg for HTML replace "<" with "&lt;"
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(),
]
}

View File

@@ -4,6 +4,7 @@ struct HBMustacheContext {
let sequenceContext: HBMustacheSequenceContext? let sequenceContext: HBMustacheSequenceContext?
let indentation: String? let indentation: String?
let inherited: [String: HBMustacheTemplate]? let inherited: [String: HBMustacheTemplate]?
let contentType: HBMustacheContentType
/// initialize context with a single objectt /// initialize context with a single objectt
init(_ object: Any) { init(_ object: Any) {
@@ -11,25 +12,34 @@ struct HBMustacheContext {
self.sequenceContext = nil self.sequenceContext = nil
self.indentation = nil self.indentation = nil
self.inherited = nil self.inherited = nil
self.contentType = HBHTMLContentType()
} }
private init( private init(
stack: [Any], stack: [Any],
sequenceContext: HBMustacheSequenceContext?, sequenceContext: HBMustacheSequenceContext?,
indentation: String?, indentation: String?,
inherited: [String: HBMustacheTemplate]? inherited: [String: HBMustacheTemplate]?,
contentType: HBMustacheContentType
) { ) {
self.stack = stack self.stack = stack
self.sequenceContext = sequenceContext self.sequenceContext = sequenceContext
self.indentation = indentation self.indentation = indentation
self.inherited = inherited self.inherited = inherited
self.contentType = contentType
} }
/// return context with object add to stack /// return context with object add to stack
func withObject(_ object: Any) -> HBMustacheContext { func withObject(_ object: Any) -> HBMustacheContext {
var stack = self.stack var stack = self.stack
stack.append(object) 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 /// return context with indent and parameter information for invoking a partial
@@ -50,13 +60,36 @@ struct HBMustacheContext {
} else { } else {
inherits = self.inherited 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 /// return context with sequence info and sequence element added to stack
func withSequence(_ object: Any, sequenceContext: HBMustacheSequenceContext) -> HBMustacheContext { func withSequence(_ object: Any, sequenceContext: HBMustacheSequenceContext) -> HBMustacheContext {
var stack = self.stack var stack = self.stack
stack.append(object) 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
)
} }
} }

View File

@@ -234,6 +234,19 @@ extension HBParser {
return self.buffer[startIndex..<self.position] return self.buffer[startIndex..<self.position]
} }
/// Read while closure returns true
/// - Parameter while: closure
/// - Returns: String read from buffer
@discardableResult mutating func read(while cb: (Character) -> Bool) -> Substring {
let startIndex = self.position
while !self.reachedEnd(),
cb(unsafeCurrent())
{
unsafeAdvance()
}
return self.buffer[startIndex..<self.position]
}
/// Read while character at current position is in supplied set /// Read while character at current position is in supplied set
/// - Parameter while: character set to check /// - Parameter while: character set to check
/// - Returns: String read from buffer /// - Returns: String read from buffer

View File

@@ -22,6 +22,10 @@ extension HBMustacheTemplate {
case illegalTokenInsideInheritSection case illegalTokenInsideInheritSection
/// text found inside inherit section of partial /// text found inside inherit section of partial
case textInsideInheritSection case textInsideInheritSection
/// config variable syntax is wrong
case invalidConfigVariableSyntax
/// unrecognised config variable
case unrecognisedConfigVariable
} }
struct ParserState { struct ParserState {
@@ -51,13 +55,6 @@ extension HBMustacheTemplate {
newValue.endDelimiter = end newValue.endDelimiter = end
return newValue return newValue
} }
func withDefaultDelimiters(start _: String, end _: String) -> ParserState {
var newValue = self
newValue.startDelimiter = "{{"
newValue.endDelimiter = "}}"
return newValue
}
} }
/// parse mustache text to generate a list of tokens /// parse mustache text to generate a list of tokens
@@ -236,6 +233,14 @@ extension HBMustacheTemplate {
state = try self.parserSetDelimiter(&parser, state: state) state = try self.parserSetDelimiter(&parser, state: state)
setNewLine = self.isStandalone(&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: default:
// variable // variable
if whiteSpaceBefore.count > 0 { if whiteSpaceBefore.count > 0 {
@@ -327,6 +332,34 @@ extension HBMustacheTemplate {
return state.withDelimiters(start: String(startDelimiter), end: String(endDelimiter)) 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 { static func hasLineFinished(_ parser: inout HBParser) -> Bool {
var parser2 = parser var parser2 = parser
if parser.reachedEnd() { return true } if parser.reachedEnd() { return true }

View File

@@ -8,22 +8,24 @@ extension HBMustacheTemplate {
/// - Returns: Rendered text /// - Returns: Rendered text
func render(context: HBMustacheContext) -> String { func render(context: HBMustacheContext) -> String {
var string = "" var string = ""
var context = context
if let indentation = context.indentation, indentation != "" { if let indentation = context.indentation, indentation != "" {
for token in tokens { for token in tokens {
if string.last == "\n" { if string.last == "\n" {
string += indentation string += indentation
} }
string += self.renderToken(token, context: context) string += self.renderToken(token, context: &context)
} }
} else { } else {
for token in tokens { for token in tokens {
string += self.renderToken(token, context: context) string += self.renderToken(token, context: &context)
} }
} }
return string return string
} }
func renderToken(_ token: Token, context: HBMustacheContext) -> String { func renderToken(_ token: Token, context: inout HBMustacheContext) -> String {
switch token { switch token {
case .text(let text): case .text(let text):
return text return text
@@ -32,7 +34,7 @@ extension HBMustacheTemplate {
if let template = child as? HBMustacheTemplate { if let template = child as? HBMustacheTemplate {
return template.render(context: context) return template.render(context: context)
} else { } else {
return String(describing: child).htmlEscape() return context.contentType.escapeText(String(describing: child))
} }
} }
case .unescapedVariable(let variable, let transform): case .unescapedVariable(let variable, let transform):
@@ -58,6 +60,9 @@ extension HBMustacheTemplate {
if let template = library?.getTemplate(named: name) { if let template = library?.getTemplate(named: name) {
return template.render(context: context.withPartial(indented: indentation, inheriting: overrides)) return template.render(context: context.withPartial(indented: indentation, inheriting: overrides))
} }
case .contentType(let contentType):
context = context.withContentType(contentType)
} }
return "" return ""
} }

View File

@@ -40,6 +40,7 @@ public final class HBMustacheTemplate {
case invertedSection(name: String, transform: String? = nil, template: HBMustacheTemplate) case invertedSection(name: String, transform: String? = nil, template: HBMustacheTemplate)
case inheritedSection(name: String, template: HBMustacheTemplate) case inheritedSection(name: String, template: HBMustacheTemplate)
case partial(String, indentation: String?, inherits: [String: HBMustacheTemplate]?) case partial(String, indentation: String?, inherits: [String: HBMustacheTemplate]?)
case contentType(HBMustacheContentType)
} }
let tokens: [Token] let tokens: [Token]

View File

@@ -31,6 +31,11 @@ final class TemplateParserTests: XCTestCase {
let template = try HBMustacheTemplate(string: "{{ section }}") let template = try HBMustacheTemplate(string: "{{ section }}")
XCTAssertEqual(template.tokens, [.variable(name: "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 { extension HBMustacheTemplate: Equatable {
@@ -52,6 +57,8 @@ extension HBMustacheTemplate.Token: Equatable {
return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3
case (.partial(let name1, let indent1, _), .partial(let name2, let indent2, _)): case (.partial(let name1, let indent1, _), .partial(let name2, let indent2, _)):
return name1 == name2 && indent1 == indent2 return name1 == name2 && indent1 == indent2
case (.contentType(let contentType), .contentType(let contentType2)):
return type(of: contentType) == type(of: contentType2)
default: default:
return false return false
} }

View File

@@ -92,6 +92,13 @@ final class TemplateRendererTests: XCTestCase {
XCTAssertEqual(template.render(Test(test: .init(string: "sub"))), "test sub") 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("<>"), "&lt;&gt;")
}
/// variables /// variables
func testMustacheManualExample1() throws { func testMustacheManualExample1() throws {
let template = try HBMustacheTemplate(string: """ let template = try HBMustacheTemplate(string: """