Template inheritance (#9)

* Move all context variables into HBMustacheContext

* Add support for reading inherited sections

* Render inherited tokens

* Test inheritance spec, fix two minor issues

* fix warning

* swift format
This commit is contained in:
Adam Fowler
2021-03-22 12:02:22 +00:00
committed by GitHub
parent af345e9138
commit 35d52603e2
9 changed files with 232 additions and 59 deletions

View File

@@ -0,0 +1,57 @@
struct HBMustacheContext {
let stack: [Any]
let sequenceContext: HBMustacheSequenceContext?
let indentation: String?
let inherited: [String: HBMustacheTemplate]?
init(_ object: Any) {
self.stack = [object]
self.sequenceContext = nil
self.indentation = nil
self.inherited = nil
}
private init(
stack: [Any],
sequenceContext: HBMustacheSequenceContext?,
indentation: String?,
inherited: [String: HBMustacheTemplate]?
) {
self.stack = stack
self.sequenceContext = sequenceContext
self.indentation = indentation
self.inherited = inherited
}
func withObject(_ object: Any) -> HBMustacheContext {
var stack = self.stack
stack.append(object)
return .init(stack: stack, sequenceContext: nil, indentation: self.indentation, inherited: self.inherited)
}
func withPartial(indented: String?, inheriting: [String: HBMustacheTemplate]?) -> HBMustacheContext {
let indentation: String?
if let indented = indented {
indentation = (self.indentation ?? "") + indented
} else {
indentation = self.indentation
}
let inherits: [String: HBMustacheTemplate]?
if let inheriting = inheriting {
if let originalInherits = self.inherited {
inherits = originalInherits.merging(inheriting) { value, _ in value }
} else {
inherits = inheriting
}
} else {
inherits = self.inherited
}
return .init(stack: self.stack, sequenceContext: nil, indentation: indentation, inherited: inherits)
}
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)
}
}

View File

@@ -30,6 +30,16 @@ public final class HBMustacheLibrary {
self.templates[name] = template
}
/// Register template under name
/// - Parameters:
/// - mustache: Mustache text
/// - name: Name of template
public func register(_ mustache: String, named name: String) throws {
let template = try HBMustacheTemplate(string: mustache)
template.setLibrary(self)
self.templates[name] = template
}
/// Return template registed with name
/// - Parameter name: name to search for
/// - Returns: Template

View File

