├── .ruby-version
├── Tests
└── StencilTests
│ ├── fixtures
│ ├── test.html
│ ├── if-block.html
│ ├── invalid-include.html
│ ├── base.html
│ ├── child-super.html
│ ├── child-child.html
│ ├── invalid-base.html
│ ├── invalid-child-super.html
│ ├── base-repeat.html
│ ├── child.html
│ ├── child-repeat.html
│ └── if-block-child.html
│ ├── .swiftlint.yml
│ ├── TemplateSpec.swift
│ ├── TokenSpec.swift
│ ├── NowNodeSpec.swift
│ ├── Helpers.swift
│ ├── LoaderSpec.swift
│ ├── StencilSpec.swift
│ ├── FilterTagSpec.swift
│ ├── InheritanceSpec.swift
│ ├── ParserSpec.swift
│ ├── EnvironmentIncludeTemplateSpec.swift
│ ├── IncludeSpec.swift
│ ├── EnvironmentBaseAndChildTemplateSpec.swift
│ ├── TrimBehaviourSpec.swift
│ ├── NodeSpec.swift
│ ├── ContextSpec.swift
│ ├── LexerSpec.swift
│ └── EnvironmentSpec.swift
├── .github
├── dependabot.yml
└── workflows
│ ├── lint-cocoapods.yml
│ ├── release-check-versions.yml
│ ├── swiftlint.yml
│ ├── danger.yml
│ ├── tag-publish.yml
│ └── test-spm.yml
├── rakelib
├── pod.rake
├── spm.rake
├── lint.sh
├── changelog.rake
├── lint.rake
├── github.rake
├── check_changelog.rb
├── Dangerfile
├── release.rake
└── utils.rake
├── Gemfile
├── Package.swift
├── Package.resolved
├── Sources
└── Stencil
│ ├── DynamicMemberLookup.swift
│ ├── FilterTag.swift
│ ├── NowTag.swift
│ ├── Include.swift
│ ├── LazyValueWrapper.swift
│ ├── TrimBehaviour.swift
│ ├── Errors.swift
│ ├── Environment.swift
│ ├── Template.swift
│ ├── KeyPath.swift
│ ├── Context.swift
│ ├── Extension.swift
│ ├── Loader.swift
│ ├── Filters.swift
│ ├── Tokenizer.swift
│ ├── Inheritance.swift
│ ├── Node.swift
│ ├── ForTag.swift
│ └── Expression.swift
├── Stencil.podspec.json
├── docs
├── getting-started.rst
├── _templates
│ └── sidebar_intro.html
├── installation.rst
├── index.rst
├── custom-template-tags-and-filters.rst
├── api.rst
├── templates.rst
└── Makefile
├── LICENSE
├── .gitignore
├── Rakefile
├── README.md
├── .swiftlint.yml
└── Gemfile.lock
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.0.4
2 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/test.html:
--------------------------------------------------------------------------------
1 | Hello {{ target }}!
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/if-block.html:
--------------------------------------------------------------------------------
1 | {% block title %}Title{% endblock %}
2 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/invalid-include.html:
--------------------------------------------------------------------------------
1 | Hello {{ target|unknown }}!
2 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/base.html:
--------------------------------------------------------------------------------
1 | {% block header %}Header{% endblock %}
2 | {% block body %}Body{% endblock %}
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/child-super.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% block body %}Child_{{ block.super }}{% endblock %}
3 |
4 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/child-child.html:
--------------------------------------------------------------------------------
1 | {% extends "child.html" %}
2 | {% block header %}{{ block.super }} Child_Child_Header{% endblock %}
3 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/invalid-base.html:
--------------------------------------------------------------------------------
1 | {% block header %}Header{% endblock %}
2 | {% block body %}Body {{ target|unknown }} {% endblock %}
3 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/invalid-child-super.html:
--------------------------------------------------------------------------------
1 | {% extends "invalid-base.html" %}
2 | {% block body %}Child {{ block.super }}{% endblock %}
3 |
4 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/base-repeat.html:
--------------------------------------------------------------------------------
1 | {% block header %}Header{% endblock %}
2 | {% block body %}Body{% endblock %}
3 | Repeat
4 | {{ block.header }}
5 | {{ block.body }}
--------------------------------------------------------------------------------
/Tests/StencilTests/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | parent_config: ../../.swiftlint.yml
2 |
3 | disabled_rules: # rule identifiers to exclude from running
4 | - type_body_length
5 | - file_length
6 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/child.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% block header %}Super_{{ block.super }} Child_Header{% endblock %}
3 | {% block body %}Child_Body{% endblock %}
4 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/child-repeat.html:
--------------------------------------------------------------------------------
1 | {% extends "base-repeat.html" %}
2 | {% block header %}Super_{{ block.super }} Child_Header{% endblock %}
3 | {% block body %}Child_Body{% endblock %}
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 |
--------------------------------------------------------------------------------
/Tests/StencilTests/fixtures/if-block-child.html:
--------------------------------------------------------------------------------
1 | {% extends "if-block.html" %}
2 | {% block title %}{% if sort == "new" %}{{ block.super }} - Nieuwste spellen{% elif sort == "upcoming" %}{{ block.super }} - Binnenkort op de agenda{% elif sort == "near-me" %}{{ block.super }} - In mijn buurt{% endif %}{% endblock %}
3 |
--------------------------------------------------------------------------------
/rakelib/pod.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - POD_NAME
5 |
6 | namespace :pod do
7 | desc 'Lint the Pod'
8 | task :lint do |task|
9 | Utils.print_header 'Linting the pod spec'
10 | Utils.run(%(bundle exec pod lib lint "#{POD_NAME}.podspec.json" --quick), task)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/rakelib/spm.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # _none_
5 |
6 | namespace :spm do
7 | desc 'Build using SPM'
8 | task :build do |task|
9 | Utils.print_header 'Compile using SPM'
10 | Utils.run('swift build', task, xcrun: true)
11 | end
12 |
13 | desc 'Run SPM Unit Tests'
14 | task :test => :build do |task|
15 | Utils.print_header 'Run the unit tests using SPM'
16 | Utils.run('swift test --parallel', task, xcrun: true)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | # The bare minimum for building, e.g. in Homebrew
6 | group :build do
7 | gem 'rake', '~> 13.0'
8 | gem 'xcpretty', '~> 0.3'
9 | end
10 |
11 | # In addition to :build, for contributing
12 | group :development do
13 | gem 'cocoapods', '~> 1.11'
14 | gem 'danger', '~> 8.4'
15 | gem 'rubocop', '~> 1.22'
16 | end
17 |
18 | # For releasing to GitHub
19 | group :release do
20 | gem 'octokit', '~> 4.7'
21 | end
22 |
--------------------------------------------------------------------------------
/.github/workflows/lint-cocoapods.yml:
--------------------------------------------------------------------------------
1 | name: Lint Cocoapods
2 |
3 | on:
4 | push:
5 | branches: master
6 | pull_request:
7 |
8 | jobs:
9 | lint:
10 | name: Pod Lint
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Lint podspec
23 | run: bundle exec rake pod:lint
24 |
--------------------------------------------------------------------------------
/.github/workflows/release-check-versions.yml:
--------------------------------------------------------------------------------
1 | name: Check Versions
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'release/**'
7 |
8 | jobs:
9 | check_versions:
10 | name: Check Versions
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Check versions
23 | run: bundle exec rake release:check_versions
24 |
--------------------------------------------------------------------------------
/.github/workflows/swiftlint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 |
3 | on:
4 | push:
5 | branches: master
6 | pull_request:
7 |
8 | jobs:
9 | lint:
10 | name: SwiftLint
11 | runs-on: macos-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Lint source code
23 | run: bundle exec rake lint:code
24 | -
25 | name: Lint tests source code
26 | run: bundle exec rake lint:tests
27 |
--------------------------------------------------------------------------------
/.github/workflows/danger.yml:
--------------------------------------------------------------------------------
1 | name: Danger
2 |
3 | on:
4 | push:
5 | branches: master
6 | pull_request:
7 |
8 | jobs:
9 | check:
10 | name: Danger Check
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Run Danger
23 | run: bundle exec danger --verbose --dangerfile=rakelib/Dangerfile
24 | env:
25 | DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.0
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "Stencil",
6 | products: [
7 | .library(name: "Stencil", targets: ["Stencil"])
8 | ],
9 | dependencies: [
10 | .package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1"),
11 | .package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
12 | ],
13 | targets: [
14 | .target(name: "Stencil", dependencies: [
15 | "PathKit"
16 | ]),
17 | .testTarget(name: "StencilTests", dependencies: [
18 | "Stencil",
19 | "Spectre"
20 | ])
21 | ],
22 | swiftLanguageVersions: [.v5]
23 | )
24 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "PathKit",
6 | "repositoryURL": "https://github.com/kylef/PathKit.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
10 | "version": "1.0.1"
11 | }
12 | },
13 | {
14 | "package": "Spectre",
15 | "repositoryURL": "https://github.com/kylef/Spectre.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
19 | "version": "0.10.1"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/Stencil/DynamicMemberLookup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | /// Marker protocol so we can know which types support `@dynamicMemberLookup`. Add this to your own types that support
8 | /// lookup by String.
9 | public protocol DynamicMemberLookup {
10 | /// Get a value for a given `String` key
11 | subscript(dynamicMember member: String) -> Any? { get }
12 | }
13 |
14 | public extension DynamicMemberLookup where Self: RawRepresentable {
15 | /// Get a value for a given `String` key
16 | subscript(dynamicMember member: String) -> Any? {
17 | switch member {
18 | case "rawValue":
19 | return rawValue
20 | default:
21 | return nil
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Tests/StencilTests/TemplateSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class TemplateTests: XCTestCase {
12 | func testTemplate() {
13 | it("can render a template from a string") {
14 | let template = Template(templateString: "Hello World")
15 | let result = try template.render([ "name": "Kyle" ])
16 | try expect(result) == "Hello World"
17 | }
18 |
19 | it("can render a template from a string literal") {
20 | let template: Template = "Hello World"
21 | let result = try template.render([ "name": "Kyle" ])
22 | try expect(result) == "Hello World"
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Stencil.podspec.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stencil",
3 | "version": "0.15.1",
4 | "summary": "Stencil is a simple and powerful template language for Swift.",
5 | "homepage": "https://stencil.fuller.li",
6 | "license": {
7 | "type": "BSD",
8 | "file": "LICENSE"
9 | },
10 | "authors": {
11 | "Kyle Fuller": "kyle@fuller.li"
12 | },
13 | "social_media_url": "https://twitter.com/kylefuller",
14 | "source": {
15 | "git": "https://github.com/stencilproject/Stencil.git",
16 | "tag": "0.15.1"
17 | },
18 | "source_files": [
19 | "Sources/Stencil/*.swift"
20 | ],
21 | "platforms": {
22 | "ios": "8.0",
23 | "osx": "10.9",
24 | "tvos": "9.0"
25 | },
26 | "cocoapods_version": ">= 1.7.0",
27 | "swift_versions": [
28 | "5.0"
29 | ],
30 | "requires_arc": true,
31 | "dependencies": {
32 | "PathKit": [
33 | "~> 1.0.0"
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/rakelib/lint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PROJECT_DIR="${PROJECT_DIR:-`cd "$(dirname $0)/..";pwd`}"
4 | SWIFTLINT="${PROJECT_DIR}/.build/swiftlint/swiftlint"
5 | CONFIG="${PROJECT_DIR}/.swiftlint.yml"
6 | if [ $CI ]; then
7 | REPORTER="--reporter github-actions-logging"
8 | else
9 | REPORTER=
10 | fi
11 |
12 | # possible paths
13 | paths_sources="Sources/Stencil"
14 | paths_tests="Tests/StencilTests"
15 |
16 | # load selected group
17 | if [ $# -gt 0 ]; then
18 | key="$1"
19 | else
20 | echo "error: need group to lint."
21 | exit 1
22 | fi
23 |
24 | selected_path=`eval echo '$'paths_$key`
25 | if [ -z "$selected_path" ]; then
26 | echo "error: need a valid group to lint."
27 | exit 1
28 | fi
29 |
30 | SUB_CONFIG="${PROJECT_DIR}/${selected_path}/.swiftlint.yml"
31 | if [ -f "$SUB_CONFIG" ]; then
32 | "$SWIFTLINT" lint --strict --config "$SUB_CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
33 | else
34 | "$SWIFTLINT" lint --strict --config "$CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
35 | fi
36 |
--------------------------------------------------------------------------------
/.github/workflows/tag-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish on Tag
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | cocoapods:
11 | name: Push To CocoaPods
12 | runs-on: ubuntu-latest
13 | steps:
14 | -
15 | name: Checkout
16 | uses: actions/checkout@v3
17 | -
18 | name: Set up Ruby
19 | uses: ruby/setup-ruby@v1
20 | with:
21 | bundler-cache: true
22 | -
23 | name: Push to CocoaPods
24 | run: bundle exec rake release:cocoapods
25 | env:
26 | COCOAPODS_TRUNK_TOKEN: ${{secrets.COCOAPODS_TRUNK_TOKEN}}
27 |
28 | github:
29 | name: GitHub Release
30 | runs-on: ubuntu-latest
31 | steps:
32 | -
33 | name: Checkout
34 | uses: actions/checkout@v3
35 | -
36 | name: Set up Ruby
37 | uses: ruby/setup-ruby@v1
38 | with:
39 | bundler-cache: true
40 | -
41 | name: Create release on GitHub
42 | run: bundle exec rake release:github
43 | env:
44 | DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
45 |
--------------------------------------------------------------------------------
/docs/getting-started.rst:
--------------------------------------------------------------------------------
1 | Getting Started
2 | ===============
3 |
4 | The easiest way to render a template using Stencil is to create a template and
5 | call render on it providing a context.
6 |
7 | .. code-block:: swift
8 |
9 | let template = Template(templateString: "Hello {{ name }}")
10 | try template.render(["name": "kyle"])
11 |
12 | For more advanced uses, you would normally create an ``Environment`` and call
13 | the ``renderTemplate`` convinience method.
14 |
15 | .. code-block:: swift
16 |
17 | let environment = Environment()
18 |
19 | let context = ["name": "kyle"]
20 | try environment.renderTemplate(string: "Hello {{ name }}", context: context)
21 |
22 | Template Loaders
23 | ----------------
24 |
25 | A template loader allows you to load files from disk or elsewhere. Using a
26 | ``FileSystemLoader`` we can easily render a template from disk.
27 |
28 | For example, to render a template called ``index.html`` inside the
29 | ``templates/`` directory we can use the following:
30 |
31 | .. code-block:: swift
32 |
33 | let fsLoader = FileSystemLoader(paths: ["templates/"])
34 | let environment = Environment(loader: fsLoader)
35 |
36 | let context = ["name": "kyle"]
37 | try environment.renderTemplate(name: "index.html", context: context)
38 |
--------------------------------------------------------------------------------
/Tests/StencilTests/TokenSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class TokenTests: XCTestCase {
12 | func testToken() {
13 | it("can split the contents into components") {
14 | let token = Token.text(value: "hello world", at: .unknown)
15 | let components = token.components
16 |
17 | try expect(components.count) == 2
18 | try expect(components[0]) == "hello"
19 | try expect(components[1]) == "world"
20 | }
21 |
22 | it("can split the contents into components with single quoted strings") {
23 | let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
24 | let components = token.components
25 |
26 | try expect(components.count) == 2
27 | try expect(components[0]) == "hello"
28 | try expect(components[1]) == "'kyle fuller'"
29 | }
30 |
31 | it("can split the contents into components with double quoted strings") {
32 | let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
33 | let components = token.components
34 |
35 | try expect(components.count) == 2
36 | try expect(components[0]) == "hello"
37 | try expect(components[1]) == "\"kyle fuller\""
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/Stencil/FilterTag.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | class FilterNode: NodeType {
8 | let resolvable: Resolvable
9 | let nodes: [NodeType]
10 | let token: Token?
11 |
12 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
13 | let bits = token.components
14 |
15 | guard bits.count == 2 else {
16 | throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
17 | }
18 |
19 | let blocks = try parser.parse(until(["endfilter"]))
20 |
21 | guard parser.nextToken() != nil else {
22 | throw TemplateSyntaxError("`endfilter` was not found.")
23 | }
24 |
25 | let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
26 | return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
27 | }
28 |
29 | init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
30 | self.nodes = nodes
31 | self.resolvable = resolvable
32 | self.token = token
33 | }
34 |
35 | func render(_ context: Context) throws -> String {
36 | let value = try renderNodes(nodes, context)
37 |
38 | return try context.push(dictionary: ["filter_value": value]) {
39 | try VariableNode(variable: resolvable, token: token).render(context)
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2022, Kyle Fuller
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 |
--------------------------------------------------------------------------------
/Sources/Stencil/NowTag.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | #if !os(Linux)
8 | import Foundation
9 |
10 | class NowNode: NodeType {
11 | let format: Variable
12 | let token: Token?
13 |
14 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
15 | var format: Variable?
16 |
17 | let components = token.components
18 | guard components.count <= 2 else {
19 | throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
20 | }
21 | if components.count == 2 {
22 | format = Variable(components[1])
23 | }
24 |
25 | return NowNode(format: format, token: token)
26 | }
27 |
28 | init(format: Variable?, token: Token? = nil) {
29 | self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
30 | self.token = token
31 | }
32 |
33 | func render(_ context: Context) throws -> String {
34 | let date = Date()
35 | let format = try self.format.resolve(context)
36 |
37 | var formatter: DateFormatter
38 | if let format = format as? DateFormatter {
39 | formatter = format
40 | } else if let format = format as? String {
41 | formatter = DateFormatter()
42 | formatter.dateFormat = format
43 | } else {
44 | return ""
45 | }
46 |
47 | return formatter.string(from: date)
48 | }
49 | }
50 | #endif
51 |
--------------------------------------------------------------------------------
/rakelib/changelog.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # _none_
5 |
6 | require_relative 'check_changelog'
7 |
8 | namespace :changelog do
9 | desc 'Add the empty CHANGELOG entries after a new release'
10 | task :reset do
11 | changelog = File.read('CHANGELOG.md')
12 | abort('A Master entry already exists') if changelog =~ /^##\s*Master$/
13 | changelog.sub!(/^##[^#]/, "#{header}\\0")
14 | File.write('CHANGELOG.md', changelog)
15 | end
16 |
17 | def header
18 | <<-HEADER.gsub(/^\s*\|/, '')
19 | |## Master
20 | |
21 | |### Breaking
22 | |
23 | |_None_
24 | |
25 | |### Enhancements
26 | |
27 | |_None_
28 | |
29 | |### Deprecations
30 | |
31 | |_None_
32 | |
33 | |### Bug Fixes
34 | |
35 | |_None_
36 | |
37 | |### Internal Changes
38 | |
39 | |_None_
40 | |
41 | HEADER
42 | end
43 |
44 | desc 'Check if links to issues and PRs use matching numbers between text & link'
45 | task :check do
46 | warnings = check_changelog
47 | if warnings.empty?
48 | puts "\u{2705} All entries seems OK (end with period + 2 spaces, correct links)"
49 | else
50 | puts "\u{274C} Some warnings were found:\n" + Array(warnings.map do |warning|
51 | " - Line #{warning[:line]}: #{warning[:message]}"
52 | end).join("\n")
53 | exit 1
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/rakelib/lint.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - BUILD_DIR
5 |
6 | namespace :lint do
7 | SWIFTLINT = 'rakelib/lint.sh'
8 | SWIFTLINT_VERSION = '0.48.0'
9 |
10 | task :install do |task|
11 | next if check_version
12 |
13 | if OS.mac?
14 | url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip"
15 | else
16 | url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/swiftlint_linux.zip"
17 | end
18 | tmppath = '/tmp/swiftlint.zip'
19 | destination = "#{BUILD_DIR}/swiftlint"
20 |
21 | Utils.run([
22 | %(curl -Lo #{tmppath} #{url}),
23 | %(rm -rf #{destination}),
24 | %(mkdir -p #{destination}),
25 | %(unzip #{tmppath} -d #{destination})
26 | ], task)
27 | end
28 |
29 | desc 'Lint the code'
30 | task :code => :install do |task|
31 | Utils.print_header 'Linting the code'
32 | Utils.run(%(#{SWIFTLINT} sources), task)
33 | end
34 |
35 | desc 'Lint the tests'
36 | task :tests => :install do |task|
37 | Utils.print_header 'Linting the unit test code'
38 | Utils.run(%(#{SWIFTLINT} tests), task)
39 | end
40 |
41 | def check_version
42 | swiftlint = "#{BUILD_DIR}/swiftlint/swiftlint"
43 | return false unless File.executable?(swiftlint)
44 |
45 | current = `#{swiftlint} version`.chomp
46 | required = SWIFTLINT_VERSION.chomp
47 |
48 | current == required
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/docs/_templates/sidebar_intro.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 | Stencil is a simple and powerful template language for Swift.
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Other Projects
26 |
27 | More Kyle Fuller projects:
28 |
34 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ============
3 |
4 | Swift Package Manager
5 | ---------------------
6 |
7 | If you're using the Swift Package Manager, you can add ``Stencil`` to your
8 | dependencies inside ``Package.swift``.
9 |
10 | .. code-block:: swift
11 |
12 | import PackageDescription
13 |
14 | let package = Package(
15 | name: "MyApplication",
16 | dependencies: [
17 | .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
18 | ]
19 | )
20 |
21 | CocoaPods
22 | ---------
23 |
24 | If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
25 | ``pod install``.
26 |
27 | .. code-block:: ruby
28 |
29 | pod 'Stencil', '~> 0.15.1'
30 |
31 | Carthage
32 | --------
33 |
34 | .. note:: Use at your own risk. We don't offer support for Carthage and instead recommend you use Swift Package Manager.
35 |
36 | 1) Add ``Stencil`` to your ``Cartfile``:
37 |
38 | .. code-block:: text
39 |
40 | github "stencilproject/Stencil" ~> 0.15.1
41 |
42 | 2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:
43 |
44 | .. code-block:: shell
45 |
46 | $ carthage update
47 | $ (cd Carthage/Checkouts/Stencil && swift package generate-xcodeproj)
48 | $ carthage build
49 |
50 | 3) Follow the Carthage steps to add the built frameworks to your project.
51 |
52 | To learn more about this approach see `Using Swift Package Manager with Carthage `_.
53 |
--------------------------------------------------------------------------------
/rakelib/github.rake:
--------------------------------------------------------------------------------
1 | require 'octokit'
2 |
3 | def repo_slug
4 | url_parts = `git remote get-url origin`.chomp.split(%r{/|:})
5 | last_two_parts = url_parts[-2..-1].join('/')
6 | last_two_parts.gsub(/\.git$/, '')
7 | end
8 |
9 | def github_client
10 | Octokit::Client.new(:netrc => true)
11 | end
12 |
13 | namespace :github do
14 | # rake github:create_release_pr[version]
15 | task :create_release_pr, [:version] do |_, args|
16 | version = args[:version]
17 | branch = release_branch(version)
18 |
19 | title = "Release #{version}"
20 | body = <<~BODY
21 | This PR prepares the release for version #{version}.
22 |
23 | Once the PR is merged into master, run `bundle exec rake release:finish` to tag and push to trunk.
24 | BODY
25 |
26 | header "Opening PR"
27 | res = github_client.create_pull_request(repo_slug, "master", branch, title, body)
28 | info "Pull request created: #{res['html_url']}"
29 | end
30 |
31 | # rake github:tag
32 | task :tag do
33 | tag = current_pod_version
34 | sh("git", "tag", tag)
35 | sh("git", "push", "origin", tag)
36 | end
37 |
38 | # rake github:create_release
39 | task :create_release do
40 | tag_name = current_pod_version
41 | title = tag_name
42 | body = changelog_first_section()
43 | res = github_client.create_release(repo_slug, tag_name, name: title, body: body)
44 | info "GitHub Release created: #{res['html_url']}"
45 | end
46 |
47 | # rake github:pull_master
48 | task :pull_master do
49 | sh("git", "switch", "master")
50 | sh("git", "pull")
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | The Stencil template language
2 | =============================
3 |
4 | Stencil is a simple and powerful template language for Swift. It provides a
5 | syntax similar to Django and Mustache. If you're familiar with these, you will
6 | feel right at home with Stencil.
7 |
8 | .. code-block:: html+django
9 |
10 | There are {{ articles.count }} articles.
11 |
12 |
13 | {% for article in articles %}
14 | {{ article.title }} by {{ article.author }}
15 | {% endfor %}
16 |
17 |
18 | .. code-block:: swift
19 |
20 | import Stencil
21 |
22 | struct Article {
23 | let title: String
24 | let author: String
25 | }
26 |
27 | let context = [
28 | "articles": [
29 | Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
30 | Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
31 | ]
32 | ]
33 |
34 | let environment = Environment(loader: FileSystemLoader(paths: ["templates/"])
35 | let rendered = try environment.renderTemplate(name: "articles.html", context: context)
36 |
37 | print(rendered)
38 |
39 | The User Guide
40 | --------------
41 |
42 | For Template Writers
43 | ~~~~~~~~~~~~~~~~~~~~
44 |
45 | Resources for Stencil template authors to write Stencil templates.
46 |
47 | .. toctree::
48 | :maxdepth: 2
49 |
50 | templates
51 | builtins
52 |
53 | For Developers
54 | ~~~~~~~~~~~~~~
55 |
56 | Resources to help you integrate Stencil into a Swift project.
57 |
58 | .. toctree::
59 | :maxdepth: 1
60 |
61 | installation
62 | getting-started
63 | api
64 | custom-template-tags-and-filters
65 |
--------------------------------------------------------------------------------
/Tests/StencilTests/NowNodeSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class NowNodeTests: XCTestCase {
12 | func testParsing() {
13 | it("parses default format without any now arguments") {
14 | #if os(Linux)
15 | throw skip()
16 | #else
17 | let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
18 | let parser = TokenParser(tokens: tokens, environment: Environment())
19 |
20 | let nodes = try parser.parse()
21 | let node = nodes.first as? NowNode
22 | try expect(nodes.count) == 1
23 | try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
24 | #endif
25 | }
26 |
27 | it("parses now with a format") {
28 | #if os(Linux)
29 | throw skip()
30 | #else
31 | let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
32 | let parser = TokenParser(tokens: tokens, environment: Environment())
33 | let nodes = try parser.parse()
34 | let node = nodes.first as? NowNode
35 | try expect(nodes.count) == 1
36 | try expect(node?.format.variable) == "\"HH:mm\""
37 | #endif
38 | }
39 | }
40 |
41 | func testRendering() {
42 | it("renders the date") {
43 | #if os(Linux)
44 | throw skip()
45 | #else
46 | let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
47 |
48 | let formatter = DateFormatter()
49 | formatter.dateFormat = "yyyy-MM-dd"
50 | let date = formatter.string(from: Date())
51 |
52 | try expect(try node.render(Context())) == date
53 | #endif
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Build generated
6 | build/
7 | DerivedData/
8 | Fixtures/stub-env/**/*.swiftmodule
9 |
10 | ## Various settings
11 | *.pbxuser
12 | !default.pbxuser
13 | *.mode1v3
14 | !default.mode1v3
15 | *.mode2v3
16 | !default.mode2v3
17 | *.perspectivev3
18 | !default.perspectivev3
19 | xcuserdata/
20 |
21 | ## Other
22 | *.moved-aside
23 | *.xcuserstate
24 | *.xcscmblueprint
25 |
26 | ## Obj-C/Swift specific
27 | *.hmap
28 | *.ipa
29 | *.dSYM.zip
30 | *.dSYM
31 |
32 | ## Playgrounds
33 | timeline.xctimeline
34 | playground.xcworkspace
35 |
36 | # Swift Package Manager
37 | #
38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
39 | Packages/
40 | .build/
41 | .swiftpm/
42 |
43 | # CocoaPods
44 | #
45 | # We recommend against adding the Pods directory to your .gitignore. However
46 | # you should judge for yourself, the pros and cons are mentioned at:
47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
48 | #
49 | # Pods/
50 |
51 | # Carthage
52 | #
53 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
54 | Carthage/Checkouts
55 | Carthage/Build
56 |
57 | # fastlane
58 | #
59 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
60 | # screenshots whenever they are needed.
61 | # For more information about the recommended setup visit:
62 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
63 |
64 | fastlane/report.xml
65 | fastlane/Preview.html
66 | fastlane/screenshots
67 | fastlane/test_output
68 |
69 | # Other stuff
70 | .apitoken
71 | .DS_Store
72 | .idea/
73 | bin/
74 | Frameworks/
75 | Rome/
76 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/rake
2 |
3 | unless defined?(Bundler)
4 | puts 'Please use bundle exec to run the rake command'
5 | exit 1
6 | end
7 |
8 | require 'English'
9 |
10 | ## [ Constants ] ##############################################################
11 |
12 | POD_NAME = 'Stencil'
13 | MIN_XCODE_VERSION = 13.0
14 | BUILD_DIR = File.absolute_path('./.build')
15 |
16 | ## [ Build Tasks ] ############################################################
17 |
18 | namespace :files do
19 | desc 'Update all files containing a version'
20 | task :update, [:version] do |_, args|
21 | version = args[:version]
22 |
23 | Utils.print_header "Updating files for version #{version}"
24 |
25 | podspec = Utils.podspec(POD_NAME)
26 | podspec['version'] = version
27 | podspec['source']['tag'] = version
28 | File.write("#{POD_NAME}.podspec.json", JSON.pretty_generate(podspec) + "\n")
29 |
30 | replace('CHANGELOG.md', '## Master' => "\#\# #{version}")
31 | replace("docs/conf.py",
32 | /^version = .*/ => %Q(version = '#{version}'),
33 | /^release = .*/ => %Q(release = '#{version}')
34 | )
35 | docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
36 | replace("docs/installation.rst",
37 | /\.package\(url: .+, from: "(.+)"/ => %Q(.package\(url: "https://github.com/stencilproject/Stencil.git", from: "#{version}"),
38 | /pod 'Stencil', '.*'/ => %Q(pod 'Stencil', '~> #{version}'),
39 | /github "stencilproject\/Stencil" ~> .*/ => %Q(github "stencilproject/Stencil" ~> #{version})
40 | )
41 | end
42 |
43 | def replace(file, replacements)
44 | content = File.read(file)
45 | replacements.each do |match, replacement|
46 | content.gsub!(match, replacement)
47 | end
48 | File.write(file, content)
49 | end
50 | end
51 |
52 | task :default => 'release:new'
53 |
--------------------------------------------------------------------------------
/.github/workflows/test-spm.yml:
--------------------------------------------------------------------------------
1 | name: Test SPM
2 |
3 | on:
4 | push:
5 | branches: master
6 | pull_request:
7 |
8 | jobs:
9 | linux:
10 | name: Test SPM Linux
11 | runs-on: ubuntu-latest
12 | container: swiftgen/swift:5.6
13 | steps:
14 | -
15 | name: Checkout
16 | uses: actions/checkout@v3
17 | -
18 | # Note: we can't use `ruby/setup-ruby` on custom docker images, so we
19 | # have to do our own caching
20 | name: Cache gems
21 | uses: actions/cache@v3
22 | with:
23 | path: vendor/bundle
24 | key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
25 | restore-keys: |
26 | ${{ runner.os }}-gems-
27 | -
28 | name: Cache SPM
29 | uses: actions/cache@v3
30 | with:
31 | path: .build
32 | key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
33 | restore-keys: |
34 | ${{ runner.os }}-spm-
35 | -
36 | name: Bundle install
37 | run: |
38 | bundle config path vendor/bundle
39 | bundle install --jobs 4 --retry 3
40 | -
41 | name: Run tests
42 | run: bundle exec rake spm:test
43 |
44 | macos:
45 | name: Test SPM macOS
46 | runs-on: macos-latest
47 | steps:
48 | -
49 | name: Checkout
50 | uses: actions/checkout@v3
51 | -
52 | name: Setup Ruby
53 | uses: ruby/setup-ruby@v1
54 | with:
55 | bundler-cache: true
56 | -
57 | name: Cache SPM
58 | uses: actions/cache@v3
59 | with:
60 | path: .build
61 | key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
62 | restore-keys: |
63 | ${{ runner.os }}-spm-
64 | -
65 | name: Run tests
66 | run: bundle exec rake spm:test
67 |
--------------------------------------------------------------------------------
/Sources/Stencil/Include.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 |
9 | class IncludeNode: NodeType {
10 | let templateName: Variable
11 | let includeContext: String?
12 | let token: Token?
13 |
14 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
15 | let bits = token.components
16 |
17 | guard bits.count == 2 || bits.count == 3 else {
18 | throw TemplateSyntaxError(
19 | """
20 | 'include' tag requires one argument, the template file to be included. \
21 | A second optional argument can be used to specify the context that will \
22 | be passed to the included file
23 | """
24 | )
25 | }
26 |
27 | return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
28 | }
29 |
30 | init(templateName: Variable, includeContext: String? = nil, token: Token) {
31 | self.templateName = templateName
32 | self.includeContext = includeContext
33 | self.token = token
34 | }
35 |
36 | func render(_ context: Context) throws -> String {
37 | guard let templateName = try self.templateName.resolve(context) as? String else {
38 | throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
39 | }
40 |
41 | let template = try context.environment.loadTemplate(name: templateName)
42 |
43 | do {
44 | let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
45 | return try context.push(dictionary: subContext) {
46 | try template.render(context)
47 | }
48 | } catch {
49 | if let error = error as? TemplateSyntaxError {
50 | throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
51 | } else {
52 | throw error
53 | }
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Tests/StencilTests/Helpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | extension Expectation {
13 | @discardableResult
14 | func toThrow() throws -> T {
15 | var thrownError: Error?
16 |
17 | do {
18 | _ = try expression()
19 | } catch {
20 | thrownError = error
21 | }
22 |
23 | if let thrownError = thrownError {
24 | if let thrownError = thrownError as? T {
25 | return thrownError
26 | } else {
27 | throw failure("\(thrownError) is not \(T.self)")
28 | }
29 | } else {
30 | throw failure("expression did not throw an error")
31 | }
32 | }
33 | }
34 |
35 | extension XCTestCase {
36 | func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
37 | guard let range = template.templateString.range(of: token) else {
38 | fatalError("Can't find '\(token)' in '\(template)'")
39 | }
40 | let lexer = Lexer(templateString: template.templateString)
41 | let location = lexer.rangeLocation(range)
42 | let sourceMap = SourceMap(filename: template.name, location: location)
43 | let token = Token.block(value: token, at: sourceMap)
44 | return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
45 | }
46 | }
47 |
48 | // MARK: - Test Types
49 |
50 | class ExampleLoader: Loader {
51 | func loadTemplate(name: String, environment: Environment) throws -> Template {
52 | if name == "example.html" {
53 | return Template(templateString: "Hello World!", environment: environment, name: name)
54 | }
55 |
56 | throw TemplateDoesNotExist(templateNames: [name], loader: self)
57 | }
58 | }
59 |
60 | class ErrorNode: NodeType {
61 | let token: Token?
62 | init(token: Token? = nil) {
63 | self.token = token
64 | }
65 |
66 | func render(_ context: Context) throws -> String {
67 | throw TemplateSyntaxError("Custom Error")
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Tests/StencilTests/LoaderSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | import Stencil
10 | import XCTest
11 |
12 | final class TemplateLoaderTests: XCTestCase {
13 | func testFileSystemLoader() {
14 | let path = Path(#file as String) + ".." + "fixtures"
15 | let loader = FileSystemLoader(paths: [path])
16 | let environment = Environment(loader: loader)
17 |
18 | it("errors when a template cannot be found") {
19 | try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
20 | }
21 |
22 | it("errors when an array of templates cannot be found") {
23 | try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
24 | }
25 |
26 | it("can load a template from a file") {
27 | _ = try environment.loadTemplate(name: "test.html")
28 | }
29 |
30 | it("errors when loading absolute file outside of the selected path") {
31 | try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
32 | }
33 |
34 | it("errors when loading relative file outside of the selected path") {
35 | try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
36 | }
37 | }
38 |
39 | func testDictionaryLoader() {
40 | let loader = DictionaryLoader(templates: [
41 | "index.html": "Hello World"
42 | ])
43 | let environment = Environment(loader: loader)
44 |
45 | it("errors when a template cannot be found") {
46 | try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
47 | }
48 |
49 | it("errors when an array of templates cannot be found") {
50 | try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
51 | }
52 |
53 | it("can load a template from a known templates") {
54 | _ = try environment.loadTemplate(name: "index.html")
55 | }
56 |
57 | it("can load a known template from a collection of templates") {
58 | _ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Tests/StencilTests/StencilSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | import Stencil
9 | import XCTest
10 |
11 | final class StencilTests: XCTestCase {
12 | private lazy var environment: Environment = {
13 | let exampleExtension = Extension()
14 | exampleExtension.registerSimpleTag("simpletag") { _ in
15 | "Hello World"
16 | }
17 | exampleExtension.registerTag("customtag") { _, token in
18 | CustomNode(token: token)
19 | }
20 | return Environment(extensions: [exampleExtension])
21 | }()
22 |
23 | func testStencil() {
24 | it("can render the README example") {
25 | let templateString = """
26 | There are {{ articles.count }} articles.
27 |
28 | {% for article in articles %}\
29 | - {{ article.title }} by {{ article.author }}.
30 | {% endfor %}
31 | """
32 |
33 | let context = [
34 | "articles": [
35 | Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
36 | Article(title: "Memory Management with ARC", author: "Kyle Fuller")
37 | ]
38 | ]
39 |
40 | let template = Template(templateString: templateString)
41 | let result = try template.render(context)
42 |
43 | try expect(result) == """
44 | There are 2 articles.
45 |
46 | - Migrating from OCUnit to XCTest by Kyle Fuller.
47 | - Memory Management with ARC by Kyle Fuller.
48 |
49 | """
50 | }
51 |
52 | it("can render a custom template tag") {
53 | let result = try self.environment.renderTemplate(string: "{% customtag %}")
54 | try expect(result) == "Hello World"
55 | }
56 |
57 | it("can render a simple custom tag") {
58 | let result = try self.environment.renderTemplate(string: "{% simpletag %}")
59 | try expect(result) == "Hello World"
60 | }
61 | }
62 | }
63 |
64 | // MARK: - Helpers
65 |
66 | private struct CustomNode: NodeType {
67 | let token: Token?
68 | func render(_ context: Context) throws -> String {
69 | "Hello World"
70 | }
71 | }
72 |
73 | private struct Article {
74 | let title: String
75 | let author: String
76 | }
77 |
--------------------------------------------------------------------------------
/Tests/StencilTests/FilterTagSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | import Stencil
9 | import XCTest
10 |
11 | final class FilterTagTests: XCTestCase {
12 | func testFilterTag() {
13 | it("allows you to use a filter") {
14 | let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
15 | let result = try template.render()
16 | try expect(result) == "TEST"
17 | }
18 |
19 | it("allows you to chain filters") {
20 | let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
21 | let result = try template.render()
22 | try expect(result) == "Test"
23 | }
24 |
25 | it("errors without a filter") {
26 | let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
27 | try expect(try template.render()).toThrow()
28 | }
29 |
30 | it("can render filters with arguments") {
31 | let ext = Extension()
32 | ext.registerFilter("split") { value, args in
33 | guard let value = value as? String,
34 | let argument = args.first as? String else { return value }
35 | return value.components(separatedBy: argument)
36 | }
37 | let env = Environment(extensions: [ext])
38 | let result = try env.renderTemplate(string: """
39 | {% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
40 | """, context: ["items": [1, 2]])
41 | try expect(result) == "1;2"
42 | }
43 |
44 | it("can render filters with quote as an argument") {
45 | let ext = Extension()
46 | ext.registerFilter("replace") { value, args in
47 | guard let value = value as? String,
48 | args.count == 2,
49 | let search = args.first as? String,
50 | let replacement = args.last as? String else { return value }
51 | return value.replacingOccurrences(of: search, with: replacement)
52 | }
53 | let env = Environment(extensions: [ext])
54 | let result = try env.renderTemplate(string: """
55 | {% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
56 | """, context: ["items": ["\"1\"", "\"2\""]])
57 | try expect(result) == "1,2"
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/Stencil/LazyValueWrapper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | /// Used to lazily set context data. Useful for example if you have some data that requires heavy calculations, and may
8 | /// not be used in every render possiblity.
9 | public final class LazyValueWrapper {
10 | private let closure: (Context) throws -> Any
11 | private let context: Context?
12 | private var cachedValue: Any?
13 |
14 | /// Create a wrapper that'll use a **reference** to the current context.
15 | /// This means when the closure is evaluated, it'll use the **active** context at that moment.
16 | ///
17 | /// - Parameters:
18 | /// - closure: The closure to lazily evaluate
19 | public init(closure: @escaping (Context) throws -> Any) {
20 | self.context = nil
21 | self.closure = closure
22 | }
23 |
24 | /// Create a wrapper that'll create a **copy** of the current context.
25 | /// This means when the closure is evaluated, it'll use the context **as it was** when this wrapper was created.
26 | ///
27 | /// - Parameters:
28 | /// - context: The context to use during evaluation
29 | /// - closure: The closure to lazily evaluate
30 | /// - Note: This will use more memory than the other `init` as it needs to keep a copy of the full context around.
31 | public init(copying context: Context, closure: @escaping (Context) throws -> Any) {
32 | self.context = Context(dictionaries: context.dictionaries, environment: context.environment)
33 | self.closure = closure
34 | }
35 |
36 | /// Shortcut for creating a lazy wrapper when you don't need access to the Stencil context.
37 | ///
38 | /// - Parameters:
39 | /// - closure: The closure to lazily evaluate
40 | public init(_ closure: @autoclosure @escaping () throws -> Any) {
41 | self.context = nil
42 | self.closure = { _ in try closure() }
43 | }
44 | }
45 |
46 | extension LazyValueWrapper {
47 | func value(context: Context) throws -> Any {
48 | if let value = cachedValue {
49 | return value
50 | } else {
51 | let value = try closure(self.context ?? context)
52 | cachedValue = value
53 | return value
54 | }
55 | }
56 | }
57 |
58 | extension LazyValueWrapper: Resolvable {
59 | public func resolve(_ context: Context) throws -> Any? {
60 | let value = try self.value(context: context)
61 | return try (value as? Resolvable)?.resolve(context) ?? value
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Tests/StencilTests/InheritanceSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | import Stencil
10 | import XCTest
11 |
12 | final class InheritanceTests: XCTestCase {
13 | private let path = Path(#file as String) + ".." + "fixtures"
14 | private lazy var loader = FileSystemLoader(paths: [path])
15 | private lazy var environment = Environment(loader: loader)
16 |
17 | func testInheritance() {
18 | it("can inherit from another template") {
19 | let template = try self.environment.loadTemplate(name: "child.html")
20 | try expect(try template.render()) == """
21 | Super_Header Child_Header
22 | Child_Body
23 | """
24 | }
25 |
26 | it("can inherit from another template inheriting from another template") {
27 | let template = try self.environment.loadTemplate(name: "child-child.html")
28 | try expect(try template.render()) == """
29 | Super_Header Child_Header Child_Child_Header
30 | Child_Body
31 | """
32 | }
33 |
34 | it("can inherit from a template that calls a super block") {
35 | let template = try self.environment.loadTemplate(name: "child-super.html")
36 | try expect(try template.render()) == """
37 | Header
38 | Child_Body
39 | """
40 | }
41 |
42 | it("can render block.super in if tag") {
43 | let template = try self.environment.loadTemplate(name: "if-block-child.html")
44 |
45 | try expect(try template.render(["sort": "new"])) == """
46 | Title - Nieuwste spellen
47 |
48 | """
49 |
50 | try expect(try template.render(["sort": "upcoming"])) == """
51 | Title - Binnenkort op de agenda
52 |
53 | """
54 |
55 | try expect(try template.render(["sort": "near-me"])) == """
56 | Title - In mijn buurt
57 |
58 | """
59 | }
60 | }
61 |
62 | func testInheritanceCache() {
63 | it("can call block twice") {
64 | let template: Template = "{% block repeat %}Block{% endblock %}{{ block.repeat }}"
65 | try expect(try template.render()) == "BlockBlock"
66 | }
67 |
68 | it("renders child content when calling block twice in base template") {
69 | let template = try self.environment.loadTemplate(name: "child-repeat.html")
70 | try expect(try template.render()) == """
71 | Super_Header Child_Header
72 | Child_Body
73 | Repeat
74 | Super_Header Child_Header
75 | Child_Body
76 | """
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stencil
2 |
3 | Stencil is a simple and powerful template language for Swift. It provides a
4 | syntax similar to Django and Mustache. If you're familiar with these, you will
5 | feel right at home with Stencil.
6 |
7 | ## Example
8 |
9 | ```html+django
10 | There are {{ articles.count }} articles.
11 |
12 |
13 | {% for article in articles %}
14 | {{ article.title }} by {{ article.author }}
15 | {% endfor %}
16 |
17 | ```
18 |
19 | ```swift
20 | import Stencil
21 |
22 | struct Article {
23 | let title: String
24 | let author: String
25 | }
26 |
27 | let context = [
28 | "articles": [
29 | Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
30 | Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
31 | ]
32 | ]
33 |
34 | let environment = Environment(loader: FileSystemLoader(paths: ["templates/"]))
35 | let rendered = try environment.renderTemplate(name: "article_list.html", context: context)
36 |
37 | print(rendered)
38 | ```
39 |
40 | ## Philosophy
41 |
42 | Stencil follows the same philosophy of Django:
43 |
44 | > If you have a background in programming, or if you’re used to languages which
45 | > mix programming code directly into HTML, you’ll want to bear in mind that the
46 | > Django template system is not simply Python embedded into HTML. This is by
47 | > design: the template system is meant to express presentation, not program
48 | > logic.
49 |
50 | ## The User Guide
51 |
52 | Resources for Stencil template authors to write Stencil templates:
53 |
54 | - [Language overview](http://stencil.fuller.li/en/latest/templates.html)
55 | - [Built-in template tags and filters](http://stencil.fuller.li/en/latest/builtins.html)
56 |
57 | Resources to help you integrate Stencil into a Swift project:
58 |
59 | - [Installation](http://stencil.fuller.li/en/latest/installation.html)
60 | - [Getting Started](http://stencil.fuller.li/en/latest/getting-started.html)
61 | - [API Reference](http://stencil.fuller.li/en/latest/api.html)
62 | - [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html)
63 |
64 | ## Projects that use Stencil
65 |
66 | [Sourcery](https://github.com/krzysztofzablocki/Sourcery),
67 | [SwiftGen](https://github.com/SwiftGen/SwiftGen),
68 | [Kitura](https://github.com/IBM-Swift/Kitura),
69 | [Weaver](https://github.com/scribd/Weaver),
70 | [Genesis](https://github.com/yonaskolb/Genesis)
71 |
72 | ## License
73 |
74 | Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more
75 | info.
76 |
--------------------------------------------------------------------------------
/docs/custom-template-tags-and-filters.rst:
--------------------------------------------------------------------------------
1 | Custom Template Tags and Filters
2 | ================================
3 |
4 | You can build your own custom filters and tags and pass them down while
5 | rendering your template. Any custom filters or tags must be registered with a
6 | extension which contains all filters and tags available to the template.
7 |
8 | .. code-block:: swift
9 |
10 | let ext = Extension()
11 | // Register your filters and tags with the extension
12 |
13 | let environment = Environment(extensions: [ext])
14 | try environment.renderTemplate(name: "example.html")
15 |
16 | Custom Filters
17 | --------------
18 |
19 | Registering custom filters:
20 |
21 | .. code-block:: swift
22 |
23 | ext.registerFilter("double") { (value: Any?) in
24 | if let value = value as? Int {
25 | return value * 2
26 | }
27 |
28 | return value
29 | }
30 |
31 | Registering custom filters with arguments:
32 |
33 | .. code-block:: swift
34 |
35 | ext.registerFilter("multiply") { (value: Any?, arguments: [Any?]) in
36 | let amount: Int
37 |
38 | if let value = arguments.first as? Int {
39 | amount = value
40 | } else {
41 | throw TemplateSyntaxError("multiple tag must be called with an integer argument")
42 | }
43 |
44 | if let value = value as? Int {
45 | return value * amount
46 | }
47 |
48 | return value
49 | }
50 |
51 | Registering custom boolean filters:
52 |
53 | .. code-block:: swift
54 |
55 | ext.registerFilter("ordinary", negativeFilterName: "odd") { (value: Any?) in
56 | if let value = value as? Int {
57 | return myInt % 2 == 0
58 | }
59 | return nil
60 | }
61 |
62 | Custom Tags
63 | -----------
64 |
65 | You can build a custom template tag. There are a couple of APIs to allow you to
66 | write your own custom tags. The following is the simplest form:
67 |
68 | .. code-block:: swift
69 |
70 | ext.registerSimpleTag("custom") { context in
71 | return "Hello World"
72 | }
73 |
74 | When your tag is used via ``{% custom %}`` it will execute the registered block
75 | of code allowing you to modify or retrieve a value from the context. Then
76 | return either a string rendered in your template, or throw an error.
77 |
78 | If you want to accept arguments or to capture different tokens between two sets
79 | of template tags. You will need to call the ``registerTag`` API which accepts a
80 | closure to handle the parsing. You can find examples of the ``now``, ``if`` and
81 | ``for`` tags found inside Stencil source code.
82 |
--------------------------------------------------------------------------------
/Sources/Stencil/TrimBehaviour.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | public struct TrimBehaviour: Equatable {
10 | var leading: Trim
11 | var trailing: Trim
12 |
13 | public enum Trim {
14 | /// nothing
15 | case nothing
16 |
17 | /// tabs and spaces
18 | case whitespace
19 |
20 | /// tabs and spaces and a single new line
21 | case whitespaceAndOneNewLine
22 |
23 | /// all tabs spaces and newlines
24 | case whitespaceAndNewLines
25 | }
26 |
27 | public init(leading: Trim, trailing: Trim) {
28 | self.leading = leading
29 | self.trailing = trailing
30 | }
31 |
32 | /// doesn't touch newlines
33 | public static let nothing = TrimBehaviour(leading: .nothing, trailing: .nothing)
34 |
35 | /// removes whitespace before a block and whitespace and a single newline after a block
36 | public static let smart = TrimBehaviour(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
37 |
38 | /// removes all whitespace and newlines before and after a block
39 | public static let all = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
40 |
41 | static func leadingRegex(trim: Trim) -> NSRegularExpression {
42 | switch trim {
43 | case .nothing:
44 | fatalError("No RegularExpression for none")
45 | case .whitespace:
46 | return Self.leadingWhitespace
47 | case .whitespaceAndOneNewLine:
48 | return Self.leadingWhitespaceAndOneNewLine
49 | case .whitespaceAndNewLines:
50 | return Self.leadingWhitespaceAndNewlines
51 | }
52 | }
53 |
54 | static func trailingRegex(trim: Trim) -> NSRegularExpression {
55 | switch trim {
56 | case .nothing:
57 | fatalError("No RegularExpression for none")
58 | case .whitespace:
59 | return Self.trailingWhitespace
60 | case .whitespaceAndOneNewLine:
61 | return Self.trailingWhitespaceAndOneNewLine
62 | case .whitespaceAndNewLines:
63 | return Self.trailingWhitespaceAndNewLines
64 | }
65 | }
66 |
67 | // swiftlint:disable force_try
68 | private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
69 | private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
70 |
71 | private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
72 | private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
73 |
74 | private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
75 | private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
76 | }
77 |
--------------------------------------------------------------------------------
/Tests/StencilTests/ParserSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class TokenParserTests: XCTestCase {
12 | func testTextToken() throws {
13 | let parser = TokenParser(tokens: [
14 | .text(value: "Hello World", at: .unknown)
15 | ], environment: Environment())
16 |
17 | let nodes = try parser.parse()
18 | let node = nodes.first as? TextNode
19 |
20 | try expect(nodes.count) == 1
21 | try expect(node?.text) == "Hello World"
22 | }
23 |
24 | func testVariableToken() throws {
25 | let parser = TokenParser(tokens: [
26 | .variable(value: "'name'", at: .unknown)
27 | ], environment: Environment())
28 |
29 | let nodes = try parser.parse()
30 | let node = nodes.first as? VariableNode
31 | try expect(nodes.count) == 1
32 | let result = try node?.render(Context())
33 | try expect(result) == "name"
34 | }
35 |
36 | func testCommentToken() throws {
37 | let parser = TokenParser(tokens: [
38 | .comment(value: "Secret stuff!", at: .unknown)
39 | ], environment: Environment())
40 |
41 | let nodes = try parser.parse()
42 | try expect(nodes.count) == 0
43 | }
44 |
45 | func testTagToken() throws {
46 | let simpleExtension = Extension()
47 | simpleExtension.registerSimpleTag("known") { _ in
48 | ""
49 | }
50 |
51 | let parser = TokenParser(tokens: [
52 | .block(value: "known", at: .unknown)
53 | ], environment: Environment(extensions: [simpleExtension]))
54 |
55 | let nodes = try parser.parse()
56 | try expect(nodes.count) == 1
57 | }
58 |
59 | func testErrorUnknownTag() throws {
60 | let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
61 | let parser = TokenParser(tokens: tokens, environment: Environment())
62 |
63 | try expect(try parser.parse()).toThrow(TemplateSyntaxError(
64 | reason: "Unknown template tag 'unknown'",
65 | token: tokens.first
66 | ))
67 | }
68 |
69 | func testTransformWhitespaceBehaviourToTrimBehaviour() throws {
70 | let simpleExtension = Extension()
71 | simpleExtension.registerSimpleTag("known") { _ in "" }
72 |
73 | let parser = TokenParser(tokens: [
74 | .block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .unspecified, trailing: .trim)),
75 | .text(value: " \nSome text ", at: .unknown),
76 | .block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .keep, trailing: .trim))
77 | ], environment: Environment(extensions: [simpleExtension]))
78 |
79 | let nodes = try parser.parse()
80 | try expect(nodes.count) == 3
81 | let textNode = nodes[1] as? TextNode
82 | try expect(textNode?.text) == " \nSome text "
83 | try expect(textNode?.trimBehaviour) == TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/Stencil/Errors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | public class TemplateDoesNotExist: Error, CustomStringConvertible {
8 | let templateNames: [String]
9 | let loader: Loader?
10 |
11 | public init(templateNames: [String], loader: Loader? = nil) {
12 | self.templateNames = templateNames
13 | self.loader = loader
14 | }
15 |
16 | public var description: String {
17 | let templates = templateNames.joined(separator: ", ")
18 |
19 | if let loader = loader {
20 | return "Template named `\(templates)` does not exist in loader \(loader)"
21 | }
22 |
23 | return "Template named `\(templates)` does not exist. No loaders found"
24 | }
25 | }
26 |
27 | public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
28 | public let reason: String
29 | public var description: String { reason }
30 | public internal(set) var token: Token?
31 | public internal(set) var stackTrace: [Token]
32 | public var templateName: String? { token?.sourceMap.filename }
33 | var allTokens: [Token] {
34 | stackTrace + (token.map { [$0] } ?? [])
35 | }
36 |
37 | public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
38 | self.reason = reason
39 | self.stackTrace = stackTrace
40 | self.token = token
41 | }
42 |
43 | public init(_ description: String) {
44 | self.init(reason: description)
45 | }
46 | }
47 |
48 | extension Error {
49 | func withToken(_ token: Token?) -> Error {
50 | if var error = self as? TemplateSyntaxError {
51 | error.token = error.token ?? token
52 | return error
53 | } else {
54 | return TemplateSyntaxError(reason: "\(self)", token: token)
55 | }
56 | }
57 | }
58 |
59 | public protocol ErrorReporter: AnyObject {
60 | func renderError(_ error: Error) -> String
61 | }
62 |
63 | open class SimpleErrorReporter: ErrorReporter {
64 | open func renderError(_ error: Error) -> String {
65 | guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
66 |
67 | func describe(token: Token) -> String {
68 | let templateName = token.sourceMap.filename ?? ""
69 | let location = token.sourceMap.location
70 | let highlight = """
71 | \(String(Array(repeating: " ", count: location.lineOffset)))\
72 | ^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
73 | """
74 |
75 | return """
76 | \(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
77 | \(location.content)
78 | \(highlight)
79 | """
80 | }
81 |
82 | var descriptions = templateError.stackTrace.reduce(into: []) { $0.append(describe(token: $1)) }
83 | let description = templateError.token.map(describe(token:)) ?? templateError.reason
84 | descriptions.append(description)
85 | return descriptions.joined(separator: "\n")
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Tests/StencilTests/EnvironmentIncludeTemplateSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | final class EnvironmentIncludeTemplateTests: XCTestCase {
13 | private var environment = Environment(loader: ExampleLoader())
14 | private var template: Template = ""
15 | private var includedTemplate: Template = ""
16 |
17 | override func setUp() {
18 | super.setUp()
19 |
20 | let path = Path(#file as String) + ".." + "fixtures"
21 | let loader = FileSystemLoader(paths: [path])
22 | environment = Environment(loader: loader)
23 | template = ""
24 | includedTemplate = ""
25 | }
26 |
27 | override func tearDown() {
28 | super.tearDown()
29 | }
30 |
31 | func testSyntaxError() throws {
32 | template = Template(templateString: """
33 | {% include "invalid-include.html" %}
34 | """, environment: environment)
35 | includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
36 |
37 | try expectError(
38 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
39 | token: #"include "invalid-include.html""#,
40 | includedToken: "target|unknown"
41 | )
42 | }
43 |
44 | func testRuntimeError() throws {
45 | let filterExtension = Extension()
46 | filterExtension.registerFilter("unknown") { (_: Any?) in
47 | throw TemplateSyntaxError("filter error")
48 | }
49 | environment.extensions += [filterExtension]
50 |
51 | template = Template(templateString: """
52 | {% include "invalid-include.html" %}
53 | """, environment: environment)
54 | includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
55 |
56 | try expectError(
57 | reason: "filter error",
58 | token: "include \"invalid-include.html\"",
59 | includedToken: "target|unknown"
60 | )
61 | }
62 |
63 | private func expectError(
64 | reason: String,
65 | token: String,
66 | includedToken: String,
67 | file: String = #file,
68 | line: Int = #line,
69 | function: String = #function
70 | ) throws {
71 | var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
72 | expectedError.stackTrace = [
73 | expectedSyntaxError(
74 | token: includedToken,
75 | template: includedTemplate,
76 | description: reason
77 | ).token
78 | ].compactMap { $0 }
79 |
80 | let error = try expect(
81 | self.environment.render(template: self.template, context: ["target": "World"]),
82 | file: file,
83 | line: line,
84 | function: function
85 | ).toThrow() as TemplateSyntaxError
86 | let reporter = SimpleErrorReporter()
87 | try expect(
88 | reporter.renderError(error),
89 | file: file,
90 | line: line,
91 | function: function
92 | ) == reporter.renderError(expectedError)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Tests/StencilTests/IncludeSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | final class IncludeTests: XCTestCase {
13 | private let path = Path(#file as String) + ".." + "fixtures"
14 | private lazy var loader = FileSystemLoader(paths: [path])
15 | private lazy var environment = Environment(loader: loader)
16 |
17 | func testParsing() {
18 | it("throws an error when no template is given") {
19 | let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
20 | let parser = TokenParser(tokens: tokens, environment: Environment())
21 |
22 | let error = TemplateSyntaxError(reason: """
23 | 'include' tag requires one argument, the template file to be included. \
24 | A second optional argument can be used to specify the context that will \
25 | be passed to the included file
26 | """, token: tokens.first)
27 | try expect(try parser.parse()).toThrow(error)
28 | }
29 |
30 | it("can parse a valid include block") {
31 | let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
32 | let parser = TokenParser(tokens: tokens, environment: Environment())
33 |
34 | let nodes = try parser.parse()
35 | let node = nodes.first as? IncludeNode
36 | try expect(nodes.count) == 1
37 | try expect(node?.templateName) == Variable("\"test.html\"")
38 | }
39 | }
40 |
41 | func testRendering() {
42 | it("throws an error when rendering without a loader") {
43 | let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
44 |
45 | do {
46 | _ = try node.render(Context())
47 | } catch {
48 | try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
49 | }
50 | }
51 |
52 | it("throws an error when it cannot find the included template") {
53 | let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
54 |
55 | do {
56 | _ = try node.render(Context(environment: self.environment))
57 | } catch {
58 | try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
59 | }
60 | }
61 |
62 | it("successfully renders a found included template") {
63 | let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
64 | let context = Context(dictionary: ["target": "World"], environment: self.environment)
65 | let value = try node.render(context)
66 | try expect(value) == "Hello World!"
67 | }
68 |
69 | it("successfully passes context") {
70 | let template = Template(templateString: """
71 | {% include "test.html" child %}
72 | """)
73 | let context = Context(dictionary: ["child": ["target": "World"]], environment: self.environment)
74 | let value = try template.render(context)
75 | try expect(value) == "Hello World!"
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/rakelib/check_changelog.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This analyze the CHANGELOG.md file and report warnings on its content
4 | #
5 | # It checks:
6 | # - if the description part of each entry ends with a period and two spaces
7 | # - that all links to PRs & issues with format [#nn](repo_url/nn) are consistent
8 | # (use the same number in the link title and URL)
9 | #
10 | # @return Array of Hashes with keys `:line` & `:message` for each element
11 | #
12 | def check_changelog
13 | current_repo = File.basename(`git remote get-url origin`.chomp, '.git').freeze
14 | slug_re = '([a-zA-Z]*/[a-zA-Z]*)'
15 | links = %r{\[#{slug_re}?\#([0-9]+)\]\(https://github.com/#{slug_re}/(issues|pull)/([0-9]+)\)}
16 | links_typos = %r{https://github.com/#{slug_re}/(issue|pulls)/([0-9]+)}
17 |
18 | all_warnings = []
19 | inside_entry = false
20 | last_line_has_correct_ending = false
21 |
22 | File.readlines('CHANGELOG.md').each_with_index do |line, idx|
23 | line.chomp! # Remove \n the end, it's easier for checks below
24 | was_inside_entry = inside_entry
25 | just_started_new_entry = line.start_with?('* ')
26 | inside_entry = true if just_started_new_entry
27 | inside_entry = false if /^ \[.*\]\(.*\)$/ =~ line # link-only line
28 |
29 | if was_inside_entry && !inside_entry && !last_line_has_correct_ending
30 | # We just ended an entry's description by starting the links, but description didn't end with '. '
31 | # Note: entry descriptions can be on multiple lines, hence the need to wait for the next line
32 | # to not be inside an entry to be able to consider the previous line as the end of entry description.
33 | all_warnings.concat [
34 | { line: idx, message: 'Line describing your entry should end with a period and 2 spaces.' }
35 | ]
36 | end
37 | # Store if current line has correct ending, for next iteration, so that if the next line isn't
38 | # part of the entry description, we can check if previous line ends description correctly.
39 | # Also, lines just linking to CHANGELOG to other repositories (StencilSwiftKit & Stencil mainly)
40 | # should be considered as not needing the '. ' ending.
41 | last_line_has_correct_ending = line.end_with?('. ') || line.end_with?('/CHANGELOG.md)')
42 |
43 | # Now, check that links [#nn](.../nn) have matching numbers in link title & URL
44 | wrong_links = line.scan(links).reject do |m|
45 | slug = m[0] || "stencilproject/#{current_repo}"
46 | (slug == m[2]) && (m[1] == m[4])
47 | end
48 | all_warnings.concat Array(wrong_links.map do |m|
49 | link_text = "#{m[0]}##{m[1]}"
50 | link_url = "#{m[2]}##{m[4]}"
51 | { line: idx + 1, message: "Link text is #{link_text} but links points to #{link_url}." }
52 | end)
53 |
54 | # Flag common typos in GitHub issue/PR URLs
55 | typo_links = line.scan(links_typos)
56 | all_warnings.concat Array(typo_links.map do |_|
57 | { line: idx + 1, message: 'This looks like a GitHub link URL with a typo. Issue links should use `/issues/123` (plural) and PR links should use `/pull/123` (singular).' }
58 | end)
59 | end
60 | all_warnings
61 | end
62 |
--------------------------------------------------------------------------------
/Sources/Stencil/Environment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | /// Container for environment data, such as registered extensions
8 | public struct Environment {
9 | /// The class for loading new templates
10 | public let templateClass: Template.Type
11 | /// List of registered extensions
12 | public var extensions: [Extension]
13 | /// How to handle whitespace
14 | public var trimBehaviour: TrimBehaviour
15 | /// Mechanism for loading new files
16 | public var loader: Loader?
17 |
18 | /// Basic initializer
19 | ///
20 | /// - Parameters:
21 | /// - loader: Mechanism for loading new files
22 | /// - extensions: List of extension containers
23 | /// - templateClass: Class for newly loaded templates
24 | /// - trimBehaviour: How to handle whitespace
25 | public init(
26 | loader: Loader? = nil,
27 | extensions: [Extension] = [],
28 | templateClass: Template.Type = Template.self,
29 | trimBehaviour: TrimBehaviour = .nothing
30 | ) {
31 | self.templateClass = templateClass
32 | self.loader = loader
33 | self.extensions = extensions + [DefaultExtension()]
34 | self.trimBehaviour = trimBehaviour
35 | }
36 |
37 | /// Load a template with the given name
38 | ///
39 | /// - Parameters:
40 | /// - name: Name of the template
41 | /// - returns: Loaded template instance
42 | public func loadTemplate(name: String) throws -> Template {
43 | if let loader = loader {
44 | return try loader.loadTemplate(name: name, environment: self)
45 | } else {
46 | throw TemplateDoesNotExist(templateNames: [name], loader: nil)
47 | }
48 | }
49 |
50 | /// Load a template with the given names
51 | ///
52 | /// - Parameters:
53 | /// - names: Names of the template
54 | /// - returns: Loaded template instance
55 | public func loadTemplate(names: [String]) throws -> Template {
56 | if let loader = loader {
57 | return try loader.loadTemplate(names: names, environment: self)
58 | } else {
59 | throw TemplateDoesNotExist(templateNames: names, loader: nil)
60 | }
61 | }
62 |
63 | /// Render a template with the given name, providing some data
64 | ///
65 | /// - Parameters:
66 | /// - name: Name of the template
67 | /// - context: Data for rendering
68 | /// - returns: Rendered output
69 | public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
70 | let template = try loadTemplate(name: name)
71 | return try render(template: template, context: context)
72 | }
73 |
74 | /// Render the given template string, providing some data
75 | ///
76 | /// - Parameters:
77 | /// - string: Template string
78 | /// - context: Data for rendering
79 | /// - returns: Rendered output
80 | public func renderTemplate(string: String, context: [String: Any] = [:]) throws -> String {
81 | let template = templateClass.init(templateString: string, environment: self)
82 | return try render(template: template, context: context)
83 | }
84 |
85 | func render(template: Template, context: [String: Any]) throws -> String {
86 | // update template environment as it can be created from string literal with default environment
87 | template.environment = self
88 | return try template.render(context)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Sources/Stencil/Template.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import PathKit
9 |
10 | #if os(Linux)
11 | // swiftlint:disable:next prefixed_toplevel_constant
12 | let NSFileNoSuchFileError = 4
13 | #endif
14 |
15 | /// A class representing a template
16 | open class Template: ExpressibleByStringLiteral {
17 | let templateString: String
18 | var environment: Environment
19 |
20 | /// The list of parsed (lexed) tokens
21 | public let tokens: [Token]
22 |
23 | /// The name of the loaded Template if the Template was loaded from a Loader
24 | public let name: String?
25 |
26 | /// Create a template with a template string
27 | public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
28 | self.environment = environment ?? Environment()
29 | self.name = name
30 | self.templateString = templateString
31 |
32 | let lexer = Lexer(templateName: name, templateString: templateString)
33 | tokens = lexer.tokenize()
34 | }
35 |
36 | /// Create a template with the given name inside the given bundle
37 | @available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
38 | public convenience init(named: String, inBundle bundle: Bundle? = nil) throws {
39 | let useBundle = bundle ?? Bundle.main
40 | guard let url = useBundle.url(forResource: named, withExtension: nil) else {
41 | throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
42 | }
43 |
44 | try self.init(URL: url)
45 | }
46 |
47 | /// Create a template with a file found at the given URL
48 | @available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
49 | public convenience init(URL: Foundation.URL) throws {
50 | try self.init(path: Path(URL.path))
51 | }
52 |
53 | /// Create a template with a file found at the given path
54 | @available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
55 | public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
56 | self.init(templateString: try path.read(), environment: environment, name: name)
57 | }
58 |
59 | // MARK: ExpressibleByStringLiteral
60 |
61 | // Create a templaVte with a template string literal
62 | public required convenience init(stringLiteral value: String) {
63 | self.init(templateString: value)
64 | }
65 |
66 | // Create a template with a template string literal
67 | public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
68 | self.init(stringLiteral: value)
69 | }
70 |
71 | // Create a template with a template string literal
72 | public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
73 | self.init(stringLiteral: value)
74 | }
75 |
76 | /// Render the given template with a context
77 | public func render(_ context: Context) throws -> String {
78 | let context = context
79 | let parser = TokenParser(tokens: tokens, environment: context.environment)
80 | let nodes = try parser.parse()
81 | return try renderNodes(nodes, context)
82 | }
83 |
84 | // swiftlint:disable discouraged_optional_collection
85 | /// Render the given template
86 | open func render(_ dictionary: [String: Any]? = nil) throws -> String {
87 | try render(Context(dictionary: dictionary ?? [:], environment: environment))
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/rakelib/Dangerfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'check_changelog'
4 |
5 | is_release = github.branch_for_head.start_with?('release/')
6 | is_hotfix = github.branch_for_head.start_with?('hotfix/')
7 |
8 | ################################################
9 | # Welcome message
10 | markdown [
11 | "Hey 👋 I'm Eve, the friendly bot watching over Stencil 🤖",
12 | 'Thanks a lot for your contribution!',
13 | '', '---', ''
14 | ]
15 |
16 | need_fixes = []
17 |
18 | ################################################
19 | # Make it more obvious that a PR is a work in progress and shouldn't be merged yet
20 | warn('PR is classed as Work in Progress') if github.pr_title.include? '[WIP]'
21 |
22 | # Note when there is a big PR
23 | message('Big PR') if git.lines_of_code > 500 && !is_release
24 |
25 | ################################################
26 | # Check for correct base branch
27 | if is_release
28 | message('This is a Release PR')
29 |
30 | require 'open3'
31 |
32 | stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'changelog:check')
33 | markdown [
34 | '',
35 | '### ChangeLog check',
36 | '',
37 | stdout
38 | ]
39 | need_fixes << fail('Please fix the CHANGELOG errors') unless status.success?
40 |
41 | stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'release:check_versions')
42 | markdown [
43 | '',
44 | '### Release version check',
45 | '',
46 | stdout
47 | ]
48 | need_fixes << fail('Please fix the versions inconsistencies') unless status.success?
49 | elsif is_hotfix
50 | message('This is a Hotfix PR')
51 | end
52 |
53 | ################################################
54 | # Check for a CHANGELOG entry
55 | declared_trivial = github.pr_title.include? '#trivial'
56 | has_changelog = git.modified_files.include?('CHANGELOG.md')
57 | changelog_msg = ''
58 | unless has_changelog || declared_trivial
59 | repo_url = github.pr_json['head']['repo']['html_url']
60 | pr_title = github.pr_title
61 | pr_title += '.' unless pr_title.end_with?('.')
62 | pr_number = github.pr_json['number']
63 | pr_url = github.pr_json['html_url']
64 | pr_author = github.pr_author
65 | pr_author_url = "https://github.com/#{pr_author}"
66 |
67 | need_fixes = fail("Please include a CHANGELOG entry to credit your work. \nYou can find it at [CHANGELOG.md](#{repo_url}/blob/#{github.branch_for_head}/CHANGELOG.md).")
68 |
69 | changelog_msg = <<-CHANGELOG_FORMAT.gsub(/^ *\|/, '')
70 | |📝 We use the following format for CHANGELOG entries:
71 | |```
72 | |* #{pr_title}
73 | | [##{pr_number}](#{pr_url})
74 | | [@#{pr_author}](#{pr_author_url})
75 | |```
76 | |:bulb: Don't forget to end the line describing your changes by a period and two spaces.
77 | CHANGELOG_FORMAT
78 | # changelog_msg is printed during the "Encouragement message" section, see below
79 | end
80 |
81 | changelog_warnings = check_changelog
82 | unless changelog_warnings.empty?
83 | need_fixes << warn('Found some warnings in CHANGELOG.md')
84 | changelog_warnings.each do |warning|
85 | warn(warning[:message], file: 'CHANGELOG.md', line: warning[:line])
86 | end
87 | end
88 |
89 | ################################################
90 | # Encouragement message
91 | if need_fixes.empty?
92 | markdown('Seems like everything is in order 👍 You did a good job here! 🤝')
93 | else
94 | markdown('Once you fix those tiny nitpickings above, we should be good to go! 🙌')
95 | markdown(changelog_msg) unless changelog_msg.empty?
96 | markdown('ℹ️ _I will update this comment as you add new commits_')
97 | end
98 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | swiftlint_version: 0.48.0
2 |
3 | opt_in_rules:
4 | - accessibility_label_for_image
5 | - anonymous_argument_in_multiline_closure
6 | - anyobject_protocol
7 | - array_init
8 | - attributes
9 | - balanced_xctest_lifecycle
10 | - closure_body_length
11 | - closure_end_indentation
12 | - closure_spacing
13 | - collection_alignment
14 | - comment_spacing
15 | - conditional_returns_on_newline
16 | - contains_over_filter_count
17 | - contains_over_filter_is_empty
18 | - contains_over_first_not_nil
19 | - contains_over_range_nil_comparison
20 | - convenience_type
21 | - discarded_notification_center_observer
22 | - discouraged_assert
23 | - discouraged_none_name
24 | - discouraged_optional_boolean
25 | - discouraged_optional_collection
26 | - empty_collection_literal
27 | - empty_count
28 | - empty_string
29 | - empty_xctest_method
30 | - enum_case_associated_values_count
31 | - fallthrough
32 | - fatal_error_message
33 | - file_header
34 | - first_where
35 | - flatmap_over_map_reduce
36 | - force_unwrapping
37 | - ibinspectable_in_extension
38 | - identical_operands
39 | - implicit_return
40 | - implicitly_unwrapped_optional
41 | - inclusive_language
42 | - indentation_width
43 | - joined_default_parameter
44 | - last_where
45 | - legacy_multiple
46 | - legacy_objc_type
47 | - legacy_random
48 | - literal_expression_end_indentation
49 | - lower_acl_than_parent
50 | - missing_docs
51 | - modifier_order
52 | - multiline_arguments
53 | - multiline_arguments_brackets
54 | - multiline_function_chains
55 | - multiline_literal_brackets
56 | - multiline_parameters
57 | - multiline_parameters_brackets
58 | - nslocalizedstring_key
59 | - nslocalizedstring_require_bundle
60 | - number_separator
61 | - operator_usage_whitespace
62 | - optional_enum_case_matching
63 | - overridden_super_call
64 | - override_in_extension
65 | - prefer_self_in_static_references
66 | - prefer_self_type_over_type_of_self
67 | - prefer_zero_over_explicit_init
68 | - prefixed_toplevel_constant
69 | - private_action
70 | - private_outlet
71 | - private_subject
72 | - prohibited_super_call
73 | - raw_value_for_camel_cased_codable_enum
74 | - reduce_into
75 | - redundant_nil_coalescing
76 | - redundant_type_annotation
77 | - required_enum_case
78 | - return_value_from_void_function
79 | - single_test_class
80 | - sorted_first_last
81 | - sorted_imports
82 | - static_operator
83 | - strong_iboutlet
84 | - switch_case_on_newline
85 | - test_case_accessibility
86 | - toggle_bool
87 | - trailing_closure
88 | - unavailable_function
89 | - unneeded_parentheses_in_closure_argument
90 | - unowned_variable_capture
91 | - unused_closure_parameter
92 | - vertical_parameter_alignment_on_call
93 | - vertical_whitespace_closing_braces
94 | - vertical_whitespace_opening_braces
95 | - void_function_in_ternary
96 | - weak_delegate
97 | - xct_specific_matcher
98 | - yoda_condition
99 |
100 | # Rules customization
101 | closure_body_length:
102 | warning: 25
103 |
104 | conditional_returns_on_newline:
105 | if_only: true
106 |
107 | file_header:
108 | required_pattern: |
109 | \/\/
110 | \/\/ Stencil
111 | \/\/ Copyright © 2022 Stencil
112 | \/\/ MIT Licence
113 | \/\/
114 |
115 | indentation_width:
116 | indentation_width: 2
117 |
118 | line_length:
119 | warning: 120
120 | error: 200
121 |
122 | nesting:
123 | type_level:
124 | warning: 2
125 |
--------------------------------------------------------------------------------
/Sources/Stencil/KeyPath.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | /// A structure used to represent a template variable, and to resolve it in a given context.
10 | final class KeyPath {
11 | private var components = [String]()
12 | private var current = ""
13 | private var partialComponents = [String]()
14 | private var subscriptLevel = 0
15 |
16 | let variable: String
17 | let context: Context
18 |
19 | // Split the keypath string and resolve references if possible
20 | init(_ variable: String, in context: Context) {
21 | self.variable = variable
22 | self.context = context
23 | }
24 |
25 | func parse() throws -> [String] {
26 | defer {
27 | components = []
28 | current = ""
29 | partialComponents = []
30 | subscriptLevel = 0
31 | }
32 |
33 | for character in variable {
34 | switch character {
35 | case "." where subscriptLevel == 0:
36 | try foundSeparator()
37 | case "[":
38 | try openBracket()
39 | case "]":
40 | try closeBracket()
41 | default:
42 | try addCharacter(character)
43 | }
44 | }
45 | try finish()
46 |
47 | return components
48 | }
49 |
50 | private func foundSeparator() throws {
51 | if !current.isEmpty {
52 | partialComponents.append(current)
53 | }
54 |
55 | guard !partialComponents.isEmpty else {
56 | throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
57 | }
58 |
59 | components += partialComponents
60 | current = ""
61 | partialComponents = []
62 | }
63 |
64 | // when opening the first bracket, we must have a partial component
65 | private func openBracket() throws {
66 | guard !partialComponents.isEmpty || !current.isEmpty else {
67 | throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
68 | }
69 |
70 | if subscriptLevel > 0 {
71 | current.append("[")
72 | } else if !current.isEmpty {
73 | partialComponents.append(current)
74 | current = ""
75 | }
76 |
77 | subscriptLevel += 1
78 | }
79 |
80 | // for a closing bracket at root level, try to resolve the reference
81 | private func closeBracket() throws {
82 | guard subscriptLevel > 0 else {
83 | throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
84 | }
85 |
86 | if subscriptLevel > 1 {
87 | current.append("]")
88 | } else if !current.isEmpty,
89 | let value = try Variable(current).resolve(context) {
90 | partialComponents.append("\(value)")
91 | current = ""
92 | } else {
93 | throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
94 | }
95 |
96 | subscriptLevel -= 1
97 | }
98 |
99 | private func addCharacter(_ character: Character) throws {
100 | guard partialComponents.isEmpty || subscriptLevel > 0 else {
101 | throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
102 | }
103 |
104 | current.append(character)
105 | }
106 |
107 | private func finish() throws {
108 | // check if we have a last piece
109 | if !current.isEmpty {
110 | partialComponents.append(current)
111 | }
112 | components += partialComponents
113 |
114 | guard subscriptLevel == 0 else {
115 | throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/Sources/Stencil/Context.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | /// A container for template variables.
8 | public class Context {
9 | var dictionaries: [[String: Any?]]
10 |
11 | /// The context's environment, such as registered extensions, classes, …
12 | public let environment: Environment
13 |
14 | init(dictionaries: [[String: Any?]], environment: Environment) {
15 | self.dictionaries = dictionaries
16 | self.environment = environment
17 | }
18 |
19 | /// Create a context from a dictionary (and an env.)
20 | ///
21 | /// - Parameters:
22 | /// - dictionary: The context's data
23 | /// - environment: Environment such as extensions, …
24 | public convenience init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
25 | self.init(
26 | dictionaries: dictionary.isEmpty ? [] : [dictionary],
27 | environment: environment ?? Environment()
28 | )
29 | }
30 |
31 | /// Access variables in this context by name
32 | public subscript(key: String) -> Any? {
33 | /// Retrieves a variable's value, starting at the current context and going upwards
34 | get {
35 | for dictionary in Array(dictionaries.reversed()) {
36 | if let value = dictionary[key] {
37 | return value
38 | }
39 | }
40 |
41 | return nil
42 | }
43 |
44 | /// Set a variable in the current context, deleting the variable if it's nil
45 | set(value) {
46 | if var dictionary = dictionaries.popLast() {
47 | dictionary[key] = value
48 | dictionaries.append(dictionary)
49 | }
50 | }
51 | }
52 |
53 | /// Push a new level into the Context
54 | ///
55 | /// - Parameters:
56 | /// - dictionary: The new level data
57 | fileprivate func push(_ dictionary: [String: Any] = [:]) {
58 | dictionaries.append(dictionary)
59 | }
60 |
61 | /// Pop the last level off of the Context
62 | ///
63 | /// - returns: The popped level
64 | fileprivate func pop() -> [String: Any?]? {
65 | dictionaries.popLast()
66 | }
67 |
68 | /// Push a new level onto the context for the duration of the execution of the given closure
69 | ///
70 | /// - Parameters:
71 | /// - dictionary: The new level data
72 | /// - closure: The closure to execute
73 | /// - returns: Return value of the closure
74 | public func push(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
75 | push(dictionary)
76 | defer { _ = pop() }
77 | return try closure()
78 | }
79 |
80 | /// Flatten all levels of context data into 1, merging duplicate variables
81 | ///
82 | /// - returns: All collected variables
83 | public func flatten() -> [String: Any] {
84 | var accumulator: [String: Any] = [:]
85 |
86 | for dictionary in dictionaries {
87 | for (key, value) in dictionary {
88 | if let value = value {
89 | accumulator.updateValue(value, forKey: key)
90 | }
91 | }
92 | }
93 |
94 | return accumulator
95 | }
96 |
97 | /// Cache result of block by its name in the context top-level, so that it can be later rendered
98 | /// via `{{ block.name }}`
99 | ///
100 | /// - Parameters:
101 | /// - name: The name of the stored block
102 | /// - content: The block's rendered content
103 | public func cacheBlock(_ name: String, content: String) {
104 | if var block = dictionaries.first?["block"] as? [String: String] {
105 | block[name] = content
106 | dictionaries[0]["block"] = block
107 | } else {
108 | dictionaries.insert(["block": [name: content]], at: 0)
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/Sources/Stencil/Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | /// Container for registered tags and filters
8 | open class Extension {
9 | typealias TagParser = (TokenParser, Token) throws -> NodeType
10 |
11 | var tags = [String: TagParser]()
12 | var filters = [String: Filter]()
13 |
14 | /// Simple initializer
15 | public init() {
16 | }
17 |
18 | /// Registers a new template tag
19 | public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
20 | tags[name] = parser
21 | }
22 |
23 | /// Registers a simple template tag with a name and a handler
24 | public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
25 | registerTag(name) { _, token in
26 | SimpleNode(token: token, handler: handler)
27 | }
28 | }
29 |
30 | /// Registers boolean filter with it's negative counterpart
31 | public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
32 | // swiftlint:disable:previous discouraged_optional_boolean
33 | filters[name] = .simple(filter)
34 | filters[negativeFilterName] = .simple { value in
35 | guard let result = try filter(value) else { return nil }
36 | return !result
37 | }
38 | }
39 |
40 | /// Registers a template filter with the given name
41 | public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
42 | filters[name] = .simple(filter)
43 | }
44 |
45 | /// Registers a template filter with the given name
46 | public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
47 | filters[name] = .arguments { value, args, _ in try filter(value, args) }
48 | }
49 |
50 | /// Registers a template filter with the given name
51 | public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?], Context) throws -> Any?) {
52 | filters[name] = .arguments(filter)
53 | }
54 | }
55 |
56 | class DefaultExtension: Extension {
57 | override init() {
58 | super.init()
59 | registerDefaultTags()
60 | registerDefaultFilters()
61 | }
62 |
63 | fileprivate func registerDefaultTags() {
64 | registerTag("for", parser: ForNode.parse)
65 | registerTag("break", parser: LoopTerminationNode.parse)
66 | registerTag("continue", parser: LoopTerminationNode.parse)
67 | registerTag("if", parser: IfNode.parse)
68 | registerTag("ifnot", parser: IfNode.parse_ifnot)
69 | #if !os(Linux)
70 | registerTag("now", parser: NowNode.parse)
71 | #endif
72 | registerTag("include", parser: IncludeNode.parse)
73 | registerTag("extends", parser: ExtendsNode.parse)
74 | registerTag("block", parser: BlockNode.parse)
75 | registerTag("filter", parser: FilterNode.parse)
76 | }
77 |
78 | fileprivate func registerDefaultFilters() {
79 | registerFilter("default", filter: defaultFilter)
80 | registerFilter("capitalize", filter: capitalise)
81 | registerFilter("uppercase", filter: uppercase)
82 | registerFilter("lowercase", filter: lowercase)
83 | registerFilter("join", filter: joinFilter)
84 | registerFilter("split", filter: splitFilter)
85 | registerFilter("indent", filter: indentFilter)
86 | registerFilter("filter", filter: filterFilter)
87 | }
88 | }
89 |
90 | protocol FilterType {
91 | func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
92 | }
93 |
94 | enum Filter: FilterType {
95 | case simple(((Any?) throws -> Any?))
96 | case arguments(((Any?, [Any?], Context) throws -> Any?))
97 |
98 | func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
99 | switch self {
100 | case let .simple(filter):
101 | if !arguments.isEmpty {
102 | throw TemplateSyntaxError("Can't invoke filter with an argument")
103 | }
104 | return try filter(value)
105 | case let .arguments(filter):
106 | return try filter(value, arguments, context)
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/rakelib/release.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - BUILD_DIR
5 |
6 | require 'json'
7 |
8 | namespace :release do
9 | desc 'Create a new release'
10 | task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github, :cocoapods]
11 |
12 | desc 'Check if all versions from the podspecs and CHANGELOG match'
13 | task :check_versions do
14 | results = []
15 |
16 | Utils.table_header('Check', 'Status')
17 |
18 | # Check if bundler is installed first, as we'll need it for the cocoapods task (and we prefer to fail early)
19 | `which bundler`
20 | results << Utils.table_result(
21 | $CHILD_STATUS.success?,
22 | 'Bundler installed',
23 | 'Install bundler using `gem install bundler` and run `bundle install` first.'
24 | )
25 |
26 | # Extract version from podspec
27 | podspec = Utils::podspec(POD_NAME)
28 | v = podspec['version']
29 | Utils.table_info("#{POD_NAME}.podspec", v)
30 |
31 | # Check podspec tag
32 | podspec_tag = podspec['source']['tag']
33 | results << Utils.table_result(podspec_tag == v, 'Podspec version & tag equal', 'Update the `tag` in podspec')
34 |
35 | # Check docs config
36 | docs_version = Utils.first_match_in_file('docs/conf.py', /version = '(.+)'/, 1)
37 | docs_release = Utils.first_match_in_file('docs/conf.py', /release = '(.+)'/, 1)
38 | results << Utils.table_result(docs_version == v,'Docs, version updated', 'Update the `version` in docs/conf.py')
39 | results << Utils.table_result(docs_release == v, 'Docs, release updated', 'Update the `release` in docs/conf.py')
40 |
41 | # Check docs installation
42 | docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
43 | docs_cocoapods = Utils.first_match_in_file('docs/installation.rst', /pod 'Stencil', '~> (.+)'/, 1)
44 | docs_carthage = Utils.first_match_in_file('docs/installation.rst', /github ".+\/Stencil" ~> (.+)/, 1)
45 | results << Utils.table_result(docs_package == v, 'Docs, package updated', 'Update the package version in docs/installation.rst')
46 | results << Utils.table_result(docs_cocoapods == v, 'Docs, cocoapods updated', 'Update the cocoapods version in docs/installation.rst')
47 | results << Utils.table_result(docs_carthage == v, 'Docs, carthage updated', 'Update the carthage version in docs/installation.rst')
48 |
49 | # Check if entry present in CHANGELOG
50 | changelog_entry = Utils.first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(v)}$/)
51 | results << Utils.table_result(changelog_entry, 'CHANGELOG, Entry added', "Add an entry for #{v} in CHANGELOG.md")
52 |
53 | changelog_has_stable = system("grep -qi '^## Master' CHANGELOG.md")
54 | results << Utils.table_result(!changelog_has_stable, 'CHANGELOG, No master', 'Remove section for master branch in CHANGELOG')
55 |
56 | exit 1 unless results.all?
57 | end
58 |
59 | desc "Check tag and ask to release"
60 | task :check_tag_and_ask_to_release do
61 | results = []
62 | podspec_version = Utils.podspec_version(POD_NAME)
63 |
64 | tag_set = !`git ls-remote --tags . refs/tags/#{podspec_version}`.empty?
65 | results << Utils.table_result(
66 | tag_set,
67 | 'Tag pushed',
68 | 'Please create a tag and push it'
69 | )
70 |
71 | exit 1 unless results.all?
72 |
73 | print "Release version #{podspec_version} [Y/n]? "
74 | exit 2 unless STDIN.gets.chomp == 'Y'
75 | end
76 |
77 | desc "Create a new GitHub release"
78 | task :github do
79 | require 'octokit'
80 |
81 | client = Utils.octokit_client
82 | tag = Utils.top_changelog_version
83 | body = Utils.top_changelog_entry
84 |
85 | raise 'Must be a valid version' if tag == 'Master'
86 |
87 | repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze
88 | puts "Pushing release notes for tag #{tag}"
89 | client.create_release("stencilproject/#{repo_name}", tag, name: tag, body: body)
90 | end
91 |
92 | desc "pod trunk push #{POD_NAME} to CocoaPods"
93 | task :cocoapods do
94 | Utils.print_header 'Pushing pod to CocoaPods Trunk'
95 | sh "bundle exec pod trunk push #{POD_NAME}.podspec.json"
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/Tests/StencilTests/EnvironmentBaseAndChildTemplateSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
13 | private var environment = Environment(loader: ExampleLoader())
14 | private var childTemplate: Template = ""
15 | private var baseTemplate: Template = ""
16 |
17 | override func setUp() {
18 | super.setUp()
19 |
20 | let path = Path(#file as String) + ".." + "fixtures"
21 | let loader = FileSystemLoader(paths: [path])
22 | environment = Environment(loader: loader)
23 | childTemplate = ""
24 | baseTemplate = ""
25 | }
26 |
27 | override func tearDown() {
28 | super.tearDown()
29 | }
30 |
31 | func testSyntaxErrorInBaseTemplate() throws {
32 | childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
33 | baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
34 |
35 | try expectError(
36 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
37 | childToken: "extends \"invalid-base.html\"",
38 | baseToken: "target|unknown"
39 | )
40 | }
41 |
42 | func testRuntimeErrorInBaseTemplate() throws {
43 | let filterExtension = Extension()
44 | filterExtension.registerFilter("unknown") { (_: Any?) in
45 | throw TemplateSyntaxError("filter error")
46 | }
47 | environment.extensions += [filterExtension]
48 |
49 | childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
50 | baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
51 |
52 | try expectError(
53 | reason: "filter error",
54 | childToken: "extends \"invalid-base.html\"",
55 | baseToken: "target|unknown"
56 | )
57 | }
58 |
59 | func testSyntaxErrorInChildTemplate() throws {
60 | childTemplate = Template(
61 | templateString: """
62 | {% extends "base.html" %}
63 | {% block body %}Child {{ target|unknown }}{% endblock %}
64 | """,
65 | environment: environment,
66 | name: nil
67 | )
68 |
69 | try expectError(
70 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
71 | childToken: "target|unknown",
72 | baseToken: nil
73 | )
74 | }
75 |
76 | func testRuntimeErrorInChildTemplate() throws {
77 | let filterExtension = Extension()
78 | filterExtension.registerFilter("unknown") { (_: Any?) in
79 | throw TemplateSyntaxError("filter error")
80 | }
81 | environment.extensions += [filterExtension]
82 |
83 | childTemplate = Template(
84 | templateString: """
85 | {% extends "base.html" %}
86 | {% block body %}Child {{ target|unknown }}{% endblock %}
87 | """,
88 | environment: environment,
89 | name: nil
90 | )
91 |
92 | try expectError(
93 | reason: "filter error",
94 | childToken: "target|unknown",
95 | baseToken: nil
96 | )
97 | }
98 |
99 | private func expectError(
100 | reason: String,
101 | childToken: String,
102 | baseToken: String?,
103 | file: String = #file,
104 | line: Int = #line,
105 | function: String = #function
106 | ) throws {
107 | var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
108 | if let baseToken = baseToken {
109 | expectedError.stackTrace = [
110 | expectedSyntaxError(
111 | token: baseToken,
112 | template: baseTemplate,
113 | description: reason
114 | ).token
115 | ].compactMap { $0 }
116 | }
117 | let error = try expect(
118 | self.environment.render(template: self.childTemplate, context: ["target": "World"]),
119 | file: file,
120 | line: line,
121 | function: function
122 | ).toThrow() as TemplateSyntaxError
123 | let reporter = SimpleErrorReporter()
124 | try expect(
125 | reporter.renderError(error),
126 | file: file,
127 | line: line,
128 | function: function
129 | ) == reporter.renderError(expectedError)
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/Sources/Stencil/Loader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import PathKit
9 |
10 | /// Type used for loading a template
11 | public protocol Loader {
12 | /// Load a template with the given name
13 | func loadTemplate(name: String, environment: Environment) throws -> Template
14 | /// Load a template with the given list of names
15 | func loadTemplate(names: [String], environment: Environment) throws -> Template
16 | }
17 |
18 | extension Loader {
19 | /// Default implementation, tries to load the first template that exists from the list of given names
20 | public func loadTemplate(names: [String], environment: Environment) throws -> Template {
21 | for name in names {
22 | do {
23 | return try loadTemplate(name: name, environment: environment)
24 | } catch is TemplateDoesNotExist {
25 | continue
26 | } catch {
27 | throw error
28 | }
29 | }
30 |
31 | throw TemplateDoesNotExist(templateNames: names, loader: self)
32 | }
33 | }
34 |
35 | // A class for loading a template from disk
36 | public class FileSystemLoader: Loader, CustomStringConvertible {
37 | public let paths: [Path]
38 |
39 | public init(paths: [Path]) {
40 | self.paths = paths
41 | }
42 |
43 | public init(bundle: [Bundle]) {
44 | self.paths = bundle.map { bundle in
45 | Path(bundle.bundlePath)
46 | }
47 | }
48 |
49 | public var description: String {
50 | "FileSystemLoader(\(paths))"
51 | }
52 |
53 | public func loadTemplate(name: String, environment: Environment) throws -> Template {
54 | for path in paths {
55 | let templatePath = try path.safeJoin(path: Path(name))
56 |
57 | if !templatePath.exists {
58 | continue
59 | }
60 |
61 | let content: String = try templatePath.read()
62 | return environment.templateClass.init(templateString: content, environment: environment, name: name)
63 | }
64 |
65 | throw TemplateDoesNotExist(templateNames: [name], loader: self)
66 | }
67 |
68 | public func loadTemplate(names: [String], environment: Environment) throws -> Template {
69 | for path in paths {
70 | for templateName in names {
71 | let templatePath = try path.safeJoin(path: Path(templateName))
72 |
73 | if templatePath.exists {
74 | let content: String = try templatePath.read()
75 | return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
76 | }
77 | }
78 | }
79 |
80 | throw TemplateDoesNotExist(templateNames: names, loader: self)
81 | }
82 | }
83 |
84 | public class DictionaryLoader: Loader {
85 | public let templates: [String: String]
86 |
87 | public init(templates: [String: String]) {
88 | self.templates = templates
89 | }
90 |
91 | public func loadTemplate(name: String, environment: Environment) throws -> Template {
92 | if let content = templates[name] {
93 | return environment.templateClass.init(templateString: content, environment: environment, name: name)
94 | }
95 |
96 | throw TemplateDoesNotExist(templateNames: [name], loader: self)
97 | }
98 |
99 | public func loadTemplate(names: [String], environment: Environment) throws -> Template {
100 | for name in names {
101 | if let content = templates[name] {
102 | return environment.templateClass.init(templateString: content, environment: environment, name: name)
103 | }
104 | }
105 |
106 | throw TemplateDoesNotExist(templateNames: names, loader: self)
107 | }
108 | }
109 |
110 | extension Path {
111 | func safeJoin(path: Path) throws -> Path {
112 | let newPath = self + path
113 |
114 | if !newPath.absolute().description.hasPrefix(absolute().description) {
115 | throw SuspiciousFileOperation(basePath: self, path: newPath)
116 | }
117 |
118 | return newPath
119 | }
120 | }
121 |
122 | class SuspiciousFileOperation: Error {
123 | let basePath: Path
124 | let path: Path
125 |
126 | init(basePath: Path, path: Path) {
127 | self.basePath = basePath
128 | self.path = path
129 | }
130 |
131 | var description: String {
132 | "Path `\(path)` is located outside of base path `\(basePath)`"
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Sources/Stencil/Filters.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | func capitalise(_ value: Any?) -> Any? {
8 | if let array = value as? [Any?] {
9 | return array.map { stringify($0).capitalized }
10 | } else {
11 | return stringify(value).capitalized
12 | }
13 | }
14 |
15 | func uppercase(_ value: Any?) -> Any? {
16 | if let array = value as? [Any?] {
17 | return array.map { stringify($0).uppercased() }
18 | } else {
19 | return stringify(value).uppercased()
20 | }
21 | }
22 |
23 | func lowercase(_ value: Any?) -> Any? {
24 | if let array = value as? [Any?] {
25 | return array.map { stringify($0).lowercased() }
26 | } else {
27 | return stringify(value).lowercased()
28 | }
29 | }
30 |
31 | func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
32 | // value can be optional wrapping nil, so this way we check for underlying value
33 | if let value = value, String(describing: value) != "nil" {
34 | return value
35 | }
36 |
37 | for argument in arguments {
38 | if let argument = argument {
39 | return argument
40 | }
41 | }
42 |
43 | return nil
44 | }
45 |
46 | func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
47 | guard arguments.count < 2 else {
48 | throw TemplateSyntaxError("'join' filter takes at most one argument")
49 | }
50 |
51 | let separator = stringify(arguments.first ?? "")
52 |
53 | if let value = value as? [Any] {
54 | return value
55 | .map(stringify)
56 | .joined(separator: separator)
57 | }
58 |
59 | return value
60 | }
61 |
62 | func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
63 | guard arguments.count < 2 else {
64 | throw TemplateSyntaxError("'split' filter takes at most one argument")
65 | }
66 |
67 | let separator = stringify(arguments.first ?? " ")
68 | if let value = value as? String {
69 | return value.components(separatedBy: separator)
70 | }
71 |
72 | return value
73 | }
74 |
75 | func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
76 | guard arguments.count <= 3 else {
77 | throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
78 | }
79 |
80 | var indentWidth = 4
81 | if !arguments.isEmpty {
82 | guard let value = arguments[0] as? Int else {
83 | throw TemplateSyntaxError(
84 | """
85 | 'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
86 | """
87 | )
88 | }
89 | indentWidth = value
90 | }
91 |
92 | var indentationChar = " "
93 | if arguments.count > 1 {
94 | guard let value = arguments[1] as? String else {
95 | throw TemplateSyntaxError(
96 | """
97 | 'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
98 | """
99 | )
100 | }
101 | indentationChar = value
102 | }
103 |
104 | var indentFirst = false
105 | if arguments.count > 2 {
106 | guard let value = arguments[2] as? Bool else {
107 | throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
108 | }
109 | indentFirst = value
110 | }
111 |
112 | let indentation = [String](repeating: indentationChar, count: indentWidth).joined()
113 | return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
114 | }
115 |
116 | func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
117 | guard !indentation.isEmpty else { return content }
118 |
119 | var lines = content.components(separatedBy: .newlines)
120 | let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
121 | let result = lines.reduce(into: [firstLine]) { result, line in
122 | result.append(line.isEmpty ? "" : "\(indentation)\(line)")
123 | }
124 | return result.joined(separator: "\n")
125 | }
126 |
127 | func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
128 | guard let value = value else { return nil }
129 | guard arguments.count == 1 else {
130 | throw TemplateSyntaxError("'filter' filter takes one argument")
131 | }
132 |
133 | let attribute = stringify(arguments[0])
134 |
135 | let expr = try context.environment.compileFilter("$0|\(attribute)")
136 | return try context.push(dictionary: ["$0": value]) {
137 | try expr.resolve(context)
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | Template API
2 | ============
3 |
4 | This document describes Stencils Swift API, and not the Swift template language.
5 |
6 | .. contents:: :depth: 2
7 |
8 | Environment
9 | -----------
10 |
11 | An environment contains shared configuration such as custom filters and tags
12 | along with template loaders.
13 |
14 | .. code-block:: swift
15 |
16 | let environment = Environment()
17 |
18 | You can optionally provide a loader or extensions when creating an environment:
19 |
20 | .. code-block:: swift
21 |
22 | let environment = Environment(loader: ..., extensions: [...])
23 |
24 | Rendering a Template
25 | ~~~~~~~~~~~~~~~~~~~~
26 |
27 | Environment provides convinience methods to render a template either from a
28 | string or a template loader.
29 |
30 | .. code-block:: swift
31 |
32 | let template = "Hello {{ name }}"
33 | let context = ["name": "Kyle"]
34 | let rendered = environment.renderTemplate(string: template, context: context)
35 |
36 | Rendering a template from the configured loader:
37 |
38 | .. code-block:: swift
39 |
40 | let context = ["name": "Kyle"]
41 | let rendered = environment.renderTemplate(name: "example.html", context: context)
42 |
43 | Loading a Template
44 | ~~~~~~~~~~~~~~~~~~
45 |
46 | Environment provides an API to load a template from the configured loader.
47 |
48 | .. code-block:: swift
49 |
50 | let template = try environment.loadTemplate(name: "example.html")
51 |
52 | Loader
53 | ------
54 |
55 | Loaders are responsible for loading templates from a resource such as the file
56 | system.
57 |
58 | Stencil provides a ``FileSytemLoader`` which allows you to load a template
59 | directly from the file system.
60 |
61 | FileSystemLoader
62 | ~~~~~~~~~~~~~~~~
63 |
64 | Loads templates from the file system. This loader can find templates in folders
65 | on the file system.
66 |
67 | .. code-block:: swift
68 |
69 | FileSystemLoader(paths: ["./templates"])
70 |
71 | .. code-block:: swift
72 |
73 | FileSystemLoader(bundle: [Bundle.main])
74 |
75 |
76 | DictionaryLoader
77 | ~~~~~~~~~~~~~~~~
78 |
79 | Loads templates from a dictionary.
80 |
81 | .. code-block:: swift
82 |
83 | DictionaryLoader(templates: ["index.html": "Hello World"])
84 |
85 |
86 | Custom Loaders
87 | ~~~~~~~~~~~~~~
88 |
89 | ``Loader`` is a protocol, so you can implement your own compatible loaders. You
90 | will need to implement a ``loadTemplate`` method to load the template,
91 | throwing a ``TemplateDoesNotExist`` when the template is not found.
92 |
93 | .. code-block:: swift
94 |
95 | class ExampleMemoryLoader: Loader {
96 | func loadTemplate(name: String, environment: Environment) throws -> Template {
97 | if name == "index.html" {
98 | return Template(templateString: "Hello", environment: environment)
99 | }
100 |
101 | throw TemplateDoesNotExist(name: name, loader: self)
102 | }
103 | }
104 |
105 |
106 | Context
107 | -------
108 |
109 | A ``Context`` is a structure containing any templates you would like to use in
110 | a template. It’s somewhat like a dictionary, however you can push and pop to
111 | scope variables. So that means that when iterating over a for loop, you can
112 | push a new scope into the context to store any variables local to the scope.
113 |
114 | You would normally only access the ``Context`` within a custom template tag or
115 | filter.
116 |
117 | Subscripting
118 | ~~~~~~~~~~~~
119 |
120 | You can use subscripting to get and set values from the context.
121 |
122 | .. code-block:: swift
123 |
124 | context["key"] = value
125 | let value = context["key"]
126 |
127 | ``push()``
128 | ~~~~~~~~~~
129 |
130 | A ``Context`` is a stack. You can push a new level onto the ``Context`` so that
131 | modifications can easily be poped off. This is useful for isolating mutations
132 | into scope of a template tag. Such as ``{% if %}`` and ``{% for %}`` tags.
133 |
134 | .. code-block:: swift
135 |
136 | context.push(["name": "example"]) {
137 | // context contains name which is `example`.
138 | }
139 |
140 | // name is popped off the context after the duration of the closure.
141 |
142 | ``flatten()``
143 | ~~~~~~~~~~~~~
144 |
145 | Using ``flatten()`` method you can get whole ``Context`` stack as one
146 | dictionary including all variables.
147 |
148 | .. code-block:: swift
149 |
150 | let dictionary = context.flatten()
151 |
--------------------------------------------------------------------------------
/Tests/StencilTests/TrimBehaviourSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | import Stencil
9 | import XCTest
10 |
11 | final class TrimBehaviourTests: XCTestCase {
12 | func testSmartTrimCanRemoveNewlines() throws {
13 | let templateString = """
14 | {% for item in items %}
15 | - {{item}}
16 | {% endfor %}
17 | text
18 | """
19 |
20 | let context = ["items": ["item 1", "item 2"]]
21 | let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
22 | let result = try template.render(context)
23 |
24 | // swiftlint:disable indentation_width
25 | try expect(result) == """
26 | - item 1
27 | - item 2
28 | text
29 | """
30 | // swiftlint:enable indentation_width
31 | }
32 |
33 | func testSmartTrimOnlyRemoveSingleNewlines() throws {
34 | let templateString = """
35 | {% for item in items %}
36 |
37 | - {{item}}
38 | {% endfor %}
39 | text
40 | """
41 |
42 | let context = ["items": ["item 1", "item 2"]]
43 | let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
44 | let result = try template.render(context)
45 |
46 | // swiftlint:disable indentation_width
47 | try expect(result) == """
48 |
49 | - item 1
50 |
51 | - item 2
52 | text
53 | """
54 | // swiftlint:enable indentation_width
55 | }
56 |
57 | func testSmartTrimCanRemoveNewlinesWhileKeepingWhitespace() throws {
58 | // swiftlint:disable indentation_width
59 | let templateString = """
60 | Items:
61 | {% for item in items %}
62 | - {{item}}
63 | {% endfor %}
64 | """
65 | // swiftlint:enable indentation_width
66 |
67 | let context = ["items": ["item 1", "item 2"]]
68 | let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
69 | let result = try template.render(context)
70 |
71 | // swiftlint:disable indentation_width
72 | try expect(result) == """
73 | Items:
74 | - item 1
75 | - item 2
76 |
77 | """
78 | // swiftlint:enable indentation_width
79 | }
80 |
81 | func testTrimSymbols() {
82 | it("Respects whitespace control symbols in for tags") {
83 | // swiftlint:disable indentation_width
84 | let template: Template = """
85 | {% for num in numbers -%}
86 | {{num}}
87 | {%- endfor %}
88 | """
89 | // swiftlint:enable indentation_width
90 | let result = try template.render([ "numbers": Array(1...9) ])
91 | try expect(result) == "123456789"
92 | }
93 | it("Respects whitespace control symbols in if tags") {
94 | let template: Template = """
95 | {% if value -%}
96 | {{text}}
97 | {%- endif %}
98 | """
99 | let result = try template.render([ "text": "hello", "value": true ])
100 | try expect(result) == "hello"
101 | }
102 | }
103 |
104 | func testTrimSymbolsOverridingEnvironment() {
105 | let environment = Environment(trimBehaviour: .all)
106 |
107 | it("respects whitespace control symbols in if tags") {
108 | // swiftlint:disable indentation_width
109 | let templateString = """
110 | {% if value +%}
111 | {{text}}
112 | {%+ endif %}
113 |
114 | """
115 | // swiftlint:enable indentation_width
116 | let template = Template(templateString: templateString, environment: environment)
117 | let result = try template.render([ "text": "hello", "value": true ])
118 | try expect(result) == "\n hello\n"
119 | }
120 |
121 | it("can customize blocks on same line as text") {
122 | // swiftlint:disable indentation_width
123 | let templateString = """
124 | Items:{% for item in items +%}
125 | - {{item}}
126 | {%- endfor %}
127 | """
128 | // swiftlint:enable indentation_width
129 |
130 | let context = ["items": ["item 1", "item 2"]]
131 | let template = Template(templateString: templateString, environment: environment)
132 | let result = try template.render(context)
133 |
134 | // swiftlint:disable indentation_width
135 | try expect(result) == """
136 | Items:
137 | - item 1
138 | - item 2
139 | """
140 | // swiftlint:enable indentation_width
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/Tests/StencilTests/NodeSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class NodeTests: XCTestCase {
12 | private let context = Context(dictionary: [
13 | "name": "Kyle",
14 | "age": 27,
15 | "items": [1, 2, 3]
16 | ])
17 |
18 | func testTextNode() {
19 | it("renders the given text") {
20 | let node = TextNode(text: "Hello World")
21 | try expect(try node.render(self.context)) == "Hello World"
22 | }
23 | it("Trims leading whitespace") {
24 | let text = " \n Some text "
25 | let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
26 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
27 | try expect(try node.render(self.context)) == "\n Some text "
28 | }
29 | it("Trims leading whitespace and one newline") {
30 | let text = "\n\n Some text "
31 | let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
32 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
33 | try expect(try node.render(self.context)) == "\n Some text "
34 | }
35 | it("Trims leading whitespace and one newline") {
36 | let text = "\n\n Some text "
37 | let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
38 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
39 | try expect(try node.render(self.context)) == "Some text "
40 | }
41 | it("Trims trailing whitespace") {
42 | let text = " Some text \n"
43 | let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
44 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
45 | try expect(try node.render(self.context)) == " Some text\n"
46 | }
47 | it("Trims trailing whitespace and one newline") {
48 | let text = " Some text \n \n "
49 | let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
50 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
51 | try expect(try node.render(self.context)) == " Some text \n "
52 | }
53 | it("Trims trailing whitespace and newlines") {
54 | let text = " Some text \n \n "
55 | let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
56 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
57 | try expect(try node.render(self.context)) == " Some text"
58 | }
59 | it("Trims all whitespace") {
60 | let text = " \n \nSome text \n "
61 | let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
62 | let node = TextNode(text: text, trimBehaviour: trimBehaviour)
63 | try expect(try node.render(self.context)) == "Some text"
64 | }
65 | }
66 |
67 | func testVariableNode() {
68 | it("resolves and renders the variable") {
69 | let node = VariableNode(variable: Variable("name"))
70 | try expect(try node.render(self.context)) == "Kyle"
71 | }
72 |
73 | it("resolves and renders a non string variable") {
74 | let node = VariableNode(variable: Variable("age"))
75 | try expect(try node.render(self.context)) == "27"
76 | }
77 | }
78 |
79 | func testRendering() {
80 | it("renders the nodes") {
81 | let nodes: [NodeType] = [
82 | TextNode(text: "Hello "),
83 | VariableNode(variable: "name")
84 | ]
85 |
86 | try expect(try renderNodes(nodes, self.context)) == "Hello Kyle"
87 | }
88 |
89 | it("correctly throws a nodes failure") {
90 | let nodes: [NodeType] = [
91 | TextNode(text: "Hello "),
92 | VariableNode(variable: "name"),
93 | ErrorNode()
94 | ]
95 |
96 | try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
97 | }
98 | }
99 |
100 | func testRenderingBooleans() {
101 | it("can render true & false") {
102 | try expect(Template(templateString: "{{ true }}").render()) == "true"
103 | try expect(Template(templateString: "{{ false }}").render()) == "false"
104 | }
105 |
106 | it("can resolve variable") {
107 | let template = Template(templateString: "{{ value == \"known\" }}")
108 | try expect(template.render(["value": "known"])) == "true"
109 | try expect(template.render(["value": "unknown"])) == "false"
110 | }
111 |
112 | it("can render a boolean expression") {
113 | try expect(Template(templateString: "{{ 1 > 0 }}").render()) == "true"
114 | try expect(Template(templateString: "{{ 1 == 2 }}").render()) == "false"
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/Tests/StencilTests/ContextSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Spectre
8 | @testable import Stencil
9 | import XCTest
10 |
11 | final class ContextTests: XCTestCase {
12 | func testContextSubscripting() {
13 | describe("Context Subscripting") { test in
14 | var context = Context()
15 | test.before {
16 | context = Context(dictionary: ["name": "Kyle"])
17 | }
18 |
19 | test.it("allows you to get a value via subscripting") {
20 | try expect(context["name"] as? String) == "Kyle"
21 | }
22 |
23 | test.it("allows you to set a value via subscripting") {
24 | context["name"] = "Katie"
25 |
26 | try expect(context["name"] as? String) == "Katie"
27 | }
28 |
29 | test.it("allows you to remove a value via subscripting") {
30 | context["name"] = nil
31 |
32 | try expect(context["name"]).to.beNil()
33 | }
34 |
35 | test.it("allows you to retrieve a value from a parent") {
36 | try context.push {
37 | try expect(context["name"] as? String) == "Kyle"
38 | }
39 | }
40 |
41 | test.it("allows you to override a parent's value") {
42 | try context.push {
43 | context["name"] = "Katie"
44 | try expect(context["name"] as? String) == "Katie"
45 | }
46 | }
47 | }
48 | }
49 |
50 | func testContextRestoration() {
51 | describe("Context Restoration") { test in
52 | var context = Context()
53 | test.before {
54 | context = Context(dictionary: ["name": "Kyle"])
55 | }
56 |
57 | test.it("allows you to pop to restore previous state") {
58 | context.push {
59 | context["name"] = "Katie"
60 | }
61 |
62 | try expect(context["name"] as? String) == "Kyle"
63 | }
64 |
65 | test.it("allows you to remove a parent's value in a level") {
66 | try context.push {
67 | context["name"] = nil
68 | try expect(context["name"]).to.beNil()
69 | }
70 |
71 | try expect(context["name"] as? String) == "Kyle"
72 | }
73 |
74 | test.it("allows you to push a dictionary and run a closure then restoring previous state") {
75 | var didRun = false
76 |
77 | try context.push(dictionary: ["name": "Katie"]) {
78 | didRun = true
79 | try expect(context["name"] as? String) == "Katie"
80 | }
81 |
82 | try expect(didRun).to.beTrue()
83 | try expect(context["name"] as? String) == "Kyle"
84 | }
85 |
86 | test.it("allows you to flatten the context contents") {
87 | try context.push(dictionary: ["test": "abc"]) {
88 | let flattened = context.flatten()
89 |
90 | try expect(flattened.count) == 2
91 | try expect(flattened["name"] as? String) == "Kyle"
92 | try expect(flattened["test"] as? String) == "abc"
93 | }
94 | }
95 | }
96 | }
97 |
98 | func testContextLazyEvaluation() {
99 | let ticker = Ticker()
100 | var context = Context()
101 | var wrapper = LazyValueWrapper("")
102 |
103 | describe("Lazy evaluation") { test in
104 | test.before {
105 | ticker.count = 0
106 | wrapper = LazyValueWrapper(ticker.tick())
107 | context = Context(dictionary: ["name": wrapper])
108 | }
109 |
110 | test.it("Evaluates lazy data") {
111 | let template = Template(templateString: "{{ name }}")
112 | let result = try template.render(context)
113 | try expect(result) == "Kyle"
114 | try expect(ticker.count) == 1
115 | }
116 |
117 | test.it("Evaluates lazy only once") {
118 | let template = Template(templateString: "{{ name }}{{ name }}")
119 | let result = try template.render(context)
120 | try expect(result) == "KyleKyle"
121 | try expect(ticker.count) == 1
122 | }
123 |
124 | test.it("Does not evaluate lazy data when not used") {
125 | let template = Template(templateString: "{{ 'Katie' }}")
126 | let result = try template.render(context)
127 | try expect(result) == "Katie"
128 | try expect(ticker.count) == 0
129 | }
130 | }
131 | }
132 |
133 | func testContextLazyAccessTypes() {
134 | it("Supports evaluation via context reference") {
135 | let context = Context(dictionary: ["name": "Kyle"])
136 | context["alias"] = LazyValueWrapper { $0["name"] ?? "" }
137 | let template = Template(templateString: "{{ alias }}")
138 |
139 | try context.push(dictionary: ["name": "Katie"]) {
140 | let result = try template.render(context)
141 | try expect(result) == "Katie"
142 | }
143 | }
144 |
145 | it("Supports evaluation via context copy") {
146 | let context = Context(dictionary: ["name": "Kyle"])
147 | context["alias"] = LazyValueWrapper(copying: context) { $0["name"] ?? "" }
148 | let template = Template(templateString: "{{ alias }}")
149 |
150 | try context.push(dictionary: ["name": "Katie"]) {
151 | let result = try template.render(context)
152 | try expect(result) == "Kyle"
153 | }
154 | }
155 | }
156 | }
157 |
158 | // MARK: - Helpers
159 |
160 | private final class Ticker {
161 | var count: Int = 0
162 | func tick() -> String {
163 | count += 1
164 | return "Kyle"
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/Sources/Stencil/Tokenizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | extension String {
10 | /// Split a string by a separator leaving quoted phrases together
11 | func smartSplit(separator: Character = " ") -> [String] {
12 | var word = ""
13 | var components: [String] = []
14 | var separate: Character = separator
15 | var singleQuoteCount = 0
16 | var doubleQuoteCount = 0
17 |
18 | for character in self {
19 | if character == "'" {
20 | singleQuoteCount += 1
21 | } else if character == "\"" {
22 | doubleQuoteCount += 1
23 | }
24 |
25 | if character == separate {
26 | if separate != separator {
27 | word.append(separate)
28 | } else if (singleQuoteCount.isMultiple(of: 2) || doubleQuoteCount.isMultiple(of: 2)) && !word.isEmpty {
29 | appendWord(word, to: &components)
30 | word = ""
31 | }
32 |
33 | separate = separator
34 | } else {
35 | if separate == separator && (character == "'" || character == "\"") {
36 | separate = character
37 | }
38 | word.append(character)
39 | }
40 | }
41 |
42 | if !word.isEmpty {
43 | appendWord(word, to: &components)
44 | }
45 |
46 | return components
47 | }
48 |
49 | private func appendWord(_ word: String, to components: inout [String]) {
50 | let specialCharacters = ",|:"
51 |
52 | if !components.isEmpty {
53 | if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
54 | // special case for labeled for-loops
55 | if components.count == 1 && word == "for" {
56 | components.append(word)
57 | } else {
58 | components[components.count - 1] += word
59 | }
60 | } else if specialCharacters.contains(word) {
61 | components[components.count - 1] += word
62 | } else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
63 | components.append(String(word.prefix(1)))
64 | appendWord(String(word.dropFirst()), to: &components)
65 | } else if word != "(" && word.last == "(" || word != ")" && word.last == ")" {
66 | appendWord(String(word.dropLast()), to: &components)
67 | components.append(String(word.suffix(1)))
68 | } else {
69 | components.append(word)
70 | }
71 | } else {
72 | components.append(word)
73 | }
74 | }
75 | }
76 |
77 | public struct SourceMap: Equatable {
78 | public let filename: String?
79 | public let location: ContentLocation
80 |
81 | init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
82 | self.filename = filename
83 | self.location = location
84 | }
85 |
86 | static let unknown = SourceMap()
87 |
88 | public static func == (lhs: SourceMap, rhs: SourceMap) -> Bool {
89 | lhs.filename == rhs.filename && lhs.location == rhs.location
90 | }
91 | }
92 |
93 | public struct WhitespaceBehaviour: Equatable {
94 | public enum Behaviour {
95 | case unspecified
96 | case trim
97 | case keep
98 | }
99 |
100 | let leading: Behaviour
101 | let trailing: Behaviour
102 |
103 | public static let unspecified = WhitespaceBehaviour(leading: .unspecified, trailing: .unspecified)
104 | }
105 |
106 | public class Token: Equatable {
107 | public enum Kind: Equatable {
108 | /// A token representing a piece of text.
109 | case text
110 | /// A token representing a variable.
111 | case variable
112 | /// A token representing a comment.
113 | case comment
114 | /// A token representing a template block.
115 | case block
116 | }
117 |
118 | public let contents: String
119 | public let kind: Kind
120 | public let sourceMap: SourceMap
121 | public var whitespace: WhitespaceBehaviour?
122 |
123 | /// Returns the underlying value as an array seperated by spaces
124 | public private(set) lazy var components: [String] = self.contents.smartSplit()
125 |
126 | init(contents: String, kind: Kind, sourceMap: SourceMap, whitespace: WhitespaceBehaviour? = nil) {
127 | self.contents = contents
128 | self.kind = kind
129 | self.sourceMap = sourceMap
130 | self.whitespace = whitespace
131 | }
132 |
133 | /// A token representing a piece of text.
134 | public static func text(value: String, at sourceMap: SourceMap) -> Token {
135 | Token(contents: value, kind: .text, sourceMap: sourceMap)
136 | }
137 |
138 | /// A token representing a variable.
139 | public static func variable(value: String, at sourceMap: SourceMap) -> Token {
140 | Token(contents: value, kind: .variable, sourceMap: sourceMap)
141 | }
142 |
143 | /// A token representing a comment.
144 | public static func comment(value: String, at sourceMap: SourceMap) -> Token {
145 | Token(contents: value, kind: .comment, sourceMap: sourceMap)
146 | }
147 |
148 | /// A token representing a template block.
149 | public static func block(
150 | value: String,
151 | at sourceMap: SourceMap,
152 | whitespace: WhitespaceBehaviour = .unspecified
153 | ) -> Token {
154 | Token(contents: value, kind: .block, sourceMap: sourceMap, whitespace: whitespace)
155 | }
156 |
157 | public static func == (lhs: Token, rhs: Token) -> Bool {
158 | lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/Sources/Stencil/Inheritance.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | class BlockContext {
8 | class var contextKey: String { "block_context" }
9 |
10 | // contains mapping of block names to their nodes and templates where they are defined
11 | var blocks: [String: [BlockNode]]
12 |
13 | init(blocks: [String: BlockNode]) {
14 | self.blocks = [:]
15 | blocks.forEach { self.blocks[$0.key] = [$0.value] }
16 | }
17 |
18 | func push(_ block: BlockNode, forKey blockName: String) {
19 | if var blocks = blocks[blockName] {
20 | blocks.append(block)
21 | self.blocks[blockName] = blocks
22 | } else {
23 | self.blocks[blockName] = [block]
24 | }
25 | }
26 |
27 | func pop(_ blockName: String) -> BlockNode? {
28 | if var blocks = blocks[blockName] {
29 | let block = blocks.removeFirst()
30 | if blocks.isEmpty {
31 | self.blocks.removeValue(forKey: blockName)
32 | } else {
33 | self.blocks[blockName] = blocks
34 | }
35 | return block
36 | } else {
37 | return nil
38 | }
39 | }
40 | }
41 |
42 | extension Collection {
43 | func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
44 | for element in self where closure(element) {
45 | return element
46 | }
47 |
48 | return nil
49 | }
50 | }
51 |
52 | class ExtendsNode: NodeType {
53 | let templateName: Variable
54 | let blocks: [String: BlockNode]
55 | let token: Token?
56 |
57 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
58 | let bits = token.components
59 |
60 | guard bits.count == 2 else {
61 | throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
62 | }
63 |
64 | let parsedNodes = try parser.parse()
65 | guard (parsedNodes.any { $0 is ExtendsNode }) == nil else {
66 | throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
67 | }
68 |
69 | let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
70 | let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
71 | accumulator[node.name] = node
72 | }
73 |
74 | return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
75 | }
76 |
77 | init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
78 | self.templateName = templateName
79 | self.blocks = blocks
80 | self.token = token
81 | }
82 |
83 | func render(_ context: Context) throws -> String {
84 | guard let templateName = try self.templateName.resolve(context) as? String else {
85 | throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
86 | }
87 |
88 | let baseTemplate = try context.environment.loadTemplate(name: templateName)
89 |
90 | let blockContext: BlockContext
91 | if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
92 | blockContext = currentBlockContext
93 | for (name, block) in blocks {
94 | blockContext.push(block, forKey: name)
95 | }
96 | } else {
97 | blockContext = BlockContext(blocks: blocks)
98 | }
99 |
100 | do {
101 | // pushes base template and renders it's content
102 | // block_context contains all blocks from child templates
103 | return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
104 | try baseTemplate.render(context)
105 | }
106 | } catch {
107 | // if error template is already set (see catch in BlockNode)
108 | // and it happend in the same template as current template
109 | // there is no need to wrap it in another error
110 | if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
111 | throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
112 | } else {
113 | throw error
114 | }
115 | }
116 | }
117 | }
118 |
119 | class BlockNode: NodeType {
120 | let name: String
121 | let nodes: [NodeType]
122 | let token: Token?
123 |
124 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
125 | let bits = token.components
126 |
127 | guard bits.count == 2 else {
128 | throw TemplateSyntaxError("'block' tag takes one argument, the block name")
129 | }
130 |
131 | let blockName = bits[1]
132 | let nodes = try parser.parse(until(["endblock"]))
133 | _ = parser.nextToken()
134 | return BlockNode(name: blockName, nodes: nodes, token: token)
135 | }
136 |
137 | init(name: String, nodes: [NodeType], token: Token) {
138 | self.name = name
139 | self.nodes = nodes
140 | self.token = token
141 | }
142 |
143 | func render(_ context: Context) throws -> String {
144 | if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
145 | let childContext: [String: Any] = [
146 | BlockContext.contextKey: blockContext,
147 | "block": ["super": try self.render(context)]
148 | ]
149 |
150 | // render extension node
151 | do {
152 | return try context.push(dictionary: childContext) {
153 | try child.render(context)
154 | }
155 | } catch {
156 | throw error.withToken(child.token)
157 | }
158 | }
159 |
160 | let result = try renderNodes(nodes, context)
161 | context.cacheBlock(name, content: result)
162 | return result
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.5)
5 | rexml
6 | activesupport (6.1.6.1)
7 | concurrent-ruby (~> 1.0, >= 1.0.2)
8 | i18n (>= 1.6, < 2)
9 | minitest (>= 5.1)
10 | tzinfo (~> 2.0)
11 | zeitwerk (~> 2.3)
12 | addressable (2.8.0)
13 | public_suffix (>= 2.0.2, < 5.0)
14 | algoliasearch (1.27.5)
15 | httpclient (~> 2.8, >= 2.8.3)
16 | json (>= 1.5.1)
17 | ast (2.4.2)
18 | atomos (0.1.3)
19 | claide (1.1.0)
20 | claide-plugins (0.9.2)
21 | cork
22 | nap
23 | open4 (~> 1.3)
24 | cocoapods (1.11.3)
25 | addressable (~> 2.8)
26 | claide (>= 1.0.2, < 2.0)
27 | cocoapods-core (= 1.11.3)
28 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
29 | cocoapods-downloader (>= 1.4.0, < 2.0)
30 | cocoapods-plugins (>= 1.0.0, < 2.0)
31 | cocoapods-search (>= 1.0.0, < 2.0)
32 | cocoapods-trunk (>= 1.4.0, < 2.0)
33 | cocoapods-try (>= 1.1.0, < 2.0)
34 | colored2 (~> 3.1)
35 | escape (~> 0.0.4)
36 | fourflusher (>= 2.3.0, < 3.0)
37 | gh_inspector (~> 1.0)
38 | molinillo (~> 0.8.0)
39 | nap (~> 1.0)
40 | ruby-macho (>= 1.0, < 3.0)
41 | xcodeproj (>= 1.21.0, < 2.0)
42 | cocoapods-core (1.11.3)
43 | activesupport (>= 5.0, < 7)
44 | addressable (~> 2.8)
45 | algoliasearch (~> 1.0)
46 | concurrent-ruby (~> 1.1)
47 | fuzzy_match (~> 2.0.4)
48 | nap (~> 1.0)
49 | netrc (~> 0.11)
50 | public_suffix (~> 4.0)
51 | typhoeus (~> 1.0)
52 | cocoapods-deintegrate (1.0.5)
53 | cocoapods-downloader (1.6.3)
54 | cocoapods-plugins (1.0.0)
55 | nap
56 | cocoapods-search (1.0.1)
57 | cocoapods-trunk (1.6.0)
58 | nap (>= 0.8, < 2.0)
59 | netrc (~> 0.11)
60 | cocoapods-try (1.2.0)
61 | colored2 (3.1.2)
62 | concurrent-ruby (1.1.10)
63 | cork (0.3.0)
64 | colored2 (~> 3.1)
65 | danger (8.6.1)
66 | claide (~> 1.0)
67 | claide-plugins (>= 0.9.2)
68 | colored2 (~> 3.1)
69 | cork (~> 0.1)
70 | faraday (>= 0.9.0, < 2.0)
71 | faraday-http-cache (~> 2.0)
72 | git (~> 1.7)
73 | kramdown (~> 2.3)
74 | kramdown-parser-gfm (~> 1.0)
75 | no_proxy_fix
76 | octokit (~> 4.7)
77 | terminal-table (>= 1, < 4)
78 | escape (0.0.4)
79 | ethon (0.15.0)
80 | ffi (>= 1.15.0)
81 | faraday (1.10.0)
82 | faraday-em_http (~> 1.0)
83 | faraday-em_synchrony (~> 1.0)
84 | faraday-excon (~> 1.1)
85 | faraday-httpclient (~> 1.0)
86 | faraday-multipart (~> 1.0)
87 | faraday-net_http (~> 1.0)
88 | faraday-net_http_persistent (~> 1.0)
89 | faraday-patron (~> 1.0)
90 | faraday-rack (~> 1.0)
91 | faraday-retry (~> 1.0)
92 | ruby2_keywords (>= 0.0.4)
93 | faraday-em_http (1.0.0)
94 | faraday-em_synchrony (1.0.0)
95 | faraday-excon (1.1.0)
96 | faraday-http-cache (2.4.0)
97 | faraday (>= 0.8)
98 | faraday-httpclient (1.0.1)
99 | faraday-multipart (1.0.4)
100 | multipart-post (~> 2)
101 | faraday-net_http (1.0.1)
102 | faraday-net_http_persistent (1.2.0)
103 | faraday-patron (1.0.0)
104 | faraday-rack (1.0.0)
105 | faraday-retry (1.0.3)
106 | ffi (1.15.5)
107 | fourflusher (2.3.1)
108 | fuzzy_match (2.0.4)
109 | gh_inspector (1.1.3)
110 | git (1.11.0)
111 | rchardet (~> 1.8)
112 | httpclient (2.8.3)
113 | i18n (1.12.0)
114 | concurrent-ruby (~> 1.0)
115 | json (2.6.2)
116 | kramdown (2.4.0)
117 | rexml
118 | kramdown-parser-gfm (1.1.0)
119 | kramdown (~> 2.0)
120 | minitest (5.16.2)
121 | molinillo (0.8.0)
122 | multipart-post (2.2.3)
123 | nanaimo (0.3.0)
124 | nap (1.1.0)
125 | netrc (0.11.0)
126 | no_proxy_fix (0.1.2)
127 | octokit (4.25.1)
128 | faraday (>= 1, < 3)
129 | sawyer (~> 0.9)
130 | open4 (1.3.4)
131 | parallel (1.22.1)
132 | parser (3.1.2.0)
133 | ast (~> 2.4.1)
134 | public_suffix (4.0.7)
135 | rainbow (3.1.1)
136 | rake (13.0.6)
137 | rchardet (1.8.0)
138 | regexp_parser (2.5.0)
139 | rexml (3.2.5)
140 | rouge (2.0.7)
141 | rubocop (1.32.0)
142 | json (~> 2.3)
143 | parallel (~> 1.10)
144 | parser (>= 3.1.0.0)
145 | rainbow (>= 2.2.2, < 4.0)
146 | regexp_parser (>= 1.8, < 3.0)
147 | rexml (>= 3.2.5, < 4.0)
148 | rubocop-ast (>= 1.19.1, < 2.0)
149 | ruby-progressbar (~> 1.7)
150 | unicode-display_width (>= 1.4.0, < 3.0)
151 | rubocop-ast (1.19.1)
152 | parser (>= 3.1.1.0)
153 | ruby-macho (2.5.1)
154 | ruby-progressbar (1.11.0)
155 | ruby2_keywords (0.0.5)
156 | sawyer (0.9.2)
157 | addressable (>= 2.3.5)
158 | faraday (>= 0.17.3, < 3)
159 | terminal-table (3.0.2)
160 | unicode-display_width (>= 1.1.1, < 3)
161 | typhoeus (1.4.0)
162 | ethon (>= 0.9.0)
163 | tzinfo (2.0.5)
164 | concurrent-ruby (~> 1.0)
165 | unicode-display_width (2.2.0)
166 | xcodeproj (1.22.0)
167 | CFPropertyList (>= 2.3.3, < 4.0)
168 | atomos (~> 0.1.3)
169 | claide (>= 1.0.2, < 2.0)
170 | colored2 (~> 3.1)
171 | nanaimo (~> 0.3.0)
172 | rexml (~> 3.2.4)
173 | xcpretty (0.3.0)
174 | rouge (~> 2.0.7)
175 | zeitwerk (2.6.0)
176 |
177 | PLATFORMS
178 | ruby
179 |
180 | DEPENDENCIES
181 | cocoapods (~> 1.11)
182 | danger (~> 8.4)
183 | octokit (~> 4.7)
184 | rake (~> 13.0)
185 | rubocop (~> 1.22)
186 | xcpretty (~> 0.3)
187 |
188 | BUNDLED WITH
189 | 2.2.33
190 |
--------------------------------------------------------------------------------
/Sources/Stencil/Node.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | /// Represents a parsed node
10 | public protocol NodeType {
11 | /// Render the node in the given context
12 | func render(_ context: Context) throws -> String
13 |
14 | /// Reference to this node's token
15 | var token: Token? { get }
16 | }
17 |
18 | /// Render the collection of nodes in the given context
19 | public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String {
20 | var result = ""
21 |
22 | for node in nodes {
23 | do {
24 | result += try node.render(context)
25 | } catch {
26 | throw error.withToken(node.token)
27 | }
28 |
29 | let shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
30 | let shouldContinue = context[LoopTerminationNode.continueContextKey] != nil
31 |
32 | if shouldBreak || shouldContinue {
33 | break
34 | }
35 | }
36 |
37 | return result
38 | }
39 |
40 | /// Simple node, used for triggering a closure during rendering
41 | public class SimpleNode: NodeType {
42 | public let handler: (Context) throws -> String
43 | public let token: Token?
44 |
45 | public init(token: Token, handler: @escaping (Context) throws -> String) {
46 | self.token = token
47 | self.handler = handler
48 | }
49 |
50 | public func render(_ context: Context) throws -> String {
51 | try handler(context)
52 | }
53 | }
54 |
55 | /// Represents a block of text, renders the text
56 | public class TextNode: NodeType {
57 | public let text: String
58 | public let token: Token?
59 | public let trimBehaviour: TrimBehaviour
60 |
61 | public init(text: String, trimBehaviour: TrimBehaviour = .nothing) {
62 | self.text = text
63 | self.token = nil
64 | self.trimBehaviour = trimBehaviour
65 | }
66 |
67 | public func render(_ context: Context) throws -> String {
68 | var string = self.text
69 | if trimBehaviour.leading != .nothing, !string.isEmpty {
70 | let range = NSRange(.. Any?
87 | }
88 |
89 | /// Represents a variable, renders the variable, may have conditional expressions.
90 | public class VariableNode: NodeType {
91 | public let variable: Resolvable
92 | public var token: Token?
93 | let condition: Expression?
94 | let elseExpression: Resolvable?
95 |
96 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
97 | let components = token.components
98 |
99 | func hasToken(_ token: String, at index: Int) -> Bool {
100 | components.count > (index + 1) && components[index] == token
101 | }
102 | func compileResolvable(_ components: [String], containedIn token: Token) throws -> Resolvable {
103 | try (try? parser.compileExpression(components: components, token: token)) ??
104 | parser.compileFilter(components.joined(separator: " "), containedIn: token)
105 | }
106 |
107 | let variable: Resolvable
108 | let condition: Expression?
109 | let elseExpression: Resolvable?
110 |
111 | if hasToken("if", at: 1) {
112 | variable = try compileResolvable([components[0]], containedIn: token)
113 |
114 | let components = components.suffix(from: 2)
115 | if let elseIndex = components.firstIndex(of: "else") {
116 | condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
117 | let elseToken = Array(components.suffix(from: elseIndex.advanced(by: 1)))
118 | elseExpression = try compileResolvable(elseToken, containedIn: token)
119 | } else {
120 | condition = try parser.compileExpression(components: Array(components), token: token)
121 | elseExpression = nil
122 | }
123 | } else if !components.isEmpty {
124 | variable = try compileResolvable(components, containedIn: token)
125 | condition = nil
126 | elseExpression = nil
127 | } else {
128 | throw TemplateSyntaxError(reason: "Missing variable name", token: token)
129 | }
130 |
131 | return VariableNode(variable: variable, token: token, condition: condition, elseExpression: elseExpression)
132 | }
133 |
134 | public init(variable: Resolvable, token: Token? = nil) {
135 | self.variable = variable
136 | self.token = token
137 | self.condition = nil
138 | self.elseExpression = nil
139 | }
140 |
141 | init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
142 | self.variable = variable
143 | self.token = token
144 | self.condition = condition
145 | self.elseExpression = elseExpression
146 | }
147 |
148 | public init(variable: String, token: Token? = nil) {
149 | self.variable = Variable(variable)
150 | self.token = token
151 | self.condition = nil
152 | self.elseExpression = nil
153 | }
154 |
155 | public func render(_ context: Context) throws -> String {
156 | if let condition = self.condition, try condition.evaluate(context: context) == false {
157 | return try elseExpression?.resolve(context).map(stringify) ?? ""
158 | }
159 |
160 | let result = try variable.resolve(context)
161 | return stringify(result)
162 | }
163 | }
164 |
165 | func stringify(_ result: Any?) -> String {
166 | if let result = result as? String {
167 | return result
168 | } else if let array = result as? [Any?] {
169 | return unwrap(array).description
170 | } else if let result = result as? CustomStringConvertible {
171 | return result.description
172 | } else if let result = result as? NSObject {
173 | return result.description
174 | }
175 |
176 | return ""
177 | }
178 |
179 | func unwrap(_ array: [Any?]) -> [Any] {
180 | array.map { (item: Any?) -> Any in
181 | if let item = item {
182 | if let items = item as? [Any?] {
183 | return unwrap(items)
184 | } else {
185 | return item
186 | }
187 | } else { return item as Any }
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/Tests/StencilTests/LexerSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | final class LexerTests: XCTestCase {
13 | func testText() throws {
14 | let lexer = Lexer(templateString: "Hello World")
15 | let tokens = lexer.tokenize()
16 |
17 | try expect(tokens.count) == 1
18 | try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
19 | }
20 |
21 | func testComment() throws {
22 | let lexer = Lexer(templateString: "{# Comment #}")
23 | let tokens = lexer.tokenize()
24 |
25 | try expect(tokens.count) == 1
26 | try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
27 | }
28 |
29 | func testVariable() throws {
30 | let lexer = Lexer(templateString: "{{ Variable }}")
31 | let tokens = lexer.tokenize()
32 |
33 | try expect(tokens.count) == 1
34 | try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
35 | }
36 |
37 | func testTokenWithoutSpaces() throws {
38 | let lexer = Lexer(templateString: "{{Variable}}")
39 | let tokens = lexer.tokenize()
40 |
41 | try expect(tokens.count) == 1
42 | try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
43 | }
44 |
45 | func testUnclosedTag() throws {
46 | let templateString = "{{ thing"
47 | let lexer = Lexer(templateString: templateString)
48 | let tokens = lexer.tokenize()
49 |
50 | try expect(tokens.count) == 1
51 | try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
52 | }
53 |
54 | func testContentMixture() throws {
55 | let templateString = "My name is {{ myname }}."
56 | let lexer = Lexer(templateString: templateString)
57 | let tokens = lexer.tokenize()
58 |
59 | try expect(tokens.count) == 3
60 | try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
61 | try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
62 | try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
63 | }
64 |
65 | func testVariablesWithoutBeingGreedy() throws {
66 | let templateString = "{{ thing }}{{ name }}"
67 | let lexer = Lexer(templateString: templateString)
68 | let tokens = lexer.tokenize()
69 |
70 | try expect(tokens.count) == 2
71 | try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
72 | try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
73 | }
74 |
75 | func testUnclosedBlock() throws {
76 | let lexer = Lexer(templateString: "{%}")
77 | _ = lexer.tokenize()
78 | }
79 |
80 | func testTokenizeIncorrectSyntaxWithoutCrashing() throws {
81 | let lexer = Lexer(templateString: "func some() {{% if %}")
82 | _ = lexer.tokenize()
83 | }
84 |
85 | func testEmptyVariable() throws {
86 | let lexer = Lexer(templateString: "{{}}")
87 | _ = lexer.tokenize()
88 | }
89 |
90 | func testNewlines() throws {
91 | // swiftlint:disable indentation_width
92 | let templateString = """
93 | My name is {%
94 | if name
95 | and
96 | name
97 | %}{{
98 | name
99 | }}{%
100 | endif %}.
101 | """
102 | // swiftlint:enable indentation_width
103 | let lexer = Lexer(templateString: templateString)
104 | let tokens = lexer.tokenize()
105 |
106 | try expect(tokens.count) == 5
107 | try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
108 | try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
109 | try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
110 | try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
111 | try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
112 | }
113 |
114 | func testTrimSymbols() throws {
115 | let fBlock = "if hello"
116 | let sBlock = "ta da"
117 | let lexer = Lexer(templateString: "{%+ \(fBlock) -%}{% \(sBlock) -%}")
118 | let tokens = lexer.tokenize()
119 | let behaviours = (
120 | WhitespaceBehaviour(leading: .keep, trailing: .trim),
121 | WhitespaceBehaviour(leading: .unspecified, trailing: .trim)
122 | )
123 |
124 | try expect(tokens.count) == 2
125 | try expect(tokens[0]) == .block(value: fBlock, at: makeSourceMap(fBlock, for: lexer), whitespace: behaviours.0)
126 | try expect(tokens[1]) == .block(value: sBlock, at: makeSourceMap(sBlock, for: lexer), whitespace: behaviours.1)
127 | }
128 |
129 | func testEscapeSequence() throws {
130 | let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
131 | let lexer = Lexer(templateString: templateString)
132 | let tokens = lexer.tokenize()
133 |
134 | try expect(tokens.count) == 5
135 | try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
136 | try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
137 | try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
138 | try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
139 | try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
140 | }
141 |
142 | func testPerformance() throws {
143 | let path = Path(#file as String) + ".." + "fixtures" + "huge.html"
144 | let content: String = try path.read()
145 |
146 | measure {
147 | let lexer = Lexer(templateString: content)
148 | _ = lexer.tokenize()
149 | }
150 | }
151 |
152 | func testCombiningDiaeresis() throws {
153 | // the symbol "ü" in the `templateString` is unusually encoded as 0x75 0xCC 0x88 (LATIN SMALL LETTER U + COMBINING
154 | // DIAERESIS) instead of 0xC3 0xBC (LATIN SMALL LETTER U WITH DIAERESIS)
155 | let templateString = "ü\n{% if test %}ü{% endif %}\n{% if ü %}ü{% endif %}\n"
156 | let lexer = Lexer(templateString: templateString)
157 | let tokens = lexer.tokenize()
158 |
159 | try expect(tokens.count) == 9
160 | assert(tokens[1].contents == "if test")
161 | }
162 |
163 | private func makeSourceMap(_ token: String, for lexer: Lexer, options: String.CompareOptions = []) -> SourceMap {
164 | guard let range = lexer.templateString.range(of: token, options: options) else { fatalError("Token not found") }
165 | return SourceMap(location: lexer.rangeLocation(range))
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/docs/templates.rst:
--------------------------------------------------------------------------------
1 | Language overview
2 | ==================
3 |
4 | - ``{{ ... }}`` for variables to print to the template output
5 | - ``{% ... %}`` for tags
6 | - ``{# ... #}`` for comments not included in the template output
7 |
8 | Variables
9 | ---------
10 |
11 | A variable can be defined in your template using the following:
12 |
13 | .. code-block:: html+django
14 |
15 | {{ variable }}
16 |
17 | Stencil will look up the variable inside the current variable context and
18 | evaluate it. When a variable contains a dot, it will try doing the
19 | following lookup:
20 |
21 | - Context lookup
22 | - Dictionary lookup
23 | - Array and string lookup (first, last, count, by index)
24 | - Key value coding lookup
25 | - @dynamicMemberLookup when conforming to our `DynamicMemberLookup` marker protocol
26 | - Type introspection (via ``Mirror``)
27 |
28 | For example, if `people` was an array:
29 |
30 | .. code-block:: html+django
31 |
32 | There are {{ people.count }} people. {{ people.first }} is the first
33 | person, followed by {{ people.1 }}.
34 |
35 | You can also use the subscript operator for indirect evaluation. The expression
36 | between brackets will be evaluated first, before the actual lookup will happen.
37 |
38 | For example, if you have the following context:
39 |
40 | .. code-block:: swift
41 |
42 | [
43 | "item": [
44 | "name": "John"
45 | ],
46 | "key": "name"
47 | ]
48 |
49 | .. code-block:: html+django
50 |
51 | 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.
52 |
53 | You can use the `LazyValueWrapper` type to have values in your context that will be lazily evaluated. The provided value will only be evaluated when it's first accessed in your template, and will be cached afterwards. For example:
54 |
55 | .. code-block:: swift
56 |
57 | [
58 | "magic": LazyValueWrapper(myHeavyCalculations())
59 | ]
60 |
61 | Boolean expressions
62 | -------------------
63 |
64 | Boolean expressions can be rendered using ``{{ ... }}`` tag.
65 | For example, this will output string `true` if variable is equal to 1 and `false` otherwise:
66 |
67 | .. code-block:: html+django
68 |
69 | {{ variable == 1 }}
70 |
71 | Filters
72 | ~~~~~~~
73 |
74 | Filters allow you to transform the values of variables. For example, they look like:
75 |
76 | .. code-block:: html+django
77 |
78 | {{ variable|uppercase }}
79 |
80 | See :ref:`all builtin filters `.
81 |
82 | Tags
83 | ----
84 |
85 | Tags are a mechanism to execute a piece of code, allowing you to have
86 | control flow within your template.
87 |
88 | .. code-block:: html+django
89 |
90 | {% if variable %}
91 | {{ variable }} was found.
92 | {% endif %}
93 |
94 | A tag can also affect the context and define variables as follows:
95 |
96 | .. code-block:: html+django
97 |
98 | {% for item in items %}
99 | {{ item }}
100 | {% endfor %}
101 |
102 | Stencil includes of built-in tags which are listed below. You can also
103 | extend Stencil by providing your own tags.
104 |
105 | See :ref:`all builtin tags `.
106 |
107 | Comments
108 | --------
109 |
110 | To comment out part of your template, you can use the following syntax:
111 |
112 | .. code-block:: html+django
113 |
114 | {# My comment is completely hidden #}
115 |
116 | Whitespace Control
117 | ------------------
118 |
119 | Stencil supports the same syntax as Jinja for whitespace control, see `their docs for more information `_.
120 |
121 | Essentially, Stencil will **not** trim whitespace by default. However you can:
122 |
123 | - Control how this is handled for the whole template by setting the trim behaviour. We provide a few pre-made combinations such as `nothing` (default), `smart` and `all`. More granular combinations are possible.
124 | - You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before.
125 | - You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after.
126 |
127 | .. _template-inheritance:
128 |
129 | Template inheritance
130 | --------------------
131 |
132 | Template inheritance allows the common components surrounding individual pages
133 | to be shared across other templates. You can define blocks which can be
134 | overidden in any child template.
135 |
136 | Let's take a look at an example. Here is our base template (``base.html``):
137 |
138 | .. code-block:: html+django
139 |
140 |
141 |
142 | {% block title %}Example{% endblock %}
143 |
144 |
145 |
146 |
147 | {% block sidebar %}
148 |
152 | {% endblock %}
153 |
154 |
155 |
156 | {% block content %}{% endblock %}
157 |
158 |
159 |
160 |
161 | This example declares three blocks, ``title``, ``sidebar`` and ``content``. We
162 | can use the ``{% extends %}`` template tag to inherit from our base template
163 | and then use ``{% block %}`` to override any blocks from our base template.
164 |
165 | A child template might look like the following:
166 |
167 | .. code-block:: html+django
168 |
169 | {% extends "base.html" %}
170 |
171 | {% block title %}Notes{% endblock %}
172 |
173 | {% block content %}
174 | {% for note in notes %}
175 | {{ note }}
176 | {% endfor %}
177 | {% endblock %}
178 |
179 | .. note:: You can use ``{{ block.super }}` inside a block to render the contents of the parent block inline.
180 |
181 | Since our child template doesn't declare a sidebar block. The original sidebar
182 | from our base template will be used. Depending on the content of ``notes`` our
183 | template might be rendered like the following:
184 |
185 | .. code-block:: html
186 |
187 |
188 |
189 | Notes
190 |
191 |
192 |
193 |
199 |
200 |
201 | Pick up food
202 | Do laundry
203 |
204 |
205 |
206 |
207 | You can use as many levels of inheritance as needed. One common way of using
208 | inheritance is the following three-level approach:
209 |
210 | * Create a ``base.html`` template that holds the main look-and-feel of your site.
211 | * Create a ``base_SECTIONNAME.html`` template for each “section” of your site.
212 | For example, ``base_news.html``, ``base_news.html``. These templates all
213 | extend ``base.html`` and include section-specific styles/design.
214 | * Create individual templates for each type of page, such as a news article or
215 | blog entry. These templates extend the appropriate section template.
216 |
217 | You can render block's content more than once by using ``{{ block.name }}`` **after** a block is defined.
218 |
--------------------------------------------------------------------------------
/Tests/StencilTests/EnvironmentSpec.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Spectre
9 | @testable import Stencil
10 | import XCTest
11 |
12 | final class EnvironmentTests: XCTestCase {
13 | private var environment = Environment(loader: ExampleLoader())
14 | private var template: Template = ""
15 |
16 | override func setUp() {
17 | super.setUp()
18 |
19 | let errorExtension = Extension()
20 | errorExtension.registerFilter("throw") { (_: Any?) in
21 | throw TemplateSyntaxError("filter error")
22 | }
23 | errorExtension.registerSimpleTag("simpletag") { _ in
24 | throw TemplateSyntaxError("simpletag error")
25 | }
26 | errorExtension.registerTag("customtag") { _, token in
27 | ErrorNode(token: token)
28 | }
29 |
30 | environment = Environment(loader: ExampleLoader())
31 | environment.extensions += [errorExtension]
32 | template = ""
33 | }
34 |
35 | override func tearDown() {
36 | super.tearDown()
37 | }
38 |
39 | func testLoading() {
40 | it("can load a template from a name") {
41 | let template = try self.environment.loadTemplate(name: "example.html")
42 | try expect(template.name) == "example.html"
43 | }
44 |
45 | it("can load a template from a names") {
46 | let template = try self.environment.loadTemplate(names: ["first.html", "example.html"])
47 | try expect(template.name) == "example.html"
48 | }
49 | }
50 |
51 | func testRendering() {
52 | it("can render a template from a string") {
53 | let result = try self.environment.renderTemplate(string: "Hello World")
54 | try expect(result) == "Hello World"
55 | }
56 |
57 | it("can render a template from a file") {
58 | let result = try self.environment.renderTemplate(name: "example.html")
59 | try expect(result) == "Hello World!"
60 | }
61 |
62 | it("allows you to provide a custom template class") {
63 | let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
64 | let result = try environment.renderTemplate(string: "Hello World")
65 |
66 | try expect(result) == "here"
67 | }
68 | }
69 |
70 | func testSyntaxError() {
71 | it("reports syntax error on invalid for tag syntax") {
72 | self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
73 | try self.expectError(
74 | reason: "'for' statements should use the syntax: `for in [where ]`.",
75 | token: "for name in"
76 | )
77 | }
78 |
79 | it("reports syntax error on missing endfor") {
80 | self.template = "{% for name in names %}{{ name }}"
81 | try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
82 | }
83 |
84 | it("reports syntax error on unknown tag") {
85 | self.template = "{% for name in names %}{{ name }}{% end %}"
86 | try self.expectError(reason: "Unknown template tag 'end'", token: "end")
87 | }
88 | }
89 |
90 | func testUnknownFilter() {
91 | it("reports syntax error in for tag") {
92 | self.template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
93 | try self.expectError(
94 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
95 | token: "names|unknown"
96 | )
97 | }
98 |
99 | it("reports syntax error in for-where tag") {
100 | self.template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
101 | try self.expectError(
102 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
103 | token: "name|unknown"
104 | )
105 | }
106 |
107 | it("reports syntax error in if tag") {
108 | self.template = "{% if name|unknown %}{{ name }}{% endif %}"
109 | try self.expectError(
110 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
111 | token: "name|unknown"
112 | )
113 | }
114 |
115 | it("reports syntax error in elif tag") {
116 | self.template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
117 | try self.expectError(
118 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
119 | token: "name|unknown"
120 | )
121 | }
122 |
123 | it("reports syntax error in ifnot tag") {
124 | self.template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
125 | try self.expectError(
126 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
127 | token: "name|unknown"
128 | )
129 | }
130 |
131 | it("reports syntax error in filter tag") {
132 | self.template = "{% filter unknown %}Text{% endfilter %}"
133 | try self.expectError(
134 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
135 | token: "filter unknown"
136 | )
137 | }
138 |
139 | it("reports syntax error in variable tag") {
140 | self.template = "{{ name|unknown }}"
141 | try self.expectError(
142 | reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
143 | token: "name|unknown"
144 | )
145 | }
146 |
147 | it("reports error in variable tag") {
148 | self.template = "{{ }}"
149 | try self.expectError(reason: "Missing variable name", token: " ")
150 | }
151 | }
152 |
153 | func testRenderingError() {
154 | it("reports rendering error in variable filter") {
155 | self.template = Template(templateString: "{{ name|throw }}", environment: self.environment)
156 | try self.expectError(reason: "filter error", token: "name|throw")
157 | }
158 |
159 | it("reports rendering error in filter tag") {
160 | self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment)
161 | try self.expectError(reason: "filter error", token: "filter throw")
162 | }
163 |
164 | it("reports rendering error in simple tag") {
165 | self.template = Template(templateString: "{% simpletag %}", environment: self.environment)
166 | try self.expectError(reason: "simpletag error", token: "simpletag")
167 | }
168 |
169 | it("reports passing argument to simple filter") {
170 | self.template = "{{ name|uppercase:5 }}"
171 | try self.expectError(reason: "Can't invoke filter with an argument", token: "name|uppercase:5")
172 | }
173 |
174 | it("reports rendering error in custom tag") {
175 | self.template = Template(templateString: "{% customtag %}", environment: self.environment)
176 | try self.expectError(reason: "Custom Error", token: "customtag")
177 | }
178 |
179 | it("reports rendering error in for body") {
180 | self.template = Template(templateString: """
181 | {% for name in names %}{% customtag %}{% endfor %}
182 | """, environment: self.environment)
183 | try self.expectError(reason: "Custom Error", token: "customtag")
184 | }
185 |
186 | it("reports rendering error in block") {
187 | self.template = Template(
188 | templateString: "{% block some %}{% customtag %}{% endblock %}",
189 | environment: self.environment
190 | )
191 | try self.expectError(reason: "Custom Error", token: "customtag")
192 | }
193 | }
194 |
195 | private func expectError(
196 | reason: String,
197 | token: String,
198 | file: String = #file,
199 | line: Int = #line,
200 | function: String = #function
201 | ) throws {
202 | let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
203 |
204 | let error = try expect(
205 | self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
206 | file: file,
207 | line: line,
208 | function: function
209 | ).toThrow() as TemplateSyntaxError
210 | let reporter = SimpleErrorReporter()
211 | try expect(
212 | reporter.renderError(error),
213 | file: file,
214 | line: line,
215 | function: function
216 | ) == reporter.renderError(expectedError)
217 | }
218 | }
219 |
220 | // MARK: - Helpers
221 |
222 | private class CustomTemplate: Template {
223 | // swiftlint:disable discouraged_optional_collection
224 | override func render(_ dictionary: [String: Any]? = nil) throws -> String {
225 | "here"
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help
18 | help:
19 | @echo "Please use \`make ' where is one of"
20 | @echo " html to make standalone HTML files"
21 | @echo " dirhtml to make HTML files named index.html in directories"
22 | @echo " singlehtml to make a single large HTML file"
23 | @echo " pickle to make pickle files"
24 | @echo " json to make JSON files"
25 | @echo " htmlhelp to make HTML files and a HTML help project"
26 | @echo " qthelp to make HTML files and a qthelp project"
27 | @echo " applehelp to make an Apple Help Book"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " epub3 to make an epub3"
31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
32 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
34 | @echo " text to make text files"
35 | @echo " man to make manual pages"
36 | @echo " texinfo to make Texinfo files"
37 | @echo " info to make Texinfo files and run them through makeinfo"
38 | @echo " gettext to make PO message catalogs"
39 | @echo " changes to make an overview of all changed/added/deprecated items"
40 | @echo " xml to make Docutils-native XML files"
41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
42 | @echo " linkcheck to check all external links for integrity"
43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
44 | @echo " coverage to run coverage check of the documentation (if enabled)"
45 | @echo " dummy to check syntax errors of document sources"
46 |
47 | .PHONY: clean
48 | clean:
49 | rm -rf $(BUILDDIR)/*
50 |
51 | .PHONY: html
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | .PHONY: dirhtml
58 | dirhtml:
59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
60 | @echo
61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
62 |
63 | .PHONY: singlehtml
64 | singlehtml:
65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
66 | @echo
67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
68 |
69 | .PHONY: pickle
70 | pickle:
71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
72 | @echo
73 | @echo "Build finished; now you can process the pickle files."
74 |
75 | .PHONY: json
76 | json:
77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
78 | @echo
79 | @echo "Build finished; now you can process the JSON files."
80 |
81 | .PHONY: htmlhelp
82 | htmlhelp:
83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
84 | @echo
85 | @echo "Build finished; now you can run HTML Help Workshop with the" \
86 | ".hhp project file in $(BUILDDIR)/htmlhelp."
87 |
88 | .PHONY: qthelp
89 | qthelp:
90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
91 | @echo
92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Stencil.qhcp"
95 | @echo "To view the help file:"
96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Stencil.qhc"
97 |
98 | .PHONY: applehelp
99 | applehelp:
100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
101 | @echo
102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
103 | @echo "N.B. You won't be able to view it unless you put it in" \
104 | "~/Library/Documentation/Help or install it in your application" \
105 | "bundle."
106 |
107 | .PHONY: devhelp
108 | devhelp:
109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
110 | @echo
111 | @echo "Build finished."
112 | @echo "To view the help file:"
113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Stencil"
114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Stencil"
115 | @echo "# devhelp"
116 |
117 | .PHONY: epub
118 | epub:
119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
120 | @echo
121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
122 |
123 | .PHONY: epub3
124 | epub3:
125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
126 | @echo
127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
128 |
129 | .PHONY: latex
130 | latex:
131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
132 | @echo
133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
135 | "(use \`make latexpdf' here to do that automatically)."
136 |
137 | .PHONY: latexpdf
138 | latexpdf:
139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
140 | @echo "Running LaTeX files through pdflatex..."
141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
143 |
144 | .PHONY: latexpdfja
145 | latexpdfja:
146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
147 | @echo "Running LaTeX files through platex and dvipdfmx..."
148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
150 |
151 | .PHONY: text
152 | text:
153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
154 | @echo
155 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
156 |
157 | .PHONY: man
158 | man:
159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
160 | @echo
161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
162 |
163 | .PHONY: texinfo
164 | texinfo:
165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
166 | @echo
167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
168 | @echo "Run \`make' in that directory to run these through makeinfo" \
169 | "(use \`make info' here to do that automatically)."
170 |
171 | .PHONY: info
172 | info:
173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
174 | @echo "Running Texinfo files through makeinfo..."
175 | make -C $(BUILDDIR)/texinfo info
176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
177 |
178 | .PHONY: gettext
179 | gettext:
180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
181 | @echo
182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
183 |
184 | .PHONY: changes
185 | changes:
186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
187 | @echo
188 | @echo "The overview file is in $(BUILDDIR)/changes."
189 |
190 | .PHONY: linkcheck
191 | linkcheck:
192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
193 | @echo
194 | @echo "Link check complete; look for any errors in the above output " \
195 | "or in $(BUILDDIR)/linkcheck/output.txt."
196 |
197 | .PHONY: doctest
198 | doctest:
199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
200 | @echo "Testing of doctests in the sources finished, look at the " \
201 | "results in $(BUILDDIR)/doctest/output.txt."
202 |
203 | .PHONY: coverage
204 | coverage:
205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
206 | @echo "Testing of coverage in the sources finished, look at the " \
207 | "results in $(BUILDDIR)/coverage/python.txt."
208 |
209 | .PHONY: xml
210 | xml:
211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
212 | @echo
213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
214 |
215 | .PHONY: pseudoxml
216 | pseudoxml:
217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
218 | @echo
219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
220 |
221 | .PHONY: dummy
222 | dummy:
223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
224 | @echo
225 | @echo "Build finished. Dummy builder generates no files."
226 |
--------------------------------------------------------------------------------
/rakelib/utils.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - MIN_XCODE_VERSION
5 |
6 | require 'json'
7 | require 'open3'
8 | require 'pathname'
9 |
10 | # Utility functions to run Xcode commands, extract versionning info and logs messages
11 | #
12 | class Utils
13 | COLUMN_WIDTHS = [45, 12].freeze
14 |
15 | ## [ Run commands ] #########################################################
16 |
17 | # formatter types
18 | # :xcpretty : through xcpretty and store in artifacts
19 | # :raw : store in artifacts
20 | # :to_string : run using backticks and return output
21 |
22 | # run a command using xcrun and xcpretty if applicable
23 | def self.run(command, task, subtask = '', xcrun: false, formatter: :raw)
24 | commands = if xcrun and OS.mac?
25 | Array(command).map { |cmd| "#{version_select} xcrun #{cmd}" }
26 | else
27 | Array(command)
28 | end
29 | case formatter
30 | when :xcpretty then xcpretty(commands, task, subtask)
31 | when :raw then plain(commands, task, subtask)
32 | when :to_string then `#{commands.join(' && ')}`
33 | else raise "Unknown formatter '#{formatter}'"
34 | end
35 | end
36 |
37 | ## [ Convenience Helpers ] ##################################################
38 |
39 | def self.podspec(file)
40 | JSON.parse(File.read("#{file}.podspec.json"))
41 | end
42 |
43 | def self.podspec_version(file)
44 | podspec_as_json(file)['version']
45 | end
46 |
47 | def self.pod_trunk_last_version(pod)
48 | require 'yaml'
49 | stdout, _, _ = Open3.capture3('bundle', 'exec', 'pod', 'trunk', 'info', pod)
50 | stdout.sub!("\n#{pod}\n", '')
51 | last_version_line = YAML.safe_load(stdout).first['Versions'].last
52 | /^[0-9.]*/.match(last_version_line)[0] # Just the 'x.y.z' part
53 | end
54 |
55 | def self.spm_own_version(dep)
56 | dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
57 | dependencies.find { |d| d['package'] == dep }['state']['version']
58 | end
59 |
60 | def self.spm_resolved_version(dep)
61 | dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
62 | dependencies.find { |d| d['package'] == dep }['state']['version']
63 | end
64 |
65 | def self.last_git_tag_version
66 | `git describe --tags --abbrev=0`.strip
67 | end
68 |
69 | def self.octokit_client
70 | token = ENV['DANGER_GITHUB_API_TOKEN']
71 | token ||= File.exist?('.apitoken') && File.read('.apitoken')
72 | token ||= File.exist?('../.apitoken') && File.read('../.apitoken')
73 | Utils.print_error('No .apitoken file found') unless token
74 | require 'octokit'
75 | Octokit::Client.new(access_token: token)
76 | end
77 |
78 | def self.top_changelog_version(changelog_file = 'CHANGELOG.md')
79 | header, _, _ = Open3.capture3('grep', '-m', '1', '^## ', changelog_file)
80 | header.gsub('## ', '').strip
81 | end
82 |
83 | def self.top_changelog_entry(changelog_file = 'CHANGELOG.md')
84 | tag = top_changelog_version
85 | stdout, _, _ = Open3.capture3('sed', '-n', "/^## #{tag}$/,/^## /p", changelog_file)
86 | stdout.gsub(/^## .*$/, '').strip
87 | end
88 |
89 | def self.first_match_in_file(file, regexp, index = 0)
90 | File.foreach(file) do |line|
91 | m = regexp.match(line)
92 | return m[index] if m
93 | end
94 | end
95 |
96 | ## [ Print info/errors ] ####################################################
97 |
98 | # print an info header
99 | def self.print_header(str)
100 | puts "== #{str.chomp} ==".format(:yellow, :bold)
101 | end
102 |
103 | # print an info message
104 | def self.print_info(str)
105 | puts str.chomp.format(:green)
106 | end
107 |
108 | # print an error message
109 | def self.print_error(str)
110 | puts str.chomp.format(:red)
111 | end
112 |
113 | # format an info message in a 2 column table
114 | def self.table_header(col1, col2)
115 | puts "| #{col1.ljust(COLUMN_WIDTHS[0])} | #{col2.ljust(COLUMN_WIDTHS[1])} |"
116 | puts "| #{'-' * COLUMN_WIDTHS[0]} | #{'-' * COLUMN_WIDTHS[1]} |"
117 | end
118 |
119 | # format an info message in a 2 column table
120 | def self.table_info(label, msg)
121 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | 👉 #{msg.ljust(COLUMN_WIDTHS[1] - 4)} |"
122 | end
123 |
124 | # format a result message in a 2 column table
125 | def self.table_result(result, label, error_msg)
126 | if result
127 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | #{'✅'.ljust(COLUMN_WIDTHS[1] - 1)} |"
128 | else
129 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | ❌ - #{error_msg.ljust(COLUMN_WIDTHS[1] - 6)} |"
130 | end
131 | result
132 | end
133 |
134 | ## [ Private helper functions ] ##################################################
135 |
136 | # run a command, pipe output through 'xcpretty' and store the output in CI artifacts
137 | def self.xcpretty(cmd, task, subtask)
138 | command = Array(cmd).join(' && \\' + "\n")
139 |
140 | if ENV['CI']
141 | Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color --report junit)
142 | elsif system('which xcpretty > /dev/null')
143 | Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color)
144 | else
145 | Rake.sh command
146 | end
147 | end
148 | private_class_method :xcpretty
149 |
150 | # run a command and store the output in CI artifacts
151 | def self.plain(cmd, task, subtask)
152 | command = Array(cmd).join(' && \\' + "\n")
153 |
154 | if ENV['CI']
155 | if OS.mac?
156 | Rake.sh %(set -o pipefail && (#{command}))
157 | else
158 | # dash on linux doesn't support `set -o`
159 | Rake.sh %(/bin/bash -eo pipefail -c "#{command}")
160 | end
161 | else
162 | Rake.sh command
163 | end
164 | end
165 | private_class_method :plain
166 |
167 | # select the xcode version we want/support
168 | def self.version_select
169 | @version_select ||= compute_developer_dir(MIN_XCODE_VERSION)
170 | end
171 | private_class_method :version_select
172 |
173 | # Return the "DEVELOPER_DIR=..." prefix to use in order to point to the best Xcode version
174 | #
175 | # @param [String|Float|Gem::Requirement] version_req
176 | # The Xcode version requirement.
177 | # - If it's a Float, it's converted to a "~> x.y" requirement
178 | # - If it's a String, it's converted to a Gem::Requirement as is
179 | # @note If you pass a String, be sure to use "~> " in the string unless you really want
180 | # to point to an exact, very specific version
181 | #
182 | def self.compute_developer_dir(version_req)
183 | version_req = Gem::Requirement.new("~> #{version_req}") if version_req.is_a?(Float)
184 | version_req = Gem::Requirement.new(version_req) unless version_req.is_a?(Gem::Requirement)
185 | # if current Xcode already fulfills min version don't force DEVELOPER_DIR=...
186 | current_xcode_version = `xcodebuild -version`.split("\n").first.match(/[0-9.]+/).to_s
187 | return '' if version_req.satisfied_by? Gem::Version.new(current_xcode_version)
188 |
189 | supported_versions = all_xcode_versions.select { |app| version_req.satisfied_by?(app[:vers]) }
190 | latest_supported_xcode = supported_versions.sort_by { |app| app[:vers] }.last
191 |
192 | # Check if it's at least the right version
193 | if latest_supported_xcode.nil?
194 | raise "\n[!!!] Requires Xcode #{version_req}, but we were not able to find it. " \
195 | "If it's already installed, either `xcode-select -s` to it, or update your Spotlight index " \
196 | "with 'mdimport /Applications/Xcode*'\n\n"
197 | end
198 |
199 | %(DEVELOPER_DIR="#{latest_supported_xcode[:path]}/Contents/Developer")
200 | end
201 | private_class_method :compute_developer_dir
202 |
203 | # @return [Array] A list of { :vers => ... , :path => ... } hashes
204 | # of all Xcodes found on the machine using Spotlight
205 | def self.all_xcode_versions
206 | xcodes = `mdfind "kMDItemCFBundleIdentifier = 'com.apple.dt.Xcode'"`.chomp.split("\n")
207 | xcodes.map do |path|
208 | { vers: Gem::Version.new(`mdls -name kMDItemVersion -raw "#{path}"`), path: path }
209 | end
210 | end
211 | private_class_method :all_xcode_versions
212 | end
213 |
214 | # OS detection
215 | #
216 | module OS
217 | def OS.mac?
218 | (/darwin/ =~ RUBY_PLATFORM) != nil
219 | end
220 |
221 | def OS.linux?
222 | OS.unix? and not OS.mac?
223 | end
224 | end
225 |
226 | # Colorization support for Strings
227 | #
228 | class String
229 | # colorization
230 | FORMATTING = {
231 | # text styling
232 | bold: 1,
233 | faint: 2,
234 | italic: 3,
235 | underline: 4,
236 | # foreground colors
237 | black: 30,
238 | red: 31,
239 | green: 32,
240 | yellow: 33,
241 | blue: 34,
242 | magenta: 35,
243 | cyan: 36,
244 | white: 37,
245 | # background colors
246 | bg_black: 40,
247 | bg_red: 41,
248 | bg_green: 42,
249 | bg_yellow: 43,
250 | bg_blue: 44,
251 | bg_magenta: 45,
252 | bg_cyan: 46,
253 | bg_white: 47
254 | }.freeze
255 |
256 | # only enable formatting if terminal supports it
257 | if `tput colors`.chomp.to_i >= 8
258 | def format(*styles)
259 | styles.map { |s| "\e[#{FORMATTING[s]}m" }.join + self + "\e[0m"
260 | end
261 | else
262 | def format(*_styles)
263 | self
264 | end
265 | end
266 | end
267 |
--------------------------------------------------------------------------------
/Sources/Stencil/ForTag.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | class ForNode: NodeType {
10 | let resolvable: Resolvable
11 | let loopVariables: [String]
12 | let nodes: [NodeType]
13 | let emptyNodes: [NodeType]
14 | let `where`: Expression?
15 | let label: String?
16 | let token: Token?
17 |
18 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
19 | var components = token.components
20 |
21 | var label: String?
22 | if components.first?.hasSuffix(":") == true {
23 | label = String(components.removeFirst().dropLast())
24 | }
25 |
26 | func hasToken(_ token: String, at index: Int) -> Bool {
27 | components.count > (index + 1) && components[index] == token
28 | }
29 |
30 | func endsOrHasToken(_ token: String, at index: Int) -> Bool {
31 | components.count == index || hasToken(token, at: index)
32 | }
33 |
34 | guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
35 | throw TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]`.")
36 | }
37 |
38 | let loopVariables = components[1]
39 | .split(separator: ",")
40 | .map(String.init)
41 | .map { $0.trim(character: " ") }
42 |
43 | let resolvable = try parser.compileResolvable(components[3], containedIn: token)
44 |
45 | let `where` = hasToken("where", at: 4)
46 | ? try parser.compileExpression(components: Array(components.suffix(from: 5)), token: token)
47 | : nil
48 |
49 | let forNodes = try parser.parse(until(["endfor", "empty"]))
50 |
51 | guard let token = parser.nextToken() else {
52 | throw TemplateSyntaxError("`endfor` was not found.")
53 | }
54 |
55 | var emptyNodes = [NodeType]()
56 | if token.contents == "empty" {
57 | emptyNodes = try parser.parse(until(["endfor"]))
58 | _ = parser.nextToken()
59 | }
60 |
61 | return ForNode(
62 | resolvable: resolvable,
63 | loopVariables: loopVariables,
64 | nodes: forNodes,
65 | emptyNodes: emptyNodes,
66 | where: `where`,
67 | label: label,
68 | token: token
69 | )
70 | }
71 |
72 | init(
73 | resolvable: Resolvable,
74 | loopVariables: [String],
75 | nodes: [NodeType],
76 | emptyNodes: [NodeType],
77 | where: Expression? = nil,
78 | label: String? = nil,
79 | token: Token? = nil
80 | ) {
81 | self.resolvable = resolvable
82 | self.loopVariables = loopVariables
83 | self.nodes = nodes
84 | self.emptyNodes = emptyNodes
85 | self.where = `where`
86 | self.label = label
87 | self.token = token
88 | }
89 |
90 | func render(_ context: Context) throws -> String {
91 | var values = try resolve(context)
92 |
93 | if let `where` = self.where {
94 | values = try values.filter { item -> Bool in
95 | try push(value: item, context: context) {
96 | try `where`.evaluate(context: context)
97 | }
98 | }
99 | }
100 |
101 | if !values.isEmpty {
102 | let count = values.count
103 | var result = ""
104 |
105 | // collect parent loop contexts
106 | let parentLoopContexts = (context["forloop"] as? [String: Any])?
107 | .filter { ($1 as? [String: Any])?["label"] != nil } ?? [:]
108 |
109 | for (index, item) in zip(0..., values) {
110 | var forContext: [String: Any] = [
111 | "first": index == 0,
112 | "last": index == (count - 1),
113 | "counter": index + 1,
114 | "counter0": index,
115 | "length": count
116 | ]
117 | if let label = label {
118 | forContext["label"] = label
119 | forContext[label] = forContext
120 | }
121 | forContext.merge(parentLoopContexts) { lhs, _ in lhs }
122 |
123 | var shouldBreak = false
124 | result += try context.push(dictionary: ["forloop": forContext]) {
125 | defer {
126 | // if outer loop should be continued we should break from current loop
127 | if let shouldContinueLabel = context[LoopTerminationNode.continueContextKey] as? String {
128 | shouldBreak = shouldContinueLabel != label || label == nil
129 | } else {
130 | shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
131 | }
132 | }
133 | return try push(value: item, context: context) {
134 | try renderNodes(nodes, context)
135 | }
136 | }
137 |
138 | if shouldBreak {
139 | break
140 | }
141 | }
142 |
143 | return result
144 | } else {
145 | return try context.push {
146 | try renderNodes(emptyNodes, context)
147 | }
148 | }
149 | }
150 |
151 | private func push(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
152 | if loopVariables.isEmpty {
153 | return try context.push {
154 | try closure()
155 | }
156 | }
157 |
158 | let valueMirror = Mirror(reflecting: value)
159 | if case .tuple? = valueMirror.displayStyle {
160 | if loopVariables.count > Int(valueMirror.children.count) {
161 | throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
162 | }
163 | var variablesContext = [String: Any]()
164 | valueMirror.children.prefix(loopVariables.count).enumerated().forEach { offset, element in
165 | if loopVariables[offset] != "_" {
166 | variablesContext[loopVariables[offset]] = element.value
167 | }
168 | }
169 |
170 | return try context.push(dictionary: variablesContext) {
171 | try closure()
172 | }
173 | }
174 |
175 | return try context.push(dictionary: [loopVariables.first ?? "": value]) {
176 | try closure()
177 | }
178 | }
179 |
180 | private func resolve(_ context: Context) throws -> [Any] {
181 | let resolved = try resolvable.resolve(context)
182 |
183 | var values: [Any]
184 | if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
185 | values = dictionary.sorted { $0.key < $1.key }
186 | } else if let array = resolved as? [Any] {
187 | values = array
188 | } else if let range = resolved as? CountableClosedRange {
189 | values = Array(range)
190 | } else if let range = resolved as? CountableRange {
191 | values = Array(range)
192 | } else if let resolved = resolved {
193 | let mirror = Mirror(reflecting: resolved)
194 | switch mirror.displayStyle {
195 | case .struct, .tuple:
196 | values = Array(mirror.children)
197 | case .class:
198 | var children = Array(mirror.children)
199 | var currentMirror: Mirror? = mirror
200 | while let superclassMirror = currentMirror?.superclassMirror {
201 | children.append(contentsOf: superclassMirror.children)
202 | currentMirror = superclassMirror
203 | }
204 | values = Array(children)
205 | default:
206 | values = []
207 | }
208 | } else {
209 | values = []
210 | }
211 |
212 | return values
213 | }
214 | }
215 |
216 | struct LoopTerminationNode: NodeType {
217 | static let breakContextKey = "_internal_forloop_break"
218 | static let continueContextKey = "_internal_forloop_continue"
219 |
220 | let name: String
221 | let label: String?
222 | let token: Token?
223 |
224 | var contextKey: String {
225 | "_internal_forloop_\(name)"
226 | }
227 |
228 | private init(name: String, label: String? = nil, token: Token? = nil) {
229 | self.name = name
230 | self.label = label
231 | self.token = token
232 | }
233 |
234 | static func parse(_ parser: TokenParser, token: Token) throws -> LoopTerminationNode {
235 | let components = token.components
236 |
237 | guard components.count <= 2 else {
238 | throw TemplateSyntaxError("'\(token.contents)' can accept only one parameter")
239 | }
240 | guard parser.hasOpenedForTag() else {
241 | throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body")
242 | }
243 |
244 | return LoopTerminationNode(name: components[0], label: components.count == 2 ? components[1] : nil, token: token)
245 | }
246 |
247 | func render(_ context: Context) throws -> String {
248 | let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in
249 | guard let forContext = dictionary["forloop"] as? [String: Any],
250 | dictionary["forloop"] != nil else { return false }
251 |
252 | if let label = label {
253 | return label == forContext["label"] as? String
254 | } else {
255 | return true
256 | }
257 | }?.0
258 |
259 | if let offset = offset {
260 | context.dictionaries[offset][contextKey] = label ?? true
261 | } else if let label = label {
262 | throw TemplateSyntaxError("No loop labeled '\(label)' is currently running")
263 | } else {
264 | throw TemplateSyntaxError("No loop is currently running")
265 | }
266 |
267 | return ""
268 | }
269 | }
270 |
271 | private extension TokenParser {
272 | func hasOpenedForTag() -> Bool {
273 | var openForCount = 0
274 | for parsedToken in parsedTokens.reversed() where parsedToken.kind == .block {
275 | if parsedToken.components.first == "endfor" { openForCount -= 1 }
276 | if parsedToken.components.first == "for" { openForCount += 1 }
277 | }
278 | return openForCount > 0
279 | }
280 | }
281 |
--------------------------------------------------------------------------------
/Sources/Stencil/Expression.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stencil
3 | // Copyright © 2022 Stencil
4 | // MIT Licence
5 | //
6 |
7 | public protocol Expression: CustomStringConvertible, Resolvable {
8 | func evaluate(context: Context) throws -> Bool
9 | }
10 |
11 | extension Expression {
12 | func resolve(_ context: Context) throws -> Any? {
13 | try "\(evaluate(context: context))"
14 | }
15 | }
16 |
17 | protocol InfixOperator: Expression {
18 | init(lhs: Expression, rhs: Expression)
19 | }
20 |
21 | protocol PrefixOperator: Expression {
22 | init(expression: Expression)
23 | }
24 |
25 | final class StaticExpression: Expression, CustomStringConvertible {
26 | let value: Bool
27 |
28 | init(value: Bool) {
29 | self.value = value
30 | }
31 |
32 | func evaluate(context: Context) throws -> Bool {
33 | value
34 | }
35 |
36 | var description: String {
37 | "\(value)"
38 | }
39 | }
40 |
41 | final class VariableExpression: Expression, CustomStringConvertible {
42 | let variable: Resolvable
43 |
44 | init(variable: Resolvable) {
45 | self.variable = variable
46 | }
47 |
48 | var description: String {
49 | "(variable: \(variable))"
50 | }
51 |
52 | func resolve(_ context: Context) throws -> Any? {
53 | try variable.resolve(context)
54 | }
55 |
56 | /// Resolves a variable in the given context as boolean
57 | func evaluate(context: Context) throws -> Bool {
58 | let result = try variable.resolve(context)
59 | var truthy = false
60 |
61 | if let result = result as? [Any] {
62 | truthy = !result.isEmpty
63 | } else if let result = result as? [String: Any] {
64 | truthy = !result.isEmpty
65 | } else if let result = result as? Bool {
66 | truthy = result
67 | } else if let result = result as? String {
68 | truthy = !result.isEmpty
69 | } else if let value = result, let result = toNumber(value: value) {
70 | truthy = result > 0
71 | } else if result != nil {
72 | truthy = true
73 | }
74 |
75 | return truthy
76 | }
77 | }
78 |
79 | final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
80 | let expression: Expression
81 |
82 | init(expression: Expression) {
83 | self.expression = expression
84 | }
85 |
86 | var description: String {
87 | "not \(expression)"
88 | }
89 |
90 | func evaluate(context: Context) throws -> Bool {
91 | try !expression.evaluate(context: context)
92 | }
93 | }
94 |
95 | final class InExpression: Expression, InfixOperator, CustomStringConvertible {
96 | let lhs: Expression
97 | let rhs: Expression
98 |
99 | init(lhs: Expression, rhs: Expression) {
100 | self.lhs = lhs
101 | self.rhs = rhs
102 | }
103 |
104 | var description: String {
105 | "(\(lhs) in \(rhs))"
106 | }
107 |
108 | func evaluate(context: Context) throws -> Bool {
109 | if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
110 | let lhsValue = try lhs.variable.resolve(context)
111 | let rhsValue = try rhs.variable.resolve(context)
112 |
113 | if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
114 | return rhs.contains(lhs)
115 | } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange {
116 | return rhs.contains(lhs)
117 | } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange {
118 | return rhs.contains(lhs)
119 | } else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
120 | return rhs.contains(lhs)
121 | } else if lhsValue == nil && rhsValue == nil {
122 | return true
123 | }
124 | }
125 |
126 | return false
127 | }
128 | }
129 |
130 | final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
131 | let lhs: Expression
132 | let rhs: Expression
133 |
134 | init(lhs: Expression, rhs: Expression) {
135 | self.lhs = lhs
136 | self.rhs = rhs
137 | }
138 |
139 | var description: String {
140 | "(\(lhs) or \(rhs))"
141 | }
142 |
143 | func evaluate(context: Context) throws -> Bool {
144 | let lhs = try self.lhs.evaluate(context: context)
145 | if lhs {
146 | return lhs
147 | }
148 |
149 | return try rhs.evaluate(context: context)
150 | }
151 | }
152 |
153 | final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
154 | let lhs: Expression
155 | let rhs: Expression
156 |
157 | init(lhs: Expression, rhs: Expression) {
158 | self.lhs = lhs
159 | self.rhs = rhs
160 | }
161 |
162 | var description: String {
163 | "(\(lhs) and \(rhs))"
164 | }
165 |
166 | func evaluate(context: Context) throws -> Bool {
167 | let lhs = try self.lhs.evaluate(context: context)
168 | if !lhs {
169 | return lhs
170 | }
171 |
172 | return try rhs.evaluate(context: context)
173 | }
174 | }
175 |
176 | class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
177 | let lhs: Expression
178 | let rhs: Expression
179 |
180 | required init(lhs: Expression, rhs: Expression) {
181 | self.lhs = lhs
182 | self.rhs = rhs
183 | }
184 |
185 | var description: String {
186 | "(\(lhs) == \(rhs))"
187 | }
188 |
189 | func evaluate(context: Context) throws -> Bool {
190 | if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
191 | let lhsValue = try lhs.variable.resolve(context)
192 | let rhsValue = try rhs.variable.resolve(context)
193 |
194 | if let lhs = lhsValue, let rhs = rhsValue {
195 | if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
196 | return lhs == rhs
197 | } else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
198 | return lhs == rhs
199 | } else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
200 | return lhs == rhs
201 | }
202 | } else if lhsValue == nil && rhsValue == nil {
203 | return true
204 | }
205 | }
206 |
207 | return false
208 | }
209 | }
210 |
211 | class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
212 | let lhs: Expression
213 | let rhs: Expression
214 |
215 | required init(lhs: Expression, rhs: Expression) {
216 | self.lhs = lhs
217 | self.rhs = rhs
218 | }
219 |
220 | var description: String {
221 | "(\(lhs) \(symbol) \(rhs))"
222 | }
223 |
224 | func evaluate(context: Context) throws -> Bool {
225 | if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
226 | let lhsValue = try lhs.variable.resolve(context)
227 | let rhsValue = try rhs.variable.resolve(context)
228 |
229 | if let lhs = lhsValue, let rhs = rhsValue {
230 | if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
231 | return compare(lhs: lhs, rhs: rhs)
232 | }
233 | }
234 | }
235 |
236 | return false
237 | }
238 |
239 | var symbol: String {
240 | ""
241 | }
242 |
243 | func compare(lhs: Number, rhs: Number) -> Bool {
244 | false
245 | }
246 | }
247 |
248 | class MoreThanExpression: NumericExpression {
249 | override var symbol: String {
250 | ">"
251 | }
252 |
253 | override func compare(lhs: Number, rhs: Number) -> Bool {
254 | lhs > rhs
255 | }
256 | }
257 |
258 | class MoreThanEqualExpression: NumericExpression {
259 | override var symbol: String {
260 | ">="
261 | }
262 |
263 | override func compare(lhs: Number, rhs: Number) -> Bool {
264 | lhs >= rhs
265 | }
266 | }
267 |
268 | class LessThanExpression: NumericExpression {
269 | override var symbol: String {
270 | "<"
271 | }
272 |
273 | override func compare(lhs: Number, rhs: Number) -> Bool {
274 | lhs < rhs
275 | }
276 | }
277 |
278 | class LessThanEqualExpression: NumericExpression {
279 | override var symbol: String {
280 | "<="
281 | }
282 |
283 | override func compare(lhs: Number, rhs: Number) -> Bool {
284 | lhs <= rhs
285 | }
286 | }
287 |
288 | class InequalityExpression: EqualityExpression {
289 | override var description: String {
290 | "(\(lhs) != \(rhs))"
291 | }
292 |
293 | override func evaluate(context: Context) throws -> Bool {
294 | try !super.evaluate(context: context)
295 | }
296 | }
297 |
298 | // swiftlint:disable:next cyclomatic_complexity
299 | func toNumber(value: Any) -> Number? {
300 | if let value = value as? Float {
301 | return Number(value)
302 | } else if let value = value as? Double {
303 | return Number(value)
304 | } else if let value = value as? UInt {
305 | return Number(value)
306 | } else if let value = value as? Int {
307 | return Number(value)
308 | } else if let value = value as? Int8 {
309 | return Number(value)
310 | } else if let value = value as? Int16 {
311 | return Number(value)
312 | } else if let value = value as? Int32 {
313 | return Number(value)
314 | } else if let value = value as? Int64 {
315 | return Number(value)
316 | } else if let value = value as? UInt8 {
317 | return Number(value)
318 | } else if let value = value as? UInt16 {
319 | return Number(value)
320 | } else if let value = value as? UInt32 {
321 | return Number(value)
322 | } else if let value = value as? UInt64 {
323 | return Number(value)
324 | } else if let value = value as? Number {
325 | return value
326 | } else if let value = value as? Float64 {
327 | return Number(value)
328 | } else if let value = value as? Float32 {
329 | return Number(value)
330 | }
331 |
332 | return nil
333 | }
334 |
--------------------------------------------------------------------------------