Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7dbba41a5 | ||
|
|
69af469d0d | ||
|
|
42e415a9bf | ||
|
|
2760843236 | ||
|
|
535a8061d9 | ||
|
|
88bec575a5 | ||
|
|
6f9bb3e931 | ||
|
|
cb4e514846 | ||
|
|
fff93f18dd | ||
|
|
652dcd246d | ||
|
|
e77bd22e83 | ||
|
|
4f84627caa | ||
|
|
07a6b2aea5 | ||
|
|
fce3dc5e48 | ||
|
|
f7bda226e8 | ||
|
|
d238c25eef | ||
|
|
df2e193891 | ||
|
|
2c3962a3de | ||
|
|
7ed95aec91 | ||
|
|
064b2f706c | ||
|
|
fce4e85a63 | ||
|
|
275e583e4a | ||
|
|
9c408d488e | ||
|
|
f9f6d95f25 | ||
|
|
0d4dee29b2 | ||
|
|
1704cd2ddf | ||
|
|
831cdf5f36 | ||
|
|
8210fa57f1 | ||
|
|
0074ee1d4a | ||
|
|
d71fe2a2ee | ||
|
|
93ccc56540 | ||
|
|
247a35fd2c | ||
|
|
8e9692c696 | ||
|
|
8bda4d5bbb | ||
|
|
e6b12c09d3 | ||
|
|
420c0eacd7 | ||
|
|
adb443229d | ||
|
|
1098921dc8 | ||
|
|
9de8190988 | ||
|
|
acda1b0caf | ||
|
|
00e71c1b4d | ||
|
|
1b85b816fd | ||
|
|
b476e50f89 | ||
|
|
2ed5763fe4 | ||
|
|
fff3d21e37 | ||
|
|
99be5f0459 | ||
|
|
2eeb7babd3 | ||
|
|
fc404b25d8 | ||
|
|
42972a1c10 | ||
|
|
6a4959cea0 | ||
|
|
ffe8f9dab0 | ||
|
|
96a004eb34 | ||
|
|
92ebfe59b1 | ||
|
|
71ad162268 | ||
|
|
b9702afbd4 | ||
|
|
4f1a5b3e3d | ||
|
|
3a4cd8aa27 | ||
|
|
4f14b4b044 | ||
|
|
b66abc3112 | ||
|
|
5bbd994581 | ||
|
|
3995ff9acf | ||
|
|
2e18892f4c | ||
|
|
39ed9aa753 | ||
|
|
d935f65d56 | ||
|
|
2627d3e0d1 | ||
|
|
1e77f1e85f | ||
|
|
47f2b33d80 | ||
|
|
1427e10698 | ||
|
|
e070ae7851 | ||
|
|
fc6c0208b2 | ||
|
|
34dbafa789 | ||
|
|
eb8c875853 | ||
|
|
098af2a7b6 | ||
|
|
7679b48164 | ||
|
|
7c499cc077 | ||
|
|
88e54ab4ba | ||
|
|
c2e25f25ac | ||
|
|
fe01beb4bb | ||
|
|
2e6a7215c5 | ||
|
|
f457cddd3f | ||
|
|
6b02fccf84 | ||
|
|
29e859f1aa | ||
|
|
8fa0bd275c | ||
|
|
91847208a3 | ||
|
|
86ed8770e1 | ||
|
|
0bc6bd974e | ||
|
|
fa68ba9df8 | ||
|
|
4827fb8e20 | ||
|
|
359d086c02 | ||
|
|
24c9746689 | ||
|
|
c4a84a6375 | ||
|
|
c30597457f | ||
|
|
b54292788f | ||
|
|
d6766b43da | ||
|
|
662849e968 | ||
|
|
4bfdb73175 | ||
|
|
a165a6715f | ||
|
|
ac2fd56e8e | ||
|
|
cb124319ec | ||
|
|
abeb30bb1c | ||
|
|
ed885f462a | ||
|
|
7756522317 | ||
|
|
8d68edd725 | ||
|
|
218822fcb0 | ||
|
|
ea7e1efac7 | ||
|
|
bb3f33724b | ||
|
|
c486617854 | ||
|
|
9a28142fa6 | ||
|
|
53c1550c5b | ||
|
|
27135f3ea3 | ||
|
|
5878c323a2 | ||
|
|
97ab3cf31d | ||
|
|
7688326204 | ||
|
|
079fdf39b8 | ||
|
|
e59609f140 | ||
|
|
d5f0be959f | ||
|
|
0edb38588d | ||
|
|
69cd8e4d3b | ||
|
|
6300dbc7bf | ||
|
|
b4dc8dbb76 | ||
|
|
2e80f70f67 | ||
|
|
a6dba67828 | ||
|
|
691fe523b3 | ||
|
|
c0e66eb96f | ||
|
|
0156f6f37b | ||
|
|
79a16854e7 | ||
|
|
a4b75f3c89 | ||
|
|
0f3a302108 | ||
|
|
1223efbc7e | ||
|
|
9357df35d1 | ||
|
|
a96fcff680 | ||
|
|
0017aee5a8 | ||
|
|
1e6846867e | ||
|
|
93c07e22b1 | ||
|
|
98461c75b0 | ||
|
|
9994972a24 | ||
|
|
cf7acea440 | ||
|
|
9e24ab658b | ||
|
|
a52ee21b72 | ||
|
|
4a93815d4c | ||
|
|
7e88cbde11 | ||
|
|
e7a0738bda | ||
|
|
46f179e3ed | ||
|
|
bf4be38377 | ||
|
|
14bac03990 | ||
|
|
3180b26673 | ||
|
|
000e9a7f1a | ||
|
|
7b9817ed50 | ||
|
|
482d595d01 | ||
|
|
f1fc747897 | ||
|
|
0444f45d2b | ||
|
|
86bfbf215f | ||
|
|
039bf4b7cb | ||
|
|
4308baf5f0 | ||
|
|
2455fb9ed0 | ||
|
|
64571464d9 | ||
|
|
5821e4849e | ||
|
|
793773f191 | ||
|
|
e217a9c873 | ||
|
|
584ed916ab | ||
|
|
65a461d0a1 | ||
|
|
89256b96f4 | ||
|
|
a8d680b30e | ||
|
|
6f48fe2d91 | ||
|
|
4f9063c147 | ||
|
|
5541eae818 | ||
|
|
d1717df6ff | ||
|
|
dc8759ba34 | ||
|
|
233dcfc59a | ||
|
|
19e4f6e506 | ||
|
|
e2f33d4337 | ||
|
|
20b9476e4b | ||
|
|
cf9714ffd0 | ||
|
|
5e78d6cc46 | ||
|
|
99efba56e9 | ||
|
|
39517b7514 | ||
|
|
fdde1dec02 | ||
|
|
8f6b403aa9 | ||
|
|
2331b11a52 | ||
|
|
26f30cbd9d | ||
|
|
d7b152089e | ||
|
|
d3706f074d | ||
|
|
aa7c36296b | ||
|
|
2d507e7c11 | ||
|
|
15facd97fb | ||
|
|
d75db241ac | ||
|
|
49936c36d4 | ||
|
|
2e04a71d59 | ||
|
|
6d05832997 | ||
|
|
6871387671 | ||
|
|
46bc1242f3 | ||
|
|
24359489ce | ||
|
|
f90597fba1 | ||
|
|
9e2a061795 | ||
|
|
2be672c6a5 | ||
|
|
2ebb79df8b | ||
|
|
63c2b935f7 | ||
|
|
abae80d39d | ||
|
|
d024da5567 | ||
|
|
98edad3566 | ||
|
|
872784f9b3 | ||
|
|
1a01ec592e | ||
|
|
f0591408be | ||
|
|
b7e200a8a0 | ||
|
|
b1da85b140 | ||
|
|
679344f53b | ||
|
|
ada4e81082 | ||
|
|
c99a40c5d9 | ||
|
|
c59b263446 | ||
|
|
ab6f1a032d | ||
|
|
e989317929 | ||
|
|
111306fb60 | ||
|
|
3eb2657a62 | ||
|
|
6ad609e562 | ||
|
|
38d7ec87f6 | ||
|
|
9af9cf4005 | ||
|
|
1975cfd627 | ||
|
|
429290e0b7 | ||
|
|
5ca1b78854 | ||
|
|
a2673bd66b | ||
|
|
9b6ee14aa3 | ||
|
|
3b5e8f2468 | ||
|
|
e84f8a41d4 | ||
|
|
2324808dca | ||
|
|
9fdbbc99e9 | ||
|
|
dfd57e9571 | ||
|
|
3293d8a526 | ||
|
|
393dc88a10 | ||
|
|
a014fecd23 | ||
|
|
a13401b046 | ||
|
|
60b378d482 | ||
|
|
1e3afc0dd5 | ||
|
|
72f3cb579a | ||
|
|
68e6ce3022 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.conche/
|
||||
.build/
|
||||
Packages/
|
||||
Package.pins
|
||||
*.xcodeproj
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.0-GM-CANDIDATE
|
||||
18
.travis.yml
18
.travis.yml
@@ -1,11 +1,19 @@
|
||||
os:
|
||||
- osx
|
||||
- linux
|
||||
matrix:
|
||||
include:
|
||||
- os: osx
|
||||
osx_image: xcode9.4
|
||||
env: SWIFT_VERSION=4.1
|
||||
- os: osx
|
||||
osx_image: xcode10
|
||||
env: SWIFT_VERSION=4.2
|
||||
- os: linux
|
||||
env: SWIFT_VERSION=4.1
|
||||
- os: linux
|
||||
env: SWIFT_VERSION=4.2
|
||||
language: generic
|
||||
sudo: required
|
||||
dist: trusty
|
||||
osx_image: xcode8
|
||||
install:
|
||||
- eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/9f442512a46d7a2af7b850d65a7e9bd31edfb09b/swiftenv-install.sh)"
|
||||
- eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
|
||||
script:
|
||||
- swift test
|
||||
|
||||
175
ARCHITECTURE.md
175
ARCHITECTURE.md
@@ -1,175 +0,0 @@
|
||||
Stencil Architecture
|
||||
====================
|
||||
|
||||
This document outlines the architecture of Stencil and how it works internally.
|
||||
|
||||
Stencil uses a three-step process for rendering templates. The first step is tokenising the template into an array of Token’s. Afterwards, the array of token’s are transformed into a collection of Node’s. Once we have a collection of Node’s (objects conforming to the `Node` protocol), we then call `render(context)` on each Node instructing it to render itself inside the given context.
|
||||
|
||||
## Token
|
||||
|
||||
Token is an enum which has four members. These represent a piece of text, a variable, a comment or a template block. They are parsed using the `TokenParser` which takes the template as a string as input and returns an array of Token’s.
|
||||
|
||||
### Values
|
||||
|
||||
#### Text
|
||||
|
||||
A text token represents a string which will be rendered in the template. For example, a text token with the string `Hello World` will be rendered as such in the output.
|
||||
|
||||
#### Variable
|
||||
|
||||
A variable token represents a variable inside a context. It will be evaluated and rendered in the output. It is created from the template using `{{ string }}`.
|
||||
|
||||
#### Comment
|
||||
|
||||
The comment token represents a comment inside the source. It is created using `{# This is a comment #}`.
|
||||
|
||||
#### Block
|
||||
|
||||
A block represents a template tag. It is created using `{% this is a template block %}` inside a template. The template tag in this case would be called `this`. See “Block Token” below for more information.
|
||||
|
||||
### Parsing
|
||||
|
||||
A template is parsed using the TokenParser into an array of Token’s. For example:
|
||||
|
||||
```html+django
|
||||
Hello {{ name }}
|
||||
```
|
||||
|
||||
Would be parsed into two tokens. A token representing the string, `Hello ` and a token representing the variable called `name`. So, in Swift it would be represented as follows:
|
||||
|
||||
```swift
|
||||
let tokens = [
|
||||
Token.Text("Hello "),
|
||||
Token.Variable("name"),
|
||||
]
|
||||
```
|
||||
|
||||
## Node
|
||||
|
||||
Node is a protocol with a single method, to render it inside a context. When rendering a node, it is converted into the output string, or an error if there is a failure. Token’s are converted to Node’s using the `TokenParser` class.
|
||||
|
||||
For some Token’s, there is a direct mapping from a Token to a Node. However block node’s do not have a 1:1 mapping.
|
||||
|
||||
### Token Parsing
|
||||
|
||||
#### Text Token
|
||||
|
||||
A text token is converted directly to a `TextNode` which simply returns the text when rendered.
|
||||
|
||||
#### Variable Token
|
||||
|
||||
Variable Token’s are transformed directly to a `VariableNode`, which will evaluate a variable in the given template when rendered.
|
||||
|
||||
#### Comment Token
|
||||
|
||||
A comment token is simply omitted, a comment token will be dropped when it is converted to a Node.
|
||||
|
||||
#### Block Token
|
||||
|
||||
Block token’s are slightly different from the other tokens, there is no direct mapping. A block token is made up of a string representing the token. For example `now` or `for article in articles`. The `TokenParser` will pull out the first word inside the string representation and use that to look-up a parser for the block. So, in this example, the template tag names will be `now` or `for`.
|
||||
|
||||
The template tag’s are registered with a block of code which deals with the parsing for the given tag. This allows the parser to parse a set of tokens ahead of the block tone. This is useful for control flow, such as the `for` template tag will want to parse any following tokens up until the `endblock` block token.
|
||||
|
||||
For example:
|
||||
|
||||
```html+django
|
||||
{% for article in articles %}
|
||||
An Article
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Or as a set of tokens:
|
||||
|
||||
```swift
|
||||
let tokens = [
|
||||
Token.Block("for article in articles"),
|
||||
Token.Text(" An Article")
|
||||
Token.Block("endfor")
|
||||
]
|
||||
```
|
||||
|
||||
Will result in a single Node (a `ForNode`) which contains the sub-node containing the text. The `ForNode` class has a property called `forNodes` which contains the text node representing the text token (` An Article`).
|
||||
|
||||
When the `ForNode` is rendered in a context, it will look up the variable `articles` and if it’s an array it will loop over it. Inserting the variable `article` into the context while rendered the `forNodes` for each article.
|
||||
|
||||
### Custom Nodes
|
||||
|
||||
There are two ways to register custom template tags. A simple way which allows you to map 1:1 a block token to a Node. You can also register a more advanced template tag which has it’s own block of code for handling parsing if you want to parse up until another token such as if you are trying to provide flow-control.
|
||||
|
||||
The tags are registered with a `Namespace` passed when rendering your `Template`.
|
||||
|
||||
#### Simple Tags
|
||||
|
||||
A simple tag is registered with a string for the tag name and a block of code which is evaluated when the block is rendered in a given context.
|
||||
|
||||
Here’s an example. Registering a template tag called `custom` which just renders `Hello World` in the rendered template:
|
||||
|
||||
```swift
|
||||
namespace.registerSimpleTag("custom") { context in
|
||||
return "Hello World"
|
||||
}
|
||||
```
|
||||
|
||||
You would use it as such in a template:
|
||||
|
||||
```html+django
|
||||
{% custom %}
|
||||
```
|
||||
|
||||
#### Advanced Tags
|
||||
|
||||
If you need more control or functionality than the simple tag’s above, you can use the node based API where you can provide a block of code to deal with parsing. There are a few examples of this in use over at `Node.swift` inside Stencil. There is an implementation of `if` and `for` template tags.
|
||||
|
||||
You would register a template tag using the `registerTag` API inside a `Namespace` which accepts a name for the tag and a block of code to handle parsing. The block of code is invoked with the parser and the current token as an argument. This allows you to use the API on `TokenParser` to parse node’s further in the token array.
|
||||
|
||||
As an example, we’re going to create a template tag called `debug` which will optionally render nodes from `debug` up until `enddebug`. When rendering the `DebugNode`, it will only render the nodes inside if a variable called `debug` is set to `true` inside the template Context.
|
||||
|
||||
```html+django
|
||||
{% debug %}
|
||||
Debugging is enabled!
|
||||
{% enddebug %}
|
||||
```
|
||||
|
||||
This will be represented by a `DebugNode` which will have a property containing all of the Node’s inside the `debug`/`enddebug` block. In the above example, this will just be a TextNode containing ` Debugging is enabled!`.
|
||||
|
||||
When the `DebugNode` is rendered, it will determine if debug is enabled by introspecting the context and if it is enabled. We will render the nodes, otherwise just return an empty string to hide the debug output.
|
||||
|
||||
So, our `DebugNode` would look like as following:
|
||||
|
||||
```swift
|
||||
class DebugNode : Node {
|
||||
let nodes:[Node]
|
||||
|
||||
init(nodes:[Node]) {
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
func render(context: Context) throws -> String {
|
||||
// Is there a debug variable inside the context?
|
||||
if let debug = context["debug"] as? Bool {
|
||||
// Is debug set to true?
|
||||
if debug {
|
||||
// Let's render the given nodes in the context since debug is enabled.
|
||||
return renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug is turned off, so let's not render anything
|
||||
return ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We will need to write a parser to parse up until the `enddebug` template block and create a `DebugNode` with the nodes in-between. If there was another error form another Node inside, then we will return that error.
|
||||
|
||||
```swift
|
||||
namespace.registerTag("debug") { parser, token in
|
||||
// Use the parser to parse every token up until the `enddebug` block.
|
||||
let nodes = try until(["enddebug"]))
|
||||
return DebugNode(nodes)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
A Context is a structure containing any templates you would like to use in a template. It’s 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.
|
||||
356
CHANGELOG.md
356
CHANGELOG.md
@@ -1,4 +1,360 @@
|
||||
# Stencil Changelog
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Breaking
|
||||
|
||||
- Now requires Swift 4.1 or newer.
|
||||
[Yonas Kolb](https://github.com/yonaskolb)
|
||||
[#228](https://github.com/stencilproject/Stencil/pull/228)
|
||||
- You can now use parentheses in boolean expressions to change operator precedence.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#165](https://github.com/stencilproject/Stencil/pull/165)
|
||||
- Added method to add boolean filters with their negative counterparts.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#160](https://github.com/stencilproject/Stencil/pull/160)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Now you can conditionally render variables with `{{ variable if condition }}`, which is a shorthand for `{% if condition %}{{ variable }}{% endif %}`. You can also use `else` like `{{ variable1 if condition else variable2 }}`, which is a shorthand for `{% if condition %}{{ variable1 }}{% else %}{{ variable2 }}{% endif %}`
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#243](https://github.com/stencilproject/Stencil/pull/243)
|
||||
- Now you can access string characters by index or get string length the same was as if it was an array, i.e. `{{ 'string'.first }}`, `{{ 'string'.last }}`, `{{ 'string'.1 }}`, `{{ 'string'.count }}`.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#245](https://github.com/stencilproject/Stencil/pull/245)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the performance issues introduced in Stencil 0.12 with the error log improvements.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#230](https://github.com/stencilproject/Stencil/pull/230)
|
||||
- Now accessing undefined keys in NSObject does not cause runtime crash and instead renders empty string.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#234](https://github.com/stencilproject/Stencil/pull/234)
|
||||
- `for` tag: When iterating over a dictionary the keys will now always be sorted (in an ascending order) to ensure consistent output generation.
|
||||
[David Jennes](https://github.com/djbe)
|
||||
[#240](https://github.com/stencilproject/Stencil/pull/240)
|
||||
|
||||
### Internal Changes
|
||||
|
||||
- Updated the codebase to use Swift 4 features.
|
||||
[David Jennes](https://github.com/djbe)
|
||||
[#239](https://github.com/stencilproject/Stencil/pull/239)
|
||||
- Update to Spectre 0.9.0.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#247](https://github.com/stencilproject/Stencil/pull/247)
|
||||
- Optimise Scanner performance.
|
||||
[Eric Thorpe](https://github.com/trametheka)
|
||||
[Sébastien Duperron](https://github.com/Liquidsoul)
|
||||
[David Jennes](https://github.com/djbe)
|
||||
[#226](https://github.com/stencilproject/Stencil/pull/226)
|
||||
|
||||
|
||||
## 0.12.1
|
||||
|
||||
### Internal Changes
|
||||
|
||||
- Updated the PathKit dependency to 0.9.0 in CocoaPods, to be in line with SPM.
|
||||
[David Jennes](https://github.com/djbe)
|
||||
[#227](https://github.com/stencilproject/Stencil/pull/227)
|
||||
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
|
||||
[Yonas Kolb](https://github.com/yonaskolb)
|
||||
[#214](https://github.com/stencilproject/Stencil/pull/214)
|
||||
- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an
|
||||
object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John".
|
||||
[David Jennes](https://github.com/djbe)
|
||||
[#215](https://github.com/stencilproject/Stencil/pull/215)
|
||||
- Adds support for using spaces in filter expression.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#178](https://github.com/stencilproject/Stencil/pull/178)
|
||||
- Improvements in error reporting.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#167](https://github.com/stencilproject/Stencil/pull/167)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed using quote as a filter parameter.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#210](https://github.com/stencilproject/Stencil/pull/210)
|
||||
|
||||
|
||||
## 0.11.0 (2018-04-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Added support for resolving superclass properties for not-NSObject subclasses.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#152](https://github.com/stencilproject/Stencil/pull/152)
|
||||
- The `{% for %}` tag can now iterate over tuples, structures and classes via
|
||||
their stored properties.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#172](https://github.com/stencilproject/Stencil/pull/173)
|
||||
- Added `split` filter.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#187](https://github.com/stencilproject/Stencil/pull/187)
|
||||
- Allow default string filters to be applied to arrays.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#190](https://github.com/stencilproject/Stencil/pull/190)
|
||||
- Similar filters are suggested when unknown filter is used.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#186](https://github.com/stencilproject/Stencil/pull/186)
|
||||
- Added `indent` filter.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#188](https://github.com/stencilproject/Stencil/pull/188)
|
||||
- Allow using new lines inside tags.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#202](https://github.com/stencilproject/Stencil/pull/202)
|
||||
- Added support for iterating arrays of tuples.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#177](https://github.com/stencilproject/Stencil/pull/177)
|
||||
- Added support for ranges in if-in expression.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#193](https://github.com/stencilproject/Stencil/pull/193)
|
||||
- Added property `forloop.length` to get number of items in the loop.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#171](https://github.com/stencilproject/Stencil/pull/171)
|
||||
- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#192](https://github.com/stencilproject/Stencil/pull/192)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed rendering `{{ block.super }}` with several levels of inheritance.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#154](https://github.com/stencilproject/Stencil/pull/154)
|
||||
- Fixed checking dictionary values for nil in `default` filter.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#162](https://github.com/stencilproject/Stencil/pull/162)
|
||||
- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#168](https://github.com/stencilproject/Stencil/pull/168)
|
||||
- Integer literals now resolve into Int values, not Float.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#181](https://github.com/stencilproject/Stencil/pull/181)
|
||||
- Fixed accessing properties of optional properties via reflection.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#204](https://github.com/stencilproject/Stencil/pull/204)
|
||||
- No longer render optional values in arrays as `Optional(..)`.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#205](https://github.com/stencilproject/Stencil/pull/205)
|
||||
- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`.
|
||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||
[#172](https://github.com/stencilproject/Stencil/pull/172)
|
||||
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Add support for Xcode 9.1.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Adds `counter0` to for loop context allowing you to get the current index of
|
||||
the for loop 0 indexed.
|
||||
- Introduces a new `DictionaryLoader` for loading templates from a Swift
|
||||
Dictionary.
|
||||
- Added `in` expression in if tag for strings and arrays of hashable types
|
||||
- You can now access the amount of items in a dictionary using the `count`
|
||||
property.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes a potential crash when using the `{% for %}` template tag with the
|
||||
incorrect amount of arguments.
|
||||
- Fixes a potential crash when using incomplete tokens in a template for
|
||||
example, `{%%}` or `{{}}`.
|
||||
- Fixes evaluating nil properties as true
|
||||
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Enhancements
|
||||
|
||||
- `for` block now can contain `where` expression to filter array items. For example `{% for item in items where item > 1 %}` is now supported.
|
||||
- `if` blocks may now contain else if (`elif`) conditions.
|
||||
|
||||
```html+django
|
||||
{% if one or two and not three %}
|
||||
one or two but not three
|
||||
{% elif four %}
|
||||
four
|
||||
{% else %}
|
||||
not one, two, or four
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
- `for` block now allows you to iterate over array of tuples or dictionaries.
|
||||
|
||||
```html+django
|
||||
{% for key,value in thing %}
|
||||
<li>{{ key }}: {{ value }}</li>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- You can now use literal filter arguments which contain quotes.
|
||||
[#98](https://github.com/kylef/Stencil/pull/98)
|
||||
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### 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.
|
||||
|
||||
- `Namespace` has been removed and replaced by extensions. You can create an
|
||||
extension including any custom template tags and filters. A collection of
|
||||
extensions can be passed to an `Environment`.
|
||||
|
||||
### 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.
|
||||
|
||||
- New `{% filter %}` tag allowing you to perform a filter across the contents
|
||||
of a block.
|
||||
|
||||
```html+django
|
||||
{% filter lowercase %}
|
||||
This Text Will Be Lowercased.
|
||||
{% endfilter %}
|
||||
```
|
||||
|
||||
- You can now use `{{ block.super }}` to render a super block from another `{%
|
||||
block %}`.
|
||||
|
||||
- `Environment` allows you to provide a custom `Template` subclass, allowing
|
||||
new template to use a specific subclass.
|
||||
|
||||
- If expressions may now contain filters on variables. For example
|
||||
`{% if name|uppercase == "TEST" %}` is now supported.
|
||||
|
||||
### Deprecations
|
||||
|
||||
- `Template` initialisers have been deprecated in favour of using a template
|
||||
loader such as `FileSystemLoader` inside an `Environment`.
|
||||
|
||||
- The use of whitespace inside variable filter expression is now deprecated.
|
||||
|
||||
```diff
|
||||
- {{ name | uppercase }}
|
||||
+ {{ name|uppercase }}
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Restores compatibility with ARM based platforms such as iOS. Stencil 0.7
|
||||
introduced compilation errors due to using the `Float80` type which is not
|
||||
available.
|
||||
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes an issue where using `{% if %}` statements which use operators would
|
||||
throw a syntax error.
|
||||
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Breaking
|
||||
|
||||
- `TemplateLoader` has been renamed to `FileSystemLoader`. The
|
||||
`loadTemplate(s)` methods are now throwing and now take labels for the `name`
|
||||
and `names` arguments.
|
||||
|
||||
- Many internal classes are no longer public. Some APIs were previously
|
||||
accessible due to earlier versions of Swift requiring the types to be public
|
||||
to be able to test. Now we have access to `@testable` these can correctly be
|
||||
private.
|
||||
|
||||
- `{% ifnot %}` tag is now deprecated, please use `{% if not %}` instead.
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Variable lookup now supports introspection of Swift types. You can now lookup
|
||||
values of Swift structures and classes inside a Context.
|
||||
|
||||
- If tags can now use prefix and infix operators such as `not`, `and`, `or`,
|
||||
`==`, `!=`, `>`, `>=`, `<` and `<=`.
|
||||
|
||||
```html+django
|
||||
{% if one or two and not three %}
|
||||
```
|
||||
|
||||
- You may now register custom template filters which make use of arguments.
|
||||
- There is now a `default` filter.
|
||||
|
||||
```html+django
|
||||
Hello {{ name|default:"World" }}
|
||||
```
|
||||
|
||||
- There is now a `join` filter.
|
||||
|
||||
```html+django
|
||||
{{ value|join:", " }}
|
||||
```
|
||||
|
||||
- `{% for %}` tag now supports filters.
|
||||
|
||||
```html+django
|
||||
{% for user in non_admins|default:admins %}
|
||||
{{ user }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
|
||||
index will now resolve to `nil` instead of causing a crash.
|
||||
[#72](https://github.com/kylef/Stencil/issues/72)
|
||||
|
||||
- Templates can now extend templates that extend other templates.
|
||||
[#60](https://github.com/kylef/Stencil/issues/60)
|
||||
|
||||
- If comparisons will now treat 0 and below numbers as negative.
|
||||
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Enhancements
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2014, Kyle Fuller
|
||||
Copyright (c) 2018, Kyle Fuller
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -21,4 +21,3 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
25
Package.resolved
Normal file
25
Package.resolved
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "PathKit",
|
||||
"repositoryURL": "https://github.com/kylef/PathKit.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "e2f5be30e4c8f531c9c1e8765aa7b71c0a45d7a0",
|
||||
"version": "0.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Spectre",
|
||||
"repositoryURL": "https://github.com/kylef/Spectre.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "f14ff47f45642aa5703900980b014c2e9394b6e5",
|
||||
"version": "0.9.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
// swift-tools-version:4.1
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Stencil",
|
||||
products: [
|
||||
.library(name: "Stencil", targets: ["Stencil"])
|
||||
],
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 7),
|
||||
|
||||
// https://github.com/apple/swift-package-manager/pull/597
|
||||
.Package(url: "https://github.com/kylef/Spectre", majorVersion: 0, minor: 7),
|
||||
.package(url: "https://github.com/kylef/PathKit.git", from: "0.9.0"),
|
||||
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Stencil", dependencies: [
|
||||
"PathKit"
|
||||
], path: "Sources"),
|
||||
.testTarget(name: "StencilTests", dependencies: [
|
||||
"Stencil",
|
||||
"Spectre"
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
23
Package@swift-4.2.swift
Normal file
23
Package@swift-4.2.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
// swift-tools-version:4.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Stencil",
|
||||
products: [
|
||||
.library(name: "Stencil", targets: ["Stencil"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/kylef/PathKit.git", from: "0.9.0"),
|
||||
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Stencil", dependencies: [
|
||||
"PathKit"
|
||||
], path: "Sources"),
|
||||
.testTarget(name: "StencilTests", dependencies: [
|
||||
"Stencil",
|
||||
"Spectre"
|
||||
])
|
||||
],
|
||||
swiftLanguageVersions: [.v4, .v4_2]
|
||||
)
|
||||
233
README.md
233
README.md
@@ -1,6 +1,6 @@
|
||||
# Stencil
|
||||
|
||||
[](https://travis-ci.org/kylef/Stencil)
|
||||
[](https://travis-ci.org/stencilproject/Stencil)
|
||||
|
||||
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
|
||||
@@ -11,34 +11,32 @@ feel right at home with Stencil.
|
||||
```html+django
|
||||
There are {{ articles.count }} articles.
|
||||
|
||||
{% for article in articles %}
|
||||
- {{ article.title }} by {{ article.author }}.
|
||||
{% endfor %}
|
||||
<ul>
|
||||
{% for article in articles %}
|
||||
<li>{{ article.title }} by {{ article.author }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
```
|
||||
|
||||
```swift
|
||||
let context = Context(dictionary: [
|
||||
"articles": [
|
||||
[ "title": "Migrating from OCUnit to XCTest", "author": "Kyle Fuller" ],
|
||||
[ "title": "Memory Management with ARC", "author": "Kyle Fuller" ],
|
||||
]
|
||||
])
|
||||
import Stencil
|
||||
|
||||
do {
|
||||
let template = try Template(named: "template.stencil")
|
||||
let rendered = try template.render(context)
|
||||
print(rendered)
|
||||
} catch {
|
||||
print("Failed to render template \(error)")
|
||||
struct Article {
|
||||
let title: String
|
||||
let author: String
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
let context = [
|
||||
"articles": [
|
||||
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
|
||||
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
|
||||
]
|
||||
]
|
||||
|
||||
Installation with CocoaPods is recommended.
|
||||
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"]))
|
||||
let rendered = try environment.renderTemplate(name: "article_list.html", context: context)
|
||||
|
||||
```ruby
|
||||
pod 'Stencil'
|
||||
print(rendered)
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
@@ -51,191 +49,26 @@ Stencil follows the same philosophy of Django:
|
||||
> design: the template system is meant to express presentation, not program
|
||||
> logic.
|
||||
|
||||
## Templates
|
||||
## The User Guide
|
||||
|
||||
### Variables
|
||||
Resources for Stencil template authors to write Stencil templates:
|
||||
|
||||
A variable can be defined in your template using the following:
|
||||
- [Language overview](http://stencil.fuller.li/en/latest/templates.html)
|
||||
- [Built-in template tags and filters](http://stencil.fuller.li/en/latest/builtins.html)
|
||||
|
||||
```html+django
|
||||
{{ variable }}
|
||||
```
|
||||
Resources to help you integrate Stencil into a Swift project:
|
||||
|
||||
Stencil will look up the variable inside the current variable context and
|
||||
evaluate it. When a variable contains a dot, it will try doing the
|
||||
following lookup:
|
||||
- [Installation](http://stencil.fuller.li/en/latest/installation.html)
|
||||
- [Getting Started](http://stencil.fuller.li/en/latest/getting-started.html)
|
||||
- [API Reference](http://stencil.fuller.li/en/latest/api.html)
|
||||
- [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html)
|
||||
|
||||
- Context lookup
|
||||
- Dictionary lookup
|
||||
- Array lookup (first, last, count, index)
|
||||
- Key value coding lookup
|
||||
## Projects that use Stencil
|
||||
|
||||
For example, if `people` was an array:
|
||||
|
||||
```html+django
|
||||
There are {{ people.count }} people. {{ people.first }} is the first person,
|
||||
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 }}
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
Tags are a mechanism to execute a piece of code, allowing you to have
|
||||
control flow within your template.
|
||||
|
||||
```html+django
|
||||
{% if variable %}
|
||||
{{ variable }} was found.
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
A tag can also affect the context and define variables as follows:
|
||||
|
||||
```html+django
|
||||
{% for item in items %}
|
||||
{{ item }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Stencil has a couple of built-in tags which are listed below. You can also
|
||||
extend Stencil by providing your own tags.
|
||||
|
||||
#### for
|
||||
|
||||
A for loop allows you to iterate over an array found by variable lookup.
|
||||
|
||||
```html+django
|
||||
{% for item in items %}
|
||||
{{ item }}
|
||||
{% empty %}
|
||||
There were no items.
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
#### if
|
||||
|
||||
```html+django
|
||||
{% if variable %}
|
||||
The variable was found in the current context.
|
||||
{% else %}
|
||||
The variable was not found.
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
#### ifnot
|
||||
|
||||
```html+django
|
||||
{% ifnot variable %}
|
||||
The variable was NOT found in the current context.
|
||||
{% else %}
|
||||
The variable was found.
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
#### include
|
||||
|
||||
You can include another template using the `include` tag.
|
||||
|
||||
```html+django
|
||||
{% include "comment.html" %}
|
||||
```
|
||||
|
||||
The `include` tag requires a TemplateLoader to be found inside your context with the paths, or bundles used to lookup the template.
|
||||
|
||||
```swift
|
||||
let context = Context(dictionary: [
|
||||
"loader": TemplateLoader(bundle:[NSBundle.mainBundle()])
|
||||
])
|
||||
```
|
||||
|
||||
### Customisation
|
||||
|
||||
You can build your own custom filters and tags and pass them down while
|
||||
rendering your template. Any custom filters or tags must be registered
|
||||
with a namespace which contains all filters and tags available to the template.
|
||||
|
||||
```swift
|
||||
let namespace = Namespace()
|
||||
// Register your filters and tags with the namespace
|
||||
let rendered = try template.render(context, namespace: namespace)
|
||||
```
|
||||
|
||||
#### Registering custom filters
|
||||
|
||||
```swift
|
||||
namespace.registerFilter("double") { value in
|
||||
if let value = value as? Int {
|
||||
return value * 2
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
#### Building custom tags
|
||||
|
||||
You can build a custom template tag. There are a couple of APIs to allow
|
||||
you to write your own custom tags. The following is the simplest form:
|
||||
|
||||
```swift
|
||||
namespace.registerSimpleTag("custom") { context in
|
||||
return "Hello World"
|
||||
}
|
||||
```
|
||||
|
||||
When your tag is used via `{% custom %}` it will execute the registered block
|
||||
of code allowing you to modify or retrieve a value from the context. Then
|
||||
return either a string rendered in your template, or throw an error.
|
||||
|
||||
If you want to accept arguments or to capture different tokens between two sets
|
||||
of template tags. You will need to call the `registerTag` API which accepts a
|
||||
closure to handle the parsing. You can find examples of the `now`, `if` and
|
||||
`for` tags found inside `Node.swift`.
|
||||
|
||||
The architecture of Stencil along with how to build advanced plugins can be
|
||||
found in the [architecture](ARCHITECTURE.md) document.
|
||||
|
||||
### Comments
|
||||
|
||||
To comment out part of your template, you can use the following syntax:
|
||||
|
||||
```html+django
|
||||
{# My comment is completely hidden #}
|
||||
```
|
||||
[Sourcery](https://github.com/krzysztofzablocki/Sourcery),
|
||||
[SwiftGen](https://github.com/SwiftGen/SwiftGen),
|
||||
[Kitura](https://github.com/IBM-Swift/Kitura),
|
||||
[Weaver](https://github.com/scribd/Weaver)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/// A container for template variables.
|
||||
public class Context {
|
||||
var dictionaries: [[String: Any]]
|
||||
let namespace: Namespace
|
||||
var dictionaries: [[String: Any?]]
|
||||
|
||||
/// Initialise a Context with an optional dictionary and optional namespace
|
||||
public init(dictionary: [String: Any]? = nil, namespace: Namespace = Namespace()) {
|
||||
public let environment: Environment
|
||||
|
||||
init(dictionary: [String: Any]? = nil, environment: Environment? = nil) {
|
||||
if let dictionary = dictionary {
|
||||
dictionaries = [dictionary]
|
||||
} else {
|
||||
dictionaries = []
|
||||
}
|
||||
|
||||
self.namespace = namespace
|
||||
self.environment = environment ?? Environment()
|
||||
}
|
||||
|
||||
open subscript(key: String) -> Any? {
|
||||
public subscript(key: String) -> Any? {
|
||||
/// Retrieves a variable's value, starting at the current context and going upwards
|
||||
get {
|
||||
for dictionary in Array(dictionaries.reversed()) {
|
||||
@@ -42,14 +42,28 @@ public class Context {
|
||||
}
|
||||
|
||||
/// Pop the last level off of the Context
|
||||
fileprivate func pop() -> [String: Any]? {
|
||||
fileprivate func pop() -> [String: Any?]? {
|
||||
return dictionaries.popLast()
|
||||
}
|
||||
|
||||
/// Push a new level onto the context for the duration of the execution of the given closure
|
||||
open func push<Result>(dictionary: [String: Any]? = nil, closure: (() throws -> Result)) rethrows -> Result {
|
||||
public func push<Result>(dictionary: [String: Any]? = nil, closure: (() throws -> Result)) rethrows -> Result {
|
||||
push(dictionary)
|
||||
defer { _ = pop() }
|
||||
return try closure()
|
||||
}
|
||||
|
||||
public func flatten() -> [String: Any] {
|
||||
var accumulator: [String: Any] = [:]
|
||||
|
||||
for dictionary in dictionaries {
|
||||
for (key, value) in dictionary {
|
||||
if let value = value {
|
||||
accumulator.updateValue(value, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}
|
||||
}
|
||||
|
||||
48
Sources/Environment.swift
Normal file
48
Sources/Environment.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
public struct Environment {
|
||||
public let templateClass: Template.Type
|
||||
public var extensions: [Extension]
|
||||
|
||||
public var loader: Loader?
|
||||
|
||||
public init(loader: Loader? = nil,
|
||||
extensions: [Extension]? = nil,
|
||||
templateClass: Template.Type = Template.self) {
|
||||
|
||||
self.templateClass = templateClass
|
||||
self.loader = loader
|
||||
self.extensions = (extensions ?? []) + [DefaultExtension()]
|
||||
}
|
||||
|
||||
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 render(template: template, context: context)
|
||||
}
|
||||
|
||||
public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String {
|
||||
let template = templateClass.init(templateString: string, environment: self)
|
||||
return try render(template: template, context: context)
|
||||
}
|
||||
|
||||
func render(template: Template, context: [String: Any]?) throws -> String {
|
||||
// update template environment as it can be created from string literal with default environment
|
||||
template.environment = self
|
||||
return try template.render(context)
|
||||
}
|
||||
|
||||
}
|
||||
83
Sources/Errors.swift
Normal file
83
Sources/Errors.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
||||
let templateNames: [String]
|
||||
let loader: Loader?
|
||||
|
||||
public init(templateNames: [String], loader: Loader? = nil) {
|
||||
self.templateNames = templateNames
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
let templates = templateNames.joined(separator: ", ")
|
||||
|
||||
if let loader = loader {
|
||||
return "Template named `\(templates)` does not exist in loader \(loader)"
|
||||
}
|
||||
|
||||
return "Template named `\(templates)` does not exist. No loaders found"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible {
|
||||
public let reason: String
|
||||
public var description: String { return reason }
|
||||
public internal(set) var token: Token?
|
||||
public internal(set) var stackTrace: [Token]
|
||||
public var templateName: String? { return token?.sourceMap.filename }
|
||||
var allTokens: [Token] {
|
||||
return stackTrace + (token.map({ [$0] }) ?? [])
|
||||
}
|
||||
|
||||
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
|
||||
self.reason = reason
|
||||
self.stackTrace = stackTrace
|
||||
self.token = token
|
||||
}
|
||||
|
||||
public init(_ description: String) {
|
||||
self.init(reason: description)
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func withToken(_ token: Token?) -> Error {
|
||||
if var error = self as? TemplateSyntaxError {
|
||||
error.token = error.token ?? token
|
||||
return error
|
||||
} else {
|
||||
return TemplateSyntaxError(reason: "\(self)", token: token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ErrorReporter: class {
|
||||
func renderError(_ error: Error) -> String
|
||||
}
|
||||
|
||||
open class SimpleErrorReporter: ErrorReporter {
|
||||
|
||||
open func renderError(_ error: Error) -> String {
|
||||
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
|
||||
|
||||
func describe(token: Token) -> String {
|
||||
let templateName = token.sourceMap.filename ?? ""
|
||||
let location = token.sourceMap.location
|
||||
let highlight = """
|
||||
\(String(Array(repeating: " ", count: location.lineOffset)))\
|
||||
^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
|
||||
"""
|
||||
|
||||
return """
|
||||
\(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
|
||||
\(location.content)
|
||||
\(highlight)
|
||||
"""
|
||||
}
|
||||
|
||||
var descriptions = templateError.stackTrace.reduce([]) { $0 + [describe(token: $1)] }
|
||||
let description = templateError.token.map(describe(token:)) ?? templateError.reason
|
||||
descriptions.append(description)
|
||||
return descriptions.joined(separator: "\n")
|
||||
}
|
||||
|
||||
}
|
||||
335
Sources/Expression.swift
Normal file
335
Sources/Expression.swift
Normal file
@@ -0,0 +1,335 @@
|
||||
protocol Expression: CustomStringConvertible {
|
||||
func evaluate(context: Context) throws -> Bool
|
||||
}
|
||||
|
||||
|
||||
protocol InfixOperator: Expression {
|
||||
init(lhs: Expression, rhs: Expression)
|
||||
}
|
||||
|
||||
|
||||
protocol PrefixOperator: Expression {
|
||||
init(expression: Expression)
|
||||
}
|
||||
|
||||
|
||||
final class StaticExpression: Expression, CustomStringConvertible {
|
||||
let value: Bool
|
||||
|
||||
init(value: Bool) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
return value
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "\(value)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class VariableExpression: Expression, CustomStringConvertible {
|
||||
let variable: Resolvable
|
||||
|
||||
init(variable: Resolvable) {
|
||||
self.variable = variable
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(variable: \(variable))"
|
||||
}
|
||||
|
||||
/// Resolves a variable in the given context as boolean
|
||||
func resolve(context: Context, variable: Resolvable) throws -> Bool {
|
||||
let result = try variable.resolve(context)
|
||||
var truthy = false
|
||||
|
||||
if let result = result as? [Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? [String:Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? Bool {
|
||||
truthy = result
|
||||
} else if let result = result as? String {
|
||||
truthy = !result.isEmpty
|
||||
} else if let value = result, let result = toNumber(value: value) {
|
||||
truthy = result > 0
|
||||
} else if result != nil {
|
||||
truthy = true
|
||||
}
|
||||
|
||||
return truthy
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
return try resolve(context: context, variable: variable)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
||||
let expression: Expression
|
||||
|
||||
init(expression: Expression) {
|
||||
self.expression = expression
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "not \(expression)"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
return try !expression.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
final class InExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(\(lhs) in \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
|
||||
return rhs.contains(lhs)
|
||||
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange<Int> {
|
||||
return rhs.contains(lhs)
|
||||
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange<Int> {
|
||||
return rhs.contains(lhs)
|
||||
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
||||
return rhs.contains(lhs)
|
||||
} else if lhsValue == nil && rhsValue == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(\(lhs) or \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
let lhs = try self.lhs.evaluate(context: context)
|
||||
if lhs {
|
||||
return lhs
|
||||
}
|
||||
|
||||
return try rhs.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(\(lhs) and \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
let lhs = try self.lhs.evaluate(context: context)
|
||||
if !lhs {
|
||||
return lhs
|
||||
}
|
||||
|
||||
return try rhs.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
required init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(\(lhs) == \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
if let lhs = lhsValue, let rhs = rhsValue {
|
||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||
return lhs == rhs
|
||||
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
||||
return lhs == rhs
|
||||
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
|
||||
return lhs == rhs
|
||||
}
|
||||
} else if lhsValue == nil && rhsValue == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
required init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "(\(lhs) \(op) \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
if let lhs = lhsValue, let rhs = rhsValue {
|
||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||
return compare(lhs: lhs, rhs: rhs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var op: String {
|
||||
return ""
|
||||
}
|
||||
|
||||
func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MoreThanExpression: NumericExpression {
|
||||
override var op: String {
|
||||
return ">"
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
return lhs > rhs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MoreThanEqualExpression: NumericExpression {
|
||||
override var op: String {
|
||||
return ">="
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
return lhs >= rhs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LessThanExpression: NumericExpression {
|
||||
override var op: String {
|
||||
return "<"
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
return lhs < rhs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LessThanEqualExpression: NumericExpression {
|
||||
override var op: String {
|
||||
return "<="
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
return lhs <= rhs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class InequalityExpression: EqualityExpression {
|
||||
override var description: String {
|
||||
return "(\(lhs) != \(rhs))"
|
||||
}
|
||||
|
||||
override func evaluate(context: Context) throws -> Bool {
|
||||
return try !super.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func toNumber(value: Any) -> Number? {
|
||||
if let value = value as? Float {
|
||||
return Number(value)
|
||||
} else if let value = value as? Double {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int8 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int16 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int32 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt8 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt16 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt32 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Number {
|
||||
return value
|
||||
} else if let value = value as? Float64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Float32 {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
95
Sources/Extension.swift
Normal file
95
Sources/Extension.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
open class Extension {
|
||||
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||
var tags = [String: TagParser]()
|
||||
|
||||
var filters = [String: Filter]()
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
/// Registers a new template tag
|
||||
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
|
||||
tags[name] = parser
|
||||
}
|
||||
|
||||
/// Registers a simple template tag with a name and a handler
|
||||
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
||||
registerTag(name, parser: { parser, token in
|
||||
return SimpleNode(token: token, handler: handler)
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers boolean filter with it's negative counterpart
|
||||
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
|
||||
filters[name] = .simple(filter)
|
||||
filters[negativeFilterName] = .simple {
|
||||
guard let result = try filter($0) else { return nil }
|
||||
return !result
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a template filter with the given name
|
||||
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
|
||||
filters[name] = .simple(filter)
|
||||
}
|
||||
|
||||
/// Registers a template filter with the given name
|
||||
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
|
||||
filters[name] = .arguments(filter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DefaultExtension: Extension {
|
||||
override init() {
|
||||
super.init()
|
||||
registerDefaultTags()
|
||||
registerDefaultFilters()
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultTags() {
|
||||
registerTag("for", parser: ForNode.parse)
|
||||
registerTag("if", parser: IfNode.parse)
|
||||
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
||||
#if !os(Linux)
|
||||
registerTag("now", parser: NowNode.parse)
|
||||
#endif
|
||||
registerTag("include", parser: IncludeNode.parse)
|
||||
registerTag("extends", parser: ExtendsNode.parse)
|
||||
registerTag("block", parser: BlockNode.parse)
|
||||
registerTag("filter", parser: FilterNode.parse)
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultFilters() {
|
||||
registerFilter("default", filter: defaultFilter)
|
||||
registerFilter("capitalize", filter: capitalise)
|
||||
registerFilter("uppercase", filter: uppercase)
|
||||
registerFilter("lowercase", filter: lowercase)
|
||||
registerFilter("join", filter: joinFilter)
|
||||
registerFilter("split", filter: splitFilter)
|
||||
registerFilter("indent", filter: indentFilter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protocol FilterType {
|
||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any?
|
||||
}
|
||||
|
||||
enum Filter: FilterType {
|
||||
case simple(((Any?) throws -> Any?))
|
||||
case arguments(((Any?, [Any?]) throws -> Any?))
|
||||
|
||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
switch self {
|
||||
case let .simple(filter):
|
||||
if !arguments.isEmpty {
|
||||
throw TemplateSyntaxError("cannot invoke filter with an argument")
|
||||
}
|
||||
|
||||
return try filter(value)
|
||||
case let .arguments(filter):
|
||||
return try filter(value, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Sources/FilterTag.swift
Normal file
37
Sources/FilterTag.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
class FilterNode : NodeType {
|
||||
let resolvable: Resolvable
|
||||
let nodes: [NodeType]
|
||||
let token: Token?
|
||||
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
let bits = token.components()
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
||||
}
|
||||
|
||||
let blocks = try parser.parse(until(["endfilter"]))
|
||||
|
||||
guard parser.nextToken() != nil else {
|
||||
throw TemplateSyntaxError("`endfilter` was not found.")
|
||||
}
|
||||
|
||||
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
|
||||
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
|
||||
}
|
||||
|
||||
init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
|
||||
self.nodes = nodes
|
||||
self.resolvable = resolvable
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
let value = try renderNodes(nodes, context)
|
||||
|
||||
return try context.push(dictionary: ["filter_value": value]) {
|
||||
return try VariableNode(variable: resolvable, token: token).render(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,117 @@
|
||||
func toString(_ value: Any?) -> String? {
|
||||
if let value = value as? String {
|
||||
func capitalise(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).capitalized }
|
||||
} else {
|
||||
return stringify(value).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func uppercase(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).uppercased() }
|
||||
} else {
|
||||
return stringify(value).uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
func lowercase(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).lowercased() }
|
||||
} else {
|
||||
return stringify(value).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
|
||||
// value can be optional wrapping nil, so this way we check for underlying value
|
||||
if let value = value, String(describing: value) != "nil" {
|
||||
return value
|
||||
} else if let value = value as? CustomStringConvertible {
|
||||
return value.description
|
||||
}
|
||||
|
||||
for argument in arguments {
|
||||
if let argument = argument {
|
||||
return argument
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func capitalise(_ value: Any?) -> Any? {
|
||||
if let value = toString(value) {
|
||||
return value.capitalized
|
||||
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
guard arguments.count < 2 else {
|
||||
throw TemplateSyntaxError("'join' filter takes a single argument")
|
||||
}
|
||||
|
||||
let separator = stringify(arguments.first ?? "")
|
||||
|
||||
if let value = value as? [Any] {
|
||||
return value
|
||||
.map(stringify)
|
||||
.joined(separator: separator)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func uppercase(_ value: Any?) -> Any? {
|
||||
if let value = toString(value) {
|
||||
return value.uppercased()
|
||||
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
guard arguments.count < 2 else {
|
||||
throw TemplateSyntaxError("'split' filter takes a single argument")
|
||||
}
|
||||
|
||||
let separator = stringify(arguments.first ?? " ")
|
||||
if let value = value as? String {
|
||||
return value.components(separatedBy: separator)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func lowercase(_ value: Any?) -> Any? {
|
||||
if let value = toString(value) {
|
||||
return value.lowercased()
|
||||
func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
guard arguments.count <= 3 else {
|
||||
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
|
||||
}
|
||||
|
||||
return value
|
||||
var indentWidth = 4
|
||||
if arguments.count > 0 {
|
||||
guard let value = arguments[0] as? Int else {
|
||||
throw TemplateSyntaxError("""
|
||||
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
|
||||
""")
|
||||
}
|
||||
indentWidth = value
|
||||
}
|
||||
|
||||
var indentationChar = " "
|
||||
if arguments.count > 1 {
|
||||
guard let value = arguments[1] as? String else {
|
||||
throw TemplateSyntaxError("""
|
||||
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
|
||||
""")
|
||||
}
|
||||
indentationChar = value
|
||||
}
|
||||
|
||||
var indentFirst = false
|
||||
if arguments.count > 2 {
|
||||
guard let value = arguments[2] as? Bool else {
|
||||
throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
|
||||
}
|
||||
indentFirst = value
|
||||
}
|
||||
|
||||
let indentation = [String](repeating: indentationChar, count: indentWidth).joined(separator: "")
|
||||
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
|
||||
}
|
||||
|
||||
|
||||
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
|
||||
guard !indentation.isEmpty else { return content }
|
||||
|
||||
var lines = content.components(separatedBy: .newlines)
|
||||
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
|
||||
let result = lines.reduce([firstLine]) { (result, line) in
|
||||
return result + [(line.isEmpty ? "" : "\(indentation)\(line)")]
|
||||
}
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
open class ForNode : NodeType {
|
||||
let variable:Variable
|
||||
let loopVariable:String
|
||||
import Foundation
|
||||
|
||||
class ForNode : NodeType {
|
||||
let resolvable: Resolvable
|
||||
let loopVariables: [String]
|
||||
let nodes:[NodeType]
|
||||
let emptyNodes: [NodeType]
|
||||
let `where`: Expression?
|
||||
let token: Token?
|
||||
|
||||
open class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
let components = token.components()
|
||||
|
||||
guard components.count == 4 && components[2] == "in" else {
|
||||
throw TemplateSyntaxError("'for' statements should use the following 'for x in y' `\(token.contents)`.")
|
||||
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||
return components.count > (index + 1) && components[index] == token
|
||||
}
|
||||
|
||||
let loopVariable = components[1]
|
||||
let variable = components[3]
|
||||
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
|
||||
return components.count == index || hasToken(token, at: index)
|
||||
}
|
||||
|
||||
var emptyNodes = [NodeType]()
|
||||
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
|
||||
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
|
||||
}
|
||||
|
||||
let loopVariables = components[1]
|
||||
.split(separator: ",")
|
||||
.map(String.init)
|
||||
.map { $0.trim(character: " ") }
|
||||
|
||||
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
|
||||
|
||||
let `where` = hasToken("where", at: 4)
|
||||
? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token)
|
||||
: nil
|
||||
|
||||
let forNodes = try parser.parse(until(["endfor", "empty"]))
|
||||
|
||||
@@ -22,37 +40,112 @@ open class ForNode : NodeType {
|
||||
throw TemplateSyntaxError("`endfor` was not found.")
|
||||
}
|
||||
|
||||
var emptyNodes = [NodeType]()
|
||||
if token.contents == "empty" {
|
||||
emptyNodes = try parser.parse(until(["endfor"]))
|
||||
_ = parser.nextToken()
|
||||
}
|
||||
|
||||
return ForNode(variable: variable, loopVariable: loopVariable, nodes: forNodes, emptyNodes:emptyNodes)
|
||||
return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes: emptyNodes, where: `where`, token: token)
|
||||
}
|
||||
|
||||
public init(variable:String, loopVariable:String, nodes:[NodeType], emptyNodes:[NodeType]) {
|
||||
self.variable = Variable(variable)
|
||||
self.loopVariable = loopVariable
|
||||
init(resolvable: Resolvable, loopVariables: [String], nodes: [NodeType], emptyNodes: [NodeType], where: Expression? = nil, token: Token? = nil) {
|
||||
self.resolvable = resolvable
|
||||
self.loopVariables = loopVariables
|
||||
self.nodes = nodes
|
||||
self.emptyNodes = emptyNodes
|
||||
self.where = `where`
|
||||
self.token = token
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
let values = try variable.resolve(context)
|
||||
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
|
||||
if loopVariables.isEmpty {
|
||||
return try context.push() {
|
||||
return try closure()
|
||||
}
|
||||
}
|
||||
|
||||
if let values = values as? [Any] , values.count > 0 {
|
||||
let valueMirror = Mirror(reflecting: value)
|
||||
if case .tuple? = valueMirror.displayStyle {
|
||||
if loopVariables.count > Int(valueMirror.children.count) {
|
||||
throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
|
||||
}
|
||||
var variablesContext = [String: Any]()
|
||||
valueMirror.children.prefix(loopVariables.count).enumerated().forEach({ (offset, element) in
|
||||
if loopVariables[offset] != "_" {
|
||||
variablesContext[loopVariables[offset]] = element.value
|
||||
}
|
||||
})
|
||||
|
||||
return try context.push(dictionary: variablesContext) {
|
||||
return try closure()
|
||||
}
|
||||
}
|
||||
|
||||
return try context.push(dictionary: [loopVariables.first!: value]) {
|
||||
return try closure()
|
||||
}
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
let resolved = try resolvable.resolve(context)
|
||||
|
||||
var values: [Any]
|
||||
|
||||
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
|
||||
values = dictionary.sorted { $0.key < $1.key }
|
||||
} else if let array = resolved as? [Any] {
|
||||
values = array
|
||||
} else if let range = resolved as? CountableClosedRange<Int> {
|
||||
values = Array(range)
|
||||
} else if let range = resolved as? CountableRange<Int> {
|
||||
values = Array(range)
|
||||
} else if let resolved = resolved {
|
||||
let mirror = Mirror(reflecting: resolved)
|
||||
switch mirror.displayStyle {
|
||||
case .struct?, .tuple?:
|
||||
values = Array(mirror.children)
|
||||
case .class?:
|
||||
var children = Array(mirror.children)
|
||||
var currentMirror: Mirror? = mirror
|
||||
while let superclassMirror = currentMirror?.superclassMirror {
|
||||
children.append(contentsOf: superclassMirror.children)
|
||||
currentMirror = superclassMirror
|
||||
}
|
||||
values = Array(children)
|
||||
default:
|
||||
values = []
|
||||
}
|
||||
} else {
|
||||
values = []
|
||||
}
|
||||
|
||||
if let `where` = self.where {
|
||||
values = try values.filter({ item -> Bool in
|
||||
return try push(value: item, context: context) {
|
||||
try `where`.evaluate(context: context)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if !values.isEmpty {
|
||||
let count = values.count
|
||||
|
||||
return try values.enumerated().map { index, item in
|
||||
let forContext: [String: Any] = [
|
||||
"first": index == 0,
|
||||
"last": index == (count - 1),
|
||||
"counter": index + 1,
|
||||
"counter0": index,
|
||||
"length": count
|
||||
]
|
||||
|
||||
return try context.push(dictionary: [loopVariable: item, "forloop": forContext]) {
|
||||
try renderNodes(nodes, context)
|
||||
return try context.push(dictionary: ["forloop": forContext]) {
|
||||
return try push(value: item, context: context) {
|
||||
try renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
}.joined(separator: "")
|
||||
}.joined(separator: "")
|
||||
}
|
||||
|
||||
return try context.push {
|
||||
|
||||
@@ -1,40 +1,287 @@
|
||||
open class IfNode : NodeType {
|
||||
open let variable:Variable
|
||||
open let trueNodes:[NodeType]
|
||||
open let falseNodes:[NodeType]
|
||||
enum Operator {
|
||||
case infix(String, Int, InfixOperator.Type)
|
||||
case prefix(String, Int, PrefixOperator.Type)
|
||||
|
||||
open class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
let components = token.components()
|
||||
guard components.count == 2 else {
|
||||
throw TemplateSyntaxError("'if' statements should use the following 'if condition' `\(token.contents)`.")
|
||||
var name: String {
|
||||
switch self {
|
||||
case .infix(let name, _, _):
|
||||
return name
|
||||
case .prefix(let name, _, _):
|
||||
return name
|
||||
}
|
||||
let variable = components[1]
|
||||
var trueNodes = [NodeType]()
|
||||
var falseNodes = [NodeType]()
|
||||
}
|
||||
}
|
||||
|
||||
trueNodes = try parser.parse(until(["endif", "else"]))
|
||||
|
||||
guard let token = parser.nextToken() else {
|
||||
let operators: [Operator] = [
|
||||
.infix("in", 5, InExpression.self),
|
||||
.infix("or", 6, OrExpression.self),
|
||||
.infix("and", 7, AndExpression.self),
|
||||
.prefix("not", 8, NotExpression.self),
|
||||
.infix("==", 10, EqualityExpression.self),
|
||||
.infix("!=", 10, InequalityExpression.self),
|
||||
.infix(">", 10, MoreThanExpression.self),
|
||||
.infix(">=", 10, MoreThanEqualExpression.self),
|
||||
.infix("<", 10, LessThanExpression.self),
|
||||
.infix("<=", 10, LessThanEqualExpression.self),
|
||||
]
|
||||
|
||||
|
||||
func findOperator(name: String) -> Operator? {
|
||||
for op in operators {
|
||||
if op.name == name {
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
indirect enum IfToken {
|
||||
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
|
||||
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
|
||||
case variable(Resolvable)
|
||||
case subExpression(Expression)
|
||||
case end
|
||||
|
||||
var bindingPower: Int {
|
||||
switch self {
|
||||
case .infix(_, let bindingPower, _):
|
||||
return bindingPower
|
||||
case .prefix(_, let bindingPower, _):
|
||||
return bindingPower
|
||||
case .variable(_):
|
||||
return 0
|
||||
case .subExpression(_):
|
||||
return 0
|
||||
case .end:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
|
||||
switch self {
|
||||
case .infix(let name, _, _):
|
||||
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
||||
case .prefix(_, let bindingPower, let op):
|
||||
let expression = try parser.expression(bindingPower: bindingPower)
|
||||
return op.init(expression: expression)
|
||||
case .variable(let variable):
|
||||
return VariableExpression(variable: variable)
|
||||
case .subExpression(let expression):
|
||||
return expression
|
||||
case .end:
|
||||
throw TemplateSyntaxError("'if' expression error: end")
|
||||
}
|
||||
}
|
||||
|
||||
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
||||
switch self {
|
||||
case .infix(_, let bindingPower, let op):
|
||||
let right = try parser.expression(bindingPower: bindingPower)
|
||||
return op.init(lhs: left, rhs: right)
|
||||
case .prefix(let name, _, _):
|
||||
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
|
||||
case .variable(let variable):
|
||||
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
|
||||
case .subExpression(_):
|
||||
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
|
||||
case .end:
|
||||
throw TemplateSyntaxError("'if' expression error: end")
|
||||
}
|
||||
}
|
||||
|
||||
var isEnd: Bool {
|
||||
switch self {
|
||||
case .end:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class IfExpressionParser {
|
||||
let tokens: [IfToken]
|
||||
var position: Int = 0
|
||||
|
||||
private init(tokens: [IfToken]) {
|
||||
self.tokens = tokens
|
||||
}
|
||||
|
||||
static func parser(components: [String], tokenParser: TokenParser, token: Token) throws -> IfExpressionParser {
|
||||
return try IfExpressionParser(components: ArraySlice(components), tokenParser: tokenParser, token: token)
|
||||
}
|
||||
|
||||
private init(components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws {
|
||||
var parsedComponents = Set<Int>()
|
||||
var bracketsBalance = 0
|
||||
self.tokens = try zip(components.indices, components).compactMap { (index, component) in
|
||||
guard !parsedComponents.contains(index) else { return nil }
|
||||
|
||||
if component == "(" {
|
||||
bracketsBalance += 1
|
||||
let (expression, parsedCount) = try IfExpressionParser.subExpression(
|
||||
from: components.suffix(from: index + 1),
|
||||
tokenParser: tokenParser,
|
||||
token: token
|
||||
)
|
||||
parsedComponents.formUnion(Set(index...(index + parsedCount)))
|
||||
return .subExpression(expression)
|
||||
} else if component == ")" {
|
||||
bracketsBalance -= 1
|
||||
if bracketsBalance < 0 {
|
||||
throw TemplateSyntaxError("'if' expression error: missing opening bracket")
|
||||
}
|
||||
parsedComponents.insert(index)
|
||||
return nil
|
||||
} else {
|
||||
parsedComponents.insert(index)
|
||||
if let op = findOperator(name: component) {
|
||||
switch op {
|
||||
case .infix(let name, let bindingPower, let operatorType):
|
||||
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||
case .prefix(let name, let bindingPower, let operatorType):
|
||||
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||
}
|
||||
}
|
||||
return .variable(try tokenParser.compileResolvable(component, containedIn: token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func subExpression(from components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws -> (Expression, Int) {
|
||||
var bracketsBalance = 1
|
||||
let subComponents = components
|
||||
.prefix(while: {
|
||||
if $0 == "(" {
|
||||
bracketsBalance += 1
|
||||
} else if $0 == ")" {
|
||||
bracketsBalance -= 1
|
||||
}
|
||||
return bracketsBalance != 0
|
||||
})
|
||||
if bracketsBalance > 0 {
|
||||
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
|
||||
}
|
||||
|
||||
let expressionParser = try IfExpressionParser(components: subComponents, tokenParser: tokenParser, token: token)
|
||||
let expression = try expressionParser.parse()
|
||||
return (expression, subComponents.count)
|
||||
}
|
||||
|
||||
var currentToken: IfToken {
|
||||
if tokens.count > position {
|
||||
return tokens[position]
|
||||
}
|
||||
|
||||
return .end
|
||||
}
|
||||
|
||||
var nextToken: IfToken {
|
||||
position += 1
|
||||
return currentToken
|
||||
}
|
||||
|
||||
func parse() throws -> Expression {
|
||||
let expression = try self.expression()
|
||||
|
||||
if !currentToken.isEnd {
|
||||
throw TemplateSyntaxError("'if' expression error: dangling token")
|
||||
}
|
||||
|
||||
return expression
|
||||
}
|
||||
|
||||
func expression(bindingPower: Int = 0) throws -> Expression {
|
||||
var token = currentToken
|
||||
position += 1
|
||||
|
||||
var left = try token.nullDenotation(parser: self)
|
||||
|
||||
while bindingPower < currentToken.bindingPower {
|
||||
token = currentToken
|
||||
position += 1
|
||||
left = try token.leftDenotation(left: left, parser: self)
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
}
|
||||
|
||||
func parseExpression(components: [String], tokenParser: TokenParser, token: Token) throws -> Expression {
|
||||
let parser = try IfExpressionParser.parser(components: components, tokenParser: tokenParser, token: token)
|
||||
return try parser.parse()
|
||||
}
|
||||
|
||||
/// Represents an if condition and the associated nodes when the condition
|
||||
/// evaluates
|
||||
final class IfCondition {
|
||||
let expression: Expression?
|
||||
let nodes: [NodeType]
|
||||
|
||||
init(expression: Expression?, nodes: [NodeType]) {
|
||||
self.expression = expression
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
return try context.push {
|
||||
return try renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class IfNode : NodeType {
|
||||
let conditions: [IfCondition]
|
||||
let token: Token?
|
||||
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
var components = token.components()
|
||||
components.removeFirst()
|
||||
|
||||
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
|
||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||
var conditions: [IfCondition] = [
|
||||
IfCondition(expression: expression, nodes: nodes)
|
||||
]
|
||||
|
||||
var nextToken = parser.nextToken()
|
||||
while let current = nextToken, current.contents.hasPrefix("elif") {
|
||||
var components = current.components()
|
||||
components.removeFirst()
|
||||
let expression = try parseExpression(components: components, tokenParser: parser, token: current)
|
||||
|
||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||
nextToken = parser.nextToken()
|
||||
conditions.append(IfCondition(expression: expression, nodes: nodes))
|
||||
}
|
||||
|
||||
if let current = nextToken, current.contents == "else" {
|
||||
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
|
||||
nextToken = parser.nextToken()
|
||||
}
|
||||
|
||||
guard let current = nextToken, current.contents == "endif" else {
|
||||
throw TemplateSyntaxError("`endif` was not found.")
|
||||
}
|
||||
|
||||
if token.contents == "else" {
|
||||
falseNodes = try parser.parse(until(["endif"]))
|
||||
_ = parser.nextToken()
|
||||
}
|
||||
|
||||
return IfNode(variable: variable, trueNodes: trueNodes, falseNodes: falseNodes)
|
||||
return IfNode(conditions: conditions, token: token)
|
||||
}
|
||||
|
||||
open class func parse_ifnot(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
let components = token.components()
|
||||
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
var components = token.components()
|
||||
guard components.count == 2 else {
|
||||
throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.")
|
||||
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
|
||||
}
|
||||
let variable = components[1]
|
||||
components.removeFirst()
|
||||
var trueNodes = [NodeType]()
|
||||
var falseNodes = [NodeType]()
|
||||
|
||||
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
|
||||
falseNodes = try parser.parse(until(["endif", "else"]))
|
||||
|
||||
guard let token = parser.nextToken() else {
|
||||
@@ -46,35 +293,30 @@ open class IfNode : NodeType {
|
||||
_ = parser.nextToken()
|
||||
}
|
||||
|
||||
return IfNode(variable: variable, trueNodes: trueNodes, falseNodes: falseNodes)
|
||||
return IfNode(conditions: [
|
||||
IfCondition(expression: expression, nodes: trueNodes),
|
||||
IfCondition(expression: nil, nodes: falseNodes),
|
||||
], token: token)
|
||||
}
|
||||
|
||||
public init(variable:String, trueNodes:[NodeType], falseNodes:[NodeType]) {
|
||||
self.variable = Variable(variable)
|
||||
self.trueNodes = trueNodes
|
||||
self.falseNodes = falseNodes
|
||||
init(conditions: [IfCondition], token: Token? = nil) {
|
||||
self.conditions = conditions
|
||||
self.token = token
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
let result = try variable.resolve(context)
|
||||
var truthy = false
|
||||
func render(_ context: Context) throws -> String {
|
||||
for condition in conditions {
|
||||
if let expression = condition.expression {
|
||||
let truthy = try expression.evaluate(context: context)
|
||||
|
||||
if let result = result as? [Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? [String:Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? Bool {
|
||||
truthy = result
|
||||
} else if result != nil {
|
||||
truthy = true
|
||||
}
|
||||
|
||||
return try context.push {
|
||||
if truthy {
|
||||
return try renderNodes(trueNodes, context)
|
||||
if truthy {
|
||||
return try condition.render(context)
|
||||
}
|
||||
} else {
|
||||
return try renderNodes(falseNodes, context)
|
||||
return try condition.render(context)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
import PathKit
|
||||
|
||||
|
||||
open class IncludeNode : NodeType {
|
||||
open let templateName: Variable
|
||||
class IncludeNode : NodeType {
|
||||
let templateName: Variable
|
||||
let includeContext: String?
|
||||
let token: Token?
|
||||
|
||||
open class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
let bits = token.components()
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
|
||||
guard bits.count == 2 || bits.count == 3 else {
|
||||
throw TemplateSyntaxError("""
|
||||
'include' tag requires one argument, the template file to be included. \
|
||||
A second optional argument can be used to specify the context that will \
|
||||
be passed to the included file
|
||||
""")
|
||||
}
|
||||
|
||||
return IncludeNode(templateName: Variable(bits[1]))
|
||||
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
|
||||
}
|
||||
|
||||
public init(templateName: Variable) {
|
||||
init(templateName: Variable, includeContext: String? = nil, token: Token) {
|
||||
self.templateName = templateName
|
||||
self.includeContext = includeContext
|
||||
self.token = token
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
guard let loader = context["loader"] as? TemplateLoader else {
|
||||
throw TemplateSyntaxError("Template loader not in context")
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
guard let templateName = try self.templateName.resolve(context) as? String else {
|
||||
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
||||
}
|
||||
|
||||
guard let template = loader.loadTemplate(templateName) else {
|
||||
let paths = loader.paths.map { $0.description }.joined(separator: ", ")
|
||||
throw TemplateSyntaxError("'\(templateName)' template not found in \(paths)")
|
||||
}
|
||||
let template = try context.environment.loadTemplate(name: templateName)
|
||||
|
||||
return try template.render(context)
|
||||
do {
|
||||
let subContext = includeContext.flatMap { context[$0] as? [String: Any] }
|
||||
return try context.push(dictionary: subContext) {
|
||||
return try template.render(context)
|
||||
}
|
||||
} catch {
|
||||
if let error = error as? TemplateSyntaxError {
|
||||
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
class BlockContext {
|
||||
class var contextKey: String { return "block_context" }
|
||||
|
||||
var blocks: [String:BlockNode]
|
||||
// contains mapping of block names to their nodes and templates where they are defined
|
||||
var blocks: [String: [BlockNode]]
|
||||
|
||||
init(blocks: [String:BlockNode]) {
|
||||
self.blocks = blocks
|
||||
init(blocks: [String: BlockNode]) {
|
||||
self.blocks = [:]
|
||||
blocks.forEach { self.blocks[$0.key] = [$0.value] }
|
||||
}
|
||||
|
||||
func push(_ block: BlockNode, forKey blockName: String) {
|
||||
if var blocks = blocks[blockName] {
|
||||
blocks.append(block)
|
||||
self.blocks[blockName] = blocks
|
||||
} else {
|
||||
self.blocks[blockName] = [block]
|
||||
}
|
||||
}
|
||||
|
||||
func pop(_ blockName: String) -> BlockNode? {
|
||||
return blocks.removeValue(forKey: blockName)
|
||||
if var blocks = blocks[blockName] {
|
||||
let block = blocks.removeFirst()
|
||||
if blocks.isEmpty {
|
||||
self.blocks.removeValue(forKey: blockName)
|
||||
} else {
|
||||
self.blocks[blockName] = blocks
|
||||
}
|
||||
return block
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +50,7 @@ extension Collection {
|
||||
class ExtendsNode : NodeType {
|
||||
let templateName: Variable
|
||||
let blocks: [String:BlockNode]
|
||||
let token: Token?
|
||||
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
let bits = token.components()
|
||||
@@ -42,40 +64,55 @@ class ExtendsNode : NodeType {
|
||||
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
|
||||
}
|
||||
|
||||
let blockNodes = parsedNodes.filter { node in node is BlockNode }
|
||||
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
|
||||
|
||||
let nodes = blockNodes.reduce([String:BlockNode]()) { (accumulator, node:NodeType) -> [String:BlockNode] in
|
||||
let node = (node as! BlockNode)
|
||||
let nodes = blockNodes.reduce([String: BlockNode]()) { (accumulator, node) -> [String: BlockNode] in
|
||||
var dict = accumulator
|
||||
dict[node.name] = node
|
||||
return dict
|
||||
}
|
||||
|
||||
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes)
|
||||
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
|
||||
}
|
||||
|
||||
init(templateName: Variable, blocks: [String: BlockNode]) {
|
||||
init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
|
||||
self.templateName = templateName
|
||||
self.blocks = blocks
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
guard let loader = context["loader"] as? TemplateLoader 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")
|
||||
}
|
||||
|
||||
guard let template = loader.loadTemplate(templateName) else {
|
||||
let paths:String = loader.paths.map { $0.description }.joined(separator: ", ")
|
||||
throw TemplateSyntaxError("'\(templateName)' template not found in \(paths)")
|
||||
let baseTemplate = try context.environment.loadTemplate(name: templateName)
|
||||
|
||||
let blockContext: BlockContext
|
||||
if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
|
||||
blockContext = currentBlockContext
|
||||
for (name, block) in blocks {
|
||||
blockContext.push(block, forKey: name)
|
||||
}
|
||||
} else {
|
||||
blockContext = BlockContext(blocks: blocks)
|
||||
}
|
||||
|
||||
let blockContext = BlockContext(blocks: blocks)
|
||||
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
||||
return try template.render(context)
|
||||
do {
|
||||
// pushes base template and renders it's content
|
||||
// block_context contains all blocks from child templates
|
||||
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
||||
return try baseTemplate.render(context)
|
||||
}
|
||||
} catch {
|
||||
// if error template is already set (see catch in BlockNode)
|
||||
// and it happend in the same template as current template
|
||||
// there is no need to wrap it in another error
|
||||
if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
|
||||
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,30 +121,69 @@ class ExtendsNode : NodeType {
|
||||
class BlockNode : NodeType {
|
||||
let name: String
|
||||
let nodes: [NodeType]
|
||||
let token: Token?
|
||||
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
let bits = token.components()
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'block' tag takes one argument, the template file to be included")
|
||||
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
||||
}
|
||||
|
||||
let blockName = bits[1]
|
||||
let nodes = try parser.parse(until(["endblock"]))
|
||||
_ = parser.nextToken()
|
||||
return BlockNode(name:blockName, nodes:nodes)
|
||||
return BlockNode(name:blockName, nodes:nodes, token: token)
|
||||
}
|
||||
|
||||
init(name: String, nodes: [NodeType]) {
|
||||
init(name: String, nodes: [NodeType], token: Token) {
|
||||
self.name = name
|
||||
self.nodes = nodes
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let node = blockContext.pop(name) {
|
||||
return try node.render(context)
|
||||
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
|
||||
let childContext = try self.childContext(child, blockContext: blockContext, context: context)
|
||||
// render extension node
|
||||
do {
|
||||
return try context.push(dictionary: childContext) {
|
||||
return try child.render(context)
|
||||
}
|
||||
} catch {
|
||||
throw error.withToken(child.token)
|
||||
}
|
||||
}
|
||||
|
||||
return try renderNodes(nodes, context)
|
||||
}
|
||||
|
||||
// child node is a block node from child template that extends this node (has the same name)
|
||||
func childContext(_ child: BlockNode, blockContext: BlockContext, context: Context) throws -> [String: Any] {
|
||||
var childContext: [String: Any] = [BlockContext.contextKey: blockContext]
|
||||
|
||||
if let blockSuperNode = child.nodes.first(where: {
|
||||
if case .variable(let variable, _)? = $0.token, variable == "block.super" { return true }
|
||||
else { return false}
|
||||
}) {
|
||||
do {
|
||||
// render base node so that its content can be used as part of child node that extends it
|
||||
childContext["block"] = ["super": try self.render(context)]
|
||||
} catch {
|
||||
if let error = error as? TemplateSyntaxError {
|
||||
throw TemplateSyntaxError(
|
||||
reason: error.reason,
|
||||
token: blockSuperNode.token,
|
||||
stackTrace: error.allTokens)
|
||||
} else {
|
||||
throw TemplateSyntaxError(
|
||||
reason: "\(error)",
|
||||
token: blockSuperNode.token,
|
||||
stackTrace: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
return childContext
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
112
Sources/KeyPath.swift
Normal file
112
Sources/KeyPath.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
/// A structure used to represent a template variable, and to resolve it in a given context.
|
||||
final class KeyPath {
|
||||
private var components = [String]()
|
||||
private var current = ""
|
||||
private var partialComponents = [String]()
|
||||
private var subscriptLevel = 0
|
||||
|
||||
let variable: String
|
||||
let context: Context
|
||||
|
||||
// Split the keypath string and resolve references if possible
|
||||
init(_ variable: String, in context: Context) {
|
||||
self.variable = variable
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func parse() throws -> [String] {
|
||||
defer {
|
||||
components = []
|
||||
current = ""
|
||||
partialComponents = []
|
||||
subscriptLevel = 0
|
||||
}
|
||||
|
||||
for c in variable {
|
||||
switch c {
|
||||
case "." where subscriptLevel == 0:
|
||||
try foundSeparator()
|
||||
case "[":
|
||||
try openBracket()
|
||||
case "]":
|
||||
try closeBracket()
|
||||
default:
|
||||
try addCharacter(c)
|
||||
}
|
||||
}
|
||||
try finish()
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
private func foundSeparator() throws {
|
||||
if !current.isEmpty {
|
||||
partialComponents.append(current)
|
||||
}
|
||||
|
||||
guard !partialComponents.isEmpty else {
|
||||
throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
components += partialComponents
|
||||
current = ""
|
||||
partialComponents = []
|
||||
}
|
||||
|
||||
// when opening the first bracket, we must have a partial component
|
||||
private func openBracket() throws {
|
||||
guard !partialComponents.isEmpty || !current.isEmpty else {
|
||||
throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
if subscriptLevel > 0 {
|
||||
current.append("[")
|
||||
} else if !current.isEmpty {
|
||||
partialComponents.append(current)
|
||||
current = ""
|
||||
}
|
||||
|
||||
subscriptLevel += 1
|
||||
}
|
||||
|
||||
// for a closing bracket at root level, try to resolve the reference
|
||||
private func closeBracket() throws {
|
||||
guard subscriptLevel > 0 else {
|
||||
throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
if subscriptLevel > 1 {
|
||||
current.append("]")
|
||||
} else if !current.isEmpty,
|
||||
let value = try Variable(current).resolve(context) {
|
||||
partialComponents.append("\(value)")
|
||||
current = ""
|
||||
} else {
|
||||
throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
subscriptLevel -= 1
|
||||
}
|
||||
|
||||
private func addCharacter(_ c: Character) throws {
|
||||
guard partialComponents.isEmpty || subscriptLevel > 0 else {
|
||||
throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
current.append(c)
|
||||
}
|
||||
|
||||
private func finish() throws {
|
||||
// check if we have a last piece
|
||||
if !current.isEmpty {
|
||||
partialComponents.append(current)
|
||||
}
|
||||
components += partialComponents
|
||||
|
||||
guard subscriptLevel == 0 else {
|
||||
throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,198 @@
|
||||
public struct Lexer {
|
||||
public let templateString: String
|
||||
import Foundation
|
||||
|
||||
public init(templateString: String) {
|
||||
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
|
||||
|
||||
struct Lexer {
|
||||
let templateName: String?
|
||||
let templateString: String
|
||||
let lines: [Line]
|
||||
|
||||
/// The potential token start characters. In a template these appear after a
|
||||
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
||||
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
||||
|
||||
/// The token end characters, corresponding to their token start characters.
|
||||
/// For example, a variable token starts with `{{` and ends with `}}`
|
||||
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
||||
"{": "}",
|
||||
"%": "%",
|
||||
"#": "#"
|
||||
]
|
||||
|
||||
init(templateName: String? = nil, templateString: String) {
|
||||
self.templateName = templateName
|
||||
self.templateString = templateString
|
||||
|
||||
self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap {
|
||||
guard !$0.element.isEmpty else { return nil }
|
||||
return (content: $0.element, number: UInt($0.offset + 1), templateString.range(of: $0.element)!)
|
||||
}
|
||||
}
|
||||
|
||||
func createToken(string:String) -> Token {
|
||||
/// Create a token that will be passed on to the parser, with the given
|
||||
/// content and a range. The content will be tested to see if it's a
|
||||
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
||||
/// `text` token.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - string: The content string of the token
|
||||
/// - range: The range within the template content, used for smart
|
||||
/// error reporting
|
||||
func createToken(string: String, at range: Range<String.Index>) -> Token {
|
||||
func strip() -> String {
|
||||
let start = string.index(string.startIndex, offsetBy: 2)
|
||||
let end = string.index(string.endIndex, offsetBy: -2)
|
||||
return string[start..<end].trim(character: " ")
|
||||
guard string.count > 4 else { return "" }
|
||||
let trimmed = String(string.dropFirst(2).dropLast(2))
|
||||
.components(separatedBy: "\n")
|
||||
.filter({ !$0.isEmpty })
|
||||
.map({ $0.trim(character: " ") })
|
||||
.joined(separator: " ")
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if string.hasPrefix("{{") {
|
||||
return .variable(value: strip())
|
||||
} else if string.hasPrefix("{%") {
|
||||
return .block(value: strip())
|
||||
} else if string.hasPrefix("{#") {
|
||||
return .comment(value: strip())
|
||||
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
|
||||
let value = strip()
|
||||
let range = templateString.range(of: value, range: range) ?? range
|
||||
let location = rangeLocation(range)
|
||||
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||
|
||||
if string.hasPrefix("{{") {
|
||||
return .variable(value: value, at: sourceMap)
|
||||
} else if string.hasPrefix("{%") {
|
||||
return .block(value: value, at: sourceMap)
|
||||
} else if string.hasPrefix("{#") {
|
||||
return .comment(value: value, at: sourceMap)
|
||||
}
|
||||
}
|
||||
|
||||
return .text(value: string)
|
||||
let location = rangeLocation(range)
|
||||
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||
return .text(value: string, at: sourceMap)
|
||||
}
|
||||
|
||||
/// Returns an array of tokens from a given template string.
|
||||
public func tokenize() -> [Token] {
|
||||
/// Transforms the template into a list of tokens, that will eventually be
|
||||
/// passed on to the parser.
|
||||
///
|
||||
/// - Returns: The list of tokens (see `createToken(string: at:)`).
|
||||
func tokenize() -> [Token] {
|
||||
var tokens: [Token] = []
|
||||
|
||||
let scanner = Scanner(templateString)
|
||||
|
||||
let map = [
|
||||
"{{": "}}",
|
||||
"{%": "%}",
|
||||
"{#": "#}",
|
||||
]
|
||||
|
||||
while !scanner.isEmpty {
|
||||
if let text = scanner.scan(until: ["{{", "{%", "{#"]) {
|
||||
if !text.1.isEmpty {
|
||||
tokens.append(createToken(string: text.1))
|
||||
if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
|
||||
if !text.isEmpty {
|
||||
tokens.append(createToken(string: text, at: scanner.range))
|
||||
}
|
||||
|
||||
let end = map[text.0]!
|
||||
let result = scanner.scan(until: end, returnUntil: true)
|
||||
tokens.append(createToken(string: result))
|
||||
guard let end = Lexer.tokenCharMap[char] else { continue }
|
||||
let result = scanner.scanForTokenEnd(end)
|
||||
tokens.append(createToken(string: result, at: scanner.range))
|
||||
} else {
|
||||
tokens.append(createToken(string: scanner.content))
|
||||
tokens.append(createToken(string: scanner.content, at: scanner.range))
|
||||
scanner.content = ""
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
/// Finds the line matching the given range (for a token)
|
||||
///
|
||||
/// - Parameter range: The range to search for.
|
||||
/// - Returns: The content for that line, the line number and offset within
|
||||
/// the line.
|
||||
func rangeLocation(_ range: Range<String.Index>) -> ContentLocation {
|
||||
guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else {
|
||||
return ("", 0, 0)
|
||||
}
|
||||
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
|
||||
return (line.content, line.number, offset)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Scanner {
|
||||
let originalContent: String
|
||||
var content: String
|
||||
var range: Range<String.Index>
|
||||
|
||||
/// The start delimiter for a token.
|
||||
private static let tokenStartDelimiter: Unicode.Scalar = "{"
|
||||
/// And the corresponding end delimiter for a token.
|
||||
private static let tokenEndDelimiter: Unicode.Scalar = "}"
|
||||
|
||||
init(_ content: String) {
|
||||
self.originalContent = content
|
||||
self.content = content
|
||||
range = content.startIndex..<content.startIndex
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
return content.isEmpty
|
||||
}
|
||||
|
||||
func scan(until: String, returnUntil: Bool = false) -> String {
|
||||
if until.isEmpty {
|
||||
return ""
|
||||
}
|
||||
|
||||
var index = content.startIndex
|
||||
while index != content.endIndex {
|
||||
let substring = content.substring(from: index)
|
||||
|
||||
if substring.hasPrefix(until) {
|
||||
let result = content.substring(to: index)
|
||||
content = substring
|
||||
|
||||
if returnUntil {
|
||||
content = content.substring(from: until.endIndex)
|
||||
return result + until
|
||||
}
|
||||
/// Scans for the end of a token, with a specific ending character. If we're
|
||||
/// searching for the end of a block token `%}`, this method receives a `%`.
|
||||
/// The scanner will search for that `%` followed by a `}`.
|
||||
///
|
||||
/// Note: if the end of a token is found, the `content` and `range`
|
||||
/// properties are updated to reflect this. `content` will be set to what
|
||||
/// remains of the template after the token. `range` will be set to the range
|
||||
/// of the token within the template.
|
||||
///
|
||||
/// - Parameter tokenChar: The token end character to search for.
|
||||
/// - Returns: The content of a token, or "" if no token end was found.
|
||||
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
|
||||
var foundChar = false
|
||||
|
||||
for (index, char) in content.unicodeScalars.enumerated() {
|
||||
if foundChar && char == Scanner.tokenEndDelimiter {
|
||||
let result = String(content.prefix(index))
|
||||
content = String(content.dropFirst(index + 1))
|
||||
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index + 1)
|
||||
return result
|
||||
} else {
|
||||
foundChar = (char == tokenChar)
|
||||
}
|
||||
|
||||
index = content.index(after: index)
|
||||
}
|
||||
|
||||
content = ""
|
||||
return ""
|
||||
}
|
||||
|
||||
func scan(until: [String]) -> (String, String)? {
|
||||
if until.isEmpty {
|
||||
return nil
|
||||
}
|
||||
/// Scans for the start of a token, with a list of potential starting
|
||||
/// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
|
||||
/// comments (`{#`), this method receives the characters `{`, `%` and `#`.
|
||||
/// The scanner will search for a `{`, followed by one of the search
|
||||
/// characters. It will give the found character, and the content that came
|
||||
/// before the token.
|
||||
///
|
||||
/// Note: if the start of a token is found, the `content` and `range`
|
||||
/// properties are updated to reflect this. `content` will be set to what
|
||||
/// remains of the template starting with the token. `range` will be set to
|
||||
/// the start of the token within the template.
|
||||
///
|
||||
/// - Parameter tokenChars: List of token start characters to search for.
|
||||
/// - Returns: The found token start character, together with the content
|
||||
/// before the token, or nil of no token start was found.
|
||||
func scanForTokenStart(_ tokenChars: [Unicode.Scalar]) -> (Unicode.Scalar, String)? {
|
||||
var foundBrace = false
|
||||
|
||||
var index = content.startIndex
|
||||
while index != content.endIndex {
|
||||
let substring = content.substring(from: index)
|
||||
for string in until {
|
||||
if substring.hasPrefix(string) {
|
||||
let result = content.substring(to: index)
|
||||
content = substring
|
||||
return (string, result)
|
||||
}
|
||||
range = range.upperBound..<range.upperBound
|
||||
for (index, char) in content.unicodeScalars.enumerated() {
|
||||
if foundBrace && tokenChars.contains(char) {
|
||||
let result = String(content.prefix(index - 1))
|
||||
content = String(content.dropFirst(index - 1))
|
||||
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index - 1)
|
||||
return (char, result)
|
||||
} else {
|
||||
foundBrace = (char == Scanner.tokenStartDelimiter)
|
||||
}
|
||||
|
||||
index = content.index(after: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension String {
|
||||
func findFirstNot(character: Character) -> String.Index? {
|
||||
var index = startIndex
|
||||
@@ -147,6 +223,8 @@ extension String {
|
||||
func trim(character: Character) -> String {
|
||||
let first = findFirstNot(character: character) ?? startIndex
|
||||
let last = findLastNot(character: character) ?? endIndex
|
||||
return self[first..<last]
|
||||
return String(self[first..<last])
|
||||
}
|
||||
}
|
||||
|
||||
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
|
||||
|
||||
130
Sources/Loader.swift
Normal file
130
Sources/Loader.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import PathKit
|
||||
|
||||
|
||||
public protocol Loader {
|
||||
func loadTemplate(name: String, environment: Environment) throws -> Template
|
||||
func loadTemplate(names: [String], environment: Environment) throws -> Template
|
||||
}
|
||||
|
||||
|
||||
extension Loader {
|
||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||
for name in names {
|
||||
do {
|
||||
return try loadTemplate(name: name, environment: environment)
|
||||
} catch is TemplateDoesNotExist {
|
||||
continue
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A class for loading a template from disk
|
||||
public class FileSystemLoader: Loader, CustomStringConvertible {
|
||||
public let paths: [Path]
|
||||
|
||||
public init(paths: [Path]) {
|
||||
self.paths = paths
|
||||
}
|
||||
|
||||
public init(bundle: [Bundle]) {
|
||||
self.paths = bundle.map {
|
||||
return Path($0.bundlePath)
|
||||
}
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
return "FileSystemLoader(\(paths))"
|
||||
}
|
||||
|
||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||
for path in paths {
|
||||
let templatePath = try path.safeJoin(path: Path(name))
|
||||
|
||||
if !templatePath.exists {
|
||||
continue
|
||||
}
|
||||
|
||||
let content: String = try templatePath.read()
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||
}
|
||||
|
||||
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 {
|
||||
let content: String = try templatePath.read()
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class DictionaryLoader: Loader {
|
||||
public let templates: [String: String]
|
||||
|
||||
public init(templates: [String: String]) {
|
||||
self.templates = templates
|
||||
}
|
||||
|
||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||
if let content = templates[name] {
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||
}
|
||||
|
||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||
for name in names {
|
||||
if let content = templates[name] {
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Path {
|
||||
func safeJoin(path: Path) throws -> Path {
|
||||
let newPath = self + path
|
||||
|
||||
if !newPath.absolute().description.hasPrefix(absolute().description) {
|
||||
throw SuspiciousFileOperation(basePath: self, path: newPath)
|
||||
}
|
||||
|
||||
return newPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SuspiciousFileOperation: Error {
|
||||
let basePath: Path
|
||||
let path: Path
|
||||
|
||||
init(basePath: Path, path: Path) {
|
||||
self.basePath = basePath
|
||||
self.path = path
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "Path `\(path)` is located outside of base path `\(basePath)`"
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
open class Namespace {
|
||||
public typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||
|
||||
var tags = [String: TagParser]()
|
||||
var filters = [String: Filter]()
|
||||
|
||||
public init() {
|
||||
registerDefaultTags()
|
||||
registerDefaultFilters()
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultTags() {
|
||||
registerTag("for", parser: ForNode.parse)
|
||||
registerTag("if", parser: IfNode.parse)
|
||||
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
||||
#if !os(Linux)
|
||||
registerTag("now", parser: NowNode.parse)
|
||||
#endif
|
||||
registerTag("include", parser: IncludeNode.parse)
|
||||
registerTag("extends", parser: ExtendsNode.parse)
|
||||
registerTag("block", parser: BlockNode.parse)
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultFilters() {
|
||||
registerFilter("capitalize", filter: capitalise)
|
||||
registerFilter("uppercase", filter: uppercase)
|
||||
registerFilter("lowercase", filter: lowercase)
|
||||
}
|
||||
|
||||
/// Registers a new template tag
|
||||
open func registerTag(_ name: String, parser: @escaping TagParser) {
|
||||
tags[name] = parser
|
||||
}
|
||||
|
||||
/// Registers a simple template tag with a name and a handler
|
||||
open func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
||||
registerTag(name, parser: { parser, token in
|
||||
return SimpleNode(handler: handler)
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers a template filter with the given name
|
||||
open func registerFilter(_ name: String, filter: @escaping Filter) {
|
||||
filters[name] = filter
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible {
|
||||
public let description:String
|
||||
|
||||
public init(_ description:String) {
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool {
|
||||
return lhs.description == rhs.description
|
||||
}
|
||||
|
||||
|
||||
public protocol NodeType {
|
||||
/// Render the node in the given context
|
||||
func render(_ context:Context) throws -> String
|
||||
|
||||
/// Reference to this node's token
|
||||
var token: Token? { get }
|
||||
}
|
||||
|
||||
|
||||
/// Render the collection of nodes in the given context
|
||||
public func renderNodes(_ nodes:[NodeType], _ context:Context) throws -> String {
|
||||
return try nodes.map { try $0.render(context) }.joined(separator: "")
|
||||
return try nodes.map {
|
||||
do {
|
||||
return try $0.render(context)
|
||||
} catch {
|
||||
throw error.withToken($0.token)
|
||||
}
|
||||
}.joined(separator: "")
|
||||
}
|
||||
|
||||
open class SimpleNode : NodeType {
|
||||
let handler:(Context) throws -> String
|
||||
public class SimpleNode : NodeType {
|
||||
public let handler:(Context) throws -> String
|
||||
public let token: Token?
|
||||
|
||||
public init(handler:@escaping (Context) throws -> String) {
|
||||
public init(token: Token, handler: @escaping (Context) throws -> String) {
|
||||
self.token = token
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
public func render(_ context: Context) throws -> String {
|
||||
return try handler(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open class TextNode : NodeType {
|
||||
open let text:String
|
||||
public class TextNode : NodeType {
|
||||
public let text:String
|
||||
public let token: Token?
|
||||
|
||||
public init(text:String) {
|
||||
self.text = text
|
||||
self.token = nil
|
||||
}
|
||||
|
||||
open func render(_ context:Context) throws -> String {
|
||||
public func render(_ context:Context) throws -> String {
|
||||
return self.text
|
||||
}
|
||||
}
|
||||
@@ -57,28 +55,96 @@ public protocol Resolvable {
|
||||
}
|
||||
|
||||
|
||||
open class VariableNode : NodeType {
|
||||
open let variable: Resolvable
|
||||
public class VariableNode : NodeType {
|
||||
public let variable: Resolvable
|
||||
public var token: Token?
|
||||
let condition: Expression?
|
||||
let elseExpression: Resolvable?
|
||||
|
||||
public init(variable: Resolvable) {
|
||||
self.variable = variable
|
||||
}
|
||||
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
var components = token.components()
|
||||
|
||||
public init(variable: String) {
|
||||
self.variable = Variable(variable)
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
let result = try variable.resolve(context)
|
||||
|
||||
if let result = result as? String {
|
||||
return result
|
||||
} else if let result = result as? CustomStringConvertible {
|
||||
return result.description
|
||||
} else if let result = result as? NSObject {
|
||||
return result.description
|
||||
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||
return components.count > (index + 1) && components[index] == token
|
||||
}
|
||||
|
||||
return ""
|
||||
let condition: Expression?
|
||||
let elseExpression: Resolvable?
|
||||
|
||||
if hasToken("if", at: 1) {
|
||||
let components = components.suffix(from: 2)
|
||||
if let elseIndex = components.index(of: "else") {
|
||||
condition = try parseExpression(components: Array(components.prefix(upTo: elseIndex)), tokenParser: parser, token: token)
|
||||
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
|
||||
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
|
||||
} else {
|
||||
condition = try parseExpression(components: Array(components), tokenParser: parser, token: token)
|
||||
elseExpression = nil
|
||||
}
|
||||
} else {
|
||||
condition = nil
|
||||
elseExpression = nil
|
||||
}
|
||||
|
||||
let filter = try parser.compileResolvable(components[0], containedIn: token)
|
||||
return VariableNode(variable: filter, token: token, condition: condition, elseExpression: elseExpression)
|
||||
}
|
||||
|
||||
public init(variable: Resolvable, token: Token? = nil) {
|
||||
self.variable = variable
|
||||
self.token = token
|
||||
self.condition = nil
|
||||
self.elseExpression = nil
|
||||
}
|
||||
|
||||
init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
|
||||
self.variable = variable
|
||||
self.token = token
|
||||
self.condition = condition
|
||||
self.elseExpression = elseExpression
|
||||
}
|
||||
|
||||
public init(variable: String, token: Token? = nil) {
|
||||
self.variable = Variable(variable)
|
||||
self.token = token
|
||||
self.condition = nil
|
||||
self.elseExpression = nil
|
||||
}
|
||||
|
||||
public func render(_ context: Context) throws -> String {
|
||||
if let condition = self.condition, try condition.evaluate(context: context) == false {
|
||||
return try elseExpression?.resolve(context).map(stringify) ?? ""
|
||||
}
|
||||
|
||||
let result = try variable.resolve(context)
|
||||
return stringify(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stringify(_ result: Any?) -> String {
|
||||
if let result = result as? String {
|
||||
return result
|
||||
} else if let array = result as? [Any?] {
|
||||
return unwrap(array).description
|
||||
} else if let result = result as? CustomStringConvertible {
|
||||
return result.description
|
||||
} else if let result = result as? NSObject {
|
||||
return result.description
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func unwrap(_ array: [Any?]) -> [Any] {
|
||||
return array.map { (item: Any?) -> Any in
|
||||
if let item = item {
|
||||
if let items = item as? [Any?] {
|
||||
return unwrap(items)
|
||||
} else {
|
||||
return item
|
||||
}
|
||||
}
|
||||
else { return item as Any }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
open class NowNode : NodeType {
|
||||
open let format:Variable
|
||||
class NowNode : NodeType {
|
||||
let format:Variable
|
||||
let token: Token?
|
||||
|
||||
open class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
||||
var format:Variable?
|
||||
|
||||
let components = token.components()
|
||||
guard components.count <= 2 else {
|
||||
throw TemplateSyntaxError("'now' tags may only have one argument: the format string `\(token.contents)`.")
|
||||
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
|
||||
}
|
||||
if components.count == 2 {
|
||||
format = Variable(components[1])
|
||||
}
|
||||
|
||||
return NowNode(format:format)
|
||||
return NowNode(format:format, token: token)
|
||||
}
|
||||
|
||||
public init(format:Variable?) {
|
||||
init(format:Variable?, token: Token? = nil) {
|
||||
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
|
||||
self.token = token
|
||||
}
|
||||
|
||||
open func render(_ context: Context) throws -> String {
|
||||
func render(_ context: Context) throws -> String {
|
||||
let date = Date()
|
||||
let format = try self.format.resolve(context)
|
||||
var formatter:DateFormatter?
|
||||
|
||||
@@ -12,49 +12,48 @@ public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
|
||||
}
|
||||
}
|
||||
|
||||
public typealias Filter = (Any?) throws -> Any?
|
||||
|
||||
/// A class for parsing an array of tokens and converts them into a collection of Node's
|
||||
open class TokenParser {
|
||||
public class TokenParser {
|
||||
public typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||
|
||||
fileprivate var tokens: [Token]
|
||||
fileprivate let namespace: Namespace
|
||||
fileprivate let environment: Environment
|
||||
|
||||
public init(tokens: [Token], namespace: Namespace) {
|
||||
public init(tokens: [Token], environment: Environment) {
|
||||
self.tokens = tokens
|
||||
self.namespace = namespace
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Parse the given tokens into nodes
|
||||
open func parse() throws -> [NodeType] {
|
||||
public func parse() throws -> [NodeType] {
|
||||
return try parse(nil)
|
||||
}
|
||||
|
||||
open func parse(_ parse_until:((_ parser:TokenParser, _ token:Token) -> (Bool))?) throws -> [NodeType] {
|
||||
public func parse(_ parse_until:((_ parser:TokenParser, _ token:Token) -> (Bool))?) throws -> [NodeType] {
|
||||
var nodes = [NodeType]()
|
||||
|
||||
while tokens.count > 0 {
|
||||
let token = nextToken()!
|
||||
|
||||
switch token {
|
||||
case .text(let text):
|
||||
case .text(let text, _):
|
||||
nodes.append(TextNode(text: text))
|
||||
case .variable:
|
||||
nodes.append(VariableNode(variable: try compileFilter(token.contents)))
|
||||
try nodes.append(VariableNode.parse(self, token: token))
|
||||
case .block:
|
||||
let tag = token.components().first
|
||||
|
||||
if let parse_until = parse_until , parse_until(self, token) {
|
||||
prependToken(token)
|
||||
return nodes
|
||||
}
|
||||
|
||||
if let tag = tag {
|
||||
if let parser = namespace.tags[tag] {
|
||||
nodes.append(try parser(self, token))
|
||||
} else {
|
||||
throw TemplateSyntaxError("Unknown template tag '\(tag)'")
|
||||
if let tag = token.components().first {
|
||||
do {
|
||||
let parser = try findTag(name: tag)
|
||||
let node = try parser(self, token)
|
||||
nodes.append(node)
|
||||
} catch {
|
||||
throw error.withToken(token)
|
||||
}
|
||||
}
|
||||
case .comment:
|
||||
@@ -65,7 +64,7 @@ open class TokenParser {
|
||||
return nodes
|
||||
}
|
||||
|
||||
open func nextToken() -> Token? {
|
||||
public func nextToken() -> Token? {
|
||||
if tokens.count > 0 {
|
||||
return tokens.remove(at: 0)
|
||||
}
|
||||
@@ -73,19 +72,127 @@ open class TokenParser {
|
||||
return nil
|
||||
}
|
||||
|
||||
open func prependToken(_ token:Token) {
|
||||
public func prependToken(_ token:Token) {
|
||||
tokens.insert(token, at: 0)
|
||||
}
|
||||
|
||||
open func findFilter(_ name: String) throws -> Filter {
|
||||
if let filter = namespace.filters[name] {
|
||||
return filter
|
||||
func findTag(name: String) throws -> Extension.TagParser {
|
||||
for ext in environment.extensions {
|
||||
if let filter = ext.tags[name] {
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateSyntaxError("Invalid filter '\(name)'")
|
||||
throw TemplateSyntaxError("Unknown template tag '\(name)'")
|
||||
}
|
||||
|
||||
func compileFilter(_ token: String) throws -> Resolvable {
|
||||
func findFilter(_ name: String) throws -> FilterType {
|
||||
for ext in environment.extensions {
|
||||
if let filter = ext.filters[name] {
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
let suggestedFilters = self.suggestedFilters(for: name)
|
||||
if suggestedFilters.isEmpty {
|
||||
throw TemplateSyntaxError("Unknown filter '\(name)'.")
|
||||
} else {
|
||||
throw TemplateSyntaxError("""
|
||||
Unknown filter '\(name)'. \
|
||||
Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", ")).
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
private func suggestedFilters(for name: String) -> [String] {
|
||||
let allFilters = environment.extensions.flatMap({ $0.filters.keys })
|
||||
|
||||
let filtersWithDistance = allFilters
|
||||
.map({ (filterName: $0, distance: $0.levenshteinDistance(name)) })
|
||||
// do not suggest filters which names are shorter than the distance
|
||||
.filter({ $0.filterName.count > $0.distance })
|
||||
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
|
||||
return []
|
||||
}
|
||||
// suggest all filters with the same distance
|
||||
return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName })
|
||||
}
|
||||
|
||||
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||
do {
|
||||
return try FilterExpression(token: filterToken, parser: self)
|
||||
} catch {
|
||||
guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else {
|
||||
throw error
|
||||
}
|
||||
// find offset of filter in the containing token so that only filter is highligted, not the whole token
|
||||
if let filterTokenRange = containingToken.contents.range(of: filterToken) {
|
||||
var location = containingToken.sourceMap.location
|
||||
location.lineOffset += containingToken.contents.distance(from: containingToken.contents.startIndex, to: filterTokenRange.lowerBound)
|
||||
syntaxError.token = .variable(value: filterToken, at: SourceMap(filename: containingToken.sourceMap.filename, location: location))
|
||||
} else {
|
||||
syntaxError.token = containingToken
|
||||
}
|
||||
throw syntaxError
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "Use compileFilter(_:containedIn:)")
|
||||
public func compileFilter(_ token: String) throws -> Resolvable {
|
||||
return try FilterExpression(token: token, parser: self)
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "Use compileResolvable(_:containedIn:)")
|
||||
public func compileResolvable(_ token: String) throws -> Resolvable {
|
||||
return try RangeVariable(token, parser: self)
|
||||
?? compileFilter(token)
|
||||
}
|
||||
|
||||
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||
return try RangeVariable(token, parser: self, containedIn: containingToken)
|
||||
?? compileFilter(token, containedIn: containingToken)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
||||
extension String {
|
||||
|
||||
subscript(_ i: Int) -> Character {
|
||||
return self[self.index(self.startIndex, offsetBy: i)]
|
||||
}
|
||||
|
||||
func levenshteinDistance(_ target: String) -> Int {
|
||||
// create two work vectors of integer distances
|
||||
var last, current: [Int]
|
||||
|
||||
// initialize v0 (the previous row of distances)
|
||||
// this row is A[0][i]: edit distance for an empty s
|
||||
// the distance is just the number of characters to delete from t
|
||||
last = [Int](0...target.count)
|
||||
current = [Int](repeating: 0, count: target.count + 1)
|
||||
|
||||
for i in 0..<self.count {
|
||||
// calculate v1 (current row distances) from the previous row v0
|
||||
|
||||
// first element of v1 is A[i+1][0]
|
||||
// edit distance is delete (i+1) chars from s to match empty t
|
||||
current[0] = i + 1
|
||||
|
||||
// use formula to fill in the rest of the row
|
||||
for j in 0..<target.count {
|
||||
current[j+1] = Swift.min(
|
||||
last[j+1] + 1,
|
||||
current[j] + 1,
|
||||
last[j] + (self[i] == target[j] ? 0 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
// copy v1 (current row) to v0 (previous row) for next iteration
|
||||
last = current
|
||||
}
|
||||
|
||||
return current[target.count]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,10 +6,26 @@ let NSFileNoSuchFileError = 4
|
||||
#endif
|
||||
|
||||
/// A class representing a template
|
||||
open class Template {
|
||||
open class Template: ExpressibleByStringLiteral {
|
||||
let templateString: String
|
||||
internal(set) var 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 required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
|
||||
self.environment = environment ?? Environment()
|
||||
self.name = name
|
||||
self.templateString = templateString
|
||||
|
||||
let lexer = Lexer(templateName: name, templateString: templateString)
|
||||
tokens = lexer.tokenize()
|
||||
}
|
||||
|
||||
/// Create a template with the given name inside the given bundle
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
public convenience init(named:String, inBundle bundle:Bundle? = nil) throws {
|
||||
let useBundle = bundle ?? Bundle.main
|
||||
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
|
||||
@@ -20,26 +36,44 @@ open class Template {
|
||||
}
|
||||
|
||||
/// Create a template with a file found at the given URL
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
public convenience init(URL:Foundation.URL) throws {
|
||||
try self.init(path: Path(URL.path))
|
||||
}
|
||||
|
||||
/// Create a template with a file found at the given path
|
||||
public convenience init(path:Path) throws {
|
||||
self.init(templateString: try path.read())
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
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
|
||||
public init(templateString:String) {
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
tokens = lexer.tokenize()
|
||||
// MARK: ExpressibleByStringLiteral
|
||||
|
||||
// Create a templaVte with a template string literal
|
||||
public convenience required init(stringLiteral value: String) {
|
||||
self.init(templateString: value)
|
||||
}
|
||||
|
||||
/// Render the given template
|
||||
open func render(_ context: Context? = nil) throws -> String {
|
||||
let context = context ?? Context()
|
||||
let parser = TokenParser(tokens: tokens, namespace: context.namespace)
|
||||
// Create a template with a template string literal
|
||||
public convenience required init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
||||
self.init(stringLiteral: value)
|
||||
}
|
||||
|
||||
// Create a template with a template string literal
|
||||
public convenience required init(unicodeScalarLiteral value: StringLiteralType) {
|
||||
self.init(stringLiteral: value)
|
||||
}
|
||||
|
||||
/// Render the given template with a context
|
||||
func render(_ context: Context) throws -> String {
|
||||
let context = context
|
||||
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
||||
let nodes = try parser.parse()
|
||||
return try renderNodes(nodes, context)
|
||||
}
|
||||
|
||||
/// Render the given template
|
||||
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||
return try render(Context(dictionary: dictionary, environment: environment))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import Foundation
|
||||
import PathKit
|
||||
|
||||
|
||||
// A class for loading a template from disk
|
||||
open class TemplateLoader {
|
||||
open let paths: [Path]
|
||||
|
||||
public init(paths: [Path]) {
|
||||
self.paths = paths
|
||||
}
|
||||
|
||||
public init(bundle: [Bundle]) {
|
||||
self.paths = bundle.map {
|
||||
return Path($0.bundlePath)
|
||||
}
|
||||
}
|
||||
|
||||
open func loadTemplate(_ templateName: String) -> Template? {
|
||||
return loadTemplate([templateName])
|
||||
}
|
||||
|
||||
open func loadTemplate(_ templateNames: [String]) -> Template? {
|
||||
for path in paths {
|
||||
for templateName in templateNames {
|
||||
let templatePath = path + Path(templateName)
|
||||
|
||||
if templatePath.exists {
|
||||
if let template = try? Template(path: templatePath) {
|
||||
return template
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,117 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
/// Split a string by spaces leaving quoted phrases together
|
||||
func smartSplit(_ value: String) -> [String] {
|
||||
var word = ""
|
||||
var separator: Character = " "
|
||||
var components: [String] = []
|
||||
extension String {
|
||||
/// Split a string by a separator leaving quoted phrases together
|
||||
func smartSplit(separator: Character = " ") -> [String] {
|
||||
var word = ""
|
||||
var components: [String] = []
|
||||
var separate: Character = separator
|
||||
var singleQuoteCount = 0
|
||||
var doubleQuoteCount = 0
|
||||
|
||||
for character in value.characters {
|
||||
if character == separator {
|
||||
if separator != " " {
|
||||
word.append(separator)
|
||||
}
|
||||
|
||||
if !word.isEmpty {
|
||||
let specialCharacters = ",|:"
|
||||
func appendWord(_ word: String) {
|
||||
if components.count > 0 {
|
||||
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
|
||||
components[components.count-1] += word
|
||||
} else if specialCharacters.contains(word) {
|
||||
components[components.count-1] += word
|
||||
} else {
|
||||
components.append(word)
|
||||
}
|
||||
} else {
|
||||
components.append(word)
|
||||
word = ""
|
||||
}
|
||||
|
||||
separator = " "
|
||||
} else {
|
||||
if separator == " " && (character == "'" || character == "\"") {
|
||||
separator = character
|
||||
}
|
||||
word.append(character)
|
||||
}
|
||||
}
|
||||
|
||||
if !word.isEmpty {
|
||||
components.append(word)
|
||||
}
|
||||
for character in self {
|
||||
if character == "'" { singleQuoteCount += 1 }
|
||||
else if character == "\"" { doubleQuoteCount += 1 }
|
||||
|
||||
return components
|
||||
if character == separate {
|
||||
|
||||
if separate != separator {
|
||||
word.append(separate)
|
||||
} else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
|
||||
appendWord(word)
|
||||
word = ""
|
||||
}
|
||||
|
||||
separate = separator
|
||||
} else {
|
||||
if separate == separator && (character == "'" || character == "\"") {
|
||||
separate = character
|
||||
}
|
||||
word.append(character)
|
||||
}
|
||||
}
|
||||
|
||||
if !word.isEmpty {
|
||||
appendWord(word)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
}
|
||||
|
||||
public struct SourceMap: Equatable {
|
||||
public let filename: String?
|
||||
public let location: ContentLocation
|
||||
|
||||
init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
|
||||
self.filename = filename
|
||||
self.location = location
|
||||
}
|
||||
|
||||
static let unknown = SourceMap()
|
||||
|
||||
public static func ==(lhs: SourceMap, rhs: SourceMap) -> Bool {
|
||||
return lhs.filename == rhs.filename && lhs.location == rhs.location
|
||||
}
|
||||
}
|
||||
|
||||
public enum Token : Equatable {
|
||||
/// A token representing a piece of text.
|
||||
case text(value: String)
|
||||
case text(value: String, at: SourceMap)
|
||||
|
||||
/// A token representing a variable.
|
||||
case variable(value: String)
|
||||
case variable(value: String, at: SourceMap)
|
||||
|
||||
/// A token representing a comment.
|
||||
case comment(value: String)
|
||||
case comment(value: String, at: SourceMap)
|
||||
|
||||
/// A token representing a template block.
|
||||
case block(value: String)
|
||||
case block(value: String, at: SourceMap)
|
||||
|
||||
/// Returns the underlying value as an array seperated by spaces
|
||||
public func components() -> [String] {
|
||||
switch self {
|
||||
case .block(let value):
|
||||
return smartSplit(value)
|
||||
case .variable(let value):
|
||||
return smartSplit(value)
|
||||
case .text(let value):
|
||||
return smartSplit(value)
|
||||
case .comment(let value):
|
||||
return smartSplit(value)
|
||||
case .block(let value, _),
|
||||
.variable(let value, _),
|
||||
.text(let value, _),
|
||||
.comment(let value, _):
|
||||
return value.smartSplit()
|
||||
}
|
||||
}
|
||||
|
||||
public var contents: String {
|
||||
switch self {
|
||||
case .block(let value):
|
||||
return value
|
||||
case .variable(let value):
|
||||
return value
|
||||
case .text(let value):
|
||||
return value
|
||||
case .comment(let value):
|
||||
case .block(let value, _),
|
||||
.variable(let value, _),
|
||||
.text(let value, _),
|
||||
.comment(let value, _):
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func == (lhs: Token, rhs: Token) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.text(let lhsValue), .text(let rhsValue)):
|
||||
return lhsValue == rhsValue
|
||||
case (.variable(let lhsValue), .variable(let rhsValue)):
|
||||
return lhsValue == rhsValue
|
||||
case (.block(let lhsValue), .block(let rhsValue)):
|
||||
return lhsValue == rhsValue
|
||||
case (.comment(let lhsValue), .comment(let rhsValue)):
|
||||
return lhsValue == rhsValue
|
||||
default:
|
||||
return false
|
||||
public var sourceMap: SourceMap {
|
||||
switch self {
|
||||
case .block(_, let sourceMap),
|
||||
.variable(_, let sourceMap),
|
||||
.text(_, let sourceMap),
|
||||
.comment(_, let sourceMap):
|
||||
return sourceMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
typealias Number = Float
|
||||
|
||||
|
||||
class FilterExpression : Resolvable {
|
||||
let filters: [Filter]
|
||||
let filters: [(FilterType, [Variable])]
|
||||
let variable: Variable
|
||||
|
||||
init(token: String, parser: TokenParser) throws {
|
||||
let bits = token.characters.split(separator: "|").map({ String($0).trim(character: " ") })
|
||||
let bits = token.split(separator: "|").map({ String($0).trim(character: " ") })
|
||||
if bits.isEmpty {
|
||||
filters = []
|
||||
variable = Variable("")
|
||||
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
||||
}
|
||||
|
||||
@@ -17,7 +18,11 @@ class FilterExpression : Resolvable {
|
||||
let filterBits = bits[bits.indices.suffix(from: 1)]
|
||||
|
||||
do {
|
||||
filters = try filterBits.map { try parser.findFilter($0) }
|
||||
filters = try filterBits.map {
|
||||
let (name, arguments) = parseFilterComponents(token: $0)
|
||||
let filter = try parser.findFilter(name)
|
||||
return (filter, arguments)
|
||||
}
|
||||
} catch {
|
||||
filters = []
|
||||
throw error
|
||||
@@ -28,7 +33,8 @@ class FilterExpression : Resolvable {
|
||||
let result = try variable.resolve(context)
|
||||
|
||||
return try filters.reduce(result) { x, y in
|
||||
return try y(x)
|
||||
let arguments = try y.1.map { try $0.resolve(context) }
|
||||
return try y.0.invoke(value: x, arguments: arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,8 +48,10 @@ public struct Variable : Equatable, Resolvable {
|
||||
self.variable = variable
|
||||
}
|
||||
|
||||
fileprivate func lookup() -> [String] {
|
||||
return variable.characters.split(separator: ".").map(String.init)
|
||||
// Split the lookup string and resolve references if possible
|
||||
fileprivate func lookup(_ context: Context) throws -> [String] {
|
||||
let keyPath = KeyPath(variable, in: context)
|
||||
return try keyPath.parse()
|
||||
}
|
||||
|
||||
/// Resolve the variable in the given context
|
||||
@@ -52,43 +60,127 @@ public struct Variable : Equatable, Resolvable {
|
||||
|
||||
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
|
||||
// String literal
|
||||
return variable[variable.characters.index(after: variable.startIndex) ..< variable.characters.index(before: variable.endIndex)]
|
||||
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
|
||||
}
|
||||
|
||||
for bit in lookup() {
|
||||
// Number literal
|
||||
if let int = Int(variable) {
|
||||
return int
|
||||
}
|
||||
if let number = Number(variable) {
|
||||
return number
|
||||
}
|
||||
// Boolean literal
|
||||
if let bool = Bool(variable) {
|
||||
return bool
|
||||
}
|
||||
|
||||
for bit in try lookup(context) {
|
||||
current = normalize(current)
|
||||
|
||||
if let context = current as? Context {
|
||||
current = context[bit]
|
||||
} else if let dictionary = current as? [String: Any] {
|
||||
current = dictionary[bit]
|
||||
} else if let array = current as? [Any] {
|
||||
if let index = Int(bit) {
|
||||
current = array[index]
|
||||
} else if bit == "first" {
|
||||
current = array.first
|
||||
} else if bit == "last" {
|
||||
current = array.last
|
||||
} else if bit == "count" {
|
||||
current = array.count
|
||||
if bit == "count" {
|
||||
current = dictionary.count
|
||||
} else {
|
||||
current = dictionary[bit]
|
||||
}
|
||||
} else if let array = current as? [Any] {
|
||||
current = resolveCollection(array, bit: bit)
|
||||
} else if let string = current as? String {
|
||||
current = resolveCollection(string, bit: bit)
|
||||
} else if let object = current as? NSObject { // NSKeyValueCoding
|
||||
#if os(Linux)
|
||||
return nil
|
||||
#else
|
||||
current = object.value(forKey: bit)
|
||||
#endif
|
||||
#if os(Linux)
|
||||
return nil
|
||||
#else
|
||||
if object.responds(to: Selector(bit)) {
|
||||
current = object.value(forKey: bit)
|
||||
}
|
||||
#endif
|
||||
} else if let value = current {
|
||||
current = Mirror(reflecting: value).getValue(for: bit)
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if let resolvable = current as? Resolvable {
|
||||
current = try resolvable.resolve(context)
|
||||
} else if let node = current as? NodeType {
|
||||
current = try node.render(context)
|
||||
}
|
||||
|
||||
return normalize(current)
|
||||
}
|
||||
}
|
||||
|
||||
public func ==(lhs: Variable, rhs: Variable) -> Bool {
|
||||
return lhs.variable == rhs.variable
|
||||
private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> Any? {
|
||||
if let index = Int(bit) {
|
||||
if index >= 0 && index < collection.count {
|
||||
return collection[collection.index(collection.startIndex, offsetBy: index)]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else if bit == "first" {
|
||||
return collection.first
|
||||
} else if bit == "last" {
|
||||
return collection[collection.index(collection.endIndex, offsetBy: -1)]
|
||||
} else if bit == "count" {
|
||||
return collection.count
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure used to represet range of two integer values expressed as `from...to`.
|
||||
/// Values should be numbers (they will be converted to integers).
|
||||
/// Rendering this variable produces array from range `from...to`.
|
||||
/// If `from` is more than `to` array will contain values of reversed range.
|
||||
public struct RangeVariable: Resolvable {
|
||||
public let from: Resolvable
|
||||
public let to: Resolvable
|
||||
|
||||
@available(*, deprecated, message: "Use init?(_:parser:containedIn:)")
|
||||
public init?(_ token: String, parser: TokenParser) throws {
|
||||
let components = token.components(separatedBy: "...")
|
||||
guard components.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.from = try parser.compileFilter(components[0])
|
||||
self.to = try parser.compileFilter(components[1])
|
||||
}
|
||||
|
||||
public init?(_ token: String, parser: TokenParser, containedIn containingToken: Token) throws {
|
||||
let components = token.components(separatedBy: "...")
|
||||
guard components.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.from = try parser.compileFilter(components[0], containedIn: containingToken)
|
||||
self.to = try parser.compileFilter(components[1], containedIn: containingToken)
|
||||
}
|
||||
|
||||
public func resolve(_ context: Context) throws -> Any? {
|
||||
let fromResolved = try from.resolve(context)
|
||||
let toResolved = try to.resolve(context)
|
||||
|
||||
guard let from = fromResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||
throw TemplateSyntaxError("'from' value is not an Integer (\(fromResolved ?? "nil"))")
|
||||
}
|
||||
|
||||
guard let to = toResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||
throw TemplateSyntaxError("'to' value is not an Integer (\(toResolved ?? "nil") )")
|
||||
}
|
||||
|
||||
let range = min(from, to)...max(from, to)
|
||||
return from > to ? Array(range.reversed()) : Array(range)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -131,3 +223,50 @@ extension Dictionary : Normalizable {
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
|
||||
func parseFilterComponents(token: String) -> (String, [Variable]) {
|
||||
var components = token.smartSplit(separator: ":")
|
||||
let name = components.removeFirst().trim(character: " ")
|
||||
let variables = components
|
||||
.joined(separator: ":")
|
||||
.smartSplit(separator: ",")
|
||||
.map { Variable($0.trim(character: " ")) }
|
||||
return (name, variables)
|
||||
}
|
||||
|
||||
extension Mirror {
|
||||
func getValue(for key: String) -> Any? {
|
||||
let result = descendant(key) ?? Int(key).flatMap({ descendant($0) })
|
||||
if result == nil {
|
||||
// go through inheritance chain to reach superclass properties
|
||||
return superclassMirror?.getValue(for: key)
|
||||
} else if let result = result {
|
||||
guard String(describing: result) != "nil" else {
|
||||
// mirror returns non-nil value even for nil-containing properties
|
||||
// so we have to check if its value is actually nil or not
|
||||
return nil
|
||||
}
|
||||
if let result = (result as? AnyOptional)?.wrapped {
|
||||
return result
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
protocol AnyOptional {
|
||||
var wrapped: Any? { get }
|
||||
}
|
||||
|
||||
extension Optional: AnyOptional {
|
||||
var wrapped: Any? {
|
||||
switch self {
|
||||
case let .some(value): return value
|
||||
case .none: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "Stencil",
|
||||
"version": "0.6.0",
|
||||
"version": "0.13.0",
|
||||
"summary": "Stencil is a simple and powerful template language for Swift.",
|
||||
"homepage": "https://github.com/kylef/Stencil",
|
||||
"homepage": "https://stencil.fuller.li",
|
||||
"license": {
|
||||
"type": "BSD",
|
||||
"file": "LICENSE"
|
||||
@@ -10,10 +10,10 @@
|
||||
"authors": {
|
||||
"Kyle Fuller": "kyle@fuller.li"
|
||||
},
|
||||
"social_media_url": "http://twitter.com/kylefuller",
|
||||
"social_media_url": "https://twitter.com/kylefuller",
|
||||
"source": {
|
||||
"git": "https://github.com/kylef/Stencil.git",
|
||||
"tag": "0.6.0"
|
||||
"git": "https://github.com/stencilproject/Stencil.git",
|
||||
"tag": "0.13.0"
|
||||
},
|
||||
"source_files": [
|
||||
"Sources/*.swift"
|
||||
@@ -23,8 +23,12 @@
|
||||
"osx": "10.9",
|
||||
"tvos": "9.0"
|
||||
},
|
||||
"cocoapods_version": ">= 1.4.0",
|
||||
"swift_version": "4.2",
|
||||
"requires_arc": true,
|
||||
"dependencies": {
|
||||
"PathKit": [ "~> 0.7.0" ]
|
||||
"PathKit": [
|
||||
"~> 0.9.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import XCTest
|
||||
|
||||
import StencilTests
|
||||
|
||||
stencilTests()
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += StencilTests.__allTests()
|
||||
|
||||
XCTMain(tests)
|
||||
|
||||
@@ -1,62 +1,85 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
|
||||
func testContext() {
|
||||
describe("Context") {
|
||||
var context: Context!
|
||||
class ContextTests: XCTestCase {
|
||||
|
||||
func testContext() {
|
||||
describe("Context") {
|
||||
var context: Context!
|
||||
|
||||
$0.before {
|
||||
context = Context(dictionary: ["name": "Kyle"])
|
||||
}
|
||||
$0.before {
|
||||
context = Context(dictionary: ["name": "Kyle"])
|
||||
}
|
||||
|
||||
$0.it("allows you to get a value via subscripting") {
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to set a value via subscripting") {
|
||||
context["name"] = "Katie"
|
||||
|
||||
try expect(context["name"] as? String) == "Katie"
|
||||
}
|
||||
|
||||
$0.it("allows you to remove a value via subscripting") {
|
||||
context["name"] = nil
|
||||
|
||||
try expect(context["name"]).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("allows you to retrieve a value from a parent") {
|
||||
try context.push {
|
||||
$0.it("allows you to get a value via subscripting") {
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("allows you to override a parent's value") {
|
||||
try context.push {
|
||||
$0.it("allows you to set a value via subscripting") {
|
||||
context["name"] = "Katie"
|
||||
try expect(context["name"] as? String) == "Katie"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("allows you to pop to restore previous state") {
|
||||
context.push {
|
||||
context["name"] = "Katie"
|
||||
}
|
||||
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to push a dictionary and run a closure then restoring previous state") {
|
||||
var didRun = false
|
||||
|
||||
try context.push(dictionary: ["name": "Katie"]) {
|
||||
didRun = true
|
||||
try expect(context["name"] as? String) == "Katie"
|
||||
}
|
||||
|
||||
try expect(didRun).to.beTrue()
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
$0.it("allows you to remove a value via subscripting") {
|
||||
context["name"] = nil
|
||||
|
||||
try expect(context["name"]).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("allows you to retrieve a value from a parent") {
|
||||
try context.push {
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("allows you to override a parent's value") {
|
||||
try context.push {
|
||||
context["name"] = "Katie"
|
||||
try expect(context["name"] as? String) == "Katie"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("allows you to pop to restore previous state") {
|
||||
context.push {
|
||||
context["name"] = "Katie"
|
||||
}
|
||||
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to remove a parent's value in a level") {
|
||||
try context.push {
|
||||
context["name"] = nil
|
||||
try expect(context["name"]).to.beNil()
|
||||
}
|
||||
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to push a dictionary and run a closure then restoring previous state") {
|
||||
var didRun = false
|
||||
|
||||
try context.push(dictionary: ["name": "Katie"]) {
|
||||
didRun = true
|
||||
try expect(context["name"] as? String) == "Katie"
|
||||
}
|
||||
|
||||
try expect(didRun).to.beTrue()
|
||||
try expect(context["name"] as? String) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to flatten the context contents") {
|
||||
try context.push(dictionary: ["test": "abc"]) {
|
||||
let flattened = context.flatten()
|
||||
|
||||
try expect(flattened.count) == 2
|
||||
try expect(flattened["name"] as? String) == "Kyle"
|
||||
try expect(flattened["test"] as? String) == "abc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
374
Tests/StencilTests/EnvironmentSpec.swift
Normal file
374
Tests/StencilTests/EnvironmentSpec.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import PathKit
|
||||
@testable import Stencil
|
||||
|
||||
class EnvironmentTests: XCTestCase {
|
||||
func testEnvironment() {
|
||||
describe("Environment") {
|
||||
var environment: Environment!
|
||||
var template: Template!
|
||||
|
||||
$0.before {
|
||||
environment = Environment(loader: ExampleLoader())
|
||||
template = nil
|
||||
}
|
||||
|
||||
$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!"
|
||||
}
|
||||
|
||||
$0.it("allows you to provide a custom template class") {
|
||||
let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
|
||||
let result = try environment.renderTemplate(string: "Hello World")
|
||||
|
||||
try expect(result) == "here"
|
||||
}
|
||||
|
||||
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
|
||||
guard let range = template.templateString.range(of: token) else {
|
||||
fatalError("Can't find '\(token)' in '\(template)'")
|
||||
}
|
||||
let lexer = Lexer(templateString: template.templateString)
|
||||
let location = lexer.rangeLocation(range)
|
||||
let sourceMap = SourceMap(filename: template.name, location: location)
|
||||
let token = Token.block(value: token, at: sourceMap)
|
||||
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
|
||||
}
|
||||
|
||||
func expectError(reason: String, token: String,
|
||||
file: String = #file, line: Int = #line, function: String = #function) throws {
|
||||
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
|
||||
|
||||
let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
|
||||
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
|
||||
let reporter = SimpleErrorReporter()
|
||||
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
|
||||
}
|
||||
|
||||
$0.context("given syntax error") {
|
||||
|
||||
$0.it("reports syntax error on invalid for tag syntax") {
|
||||
template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
|
||||
try expectError(reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", token: "for name in")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error on missing endfor") {
|
||||
template = "{% for name in names %}{{ name }}"
|
||||
try expectError(reason: "`endfor` was not found.", token: "for name in names")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error on unknown tag") {
|
||||
template = "{% for name in names %}{{ name }}{% end %}"
|
||||
try expectError(reason: "Unknown template tag 'end'", token: "end")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$0.context("given unknown filter") {
|
||||
|
||||
$0.it("reports syntax error in for tag") {
|
||||
template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "names|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in for-where tag") {
|
||||
template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in if tag") {
|
||||
template = "{% if name|unknown %}{{ name }}{% endif %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in elif tag") {
|
||||
template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in ifnot tag") {
|
||||
template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in filter tag") {
|
||||
template = "{% filter unknown %}Text{% endfilter %}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "filter unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in variable tag") {
|
||||
template = "{{ name|unknown }}"
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$0.context("given rendering error") {
|
||||
|
||||
$0.it("reports rendering error in variable filter") {
|
||||
let filterExtension = Extension()
|
||||
filterExtension.registerFilter("throw") { (value: Any?) in
|
||||
throw TemplateSyntaxError("filter error")
|
||||
}
|
||||
environment.extensions += [filterExtension]
|
||||
|
||||
template = Template(templateString: "{{ name|throw }}", environment: environment)
|
||||
try expectError(reason: "filter error", token: "name|throw")
|
||||
}
|
||||
|
||||
$0.it("reports rendering error in filter tag") {
|
||||
let filterExtension = Extension()
|
||||
filterExtension.registerFilter("throw") { (value: Any?) in
|
||||
throw TemplateSyntaxError("filter error")
|
||||
}
|
||||
environment.extensions += [filterExtension]
|
||||
|
||||
template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: environment)
|
||||
try expectError(reason: "filter error", token: "filter throw")
|
||||
}
|
||||
|
||||
$0.it("reports rendering error in simple tag") {
|
||||
let tagExtension = Extension()
|
||||
tagExtension.registerSimpleTag("simpletag") { context in
|
||||
throw TemplateSyntaxError("simpletag error")
|
||||
}
|
||||
environment.extensions += [tagExtension]
|
||||
|
||||
template = Template(templateString: "{% simpletag %}", environment: environment)
|
||||
try expectError(reason: "simpletag error", token: "simpletag")
|
||||
}
|
||||
|
||||
$0.it("reporsts passing argument to simple filter") {
|
||||
template = "{{ name|uppercase:5 }}"
|
||||
try expectError(reason: "cannot invoke filter with an argument", token: "name|uppercase:5")
|
||||
}
|
||||
|
||||
$0.it("reports rendering error in custom tag") {
|
||||
let tagExtension = Extension()
|
||||
tagExtension.registerTag("customtag") { parser, token in
|
||||
return ErrorNode(token: token)
|
||||
}
|
||||
environment.extensions += [tagExtension]
|
||||
|
||||
template = Template(templateString: "{% customtag %}", environment: environment)
|
||||
try expectError(reason: "Custom Error", token: "customtag")
|
||||
}
|
||||
|
||||
$0.it("reports rendering error in for body") {
|
||||
let tagExtension = Extension()
|
||||
tagExtension.registerTag("customtag") { parser, token in
|
||||
return ErrorNode(token: token)
|
||||
}
|
||||
environment.extensions += [tagExtension]
|
||||
|
||||
template = Template(templateString: "{% for name in names %}{% customtag %}{% endfor %}", environment: environment)
|
||||
try expectError(reason: "Custom Error", token: "customtag")
|
||||
}
|
||||
|
||||
$0.it("reports rendering error in block") {
|
||||
let tagExtension = Extension()
|
||||
tagExtension.registerTag("customtag") { parser, token in
|
||||
return ErrorNode(token: token)
|
||||
}
|
||||
environment.extensions += [tagExtension]
|
||||
|
||||
template = Template(templateString: "{% block some %}{% customtag %}{% endblock %}", environment: environment)
|
||||
try expectError(reason: "Custom Error", token: "customtag")
|
||||
}
|
||||
}
|
||||
|
||||
$0.context("given included template") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = FileSystemLoader(paths: [path])
|
||||
var environment = Environment(loader: loader)
|
||||
var template: Template!
|
||||
var includedTemplate: Template!
|
||||
|
||||
$0.before {
|
||||
environment = Environment(loader: loader)
|
||||
template = nil
|
||||
includedTemplate = nil
|
||||
}
|
||||
|
||||
func expectError(reason: String, token: String, includedToken: String,
|
||||
file: String = #file, line: Int = #line, function: String = #function) throws {
|
||||
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
|
||||
expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!]
|
||||
|
||||
let error = try expect(environment.render(template: template, context: ["target": "World"]),
|
||||
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
|
||||
let reporter = SimpleErrorReporter()
|
||||
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in included template") {
|
||||
template = Template(templateString: """
|
||||
{% include "invalid-include.html" %}
|
||||
""", environment: environment)
|
||||
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
|
||||
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||
token: """
|
||||
include "invalid-include.html"
|
||||
""",
|
||||
includedToken: "target|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports runtime error in included template") {
|
||||
let filterExtension = Extension()
|
||||
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
|
||||
throw TemplateSyntaxError("filter error")
|
||||
})
|
||||
environment.extensions += [filterExtension]
|
||||
|
||||
template = Template(templateString: """
|
||||
{% include "invalid-include.html" %}
|
||||
""", environment: environment)
|
||||
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
|
||||
|
||||
try expectError(reason: "filter error",
|
||||
token: "include \"invalid-include.html\"",
|
||||
includedToken: "target|unknown")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$0.context("given base and child templates") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = FileSystemLoader(paths: [path])
|
||||
var environment: Environment!
|
||||
var childTemplate: Template!
|
||||
var baseTemplate: Template!
|
||||
|
||||
$0.before {
|
||||
environment = Environment(loader: loader)
|
||||
childTemplate = nil
|
||||
baseTemplate = nil
|
||||
}
|
||||
|
||||
func expectError(reason: String, childToken: String, baseToken: String?,
|
||||
file: String = #file, line: Int = #line, function: String = #function) throws {
|
||||
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
|
||||
if let baseToken = baseToken {
|
||||
expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!]
|
||||
}
|
||||
let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]),
|
||||
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
|
||||
let reporter = SimpleErrorReporter()
|
||||
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in base template") {
|
||||
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
|
||||
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
|
||||
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||
childToken: "extends \"invalid-base.html\"",
|
||||
baseToken: "target|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports runtime error in base template") {
|
||||
let filterExtension = Extension()
|
||||
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
|
||||
throw TemplateSyntaxError("filter error")
|
||||
})
|
||||
environment.extensions += [filterExtension]
|
||||
|
||||
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
|
||||
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
|
||||
|
||||
try expectError(reason: "filter error",
|
||||
childToken: "block.super",
|
||||
baseToken: "target|unknown")
|
||||
}
|
||||
|
||||
$0.it("reports syntax error in child template") {
|
||||
childTemplate = Template(templateString: """
|
||||
{% extends "base.html" %}
|
||||
{% block body %}Child {{ target|unknown }}{% endblock %}
|
||||
""", environment: environment, name: nil)
|
||||
|
||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||
childToken: "target|unknown",
|
||||
baseToken: nil)
|
||||
}
|
||||
|
||||
$0.it("reports runtime error in child template") {
|
||||
let filterExtension = Extension()
|
||||
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
|
||||
throw TemplateSyntaxError("filter error")
|
||||
})
|
||||
environment.extensions += [filterExtension]
|
||||
|
||||
childTemplate = Template(templateString: """
|
||||
{% extends "base.html" %}
|
||||
{% block body %}Child {{ target|unknown }}{% endblock %}
|
||||
""", environment: environment, name: nil)
|
||||
|
||||
try expectError(reason: "filter error",
|
||||
childToken: "target|unknown",
|
||||
baseToken: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Expectation {
|
||||
@discardableResult
|
||||
func toThrow<T: Error>() throws -> T {
|
||||
var thrownError: Error? = nil
|
||||
|
||||
do {
|
||||
_ = try expression()
|
||||
} catch {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
if let thrownError = thrownError {
|
||||
if let thrownError = thrownError as? T {
|
||||
return thrownError
|
||||
} else {
|
||||
throw failure("\(thrownError) is not \(T.self)")
|
||||
}
|
||||
} else {
|
||||
throw failure("expression did not throw an error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CustomTemplate: Template {
|
||||
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||
return "here"
|
||||
}
|
||||
}
|
||||
346
Tests/StencilTests/ExpressionSpec.swift
Normal file
346
Tests/StencilTests/ExpressionSpec.swift
Normal file
@@ -0,0 +1,346 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
@testable import Stencil
|
||||
|
||||
class ExpressionsTests: XCTestCase {
|
||||
func testExpressions() {
|
||||
describe("Expression") {
|
||||
let parser = TokenParser(tokens: [], environment: Environment())
|
||||
|
||||
func parseExpression(components: [String]) throws -> Expression {
|
||||
let parser = try IfExpressionParser.parser(components: components, tokenParser: parser, token: .text(value: "", at: .unknown))
|
||||
return try parser.parse()
|
||||
}
|
||||
|
||||
$0.describe("VariableExpression") {
|
||||
let expression = VariableExpression(variable: Variable("value"))
|
||||
|
||||
$0.it("evaluates to true when value is not nil") {
|
||||
let context = Context(dictionary: ["value": "known"])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when value is unset") {
|
||||
let context = Context()
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true when array variable is not empty") {
|
||||
let items: [[String: Any]] = [["key":"key1","value":42],["key":"key2","value":1337]]
|
||||
let context = Context(dictionary: ["value": [items]])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when array value is empty") {
|
||||
let emptyItems = [[String: Any]]()
|
||||
let context = Context(dictionary: ["value": emptyItems])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when dictionary value is empty") {
|
||||
let emptyItems = [String:Any]()
|
||||
let context = Context(dictionary: ["value": emptyItems])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when Array<Any> value is empty") {
|
||||
let context = Context(dictionary: ["value": ([] as [Any])])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true when integer value is above 0") {
|
||||
let context = Context(dictionary: ["value": 1])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with string") {
|
||||
let context = Context(dictionary: ["value": "test"])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when empty string") {
|
||||
let context = Context(dictionary: ["value": ""])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when integer value is below 0 or below") {
|
||||
let context = Context(dictionary: ["value": 0])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
|
||||
let negativeContext = Context(dictionary: ["value": 0])
|
||||
try expect(try expression.evaluate(context: negativeContext)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true when float value is above 0") {
|
||||
let context = Context(dictionary: ["value": Float(0.5)])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when float is 0 or below") {
|
||||
let context = Context(dictionary: ["value": Float(0)])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true when double value is above 0") {
|
||||
let context = Context(dictionary: ["value": Double(0.5)])
|
||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when double is 0 or below") {
|
||||
let context = Context(dictionary: ["value": Double(0)])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when uint is 0") {
|
||||
let context = Context(dictionary: ["value": UInt(0)])
|
||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("NotExpression") {
|
||||
$0.it("returns truthy for positive expressions") {
|
||||
let expression = NotExpression(expression: StaticExpression(value: true))
|
||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("returns falsy for negative expressions") {
|
||||
let expression = NotExpression(expression: StaticExpression(value: false))
|
||||
try expect(expression.evaluate(context: Context())).to.beTrue()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("expression parsing") {
|
||||
$0.it("can parse a variable expression") {
|
||||
let expression = try parseExpression(components: ["value"])
|
||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("can parse a not expression") {
|
||||
let expression = try parseExpression(components: ["not", "value"])
|
||||
try expect(expression.evaluate(context: Context())).to.beTrue()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.describe("and expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "and", "rhs"])
|
||||
|
||||
$0.it("evaluates to false with lhs false") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with rhs false") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs and rhs false") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with lhs and rhs true") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("or expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "or", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with lhs true") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with rhs true") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with lhs and rhs true") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs and rhs false") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("equality expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "==", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with equal lhs/rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with non equal lhs/rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with nils") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: [:]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with numbers") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.0]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with non equal numbers") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.1]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with booleans") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with falsy booleans") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with different types") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": 1]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("inequality expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "!=", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with inequal lhs/rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with equal lhs/rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "b", "rhs": "b"]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("more than expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", ">", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with lhs > rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs == rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("more than equal expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", ">=", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with lhs == rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs < rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.1]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("less than expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "<", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with lhs < rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs == rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("less than equal expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "<=", "rhs"])
|
||||
|
||||
$0.it("evaluates to true with lhs == rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with lhs > rhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.1, "rhs": 5.0]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("multiple expression") {
|
||||
let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"])
|
||||
|
||||
$0.it("evaluates to true with one") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with one and three") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to true with two") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with two and three") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with two and three") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false with nothing") {
|
||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("in expression") {
|
||||
let expression = try! parseExpression(components: ["lhs", "in", "rhs"])
|
||||
|
||||
$0.it("evaluates to true when rhs contains lhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["a", "b", "c"]]))).to.beTrue()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "abc"]))).to.beTrue()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1...3]))).to.beTrue()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1..<3]))).to.beTrue()
|
||||
}
|
||||
|
||||
$0.it("evaluates to false when rhs does not contain lhs") {
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [2, 3, 4]]))).to.beFalse()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["b", "c", "d"]]))).to.beFalse()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "bcd"]))).to.beFalse()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 1...3]))).to.beFalse()
|
||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse()
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("sub expression") {
|
||||
$0.it("evaluates correctly") {
|
||||
let context = Context(dictionary: ["one": false, "two": false, "three": true, "four": true])
|
||||
|
||||
let expression = try! parseExpression(components: ["one", "and", "two", "or", "three", "and", "four"])
|
||||
let expressionWithBrackets = try! parseExpression(components: ["one", "and", "(", "(", "two", ")", "or", "(", "three", "and", "four", ")", ")"])
|
||||
|
||||
try expect(expression.evaluate(context: context)).to.beTrue()
|
||||
try expect(expressionWithBrackets.evaluate(context: context)).to.beFalse()
|
||||
|
||||
let notExpression = try! parseExpression(components: ["not", "one", "or", "three"])
|
||||
let notExpressionWithBrackets = try! parseExpression(components: ["not", "(", "one", "or", "three", ")"])
|
||||
|
||||
try expect(notExpression.evaluate(context: context)).to.beTrue()
|
||||
try expect(notExpressionWithBrackets.evaluate(context: context)).to.beFalse()
|
||||
}
|
||||
|
||||
$0.it("fails when brackets are not balanced") {
|
||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
|
||||
try expect(parseExpression(components: [")", "lhs", "and", "rhs"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
||||
try expect(parseExpression(components: ["lhs", "and", "rhs", ")"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", "("]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
|
||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", ")"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
||||
try expect(parseExpression(components: ["(", "lhs", "and", ")"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: end"))
|
||||
try expect(parseExpression(components: ["(", "and", "rhs", ")"]))
|
||||
.toThrow(TemplateSyntaxError("'if' expression error: infix operator 'and' doesn't have a left hand side"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,390 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
class FilterTests: XCTestCase {
|
||||
func testFilter() {
|
||||
describe("template filters") {
|
||||
let context: [String: Any] = ["name": "Kyle"]
|
||||
|
||||
func testFilter() {
|
||||
describe("template filters") {
|
||||
let context: [String: Any] = ["name": "Kyle"]
|
||||
$0.it("allows you to register a custom filter") {
|
||||
let template = Template(templateString: "{{ name|repeat }}")
|
||||
|
||||
$0.it("allows you to register a custom filter") {
|
||||
let template = Template(templateString: "{{ name|repeat }}")
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter("repeat") { (value: Any?) in
|
||||
if let value = value as? String {
|
||||
return "\(value) \(value)"
|
||||
}
|
||||
|
||||
let namespace = Namespace()
|
||||
namespace.registerFilter("repeat") { value in
|
||||
if let value = value as? String {
|
||||
return "\(value) \(value)"
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(result) == "Kyle Kyle"
|
||||
}
|
||||
|
||||
$0.it("allows you to register boolean filters") {
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
|
||||
if let value = value as? Int {
|
||||
return value > 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let result = try Template(templateString: "{{ value|isPositive }}")
|
||||
.render(Context(dictionary: ["value": 1], environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(result) == "true"
|
||||
|
||||
let negativeResult = try Template(templateString: "{{ value|isNotPositive }}")
|
||||
.render(Context(dictionary: ["value": -1], environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(negativeResult) == "true"
|
||||
}
|
||||
|
||||
let result = try template.render(Context(dictionary: context, namespace: namespace))
|
||||
try expect(result) == "Kyle Kyle"
|
||||
}
|
||||
$0.it("allows you to register a custom filter which accepts single argument") {
|
||||
let template = Template(templateString: """
|
||||
{{ name|repeat:'value1, "value2"' }}
|
||||
""")
|
||||
|
||||
$0.it("allows you to register a custom which throws") {
|
||||
let template = Template(templateString: "{{ name|repeat }}")
|
||||
let namespace = Namespace()
|
||||
namespace.registerFilter("repeat") { value in
|
||||
throw TemplateSyntaxError("No Repeat")
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||
if !arguments.isEmpty {
|
||||
return "\(value!) \(value!) with args \(arguments.first!!)"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(result) == """
|
||||
Kyle Kyle with args value1, "value2"
|
||||
"""
|
||||
}
|
||||
|
||||
try expect(try template.render(Context(dictionary: context, namespace: namespace))).toThrow(TemplateSyntaxError("No Repeat"))
|
||||
$0.it("allows you to register a custom filter which accepts several arguments") {
|
||||
let template = Template(templateString: """
|
||||
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
|
||||
""")
|
||||
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||
if !arguments.isEmpty {
|
||||
return "\(value!) \(value!) with args 0: \(arguments[0]!), 1: \(arguments[1]!), 2: \(arguments[2]!)"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(result) == """
|
||||
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("allows you to register a custom which throws") {
|
||||
let template = Template(templateString: "{{ name|repeat }}")
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter("repeat") { (value: Any?) in
|
||||
throw TemplateSyntaxError("No Repeat")
|
||||
}
|
||||
|
||||
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
|
||||
try expect(try template.render(context)).toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first))
|
||||
}
|
||||
|
||||
$0.it("allows you to override a default filter") {
|
||||
let template = Template(templateString: "{{ name|join }}")
|
||||
|
||||
let repeatExtension = Extension()
|
||||
repeatExtension.registerFilter("join") { (value: Any?) in
|
||||
return "joined"
|
||||
}
|
||||
|
||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
||||
try expect(result) == "joined"
|
||||
}
|
||||
|
||||
$0.it("allows whitespace in expression") {
|
||||
let template = Template(templateString: """
|
||||
{{ value | join : ", " }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||
try expect(result) == "One, Two"
|
||||
}
|
||||
|
||||
$0.it("throws when you pass arguments to simple filter") {
|
||||
let template = Template(templateString: "{{ name|uppercase:5 }}")
|
||||
try expect(try template.render(Context(dictionary: ["name": "kyle"]))).toThrow()
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("allows whitespace in expression") {
|
||||
let template = Template(templateString: "{{ name | uppercase }}")
|
||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||
try expect(result) == "KYLE"
|
||||
describe("string filters") {
|
||||
$0.context("given string") {
|
||||
$0.it("transforms a string to be capitalized") {
|
||||
let template = Template(templateString: "{{ name|capitalize }}")
|
||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("transforms a string to be uppercase") {
|
||||
let template = Template(templateString: "{{ name|uppercase }}")
|
||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||
try expect(result) == "KYLE"
|
||||
}
|
||||
|
||||
$0.it("transforms a string to be lowercase") {
|
||||
let template = Template(templateString: "{{ name|lowercase }}")
|
||||
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
||||
try expect(result) == "kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.context("given array of strings") {
|
||||
$0.it("transforms a string to be capitalized") {
|
||||
let template = Template(templateString: "{{ names|capitalize }}")
|
||||
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||
try expect(result) == """
|
||||
["Kyle", "Kyle"]
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("transforms a string to be uppercase") {
|
||||
let template = Template(templateString: "{{ names|uppercase }}")
|
||||
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||
try expect(result) == """
|
||||
["KYLE", "KYLE"]
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("transforms a string to be lowercase") {
|
||||
let template = Template(templateString: "{{ names|lowercase }}")
|
||||
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
|
||||
try expect(result) == """
|
||||
["kyle", "kyle"]
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("default filter") {
|
||||
let template = Template(templateString: """
|
||||
Hello {{ name|default:"World" }}
|
||||
""")
|
||||
|
||||
describe("capitalize filter") {
|
||||
let template = Template(templateString: "{{ name|capitalize }}")
|
||||
$0.it("shows the variable value") {
|
||||
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
||||
try expect(result) == "Hello Kyle"
|
||||
}
|
||||
|
||||
$0.it("capitalizes a string") {
|
||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||
try expect(result) == "Kyle"
|
||||
$0.it("shows the default value") {
|
||||
let result = try template.render(Context(dictionary: [:]))
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("supports multiple defaults") {
|
||||
let template = Template(templateString: """
|
||||
Hello {{ name|default:a,b,c,"World" }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: [:]))
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("can use int as default") {
|
||||
let template = Template(templateString: "{{ value|default:1 }}")
|
||||
let result = try template.render(Context(dictionary: [:]))
|
||||
try expect(result) == "1"
|
||||
}
|
||||
|
||||
$0.it("can use float as default") {
|
||||
let template = Template(templateString: "{{ value|default:1.5 }}")
|
||||
let result = try template.render(Context(dictionary: [:]))
|
||||
try expect(result) == "1.5"
|
||||
}
|
||||
|
||||
$0.it("checks for underlying nil value correctly") {
|
||||
let template = Template(templateString: """
|
||||
Hello {{ user.name|default:"anonymous" }}
|
||||
""")
|
||||
let nilName: String? = nil
|
||||
let user: [String: Any?] = ["name": nilName]
|
||||
let result = try template.render(Context(dictionary: ["user": user]))
|
||||
try expect(result) == "Hello anonymous"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("join filter") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|join:", " }}
|
||||
""")
|
||||
|
||||
describe("uppercase filter") {
|
||||
let template = Template(templateString: "{{ name|uppercase }}")
|
||||
$0.it("joins a collection of strings") {
|
||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||
try expect(result) == "One, Two"
|
||||
}
|
||||
|
||||
$0.it("transforms a string to be uppercase") {
|
||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||
try expect(result) == "KYLE"
|
||||
$0.it("joins a mixed-type collection") {
|
||||
let result = try template.render(Context(dictionary: ["value": ["One", 2, true, 10.5, "Five"]]))
|
||||
try expect(result) == "One, 2, true, 10.5, Five"
|
||||
}
|
||||
|
||||
$0.it("can join by non string") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|join:separator }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
|
||||
try expect(result) == "OnetrueTwo"
|
||||
}
|
||||
|
||||
$0.it("can join without arguments") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|join }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||
try expect(result) == "OneTwo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("lowercase filter") {
|
||||
let template = Template(templateString: "{{ name|lowercase }}")
|
||||
describe("split filter") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|split:", " }}
|
||||
""")
|
||||
|
||||
$0.it("transforms a string to be lowercase") {
|
||||
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
||||
try expect(result) == "kyle"
|
||||
$0.it("split a string into array") {
|
||||
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
||||
try expect(result) == """
|
||||
["One", "Two"]
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can split without arguments") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|split }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
||||
try expect(result) == """
|
||||
["One,", "Two"]
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
describe("filter suggestion") {
|
||||
var template: Template!
|
||||
var filterExtension: Extension!
|
||||
|
||||
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
|
||||
guard let range = template.templateString.range(of: token) else {
|
||||
fatalError("Can't find '\(token)' in '\(template)'")
|
||||
}
|
||||
let lexer = Lexer(templateString: template.templateString)
|
||||
let location = lexer.rangeLocation(range)
|
||||
let sourceMap = SourceMap(filename: template.name, location: location)
|
||||
let token = Token.block(value: token, at: sourceMap)
|
||||
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
|
||||
}
|
||||
|
||||
func expectError(reason: String, token: String,
|
||||
file: String = #file, line: Int = #line, function: String = #function) throws {
|
||||
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
|
||||
let environment = Environment(extensions: [filterExtension])
|
||||
|
||||
let error = try expect(environment.render(template: template, context: [:]),
|
||||
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
|
||||
let reporter = SimpleErrorReporter()
|
||||
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
|
||||
}
|
||||
|
||||
$0.it("made for unknown filter") {
|
||||
template = Template(templateString: "{{ value|unknownFilter }}")
|
||||
|
||||
filterExtension = Extension()
|
||||
filterExtension.registerFilter("knownFilter") { value, _ in value }
|
||||
|
||||
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter")
|
||||
}
|
||||
|
||||
$0.it("made for multiple similar filters") {
|
||||
template = Template(templateString: "{{ value|lowerFirst }}")
|
||||
|
||||
filterExtension = Extension()
|
||||
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
|
||||
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
|
||||
|
||||
try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst")
|
||||
}
|
||||
|
||||
$0.it("not made when can't find similar filter") {
|
||||
template = Template(templateString: "{{ value|unknownFilter }}")
|
||||
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
describe("indent filter") {
|
||||
$0.it("indents content") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|indent:2 }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": """
|
||||
One
|
||||
Two
|
||||
"""]))
|
||||
try expect(result) == """
|
||||
One
|
||||
Two
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can indent with arbitrary character") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|indent:2,"\t" }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": """
|
||||
One
|
||||
Two
|
||||
"""]))
|
||||
try expect(result) == """
|
||||
One
|
||||
\t\tTwo
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can indent first line") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|indent:2," ",true }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": """
|
||||
One
|
||||
Two
|
||||
"""]))
|
||||
try expect(result) == """
|
||||
One
|
||||
Two
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("does not indent empty lines") {
|
||||
let template = Template(templateString: """
|
||||
{{ value|indent }}
|
||||
""")
|
||||
let result = try template.render(Context(dictionary: ["value": """
|
||||
One
|
||||
|
||||
|
||||
Two
|
||||
|
||||
|
||||
"""]))
|
||||
try expect(result) == """
|
||||
One
|
||||
|
||||
|
||||
Two
|
||||
|
||||
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
Tests/StencilTests/FilterTagSpec.swift
Normal file
51
Tests/StencilTests/FilterTagSpec.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
|
||||
class FilterTagTests: XCTestCase {
|
||||
func testFilterTag() {
|
||||
describe("Filter Tag") {
|
||||
$0.it("allows you to use a filter") {
|
||||
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
|
||||
let result = try template.render()
|
||||
try expect(result) == "TEST"
|
||||
}
|
||||
|
||||
$0.it("allows you to chain filters") {
|
||||
let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
|
||||
let result = try template.render()
|
||||
try expect(result) == "Test"
|
||||
}
|
||||
|
||||
$0.it("errors without a filter") {
|
||||
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
|
||||
try expect(try template.render()).toThrow()
|
||||
}
|
||||
|
||||
$0.it("can render filters with arguments") {
|
||||
let ext = Extension()
|
||||
ext.registerFilter("split", filter: {
|
||||
return ($0 as! String).components(separatedBy: $1[0] as! String)
|
||||
})
|
||||
let env = Environment(extensions: [ext])
|
||||
let result = try env.renderTemplate(string: """
|
||||
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
|
||||
""", context: ["items": [1, 2]])
|
||||
try expect(result) == "1;2"
|
||||
}
|
||||
|
||||
$0.it("can render filters with quote as an argument") {
|
||||
let ext = Extension()
|
||||
ext.registerFilter("replace", filter: {
|
||||
print($1[0] as! String)
|
||||
return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String)
|
||||
})
|
||||
let env = Environment(extensions: [ext])
|
||||
let result = try env.renderTemplate(string: """
|
||||
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
|
||||
""", context: ["items": ["\"1\"", "\"2\""]])
|
||||
try expect(result) == "1,2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,356 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
import Foundation
|
||||
|
||||
class ForNodeTests: XCTestCase {
|
||||
func testForNode() {
|
||||
describe("ForNode") {
|
||||
let context = Context(dictionary: [
|
||||
"items": [1, 2, 3],
|
||||
"emptyItems": [Int](),
|
||||
"dict": [
|
||||
"one": "I",
|
||||
"two": "II",
|
||||
],
|
||||
"tuples": [(1, 2, 3), (4, 5, 6)]
|
||||
])
|
||||
|
||||
func testForNode() {
|
||||
describe("ForNode") {
|
||||
let context = Context(dictionary: [
|
||||
"items": [1, 2, 3],
|
||||
"emptyItems": [Int](),
|
||||
])
|
||||
$0.it("renders the given nodes for each item") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "123"
|
||||
}
|
||||
|
||||
$0.it("renders the given empty nodes when no items found item") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||
let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes)
|
||||
try expect(try node.render(context)) == "empty"
|
||||
}
|
||||
|
||||
$0.it("renders a context variable of type Array<Any>") {
|
||||
let any_context = Context(dictionary: [
|
||||
"items": ([1, 2, 3] as [Any])
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(any_context)) == "123"
|
||||
}
|
||||
|
||||
$0.it("renders a context variable of type CountableClosedRange<Int>") {
|
||||
let context = Context(dictionary: ["range": 1...3])
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
|
||||
try expect(try node.render(context)) == "123"
|
||||
}
|
||||
|
||||
$0.it("renders a context variable of type CountableRange<Int>") {
|
||||
let context = Context(dictionary: ["range": 1..<4])
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
|
||||
try expect(try node.render(context)) == "123"
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
$0.it("renders a context variable of type NSArray") {
|
||||
let nsarray_context = Context(dictionary: [
|
||||
"items": NSArray(array: [1, 2, 3])
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(nsarray_context)) == "123"
|
||||
}
|
||||
#endif
|
||||
|
||||
$0.it("renders the given nodes while providing if the item is first in the context") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "1true2false3false"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing if the item is last in the context") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.last")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "1false2false3true"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing item counter") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "112233"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing item counter") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter0")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "102132"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing loop length") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")]
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "132333"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while filtering items using where expression") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
|
||||
let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
|
||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`)
|
||||
try expect(try node.render(context)) == "2132"
|
||||
}
|
||||
|
||||
$0.it("renders the given empty nodes when all items filtered out with where expression") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||
let `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
|
||||
let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`)
|
||||
try expect(try node.render(context)) == "empty"
|
||||
}
|
||||
|
||||
$0.it("can render a filter with spaces") {
|
||||
let templateString = """
|
||||
{% for article in ars | default: a, b , articles %}\
|
||||
- {{ article.title }} by {{ article.author }}.
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let context = Context(dictionary: [
|
||||
"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)
|
||||
|
||||
try expect(result) == """
|
||||
- Migrating from OCUnit to XCTest by Kyle Fuller.
|
||||
- Memory Management with ARC by Kyle Fuller.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.context("given array of tuples") {
|
||||
$0.it("can iterate over all tuple values") {
|
||||
let templateString = """
|
||||
{% for first,second,third in tuples %}\
|
||||
{{ first }}, {{ second }}, {{ third }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
let result = try template.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
1, 2, 3
|
||||
4, 5, 6
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can iterate with less number of variables") {
|
||||
let templateString = """
|
||||
{% for first,second in tuples %}\
|
||||
{{ first }}, {{ second }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
let result = try template.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
1, 2
|
||||
4, 5
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can use _ to skip variables") {
|
||||
let templateString = """
|
||||
{% for first,_,third in tuples %}\
|
||||
{{ first }}, {{ third }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
let result = try template.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
1, 3
|
||||
4, 6
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("throws when number of variables is more than number of tuple values") {
|
||||
let templateString = """
|
||||
{% for key,value,smth in dict %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
try expect(template.render(context)).toThrow()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$0.it("can iterate over dictionary") {
|
||||
let templateString = """
|
||||
{% for key, value in dict %}\
|
||||
{{ key }}: {{ value }},\
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
let result = try template.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
one: I,two: II,
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("renders supports iterating over dictionary") {
|
||||
let nodes: [NodeType] = [
|
||||
VariableNode(variable: "key"),
|
||||
TextNode(text: ","),
|
||||
]
|
||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
||||
let result = try node.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
one,two,
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("renders supports iterating over dictionary") {
|
||||
let nodes: [NodeType] = [
|
||||
VariableNode(variable: "key"),
|
||||
TextNode(text: "="),
|
||||
VariableNode(variable: "value"),
|
||||
TextNode(text: ","),
|
||||
]
|
||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
||||
let result = try node.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
one=I,two=II,
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("handles invalid input") {
|
||||
let token = Token.block(value: "for i", at: .unknown)
|
||||
let parser = TokenParser(tokens: [token], environment: Environment())
|
||||
let error = TemplateSyntaxError(reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", token: token)
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
|
||||
$0.it("can iterate over struct properties") {
|
||||
struct MyStruct {
|
||||
let string: String
|
||||
let number: Int
|
||||
}
|
||||
|
||||
let context = Context(dictionary: [
|
||||
"struct": MyStruct(string: "abc", number: 123)
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [
|
||||
VariableNode(variable: "property"),
|
||||
TextNode(text: "="),
|
||||
VariableNode(variable: "value"),
|
||||
TextNode(text: "\n"),
|
||||
]
|
||||
let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: [])
|
||||
let result = try node.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
string=abc
|
||||
number=123
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can iterate tuple items") {
|
||||
let context = Context(dictionary: [
|
||||
"tuple": (one: 1, two: "dva"),
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [
|
||||
VariableNode(variable: "label"),
|
||||
TextNode(text: "="),
|
||||
VariableNode(variable: "value"),
|
||||
TextNode(text: "\n"),
|
||||
]
|
||||
|
||||
let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
|
||||
let result = try node.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
one=1
|
||||
two=dva
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can iterate over class properties") {
|
||||
class MyClass {
|
||||
var baseString: String
|
||||
var baseInt: Int
|
||||
init(_ string: String, _ int: Int) {
|
||||
baseString = string
|
||||
baseInt = int
|
||||
}
|
||||
}
|
||||
|
||||
class MySubclass: MyClass {
|
||||
var childString: String
|
||||
init(_ childString: String, _ string: String, _ int: Int) {
|
||||
self.childString = childString
|
||||
super.init(string, int)
|
||||
}
|
||||
}
|
||||
|
||||
let context = Context(dictionary: [
|
||||
"class": MySubclass("child", "base", 1)
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [
|
||||
VariableNode(variable: "label"),
|
||||
TextNode(text: "="),
|
||||
VariableNode(variable: "value"),
|
||||
TextNode(text: "\n"),
|
||||
]
|
||||
|
||||
let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
|
||||
let result = try node.render(context)
|
||||
|
||||
try expect(result) == """
|
||||
childString=child
|
||||
baseString=base
|
||||
baseInt=1
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can iterate in range of variables") {
|
||||
let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}"
|
||||
try expect(try template.render(Context(dictionary: ["j": 3]))) == "123"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes for each item") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "123"
|
||||
}
|
||||
|
||||
$0.it("renders the given empty nodes when no items found item") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||
let node = ForNode(variable: "emptyItems", loopVariable: "item", nodes: nodes, emptyNodes: emptyNodes)
|
||||
try expect(try node.render(context)) == "empty"
|
||||
}
|
||||
|
||||
$0.it("renders a context variable of type Array<Any>") {
|
||||
let any_context = Context(dictionary: [
|
||||
"items": ([1, 2, 3] as [Any])
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(any_context)) == "123"
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
$0.it("renders a context variable of type NSArray") {
|
||||
let nsarray_context = Context(dictionary: [
|
||||
"items": NSArray(array: [1, 2, 3])
|
||||
])
|
||||
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(nsarray_context)) == "123"
|
||||
}
|
||||
#endif
|
||||
|
||||
$0.it("renders the given nodes while providing if the item is first in the context") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "1true2false3false"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing if the item is last in the context") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.last")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "1false2false3true"
|
||||
}
|
||||
|
||||
$0.it("renders the given nodes while providing item counter") {
|
||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
|
||||
let node = ForNode(variable: "items", loopVariable: "item", nodes: nodes, emptyNodes: [])
|
||||
try expect(try node.render(context)) == "112233"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct Article {
|
||||
let title: String
|
||||
let author: String
|
||||
}
|
||||
|
||||
@@ -1,128 +1,289 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
class IfNodeTests: XCTestCase {
|
||||
func testIfNode() {
|
||||
describe("IfNode") {
|
||||
$0.describe("parsing") {
|
||||
$0.it("can parse an if block") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 1
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
}
|
||||
|
||||
$0.it("can parse an if with else block") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "else", at: .unknown),
|
||||
.text(value: "false", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 2
|
||||
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
|
||||
try expect(conditions?[1].nodes.count) == 1
|
||||
let falseNode = conditions?[1].nodes.first as? TextNode
|
||||
try expect(falseNode?.text) == "false"
|
||||
}
|
||||
|
||||
$0.it("can parse an if with elif block") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "elif something", at: .unknown),
|
||||
.text(value: "some", at: .unknown),
|
||||
.block(value: "else", at: .unknown),
|
||||
.text(value: "false", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 3
|
||||
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
|
||||
try expect(conditions?[1].nodes.count) == 1
|
||||
let elifNode = conditions?[1].nodes.first as? TextNode
|
||||
try expect(elifNode?.text) == "some"
|
||||
|
||||
try expect(conditions?[2].nodes.count) == 1
|
||||
let falseNode = conditions?[2].nodes.first as? TextNode
|
||||
try expect(falseNode?.text) == "false"
|
||||
}
|
||||
|
||||
$0.it("can parse an if with elif block without else") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "elif something", at: .unknown),
|
||||
.text(value: "some", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 2
|
||||
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
|
||||
try expect(conditions?[1].nodes.count) == 1
|
||||
let elifNode = conditions?[1].nodes.first as? TextNode
|
||||
try expect(elifNode?.text) == "some"
|
||||
}
|
||||
|
||||
$0.it("can parse an if with multiple elif block") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "elif something1", at: .unknown),
|
||||
.text(value: "some1", at: .unknown),
|
||||
.block(value: "elif something2", at: .unknown),
|
||||
.text(value: "some2", at: .unknown),
|
||||
.block(value: "else", at: .unknown),
|
||||
.text(value: "false", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 4
|
||||
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
|
||||
try expect(conditions?[1].nodes.count) == 1
|
||||
let elifNode = conditions?[1].nodes.first as? TextNode
|
||||
try expect(elifNode?.text) == "some1"
|
||||
|
||||
try expect(conditions?[2].nodes.count) == 1
|
||||
let elif2Node = conditions?[2].nodes.first as? TextNode
|
||||
try expect(elif2Node?.text) == "some2"
|
||||
|
||||
try expect(conditions?[3].nodes.count) == 1
|
||||
let falseNode = conditions?[3].nodes.first as? TextNode
|
||||
try expect(falseNode?.text) == "false"
|
||||
}
|
||||
|
||||
|
||||
func testIfNode() {
|
||||
describe("IfNode") {
|
||||
$0.describe("parsing") {
|
||||
$0.it("can parse an if block") {
|
||||
$0.it("can parse an if with complex expression") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value == \"test\" and not name", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
try expect(nodes.first is IfNode).beTrue()
|
||||
}
|
||||
|
||||
$0.it("can parse an ifnot block") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "ifnot value", at: .unknown),
|
||||
.text(value: "false", at: .unknown),
|
||||
.block(value: "else", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
let conditions = node?.conditions
|
||||
try expect(conditions?.count) == 2
|
||||
|
||||
try expect(conditions?[0].nodes.count) == 1
|
||||
let trueNode = conditions?[0].nodes.first as? TextNode
|
||||
try expect(trueNode?.text) == "true"
|
||||
|
||||
try expect(conditions?[1].nodes.count) == 1
|
||||
let falseNode = conditions?[1].nodes.first as? TextNode
|
||||
try expect(falseNode?.text) == "false"
|
||||
}
|
||||
|
||||
$0.it("throws an error when parsing an if block without an endif") {
|
||||
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
|
||||
$0.it("throws an error when parsing an ifnot without an endif") {
|
||||
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("rendering") {
|
||||
$0.it("renders a true expression") {
|
||||
let node = IfNode(conditions: [
|
||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "1")]),
|
||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
|
||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
||||
])
|
||||
|
||||
try expect(try node.render(Context())) == "1"
|
||||
}
|
||||
|
||||
$0.it("renders the first true expression") {
|
||||
let node = IfNode(conditions: [
|
||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
|
||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
||||
])
|
||||
|
||||
try expect(try node.render(Context())) == "2"
|
||||
}
|
||||
|
||||
$0.it("renders the empty expression when other conditions are falsy") {
|
||||
let node = IfNode(conditions: [
|
||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
|
||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
||||
])
|
||||
|
||||
try expect(try node.render(Context())) == "3"
|
||||
}
|
||||
|
||||
$0.it("renders empty when no truthy conditions") {
|
||||
let node = IfNode(conditions: [
|
||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
|
||||
])
|
||||
|
||||
try expect(try node.render(Context())) == ""
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("supports variable filters in the if expression") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value"),
|
||||
.text(value: "true"),
|
||||
.block(value: "else"),
|
||||
.text(value: "false"),
|
||||
.block(value: "endif")
|
||||
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
let trueNode = node?.trueNodes.first as? TextNode
|
||||
let falseNode = node?.falseNodes.first as? TextNode
|
||||
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.variable.variable) == "value"
|
||||
try expect(node?.trueNodes.count) == 1
|
||||
try expect(trueNode?.text) == "true"
|
||||
try expect(node?.falseNodes.count) == 1
|
||||
try expect(falseNode?.text) == "false"
|
||||
let result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
|
||||
try expect(result) == "true"
|
||||
}
|
||||
|
||||
$0.it("can parse an ifnot block") {
|
||||
$0.it("evaluates nil properties as false") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "ifnot value"),
|
||||
.text(value: "false"),
|
||||
.block(value: "else"),
|
||||
.text(value: "true"),
|
||||
.block(value: "endif")
|
||||
.block(value: "if instance.value", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IfNode
|
||||
let trueNode = node?.trueNodes.first as? TextNode
|
||||
let falseNode = node?.falseNodes.first as? TextNode
|
||||
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.variable.variable) == "value"
|
||||
try expect(node?.trueNodes.count) == 1
|
||||
try expect(trueNode?.text) == "true"
|
||||
try expect(node?.falseNodes.count) == 1
|
||||
try expect(falseNode?.text) == "false"
|
||||
struct SomeType {
|
||||
let value: String? = nil
|
||||
}
|
||||
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
|
||||
try expect(result) == ""
|
||||
}
|
||||
|
||||
$0.it("throws an error when parsing an if block without an endif") {
|
||||
$0.it("supports closed range variables") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "if value"),
|
||||
.block(value: "if value in 1...3", at: .unknown),
|
||||
.text(value: "true", at: .unknown),
|
||||
.block(value: "else", at: .unknown),
|
||||
.text(value: "false", at: .unknown),
|
||||
.block(value: "endif", at: .unknown)
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let error = TemplateSyntaxError("`endif` was not found.")
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
|
||||
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
|
||||
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
|
||||
}
|
||||
|
||||
$0.it("throws an error when parsing an ifnot without an endif") {
|
||||
let tokens: [Token] = [
|
||||
.block(value: "ifnot value"),
|
||||
]
|
||||
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let error = TemplateSyntaxError("`endif` was not found.")
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("rendering") {
|
||||
$0.it("renders the truth when expression evaluates to true") {
|
||||
let context = Context(dictionary: ["items": true])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(context)) == "true"
|
||||
}
|
||||
|
||||
$0.it("renders the false when expression evaluates to false") {
|
||||
let context = Context(dictionary: ["items": false])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(context)) == "false"
|
||||
}
|
||||
|
||||
$0.it("renders the truth when expression is not nil") {
|
||||
let context = Context(dictionary: ["known": "known"])
|
||||
let node = IfNode(variable: "known", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(context)) == "true"
|
||||
}
|
||||
|
||||
$0.it("renders the false when expression is nil") {
|
||||
let context = Context(dictionary: [:])
|
||||
let node = IfNode(variable: "unknown", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(context)) == "false"
|
||||
}
|
||||
|
||||
$0.it("renders the truth when array expression is not empty") {
|
||||
let items: [[String: Any]] = [["key":"key1","value":42],["key":"key2","value":1337]]
|
||||
let arrayContext = Context(dictionary: ["items": [items]])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(arrayContext)) == "true"
|
||||
}
|
||||
|
||||
$0.it("renders the false when array expression is empty") {
|
||||
let emptyItems = [[String: Any]]()
|
||||
let arrayContext = Context(dictionary: ["items": emptyItems])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(arrayContext)) == "false"
|
||||
}
|
||||
|
||||
$0.it("renders the false when dictionary expression is empty") {
|
||||
let emptyItems = [String:Any]()
|
||||
let arrayContext = Context(dictionary: ["items": emptyItems])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(arrayContext)) == "false"
|
||||
}
|
||||
|
||||
$0.it("renders the false when Array<Any> variable is empty") {
|
||||
let arrayContext = Context(dictionary: ["items": ([] as [Any])])
|
||||
let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
|
||||
try expect(try node.render(arrayContext)) == "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,71 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
import PathKit
|
||||
|
||||
class IncludeTests: XCTestCase {
|
||||
func testInclude() {
|
||||
describe("Include") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = FileSystemLoader(paths: [path])
|
||||
let environment = Environment(loader: loader)
|
||||
|
||||
func testInclude() {
|
||||
describe("Include") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = TemplateLoader(paths: [path])
|
||||
$0.describe("parsing") {
|
||||
$0.it("throws an error when no template is given") {
|
||||
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
|
||||
$0.describe("parsing") {
|
||||
$0.it("throws an error when no template is given") {
|
||||
let tokens: [Token] = [ .block(value: "include") ]
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let error = TemplateSyntaxError(reason: "'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file", token: tokens.first)
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
|
||||
let error = TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
|
||||
try expect(try parser.parse()).toThrow(error)
|
||||
}
|
||||
$0.it("can parse a valid include block") {
|
||||
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
|
||||
$0.it("can parse a valid include block") {
|
||||
let tokens: [Token] = [ .block(value: "include \"test.html\"") ]
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IncludeNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.templateName) == Variable("\"test.html\"")
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("rendering") {
|
||||
$0.it("throws an error when rendering without a loader") {
|
||||
let node = IncludeNode(templateName: Variable("\"test.html\""))
|
||||
|
||||
do {
|
||||
_ = try node.render(Context())
|
||||
} catch {
|
||||
try expect("\(error)") == "Template loader not in context"
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? IncludeNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.templateName) == Variable("\"test.html\"")
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("throws an error when it cannot find the included template") {
|
||||
let node = IncludeNode(templateName: Variable("\"unknown.html\""))
|
||||
$0.describe("rendering") {
|
||||
$0.it("throws an error when rendering without a loader") {
|
||||
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
||||
|
||||
do {
|
||||
_ = try node.render(Context(dictionary: ["loader": loader]))
|
||||
} catch {
|
||||
try expect("\(error)".hasPrefix("'unknown.html' template not found")).to.beTrue()
|
||||
do {
|
||||
_ = try node.render(Context())
|
||||
} catch {
|
||||
try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("successfully renders a found included template") {
|
||||
let node = IncludeNode(templateName: Variable("\"test.html\""))
|
||||
let context = Context(dictionary: ["loader":loader, "target": "World"])
|
||||
let value = try node.render(context)
|
||||
try expect(value) == "Hello World!"
|
||||
$0.it("throws an error when it cannot find the included template") {
|
||||
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
|
||||
|
||||
do {
|
||||
_ = try node.render(Context(environment: environment))
|
||||
} catch {
|
||||
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("successfully renders a found included template") {
|
||||
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
||||
let context = Context(dictionary: ["target": "World"], environment: environment)
|
||||
let value = try node.render(context)
|
||||
try expect(value) == "Hello World!"
|
||||
}
|
||||
|
||||
$0.it("successfully passes context") {
|
||||
let template = Template(templateString: """
|
||||
{% include "test.html" child %}
|
||||
""")
|
||||
let context = Context(dictionary: ["child": ["target": "World"]], environment: environment)
|
||||
let value = try template.render(context)
|
||||
try expect(value) == "Hello World!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
import PathKit
|
||||
|
||||
class InheritenceTests: XCTestCase {
|
||||
func testInheritence() {
|
||||
describe("Inheritence") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = FileSystemLoader(paths: [path])
|
||||
let environment = Environment(loader: loader)
|
||||
|
||||
func testInheritence() {
|
||||
describe("Inheritence") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = TemplateLoader(paths: [path])
|
||||
$0.it("can inherit from another template") {
|
||||
let template = try environment.loadTemplate(name: "child.html")
|
||||
try expect(try template.render()) == """
|
||||
Super_Header Child_Header
|
||||
Child_Body
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can inherit from another template") {
|
||||
let context = Context(dictionary: ["loader": loader])
|
||||
let template = loader.loadTemplate("child.html")
|
||||
try expect(try template?.render(context)) == "Header\nChild"
|
||||
$0.it("can inherit from another template inheriting from another template") {
|
||||
let template = try environment.loadTemplate(name: "child-child.html")
|
||||
try expect(try template.render()) == """
|
||||
Super_Header Child_Header Child_Child_Header
|
||||
Child_Body
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can inherit from a template that calls a super block") {
|
||||
let template = try environment.loadTemplate(name: "child-super.html")
|
||||
try expect(try template.render()) == """
|
||||
Header
|
||||
Child_Body
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,129 @@
|
||||
import PathKit
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
import XCTest
|
||||
|
||||
class LexerTests: XCTestCase {
|
||||
func testLexer() {
|
||||
describe("Lexer") {
|
||||
func makeSourceMap(_ token: String, for lexer: Lexer, options: String.CompareOptions = []) -> SourceMap {
|
||||
guard let range = lexer.templateString.range(of: token, options: options) else { fatalError("Token not found") }
|
||||
return SourceMap(location: lexer.rangeLocation(range))
|
||||
}
|
||||
|
||||
func testLexer() {
|
||||
describe("Lexer") {
|
||||
$0.it("can tokenize text") {
|
||||
let lexer = Lexer(templateString: "Hello World")
|
||||
let tokens = lexer.tokenize()
|
||||
$0.it("can tokenize text") {
|
||||
let lexer = Lexer(templateString: "Hello World")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .text(value: "Hello World")
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .text(value: "Hello World", at: SourceMap(location: ("Hello World", 1, 0)))
|
||||
}
|
||||
|
||||
$0.it("can tokenize a comment") {
|
||||
let lexer = Lexer(templateString: "{# Comment #}")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .comment(value: "Comment", at: SourceMap(location: ("{# Comment #}", 1, 3)))
|
||||
}
|
||||
|
||||
$0.it("can tokenize a variable") {
|
||||
let lexer = Lexer(templateString: "{{ Variable }}")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .variable(value: "Variable", at: SourceMap(location: ("{{ Variable }}", 1, 3)))
|
||||
}
|
||||
|
||||
$0.it("can tokenize unclosed tag by ignoring it") {
|
||||
let templateString = "{{ thing"
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .text(value: "", at: SourceMap(location: ("{{ thing", 1, 0)))
|
||||
}
|
||||
|
||||
$0.it("can tokenize a mixture of content") {
|
||||
let templateString = "My name is {{ myname }}."
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 3
|
||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
||||
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
||||
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||
}
|
||||
|
||||
$0.it("can tokenize two variables without being greedy") {
|
||||
let templateString = "{{ thing }}{{ name }}"
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 2
|
||||
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
||||
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
|
||||
}
|
||||
|
||||
$0.it("can tokenize an unclosed block") {
|
||||
let lexer = Lexer(templateString: "{%}")
|
||||
_ = lexer.tokenize()
|
||||
}
|
||||
|
||||
$0.it("can tokenize incorrect syntax without crashing") {
|
||||
let lexer = Lexer(templateString: "func some() {{% if %}")
|
||||
_ = lexer.tokenize()
|
||||
}
|
||||
|
||||
$0.it("can tokenize an empty variable") {
|
||||
let lexer = Lexer(templateString: "{{}}")
|
||||
_ = lexer.tokenize()
|
||||
}
|
||||
|
||||
$0.it("can tokenize with new lines") {
|
||||
let templateString = """
|
||||
My name is {%
|
||||
if name
|
||||
and
|
||||
name
|
||||
%}{{
|
||||
name
|
||||
}}{%
|
||||
endif %}.
|
||||
"""
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 5
|
||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
||||
try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
||||
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
||||
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||
try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||
}
|
||||
|
||||
$0.it("can tokenize escape sequences") {
|
||||
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
|
||||
let lexer = Lexer(templateString: templateString)
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 5
|
||||
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
||||
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
||||
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
||||
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
||||
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can tokenize a comment") {
|
||||
let lexer = Lexer(templateString: "{# Comment #}")
|
||||
let tokens = lexer.tokenize()
|
||||
func testPerformance() throws {
|
||||
let path = Path(#file) + ".." + "fixtures" + "huge.html"
|
||||
let content: String = try path.read()
|
||||
|
||||
try expect(tokens.count) == (1)
|
||||
try expect(tokens.first) == .comment(value: "Comment")
|
||||
}
|
||||
|
||||
$0.it("can tokenize a variable") {
|
||||
let lexer = Lexer(templateString: "{{ Variable }}")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 1
|
||||
try expect(tokens.first) == .variable(value: "Variable")
|
||||
}
|
||||
|
||||
$0.it("can tokenize a mixture of content") {
|
||||
let lexer = Lexer(templateString: "My name is {{ name }}.")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 3
|
||||
try expect(tokens[0]) == Token.text(value: "My name is ")
|
||||
try expect(tokens[1]) == Token.variable(value: "name")
|
||||
try expect(tokens[2]) == Token.text(value: ".")
|
||||
}
|
||||
|
||||
$0.it("can tokenize two variables without being greedy") {
|
||||
let lexer = Lexer(templateString: "{{ thing }}{{ name }}")
|
||||
let tokens = lexer.tokenize()
|
||||
|
||||
try expect(tokens.count) == 2
|
||||
try expect(tokens[0]) == Token.variable(value: "thing")
|
||||
try expect(tokens[1]) == Token.variable(value: "name")
|
||||
measure {
|
||||
let lexer = Lexer(templateString: content)
|
||||
_ = lexer.tokenize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
57
Tests/StencilTests/LoaderSpec.swift
Normal file
57
Tests/StencilTests/LoaderSpec.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
import PathKit
|
||||
|
||||
class TemplateLoaderTests: XCTestCase {
|
||||
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 environment.loadTemplate(name: "unknown.html")).toThrow()
|
||||
}
|
||||
|
||||
$0.it("errors when an array of templates cannot be found") {
|
||||
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
||||
}
|
||||
|
||||
$0.it("can load a template from a file") {
|
||||
_ = try environment.loadTemplate(name: "test.html")
|
||||
}
|
||||
|
||||
$0.it("errors when loading absolute file outside of the selected path") {
|
||||
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
|
||||
}
|
||||
|
||||
$0.it("errors when loading relative file outside of the selected path") {
|
||||
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
|
||||
}
|
||||
}
|
||||
|
||||
describe("DictionaryLoader") {
|
||||
let loader = DictionaryLoader(templates: [
|
||||
"index.html": "Hello World"
|
||||
])
|
||||
let environment = Environment(loader: loader)
|
||||
|
||||
$0.it("errors when a template cannot be found") {
|
||||
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
||||
}
|
||||
|
||||
$0.it("errors when an array of templates cannot be found") {
|
||||
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
||||
}
|
||||
|
||||
$0.it("can load a template from a known templates") {
|
||||
_ = try environment.loadTemplate(name: "index.html")
|
||||
}
|
||||
|
||||
$0.it("can load a known template from a collection of templates") {
|
||||
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,65 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
|
||||
@testable import Stencil
|
||||
|
||||
class ErrorNode : NodeType {
|
||||
let token: Token?
|
||||
init(token: Token? = nil) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
throw TemplateSyntaxError("Custom Error")
|
||||
}
|
||||
}
|
||||
|
||||
class NodeTests: XCTestCase {
|
||||
func testNode() {
|
||||
describe("Node") {
|
||||
let context = Context(dictionary: [
|
||||
"name": "Kyle",
|
||||
"age": 27,
|
||||
"items": [1, 2, 3],
|
||||
])
|
||||
|
||||
func testNode() {
|
||||
describe("Node") {
|
||||
let context = Context(dictionary: [
|
||||
"name": "Kyle",
|
||||
"age": 27,
|
||||
"items": [1, 2, 3],
|
||||
])
|
||||
|
||||
$0.describe("TextNode") {
|
||||
$0.it("renders the given text") {
|
||||
let node = TextNode(text: "Hello World")
|
||||
try expect(try node.render(context)) == "Hello World"
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("VariableNode") {
|
||||
$0.it("resolves and renders the variable") {
|
||||
let node = VariableNode(variable: Variable("name"))
|
||||
try expect(try node.render(context)) == "Kyle"
|
||||
$0.describe("TextNode") {
|
||||
$0.it("renders the given text") {
|
||||
let node = TextNode(text: "Hello World")
|
||||
try expect(try node.render(context)) == "Hello World"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("resolves and renders a non string variable") {
|
||||
let node = VariableNode(variable: Variable("age"))
|
||||
try expect(try node.render(context)) == "27"
|
||||
}
|
||||
}
|
||||
$0.describe("VariableNode") {
|
||||
$0.it("resolves and renders the variable") {
|
||||
let node = VariableNode(variable: Variable("name"))
|
||||
try expect(try node.render(context)) == "Kyle"
|
||||
}
|
||||
|
||||
$0.describe("rendering nodes") {
|
||||
$0.it("renders the nodes") {
|
||||
let nodes: [NodeType] = [
|
||||
TextNode(text:"Hello "),
|
||||
VariableNode(variable: "name"),
|
||||
]
|
||||
|
||||
try expect(try renderNodes(nodes, context)) == "Hello Kyle"
|
||||
$0.it("resolves and renders a non string variable") {
|
||||
let node = VariableNode(variable: Variable("age"))
|
||||
try expect(try node.render(context)) == "27"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("correctly throws a nodes failure") {
|
||||
let nodes: [NodeType] = [
|
||||
TextNode(text:"Hello "),
|
||||
VariableNode(variable: "name"),
|
||||
ErrorNode(),
|
||||
]
|
||||
$0.describe("rendering nodes") {
|
||||
$0.it("renders the nodes") {
|
||||
let nodes: [NodeType] = [
|
||||
TextNode(text:"Hello "),
|
||||
VariableNode(variable: "name"),
|
||||
]
|
||||
|
||||
try expect(try renderNodes(nodes, context)).toThrow(TemplateSyntaxError("Custom Error"))
|
||||
try expect(try renderNodes(nodes, context)) == "Hello Kyle"
|
||||
}
|
||||
|
||||
$0.it("correctly throws a nodes failure") {
|
||||
let nodes: [NodeType] = [
|
||||
TextNode(text:"Hello "),
|
||||
VariableNode(variable: "name"),
|
||||
ErrorNode(),
|
||||
]
|
||||
|
||||
try expect(try renderNodes(nodes, context)).toThrow(TemplateSyntaxError("Custom Error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
|
||||
func testNowNode() {
|
||||
#if !os(Linux)
|
||||
describe("NowNode") {
|
||||
$0.describe("parsing") {
|
||||
$0.it("parses default format without any now arguments") {
|
||||
let tokens: [Token] = [ .block(value: "now") ]
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
class NowNodeTests: XCTestCase {
|
||||
func testNowNode() {
|
||||
#if !os(Linux)
|
||||
describe("NowNode") {
|
||||
$0.describe("parsing") {
|
||||
$0.it("parses default format without any now arguments") {
|
||||
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? NowNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? NowNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
|
||||
}
|
||||
|
||||
$0.it("parses now with a format") {
|
||||
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? NowNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.format.variable) == "\"HH:mm\""
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("parses now with a format") {
|
||||
let tokens: [Token] = [ .block(value: "now \"HH:mm\"") ]
|
||||
let parser = TokenParser(tokens: tokens, namespace: Namespace())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? NowNode
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.format.variable) == "\"HH:mm\""
|
||||
}
|
||||
}
|
||||
|
||||
$0.describe("rendering") {
|
||||
$0.it("renders the date") {
|
||||
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let date = formatter.string(from: NSDate() as Date)
|
||||
|
||||
try expect(try node.render(Context())) == date
|
||||
$0.describe("rendering") {
|
||||
$0.it("renders the date") {
|
||||
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let date = formatter.string(from: NSDate() as Date)
|
||||
|
||||
try expect(try node.render(Context())) == date
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,62 +1,63 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
class TokenParserTests: XCTestCase {
|
||||
func testTokenParser() {
|
||||
describe("TokenParser") {
|
||||
$0.it("can parse a text token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.text(value: "Hello World", at: .unknown)
|
||||
], environment: Environment())
|
||||
|
||||
func testTokenParser() {
|
||||
describe("TokenParser") {
|
||||
$0.it("can parse a text token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.text(value: "Hello World")
|
||||
], namespace: Namespace())
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? TextNode
|
||||
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? TextNode
|
||||
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.text) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("can parse a variable token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.variable(value: "'name'")
|
||||
], namespace: Namespace())
|
||||
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? VariableNode
|
||||
try expect(nodes.count) == 1
|
||||
let result = try node?.render(Context())
|
||||
try expect(result) == "name"
|
||||
}
|
||||
|
||||
$0.it("can parse a comment token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.comment(value: "Secret stuff!")
|
||||
], namespace: Namespace())
|
||||
|
||||
let nodes = try parser.parse()
|
||||
try expect(nodes.count) == 0
|
||||
}
|
||||
|
||||
$0.it("can parse a tag token") {
|
||||
let namespace = Namespace()
|
||||
namespace.registerSimpleTag("known") { _ in
|
||||
return ""
|
||||
try expect(nodes.count) == 1
|
||||
try expect(node?.text) == "Hello World"
|
||||
}
|
||||
|
||||
let parser = TokenParser(tokens: [
|
||||
.block(value: "known"),
|
||||
], namespace: namespace)
|
||||
$0.it("can parse a variable token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.variable(value: "'name'", at: .unknown)
|
||||
], environment: Environment())
|
||||
|
||||
let nodes = try parser.parse()
|
||||
try expect(nodes.count) == 1
|
||||
}
|
||||
let nodes = try parser.parse()
|
||||
let node = nodes.first as? VariableNode
|
||||
try expect(nodes.count) == 1
|
||||
let result = try node?.render(Context())
|
||||
try expect(result) == "name"
|
||||
}
|
||||
|
||||
$0.it("errors when parsing an unknown tag") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.block(value: "unknown"),
|
||||
], namespace: Namespace())
|
||||
$0.it("can parse a comment token") {
|
||||
let parser = TokenParser(tokens: [
|
||||
.comment(value: "Secret stuff!", at: .unknown)
|
||||
], environment: Environment())
|
||||
|
||||
try expect(try parser.parse()).toThrow(TemplateSyntaxError("Unknown template tag 'unknown'"))
|
||||
let nodes = try parser.parse()
|
||||
try expect(nodes.count) == 0
|
||||
}
|
||||
|
||||
$0.it("can parse a tag token") {
|
||||
let simpleExtension = Extension()
|
||||
simpleExtension.registerSimpleTag("known") { _ in
|
||||
return ""
|
||||
}
|
||||
|
||||
let parser = TokenParser(tokens: [
|
||||
.block(value: "known", at: .unknown),
|
||||
], environment: Environment(extensions: [simpleExtension]))
|
||||
|
||||
let nodes = try parser.parse()
|
||||
try expect(nodes.count) == 1
|
||||
}
|
||||
|
||||
$0.it("errors when parsing an unknown tag") {
|
||||
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||
|
||||
try expect(try parser.parse()).toThrow(TemplateSyntaxError(reason: "Unknown template tag 'unknown'", token: tokens.first))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
|
||||
|
||||
class CustomNode : NodeType {
|
||||
fileprivate struct CustomNode : NodeType {
|
||||
let token: Token?
|
||||
func render(_ context:Context) throws -> String {
|
||||
return "Hello World"
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct Article {
|
||||
let title: String
|
||||
let author: String
|
||||
}
|
||||
|
||||
func testStencil() {
|
||||
describe("Stencil") {
|
||||
$0.it("can render the README example") {
|
||||
let templateString = "There are {{ articles.count }} articles.\n" +
|
||||
"\n" +
|
||||
"{% for article in articles %}" +
|
||||
" - {{ article.title }} by {{ article.author }}.\n" +
|
||||
"{% endfor %}\n"
|
||||
class StencilTests: XCTestCase {
|
||||
func testStencil() {
|
||||
describe("Stencil") {
|
||||
let exampleExtension = Extension()
|
||||
|
||||
let context = Context(dictionary: [
|
||||
"articles": [
|
||||
[ "title": "Migrating from OCUnit to XCTest", "author": "Kyle Fuller" ],
|
||||
[ "title": "Memory Management with ARC", "author": "Kyle Fuller" ],
|
||||
]
|
||||
])
|
||||
|
||||
let template = Template(templateString: templateString)
|
||||
let result = try template.render(context)
|
||||
|
||||
let fixture = "There are 2 articles.\n" +
|
||||
"\n" +
|
||||
" - Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
|
||||
" - Memory Management with ARC by Kyle Fuller.\n" +
|
||||
"\n"
|
||||
|
||||
try expect(result) == fixture
|
||||
}
|
||||
|
||||
$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))
|
||||
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
|
||||
exampleExtension.registerSimpleTag("simpletag") { context in
|
||||
return "Hello World"
|
||||
}
|
||||
|
||||
try expect(try template.render(Context(namespace: namespace))) == "Hello World"
|
||||
exampleExtension.registerTag("customtag") { parser, token in
|
||||
return CustomNode(token: token)
|
||||
}
|
||||
|
||||
let environment = Environment(extensions: [exampleExtension])
|
||||
|
||||
$0.it("can render the README example") {
|
||||
|
||||
let templateString = """
|
||||
There are {{ articles.count }} articles.
|
||||
|
||||
{% for article in articles %}\
|
||||
- {{ article.title }} by {{ article.author }}.
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
try expect(result) == """
|
||||
There are 2 articles.
|
||||
|
||||
- Migrating from OCUnit to XCTest by Kyle Fuller.
|
||||
- Memory Management with ARC by Kyle Fuller.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
$0.it("can render a custom template tag") {
|
||||
let result = try environment.renderTemplate(string: "{% customtag %}")
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("can render a simple custom tag") {
|
||||
let result = try environment.renderTemplate(string: "{% simpletag %}")
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import Spectre
|
||||
import Stencil
|
||||
import PathKit
|
||||
|
||||
|
||||
func testTemplateLoader() {
|
||||
describe("TemplateLoader") {
|
||||
let path = Path(#file) + ".." + "fixtures"
|
||||
let loader = TemplateLoader(paths: [path])
|
||||
|
||||
$0.it("returns nil when a template cannot be found") {
|
||||
try expect(loader.loadTemplate("unknown.html")).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("returns nil when an array of templates cannot be found") {
|
||||
try expect(loader.loadTemplate(["unknown.html", "unknown2.html"])).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("can load a template from a file") {
|
||||
if loader.loadTemplate("test.html") == nil {
|
||||
throw failure("didn't find the template")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
class TemplateTests: XCTestCase {
|
||||
func testTemplate() {
|
||||
describe("Template") {
|
||||
$0.it("can render a template from a string") {
|
||||
let template = Template(templateString: "Hello World")
|
||||
let result = try template.render([ "name": "Kyle" ])
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("can render a template from a string literal") {
|
||||
let template: Template = "Hello World"
|
||||
let result = try template.render([ "name": "Kyle" ])
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
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)
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import XCTest
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
class TokenTests: XCTestCase {
|
||||
func testToken() {
|
||||
describe("Token") {
|
||||
$0.it("can split the contents into components") {
|
||||
let token = Token.text(value: "hello world", at: .unknown)
|
||||
let components = token.components()
|
||||
|
||||
func testToken() {
|
||||
describe("Token") {
|
||||
$0.it("can split the contents into components") {
|
||||
let token = Token.text(value: "hello world")
|
||||
let components = token.components()
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "world"
|
||||
}
|
||||
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "world"
|
||||
}
|
||||
$0.it("can split the contents into components with single quoted strings") {
|
||||
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
|
||||
let components = token.components()
|
||||
|
||||
$0.it("can split the contents into components with single quoted strings") {
|
||||
let token = Token.text(value: "hello 'kyle fuller'")
|
||||
let components = token.components()
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "'kyle fuller'"
|
||||
}
|
||||
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "'kyle fuller'"
|
||||
}
|
||||
$0.it("can split the contents into components with double quoted strings") {
|
||||
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
|
||||
let components = token.components()
|
||||
|
||||
$0.it("can split the contents into components with double quoted strings") {
|
||||
let token = Token.text(value: "hello \"kyle fuller\"")
|
||||
let components = token.components()
|
||||
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "\"kyle fuller\""
|
||||
try expect(components.count) == 2
|
||||
try expect(components[0]) == "hello"
|
||||
try expect(components[1]) == "\"kyle fuller\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,412 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Spectre
|
||||
import Stencil
|
||||
@testable import Stencil
|
||||
|
||||
|
||||
#if os(OSX)
|
||||
@objc class Object : NSObject {
|
||||
let title = "Hello World"
|
||||
@objc class Superclass: NSObject {
|
||||
@objc let name = "Foo"
|
||||
}
|
||||
@objc class Object : Superclass {
|
||||
@objc let title = "Hello World"
|
||||
}
|
||||
#endif
|
||||
|
||||
fileprivate struct Person {
|
||||
let name: String
|
||||
}
|
||||
|
||||
func testVariable() {
|
||||
describe("Variable") {
|
||||
let context = Context(dictionary: [
|
||||
"name": "Kyle",
|
||||
"contacts": ["Katie", "Carlton"],
|
||||
"profiles": [
|
||||
"github": "kylef",
|
||||
],
|
||||
])
|
||||
fileprivate struct Article {
|
||||
let author: Person
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
context["object"] = Object()
|
||||
#endif
|
||||
fileprivate class WebSite {
|
||||
let url: String = "blog.com"
|
||||
}
|
||||
|
||||
$0.it("can resolve a string literal with double quotes") {
|
||||
let variable = Variable("\"name\"")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "name"
|
||||
fileprivate class Blog: WebSite {
|
||||
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
|
||||
let featuring: Article? = Article(author: Person(name: "Jhon"))
|
||||
}
|
||||
|
||||
class VariableTests: XCTestCase {
|
||||
func testVariable() {
|
||||
describe("Variable") {
|
||||
let context = Context(dictionary: [
|
||||
"name": "Kyle",
|
||||
"contacts": ["Katie", "Carlton"],
|
||||
"profiles": [
|
||||
"github": "kylef",
|
||||
],
|
||||
"counter": [
|
||||
"count": "kylef",
|
||||
],
|
||||
"article": Article(author: Person(name: "Kyle")),
|
||||
"tuple": (one: 1, two: 2)
|
||||
])
|
||||
|
||||
#if os(OSX)
|
||||
context["object"] = Object()
|
||||
#endif
|
||||
context["blog"] = Blog()
|
||||
|
||||
$0.it("can resolve a string literal with double quotes") {
|
||||
let variable = Variable("\"name\"")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "name"
|
||||
}
|
||||
|
||||
$0.it("can resolve a string literal with single quotes") {
|
||||
let variable = Variable("'name'")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "name"
|
||||
}
|
||||
|
||||
$0.it("can resolve an integer literal") {
|
||||
let variable = Variable("5")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 5
|
||||
}
|
||||
|
||||
$0.it("can resolve an float literal") {
|
||||
let variable = Variable("3.14")
|
||||
let result = try variable.resolve(context) as? Number
|
||||
try expect(result) == 3.14
|
||||
}
|
||||
|
||||
$0.it("can resolve boolean literal") {
|
||||
try expect(Variable("true").resolve(context) as? Bool) == true
|
||||
try expect(Variable("false").resolve(context) as? Bool) == false
|
||||
try expect(Variable("0").resolve(context) as? Int) == 0
|
||||
try expect(Variable("1").resolve(context) as? Int) == 1
|
||||
}
|
||||
|
||||
$0.it("can resolve a string variable") {
|
||||
let variable = Variable("name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
|
||||
$0.context("given string") {
|
||||
$0.it("can resolve an item via it's index") {
|
||||
let variable = Variable("name.0")
|
||||
let result = try variable.resolve(context) as? Character
|
||||
try expect(result) == "K"
|
||||
|
||||
let variable1 = Variable("name.1")
|
||||
let result1 = try variable1.resolve(context) as? Character
|
||||
try expect(result1) == "y"
|
||||
}
|
||||
|
||||
$0.it("can resolve an item via unknown index") {
|
||||
let variable = Variable("name.5")
|
||||
let result = try variable.resolve(context) as? Character
|
||||
try expect(result).to.beNil()
|
||||
|
||||
let variable1 = Variable("name.-5")
|
||||
let result1 = try variable1.resolve(context) as? Character
|
||||
try expect(result1).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("can resolve the first item") {
|
||||
let variable = Variable("name.first")
|
||||
let result = try variable.resolve(context) as? Character
|
||||
try expect(result) == "K"
|
||||
}
|
||||
|
||||
$0.it("can resolve the last item") {
|
||||
let variable = Variable("name.last")
|
||||
let result = try variable.resolve(context) as? Character
|
||||
try expect(result) == "e"
|
||||
}
|
||||
|
||||
$0.it("can get the characters count") {
|
||||
let variable = Variable("name.count")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 4
|
||||
}
|
||||
}
|
||||
|
||||
$0.context("given dictionary") {
|
||||
$0.it("can resolve an item") {
|
||||
let variable = Variable("profiles.github")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "kylef"
|
||||
}
|
||||
|
||||
$0.it("can get the count") {
|
||||
let variable = Variable("profiles.count")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 1
|
||||
}
|
||||
}
|
||||
|
||||
$0.context("given array") {
|
||||
$0.it("can resolve an item via it's index") {
|
||||
let variable = Variable("contacts.0")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Katie"
|
||||
|
||||
let variable1 = Variable("contacts.1")
|
||||
let result1 = try variable1.resolve(context) as? String
|
||||
try expect(result1) == "Carlton"
|
||||
}
|
||||
|
||||
$0.it("can resolve an item via unknown index") {
|
||||
let variable = Variable("contacts.5")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result).to.beNil()
|
||||
|
||||
let variable1 = Variable("contacts.-5")
|
||||
let result1 = try variable1.resolve(context) as? String
|
||||
try expect(result1).to.beNil()
|
||||
}
|
||||
|
||||
$0.it("can resolve the first item") {
|
||||
let variable = Variable("contacts.first")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Katie"
|
||||
}
|
||||
|
||||
$0.it("can resolve the last item") {
|
||||
let variable = Variable("contacts.last")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Carlton"
|
||||
}
|
||||
|
||||
$0.it("can get the count") {
|
||||
let variable = Variable("contacts.count")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 2
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can resolve a property with reflection") {
|
||||
let variable = Variable("article.author.name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
$0.it("can resolve a value via KVO") {
|
||||
let variable = Variable("object.title")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
|
||||
$0.it("can resolve a superclass value via KVO") {
|
||||
let variable = Variable("object.name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Foo"
|
||||
}
|
||||
|
||||
$0.it("does not crash on KVO") {
|
||||
let variable = Variable("object.fullname")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result).to.beNil()
|
||||
}
|
||||
#endif
|
||||
|
||||
$0.it("can resolve a value via reflection") {
|
||||
let variable = Variable("blog.articles.0.author.name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
|
||||
$0.it("can resolve a superclass value via reflection") {
|
||||
let variable = Variable("blog.url")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "blog.com"
|
||||
}
|
||||
|
||||
$0.it("can resolve optional variable property using reflection") {
|
||||
let variable = Variable("blog.featuring.author.name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Jhon"
|
||||
}
|
||||
|
||||
$0.it("does not render Optional") {
|
||||
var array: [Any?] = [1, nil]
|
||||
array.append(array)
|
||||
let context = Context(dictionary: ["values": array])
|
||||
|
||||
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
|
||||
try expect(VariableNode(variable: "values.1").render(context)) == ""
|
||||
}
|
||||
|
||||
$0.it("can subscript tuple by index") {
|
||||
let variable = Variable("tuple.0")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 1
|
||||
}
|
||||
|
||||
$0.it("can subscript tuple by label") {
|
||||
let variable = Variable("tuple.two")
|
||||
let result = try variable.resolve(context) as? Int
|
||||
try expect(result) == 2
|
||||
}
|
||||
|
||||
$0.describe("Subscripting") {
|
||||
$0.it("can resolve a property subscript via reflection") {
|
||||
try context.push(dictionary: ["property": "name"]) {
|
||||
let variable = Variable("article.author[property]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can subscript an array with a valid index") {
|
||||
try context.push(dictionary: ["property": 0]) {
|
||||
let variable = Variable("contacts[property]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Katie"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can subscript an array with an unknown index") {
|
||||
try context.push(dictionary: ["property": 5]) {
|
||||
let variable = Variable("contacts[property]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result).to.beNil()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
$0.it("can resolve a subscript via KVO") {
|
||||
try context.push(dictionary: ["property": "name"]) {
|
||||
let variable = Variable("object[property]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Foo"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
$0.it("can resolve an optional subscript via reflection") {
|
||||
try context.push(dictionary: ["property": "featuring"]) {
|
||||
let variable = Variable("blog[property].author.name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Jhon"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can resolve multiple subscripts") {
|
||||
try context.push(dictionary: [
|
||||
"prop1": "articles",
|
||||
"prop2": 0,
|
||||
"prop3": "name"
|
||||
]) {
|
||||
let variable = Variable("blog[prop1][prop2].author[prop3]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can resolve nested subscripts") {
|
||||
try context.push(dictionary: [
|
||||
"prop1": "prop2",
|
||||
"ref": ["prop2": "name"]
|
||||
]) {
|
||||
let variable = Variable("article.author[ref[prop1]]")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("throws for invalid keypath syntax") {
|
||||
try context.push(dictionary: ["prop": "name"]) {
|
||||
let samples = [
|
||||
".",
|
||||
"..",
|
||||
".test",
|
||||
"test..test",
|
||||
"[prop]",
|
||||
"article.author[prop",
|
||||
"article.author[[prop]",
|
||||
"article.author[prop]]",
|
||||
"article.author[]",
|
||||
"article.author[[]]",
|
||||
"article.author[prop][]",
|
||||
"article.author[prop]comments",
|
||||
"article.author[.]"
|
||||
]
|
||||
|
||||
for lookup in samples {
|
||||
let variable = Variable(lookup)
|
||||
try expect(variable.resolve(context)).toThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can resolve a string literal with single quotes") {
|
||||
let variable = Variable("'name'")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "name"
|
||||
describe("RangeVariable") {
|
||||
|
||||
let context: Context = {
|
||||
let ext = Extension()
|
||||
ext.registerFilter("incr", filter: { (arg: Any?) in toNumber(value: arg!)! + 1 })
|
||||
let environment = Environment(extensions: [ext])
|
||||
return Context(dictionary: [:], environment: environment)
|
||||
}()
|
||||
|
||||
func makeVariable(_ token: String) throws -> RangeVariable? {
|
||||
let token = Token.variable(value: token, at: .unknown)
|
||||
let parser = TokenParser(tokens: [token], environment: context.environment)
|
||||
return try RangeVariable(token.contents, parser: parser, containedIn: token)
|
||||
}
|
||||
|
||||
$0.it("can resolve closed range as array") {
|
||||
let result = try makeVariable("1...3")?.resolve(context) as? [Int]
|
||||
try expect(result) == [1, 2, 3]
|
||||
}
|
||||
|
||||
$0.it("can resolve decreasing closed range as reversed array") {
|
||||
let result = try makeVariable("3...1")?.resolve(context) as? [Int]
|
||||
try expect(result) == [3, 2, 1]
|
||||
}
|
||||
|
||||
$0.it("can use filter on range variables") {
|
||||
let result = try makeVariable("1|incr...3|incr")?.resolve(context) as? [Int]
|
||||
try expect(result) == [2, 3, 4]
|
||||
}
|
||||
|
||||
$0.it("throws when left value is not int") {
|
||||
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
|
||||
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
|
||||
}
|
||||
|
||||
$0.it("throws when right value is not int") {
|
||||
let variable = try makeVariable("k...j")
|
||||
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
|
||||
}
|
||||
|
||||
$0.it("throws is left range value is missing") {
|
||||
try expect(makeVariable("...1")).toThrow()
|
||||
}
|
||||
|
||||
$0.it("throws is right range value is missing") {
|
||||
try expect(makeVariable("1...")).toThrow()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$0.it("can resolve a string variable") {
|
||||
let variable = Variable("name")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Kyle"
|
||||
}
|
||||
describe("inline if expression") {
|
||||
|
||||
$0.it("can resolve an item from a dictionary") {
|
||||
let variable = Variable("profiles.github")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "kylef"
|
||||
}
|
||||
$0.it("can conditionally render variable") {
|
||||
let template: Template = "{{ variable if variable|uppercase == \"A\" }}"
|
||||
try expect(template.render(Context(dictionary: ["variable": "a"]))) == "a"
|
||||
try expect(template.render(Context(dictionary: ["variable": "b"]))) == ""
|
||||
}
|
||||
|
||||
$0.it("can resolve an item from an array via it's index") {
|
||||
let variable = Variable("contacts.0")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Katie"
|
||||
}
|
||||
$0.it("can render with else expression") {
|
||||
let template: Template = "{{ variable if variable|uppercase == \"A\" else fallback|uppercase }}"
|
||||
try expect(template.render(Context(dictionary: ["variable": "b", "fallback": "c"]))) == "C"
|
||||
}
|
||||
|
||||
$0.it("can resolve the first item from an array") {
|
||||
let variable = Variable("contacts.first")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Katie"
|
||||
$0.it("throws when used invalid condition") {
|
||||
let template: Template = "{{ variable if variable \"A\" }}"
|
||||
try expect(template.render(Context(dictionary: ["variable": "a"]))).toThrow()
|
||||
}
|
||||
}
|
||||
|
||||
$0.it("can resolve the last item from an array") {
|
||||
let variable = Variable("contacts.last")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Carlton"
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
$0.it("can resolve a value via KVO") {
|
||||
let variable = Variable("object.title")
|
||||
let result = try variable.resolve(context) as? String
|
||||
try expect(result) == "Hello World"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
|
||||
public func stencilTests() {
|
||||
testContext()
|
||||
testFilter()
|
||||
testLexer()
|
||||
testToken()
|
||||
testTokenParser()
|
||||
testTemplateLoader()
|
||||
testTemplate()
|
||||
testVariable()
|
||||
testNode()
|
||||
testForNode()
|
||||
testIfNode()
|
||||
testNowNode()
|
||||
testInclude()
|
||||
testInheritence()
|
||||
testStencil()
|
||||
|
||||
}
|
||||
|
||||
|
||||
class StencilTests: XCTestCase {
|
||||
func testRunStencilTests() {
|
||||
stencilTests()
|
||||
}
|
||||
}
|
||||
135
Tests/StencilTests/XCTestManifests.swift
Normal file
135
Tests/StencilTests/XCTestManifests.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import XCTest
|
||||
|
||||
extension ContextTests {
|
||||
static let __allTests = [
|
||||
("testContext", testContext),
|
||||
]
|
||||
}
|
||||
|
||||
extension EnvironmentTests {
|
||||
static let __allTests = [
|
||||
("testEnvironment", testEnvironment),
|
||||
]
|
||||
}
|
||||
|
||||
extension ExpressionsTests {
|
||||
static let __allTests = [
|
||||
("testExpressions", testExpressions),
|
||||
]
|
||||
}
|
||||
|
||||
extension FilterTagTests {
|
||||
static let __allTests = [
|
||||
("testFilterTag", testFilterTag),
|
||||
]
|
||||
}
|
||||
|
||||
extension FilterTests {
|
||||
static let __allTests = [
|
||||
("testFilter", testFilter),
|
||||
]
|
||||
}
|
||||
|
||||
extension ForNodeTests {
|
||||
static let __allTests = [
|
||||
("testForNode", testForNode),
|
||||
]
|
||||
}
|
||||
|
||||
extension IfNodeTests {
|
||||
static let __allTests = [
|
||||
("testIfNode", testIfNode),
|
||||
]
|
||||
}
|
||||
|
||||
extension IncludeTests {
|
||||
static let __allTests = [
|
||||
("testInclude", testInclude),
|
||||
]
|
||||
}
|
||||
|
||||
extension InheritenceTests {
|
||||
static let __allTests = [
|
||||
("testInheritence", testInheritence),
|
||||
]
|
||||
}
|
||||
|
||||
extension LexerTests {
|
||||
static let __allTests = [
|
||||
("testLexer", testLexer),
|
||||
("testPerformance", testPerformance),
|
||||
]
|
||||
}
|
||||
|
||||
extension NodeTests {
|
||||
static let __allTests = [
|
||||
("testNode", testNode),
|
||||
]
|
||||
}
|
||||
|
||||
extension NowNodeTests {
|
||||
static let __allTests = [
|
||||
("testNowNode", testNowNode),
|
||||
]
|
||||
}
|
||||
|
||||
extension StencilTests {
|
||||
static let __allTests = [
|
||||
("testStencil", testStencil),
|
||||
]
|
||||
}
|
||||
|
||||
extension TemplateLoaderTests {
|
||||
static let __allTests = [
|
||||
("testTemplateLoader", testTemplateLoader),
|
||||
]
|
||||
}
|
||||
|
||||
extension TemplateTests {
|
||||
static let __allTests = [
|
||||
("testTemplate", testTemplate),
|
||||
]
|
||||
}
|
||||
|
||||
extension TokenParserTests {
|
||||
static let __allTests = [
|
||||
("testTokenParser", testTokenParser),
|
||||
]
|
||||
}
|
||||
|
||||
extension TokenTests {
|
||||
static let __allTests = [
|
||||
("testToken", testToken),
|
||||
]
|
||||
}
|
||||
|
||||
extension VariableTests {
|
||||
static let __allTests = [
|
||||
("testVariable", testVariable),
|
||||
]
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
public func __allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(ContextTests.__allTests),
|
||||
testCase(EnvironmentTests.__allTests),
|
||||
testCase(ExpressionsTests.__allTests),
|
||||
testCase(FilterTagTests.__allTests),
|
||||
testCase(FilterTests.__allTests),
|
||||
testCase(ForNodeTests.__allTests),
|
||||
testCase(IfNodeTests.__allTests),
|
||||
testCase(IncludeTests.__allTests),
|
||||
testCase(InheritenceTests.__allTests),
|
||||
testCase(LexerTests.__allTests),
|
||||
testCase(NodeTests.__allTests),
|
||||
testCase(NowNodeTests.__allTests),
|
||||
testCase(StencilTests.__allTests),
|
||||
testCase(TemplateLoaderTests.__allTests),
|
||||
testCase(TemplateTests.__allTests),
|
||||
testCase(TokenParserTests.__allTests),
|
||||
testCase(TokenTests.__allTests),
|
||||
testCase(VariableTests.__allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
2
Tests/StencilTests/fixtures/child-child.html
Normal file
2
Tests/StencilTests/fixtures/child-child.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% extends "child.html" %}
|
||||
{% block header %}{{ block.super }} Child_Child_Header{% endblock %}
|
||||
3
Tests/StencilTests/fixtures/child-super.html
Normal file
3
Tests/StencilTests/fixtures/child-super.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}Child_{{ block.super }}{% endblock %}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}Child{% endblock %}
|
||||
{% block header %}Super_{{ block.super }} Child_Header{% endblock %}
|
||||
{% block body %}Child_Body{% endblock %}
|
||||
|
||||
1131
Tests/StencilTests/fixtures/huge.html
Normal file
1131
Tests/StencilTests/fixtures/huge.html
Normal file
File diff suppressed because it is too large
Load Diff
2
Tests/StencilTests/fixtures/invalid-base.html
Normal file
2
Tests/StencilTests/fixtures/invalid-base.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% block header %}Header{% endblock %}
|
||||
{% block body %}Body {{ target|unknown }} {% endblock %}
|
||||
3
Tests/StencilTests/fixtures/invalid-child-super.html
Normal file
3
Tests/StencilTests/fixtures/invalid-child-super.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% extends "invalid-base.html" %}
|
||||
{% block body %}Child {{ block.super }}{% endblock %}
|
||||
|
||||
1
Tests/StencilTests/fixtures/invalid-include.html
Normal file
1
Tests/StencilTests/fixtures/invalid-include.html
Normal file
@@ -0,0 +1 @@
|
||||
Hello {{ target|unknown }}!
|
||||
225
docs/Makefile
Normal file
225
docs/Makefile
Normal file
@@ -0,0 +1,225 @@
|
||||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " applehelp to make an Apple Help Book"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " epub3 to make an epub3"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
@echo " dummy to check syntax errors of document sources"
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
.PHONY: html
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
.PHONY: dirhtml
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
.PHONY: singlehtml
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
.PHONY: pickle
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
.PHONY: json
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
.PHONY: htmlhelp
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
.PHONY: qthelp
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Stencil.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Stencil.qhc"
|
||||
|
||||
.PHONY: applehelp
|
||||
applehelp:
|
||||
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
|
||||
@echo
|
||||
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
|
||||
@echo "N.B. You won't be able to view it unless you put it in" \
|
||||
"~/Library/Documentation/Help or install it in your application" \
|
||||
"bundle."
|
||||
|
||||
.PHONY: devhelp
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/Stencil"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Stencil"
|
||||
@echo "# devhelp"
|
||||
|
||||
.PHONY: epub
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
.PHONY: epub3
|
||||
epub3:
|
||||
$(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
|
||||
@echo
|
||||
@echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
|
||||
|
||||
.PHONY: latex
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
.PHONY: latexpdf
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
.PHONY: latexpdfja
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
.PHONY: text
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
.PHONY: man
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
.PHONY: texinfo
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
.PHONY: info
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
.PHONY: gettext
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
.PHONY: changes
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
.PHONY: linkcheck
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
.PHONY: doctest
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
.PHONY: coverage
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
.PHONY: xml
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
.PHONY: pseudoxml
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
|
||||
.PHONY: dummy
|
||||
dummy:
|
||||
$(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
|
||||
@echo
|
||||
@echo "Build finished. Dummy builder generates no files."
|
||||
33
docs/_templates/sidebar_intro.html
vendored
Normal file
33
docs/_templates/sidebar_intro.html
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<h1><a href="/">Stencil</a></h1>
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=stencilproject&repo=Stencil&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px">
|
||||
</iframe>
|
||||
</p>
|
||||
|
||||
<p>Stencil is a simple and powerful template language for Swift.</p>
|
||||
|
||||
<div class="social">
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kylef&type=follow&count=false"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200" height="20">
|
||||
</iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://twitter.com/kylefuller" class="twitter-follow-button" data-show-count="false">Follow @kylefuller</a> <script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js,fjs);}}(document, 'script', 'twitter-wjs');</script>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Other Projects</h3>
|
||||
|
||||
<p>More <a href="https://fuller.li/">Kyle Fuller</a> projects:</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/kylef/Commander">Commander</a></li>
|
||||
<li><a href="https://curassow.fuller.li/">Curassow</a></li>
|
||||
<li><a href="https://github.com/kylef/Spectre">Spectre</a></li>
|
||||
<li><a href="https://github.com/kylef/heroku-buildpack-swift">Heroku Swift buildpack</a></li>
|
||||
</ul>
|
||||
150
docs/api.rst
Normal file
150
docs/api.rst
Normal file
@@ -0,0 +1,150 @@
|
||||
Template API
|
||||
============
|
||||
|
||||
This document describes Stencils Swift API, and not the Swift template language.
|
||||
|
||||
.. contents:: :depth: 2
|
||||
|
||||
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 extensions when creating an environment:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let environment = Environment(loader: ..., extensions: [...])
|
||||
|
||||
Rendering a Template
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Environment provides convinience 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.renderTemplate(string: template, context: context)
|
||||
|
||||
Rendering a template from the configured loader:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let context = ["name": "Kyle"]
|
||||
let rendered = environment.renderTemplate(name: "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")
|
||||
|
||||
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.
|
||||
|
||||
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])
|
||||
|
||||
|
||||
DictionaryLoader
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Loads templates from a dictionary.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
DictionaryLoader(templates: ["index.html": "Hello World"])
|
||||
|
||||
|
||||
Custom Loaders
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
``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(name: name, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
A ``Context`` is a structure containing any templates you would like to use in
|
||||
a template. It’s 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 would normally only access the ``Context`` within a custom template tag or
|
||||
filter.
|
||||
|
||||
Subscripting
|
||||
~~~~~~~~~~~~
|
||||
|
||||
You can use subscripting to get and set values from the context.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
context["key"] = value
|
||||
let value = context["key"]
|
||||
|
||||
``push()``
|
||||
~~~~~~~~~~
|
||||
|
||||
A ``Context`` is a stack. You can push a new level onto the ``Context`` so that
|
||||
modifications can easily be poped off. This is useful for isolating mutations
|
||||
into scope of a template tag. Such as ``{% if %}`` and ``{% for %}`` tags.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
context.push(["name": "example"]) {
|
||||
// context contains name which is `example`.
|
||||
}
|
||||
|
||||
// name is popped off the context after the duration of the closure.
|
||||
|
||||
``flatten()``
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Using ``flatten()`` method you can get whole ``Context`` stack as one
|
||||
dictionary including all variables.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let dictionary = context.flatten()
|
||||
388
docs/builtins.rst
Normal file
388
docs/builtins.rst
Normal file
@@ -0,0 +1,388 @@
|
||||
Built-in template tags and filters
|
||||
==================================
|
||||
|
||||
.. _built-in-tags:
|
||||
|
||||
Built-in Tags
|
||||
-------------
|
||||
|
||||
``for``
|
||||
~~~~~~~
|
||||
|
||||
A for loop allows you to iterate over an array found by variable lookup.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<ul>
|
||||
{% for user in users %}
|
||||
<li>{{ user }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
The ``for`` tag can iterate over dictionaries.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<ul>
|
||||
{% for key, value in dict %}
|
||||
<li>{{ key }}: {{ value }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
It can also iterate over ranges, tuple elements, structs' and classes' stored properties (using ``Mirror``).
|
||||
|
||||
You can iterate over range literals created using ``N...M`` syntax, both in ascending and descending order:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<ul>
|
||||
{% for i in 1...array.count %}
|
||||
<li>{{ i }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
The ``for`` tag can contain optional ``where`` expression to filter out
|
||||
elements on which this expression evaluates to false.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<ul>
|
||||
{% for user in users where user.name != "Kyle" %}
|
||||
<li>{{ user }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
The ``for`` tag can take an optional ``{% empty %}`` block that will be
|
||||
displayed if the given list is empty or could not be found.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<ul>
|
||||
{% for user in users %}
|
||||
<li>{{ user }}</li>
|
||||
{% empty %}
|
||||
<li>There are no users.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
The for block sets a few variables available within the loop:
|
||||
|
||||
- ``first`` - True if this is the first time through the loop
|
||||
- ``last`` - True if this is the last time through the loop
|
||||
- ``counter`` - The current iteration of the loop (1 indexed)
|
||||
- ``counter0`` - The current iteration of the loop (0 indexed)
|
||||
- ``length`` - The total length of the loop
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% for user in users %}
|
||||
{% if forloop.first %}
|
||||
This is the first user.
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% for user in users %}
|
||||
This is user number {{ forloop.counter }} user.
|
||||
{% endfor %}
|
||||
|
||||
|
||||
``if``
|
||||
~~~~~~
|
||||
|
||||
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to
|
||||
true the contents of the block are processed. Being true is defined as:
|
||||
|
||||
* Present in the context
|
||||
* Being non-empty (dictionaries or arrays)
|
||||
* Not being a false boolean value
|
||||
* Not being a numerical value of 0 or below
|
||||
* Not being an empty string
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if admin %}
|
||||
The user is an administrator.
|
||||
{% elif user %}
|
||||
A user is logged in.
|
||||
{% else %}
|
||||
No user was found.
|
||||
{% endif %}
|
||||
|
||||
Operators
|
||||
^^^^^^^^^
|
||||
|
||||
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables
|
||||
or to negate a variable.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if one and two %}
|
||||
Both one and two evaluate to true.
|
||||
{% endif %}
|
||||
|
||||
{% if not one %}
|
||||
One evaluates to false
|
||||
{% endif %}
|
||||
|
||||
{% if one or two %}
|
||||
Either one or two evaluates to true.
|
||||
{% endif %}
|
||||
|
||||
{% if not one or two %}
|
||||
One does not evaluate to false or two evaluates to true.
|
||||
{% endif %}
|
||||
|
||||
You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has
|
||||
higest precedence followed by ``and``. For example:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if one or two and three %}
|
||||
|
||||
Will be treated as:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
one or (two and three)
|
||||
|
||||
You can use parentheses to change operator precedence. For example:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if (one or two) and three %}
|
||||
|
||||
Will be treated as:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
(one or two) and three
|
||||
|
||||
|
||||
``==`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value == other_value %}
|
||||
value is equal to other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The equality operator only supports numerical, string and boolean types.
|
||||
|
||||
``!=`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value != other_value %}
|
||||
value is not equal to other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The inequality operator only supports numerical, string and boolean types.
|
||||
|
||||
``<`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value < other_value %}
|
||||
value is less than other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The less than operator only supports numerical types.
|
||||
|
||||
``<=`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value <= other_value %}
|
||||
value is less than or equal to other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The less than equal operator only supports numerical types.
|
||||
|
||||
``>`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value > other_value %}
|
||||
value is more than other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The more than operator only supports numerical types.
|
||||
|
||||
``>=`` operator
|
||||
"""""""""""""""
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if value >= other_value %}
|
||||
value is more than or equal to other_value
|
||||
{% endif %}
|
||||
|
||||
.. note:: The more than equal operator only supports numerical types.
|
||||
|
||||
``ifnot``
|
||||
~~~~~~~~~
|
||||
|
||||
.. note:: ``{% ifnot %}`` is deprecated. You should use ``{% if not %}``.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% ifnot variable %}
|
||||
The variable was NOT found in the current context.
|
||||
{% else %}
|
||||
The variable was found.
|
||||
{% endif %}
|
||||
|
||||
``now``
|
||||
~~~~~~~
|
||||
|
||||
``filter``
|
||||
~~~~~~~~~~
|
||||
|
||||
Filters the contents of the block.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% filter lowercase %}
|
||||
This Text Will Be Lowercased.
|
||||
{% endfilter %}
|
||||
|
||||
You can chain multiple filters with a pipe (`|`).
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% filter lowercase|capitalize %}
|
||||
This Text Will First Be Lowercased, Then The First Character Will BE
|
||||
Capitalised.
|
||||
{% endfilter %}
|
||||
|
||||
``include``
|
||||
~~~~~~~~~~~
|
||||
|
||||
You can include another template using the `include` tag.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% include "comment.html" %}
|
||||
|
||||
By default the included file gets passed the current context. You can pass a sub context by using an optional 2nd parameter as a lookup in the current context.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% include "comment.html" comment %}
|
||||
|
||||
The `include` tag requires you to provide a loader which will be used to lookup
|
||||
the template.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let environment = Environment(bundle: [Bundle.main])
|
||||
let template = environment.loadTemplate(name: "index.html")
|
||||
|
||||
``extends``
|
||||
~~~~~~~~~~~
|
||||
|
||||
Extends the template from a parent template.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
See :ref:`template-inheritance` for more information.
|
||||
|
||||
``block``
|
||||
~~~~~~~~~
|
||||
|
||||
Defines a block that can be overridden by child templates. See
|
||||
:ref:`template-inheritance` for more information.
|
||||
|
||||
.. _built-in-filters:
|
||||
|
||||
Built-in Filters
|
||||
----------------
|
||||
|
||||
``capitalize``
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
The capitalize filter allows you to capitalize a string.
|
||||
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ "stencil"|capitalize }}
|
||||
|
||||
``uppercase``
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
The uppercase filter allows you to transform a string to uppercase.
|
||||
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ "Stencil"|uppercase }}
|
||||
|
||||
``lowercase``
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
The uppercase filter allows you to transform a string to lowercase.
|
||||
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ "Stencil"|lowercase }}
|
||||
|
||||
``default``
|
||||
~~~~~~~~~~~
|
||||
|
||||
If a variable not present in the context, use given default. Otherwise, use the
|
||||
value of the variable. For example:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
Hello {{ name|default:"World" }}
|
||||
|
||||
``join``
|
||||
~~~~~~~~
|
||||
|
||||
Join an array of items.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ value|join:", " }}
|
||||
|
||||
.. note:: The value MUST be an array. Default argument value is empty string.
|
||||
|
||||
``split``
|
||||
~~~~~~~~~
|
||||
|
||||
Split string into substrings by separator.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ value|split:", " }}
|
||||
|
||||
.. note:: The value MUST be a String. Default argument value is a single-space string.
|
||||
|
||||
``indent``
|
||||
~~~~~~~~~
|
||||
|
||||
Indents lines of rendered value or block.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ value|indent:2," ",true }}
|
||||
|
||||
Filter accepts several arguments:
|
||||
|
||||
* indentation width: number of indentation characters to indent lines with. Default is ``4``.
|
||||
* indentation character: character to be used for indentation. Default is a space.
|
||||
* indent first line: whether first line of output should be indented or not. Default is ``false``.
|
||||
|
||||
341
docs/conf.py
Normal file
341
docs/conf.py
Normal file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Stencil documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sun Nov 27 05:54:36 2016.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = []
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The encoding of source files.
|
||||
#
|
||||
# source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'Stencil'
|
||||
copyright = '2016, Kyle Fuller'
|
||||
author = 'Kyle Fuller'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.13.0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.13.0'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#
|
||||
# today = ''
|
||||
#
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#
|
||||
# today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This patterns also effect to html_static_path and html_extra_path
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#
|
||||
# default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#
|
||||
# add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#
|
||||
# add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#
|
||||
# show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
# modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
# keep_warnings = False
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'alabaster'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
# html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
# html_theme_path = []
|
||||
|
||||
# The name for this set of Sphinx documents.
|
||||
# "<project> v<release> documentation" by default.
|
||||
#
|
||||
# html_title = 'Stencil v0.6.0'
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#
|
||||
# html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#
|
||||
# html_logo = None
|
||||
|
||||
# The name of an image file (relative to this directory) to use as a favicon of
|
||||
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#
|
||||
# html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#
|
||||
# html_extra_path = []
|
||||
|
||||
# If not None, a 'Last updated on:' timestamp is inserted at every page
|
||||
# bottom, using the given strftime format.
|
||||
# The empty string is equivalent to '%b %d, %Y'.
|
||||
#
|
||||
# html_last_updated_fmt = None
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#
|
||||
# html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
html_sidebars = {
|
||||
'index': ['sidebar_intro.html', 'searchbox.html'],
|
||||
'**': ['sidebar_intro.html', 'localtoc.html', 'relations.html', 'searchbox.html'],
|
||||
}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#
|
||||
# html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#
|
||||
# html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#
|
||||
# html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#
|
||||
# html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#
|
||||
# html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#
|
||||
# html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#
|
||||
# html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#
|
||||
# html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
# html_file_suffix = None
|
||||
|
||||
# Language to be used for generating the HTML full-text search index.
|
||||
# Sphinx supports the following languages:
|
||||
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
|
||||
# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh'
|
||||
#
|
||||
# html_search_language = 'en'
|
||||
|
||||
# A dictionary with options for the search language support, empty by default.
|
||||
# 'ja' uses this config value.
|
||||
# 'zh' user can custom change `jieba` dictionary path.
|
||||
#
|
||||
# html_search_options = {'type': 'default'}
|
||||
|
||||
# The name of a javascript file (relative to the configuration directory) that
|
||||
# implements a search results scorer. If empty, the default will be used.
|
||||
#
|
||||
# html_search_scorer = 'scorer.js'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'Stencildoc'
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'Stencil.tex', 'Stencil Documentation',
|
||||
'Kyle Fuller', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#
|
||||
# latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#
|
||||
# latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#
|
||||
# latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#
|
||||
# latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#
|
||||
# latex_appendices = []
|
||||
|
||||
# It false, will not define \strong, \code, itleref, \crossref ... but only
|
||||
# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added
|
||||
# packages.
|
||||
#
|
||||
# latex_keep_old_macro_names = True
|
||||
|
||||
# If false, no module index is generated.
|
||||
#
|
||||
# latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'stencil', 'Stencil Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#
|
||||
# man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'Stencil', 'Stencil Documentation',
|
||||
author, 'Stencil', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#
|
||||
# texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#
|
||||
# texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#
|
||||
# texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#
|
||||
# texinfo_no_detailmenu = False
|
||||
81
docs/custom-template-tags-and-filters.rst
Normal file
81
docs/custom-template-tags-and-filters.rst
Normal file
@@ -0,0 +1,81 @@
|
||||
Custom Template Tags and Filters
|
||||
================================
|
||||
|
||||
You can build your own custom filters and tags and pass them down while
|
||||
rendering your template. Any custom filters or tags must be registered with a
|
||||
extension which contains all filters and tags available to the template.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let ext = Extension()
|
||||
// Register your filters and tags with the extension
|
||||
|
||||
let environment = Environment(extensions: [ext])
|
||||
try environment.renderTemplate(name: "example.html")
|
||||
|
||||
Custom Filters
|
||||
--------------
|
||||
|
||||
Registering custom filters:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
ext.registerFilter("double") { (value: Any?) in
|
||||
if let value = value as? Int {
|
||||
return value * 2
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
Registering custom filters with arguments:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
ext.registerFilter("multiply") { (value: Any?, arguments: [Any?]) in
|
||||
let amount: Int
|
||||
|
||||
if let value = arguments.first as? Int {
|
||||
amount = value
|
||||
} else {
|
||||
throw TemplateSyntaxError("multiple tag must be called with an integer argument")
|
||||
}
|
||||
|
||||
if let value = value as? Int {
|
||||
return value * amount
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
Registering custom boolean filters:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
ext.registerFilter("ordinary", negativeFilterName: "odd") { (value: Any?) in
|
||||
if let value = value as? Int {
|
||||
return myInt % 2 == 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Custom Tags
|
||||
-----------
|
||||
|
||||
You can build a custom template tag. There are a couple of APIs to allow you to
|
||||
write your own custom tags. The following is the simplest form:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
ext.registerSimpleTag("custom") { context in
|
||||
return "Hello World"
|
||||
}
|
||||
|
||||
When your tag is used via ``{% custom %}`` it will execute the registered block
|
||||
of code allowing you to modify or retrieve a value from the context. Then
|
||||
return either a string rendered in your template, or throw an error.
|
||||
|
||||
If you want to accept arguments or to capture different tokens between two sets
|
||||
of template tags. You will need to call the ``registerTag`` API which accepts a
|
||||
closure to handle the parsing. You can find examples of the ``now``, ``if`` and
|
||||
``for`` tags found inside Stencil source code.
|
||||
37
docs/getting-started.rst
Normal file
37
docs/getting-started.rst
Normal file
@@ -0,0 +1,37 @@
|
||||
Getting Started
|
||||
===============
|
||||
|
||||
The easiest way to render a template using Stencil is to create a template and
|
||||
call render on it providing a context.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let template = Template(templateString: "Hello {{ name }}")
|
||||
try template.render(["name": "kyle"])
|
||||
|
||||
For more advanced uses, you would normally create an ``Environment`` and call
|
||||
the ``renderTemplate`` convinience method.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let environment = Environment()
|
||||
|
||||
let context = ["name": "kyle"]
|
||||
try environment.renderTemplate(string: "Hello {{ name }}", context: context)
|
||||
|
||||
Template Loaders
|
||||
----------------
|
||||
|
||||
A template loader allows you to load files from disk or elsewhere. Using a
|
||||
``FileSystemLoader`` we can easily render a template from disk.
|
||||
|
||||
For example, to render a template called ``index.html`` inside the
|
||||
``templates/`` directory we can use the following:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
let fsLoader = FileSystemLoader(paths: ["templates/"])
|
||||
let environment = Environment(loader: fsLoader)
|
||||
|
||||
let context = ["name": "kyle"]
|
||||
try environment.renderTemplate(name: "index.html", context: context)
|
||||
64
docs/index.rst
Normal file
64
docs/index.rst
Normal file
@@ -0,0 +1,64 @@
|
||||
The Stencil template language
|
||||
=============================
|
||||
|
||||
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
|
||||
feel right at home with Stencil.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
There are {{ articles.count }} articles.
|
||||
|
||||
<ul>
|
||||
{% for article in articles %}
|
||||
<li>{{ article.title }} by {{ article.author }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
import Stencil
|
||||
|
||||
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"),
|
||||
]
|
||||
]
|
||||
|
||||
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"])
|
||||
let rendered = try environment.renderTemplate(name: context)
|
||||
|
||||
print(rendered)
|
||||
|
||||
The User Guide
|
||||
--------------
|
||||
|
||||
For Template Writers
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Resources for Stencil template authors to write Stencil templates.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
templates
|
||||
builtins
|
||||
|
||||
For Developers
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Resources to help you integrate Stencil into a Swift project.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
installation
|
||||
getting-started
|
||||
api
|
||||
custom-template-tags-and-filters
|
||||
52
docs/installation.rst
Normal file
52
docs/installation.rst
Normal file
@@ -0,0 +1,52 @@
|
||||
Installation
|
||||
============
|
||||
|
||||
Swift Package Manager
|
||||
---------------------
|
||||
|
||||
If you're using the Swift Package Manager, you can add ``Stencil`` to your
|
||||
dependencies inside ``Package.swift``.
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MyApplication",
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/stencilproject/Stencil.git", majorVersion: 0, minor: 13),
|
||||
]
|
||||
)
|
||||
|
||||
CocoaPods
|
||||
---------
|
||||
|
||||
If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
|
||||
``pod install``.
|
||||
|
||||
.. code-block:: ruby
|
||||
|
||||
pod 'Stencil', '~> 0.13.0'
|
||||
|
||||
Carthage
|
||||
--------
|
||||
|
||||
.. note:: Use at your own risk. We don't offer support for Carthage and instead recommend you use Swift Package Manager.
|
||||
|
||||
1) Add ``Stencil`` to your ``Cartfile``:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
github "stencilproject/Stencil" ~> 0.13.0
|
||||
|
||||
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ carthage update
|
||||
$ (cd Carthage/Checkouts/Stencil && swift package generate-xcodeproj)
|
||||
$ carthage build
|
||||
|
||||
3) Follow the Carthage steps to add the built frameworks to your project.
|
||||
|
||||
To learn more about this approach see `Using Swift Package Manager with Carthage <https://fuller.li/posts/using-swift-package-manager-with-carthage/>`_.
|
||||
185
docs/templates.rst
Normal file
185
docs/templates.rst
Normal file
@@ -0,0 +1,185 @@
|
||||
Language overview
|
||||
==================
|
||||
|
||||
- ``{{ ... }}`` for variables to print to the template output
|
||||
- ``{% ... %}`` for tags
|
||||
- ``{# ... #}`` for comments not included in the template output
|
||||
|
||||
Variables
|
||||
---------
|
||||
|
||||
A variable can be defined in your template using the following:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ variable }}
|
||||
|
||||
Stencil will look up the variable inside the current variable context and
|
||||
evaluate it. When a variable contains a dot, it will try doing the
|
||||
following lookup:
|
||||
|
||||
- Context lookup
|
||||
- Dictionary lookup
|
||||
- Array and string lookup (first, last, count, by index)
|
||||
- Key value coding lookup
|
||||
- Type introspection (via ``Mirror``)
|
||||
|
||||
For example, if `people` was an array:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
There are {{ people.count }} people. {{ people.first }} is the first
|
||||
person, followed by {{ people.1 }}.
|
||||
|
||||
You can also use the subscript operator for indirect evaluation. The expression
|
||||
between brackets will be evaluated first, before the actual lookup will happen.
|
||||
|
||||
For example, if you have the following context:
|
||||
|
||||
.. code-block:: swift
|
||||
|
||||
[
|
||||
"item": [
|
||||
"name": "John"
|
||||
],
|
||||
"key": "name"
|
||||
]
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
The result of {{ item[key] }} will be the same as {{ item.name }}. It will first evaluate the result of {{ key }}, and only then evaluate the lookup expression.
|
||||
|
||||
Filters
|
||||
~~~~~~~
|
||||
|
||||
Filters allow you to transform the values of variables. For example, they look like:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{{ variable|uppercase }}
|
||||
|
||||
See :ref:`all builtin filters <built-in-filters>`.
|
||||
|
||||
Tags
|
||||
----
|
||||
|
||||
Tags are a mechanism to execute a piece of code, allowing you to have
|
||||
control flow within your template.
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% if variable %}
|
||||
{{ variable }} was found.
|
||||
{% endif %}
|
||||
|
||||
A tag can also affect the context and define variables as follows:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% for item in items %}
|
||||
{{ item }}
|
||||
{% endfor %}
|
||||
|
||||
Stencil includes of built-in tags which are listed below. You can also
|
||||
extend Stencil by providing your own tags.
|
||||
|
||||
See :ref:`all builtin tags <built-in-tags>`.
|
||||
|
||||
Comments
|
||||
--------
|
||||
|
||||
To comment out part of your template, you can use the following syntax:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{# My comment is completely hidden #}
|
||||
|
||||
.. _template-inheritance:
|
||||
|
||||
Template inheritance
|
||||
--------------------
|
||||
|
||||
Template inheritance allows the common components surrounding individual pages
|
||||
to be shared across other templates. You can define blocks which can be
|
||||
overidden in any child template.
|
||||
|
||||
Let's take a look at an example. Here is our base template (``base.html``):
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}Example{% endblock %}</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<aside>
|
||||
{% block sidebar %}
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/notes/">Notes</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
This example declares three blocks, ``title``, ``sidebar`` and ``content``. We
|
||||
can use the ``{% extends %}`` template tag to inherit from out base template
|
||||
and then use ``{% block %}`` to override any blocks from our base template.
|
||||
|
||||
A child template might look like the following:
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Notes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% for note in notes %}
|
||||
<h2>{{ note }}</h2>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
.. note:: You can use ``{{ block.super }}` inside a block to render the contents of the parent block inline.
|
||||
|
||||
Since our child template doesn't declare a sidebar block. The original sidebar
|
||||
from our base template will be used. Depending on the content of ``notes`` our
|
||||
template might be rendered like the following:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Notes</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<aside>
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/notes/">Notes</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<h2>Pick up food</h2>
|
||||
<h2>Do laundry</h2>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
You can use as many levels of inheritance as needed. One common way of using
|
||||
inheritance is the following three-level approach:
|
||||
|
||||
* Create a ``base.html`` template that holds the main look-and-feel of your site.
|
||||
* Create a ``base_SECTIONNAME.html`` template for each “section” of your site.
|
||||
For example, ``base_news.html``, ``base_news.html``. These templates all
|
||||
extend ``base.html`` and include section-specific styles/design.
|
||||
* Create individual templates for each type of page, such as a news article or
|
||||
blog entry. These templates extend the appropriate section template.
|
||||
Reference in New Issue
Block a user