refactor: Introducing Environments

This commit is contained in:
Kyle Fuller
2016-12-01 00:17:04 +00:00
parent 2be672c6a5
commit 9e2a061795
27 changed files with 289 additions and 91 deletions

View File

@@ -4,11 +4,39 @@
### Breaking
- It is no longer possible to create `Context` objects. Instead, you can pass a
dictionary directly to a `Template`s `render` method.
```diff
- try template.render(Context(dictionary: ["name": "Kyle"]))
+ try template.render(["name": "Kyle"])
```
- Template loader are no longer passed into a `Context`, instead you will need
to pass the `Loader` to an `Environment` and create a template from the
`Environment`.
```diff
let loader = FileSystemLoader(paths: ["templates/"])
- let template = loader.loadTemplate(name: "index.html")
- try template.render(Context(dictionary: ["loader": loader]))
+ let environment = Environment(loader: loader)
+ try environment.renderTemplate(name: "index.html")
```
- `Loader`s will now throw a `TemplateDoesNotExist` error when a template
is not found.
### Enhancements
- `Environment` is a new way to load templates. You can configure an
environment with custom template filters, tags and loaders and then create a
template from an environment.
Environment also provides a convenience method to render a template directly.
- `FileSystemLoader` will now ensure that template paths are within the base
path. Any template names that try to escape the base path will raise a
`SuspiciousFileOperation` error.

View File