@@ -1,46 +1,39 @@
/// Protocol for objects that can be rendered as a sequence in Mustache
public protocol HBMustacheSequence {
protocol HBMustacheSequence {
/// Render section using template
func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String
func renderSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String
/// Render inverted section using template
func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String
func renderInvertedSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String
}
public extension Sequence {
extension Sequence {
/// Render section using template
func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String {
func renderSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String {
var string = ""
var context = HBMustacheSequenceContext(first: true)
var sequenceContext = HBMustacheSequenceContext(first: true)
var iterator = makeIterator()
guard var currentObject = iterator.next() else { return "" }
while let object = iterator.next() {
var stack = stack
stack.append(currentObject)
string += template.render(stack, context: context)
string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext))
currentObject = object
context.first = false
context.index += 1
sequenceContext.first = false
sequenceContext.index += 1
}
context.last = true
var stack = stack
stack.append(currentObject)
string += template.render(stack, context: context)
sequenceContext.last = true
string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext))
return string
}
/// Render inverted section using template
func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String {
var stack = stack
stack.append(self)
func renderInvertedSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String {
var iterator = makeIterator()
if iterator.next() == nil {
return template.render(stack)
return template.render(context: context.withObject(self))
}
return ""
}

View File

@@ -16,6 +16,12 @@ extension HBMustacheTemplate {
case expectedSectionEnd
/// set delimiter tag badly formatted
case invalidSetDelimiter
/// cannot apply transform to inherited section
case transformAppliedToInheritanceSection
/// illegal token inside inherit section of partial
case illegalTokenInsideInheritSection
/// text found inside inherit section of partial
case textInsideInheritSection
}
struct ParserState {
@@ -121,6 +127,21 @@ extension HBMustacheTemplate {
let sectionTokens = try parse(&parser, state: state.withSectionName(name, method: method))
tokens.append(.invertedSection(name: name, method: method, template: HBMustacheTemplate(sectionTokens)))
case "$":
// inherited section
parser.unsafeAdvance()
let (name, method) = try parseName(&parser, state: state)
// ERROR: can't have methods applied to inherited sections
guard method == nil else { throw Error.transformAppliedToInheritanceSection }
if self.isStandalone(&parser, state: state) {
setNewLine = true
} else if whiteSpaceBefore.count > 0 {
tokens.append(.text(String(whiteSpaceBefore)))
whiteSpaceBefore = ""
}
let sectionTokens = try parse(&parser, state: state.withSectionName(name, method: method))
tokens.append(.inheritedSection(name: name, template: HBMustacheTemplate(sectionTokens)))
case "/":
// end of section
parser.unsafeAdvance()
@@ -174,12 +195,40 @@ extension HBMustacheTemplate {
}
if self.isStandalone(&parser, state: state) {
setNewLine = true
tokens.append(.partial(name, indentation: String(whiteSpaceBefore)))
tokens.append(.partial(name, indentation: String(whiteSpaceBefore), inherits: nil))
} else {
tokens.append(.partial(name, indentation: nil))
tokens.append(.partial(name, indentation: nil, inherits: nil))
}
whiteSpaceBefore = ""
case "<":
// partial with inheritance
parser.unsafeAdvance()
let (name, method) = try parseName(&parser, state: state)
// ERROR: can't have methods applied to inherited sections
guard method == nil else { throw Error.transformAppliedToInheritanceSection }
var indent: String?
if self.isStandalone(&parser, state: state) {
setNewLine = true
} else if whiteSpaceBefore.count > 0 {
indent = String(whiteSpaceBefore)
tokens.append(.text(indent!))
whiteSpaceBefore = ""
}
let sectionTokens = try parse(&parser, state: state.withSectionName(name, method: method))
var inherit: [String: HBMustacheTemplate] = [:]
for token in sectionTokens {
switch token {
case .inheritedSection(let name, let template):
inherit[name] = template
case .text:
break
default:
throw Error.illegalTokenInsideInheritSection
}
}
tokens.append(.partial(name, indentation: indent, inherits: inherit))
case "=":
// set delimiter
parser.unsafeAdvance()

View File

@@ -6,50 +6,57 @@ extension HBMustacheTemplate {
/// - context: Context that render is occurring in. Contains information about position in sequence
/// - indentation: indentation of partial
/// - Returns: Rendered text
func render(_ stack: [Any], context: HBMustacheSequenceContext? = nil, indentation: String? = nil) -> String {
func render(context: HBMustacheContext) -> String {
var string = ""
if let indentation = indentation, indentation != "" {
if let indentation = context.indentation, indentation != "" {
for token in tokens {
if string.last == "\n" {
string += indentation
}
string += self.renderToken(token, stack: stack, context: context)
string += self.renderToken(token, context: context)
}
} else {
for token in tokens {
string += self.renderToken(token, stack: stack, context: context)
string += self.renderToken(token, context: context)
}
}
return string
}
func renderToken(_ token: Token, stack: [Any], context: HBMustacheSequenceContext? = nil) -> String {
func renderToken(_ token: Token, context: HBMustacheContext) -> String {
switch token {
case .text(let text):
return text
case .variable(let variable, let method):
if let child = getChild(named: variable, from: stack, method: method, context: context) {
if let child = getChild(named: variable, method: method, context: context) {
if let template = child as? HBMustacheTemplate {
return template.render(stack)
return template.render(context: context)
} else {
return String(describing: child).htmlEscape()
}
}
case .unescapedVariable(let variable, let method):
if let child = getChild(named: variable, from: stack, method: method, context: context) {
if let child = getChild(named: variable, method: method, context: context) {
return String(describing: child)
}
case .section(let variable, let method, let template):
let child = self.getChild(named: variable, from: stack, method: method, context: context)
return self.renderSection(child, stack: stack, with: template)
let child = self.getChild(named: variable, method: method, context: context)
return self.renderSection(child, with: template, context: context)
case .invertedSection(let variable, let method, let template):
let child = self.getChild(named: variable, from: stack, method: method, context: context)
return self.renderInvertedSection(child, stack: stack, with: template)
let child = self.getChild(named: variable, method: method, context: context)
return self.renderInvertedSection(child, with: template, context: context)
case .partial(let name, let indentation):
case .inheritedSection(let name, let template):
if let override = context.inherited?[name] {
return override.render(context: context)
} else {
return template.render(context: context)
}
case .partial(let name, let indentation, let overrides):
if let template = library?.getTemplate(named: name) {
return template.render(stack, indentation: indentation)
return template.render(context: context.withPartial(indented: indentation, inheriting: overrides))
}
}
return ""
@@ -61,16 +68,16 @@ extension HBMustacheTemplate {
/// - parent: Current object being rendered
/// - template: Template to render with
/// - Returns: Rendered text
func renderSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String {
func renderSection(_ child: Any?, with template: HBMustacheTemplate, context: HBMustacheContext) -> String {
switch child {
case let array as HBMustacheSequence:
return array.renderSection(with: template, stack: stack + [array])
return array.renderSection(with: template, context: context)
case let bool as Bool:
return bool ? template.render(stack) : ""
return bool ? template.render(context: context) : ""
case let lambda as HBMustacheLambda:
return lambda.run(stack.last!, template)
return lambda.run(context.stack.last!, template)
case .some(let value):
return template.render(stack + [value])
return template.render(context: context.withObject(value))
case .none:
return ""
}
@@ -82,21 +89,21 @@ extension HBMustacheTemplate {
/// - parent: Current object being rendered
/// - template: Template to render with
/// - Returns: Rendered text
func renderInvertedSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String {
func renderInvertedSection(_ child: Any?, with template: HBMustacheTemplate, context: HBMustacheContext) -> String {
switch child {
case let array as HBMustacheSequence:
return array.renderInvertedSection(with: template, stack: stack)
return array.renderInvertedSection(with: template, context: context)
case let bool as Bool:
return bool ? "" : template.render(stack)
return bool ? "" : template.render(context: context)
case .some:
return ""
case .none:
return template.render(stack)
return template.render(context: context)
}
}
/// Get child object from variable name
func getChild(named name: String, from stack: [Any], method: String?, context: HBMustacheSequenceContext?) -> Any? {
func getChild(named name: String, method: String?, context: HBMustacheContext) -> Any? {
func _getImmediateChild(named name: String, from object: Any) -> Any? {
if let customBox = object as? HBMustacheParent {
return customBox.child(named: name)
@@ -129,12 +136,12 @@ extension HBMustacheTemplate {
// the name is split by "." and we use mirror to get the correct child object
let child: Any?
if name == "." {
child = stack.last!
child = context.stack.last!
} else if name == "", method != nil {
child = context
child = context.sequenceContext
} else {
let nameSplit = name.split(separator: ".").map { String($0) }
child = _getChildInStack(named: nameSplit[...], from: stack)
child = _getChildInStack(named: nameSplit[...], from: context.stack)
}
// if we want to run a method and the current child can have methods applied to it then
// run method on the current child

View File

@@ -11,7 +11,7 @@ public final class HBMustacheTemplate {
/// - Parameter object: Object to render
/// - Returns: Rendered text
public func render(_ object: Any) -> String {
self.render([object], context: nil)
self.render(context: .init(object))
}
internal init(_ tokens: [Token]) {
@@ -22,8 +22,10 @@ public final class HBMustacheTemplate {
self.library = library
for token in self.tokens {
switch token {
case .section(_, _, let template), .invertedSection(_, _, let template):
case .section(_, _, let template), .invertedSection(_, _, let template), .inheritedSection(_, let template):
template.setLibrary(library)
case .partial(_, _, let templates):
templates?.forEach { $1.setLibrary(library) }
default:
break
}
@@ -36,7 +38,8 @@ public final class HBMustacheTemplate {
case unescapedVariable(name: String, method: String? = nil)
case section(name: String, method: String? = nil, template: HBMustacheTemplate)
case invertedSection(name: String, method: String? = nil, template: HBMustacheTemplate)
case partial(String, indentation: String?)
case inheritedSection(name: String, template: HBMustacheTemplate)
case partial(String, indentation: String?, inherits: [String: HBMustacheTemplate]?)
}
let tokens: [Token]

View File

@@ -51,4 +51,51 @@ final class PartialTests: XCTestCase {
""")
}
/// test inheritance
func testInheritance() throws {
let library = HBMustacheLibrary()
try library.register(
"""
<head>
<title>{{$title}}Default title{{/title}}</title>
</head>
""",
named: "header"
)
try library.register(
"""
<html>
{{$header}}{{/header}}
{{$content}}{{/content}}
</html>
""",
named: "base"
)
try library.register(
"""
{{<base}}
{{$header}}
{{<header}}
{{$title}}My page title{{/title}}
{{/header}}
{{/header}}
{{$content}}<h1>Hello world</h1>{{/content}}
{{/base}}
""",
named: "mypage"
)
XCTAssertEqual(library.render({}, withTemplate: "mypage")!, """
<html>
<head>
<title>My page title</title>
</head>
<h1>Hello world</h1>
</html>
""")
}
}

View File

@@ -46,11 +46,6 @@ public extension AnyDecodable {
/// Verify implementation against formal standard for Mustache.
/// https://github.com/mustache/spec
final class MustacheSpecTests: XCTestCase {
func loadSpec(name: String) throws -> Data {
let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json")!
return try Data(contentsOf: url)
}
struct Spec: Decodable {
struct Test: Decodable {
let name: String
@@ -91,7 +86,12 @@ final class MustacheSpecTests: XCTestCase {
}
func testSpec(name: String, ignoring: [String] = []) throws {
let data = try loadSpec(name: name)
let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json")!
try testSpec(url: url, ignoring: ignoring)
}
func testSpec(url: URL, ignoring: [String] = []) throws {
let data = try Data(contentsOf: url)
let spec = try JSONDecoder().decode(Spec.self, from: data)
print(spec.overview)
@@ -124,4 +124,11 @@ final class MustacheSpecTests: XCTestCase {
func testSectionsSpec() throws {
try self.testSpec(name: "sections", ignoring: ["Variable test"])
}
func testInheritanceSpec() throws {
let url = URL(
string: "https://raw.githubusercontent.com/mustache/spec/ab227509e64961943ca374c09c08b63f59da014a/specs/inheritance.json"
)!
try self.testSpec(url: url)
}
}

View File

@@ -50,7 +50,7 @@ extension HBMustacheTemplate.Token: Equatable {
return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3
case (.invertedSection(let lhs1, let lhs2, let lhs3), .invertedSection(let rhs1, let rhs2, let 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
default:
return false