Introduce variable filters

This commit is contained in:
Kyle Fuller
2015-10-22 09:47:45 -07:00
parent 7d5d226017
commit 16da9ac034
8 changed files with 191 additions and 19 deletions

View File

@@ -1,5 +1,4 @@
Stencil # Stencil
=======
[![Build Status](http://img.shields.io/circleci/project/kylef/Stencil/master.svg)](https://circleci.com/gh/kylef/Stencil) [![Build Status](http://img.shields.io/circleci/project/kylef/Stencil/master.svg)](https://circleci.com/gh/kylef/Stencil)
@@ -7,7 +6,7 @@ Stencil is a simple and powerful template language for Swift. It provides a
syntax similar to Django and Mustache. If you're familiar with these, you will syntax similar to Django and Mustache. If you're familiar with these, you will
feel right at home with Stencil. feel right at home with Stencil.
### Example ## Example
```html+django ```html+django
There are {{ articles.count }} articles. There are {{ articles.count }} articles.
@@ -78,6 +77,53 @@ There are {{ people.count }} people, {{ people.first }} is first person.
Followed by {{ people.1 }}. Followed by {{ people.1 }}.
``` ```
#### Filters
Filters allow you to transform the values of variables. For example, they look like:
```html+django
{{ variable|uppercase }}
```
##### Capitalize
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`.
```html+django
{{ "stencil"|capitalize }}
```
##### Uppercase
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`.
```html+django
{{ "Stencil"|uppercase }}
```
##### Lowercase
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`.
```html+django
{{ "Stencil"|lowercase }}
```
#### Registering custom filters
```
template.parser.registerFilter("double") { value in
if let value = value as? Int {
return value * 2
}
return value
}
```
### Tags ### Tags
Tags are a mechanism to execute a piece of code, allowing you to have Tags are a mechanism to execute a piece of code, allowing you to have

View File

@@ -11,6 +11,8 @@
27A848E91B42242C004ACA13 /* base.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E71B42242C004ACA13 /* base.html */; }; 27A848E91B42242C004ACA13 /* base.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E71B42242C004ACA13 /* base.html */; };
27A848EA1B42242C004ACA13 /* child.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E81B42242C004ACA13 /* child.html */; }; 27A848EA1B42242C004ACA13 /* child.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E81B42242C004ACA13 /* child.html */; };
27A848EC1B42247D004ACA13 /* InheritenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A848EB1B42247D004ACA13 /* InheritenceTests.swift */; }; 27A848EC1B42247D004ACA13 /* InheritenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A848EB1B42247D004ACA13 /* InheritenceTests.swift */; };
27BA0A9E1BD9465700B7209B /* Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BA0A9D1BD9465700B7209B /* Filters.swift */; settings = {ASSET_TAGS = (); }; };
27BA0AA01BD946C300B7209B /* FilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BA0A9F1BD946C300B7209B /* FilterTests.swift */; settings = {ASSET_TAGS = (); }; };
27CE0ADE1A50BEC3004A105B /* TemplateLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */; }; 27CE0ADE1A50BEC3004A105B /* TemplateLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */; };
27CE0AE01A50BF05004A105B /* TemplateLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */; }; 27CE0AE01A50BF05004A105B /* TemplateLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */; };
27CE0AFA1A50C963004A105B /* test.html in Resources */ = {isa = PBXBuildFile; fileRef = 27CE0AF91A50C963004A105B /* test.html */; }; 27CE0AFA1A50C963004A105B /* test.html in Resources */ = {isa = PBXBuildFile; fileRef = 27CE0AF91A50C963004A105B /* test.html */; };
@@ -53,6 +55,8 @@
27A848E71B42242C004ACA13 /* base.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = base.html; sourceTree = "<group>"; }; 27A848E71B42242C004ACA13 /* base.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = base.html; sourceTree = "<group>"; };
27A848E81B42242C004ACA13 /* child.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = child.html; sourceTree = "<group>"; }; 27A848E81B42242C004ACA13 /* child.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = child.html; sourceTree = "<group>"; };
27A848EB1B42247D004ACA13 /* InheritenceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InheritenceTests.swift; sourceTree = "<group>"; }; 27A848EB1B42247D004ACA13 /* InheritenceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InheritenceTests.swift; sourceTree = "<group>"; };
27BA0A9D1BD9465700B7209B /* Filters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Filters.swift; sourceTree = "<group>"; };
27BA0A9F1BD946C300B7209B /* FilterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterTests.swift; sourceTree = "<group>"; };
27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoader.swift; sourceTree = "<group>"; }; 27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoader.swift; sourceTree = "<group>"; };
27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoaderTests.swift; sourceTree = "<group>"; }; 27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoaderTests.swift; sourceTree = "<group>"; };
27CE0AF91A50C963004A105B /* test.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = test.html; sourceTree = "<group>"; }; 27CE0AF91A50C963004A105B /* test.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = test.html; sourceTree = "<group>"; };
@@ -149,6 +153,7 @@
children = ( children = (
77FAAE5719F91E480029DC5E /* Stencil.h */, 77FAAE5719F91E480029DC5E /* Stencil.h */,
77FAAE6E19F920750029DC5E /* Context.swift */, 77FAAE6E19F920750029DC5E /* Context.swift */,
27BA0A9D1BD9465700B7209B /* Filters.swift */,
77EB082A19FA8600001870F1 /* Lexer.swift */, 77EB082A19FA8600001870F1 /* Lexer.swift */,
7725B3D419F9438F002CF74B /* Node.swift */, 7725B3D419F9438F002CF74B /* Node.swift */,
7725B3D619F94A43002CF74B /* Parser.swift */, 7725B3D619F94A43002CF74B /* Parser.swift */,
@@ -177,6 +182,7 @@
77FAAE7019F9208C0029DC5E /* ContextTests.swift */, 77FAAE7019F9208C0029DC5E /* ContextTests.swift */,
7725B3CA19F92B4F002CF74B /* VariableTests.swift */, 7725B3CA19F92B4F002CF74B /* VariableTests.swift */,
7725B3D219F9437F002CF74B /* NodeTests.swift */, 7725B3D219F9437F002CF74B /* NodeTests.swift */,
27BA0A9F1BD946C300B7209B /* FilterTests.swift */,
7725B3D819F94A61002CF74B /* ParserTests.swift */, 7725B3D819F94A61002CF74B /* ParserTests.swift */,
77EB082819FA85F2001870F1 /* LexerTests.swift */, 77EB082819FA85F2001870F1 /* LexerTests.swift */,
77EB082619F96E9C001870F1 /* TemplateTests.swift */, 77EB082619F96E9C001870F1 /* TemplateTests.swift */,
@@ -416,6 +422,7 @@
77FAAE6F19F920750029DC5E /* Context.swift in Sources */, 77FAAE6F19F920750029DC5E /* Context.swift in Sources */,
77EB082B19FA8600001870F1 /* Lexer.swift in Sources */, 77EB082B19FA8600001870F1 /* Lexer.swift in Sources */,
7725B3CF19F94214002CF74B /* Tokenizer.swift in Sources */, 7725B3CF19F94214002CF74B /* Tokenizer.swift in Sources */,
27BA0A9E1BD9465700B7209B /* Filters.swift in Sources */,
7725B3D719F94A43002CF74B /* Parser.swift in Sources */, 7725B3D719F94A43002CF74B /* Parser.swift in Sources */,
77EB082519F96E88001870F1 /* Template.swift in Sources */, 77EB082519F96E88001870F1 /* Template.swift in Sources */,
7725B3CD19F92B61002CF74B /* Variable.swift in Sources */, 7725B3CD19F92B61002CF74B /* Variable.swift in Sources */,
@@ -437,6 +444,7 @@
77EB082719F96E9C001870F1 /* TemplateTests.swift in Sources */, 77EB082719F96E9C001870F1 /* TemplateTests.swift in Sources */,
7725B3CB19F92B4F002CF74B /* VariableTests.swift in Sources */, 7725B3CB19F92B4F002CF74B /* VariableTests.swift in Sources */,
27CE0B041A50CBEA004A105B /* IncludeTests.swift in Sources */, 27CE0B041A50CBEA004A105B /* IncludeTests.swift in Sources */,
27BA0AA01BD946C300B7209B /* FilterTests.swift in Sources */,
77EB082919FA85F2001870F1 /* LexerTests.swift in Sources */, 77EB082919FA85F2001870F1 /* LexerTests.swift in Sources */,
77FAAE7119F9208C0029DC5E /* ContextTests.swift in Sources */, 77FAAE7119F9208C0029DC5E /* ContextTests.swift in Sources */,
); );

33
Stencil/Filters.swift Normal file
View File

@@ -0,0 +1,33 @@
func toString(value: Any?) -> String? {
if let value = value as? String {
return value
} else if let value = value as? CustomStringConvertible {
return value.description
}
return nil
}
func capitalise(value: Any?) -> Any? {
if let value = toString(value) {
return value.capitalizedString
}
return value
}
func uppercase(value: Any?) -> Any? {
if let value = toString(value) {
return value.uppercaseString
}
return value
}
func lowercase(value: Any?) -> Any? {
if let value = toString(value) {
return value.lowercaseString
}
return value
}

View File

@@ -46,22 +46,28 @@ public class TextNode : NodeType {
} }
} }
public class VariableNode : NodeType { public protocol Resolvable {
public let variable:Variable func resolve(context: Context) -> Any?
}
public init(variable:Variable) { public class VariableNode : NodeType {
public let variable: Resolvable
public init(variable: Resolvable) {
self.variable = variable self.variable = variable
} }
public init(variable:String) { public init(variable: String) {
self.variable = Variable(variable) self.variable = Variable(variable)
} }
public func render(context:Context) throws -> String { public func render(context: Context) throws -> String {
let result:AnyObject? = variable.resolve(context) let result = variable.resolve(context)
if let result = result as? String { if let result = result as? String {
return result return result
} else if let result = result as? CustomStringConvertible {
return result.description
} else if let result = result as? NSObject { } else if let result = result as? NSObject {
return result.description return result.description
} }
@@ -94,7 +100,7 @@ public class NowNode : NodeType {
public func render(context: Context) throws -> String { public func render(context: Context) throws -> String {
let date = NSDate() let date = NSDate()
let format: AnyObject? = self.format.resolve(context) let format = self.format.resolve(context)
var formatter:NSDateFormatter? var formatter:NSDateFormatter?
if let format = format as? NSDateFormatter { if let format = format as? NSDateFormatter {
@@ -212,7 +218,7 @@ public class IfNode : NodeType {
} }
public func render(context: Context) throws -> String { public func render(context: Context) throws -> String {
let result: AnyObject? = variable.resolve(context) let result = variable.resolve(context)
var truthy = false var truthy = false
if let result = result as? [AnyObject] { if let result = result as? [AnyObject] {

View File

@@ -10,12 +10,15 @@ public func until(tags:[String])(parser:TokenParser, token:Token) -> Bool {
return false return false
} }
public typealias Filter = Any? -> Any?
/// A class for parsing an array of tokens and converts them into a collection of Node's /// A class for parsing an array of tokens and converts them into a collection of Node's
public class TokenParser { public class TokenParser {
public typealias TagParser = (TokenParser, Token) throws -> NodeType public typealias TagParser = (TokenParser, Token) throws -> NodeType
private var tokens:[Token] private var tokens:[Token]
private var tags = [String:TagParser]() private var tags = [String:TagParser]()
private var filters = [String: Filter]()
public init(tokens:[Token]) { public init(tokens:[Token]) {
self.tokens = tokens self.tokens = tokens
@@ -26,6 +29,9 @@ public class TokenParser {
registerTag("include", parser: IncludeNode.parse) registerTag("include", parser: IncludeNode.parse)
registerTag("extends", parser: ExtendsNode.parse) registerTag("extends", parser: ExtendsNode.parse)
registerTag("block", parser: BlockNode.parse) registerTag("block", parser: BlockNode.parse)
registerFilter("capitalize", filter: capitalise)
registerFilter("uppercase", filter: uppercase)
registerFilter("lowercase", filter: lowercase)
} }
/// Registers a new template tag /// Registers a new template tag
@@ -40,6 +46,10 @@ public class TokenParser {
}) })
} }
public func registerFilter(name: String, filter: Filter) {
filters[name] = filter
}
/// Parse the given tokens into nodes /// Parse the given tokens into nodes
public func parse() throws -> [NodeType] { public func parse() throws -> [NodeType] {
return try parse(nil) return try parse(nil)
@@ -54,8 +64,8 @@ public class TokenParser {
switch token { switch token {
case .Text(let text): case .Text(let text):
nodes.append(TextNode(text: text)) nodes.append(TextNode(text: text))
case .Variable(let variable): case .Variable:
nodes.append(VariableNode(variable: variable)) nodes.append(VariableNode(variable: try compileFilter(token.contents)))
case .Block: case .Block:
let tag = token.components().first let tag = token.components().first
@@ -88,4 +98,16 @@ public class TokenParser {
public func prependToken(token:Token) { public func prependToken(token:Token) {
tokens.insert(token, atIndex: 0) tokens.insert(token, atIndex: 0)
} }
public func findFilter(name: String) throws -> Filter {
if let filter = filters[name] {
return filter
}
throw TemplateSyntaxError("Invalid filter '\(name)'")
}
func compileFilter(token: String) throws -> Resolvable {
return try FilterExpression(token: token, parser: self)
}
} }

View File

@@ -1,8 +1,40 @@
import Foundation import Foundation
class FilterExpression : Resolvable {
let filters: [Filter]
let variable: Variable
init(token: String, parser: TokenParser) throws {
let bits = token.componentsSeparatedByString("|")
if bits.isEmpty {
filters = []
variable = Variable("")
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
}
variable = Variable(bits[0])
let filterBits = bits[1 ..< bits.endIndex]
do {
filters = try filterBits.map { try parser.findFilter($0) }
} catch {
filters = []
throw error
}
}
func resolve(context: Context) -> Any? {
let result = variable.resolve(context)
return filters.reduce(result) { x, y in
return y(x)
}
}
}
/// A structure used to represent a template variable, and to resolve it in a given context. /// A structure used to represent a template variable, and to resolve it in a given context.
public struct Variable : Equatable { public struct Variable : Equatable, Resolvable {
public let variable:String public let variable:String
/// Create a variable with a string representing the variable /// Create a variable with a string representing the variable
@@ -15,11 +47,12 @@ public struct Variable : Equatable {
} }
/// Resolve the variable in the given context /// Resolve the variable in the given context
public func resolve(context:Context) -> AnyObject? { public func resolve(context:Context) -> Any? {
var current:AnyObject? = context var current:AnyObject? = context
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) { if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
return variable.substringWithRange(variable.startIndex.successor() ..< variable.endIndex.predecessor()) // String literal
return variable[variable.startIndex.successor() ..< variable.endIndex.predecessor()]
} }
for bit in lookup() { for bit in lookup() {

View File

@@ -0,0 +1,23 @@
import XCTest
import Stencil
class FilterTests: XCTestCase {
func testCapitalizeFilter() {
let template = Template(templateString: "{{ name|capitalize }}")
let result = try? template.render(Context(dictionary: ["name": "kyle"]))
XCTAssertEqual(result, "Kyle")
}
func testUppercaseFilter() {
let template = Template(templateString: "{{ name|uppercase }}")
let result = try? template.render(Context(dictionary: ["name": "kyle"]))
XCTAssertEqual(result, "KYLE")
}
func testLowercaseFilter() {
let template = Template(templateString: "{{ name|lowercase }}")
let result = try? template.render(Context(dictionary: ["name": "Kyle"]))
XCTAssertEqual(result, "kyle")
}
}

View File

@@ -17,13 +17,14 @@ class TokenParserTests: XCTestCase {
func testParsingVariableToken() { func testParsingVariableToken() {
let parser = TokenParser(tokens: [ let parser = TokenParser(tokens: [
Token.Variable(value: "name") Token.Variable(value: "'name'")
]) ])
assertSuccess(try parser.parse()) { nodes in assertSuccess(try parser.parse()) { nodes in
let node = nodes.first as! VariableNode let node = nodes.first as! VariableNode
XCTAssertEqual(nodes.count, 1) XCTAssertEqual(nodes.count, 1)
XCTAssertEqual(node.variable, Variable("name")) let result = try? node.render(Context())
XCTAssertEqual(result, "name")
} }
} }