@@ -19,25 +19,24 @@ There are {{ articles.count }} articles.
```
```swift
import Stencil
struct Article {
let title: String
let author: String
}
let context = Context(dictionary: [
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
do {
let template = try Template(named: "template.html")
let rendered = try template.render(context)
print(rendered)
} catch {
print("Failed to render template \(error)")
}
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"])
let rendered = try environment.renderTemplate(name: context)
print(rendered)
```
## Installation

View File

@@ -1,17 +1,19 @@
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
public let environment: Environment
let namespace: Namespace
/// Initialise a Context with an optional dictionary and optional namespace
public init(dictionary: [String: Any]? = nil, namespace: Namespace = Namespace()) {
init(dictionary: [String: Any]? = nil, namespace: Namespace? = nil, environment: Environment? = nil) {
if let dictionary = dictionary {
dictionaries = [dictionary]
} else {
dictionaries = []
}
self.namespace = namespace
self.namespace = namespace ?? environment?.namespace ?? Namespace()
self.environment = environment ?? Environment()
}
public subscript(key: String) -> Any? {

36
Sources/Environment.swift Normal file
View File

@@ -0,0 +1,36 @@
public struct Environment {
var namespace: Namespace
public var loader: Loader?
public init(loader: Loader? = nil, namespace: Namespace? = nil) {
self.loader = loader
self.namespace = namespace ?? Namespace()
}
public func loadTemplate(name: String) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(name: name, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: [name], loader: nil)
}
}
public func loadTemplate(names: [String]) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(names: names, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: names, loader: nil)
}
}
public func renderTemplate(name: String, context: [String: Any]? = nil) throws -> String {
let template = try loadTemplate(name: name)
return try template.render(context)
}
public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String {
let template = Template(templateString: string, environment: self)
return try template.render(context)
}
}

View File

@@ -1,14 +1,19 @@
public class TemplateDoesNotExist: Error, CustomStringConvertible {
let templateNames: [String]
let loader: Loader
let loader: Loader?
public init(templateNames: [String], loader: Loader) {
public init(templateNames: [String], loader: Loader? = nil) {
self.templateNames = templateNames
self.loader = loader
}
public var description: String {
let templates = templateNames.joined(separator: ", ")
return "Template named `\(templates)` does not exist in loader \(loader)"
if let loader = loader {
return "Template named `\(templates)` does not exist in loader \(loader)"
}
return "Template named `\(templates)` does not exist. No loaders found"
}
}

View File

@@ -19,16 +19,15 @@ class IncludeNode : NodeType {
}
func render(_ context: Context) throws -> String {
guard let loader = context["loader"] as? Loader else {
throw TemplateSyntaxError("Template loader not in context")
}
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let template = try loader.loadTemplate(name: templateName)
return try template.render(context)
let template = try context.environment.loadTemplate(name: templateName)
return try context.push {
return try template.render(context)
}
}
}

View File

@@ -59,15 +59,11 @@ class ExtendsNode : NodeType {
}
func render(_ context: Context) throws -> String {
guard let loader = context["loader"] as? Loader else {
throw TemplateSyntaxError("Template loader not in context")
}
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let template = try loader.loadTemplate(name: templateName)
let template = try context.environment.loadTemplate(name: templateName)
let blockContext: BlockContext
if let context = context[BlockContext.contextKey] as? BlockContext {

View File

@@ -3,16 +3,16 @@ import PathKit
public protocol Loader {
func loadTemplate(name: String) throws -> Template
func loadTemplate(names: [String]) throws -> Template
func loadTemplate(name: String, environment: Environment) throws -> Template
func loadTemplate(names: [String], environment: Environment) throws -> Template
}
extension Loader {
func loadTemplate(names: [String]) throws -> Template {
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
do {
return try loadTemplate(name: name)
return try loadTemplate(name: name, environment: environment)
} catch is TemplateDoesNotExist {
continue
} catch {
@@ -43,7 +43,7 @@ public class FileSystemLoader: Loader, CustomStringConvertible {
return "FileSystemLoader(\(paths))"
}
public func loadTemplate(name: String) throws -> Template {
public func loadTemplate(name: String, environment: Environment) throws -> Template {
for path in paths {
let templatePath = try path.safeJoin(path: Path(name))
@@ -51,19 +51,19 @@ public class FileSystemLoader: Loader, CustomStringConvertible {
continue
}
return try Template(path: templatePath)
return try Template(path: templatePath, environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
public func loadTemplate(names: [String]) throws -> Template {
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for path in paths {
for templateName in names {
let templatePath = try path.safeJoin(path: Path(templateName))
if templatePath.exists {
return try Template(path: templatePath)
return try Template(path: templatePath, environment: environment, name: templateName)
}
}
}

View File

@@ -7,10 +7,17 @@ let NSFileNoSuchFileError = 4
/// A class representing a template
public class Template: ExpressibleByStringLiteral {
let environment: Environment
let tokens: [Token]
/// The name of the loaded Template if the Template was loaded from a Loader
public let name: String?
/// Create a template with a template string
public init(templateString: String) {
public init(templateString: String, environment: Environment? = nil, name: String? = nil) {
self.environment = environment ?? Environment()
self.name = name
let lexer = Lexer(templateString: templateString)
tokens = lexer.tokenize()
}
@@ -31,11 +38,13 @@ public class Template: ExpressibleByStringLiteral {
}
/// Create a template with a file found at the given path
public convenience init(path: Path) throws {
self.init(templateString: try path.read())
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
self.init(templateString: try path.read(), environment: environment, name: name)
}
// Create a template with a template string literal
// MARK: ExpressibleByStringLiteral
// Create a templaVte with a template string literal
public convenience required init(stringLiteral value: String) {
self.init(templateString: value)
}
@@ -50,11 +59,16 @@ public class Template: ExpressibleByStringLiteral {
self.init(stringLiteral: value)
}
/// Render the given template
public func render(_ context: Context? = nil) throws -> String {
let context = context ?? Context()
/// Render the given template with a context
func render(_ context: Context) throws -> String {
let context = context ?? Context(environment: environment)
let parser = TokenParser(tokens: tokens, namespace: context.namespace)
let nodes = try parser.parse()
return try renderNodes(nodes, context)
}
/// Render the given template
public func render(_ dictionary: [String: Any]? = nil) throws -> String {
return try render(Context(dictionary: dictionary, environment: environment))
}
}

View File

@@ -1,5 +1,5 @@
import Spectre
import Stencil
@testable import Stencil
func testContext() {

View File

@@ -0,0 +1,41 @@
import Spectre
import Stencil
func testEnvironment() {
describe("Environment") {
let environment = Environment(loader: ExampleLoader())
$0.it("can load a template from a name") {
let template = try environment.loadTemplate(name: "example.html")
try expect(template.name) == "example.html"
}
$0.it("can load a template from a names") {
let template = try environment.loadTemplate(names: ["first.html", "example.html"])
try expect(template.name) == "example.html"
}
$0.it("can render a template from a string") {
let result = try environment.renderTemplate(string: "Hello World")
try expect(result) == "Hello World"
}
$0.it("can render a template from a file") {
let result = try environment.renderTemplate(name: "example.html")
try expect(result) == "Hello World!"
}
}
}
fileprivate class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "example.html" {
return Template(templateString: "Hello World!", environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
}

View File

@@ -1,5 +1,5 @@
import Spectre
import Stencil
@testable import Stencil
func testFilter() {

View File

@@ -7,6 +7,7 @@ func testInclude() {
describe("Include") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
$0.describe("parsing") {
$0.it("throws an error when no template is given") {
@@ -35,7 +36,7 @@ func testInclude() {
do {
_ = try node.render(Context())
} catch {
try expect("\(error)") == "Template loader not in context"
try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
}
}
@@ -43,7 +44,7 @@ func testInclude() {
let node = IncludeNode(templateName: Variable("\"unknown.html\""))
do {
_ = try node.render(Context(dictionary: ["loader": loader]))
_ = try node.render(Context(environment: environment))
} catch {
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
}
@@ -51,7 +52,7 @@ func testInclude() {
$0.it("successfully renders a found included template") {
let node = IncludeNode(templateName: Variable("\"test.html\""))
let context = Context(dictionary: ["loader":loader, "target": "World"])
let context = Context(dictionary: ["target": "World"], environment: environment)
let value = try node.render(context)
try expect(value) == "Hello World!"
}

View File

@@ -7,17 +7,16 @@ func testInheritence() {
describe("Inheritence") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
$0.it("can inherit from another template") {
let context = Context(dictionary: ["loader": loader])
let template = try loader.loadTemplate(name: "child.html")
try expect(try template.render(context)) == "Header\nChild"
let template = try environment.loadTemplate(name: "child.html")
try expect(try template.render()) == "Header\nChild"
}
$0.it("can inherit from another template inheriting from another template") {
let context = Context(dictionary: ["loader": loader])
let template = try loader.loadTemplate(name: "child-child.html")
try expect(try template.render(context)) == "Child Child Header\nChild"
let template = try environment.loadTemplate(name: "child-child.html")
try expect(try template.render()) == "Child Child Header\nChild"
}
}
}

View File

@@ -7,25 +7,26 @@ func testTemplateLoader() {
describe("FileSystemLoader") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
$0.it("errors when a template cannot be found") {
try expect(try loader.loadTemplate(name: "unknown.html")).toThrow()
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
}
$0.it("errors when an array of templates cannot be found") {
try expect(try loader.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
}
$0.it("can load a template from a file") {
_ = try loader.loadTemplate(name: "test.html")
_ = try environment.loadTemplate(name: "test.html")
}
$0.it("errors when loading absolute file outside of the selected path") {
try expect(try loader.loadTemplate(name: "/etc/hosts")).toThrow()
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
}
$0.it("errors when loading relative file outside of the selected path") {
try expect(try loader.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
}
}
}

View File

@@ -1,5 +1,5 @@
import Spectre
import Stencil
@testable import Stencil
class ErrorNode : NodeType {

View File

@@ -1,5 +1,5 @@
import Spectre
import Stencil
@testable import Stencil
func testTokenParser() {

View File

@@ -25,12 +25,12 @@ func testStencil() {
" - {{ article.title }} by {{ article.author }}.\n" +
"{% endfor %}\n"
let context = Context(dictionary: [
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
let template = Template(templateString: templateString)
let result = try template.render(context)
@@ -45,28 +45,27 @@ func testStencil() {
}
$0.it("can render a custom template tag") {
let templateString = "{% custom %}"
let template = Template(templateString: templateString)
let namespace = Namespace()
namespace.registerTag("custom") { parser, token in
return CustomNode()
}
let result = try template.render(Context(namespace: namespace))
let environment = Environment(namespace: namespace)
let result = try environment.renderTemplate(string: "{% custom %}")
try expect(result) == "Hello World"
}
$0.it("can render a simple custom tag") {
let templateString = "{% custom %}"
let template = Template(templateString: templateString)
let namespace = Namespace()
namespace.registerSimpleTag("custom") { context in
return "Hello World"
}
try expect(try template.render(Context(namespace: namespace))) == "Hello World"
let environment = Environment(namespace: namespace)
let result = try environment.renderTemplate(string: "{% custom %}")
try expect(result) == "Hello World"
}
}
}

View File

@@ -5,16 +5,14 @@ import Stencil
func testTemplate() {
describe("Template") {
$0.it("can render a template from a string") {
let context = Context(dictionary: [ "name": "Kyle" ])
let template = Template(templateString: "Hello World")
let result = try template.render(context)
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
$0.it("can render a template from a string literal") {
let context = Context(dictionary: [ "name": "Kyle" ])
let template: Template = "Hello World"
let result = try template.render(context)
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
import Spectre
import Stencil
@testable import Stencil
#if os(OSX)

View File

@@ -18,6 +18,7 @@ public func stencilTests() {
testInclude()
testInheritence()
testFilterTag()
testEnvironment()
testStencil()
}

View File

@@ -6,12 +6,6 @@ template. Its somewhat like a dictionary, however you can push and pop to
scope variables. So that means that when iterating over a for loop, you can
push a new scope into the context to store any variables local to the scope.
You can initialise a ``Context`` with a ``Dictionary``.
.. code-block:: swift
Context(dictionary: [String: Any]? = nil)
API
----

43
docs/api/environment.rst Normal file
View File

@@ -0,0 +1,43 @@
Environment
===========
An environment contains shared configuration such as custom filters and tags
along with template loaders.
.. code-block:: swift
let environment = Environment()
You can optionally provide a loader or namespace when creating an environment:
.. code-block:: swift
let environment = Environment(loader: ..., namespace: ...)
Rendering a Template
--------------------
Environment providences coninience methods to render a template either from a
string or a template loader.
.. code-block:: swift
let template = "Hello {{ name }}"
let context = ["name": "Kyle"]
let rendered = environment.render(templateString: template, context: context)
Rendering a template from the configured loader:
.. code-block:: swift
let context = ["name": "Kyle"]
let rendered = environment.render(templateName: "example.html", context: context)
Loading a Template
------------------
Environment provides an API to load a template from the configured loader.
.. code-block:: swift
let template = try environment.loadTemplate(name: "example.html")

38
docs/api/loader.rst Normal file
View File

@@ -0,0 +1,38 @@
Loader
======
Loaders are responsible for loading templates from a resource such as the file
system.
Stencil provides a ``FileSytemLoader`` which allows you to load a template
directly from the file system.
``Loader`` is a protocol, so you can implement your own compatible loaders. You
will need to implement a ``loadTemplate`` method to load the template,
throwing a ``TemplateDoesNotExist`` when the template is not found.
.. code-block:: swift
class ExampleMemoryLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "index.html" {
return Template(templateString: "Hello", environment: environment)
}
throw TemplateDoesNotExist()
}
}
FileSystemLoader
----------------
Loads templates from the file system. This loader can find templates in folders
on the file system.
.. code-block:: swift
FileSystemLoader(paths: ["./templates"])
.. code-block:: swift
FileSystemLoader(bundle: [Bundle.main])

View File

@@ -206,13 +206,13 @@ You can include another template using the `include` tag.
{% include "comment.html" %}
The `include` tag requires a FileSystemLoader to be found inside your context with the paths, or bundles used to lookup the template.
The `include` tag requires you to provide a loader which will be used to lookup
the template.
.. code-block:: swift
let context = Context(dictionary: [
"loader": FileSystemLoader(bundle: [NSBundle.mainBundle()])
])
let environment = Environment(bundle: [Bundle.main])
let template = environment.loadTemplate(name: "index.html")
``extends``
~~~~~~~~~~~

View File

@@ -9,7 +9,9 @@ namespace which contains all filters and tags available to the template.
let namespace = Namespace()
// Register your filters and tags with the namespace
let rendered = try template.render(context, namespace: namespace)
let environment = Environment(namespace: namespace)
try environment.renderTemplate(name: "example.html")
Custom Filters
--------------

View File

@@ -17,17 +17,19 @@ feel right at home with Stencil.
.. code-block:: swift
struct Article {
let title: String
let author: String
}
import Stencil
let context = Context(dictionary: [
struct Article {
let title: String
let author: String
}
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
do {
let template = try Template(named: "template.html")