├── .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 |

Stencil

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 | 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 | 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 | 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 | --------------------------------------------------------------------------------