155 Commits

Author SHA1 Message Date
David Jennes
b476e50f89 Forgot version bump 2018-08-30 13:52:14 +02:00
David Jennes
2ed5763fe4 Release 0.12.1 2018-08-30 13:47:19 +02:00
David Jennes
fff3d21e37 Merge pull request #227 from stencilproject/feature/pathkit-cocoapods
Updated the PathKit dependency to 0.9.0 in CocoaPods
2018-08-30 13:41:52 +02:00
David Jennes
99be5f0459 Changelog entry 2018-08-30 13:29:52 +02:00
David Jennes
2eeb7babd3 Updated the PathKit dependency to 0.9.0 in the podspec 2018-08-30 13:29:45 +02:00
David Jennes
fc404b25d8 Update the docs version number 2018-08-26 23:27:37 +02:00
David Jennes
42972a1c10 Reset Changelog 2018-08-26 23:24:29 +02:00
David Jennes
6a4959cea0 Release 0.12.0 2018-08-26 23:17:13 +02:00
David Jennes
ffe8f9dab0 Merge pull request #167 from stencilproject/errors-logs-improvements
Errors logs improvements
2018-08-15 14:06:49 +02:00
Ilya Puchka
96a004eb34 replace implicitly unwrapped optional with fatalError 2018-08-14 00:56:10 +01:00
Ilya Puchka
92ebfe59b1 removed unneeded error reporter references 2018-08-13 23:05:33 +01:00
Ilya Puchka
71ad162268 more indentations fixed 2018-08-13 20:02:07 +01:00
Ilya Puchka
b9702afbd4 fixed indetnations 2018-08-13 20:00:27 +01:00
Ilya Puchka
4f1a5b3e3d store reference to token when parsing range variable 2018-08-12 22:25:25 +01:00
Ilya Puchka
3a4cd8aa27 Merge branch 'master' into errors-logs-improvements 2018-08-12 22:08:13 +01:00
David Jennes
4f14b4b044 Merge pull request #221 from stencilproject/fix/update-changelog-entry-format
Update changelog entry format
2018-07-11 23:31:00 +02:00
David Jennes
b66abc3112 Update CHANGELOG.md 2018-07-11 23:16:06 +02:00
David Jennes
5bbd994581 Merge pull request #216 from trupin/master
Added Weaver to the list of projects using Stencil
2018-07-11 22:45:42 +02:00
Theophane RUPIN
3995ff9acf Added Weaver to the list of projects using Stencil 2018-05-20 16:52:22 -07:00
David Jennes
2e18892f4c Subscript syntax for Variables (#215)
* Implement variable indirect resolution

* Add some tests

* Changelog entry

* Update documentation

* Rework the syntax to use brackets instead of a $

* Move the lookup parser into it's own file

* Add invalid syntax tests

* Swift 3 support

* Rename some things + extra test
2018-05-19 21:03:51 +01:00
Ilya Puchka
39ed9aa753 Fixed using spaces in filter expressions and variables lists (#178)
* fixed using spaces in filter expression

* fixed breaking variables lists and filters by spaces

* simplified smartJoin

* avoid force unwrap
2018-05-13 01:06:38 +01:00
Yonas Kolb
d935f65d56 Merge pull request #214 from yonaskolb/include_context
Context param for Include block
2018-05-10 18:01:02 +10:00
Yonas Kolb
2627d3e0d1 update changelog formatting 2018-05-10 17:50:59 +10:00
Yonas Kolb
1e77f1e85f document new include param 2018-05-07 18:47:18 +10:00
Yonas Kolb
47f2b33d80 code formatting 2018-05-07 18:47:18 +10:00
Yonas Kolb
1427e10698 update changelog 2018-05-07 18:47:18 +10:00
Yonas Kolb
e070ae7851 Merge branch 'master' into include_context 2018-05-07 01:43:02 +10:00
Kyle Fuller
fc6c0208b2 fix(cocoapods): Update link to source repository 2018-05-05 17:56:03 -07:00
Kyle Fuller
34dbafa789 docs: Update link to Travis CI 2018-05-05 17:55:14 -07:00
Ilya Puchka
eb8c875853 docs: Update documentation (#213)
* Update documentation

* Update builtins.rst
2018-05-05 15:33:11 -07:00
Yonas Kolb
098af2a7b6 remove "using" param name 2018-04-30 12:00:54 +10:00
Yonas Kolb
7679b48164 add contex to include 2018-04-30 11:45:22 +10:00
Ilya Puchka
7c499cc077 fix(tokeniser): Tokenising a quote inside quoted parameter (#210) 2018-04-17 11:28:28 -07:00
Ole Begemann
88e54ab4ba docs: Fix typo of precedence (#212) 2018-04-17 11:25:15 -07:00
Kyle Fuller
c2e25f25ac Release 0.11.0 2018-04-04 18:30:05 -07:00
Ilya Puchka
fe01beb4bb feat(for loop): Support range literals (#192) 2018-04-04 17:56:58 -07:00
Ilya Puchka
2e6a7215c5 fix: subscripting tuple by value index (#172) 2018-04-04 17:25:13 -07:00
Ilya Puchka
f457cddd3f feat(for loop): added property for loop length (#171) 2018-04-04 17:23:02 -07:00
Ilya Puchka
6b02fccf84 feat: added support for ranges in if-in expression (#193) 2018-04-04 17:22:05 -07:00
Ilya Puchka
29e859f1aa fix: Do not print Optional(...) when rendering arrays (#205) 2018-04-04 17:13:34 -07:00
Ilya Puchka
8fa0bd275c fix: accessing optional properties through reflection (#204) 2018-04-04 17:03:19 -07:00
Ilya Puchka
91847208a3 Merge pull request #177 from kylef/iterating-tuple-arrays
Iterating array of tuples with more than two values
2018-03-13 09:25:17 +00:00
Kyle Fuller
86ed8770e1 Merge branch 'master' into iterating-tuple-arrays 2018-03-13 09:12:09 +00:00
Ilya Puchka
0bc6bd974e feat: allow using new lines inside tags (#202) 2018-03-13 09:07:56 +00:00
Ilya Puchka
fa68ba9df8 feat: Added indent filter (#188) 2018-01-28 16:17:23 +00:00
Ilya Puchka
4827fb8e20 docs: Added the mention of projects that use Stencil (#176) 2018-01-22 18:30:53 +00:00
Ilya Puchka
359d086c02 feat(filters): Show similar filter names when missing filter(#186) 2018-01-22 18:27:42 +00:00
Ilya Puchka
24c9746689 fix: updated package bumping PathKit version and created package maifest for swift 3 (#184) 2018-01-22 18:24:32 +00:00
Ilya Puchka
c4a84a6375 feat: apply string filters to arrays (#190) 2018-01-22 17:20:42 +00:00
Ilya Puchka
c30597457f feat: added split fitler (#187) 2018-01-21 16:49:32 -08:00
Ilya Puchka
b54292788f fixed swift 3 compiler crash 2018-01-01 16:22:06 +01:00
Ilya Puchka
d6766b43da minor code refactoring 2018-01-01 16:22:06 +01:00
Ilya Puchka
662849e968 removed trailing witespaces 2018-01-01 16:22:06 +01:00
Ilya Puchka
4bfdb73175 removed unneeded code 2018-01-01 16:22:06 +01:00
Ilya Puchka
a165a6715f fixed typos 2018-01-01 16:22:06 +01:00
Ilya Puchka
ac2fd56e8e storing full sourcemap in token, refactored error reporting 2018-01-01 16:22:06 +01:00
Ilya Puchka
cb124319ec removed unneeded changes 2018-01-01 16:22:05 +01:00
Ilya Puchka
abeb30bb1c fix rendering templates created from string literals 2018-01-01 16:22:05 +01:00
Ilya Puchka
ed885f462a refactored environment tests 2018-01-01 16:22:05 +01:00
Ilya Puchka
7756522317 fixed error on swift 3.1 2018-01-01 16:22:05 +01:00
Ilya Puchka
8d68edd725 replaced Lexeme protocol with Token 2018-01-01 16:22:05 +01:00
Ilya Puchka
218822fcb0 updated CHANGELOG 2018-01-01 16:22:04 +01:00
Ilya Puchka
ea7e1efac7 fixed highlighting of errors happening in {{ block.super }} 2018-01-01 16:21:51 +01:00
Ilya Puchka
bb3f33724b unified setting higlighting range for errors 2018-01-01 16:21:51 +01:00
Ilya Puchka
c486617854 fixed reporting errors in child templates 2018-01-01 16:21:51 +01:00
Ilya Puchka
9a28142fa6 reporting error with its parent context 2018-01-01 16:21:51 +01:00
Ilya Puchka
53c1550c5b reporting node rendering errors using reference to node’s token 2018-01-01 16:21:50 +01:00
Ilya Puchka
27135f3ea3 changer Never return type to Error in ErrorReporter
this resolves warning related to Never type
2018-01-01 16:21:50 +01:00
Ilya Puchka
5878c323a2 fixed iterating over template lines on linux 2018-01-01 16:21:50 +01:00
Ilya Puchka
97ab3cf31d xcode backward compatibility fixes
- moved back to single line string literal
- fixed calculating string lenght
2018-01-01 16:21:50 +01:00
Ilya Puchka
7688326204 renamed Scanner property for original content 2018-01-01 16:21:50 +01:00
Ilya Puchka
079fdf39b8 added parent context to ErrorReporterContext and handling errors in include and extend nodes 2018-01-01 16:21:50 +01:00
Ilya Puchka
e59609f140 handling unknown filter errors 2018-01-01 16:21:50 +01:00
Ilya Puchka
d5f0be959f using error reporter from environment to handle syntax errors 2018-01-01 16:21:50 +01:00
Ilya Puchka
0edb38588d added ErrorReporter protocol with default implementation 2018-01-01 16:21:50 +01:00
Ilya Puchka
69cd8e4d3b replaced Token with Lexeme protocol on TemplateSyntaxError 2018-01-01 16:21:50 +01:00
Ilya Puchka
6300dbc7bf improved template syntax errors with file, line number and failed token highlighted in error message 2018-01-01 16:21:48 +01:00
Ilya Puchka
b4dc8dbb76 support for iterating array of tuples with more than two values 2018-01-01 16:11:33 +01:00
Ilya Puchka
2e80f70f67 Merge pull request #181 from kylef/fix-float-from-int
Fix creating float from int
2018-01-01 15:46:21 +01:00
Ilya Puchka
a6dba67828 fixed tests on swift 3.1 2018-01-01 15:20:02 +01:00
Ilya Puchka
691fe523b3 Merge branch 'master' into fix-float-from-int 2018-01-01 14:26:27 +01:00
Ilya Puchka
c0e66eb96f feat: Allow iterating over tuple, struct and class properties
Closes #173
2017-12-31 12:38:09 -08:00
Kyle Fuller
0156f6f37b test: Count for unordered dictionary in ForLoop tests
Closes #166
2017-12-31 12:26:25 -08:00
Ilya Puchka
79a16854e7 fixed implicit conversion of integer literals to float 2017-12-29 17:46:26 +01:00
Kyle Fuller
a4b75f3c89 docs(changelog): Add further information regarding substring comparison 2017-12-26 20:32:25 -08:00
Ilya Puchka
0f3a302108 fix(variable): Resolving variable as string literal (#168)
In Swift 4, a Substring was returned.
2017-12-26 20:27:26 -08:00
Ilya Puchka
1223efbc7e fix(default): Check for wrapped nil in filter (#162) 2017-12-24 11:11:46 -08:00
Ilya Puchka
9357df35d1 Merge pull request #152 from kylef/resolving-superclass-keys
Added recursive resolution for superclass properties
2017-11-30 15:56:46 +00:00
Ilya Puchka
a96fcff680 Merge branch 'master' into resolving-superclass-keys 2017-11-30 10:44:46 +00:00
Ilya Puchka
0017aee5a8 Merge pull request #154 from kylef/fix-block-inheritance
Fixed block inheritance with several levels
2017-11-30 10:43:51 +00:00
Ilya Puchka
1e6846867e fixed compiling in swift 3 2017-11-29 23:18:48 +01:00
Ilya Puchka
93c07e22b1 fixed block inheritance with several levels 2017-11-29 23:04:33 +01:00
Ilya Puchka
98461c75b0 updated CHANGELOG.md 2017-11-29 10:23:54 +00:00
Ilya Puchka
9994972a24 added recursive resolution for superclass properties 2017-11-29 10:18:56 +00:00
Kyle Fuller
cf7acea440 chore: Release 0.10.1 2017-11-17 18:42:34 +00:00
Dan Watson
9e24ab658b feat: Add support for Xcode 9.1 (#149) 2017-11-17 18:41:32 +00:00
Kyle Fuller
a52ee21b72 chore: Add package pins to gitignore 2017-10-28 21:38:10 +01:00
Kyle Fuller
4a93815d4c chore: Release 0.10.0
Closes #148
2017-10-28 21:37:50 +01:00
Kyle Fuller
7e88cbde11 chore: Remove .swift-version. Library supports range of versionns 2017-10-28 21:34:04 +01:00
Ilya Puchka
e7a0738bda fix(variable): checking values for nil when using mirror (#144) 2017-10-02 09:38:50 +01:00
Kyle Fuller
46f179e3ed chore: Test with Xcode 8.3, Swift 3.1.1 and Swift 4 (#145) 2017-10-01 12:15:29 +01:00
Yonas Kolb
bf4be38377 docs(for): document that dictionary for doesn't support space 2017-10-01 11:54:53 +01:00
Yonas Kolb
14bac03990 feat(variable): Allow accessing dictionary count 2017-10-01 11:54:53 +01:00
Ilya Puchka
3180b26673 feat: added in expression in if tag (#143) 2017-10-01 11:46:48 +01:00
Kyle Fuller
000e9a7f1a feat: Add DictionaryLoader to load templates from dictionary
Closes #97
2017-09-07 18:40:34 +01:00
Kyle Fuller
7b9817ed50 feat(for tag): Provide the counter 0 indexed to the context
Closes #121
2017-09-07 18:21:16 +01:00
Jan Berkel
482d595d01 fix(lexer): Handle incomplete tokens
Fixes #135
2017-09-07 18:06:55 +01:00
Jan Berkel
f1fc747897 fix(for tag): Potential ‘Index out of range’ crash
Fixes #136
2017-09-07 17:17:23 +01:00
Kyle Fuller
0444f45d2b fix(README): Supply name of template
Fixes #133
2017-08-29 13:11:17 -07:00
Anton
86bfbf215f docs: Fix typo in Custom Template Tags and Filters (#131) 2017-08-10 13:20:00 -07:00
Paul Melnikow
039bf4b7cb docs: Fix example code in getting started (#130) 2017-08-10 13:18:43 -07:00
Kyle Fuller
4308baf5f0 Merge pull request #129 from bfad/forloop-add-range-support
Add range support for for tag
2017-08-08 17:06:51 -07:00
Brett R. Toomey
2455fb9ed0 Make extensions mutable (#126) 2017-08-08 16:12:45 -07:00
Brad Lindsay
64571464d9 Add basic support for ranges in for loops
Closes kylef/Stencil#128
2017-08-08 17:38:36 -05:00
Brad Lindsay
5821e4849e Ignore Package.resolved file 2017-08-08 16:41:59 -05:00
Ian Dundas
793773f191 docs: Added example to show use of forloop.xin a for loop (#123) 2017-07-12 11:46:43 +02:00
Gustavo Barbosa
e217a9c873 fix: Typo of Manager in installation docs (#122) 2017-06-07 18:23:47 +02:00
Krzysztof Zabłocki
584ed916ab fix(lexer): Prevent deadlock with unclosed tag 2017-05-17 21:03:43 +01:00
Kyle Fuller
65a461d0a1 chore: Release 0.9.0 2017-04-19 00:04:59 +01:00
Kyle Fuller
89256b96f4 feat(for): Allow iterating over dictionary key and value
Closes #94
2017-04-19 00:03:47 +01:00
Kyle Fuller
a8d680b30e fix(for): Support looping dictionaries 2017-04-18 23:26:56 +01:00
Kyle Fuller
6f48fe2d91 fix: Resolve build warnings related to throwables and Any?
Closes #99
Closes #106
2017-04-18 23:05:02 +01:00
Kyle Fuller
4f9063c147 chore(CHANGELOG): Add entry for #98 2017-04-18 23:01:01 +01:00
Ilya Puchka
5541eae818 feat: fixed parsing several filter arguments with quotes (#98) 2017-04-18 22:58:42 +01:00
Ilya Puchka
d1717df6ff feat(for tag): Support where expressions (#96) 2017-04-11 11:39:00 +01:00
Kyle Fuller
dc8759ba34 feat(if): Support elif tags 2017-03-03 09:56:41 +00:00
Kyle Fuller
233dcfc59a refactor(if node): Support multiple conditions 2017-03-03 09:25:16 +00:00
Mrdaios
19e4f6e506 Update README.md (#103)
miss ")"
2017-03-02 00:09:02 +00:00
Kyle Fuller
e2f33d4337 chore: Update CHANGELOG for 0.8.0 2017-02-18 12:44:33 +00:00
Kyle Fuller
20b9476e4b chore: Release 0.8.0 2017-02-18 12:43:22 +00:00
Kyle Fuller
cf9714ffd0 chore: Update PathKit to 0.8.0 2017-02-18 12:21:37 +00:00
Valentin Knabel
5e78d6cc46 Use .git extension on dependencies URL (#84) 2017-02-18 12:21:09 +00:00
Kyle Fuller
99efba56e9 feat: Support filters in if expressions
Closes #90
2017-01-05 20:56:09 +00:00
Kyle Fuller
39517b7514 fix(changelog): Add entry for iOS compatibility restored 2016-12-12 18:57:01 +00:00
Kyle Fuller
fdde1dec02 refactor: Simplify string filters to use stringify 2016-12-08 16:17:26 +00:00
Kyle Fuller
8f6b403aa9 fix(join): Dont force strings 2016-12-08 16:15:57 +00:00
Kyle Fuller
2331b11a52 fix: Allow overriding default filters/tags 2016-12-07 22:32:31 +00:00
Kyle Fuller
26f30cbd9d feat: Allow subclassing templates (#79) 2016-12-07 21:46:04 +00:00
Kyle Fuller
d7b152089e feat: New extensions API (#78) 2016-12-07 21:27:31 +00:00
Kyle Fuller
d3706f074d fix: Use Float as Number instead of Float80
Closes #76
2016-12-07 20:07:53 +00:00
Kyle Fuller
aa7c36296b refactor: Deal with Number instead of Float80 2016-12-07 19:54:29 +00:00
David Jennes
2d507e7c11 Simple number literal parsing (#77) 2016-12-07 19:33:06 +00:00
Kyle Fuller
15facd97fb chore: Add testing template 2016-12-02 22:53:16 +00:00
Kyle Fuller
d75db241ac feat(block): Support rendering parent block 2016-12-02 00:59:01 +00:00
Kyle Fuller
49936c36d4 feat(variable): Allow resolving nodes and resolvables 2016-12-02 00:57:01 +00:00
Kyle Fuller
2e04a71d59 fix: Silence build warnings 2016-12-02 00:29:28 +00:00
Kyle Fuller
6d05832997 chore(docs): Add installation instructions for Carthage
Closes #27
Closes #65
2016-12-01 18:26:44 +00:00
Kyle Fuller
6871387671 feat(docs): Document template inheritence 2016-12-01 16:20:28 +00:00
Kyle Fuller
46bc1242f3 refactor(variable): Deprecate whitespace in filters 2016-12-01 02:22:51 +00:00
Kyle Fuller
24359489ce refactor(Template): Deprecate init from files 2016-12-01 01:47:19 +00:00
Kyle Fuller
f90597fba1 refactor: Revamp docs to separate template writers/devs 2016-12-01 01:33:59 +00:00
Kyle Fuller
9e2a061795 refactor: Introducing Environments 2016-12-01 01:29:48 +00:00
Kyle Fuller
2be672c6a5 feat(filter): Support filter tag 2016-11-30 18:44:44 +00:00
Kyle Fuller
2ebb79df8b refactor(loader): Throw when template not found 2016-11-30 17:22:29 +00:00
Kyle Fuller
63c2b935f7 feat(FileSystemLoader): Raise when template name escapes base 2016-11-30 17:22:29 +00:00
65 changed files with 3760 additions and 920 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.conche/
.build/
Packages/
Package.resolved
Package.pins

View File

@@ -1 +0,0 @@
3.0.1

View File

@@ -1,11 +1,22 @@
os:
- osx
- linux
matrix:
include:
- os: osx
osx_image: xcode8.3
env: SWIFT_VERSION=3.1.1
- os: osx
osx_image: xcode9
env: SWIFT_VERSION=4.0
- os: osx
osx_image: xcode9.1
env: SWIFT_VERSION=4.0
- os: linux
env: SWIFT_VERSION=3.1.1
- os: linux
env: SWIFT_VERSION=4.0
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

View File

@@ -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 Tokens. Afterwards, the array of tokens are transformed into a collection of Nodes. Once we have a collection of Nodes (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 Tokens.
### 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 Tokens. 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. Tokens are converted to Nodes using the `TokenParser` class.
For some Tokens, there is a direct mapping from a Token to a Node. However block nodes 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 Tokens 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 tokens 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 tags 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 its 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 its 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.
Heres 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 tags 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 nodes further in the token array.
As an example, were 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 Nodes 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. Its somewhat like a dictionary, however you can push and pop to scope variables. So that means that when iterating over a for loop, you can push a new scope into the context to store any variables local to the scope.

View File

@@ -1,5 +1,242 @@
# Stencil Changelog
## 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
@@ -59,10 +296,10 @@
### Bug Fixes
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
index will now resolve to `nil` instead of causing a crash.
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.
- 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.

View File

@@ -1,11 +1,10 @@
// swift-tools-version:3.1
import PackageDescription
let package = Package(
name: "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", majorVersion: 0, minor: 9),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8),
]
)

10
Package@swift-3.swift Normal file
View File

@@ -0,0 +1,10 @@
// swift-tools-version:3.1
import PackageDescription
let package = Package(
name: "Stencil",
dependencies: [
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7),
]
)

View File

@@ -1,6 +1,6 @@
# Stencil
[![Build Status](https://travis-ci.org/kylef/Stencil.svg?branch=master)](https://travis-ci.org/kylef/Stencil)
[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](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
@@ -19,35 +19,24 @@ There are {{ articles.count }} articles.
```
```swift
import Stencil
struct Article {
let title: String
let author: String
}
let context = Context(dictionary: [
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
do {
let template = try Template(named: "template.html")
let rendered = try template.render(context)
print(rendered)
} catch {
print("Failed to render template \(error)")
}
```
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"]))
let rendered = try environment.renderTemplate(name: "article_list.html", context: context)
## Installation
Installation with Swift Package Manager is recommended.
### CocoaPods
```ruby
pod 'Stencil'
print(rendered)
```
## Philosophy
@@ -62,10 +51,25 @@ Stencil follows the same philosophy of Django:
## The User Guide
- [Templates](http://stencil.fuller.li/en/latest/templates.html)
Resources for Stencil template authors to write Stencil templates:
- [Language overview](http://stencil.fuller.li/en/latest/templates.html)
- [Built-in template tags and filters](http://stencil.fuller.li/en/latest/builtins.html)
Resources to help you integrate Stencil into a Swift project:
- [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)
## Projects that use Stencil
[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
Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more

View File

@@ -1,17 +1,17 @@
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
let namespace: Namespace
/// 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()
}
public subscript(key: String) -> Any? {
@@ -42,7 +42,7 @@ public class Context {
}
/// Pop the last level off of the Context
fileprivate func pop() -> [String: Any]? {
fileprivate func pop() -> [String: Any?]? {
return dictionaries.popLast()
}

48
Sources/Environment.swift Normal file
View 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
View 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)
}
public static func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool {
return lhs.description == rhs.description && lhs.token == rhs.token && lhs.stackTrace == rhs.stackTrace
}
}
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 line = token.sourceMap.line
let highlight = "\(String(Array(repeating: " ", count: line.offset)))^\(String(Array(repeating: "~", count: max(token.contents.characters.count - 1, 0))))"
return "\(templateName)\(line.number):\(line.offset): error: \(templateError.reason)\n"
+ "\(line.content)\n"
+ "\(highlight)\n"
}
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")
}
}

View File

@@ -31,18 +31,18 @@ final class StaticExpression: Expression, CustomStringConvertible {
final class VariableExpression: Expression, CustomStringConvertible {
let variable: Variable
let variable: Resolvable
init(variable: Variable) {
init(variable: Resolvable) {
self.variable = variable
}
var description: String {
return "(variable: \(variable.variable))"
return "(variable: \(variable))"
}
/// Resolves a variable in the given context as boolean
func resolve(context: Context, variable: Variable) throws -> Bool {
func resolve(context: Context, variable: Resolvable) throws -> Bool {
let result = try variable.resolve(context)
var truthy = false
@@ -85,6 +85,41 @@ final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
}
}
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
@@ -202,7 +237,7 @@ class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
return ""
}
func compare(lhs: Float80, rhs: Float80) -> Bool {
func compare(lhs: Number, rhs: Number) -> Bool {
return false
}
}
@@ -213,7 +248,7 @@ class MoreThanExpression: NumericExpression {
return ">"
}
override func compare(lhs: Float80, rhs: Float80) -> Bool {
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs > rhs
}
}
@@ -224,7 +259,7 @@ class MoreThanEqualExpression: NumericExpression {
return ">="
}
override func compare(lhs: Float80, rhs: Float80) -> Bool {
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs >= rhs
}
}
@@ -235,7 +270,7 @@ class LessThanExpression: NumericExpression {
return "<"
}
override func compare(lhs: Float80, rhs: Float80) -> Bool {
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs < rhs
}
}
@@ -246,7 +281,7 @@ class LessThanEqualExpression: NumericExpression {
return "<="
}
override func compare(lhs: Float80, rhs: Float80) -> Bool {
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs <= rhs
}
}
@@ -263,37 +298,37 @@ class InequalityExpression: EqualityExpression {
}
func toNumber(value: Any) -> Float80? {
func toNumber(value: Any) -> Number? {
if let value = value as? Float {
return Float80(value)
return Number(value)
} else if let value = value as? Double {
return Float80(value)
return Number(value)
} else if let value = value as? UInt {
return Float80(value)
return Number(value)
} else if let value = value as? Int {
return Float80(value)
return Number(value)
} else if let value = value as? Int8 {
return Float80(value)
return Number(value)
} else if let value = value as? Int16 {
return Float80(value)
return Number(value)
} else if let value = value as? Int32 {
return Float80(value)
return Number(value)
} else if let value = value as? Int64 {
return Float80(value)
return Number(value)
} else if let value = value as? UInt8 {
return Float80(value)
return Number(value)
} else if let value = value as? UInt16 {
return Float80(value)
return Number(value)
} else if let value = value as? UInt32 {
return Float80(value)
return Number(value)
} else if let value = value as? UInt64 {
return Float80(value)
} else if let value = value as? Float80 {
return Number(value)
} else if let value = value as? Number {
return value
} else if let value = value as? Float64 {
return Float80(value)
return Number(value)
} else if let value = value as? Float32 {
return Float80(value)
return Number(value)
}
return nil

View File

@@ -1,3 +1,68 @@
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 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?
}
@@ -19,58 +84,3 @@ enum Filter: FilterType {
}
}
}
public 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("default", filter: defaultFilter)
registerFilter("capitalize", filter: capitalise)
registerFilter("uppercase", filter: uppercase)
registerFilter("lowercase", filter: lowercase)
registerFilter("join", filter: joinFilter)
}
/// Registers a new template tag
public func registerTag(_ name: String, parser: @escaping TagParser) {
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(handler: handler)
})
}
/// 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)
}
}

37
Sources/FilterTag.swift Normal file
View 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)
}
}
}

View File

@@ -1,39 +1,30 @@
func toString(_ value: Any?) -> String? {
if let value = value as? String {
return value
} else if let value = value as? CustomStringConvertible {
return value.description
}
return nil
}
func capitalise(_ value: Any?) -> Any? {
if let value = toString(value) {
return value.capitalized
if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
} else {
return stringify(value).capitalized
}
return value
}
func uppercase(_ value: Any?) -> Any? {
if let value = toString(value) {
return value.uppercased()
if let array = value as? [Any?] {
return array.map { stringify($0).uppercased() }
} else {
return stringify(value).uppercased()
}
return value
}
func lowercase(_ value: Any?) -> Any? {
if let value = toString(value) {
return value.lowercased()
if let array = value as? [Any?] {
return array.map { stringify($0).lowercased() }
} else {
return stringify(value).lowercased()
}
return value
}
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
if let value = value {
// value can be optional wrapping nil, so this way we check for underlying value
if let value = value, String(describing: value) != "nil" {
return value
}
@@ -47,17 +38,76 @@ func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
}
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count == 1 else {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'join' filter takes a single argument")
}
guard let separator = arguments.first as? String else {
throw TemplateSyntaxError("'join' filter takes a separator as string")
let separator = stringify(arguments.first ?? "")
if let value = value as? [Any] {
return value
.map(stringify)
.joined(separator: separator)
}
if let value = value as? [String] {
return value.joined(separator: separator)
}
return nil
return value
}
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 indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count <= 3 else {
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
}
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")
}

View File

@@ -1,20 +1,38 @@
import Foundation
class ForNode : NodeType {
let resolvable: Resolvable
let loopVariable:String
let loopVariables: [String]
let nodes:[NodeType]
let emptyNodes: [NodeType]
let `where`: Expression?
let token: Token?
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].characters
.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,38 +40,112 @@ class ForNode : NodeType {
throw TemplateSyntaxError("`endfor` was not found.")
}
var emptyNodes = [NodeType]()
if token.contents == "empty" {
emptyNodes = try parser.parse(until(["endfor"]))
_ = parser.nextToken()
}
let filter = try parser.compileFilter(variable)
return ForNode(resolvable: filter, loopVariable: loopVariable, nodes: forNodes, emptyNodes:emptyNodes)
return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes: emptyNodes, where: `where`, token: token)
}
init(resolvable: Resolvable, loopVariable:String, nodes:[NodeType], emptyNodes:[NodeType]) {
init(resolvable: Resolvable, loopVariables: [String], nodes: [NodeType], emptyNodes: [NodeType], where: Expression? = nil, token: Token? = nil) {
self.resolvable = resolvable
self.loopVariable = loopVariable
self.loopVariables = loopVariables
self.nodes = nodes
self.emptyNodes = emptyNodes
self.where = `where`
self.token = token
}
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
if loopVariables.isEmpty {
return try context.push() {
return try closure()
}
}
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 values = try resolvable.resolve(context)
let resolved = try resolvable.resolve(context)
if let values = values as? [Any] , values.count > 0 {
var values: [Any]
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
values = dictionary.map { ($0.key, $0.value) }
} 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 {

View File

@@ -14,6 +14,7 @@ enum Operator {
let operators: [Operator] = [
.infix("in", 5, InExpression.self),
.infix("or", 6, OrExpression.self),
.infix("and", 7, AndExpression.self),
.prefix("not", 8, NotExpression.self),
@@ -40,7 +41,7 @@ func findOperator(name: String) -> Operator? {
enum IfToken {
case infix(name: String, bindingPower: Int, op: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, op: PrefixOperator.Type)
case variable(Variable)
case variable(Resolvable)
case end
var bindingPower: Int {
@@ -52,7 +53,7 @@ enum IfToken {
case .variable(_):
return 0
case .end:
return 0
return 0
}
}
@@ -99,8 +100,8 @@ final class IfExpressionParser {
let tokens: [IfToken]
var position: Int = 0
init(components: [String]) {
self.tokens = components.map { component in
init(components: [String], tokenParser: TokenParser, token: Token) throws {
self.tokens = try components.map { component in
if let op = findOperator(name: component) {
switch op {
case .infix(let name, let bindingPower, let cls):
@@ -110,7 +111,7 @@ final class IfExpressionParser {
}
}
return .variable(Variable(component))
return .variable(try tokenParser.compileResolvable(component, containedIn: token))
}
}
@@ -154,47 +155,78 @@ final class IfExpressionParser {
}
func parseExpression(components: [String]) throws -> Expression {
let parser = IfExpressionParser(components: components)
func parseExpression(components: [String], tokenParser: TokenParser, token: Token) throws -> Expression {
let parser = try IfExpressionParser(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 expression: Expression
let trueNodes: [NodeType]
let falseNodes: [NodeType]
let conditions: [IfCondition]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components()
components.removeFirst()
var trueNodes = [NodeType]()
var falseNodes = [NodeType]()
trueNodes = try parser.parse(until(["endif", "else"]))
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)
]
guard let token = parser.nextToken() else {
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()
}
let expression = try parseExpression(components: components)
return IfNode(expression: expression, trueNodes: trueNodes, falseNodes: falseNodes)
return IfNode(conditions: conditions, token: token)
}
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'.")
}
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 {
@@ -206,25 +238,30 @@ class IfNode : NodeType {
_ = parser.nextToken()
}
let expression = try parseExpression(components: components)
return IfNode(expression: expression, trueNodes: trueNodes, falseNodes: falseNodes)
return IfNode(conditions: [
IfCondition(expression: expression, nodes: trueNodes),
IfCondition(expression: nil, nodes: falseNodes),
], token: token)
}
init(expression: Expression, trueNodes: [NodeType], falseNodes: [NodeType]) {
self.expression = expression
self.trueNodes = trueNodes
self.falseNodes = falseNodes
init(conditions: [IfCondition], token: Token? = nil) {
self.conditions = conditions
self.token = token
}
func render(_ context: Context) throws -> String {
let truthy = try expression.evaluate(context: context)
for condition in conditions {
if let expression = condition.expression {
let truthy = try expression.evaluate(context: context)
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 ""
}
}

View File

@@ -3,35 +3,44 @@ import PathKit
class IncludeNode : NodeType {
let templateName: Variable
let includeContext: String?
let token: Token?
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)
}
init(templateName: Variable) {
init(templateName: Variable, includeContext: String? = nil, token: Token) {
self.templateName = templateName
self.includeContext = includeContext
self.token = token
}
func render(_ context: Context) throws -> String {
guard let loader = context["loader"] as? Loader else {
throw TemplateSyntaxError("Template loader not in context")
}
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
guard let template = try loader.loadTemplate(name: templateName) else {
throw TemplateSyntaxError("'\(templateName)' template not found")
}
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
}
}
}
}

View File

@@ -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
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()
@@ -50,42 +72,47 @@ class ExtendsNode : NodeType {
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? Loader else {
throw TemplateSyntaxError("Template loader not in context")
}
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
guard let template = try loader.loadTemplate(name: templateName) else {
throw TemplateSyntaxError("'\(templateName)' template not found")
}
let baseTemplate = try context.environment.loadTemplate(name: templateName)
let blockContext: BlockContext
if let context = context[BlockContext.contextKey] as? BlockContext {
blockContext = context
for (key, value) in blocks {
if !blockContext.blocks.keys.contains(key) {
blockContext.blocks[key] = value
}
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)
}
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
}
}
}
}
@@ -94,6 +121,7 @@ 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()
@@ -105,19 +133,57 @@ class BlockNode : NodeType {
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
View 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.characters {
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)'")
}
}
}

View File

@@ -1,26 +1,45 @@
import Foundation
struct Lexer {
let templateName: String?
let templateString: String
init(templateString: String) {
init(templateName: String? = nil, templateString: String) {
self.templateName = templateName
self.templateString = templateString
}
func createToken(string:String) -> Token {
func createToken(string: String, at range: Range<String.Index>) -> Token {
func strip() -> String {
guard string.characters.count > 4 else { return "" }
let start = string.index(string.startIndex, offsetBy: 2)
let end = string.index(string.endIndex, offsetBy: -2)
return string[start..<end].trim(character: " ")
let trimmed = String(string[start..<end])
.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 line = templateString.rangeLine(range)
let sourceMap = SourceMap(filename: templateName, line: line)
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 line = templateString.rangeLine(range)
let sourceMap = SourceMap(filename: templateName, line: line)
return .text(value: string, at: sourceMap)
}
/// Returns an array of tokens from a given template string.
@@ -33,33 +52,37 @@ struct Lexer {
"{{": "}}",
"{%": "%}",
"{#": "#}",
]
]
while !scanner.isEmpty {
if let text = scanner.scan(until: ["{{", "{%", "{#"]) {
if !text.1.isEmpty {
tokens.append(createToken(string: text.1))
tokens.append(createToken(string: text.1, at: scanner.range))
}
let end = map[text.0]!
let result = scanner.scan(until: end, returnUntil: true)
tokens.append(createToken(string: result))
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
}
}
class Scanner {
let originalContent: String
var content: String
var range: Range<String.Index>
init(_ content: String) {
self.originalContent = content
self.content = content
range = content.startIndex..<content.startIndex
}
var isEmpty: Bool {
@@ -67,29 +90,34 @@ class Scanner {
}
func scan(until: String, returnUntil: Bool = false) -> String {
var index = content.startIndex
if until.isEmpty {
return ""
}
var index = content.startIndex
range = range.upperBound..<range.upperBound
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)
range = range.lowerBound..<originalContent.index(range.upperBound, offsetBy: until.characters.count)
content = substring.substring(from: until.endIndex)
return result + until
}
content = substring
return result
}
index = content.index(after: index)
range = range.lowerBound..<originalContent.index(after: range.upperBound)
}
content = ""
return ""
}
@@ -99,6 +127,7 @@ class Scanner {
}
var index = content.startIndex
range = range.upperBound..<range.upperBound
while index != content.endIndex {
let substring = content.substring(from: index)
for string in until {
@@ -110,6 +139,7 @@ class Scanner {
}
index = content.index(after: index)
range = range.lowerBound..<originalContent.index(after: range.upperBound)
}
return nil
@@ -147,6 +177,25 @@ 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 func rangeLine(_ range: Range<String.Index>) -> RangeLine {
var lineNumber: UInt = 0
var offset: Int = 0
var lineContent = ""
for line in components(separatedBy: CharacterSet.newlines) {
lineNumber += 1
lineContent = line
if let rangeOfLine = self.range(of: line), rangeOfLine.contains(range.lowerBound) {
offset = distance(from: rangeOfLine.lowerBound, to: range.lowerBound)
break
}
}
return (lineContent, lineNumber, offset)
}
}
public typealias RangeLine = (content: String, number: UInt, offset: Int)

130
Sources/Loader.swift Normal file
View 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)`"
}
}

View File

@@ -1,35 +1,31 @@
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: "")
}
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
}
@@ -41,9 +37,11 @@ public class SimpleNode : NodeType {
public class TextNode : NodeType {
public let text:String
public let token: Token?
public init(text:String) {
self.text = text
self.token = nil
}
public func render(_ context:Context) throws -> String {
@@ -59,26 +57,48 @@ public protocol Resolvable {
public class VariableNode : NodeType {
public let variable: Resolvable
public var token: Token?
public init(variable: Resolvable) {
public init(variable: Resolvable, token: Token? = nil) {
self.variable = variable
self.token = token
}
public init(variable: String) {
public init(variable: String, token: Token? = nil) {
self.variable = Variable(variable)
self.token = token
}
public 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
}
return ""
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 }
}
}

View File

@@ -4,23 +4,25 @@ import Foundation
class NowNode : NodeType {
let format:Variable
let token: Token?
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)
}
init(format:Variable?) {
init(format:Variable?, token: Token? = nil) {
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
self.token = token
}
func render(_ context: Context) throws -> String {

View File

@@ -18,11 +18,11 @@ 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
@@ -37,23 +37,24 @@ public class TokenParser {
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)))
let filter = try compileResolvable(token.contents, containedIn: token)
nodes.append(VariableNode(variable: filter, 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:
@@ -76,15 +77,120 @@ public class TokenParser {
tokens.insert(token, at: 0)
}
func findFilter(_ name: String) throws -> FilterType {
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 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.characters.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 rangeLine = containingToken.sourceMap.line
rangeLine.offset += containingToken.contents.distance(from: containingToken.contents.startIndex, to: filterTokenRange.lowerBound)
syntaxError.token = .variable(value: filterToken, at: SourceMap(filename: containingToken.sourceMap.filename, line: rangeLine))
} 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.characters.count)
current = [Int](repeating: 0, count: target.characters.count + 1)
for i in 0..<self.characters.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.characters.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.characters.count]
}
}

View File

@@ -6,16 +6,26 @@ let NSFileNoSuchFileError = 4
#endif
/// A class representing a template
public class Template: ExpressibleByStringLiteral {
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 init(templateString: String) {
let lexer = Lexer(templateString: templateString)
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 {
@@ -26,16 +36,20 @@ public class Template: ExpressibleByStringLiteral {
}
/// 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 literal
// MARK: ExpressibleByStringLiteral
// Create a templaVte with a template string literal
public convenience required init(stringLiteral value: String) {
self.init(templateString: value)
}
@@ -50,11 +64,16 @@ public class Template: ExpressibleByStringLiteral {
self.init(stringLiteral: value)
}
/// Render the given template
public func render(_ context: Context? = nil) throws -> String {
let context = context ?? Context()
let parser = TokenParser(tokens: tokens, namespace: context.namespace)
/// 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))
}
}

View File

@@ -1,65 +0,0 @@
import Foundation
import PathKit
public protocol Loader {
func loadTemplate(name: String) throws -> Template?
func loadTemplate(names: [String]) throws -> Template?
}
extension Loader {
func loadTemplate(names: [String]) throws -> Template? {
for name in names {
let template = try loadTemplate(name: name)
if template != nil {
return template
}
}
return nil
}
}
// A class for loading a template from disk
public class FileSystemLoader: Loader {
public let paths: [Path]
public init(paths: [Path]) {
self.paths = paths
}
public init(bundle: [Bundle]) {
self.paths = bundle.map {
return Path($0.bundlePath)
}
}
public func loadTemplate(name: String) throws -> Template? {
for path in paths {
let templatePath = path + Path(name)
if templatePath.exists {
return try Template(path: templatePath)
}
}
return nil
}
public func loadTemplate(names: [String]) throws -> Template? {
for path in paths {
for templateName in names {
let templatePath = path + Path(templateName)
if templatePath.exists {
return try Template(path: templatePath)
}
}
}
return nil
}
}

View File

@@ -7,15 +7,34 @@ extension String {
var word = ""
var components: [String] = []
var separate: Character = separator
var singleQuoteCount = 0
var doubleQuoteCount = 0
let specialCharacters = ",|:"
func appendWord(_ word: String) {
if components.count > 0 {
if let precedingChar = components.last?.characters.last, specialCharacters.characters.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)
}
}
for character in self.characters {
if character == "'" { singleQuoteCount += 1 }
else if character == "\"" { doubleQuoteCount += 1 }
if character == separate {
if separate != separator {
word.append(separate)
}
if !word.isEmpty {
components.append(word)
} else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
appendWord(word)
word = ""
}
@@ -29,66 +48,86 @@ extension String {
}
if !word.isEmpty {
components.append(word)
appendWord(word)
}
return components
}
}
public struct SourceMap: Equatable {
public let filename: String?
public let line: RangeLine
init(filename: String? = nil, line: RangeLine = ("", 0, 0)) {
self.filename = filename
self.line = line
}
static let unknown = SourceMap()
public static func ==(lhs: SourceMap, rhs: SourceMap) -> Bool {
return lhs.filename == rhs.filename && lhs.line == rhs.line
}
}
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 value.smartSplit()
case .variable(let value):
return value.smartSplit()
case .text(let value):
return value.smartSplit()
case .comment(let 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 var sourceMap: SourceMap {
switch self {
case .block(_, let sourceMap),
.variable(_, let sourceMap),
.text(_, let sourceMap),
.comment(_, let sourceMap):
return sourceMap
}
}
}
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
case let (.text(lhsValue, lhsAt), .text(rhsValue, rhsAt)):
return lhsValue == rhsValue && lhsAt == rhsAt
case let (.variable(lhsValue, lhsAt), .variable(rhsValue, rhsAt)):
return lhsValue == rhsValue && lhsAt == rhsAt
case let (.block(lhsValue, lhsAt), .block(rhsValue, rhsAt)):
return lhsValue == rhsValue && lhsAt == rhsAt
case let (.comment(lhsValue, lhsAt), .comment(rhsValue, rhsAt)):
return lhsValue == rhsValue && lhsAt == rhsAt
default:
return false
}

View File

@@ -1,6 +1,9 @@
import Foundation
typealias Number = Float
class FilterExpression : Resolvable {
let filters: [(FilterType, [Variable])]
let variable: Variable
@@ -8,8 +11,6 @@ class FilterExpression : Resolvable {
init(token: String, parser: TokenParser) throws {
let bits = token.characters.split(separator: "|").map({ String($0).trim(character: " ") })
if bits.isEmpty {
filters = []
variable = Variable("")
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
}
@@ -47,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
@@ -57,16 +60,32 @@ 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.characters.index(after: variable.startIndex) ..< variable.characters.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]
if bit == "count" {
current = dictionary.count
} else {
current = dictionary[bit]
}
} else if let array = current as? [Any] {
if let index = Int(bit) {
if index >= 0 && index < array.count {
@@ -82,15 +101,13 @@ public struct Variable : Equatable, Resolvable {
current = array.count
}
} 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
current = object.value(forKey: bit)
#endif
} else if let value = current {
let mirror = Mirror(reflecting: value)
current = mirror.descendant(bit)
current = Mirror(reflecting: value).getValue(for: bit)
if current == nil {
return nil
}
@@ -99,6 +116,12 @@ public struct Variable : Equatable, Resolvable {
}
}
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)
}
}
@@ -107,6 +130,53 @@ public func ==(lhs: Variable, rhs: Variable) -> Bool {
return lhs.variable == rhs.variable
}
/// 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)
}
}
func normalize(_ current: Any?) -> Any? {
if let current = current as? Normalizable {
@@ -150,10 +220,47 @@ extension Dictionary : Normalizable {
func parseFilterComponents(token: String) -> (String, [Variable]) {
var components = token.smartSplit(separator: ":")
let name = components.removeFirst()
let name = components.removeFirst().trim(character: " ")
let variables = components
.joined(separator: ":")
.smartSplit(separator: ",")
.map { Variable($0) }
.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
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "Stencil",
"version": "0.7.1",
"version": "0.12.1",
"summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li",
"license": {
@@ -12,8 +12,8 @@
},
"social_media_url": "https://twitter.com/kylefuller",
"source": {
"git": "https://github.com/kylef/Stencil.git",
"tag": "0.7.1"
"git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.12.1"
},
"source_files": [
"Sources/*.swift"
@@ -25,6 +25,8 @@
},
"requires_arc": true,
"dependencies": {
"PathKit": [ "~> 0.7.0" ]
"PathKit": [
"~> 0.9.0"
]
}
}

View File

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

View File

@@ -0,0 +1,361 @@
import Spectre
import PathKit
@testable import Stencil
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 rangeLine = template.templateString.rangeLine(range)
let sourceMap = SourceMap(filename: template.name, line: rangeLine)
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\" %}\n" +
"{% 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\" %}\n" +
"{% 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"
}
}

View File

@@ -4,6 +4,8 @@ import Spectre
func testExpressions() {
describe("Expression") {
let parser = TokenParser(tokens: [], environment: Environment())
$0.describe("VariableExpression") {
let expression = VariableExpression(variable: Variable("value"))
@@ -103,19 +105,19 @@ func testExpressions() {
$0.describe("expression parsing") {
$0.it("can parse a variable expression") {
let expression = try parseExpression(components: ["value"])
let expression = try parseExpression(components: ["value"], tokenParser: parser, token: .text(value: "", at: .unknown))
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"])
let expression = try parseExpression(components: ["not", "value"], tokenParser: parser, token: .text(value: "", at: .unknown))
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"])
let expression = try! parseExpression(components: ["lhs", "and", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to false with lhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
@@ -135,7 +137,7 @@ func testExpressions() {
}
$0.describe("or expression") {
let expression = try! parseExpression(components: ["lhs", "or", "rhs"])
let expression = try! parseExpression(components: ["lhs", "or", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with lhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
@@ -155,7 +157,7 @@ func testExpressions() {
}
$0.describe("equality expression") {
let expression = try! parseExpression(components: ["lhs", "==", "rhs"])
let expression = try! parseExpression(components: ["lhs", "==", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
@@ -191,7 +193,7 @@ func testExpressions() {
}
$0.describe("inequality expression") {
let expression = try! parseExpression(components: ["lhs", "!=", "rhs"])
let expression = try! parseExpression(components: ["lhs", "!=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with inequal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
@@ -203,7 +205,7 @@ func testExpressions() {
}
$0.describe("more than expression") {
let expression = try! parseExpression(components: ["lhs", ">", "rhs"])
let expression = try! parseExpression(components: ["lhs", ">", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
@@ -215,7 +217,7 @@ func testExpressions() {
}
$0.describe("more than equal expression") {
let expression = try! parseExpression(components: ["lhs", ">=", "rhs"])
let expression = try! parseExpression(components: ["lhs", ">=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
@@ -227,7 +229,7 @@ func testExpressions() {
}
$0.describe("less than expression") {
let expression = try! parseExpression(components: ["lhs", "<", "rhs"])
let expression = try! parseExpression(components: ["lhs", "<", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
@@ -239,7 +241,7 @@ func testExpressions() {
}
$0.describe("less than equal expression") {
let expression = try! parseExpression(components: ["lhs", "<=", "rhs"])
let expression = try! parseExpression(components: ["lhs", "<=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
@@ -251,7 +253,7 @@ func testExpressions() {
}
$0.describe("multiple expression") {
let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"])
let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"], tokenParser: parser, token: .text(value: "", at: .unknown))
$0.it("evaluates to true with one") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
@@ -277,6 +279,26 @@ func testExpressions() {
try expect(expression.evaluate(context: Context())).to.beFalse()
}
}
$0.describe("in expression") {
let expression = try! parseExpression(components: ["lhs", "in", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown))
$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()
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
import Spectre
import Stencil
@testable import Stencil
func testFilter() {
@@ -9,8 +9,8 @@ func testFilter() {
$0.it("allows you to register a custom filter") {
let template = Template(templateString: "{{ name|repeat }}")
let namespace = Namespace()
namespace.registerFilter("repeat") { (value: Any?) in
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (value: Any?) in
if let value = value as? String {
return "\(value) \(value)"
}
@@ -18,15 +18,15 @@ func testFilter() {
return nil
}
let result = try template.render(Context(dictionary: context, namespace: namespace))
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "Kyle Kyle"
}
$0.it("allows you to register a custom filter which accepts arguments") {
let template = Template(templateString: "{{ name|repeat:'value' }}")
$0.it("allows you to register a custom filter which accepts single argument") {
let template = Template(templateString: "{{ name|repeat:'value1, \"value2\"' }}")
let namespace = Namespace()
namespace.registerFilter("repeat") { value, arguments in
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
if !arguments.isEmpty {
return "\(value!) \(value!) with args \(arguments.first!!)"
}
@@ -34,24 +34,53 @@ func testFilter() {
return nil
}
let result = try template.render(Context(dictionary: context, namespace: namespace))
try expect(result) == "Kyle Kyle with args value"
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "Kyle Kyle with args value1, \"value2\""
}
$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 namespace = Namespace()
namespace.registerFilter("repeat") { (value: Any?) in
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (value: Any?) in
throw TemplateSyntaxError("No Repeat")
}
try expect(try template.render(Context(dictionary: context, namespace: namespace))).toThrow(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: "{{ name | uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
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") {
@@ -60,32 +89,45 @@ func testFilter() {
}
}
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"
}
describe("capitalize filter") {
let template = Template(templateString: "{{ name|capitalize }}")
$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("capitalizes a string") {
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\"]"
}
describe("uppercase filter") {
let template = Template(templateString: "{{ name|uppercase }}")
$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 uppercase") {
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
}
}
describe("lowercase filter") {
let template = Template(templateString: "{{ name|lowercase }}")
$0.it("transforms a string to be lowercase") {
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: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == "[\"kyle\", \"kyle\"]"
}
}
}
@@ -107,14 +149,145 @@ func testFilter() {
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:\", \" }}")
$0.it("transforms a string to be lowercase") {
$0.it("joins a collection of strings") {
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
$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("split filter") {
let template = Template(templateString: "{{ value|split:\", \" }}")
$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 rangeLine = template.templateString.rangeLine(range)
let sourceMap = SourceMap(filename: template.name, line: rangeLine)
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\nTwo"]))
try expect(result) == "One\n 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\nTwo"]))
try expect(result) == "One\n\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\nTwo"]))
try expect(result) == " One\n Two"
}
$0.it("does not indent empty lines") {
let template = Template(templateString: "{{ value|indent }}")
let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"]))
try expect(result) == "One\n\n\n Two\n\n"
}
}
}

View File

@@ -0,0 +1,45 @@
import Spectre
import Stencil
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"
}
}
}

View File

@@ -8,72 +8,120 @@ func testForNode() {
let context = Context(dictionary: [
"items": [1, 2, 3],
"emptyItems": [Int](),
])
"dict": [
"one": "I",
"two": "II",
],
"tuples": [(1, 2, 3), (4, 5, 6)]
])
$0.it("renders the given nodes for each item") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("items"), loopVariable: "item", nodes: nodes, emptyNodes: [])
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"), loopVariable: "item", nodes: nodes, emptyNodes: emptyNodes)
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"), loopVariable: "item", nodes: nodes, emptyNodes: [])
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(any_context)) == "123"
}
#if os(OSX)
$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"), loopVariable: "item", nodes: nodes, emptyNodes: [])
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(nsarray_context)) == "123"
}
#endif
#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"), loopVariable: "item", nodes: nodes, emptyNodes: [])
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"), loopVariable: "item", nodes: nodes, emptyNodes: [])
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"), loopVariable: "item", nodes: nodes, emptyNodes: [])
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(context)) == "112233"
}
$0.it("can render a filter") {
let templateString = "{% for article in ars|default:articles %}" +
$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 }}.\n" +
"{% endfor %}\n"
"{% endfor %}\n"
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)
@@ -81,11 +129,188 @@ func testForNode() {
let fixture = "" +
"- Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
"- Memory Management with ARC by Kyle Fuller.\n" +
"\n"
"\n"
try expect(result) == fixture
}
$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 }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 2, 3\n4, 5, 6\n\n"
try expect(result) == fixture
}
$0.it("can iterate with less number of variables") {
let templateString = "{% for first,second in tuples %}" +
"{{ first }}, {{ second }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 2\n4, 5\n\n"
try expect(result) == fixture
}
$0.it("can use _ to skip variables") {
let templateString = "{% for first,_,third in tuples %}" +
"{{ first }}, {{ third }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 3\n4, 6\n\n"
try expect(result) == fixture
}
$0.it("throws when number of variables is more than number of tuple values") {
let templateString = "{% for key,value,smth in dict %}" +
"{% endfor %}\n"
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)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <)
try expect(sortedResult) == ["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)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <)
try expect(sortedResult) == ["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)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <)
try expect(sortedResult) == ["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\nnumber=123\n"
}
$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\ntwo=dva\n"
}
$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\nbaseString=base\nbaseInt=1\n"
}
$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"
}
}
}

View File

@@ -7,101 +7,281 @@ func testIfNode() {
$0.describe("parsing") {
$0.it("can parse an if block") {
let tokens: [Token] = [
.block(value: "if value"),
.text(value: "true"),
.block(value: "else"),
.text(value: "false"),
.block(value: "endif")
.block(value: "if 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?.trueNodes.count) == 1
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"
try expect(node?.falseNodes.count) == 1
}
$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 complex expression") {
$0.it("can parse an if with elif block") {
let tokens: [Token] = [
.block(value: "if value == \"test\" and not name"),
.text(value: "true"),
.block(value: "else"),
.text(value: "false"),
.block(value: "endif")
.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, 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?.trueNodes.count) == 1
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(node?.falseNodes.count) == 1
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"
}
$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"),
.text(value: "false"),
.block(value: "else"),
.text(value: "true"),
.block(value: "endif")
.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, 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
let conditions = node?.conditions
try expect(conditions?.count) == 2
try expect(nodes.count) == 1
try expect(node?.trueNodes.count) == 1
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(node?.falseNodes.count) == 1
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"),
]
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
let parser = TokenParser(tokens: tokens, namespace: Namespace())
let error = TemplateSyntaxError("`endif` was not found.")
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"),
]
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
let parser = TokenParser(tokens: tokens, namespace: Namespace())
let error = TemplateSyntaxError("`endif` was not found.")
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 the truth when expression evaluates to true") {
let node = IfNode(expression: StaticExpression(value: true), trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
try expect(try node.render(Context())) == "true"
$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 false when expression evaluates to false") {
let node = IfNode(expression: StaticExpression(value: false), trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")])
try expect(try node.render(Context())) == "false"
$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|uppercase == \"TEST\"", 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 result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
try expect(result) == "true"
}
$0.it("evaluates nil properties as false") {
let tokens: [Token] = [
.block(value: "if instance.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()
struct SomeType {
let value: String? = nil
}
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
try expect(result) == ""
}
$0.it("supports closed range variables") {
let tokens: [Token] = [
.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, 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"
}
}
}

View File

@@ -7,19 +7,20 @@ func testInclude() {
describe("Include") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
$0.describe("parsing") {
$0.it("throws an error when no template is given") {
let tokens: [Token] = [ .block(value: "include") ]
let parser = TokenParser(tokens: tokens, namespace: Namespace())
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
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)
}
$0.it("can parse a valid include block") {
let tokens: [Token] = [ .block(value: "include \"test.html\"") ]
let parser = TokenParser(tokens: tokens, namespace: Namespace())
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IncludeNode
@@ -30,31 +31,38 @@ func testInclude() {
$0.describe("rendering") {
$0.it("throws an error when rendering without a loader") {
let node = IncludeNode(templateName: Variable("\"test.html\""))
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
do {
_ = try node.render(Context())
} catch {
try expect("\(error)") == "Template loader not in context"
try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
}
}
$0.it("throws an error when it cannot find the included template") {
let node = IncludeNode(templateName: Variable("\"unknown.html\""))
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
do {
_ = try node.render(Context(dictionary: ["loader": loader]))
_ = try node.render(Context(environment: environment))
} catch {
try expect("\(error)".hasPrefix("'unknown.html' template not found")).to.beTrue()
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\""))
let context = Context(dictionary: ["loader":loader, "target": "World"])
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!"
}
}
}
}

View File

@@ -7,17 +7,21 @@ func testInheritence() {
describe("Inheritence") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
$0.it("can inherit from another template") {
let context = Context(dictionary: ["loader": loader])
let template = try loader.loadTemplate(name: "child.html")
try expect(try template?.render(context)) == "Header\nChild"
let template = try environment.loadTemplate(name: "child.html")
try expect(try template.render()) == "Super_Header Child_Header\nChild_Body"
}
$0.it("can inherit from another template inheriting from another template") {
let context = Context(dictionary: ["loader": loader])
let template = try loader.loadTemplate(name: "child-child.html")
try expect(try template?.render(context)) == "Child Child Header\nChild"
let template = try environment.loadTemplate(name: "child-child.html")
try expect(try template.render()) == "Super_Header Child_Header Child_Child_Header\nChild_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\nChild_Body"
}
}
}

View File

@@ -9,15 +9,15 @@ func testLexer() {
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "Hello World")
try expect(tokens.first) == .text(value: "Hello World", at: SourceMap(line: ("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")
try expect(tokens.count) == 1
try expect(tokens.first) == .comment(value: "Comment", at: SourceMap(line: ("{# Comment #}", 1, 3)))
}
$0.it("can tokenize a variable") {
@@ -25,26 +25,70 @@ func testLexer() {
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable")
try expect(tokens.first) == .variable(value: "Variable", at: SourceMap(line: ("{{ 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(line: ("{{ thing", 1, 0)))
}
$0.it("can tokenize a mixture of content") {
let lexer = Lexer(templateString: "My name is {{ name }}.")
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 ")
try expect(tokens[1]) == Token.variable(value: "name")
try expect(tokens[2]) == Token.text(value: ".")
try expect(tokens[0]) == Token.text(value: "My name is ", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is ")!)))
try expect(tokens[1]) == Token.variable(value: "myname", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "myname")!)))
try expect(tokens[2]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!)))
}
$0.it("can tokenize two variables without being greedy") {
let lexer = Lexer(templateString: "{{ thing }}{{ name }}")
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")
try expect(tokens[1]) == Token.variable(value: "name")
try expect(tokens[0]) == Token.variable(value: "thing", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "thing")!)))
try expect(tokens[1]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name")!)))
}
$0.it("can tokenize an unclosed block") {
let lexer = Lexer(templateString: "{%}")
let _ = lexer.tokenize()
}
$0.it("can tokenize an empty variable") {
let lexer = Lexer(templateString: "{{}}")
let _ = lexer.tokenize()
}
$0.it("can tokenize with new lines") {
let templateString =
"My name is {%\n" +
" if name\n" +
" and\n" +
" name\n" +
"%}{{\n" +
"name\n" +
"}}{%\n" +
"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: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is")!)))
try expect(tokens[1]) == Token.block(value: "if name and name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "{%")!)))
try expect(tokens[2]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name", options: [.backwards])!)))
try expect(tokens[3]) == Token.block(value: "endif", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "endif")!)))
try expect(tokens[4]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!)))
}
}
}

View File

@@ -0,0 +1,55 @@
import Spectre
import Stencil
import PathKit
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"])
}
}
}

View File

@@ -1,8 +1,13 @@
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")
}

View File

@@ -8,8 +8,8 @@ func testNowNode() {
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())
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
@@ -18,8 +18,8 @@ func testNowNode() {
}
$0.it("parses now with a format") {
let tokens: [Token] = [ .block(value: "now \"HH:mm\"") ]
let parser = TokenParser(tokens: tokens, namespace: Namespace())
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

View File

@@ -1,13 +1,13 @@
import Spectre
import Stencil
@testable import Stencil
func testTokenParser() {
describe("TokenParser") {
$0.it("can parse a text token") {
let parser = TokenParser(tokens: [
.text(value: "Hello World")
], namespace: Namespace())
.text(value: "Hello World", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? TextNode
@@ -18,8 +18,8 @@ func testTokenParser() {
$0.it("can parse a variable token") {
let parser = TokenParser(tokens: [
.variable(value: "'name'")
], namespace: Namespace())
.variable(value: "'name'", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? VariableNode
@@ -30,33 +30,32 @@ func testTokenParser() {
$0.it("can parse a comment token") {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!")
], namespace: Namespace())
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
try expect(nodes.count) == 0
}
$0.it("can parse a tag token") {
let namespace = Namespace()
namespace.registerSimpleTag("known") { _ in
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
return ""
}
let parser = TokenParser(tokens: [
.block(value: "known"),
], namespace: namespace)
.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 parser = TokenParser(tokens: [
.block(value: "unknown"),
], namespace: Namespace())
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
try expect(try parser.parse()).toThrow(TemplateSyntaxError("Unknown template tag 'unknown'"))
try expect(try parser.parse()).toThrow(TemplateSyntaxError(reason: "Unknown template tag 'unknown'", token: tokens.first))
}
}
}

View File

@@ -2,7 +2,8 @@ import Spectre
import Stencil
fileprivate class CustomNode : NodeType {
fileprivate struct CustomNode : NodeType {
let token: Token?
func render(_ context:Context) throws -> String {
return "Hello World"
}
@@ -17,6 +18,18 @@ fileprivate struct Article {
func testStencil() {
describe("Stencil") {
let exampleExtension = Extension()
exampleExtension.registerSimpleTag("simpletag") { context in
return "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.\n" +
@@ -25,12 +38,12 @@ func testStencil() {
" - {{ article.title }} by {{ article.author }}.\n" +
"{% endfor %}\n"
let context = Context(dictionary: [
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
let template = Template(templateString: templateString)
let result = try template.render(context)
@@ -45,28 +58,13 @@ func testStencil() {
}
$0.it("can render a custom template tag") {
let templateString = "{% custom %}"
let template = Template(templateString: templateString)
let namespace = Namespace()
namespace.registerTag("custom") { parser, token in
return CustomNode()
}
let result = try template.render(Context(namespace: namespace))
let result = try environment.renderTemplate(string: "{% customtag %}")
try expect(result) == "Hello World"
}
$0.it("can render a simple custom tag") {
let templateString = "{% custom %}"
let template = Template(templateString: templateString)
let namespace = Namespace()
namespace.registerSimpleTag("custom") { context in
return "Hello World"
}
try expect(try template.render(Context(namespace: namespace))) == "Hello World"
let result = try environment.renderTemplate(string: "{% simpletag %}")
try expect(result) == "Hello World"
}
}
}

View File

@@ -1,25 +0,0 @@
import Spectre
import Stencil
import PathKit
func testTemplateLoader() {
describe("TemplateLoader") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
$0.it("returns nil when a template cannot be found") {
try expect(try loader.loadTemplate(name: "unknown.html")).to.beNil()
}
$0.it("returns nil when an array of templates cannot be found") {
try expect(try loader.loadTemplate(names: ["unknown.html", "unknown2.html"])).to.beNil()
}
$0.it("can load a template from a file") {
if try loader.loadTemplate(name: "test.html") == nil {
throw failure("didn't find the template")
}
}
}
}

View File

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

View File

@@ -1,11 +1,11 @@
import Spectre
import Stencil
@testable import Stencil
func testToken() {
describe("Token") {
$0.it("can split the contents into components") {
let token = Token.text(value: "hello world")
let token = Token.text(value: "hello world", at: .unknown)
let components = token.components()
try expect(components.count) == 2
@@ -14,7 +14,7 @@ func testToken() {
}
$0.it("can split the contents into components with single quoted strings") {
let token = Token.text(value: "hello 'kyle fuller'")
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
let components = token.components()
try expect(components.count) == 2
@@ -23,7 +23,7 @@ func testToken() {
}
$0.it("can split the contents into components with double quoted strings") {
let token = Token.text(value: "hello \"kyle fuller\"")
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
let components = token.components()
try expect(components.count) == 2

View File

@@ -1,11 +1,14 @@
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
@@ -17,6 +20,14 @@ fileprivate struct Article {
let author: Person
}
fileprivate class WebSite {
let url: String = "blog.com"
}
fileprivate class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
func testVariable() {
describe("Variable") {
@@ -26,12 +37,17 @@ func testVariable() {
"profiles": [
"github": "kylef",
],
"article": Article(author: Person(name: "Kyle"))
"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\"")
@@ -45,6 +61,25 @@ func testVariable() {
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
@@ -95,12 +130,205 @@ func testVariable() {
try expect(result) == "Kyle"
}
$0.it("can get the count of a dictionary") {
let variable = Variable("profiles.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 1
}
#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"
}
#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("Subrscripting") {
$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()
}
}
}
}
}
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()
}
}
}

View File

@@ -17,6 +17,8 @@ public func stencilTests() {
testNowNode()
testInclude()
testInheritence()
testFilterTag()
testEnvironment()
testStencil()
}

View File

@@ -1,2 +1,2 @@
{% extends "child.html" %}
{% block header %}Child Child Header{% endblock %}
{% block header %}{{ block.super }} Child_Child_Header{% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block body %}Child_{{ block.super }}{% endblock %}

View File

@@ -1,2 +1,3 @@
{% extends "base.html" %}
{% block body %}Child{% endblock %}
{% block header %}Super_{{ block.super }} Child_Header{% endblock %}
{% block body %}Child_Body{% endblock %}

View File

@@ -0,0 +1,2 @@
{% block header %}Header{% endblock %}
{% block body %}Body {{ target|unknown }} {% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends "invalid-base.html" %}
{% block body %}Child {{ block.super }}{% endblock %}

View File

@@ -0,0 +1 @@
Hello {{ target|unknown }}!

150
docs/api.rst Normal file
View 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. Its somewhat like a dictionary, however you can push and pop to
scope variables. So that means that when iterating over a for loop, you can
push a new scope into the context to store any variables local to the scope.
You 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()

View File

@@ -1,51 +0,0 @@
Context
=======
A Context is a structure containing any templates you would like to use in a
template. Its somewhat like a dictionary, however you can push and pop to
scope variables. So that means that when iterating over a for loop, you can
push a new scope into the context to store any variables local to the scope.
You can initialise a ``Context`` with a ``Dictionary``.
.. code-block:: swift
Context(dictionary: [String: Any]? = nil)
API
----
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()

View File

@@ -19,6 +19,39 @@ A for loop allows you to iterate over an array found by variable lookup.
{% 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.
@@ -36,7 +69,26 @@ 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
- ``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``
~~~~~~
@@ -52,10 +104,12 @@ true the contents of the block are processed. Being true is defined as:
.. code-block:: html+django
{% if variable %}
The variable was found in the current context.
{% if admin %}
The user is an administrator.
{% elif user %}
A user is logged in.
{% else %}
The variable was not found.
No user was found.
{% endif %}
Operators
@@ -83,7 +137,7 @@ or to negate a variable.
{% endif %}
You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has
higest prescidence followed by ``and``. For example:
higest precedence followed by ``and``. For example:
.. code-block:: html+django
@@ -177,6 +231,26 @@ Will be treated as:
``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``
~~~~~~~~~~~
@@ -186,20 +260,37 @@ You can include another template using the `include` tag.
{% include "comment.html" %}
The `include` tag requires a FileSystemLoader to be found inside your context with the paths, or bundles used to lookup the template.
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 context = Context(dictionary: [
"loader": FileSystemLoader(bundle: [NSBundle.mainBundle()])
])
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
@@ -209,7 +300,7 @@ Built-in Filters
~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`.
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -219,7 +310,7 @@ For example, `stencil` to `Stencil`.
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`.
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -229,7 +320,7 @@ For example, `Stencil` to `STENCIL`.
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`.
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -248,10 +339,37 @@ value of the variable. For example:
``join``
~~~~~~~~
Join an array with a string.
Join an array of items.
.. code-block:: html+django
{{ value|join:", " }}
.. note:: The value MUST be an array of Strngs and the separator must be a string.
.. 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``.

View File

@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
# built documents.
#
# The short X.Y version.
version = '0.7.0'
version = '0.12.1'
# The full version, including alpha/beta/rc tags.
release = '0.7.0'
release = '0.12.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@@ -3,13 +3,15 @@ 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
namespace which contains all filters and tags available to the template.
extension which contains all filters and tags available to the template.
.. code-block:: swift
let namespace = Namespace()
// Register your filters and tags with the namespace
let rendered = try template.render(context, namespace: namespace)
let ext = Extension()
// Register your filters and tags with the extension
let environment = Environment(extensions: [ext])
try environment.renderTemplate(name: "example.html")
Custom Filters
--------------
@@ -18,7 +20,7 @@ Registering custom filters:
.. code-block:: swift
namespace.registerFilter("double") { (value: Any?) in
ext.registerFilter("double") { (value: Any?) in
if let value = value as? Int {
return value * 2
}
@@ -30,7 +32,7 @@ Registering custom filters with arguments:
.. code-block:: swift
namespace.registerFilter("multiply") { (value: Any?, arguments: [Any?]) in
ext.registerFilter("multiply") { (value: Any?, arguments: [Any?]) in
let amount: Int
if let value = arguments.first as? Int {
@@ -40,7 +42,7 @@ Registering custom filters with arguments:
}
if let value = value as? Int {
return value * 2
return value * amount
}
return value
@@ -54,7 +56,7 @@ write your own custom tags. The following is the simplest form:
.. code-block:: swift
namespace.registerSimpleTag("custom") { context in
ext.registerSimpleTag("custom") { context in
return "Hello World"
}

37
docs/getting-started.rst Normal file
View 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)

View File

@@ -17,32 +17,48 @@ feel right at home with Stencil.
.. code-block:: swift
struct Article {
let title: String
let author: String
}
import Stencil
let context = Context(dictionary: [
struct Article {
let title: String
let author: String
}
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
]
])
]
do {
let template = try Template(named: "template.html")
let rendered = try template.render(context)
print(rendered)
} catch {
print("Failed to render template \(error)")
}
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"])
let rendered = try environment.renderTemplate(name: context)
Contents:
print(rendered)
The User Guide
--------------
For Template Writers
~~~~~~~~~~~~~~~~~~~~
Resources for Stencil template authors to write Stencil templates.
.. toctree::
:maxdepth: 2
templates
builtins
api/context
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
View 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/kylef/Stencil.git", majorVersion: 0, minor: 8),
]
)
CocoaPods
---------
If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
``pod install``.
.. code-block:: ruby
pod 'Stencil', '~> 0.8.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 "kylef/Stencil" ~> 0.8.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/>`_.

View File

@@ -1,5 +1,5 @@
Templates
=========
Language overview
==================
- ``{{ ... }}`` for variables to print to the template output
- ``{% ... %}`` for tags
@@ -31,6 +31,24 @@ For example, if `people` was an array:
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
~~~~~~~
@@ -75,3 +93,93 @@ 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.